Skip to content

Commit 52fcd9c

Browse files
committed
v0.1.0
1 parent 150fd12 commit 52fcd9c

29 files changed

Lines changed: 2385 additions & 282 deletions

CMakeLists.txt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,18 @@ if(NETGRAPH_CORE_BUILD_TESTS)
1313
)
1414
FetchContent_MakeAvailable(googletest)
1515
enable_testing()
16-
add_executable(netgraph_core_tests tests/cpp/smoke_test.cpp tests/cpp/flow_policy_tests.cpp)
16+
add_executable(netgraph_core_tests
17+
tests/cpp/smoke_test.cpp
18+
tests/cpp/flow_policy_tests.cpp
19+
tests/cpp/strict_multidigraph_tests.cpp
20+
tests/cpp/shortest_paths_tests.cpp
21+
tests/cpp/flow_state_tests.cpp
22+
tests/cpp/flow_graph_tests.cpp
23+
tests/cpp/max_flow_tests.cpp
24+
tests/cpp/k_shortest_paths_tests.cpp
25+
)
1726
target_link_libraries(netgraph_core_tests PRIVATE netgraph_core GTest::gtest_main)
27+
target_include_directories(netgraph_core_tests PRIVATE tests/cpp)
1828
if(NETGRAPH_CORE_COVERAGE AND (CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang"))
1929
target_compile_options(netgraph_core_tests PRIVATE -O0 -g --coverage)
2030
target_link_options(netgraph_core_tests PRIVATE --coverage)
@@ -34,7 +44,6 @@ if(APPLE)
3444
if(DEFINED ENV{MACOSX_DEPLOYMENT_TARGET})
3545
set(CMAKE_OSX_DEPLOYMENT_TARGET "$ENV{MACOSX_DEPLOYMENT_TARGET}" CACHE STRING "" FORCE)
3646
elseif(NOT DEFINED CMAKE_OSX_DEPLOYMENT_TARGET)
37-
# Default matches Homebrew Python's target in this workspace
3847
set(CMAKE_OSX_DEPLOYMENT_TARGET "15.0" CACHE STRING "" FORCE)
3948
endif()
4049
if(NOT DEFINED CMAKE_OSX_ARCHITECTURES)

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ PYTEST = $(PYTHON) -m pytest
1414
RUFF = $(PYTHON) -m ruff
1515
PRECOMMIT = $(PYTHON) -m pre_commit
1616

17-
# Prefer Apple Command Line Tools compilers to avoid Homebrew libc++ ABI mismatches
17+
# Detect Apple Command Line Tools compilers (prefer system toolchain on macOS)
1818
APPLE_CLANG := $(shell xcrun --find clang 2>/dev/null)
1919
APPLE_CLANGXX := $(shell xcrun --find clang++ 2>/dev/null)
2020
DEFAULT_MACOSX := 15.0

README.md

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,56 @@
1-
NetGraph-Core
1+
# NetGraph-Core
22

3-
C++ core with Python bindings.
3+
C++ implementation of graph algorithms for network flow analysis and traffic engineering with Python bindings.
44

5-
- Native C++ kernels for shortest paths, k-shortest paths, and max-flow
6-
- Deterministic, thread-parallel, batch execution with GIL released
7-
- Python API with a stable, well-documented surface
5+
## Features
86

9-
Development
7+
- **Algorithms:** Shortest paths (Dijkstra), K-shortest paths (Yen), max-flow (successive shortest paths)
8+
- **Graph representation:** Immutable directed multigraph with CSR adjacency
9+
- **Flow placement:** Tunable policies (proportional to capacity, equal-balanced across paths)
10+
- **Python bindings:** NumPy integration, GIL released during computation
11+
- **Deterministic:** Reproducible edge ordering by (cost, src, dst)
1012

11-
- Setup: `make dev` (creates venv, installs dev deps, sets up pre-commit)
12-
- Local checks: `make check` (auto-fix with pre-commit, run C++/Python tests, then lint)
13-
- CI checks: `make check-ci` (strict, non-mutating: lint → C++ tests → Python tests)
14-
- Coverage: `make cov` (prints unified summary and writes XML + single-page HTML under build/coverage/)
13+
## Installation
14+
15+
```bash
16+
pip install netgraph-core
17+
```
18+
19+
Or from source:
20+
21+
```bash
22+
pip install -e .
23+
```
24+
25+
## Repository Structure
26+
27+
```
28+
src/ # C++ implementation
29+
include/netgraph/core/ # Public C++ headers
30+
bindings/python/ # pybind11 bindings
31+
python/netgraph_core/ # Python package
32+
tests/cpp/ # C++ tests (googletest)
33+
tests/py/ # Python tests (pytest)
34+
```
35+
36+
## Development
37+
38+
```bash
39+
make dev # Setup: venv, dependencies, pre-commit hooks
40+
make check # Run all tests and linting (auto-fix formatting)
41+
make check-ci # Strict checks without auto-fix (for CI)
42+
make cpp-test # C++ tests only
43+
make py-test # Python tests only
44+
make cov # Coverage report (C++ + Python)
45+
```
46+
47+
## Requirements
48+
49+
- **C++:** C++20 compiler (GCC 10+, Clang 12+, MSVC 2019+)
50+
- **Python:** 3.9+
51+
- **Build:** CMake 3.15+, scikit-build-core
52+
- **Dependencies:** pybind11, NumPy
53+
54+
## License
55+
56+
AGPL-3.0-or-later

bindings/python/module.cpp

Lines changed: 40 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -74,83 +74,58 @@ PYBIND11_MODULE(_netgraph_core, m) {
7474
[](std::int32_t num_nodes,
7575
py::array src, py::array dst,
7676
py::array capacity, py::array cost,
77-
bool add_reverse) {
77+
py::object ext_edge_ids_obj) {
7878
// public API: src/dst are int32; pass through as int32 to core
7979
auto src_s = as_span<std::int32_t>(src, "src");
8080
auto dst_s = as_span<std::int32_t>(dst, "dst");
8181
if (src_s.size() != dst_s.size()) throw py::type_error("src and dst must have the same length");
8282
auto cap_s = as_span<double>(capacity, "capacity");
8383
// Cost dtype must be int64 to match internal Cost type
8484
auto cost_s = as_span<std::int64_t>(cost, "cost");
85+
// Optional ext_edge_ids
86+
std::span<const std::int64_t> ext_s;
87+
if (!ext_edge_ids_obj.is_none()) {
88+
py::array ext_arr = ext_edge_ids_obj.cast<py::array>();
89+
ext_s = as_span<std::int64_t>(ext_arr, "ext_edge_ids");
90+
}
8591
return StrictMultiDiGraph::from_arrays(num_nodes,
8692
src_s,
8793
dst_s,
88-
cap_s, cost_s, add_reverse);
94+
cap_s, cost_s, ext_s);
8995
},
9096
py::arg("num_nodes"), py::arg("src"), py::arg("dst"), py::arg("capacity"), py::arg("cost"),
91-
py::kw_only(), py::arg("add_reverse") = false)
97+
py::kw_only(), py::arg("ext_edge_ids") = py::none())
9298
.def("num_nodes", &StrictMultiDiGraph::num_nodes)
9399
.def("num_edges", &StrictMultiDiGraph::num_edges)
94-
// external link ids removed; EdgeId is the canonical id
95-
.def("capacity_view", [](py::object self_obj, const StrictMultiDiGraph& g){
100+
.def("capacity_view", [](const StrictMultiDiGraph& g){
96101
auto s = g.capacity_view();
97-
py::array out(
98-
py::buffer_info(
99-
const_cast<double*>(s.data()),
100-
sizeof(double),
101-
py::format_descriptor<double>::format(),
102-
1,
103-
{ s.size() },
104-
{ sizeof(double) }
105-
),
106-
self_obj
107-
);
108-
return out;
102+
py::array_t<double> arr(s.size());
103+
std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(double));
104+
return arr;
109105
})
110-
.def("edge_src_view", [](py::object self_obj, const StrictMultiDiGraph& g){
106+
.def("edge_src_view", [](const StrictMultiDiGraph& g){
111107
auto s = g.edge_src_view();
112-
py::array out(
113-
py::buffer_info(
114-
const_cast<std::int32_t*>(s.data()),
115-
sizeof(std::int32_t),
116-
py::format_descriptor<std::int32_t>::format(),
117-
1,
118-
{ s.size() },
119-
{ sizeof(std::int32_t) }
120-
),
121-
self_obj
122-
);
123-
return out;
108+
py::array_t<std::int32_t> arr(s.size());
109+
std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(std::int32_t));
110+
return arr;
124111
})
125-
.def("edge_dst_view", [](py::object self_obj, const StrictMultiDiGraph& g){
112+
.def("edge_dst_view", [](const StrictMultiDiGraph& g){
126113
auto s = g.edge_dst_view();
127-
py::array out(
128-
py::buffer_info(
129-
const_cast<std::int32_t*>(s.data()),
130-
sizeof(std::int32_t),
131-
py::format_descriptor<std::int32_t>::format(),
132-
1,
133-
{ s.size() },
134-
{ sizeof(std::int32_t) }
135-
),
136-
self_obj
137-
);
138-
return out;
114+
py::array_t<std::int32_t> arr(s.size());
115+
std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(std::int32_t));
116+
return arr;
139117
})
140-
.def("cost_view", [](py::object self_obj, const StrictMultiDiGraph& g){
118+
.def("ext_edge_ids_view", [](const StrictMultiDiGraph& g){
119+
auto s = g.ext_edge_ids_view();
120+
py::array_t<std::int64_t> arr(s.size());
121+
std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(std::int64_t));
122+
return arr;
123+
})
124+
.def("cost_view", [](const StrictMultiDiGraph& g){
141125
auto s = g.cost_view();
142-
py::array out(
143-
py::buffer_info(
144-
const_cast<Cost*>(s.data()),
145-
sizeof(Cost),
146-
py::format_descriptor<Cost>::format(),
147-
1,
148-
{ s.size() },
149-
{ sizeof(Cost) }
150-
),
151-
self_obj
152-
);
153-
return out;
126+
py::array_t<std::int64_t> arr(s.size());
127+
std::memcpy(arr.mutable_data(), s.data(), s.size()*sizeof(std::int64_t));
128+
return arr;
154129
})
155130
.def("row_offsets_view", [](const StrictMultiDiGraph& g){
156131
auto s = g.row_offsets_view();
@@ -183,8 +158,6 @@ PYBIND11_MODULE(_netgraph_core, m) {
183158
return arr;
184159
});
185160

186-
// PredDAG properties already defined earlier at L200; avoid duplicate class registration
187-
188161
// Backend and Algorithms
189162

190163
// Opaque wrapper types
@@ -208,21 +181,26 @@ PYBIND11_MODULE(_netgraph_core, m) {
208181
std::int32_t num_nodes,
209182
py::array src, py::array dst,
210183
py::array capacity, py::array cost,
211-
bool add_reverse){
184+
py::object ext_edge_ids_obj){
212185
// Build graph by value, then move-assign into a shared_ptr to own it
186+
std::span<const std::int64_t> ext_s;
187+
if (!ext_edge_ids_obj.is_none()) {
188+
py::array ext_arr = ext_edge_ids_obj.cast<py::array>();
189+
ext_s = as_span<std::int64_t>(ext_arr, "ext_edge_ids");
190+
}
213191
StrictMultiDiGraph gv = StrictMultiDiGraph::from_arrays(
214192
num_nodes,
215193
as_span<std::int32_t>(src, "src"),
216194
as_span<std::int32_t>(dst, "dst"),
217195
as_span<double>(capacity, "capacity"),
218196
as_span<std::int64_t>(cost, "cost"),
219-
add_reverse);
197+
ext_s);
220198
auto sp = std::make_shared<StrictMultiDiGraph>();
221199
*sp = std::move(gv);
222200
auto gh = algs.build_graph(std::static_pointer_cast<const StrictMultiDiGraph>(sp));
223201
// No need to keep a Python-side owner; GraphHandle holds shared ownership
224202
return PyGraph{ gh, py::none(), sp->num_nodes(), sp->num_edges() };
225-
}, py::arg("num_nodes"), py::arg("src"), py::arg("dst"), py::arg("capacity"), py::arg("cost"), py::kw_only(), py::arg("add_reverse") = false)
203+
}, py::arg("num_nodes"), py::arg("src"), py::arg("dst"), py::arg("capacity"), py::arg("cost"), py::kw_only(), py::arg("ext_edge_ids") = py::none())
226204
.def("spf", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src,
227205
py::object dst, py::object selection_obj, py::object residual_obj,
228206
py::object node_mask, py::object edge_mask, bool multipath){
@@ -348,7 +326,6 @@ PYBIND11_MODULE(_netgraph_core, m) {
348326
py::gil_scoped_release rel; auto out = algs.batch_max_flow(pg.handle, pp, o, node_spans, edge_spans); py::gil_scoped_acquire acq; return out;
349327
}, py::arg("graph"), 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);
350328

351-
// resolve_to_paths now exposed as a PredDAG instance method
352329
py::class_<PredDAG>(m, "PredDAG")
353330
.def_property_readonly("parent_offsets", [](const PredDAG& d){
354331
py::array_t<std::int32_t> arr(d.parent_offsets.size());
@@ -390,7 +367,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
390367
}
391368
return arr;
392369
});
393-
// CostBucket/CostDistribution removed in favor of parallel arrays on FlowSummary
370+
394371
py::class_<FlowSummary>(m, "FlowSummary")
395372
.def_readonly("total_flow", &FlowSummary::total_flow)
396373
.def_readonly("min_cut", &FlowSummary::min_cut)
@@ -422,9 +399,6 @@ PYBIND11_MODULE(_netgraph_core, m) {
422399
return arr;
423400
});
424401

425-
// spf_residual removed; unified spf accepts optional residual and EdgeSelection
426-
427-
428402
// FlowState bindings
429403
py::class_<FlowState>(m, "FlowState")
430404
.def(py::init<const StrictMultiDiGraph&>())

include/netgraph/core/backend.hpp

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
Backend interface — abstracts SPF/MaxFlow/KSP implementations.
33
44
The default CPU backend delegates to in-process algorithm implementations.
5-
All execution flows through this interface via an explicit Algorithms façade.
5+
All execution flows through this interface via an Algorithms façade.
6+
7+
For Python developers:
8+
- std::shared_ptr<T>: reference-counted pointer (like Python object references)
9+
- virtual: method can be overridden in subclasses (like Python's inheritance)
10+
- = 0: pure virtual (must be implemented by subclass, like @abstractmethod)
611
*/
712
#pragma once
813

@@ -19,8 +24,8 @@
1924

2025
namespace netgraph::core {
2126

22-
// Opaque handle to a backend-owned graph. Uses shared ownership to provide
23-
// safe lifetime management across API boundaries.
27+
// GraphHandle: opaque handle to a backend-owned graph.
28+
// Uses shared_ptr for automatic lifetime management (like Python's reference counting).
2429
struct GraphHandle {
2530
std::shared_ptr<const StrictMultiDiGraph> graph {};
2631
};

include/netgraph/core/flow_policy.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ struct FlowPolicyConfig {
5050
// FlowPolicy manages flow creation, placement, reoptimization for a single demand
5151
class FlowPolicy {
5252
public:
53-
// New config-based constructor
53+
// Constructor accepting configuration struct
5454
FlowPolicy(const ExecutionContext& ctx, const FlowPolicyConfig& cfg)
5555
: ctx_(ctx),
5656
path_alg_(cfg.path_alg), flow_placement_(cfg.flow_placement), selection_(cfg.selection),

include/netgraph/core/flow_state.hpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@
1818
namespace netgraph::core {
1919

2020
// FlowState maintains per-edge residual capacity and per-edge placed flow for a
21-
// given immutable StrictMultiDiGraph. It provides efficient placement on a
22-
// provided predecessor DAG (PredDAG) using either proportional or equal-balanced
23-
// strategies, updating internal residuals deterministically.
21+
// given immutable StrictMultiDiGraph. It places flow on a predecessor DAG (PredDAG)
22+
// using either proportional or equal-balanced strategies, updating internal
23+
// residuals deterministically.
2424
class FlowState {
2525
public:
2626
explicit FlowState(const StrictMultiDiGraph& g);

include/netgraph/core/shortest_paths.hpp

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
/* Shortest paths (Dijkstra) with multipath predecessor DAG support. */
1+
/* Shortest paths (Dijkstra) with multipath predecessor DAG support.
2+
*
3+
* For Python developers:
4+
* - std::pair<A, B>: tuple of two elements (like tuple[A, B])
5+
* - std::optional<T>: nullable value (like T | None)
6+
* - std::nullopt: None equivalent for std::optional
7+
*/
28
#pragma once
39

410
#include <optional>
@@ -11,21 +17,31 @@
1117

1218
namespace netgraph::core {
1319

14-
// Predecessor DAG: compact representation of all equal-cost predecessors.
15-
// For each node v, entries are stored in [parent_offsets[v], parent_offsets[v+1])
16-
// as pairs (parents[i], via_edges[i]) where via_edges[i] is the compacted EdgeId
17-
// used to reach v from parents[i]. Multiple parallel edges are represented by
18-
// multiple entries with the same parent. Offsets has length N+1.
20+
// PredDAG (Predecessor Directed Acyclic Graph): compact representation of all equal-cost
21+
// shortest paths from a source node. Similar to a defaultdict(list) in Python, but stored
22+
// in CSR format for efficiency.
23+
//
24+
// For each node v, predecessors are stored in parents[parent_offsets[v]:parent_offsets[v+1]]
25+
// with corresponding EdgeIds in via_edges[parent_offsets[v]:parent_offsets[v+1]].
26+
// Multiple parallel edges are represented by multiple entries with the same parent.
27+
// parent_offsets has length N+1 (like CSR row pointers).
1928
struct PredDAG {
20-
std::vector<std::int32_t> parent_offsets;
21-
std::vector<NodeId> parents;
22-
std::vector<EdgeId> via_edges;
29+
std::vector<std::int32_t> parent_offsets; // Length N+1 (CSR row pointers)
30+
std::vector<NodeId> parents; // Predecessor node IDs
31+
std::vector<EdgeId> via_edges; // EdgeId used to reach node from predecessor
2332
};
2433

25-
// Optional node/edge masks:
26-
// - node_mask[v] == true means node v is allowed; false excludes it from search.
27-
// - edge_mask[e] == true means edge e is allowed; false excludes it from search.
28-
// Empty mask spans are ignored.
34+
// Compute shortest paths from src using Dijkstra's algorithm.
35+
// Returns (distances, predecessor_dag) where distances[v] is the shortest cost to reach v
36+
// (or inf if unreachable), and predecessor_dag encodes all equal-cost paths.
37+
//
38+
// Parameters:
39+
// - dst: if provided, algorithm may exit early once destination is reached
40+
// - multipath: if true, keep all equal-cost predecessors; if false, keep only one per node
41+
// - selection: edge selection policy (multi-edge, capacity filtering, tie-breaking)
42+
// - residual: if provided, use these capacities instead of graph's original capacities
43+
// - node_mask: if provided, node_mask[v]==true means node v is allowed (false excludes it)
44+
// - edge_mask: if provided, edge_mask[e]==true means edge e is allowed (false excludes it)
2945
[[nodiscard]] std::pair<std::vector<Cost>, PredDAG>
3046
shortest_paths(const StrictMultiDiGraph& g, NodeId src,
3147
std::optional<NodeId> dst,

0 commit comments

Comments
 (0)