Skip to content

Commit 1adfd1f

Browse files
committed
Add sensitivity analysis feature to max flow
1 parent 7a0e071 commit 1adfd1f

File tree

8 files changed

+296
-31
lines changed

8 files changed

+296
-31
lines changed

bindings/python/module.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,23 @@ PYBIND11_MODULE(_netgraph_core, m) {
300300
parse_mask_list(edge_masks, static_cast<std::size_t>(pg.num_edges), "edge_masks", edge_bufs, edge_spans);
301301
MaxFlowOptions o; o.placement = placement; o.shortest_path = shortest_path; o.require_capacity = require_capacity; o.with_edge_flows = with_edge_flows; o.with_reachable = with_reachable; o.with_residuals = with_residuals;
302302
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;
303-
}, 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("require_capacity") = true, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false);
303+
}, 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("require_capacity") = true, py::arg("with_edge_flows") = false, py::arg("with_reachable") = false, py::arg("with_residuals") = false)
304+
.def("sensitivity_analysis", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, std::int32_t dst,
305+
FlowPlacement placement, bool require_capacity,
306+
py::object node_mask, py::object edge_mask){
307+
if (src < 0 || src >= pg.num_nodes || dst < 0 || dst >= pg.num_nodes) throw py::value_error("src/dst out of range");
308+
MaxFlowOptions o; o.placement = placement; o.require_capacity = require_capacity;
309+
auto node_bs = to_bool_span_from_numpy(node_mask, static_cast<std::size_t>(pg.num_nodes), "node_mask");
310+
auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast<std::size_t>(pg.num_edges), "edge_mask");
311+
o.node_mask = node_bs.view; o.edge_mask = edge_bs.view;
312+
py::gil_scoped_release rel; auto res = algs.sensitivity_analysis(pg.handle, src, dst, o); py::gil_scoped_acquire acq;
313+
// Return as list of tuples (EdgeId, FlowDelta)
314+
py::list out;
315+
for (auto const& pr : res) {
316+
out.append(py::make_tuple(pr.first, pr.second));
317+
}
318+
return out;
319+
}, py::arg("graph"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("require_capacity") = true, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none());
304320

305321
py::class_<PredDAG>(m, "PredDAG")
306322
.def_property_readonly("parent_offsets", [](const PredDAG& d){

include/netgraph/core/algorithms.hpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ class Algorithms {
4444
return backend_->batch_max_flow(gh, pairs, opts, node_masks, edge_masks);
4545
}
4646

47+
[[nodiscard]] std::vector<std::pair<EdgeId, Flow>>
48+
sensitivity_analysis(const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) const {
49+
return backend_->sensitivity_analysis(gh, src, dst, opts);
50+
}
51+
4752
private:
4853
BackendPtr backend_;
4954
};

include/netgraph/core/backend.hpp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ class Backend {
5757
const MaxFlowOptions& opts,
5858
const std::vector<std::span<const bool>>& node_masks = {},
5959
const std::vector<std::span<const bool>>& edge_masks = {}) = 0;
60+
61+
[[nodiscard]] virtual std::vector<std::pair<EdgeId, Flow>> sensitivity_analysis(
62+
const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) = 0;
6063
};
6164

6265
using BackendPtr = std::shared_ptr<Backend>;

include/netgraph/core/max_flow.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,10 @@ batch_max_flow(const StrictMultiDiGraph& g,
4747
const std::vector<std::span<const bool>>& node_masks = {},
4848
const std::vector<std::span<const bool>>& edge_masks = {});
4949

50+
[[nodiscard]] std::vector<std::pair<EdgeId, Flow>>
51+
sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
52+
FlowPlacement placement, bool require_capacity,
53+
std::span<const bool> node_mask = {},
54+
std::span<const bool> edge_mask = {});
55+
5056
} // namespace netgraph::core

src/cpu_backend.cpp

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,21 @@ class CpuBackend final : public Backend {
9797
opts.with_edge_flows, opts.with_reachable, opts.with_residuals,
9898
node_masks, edge_masks);
9999
}
100+
101+
std::vector<std::pair<EdgeId, Flow>> sensitivity_analysis(
102+
const GraphHandle& gh, NodeId src, NodeId dst, const MaxFlowOptions& opts) override {
103+
const StrictMultiDiGraph& g = *gh.graph;
104+
// Validate mask lengths strictly.
105+
if (!opts.node_mask.empty() && opts.node_mask.size() != static_cast<std::size_t>(g.num_nodes())) {
106+
throw std::invalid_argument("CpuBackend::sensitivity_analysis: node_mask length mismatch");
107+
}
108+
if (!opts.edge_mask.empty() && opts.edge_mask.size() != static_cast<std::size_t>(g.num_edges())) {
109+
throw std::invalid_argument("CpuBackend::sensitivity_analysis: edge_mask length mismatch");
110+
}
111+
return netgraph::core::sensitivity_analysis(g, src, dst,
112+
opts.placement, opts.require_capacity,
113+
opts.node_mask, opts.edge_mask);
114+
}
100115
};
101116
} // namespace
102117

src/max_flow.cpp

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,4 +201,78 @@ batch_max_flow(const StrictMultiDiGraph& g,
201201
}
202202
return out;
203203
}
204+
205+
std::vector<std::pair<EdgeId, Flow>>
206+
sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
207+
FlowPlacement placement, bool require_capacity,
208+
std::span<const bool> node_mask,
209+
std::span<const bool> edge_mask) {
210+
// Step 1: Baseline analysis to identify saturated edges
211+
auto [baseline_flow, summary] = calc_max_flow(
212+
g, src, dst, placement,
213+
/*shortest_path=*/false,
214+
require_capacity,
215+
/*with_edge_flows=*/false,
216+
/*with_reachable=*/false,
217+
/*with_residuals=*/true,
218+
node_mask, edge_mask);
219+
220+
if (baseline_flow < kMinFlow || summary.residual_capacity.empty()) {
221+
return {};
222+
}
223+
224+
std::vector<EdgeId> candidates;
225+
// Identify saturated edges (residual <= kMinCap)
226+
// Only consider edges that are not already masked out
227+
for (EdgeId eid = 0; eid < static_cast<EdgeId>(summary.residual_capacity.size()); ++eid) {
228+
if (!edge_mask.empty() && !edge_mask[eid]) continue; // Already masked
229+
if (summary.residual_capacity[eid] <= kMinCap) {
230+
candidates.push_back(eid);
231+
}
232+
}
233+
234+
if (candidates.empty()) {
235+
return {};
236+
}
237+
238+
// Prepare mutable mask buffer
239+
const auto N = static_cast<std::size_t>(g.num_edges());
240+
std::unique_ptr<bool[]> test_mask_buf(new bool[N]);
241+
if (edge_mask.empty()) {
242+
std::fill(test_mask_buf.get(), test_mask_buf.get() + N, true);
243+
} else {
244+
std::copy(edge_mask.begin(), edge_mask.end(), test_mask_buf.get());
245+
}
246+
// View for passing to calc_max_flow
247+
std::span<const bool> test_mask_span(test_mask_buf.get(), N);
248+
249+
std::vector<std::pair<EdgeId, Flow>> results;
250+
results.reserve(candidates.size());
251+
252+
// Step 2: Iterate candidates
253+
for (EdgeId eid : candidates) {
254+
// Mask out the edge
255+
test_mask_buf[eid] = false;
256+
257+
auto [new_flow, _] = calc_max_flow(
258+
g, src, dst, placement,
259+
/*shortest_path=*/false,
260+
require_capacity,
261+
/*with_edge_flows=*/false,
262+
/*with_reachable=*/false,
263+
/*with_residuals=*/false,
264+
node_mask, test_mask_span);
265+
266+
double delta = baseline_flow - new_flow;
267+
if (delta > kMinFlow) {
268+
results.emplace_back(eid, delta);
269+
}
270+
271+
// Restore mask
272+
test_mask_buf[eid] = true;
273+
}
274+
275+
return results;
276+
}
277+
204278
} // namespace netgraph::core

tests/cpp/max_flow_tests.cpp

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,34 +1081,89 @@ TEST(MaxFlow, ShortestPath_MustSaturateAllEqualCostPaths) {
10811081
}
10821082

10831083
//=============================================================================
1084-
// TEST MATRIX SUMMARY
1085-
//=============================================================================
1086-
//
1087-
// Coverage Matrix (Proportional placement with shortest_path=True):
1088-
//
1089-
// | Path Structure | Equal Cap | Asym Cap | Zero Cap | Validated |
1090-
// |---------------------------|-----------|----------|----------|-----------|
1091-
// | Single path | ✓ | ✓ | - | ✓ |
1092-
// | 2 disjoint equal-cost | ✓ | ✓ | ✓ | ✓ |
1093-
// | 3 disjoint equal-cost | ✓ | - | - | ✓ |
1094-
// | 4 disjoint equal-cost | ✓ | - | - | ✓ |
1095-
// | 10 disjoint equal-cost | ✓ | - | - | ✓ |
1096-
// | 2 cost tiers | ✓ | - | - | ✓ |
1097-
// | 3 cost tiers | ✓ | - | - | ✓ |
1098-
// | Grid topology | ✓ | - | - | ✓ |
1099-
// | Shared bottleneck | ✓ | - | - | ✓ |
1100-
// | 3-tier Clos fabric | ✓ | - | - | ✓ |
1101-
// | Triangle (combine mode) | ✓ | - | - | ✓ |
1102-
// | Simple parallel paths | ✓ | - | - | ✓ |
1103-
//
1104-
// Additional validation dimensions:
1105-
// - Placement modes: Proportional ✓, EqualBalanced ✓
1106-
// - shortest_path: True ✓, False ✓
1107-
// - Optional features: edge flows ✓, residuals ✓, reachable ✓, min-cut ✓
1108-
// - Masks: node mask ✓, edge mask ✓
1109-
// - Batch operations: ✓
1110-
// - Real-world topologies: Clos ✓, Triangle ✓, Parallel paths ✓
1111-
//
1112-
// TOTAL TEST COUNT: 45 tests
1113-
// COVERAGE: All critical parameter combinations + NetGraph integration validated
1084+
// SECTION 11: SENSITIVITY ANALYSIS
11141085
//=============================================================================
1086+
1087+
TEST(MaxFlow, Sensitivity_SinglePath) {
1088+
auto g = make_line_graph(3);
1089+
auto be = make_cpu_backend();
1090+
Algorithms algs(be);
1091+
auto gh = algs.build_graph(g);
1092+
1093+
MaxFlowOptions opts;
1094+
opts.placement = FlowPlacement::Proportional;
1095+
1096+
auto results = algs.sensitivity_analysis(gh, 0, 2, opts);
1097+
1098+
// Path: 0->1->2. Both edges saturated and critical.
1099+
// Capacity is 1.0. Flow is 1.0.
1100+
// Removing any edge drops flow to 0. Delta = 1.0.
1101+
EXPECT_EQ(results.size(), 2u);
1102+
for (const auto& p : results) {
1103+
EXPECT_NEAR(p.second, 1.0, 1e-9);
1104+
}
1105+
}
1106+
1107+
TEST(MaxFlow, Sensitivity_TwoParallelPaths) {
1108+
// Two paths, capacity 10 each. Total 20.
1109+
auto g = make_n_disjoint_paths(2, 10.0);
1110+
auto be = make_cpu_backend();
1111+
Algorithms algs(be);
1112+
auto gh = algs.build_graph(g);
1113+
1114+
MaxFlowOptions opts;
1115+
opts.placement = FlowPlacement::Proportional;
1116+
1117+
auto results = algs.sensitivity_analysis(gh, 0, 3, opts);
1118+
1119+
// All 4 edges are saturated.
1120+
// Removing any edge kills one path (flow 20 -> 10). Delta = 10.
1121+
EXPECT_EQ(results.size(), 4u);
1122+
for (const auto& p : results) {
1123+
EXPECT_NEAR(p.second, 10.0, 1e-9);
1124+
}
1125+
}
1126+
1127+
TEST(MaxFlow, Sensitivity_PartialSaturation) {
1128+
// Topology: S->A (cap 10), S->B (cap 5)
1129+
// A->T (cap 5), B->T (cap 10)
1130+
// Flow: S->A->T limited by A->T (5). S->B->T limited by S->B (5). Total 10.
1131+
// Saturated: A->T (edge 2), S->B (edge 1).
1132+
// Non-Saturated: S->A (edge 0), B->T (edge 3).
1133+
1134+
std::int32_t src[4] = {0, 0, 1, 2};
1135+
std::int32_t dst[4] = {1, 2, 3, 3};
1136+
double cap[4] = {10.0, 5.0, 5.0, 10.0};
1137+
std::int64_t cost[4] = {1, 1, 1, 1};
1138+
1139+
auto g = StrictMultiDiGraph::from_arrays(4,
1140+
std::span(src, 4), std::span(dst, 4),
1141+
std::span(cap, 4), std::span(cost, 4));
1142+
1143+
auto be = make_cpu_backend();
1144+
Algorithms algs(be);
1145+
auto gh = algs.build_graph(g);
1146+
1147+
MaxFlowOptions opts;
1148+
opts.placement = FlowPlacement::Proportional;
1149+
opts.shortest_path = false;
1150+
1151+
auto results = algs.sensitivity_analysis(gh, 0, 3, opts);
1152+
1153+
// Should only report saturated edges: S->B and A->T.
1154+
// (We assume edge indices follow insertion order: 0, 1, 2, 3)
1155+
// S->B is index 1. A->T is index 2.
1156+
1157+
// Check results
1158+
int count = 0;
1159+
for (const auto& p : results) {
1160+
if (p.first == 1 || p.first == 2) {
1161+
EXPECT_NEAR(p.second, 5.0, 1e-9);
1162+
count++;
1163+
} else {
1164+
// If non-saturated edges are reported (which they shouldn't be based on logic), fail.
1165+
FAIL() << "Reported sensitivity for non-saturated edge " << p.first;
1166+
}
1167+
}
1168+
EXPECT_EQ(count, 2);
1169+
}

tests/py/test_sensitivity.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import numpy as np
2+
3+
import netgraph_core as ngc
4+
5+
6+
def test_sensitivity_simple():
7+
# S->T (cap 10)
8+
g = ngc.StrictMultiDiGraph.from_arrays(
9+
num_nodes=2,
10+
src=np.array([0], dtype=np.int32),
11+
dst=np.array([1], dtype=np.int32),
12+
capacity=np.array([10.0], dtype=np.float64),
13+
cost=np.array([1], dtype=np.int64),
14+
)
15+
alg = ngc.Algorithms(ngc.Backend.cpu())
16+
gh = alg.build_graph(g)
17+
18+
res = alg.sensitivity_analysis(gh, 0, 1)
19+
assert len(res) == 1
20+
assert res[0][0] == 0 # Edge ID
21+
assert res[0][1] == 10.0
22+
23+
24+
def test_sensitivity_parallel():
25+
# S->T (cap 10), S->T (cap 10)
26+
g = ngc.StrictMultiDiGraph.from_arrays(
27+
num_nodes=2,
28+
src=np.array([0, 0], dtype=np.int32),
29+
dst=np.array([1, 1], dtype=np.int32),
30+
capacity=np.array([10.0, 10.0], dtype=np.float64),
31+
cost=np.array([1, 1], dtype=np.int64),
32+
)
33+
alg = ngc.Algorithms(ngc.Backend.cpu())
34+
gh = alg.build_graph(g)
35+
36+
res = alg.sensitivity_analysis(gh, 0, 1)
37+
assert len(res) == 2
38+
for _eid, delta in res:
39+
assert delta == 10.0
40+
41+
42+
def test_sensitivity_partial():
43+
# S->A (10), S->B (5), A->T (5), B->T (10)
44+
# 0->1 (0), 0->2 (1), 1->3 (2), 2->3 (3)
45+
g = ngc.StrictMultiDiGraph.from_arrays(
46+
num_nodes=4,
47+
src=np.array([0, 0, 1, 2], dtype=np.int32),
48+
dst=np.array([1, 2, 3, 3], dtype=np.int32),
49+
capacity=np.array([10.0, 5.0, 5.0, 10.0], dtype=np.float64),
50+
cost=np.array([1, 1, 1, 1], dtype=np.int64),
51+
)
52+
alg = ngc.Algorithms(ngc.Backend.cpu())
53+
gh = alg.build_graph(g)
54+
55+
# Note: shortest_path shouldn't matter for sensitivity_analysis default (which runs max flow)
56+
# but sensitivity_analysis API doesn't take shortest_path arg currently?
57+
# Let's check signature in module.cpp. It does NOT take shortest_path.
58+
# It hardcodes shortest_path=false in C++ implementation:
59+
# calc_max_flow(..., /*shortest_path=*/false, ...)
60+
61+
res = alg.sensitivity_analysis(gh, 0, 3)
62+
63+
# Saturated: S->B (1), A->T (2)
64+
saturated = {1, 2}
65+
assert len(res) == 2
66+
for eid, delta in res:
67+
assert eid in saturated
68+
assert delta == 5.0
69+
70+
71+
def test_sensitivity_masked():
72+
# Two parallel paths, cap 10. One masked out via input mask.
73+
g = ngc.StrictMultiDiGraph.from_arrays(
74+
num_nodes=2,
75+
src=np.array([0, 0], dtype=np.int32),
76+
dst=np.array([1, 1], dtype=np.int32),
77+
capacity=np.array([10.0, 10.0], dtype=np.float64),
78+
cost=np.array([1, 1], dtype=np.int64),
79+
)
80+
alg = ngc.Algorithms(ngc.Backend.cpu())
81+
gh = alg.build_graph(g)
82+
83+
# Mask edge 1
84+
edge_mask = np.array([True, False], dtype=bool)
85+
86+
res = alg.sensitivity_analysis(gh, 0, 1, edge_mask=edge_mask)
87+
88+
# Should only see edge 0. Max flow is 10. Sensitivity of edge 0 is 10.
89+
assert len(res) == 1
90+
assert res[0][0] == 0
91+
assert res[0][1] == 10.0

0 commit comments

Comments
 (0)