Skip to content

Commit 5cea9d6

Browse files
committed
Prepare for v0.2.0 release: Added sensitivity analysis feature with shortest_path parameter
1 parent 7238ac2 commit 5cea9d6

File tree

10 files changed

+226
-16
lines changed

10 files changed

+226
-16
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2025-11-25
9+
10+
### Added
11+
12+
- **Sensitivity Analysis**: Added `shortest_path` parameter to `sensitivity_analysis()`.
13+
- `shortest_path=False` (default): Uses full max-flow; reports all saturated edges across all cost tiers.
14+
- `shortest_path=True`: Uses single-tier shortest-path flow; reports only edges used under ECMP routing.
15+
- Python type stub documentation for `Algorithms.sensitivity_analysis()`.
16+
817
## [0.1.0] - 2025-11-23
918

1019
### Added

bindings/python/module.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,10 +302,10 @@ PYBIND11_MODULE(_netgraph_core, m) {
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;
303303
}, 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)
304304
.def("sensitivity_analysis", [](const Algorithms& algs, const PyGraph& pg, std::int32_t src, std::int32_t dst,
305-
FlowPlacement placement, bool require_capacity,
305+
FlowPlacement placement, bool shortest_path, bool require_capacity,
306306
py::object node_mask, py::object edge_mask){
307307
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;
308+
MaxFlowOptions o; o.placement = placement; o.shortest_path = shortest_path; o.require_capacity = require_capacity;
309309
auto node_bs = to_bool_span_from_numpy(node_mask, static_cast<std::size_t>(pg.num_nodes), "node_mask");
310310
auto edge_bs = to_bool_span_from_numpy(edge_mask, static_cast<std::size_t>(pg.num_edges), "edge_mask");
311311
o.node_mask = node_bs.view; o.edge_mask = edge_bs.view;
@@ -316,7 +316,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
316316
out.append(py::make_tuple(pr.first, pr.second));
317317
}
318318
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());
319+
}, py::arg("graph"), py::arg("src"), py::arg("dst"), py::kw_only(), py::arg("flow_placement") = FlowPlacement::Proportional, py::arg("shortest_path") = false, py::arg("require_capacity") = true, py::arg("node_mask") = py::none(), py::arg("edge_mask") = py::none());
320320

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

include/netgraph/core/max_flow.hpp

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,26 @@ 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+
// Performs sensitivity analysis to identify edges that constrain flow.
51+
//
52+
// Computes baseline flow (using full max-flow or shortest-path-only mode),
53+
// then tests removing each saturated edge to measure its criticality.
54+
//
55+
// Arguments:
56+
// g: The input graph.
57+
// src, dst: Source and destination nodes.
58+
// placement: Flow placement strategy.
59+
// shortest_path: If true, uses single-pass shortest-path flow (IP/IGP mode).
60+
// If false, uses full iterative max-flow (SDN/TE mode).
61+
// require_capacity: If true, excludes saturated edges from routing.
62+
// node_mask, edge_mask: Optional masks to exclude nodes/edges.
63+
//
64+
// Returns:
65+
// Pairs of (EdgeId, FlowDelta) for edges whose removal reduces flow.
5066
[[nodiscard]] std::vector<std::pair<EdgeId, Flow>>
5167
sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
52-
FlowPlacement placement, bool require_capacity,
68+
FlowPlacement placement, bool shortest_path,
69+
bool require_capacity,
5370
std::span<const bool> node_mask = {},
5471
std::span<const bool> edge_mask = {});
5572

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"
44

55
[project]
66
name = "netgraph-core"
7-
version = "0.1.0"
7+
version = "0.2.0"
88
description = "C++ implementation of graph algorithms for network flow analysis and traffic engineering with Python bindings"
99
readme = "README.md"
1010
requires-python = ">=3.9"

python/netgraph_core/_docs.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,3 +557,46 @@ def batch_max_flow(
557557
List of FlowSummary objects, one per pair.
558558
"""
559559
...
560+
561+
def sensitivity_analysis(
562+
self,
563+
graph: "Graph",
564+
src: int,
565+
dst: int,
566+
*,
567+
flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL,
568+
shortest_path: bool = False,
569+
require_capacity: bool = True,
570+
node_mask: Optional["np.ndarray"] = None,
571+
edge_mask: Optional["np.ndarray"] = None,
572+
) -> list[tuple[int, float]]:
573+
"""Sensitivity analysis to identify critical edges that constrain flow.
574+
575+
Computes baseline flow, then tests removing each saturated edge to
576+
measure how much the total flow would be reduced.
577+
578+
The `shortest_path` parameter controls the routing semantics:
579+
580+
- shortest_path=False (default): Full max-flow analysis (SDN/TE mode).
581+
Identifies edges critical for achieving maximum possible flow.
582+
583+
- shortest_path=True: Shortest-path-only analysis (IP/IGP mode).
584+
Identifies edges critical for flow under ECMP routing. Edges on
585+
unused longer paths are not reported as critical.
586+
587+
Args:
588+
graph: Graph handle
589+
src: Source node
590+
dst: Destination node
591+
flow_placement: Flow placement strategy
592+
shortest_path: If True, use single-pass shortest-path flow (IP/IGP).
593+
If False, use full iterative max-flow (SDN/TE).
594+
require_capacity: If True, exclude saturated edges from routing.
595+
node_mask: Optional 1-D bool mask. **Copied for thread safety.**
596+
edge_mask: Optional 1-D bool mask. **Copied for thread safety.**
597+
598+
Returns:
599+
List of (edge_id, flow_delta) tuples. Each edge_id is an edge
600+
whose removal would reduce total flow by flow_delta.
601+
"""
602+
...

python/netgraph_core/_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
__all__ = ["__version__"]
44

5-
__version__ = "0.1.0"
5+
__version__ = "0.2.0"

src/cpu_backend.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ class CpuBackend final : public Backend {
109109
throw std::invalid_argument("CpuBackend::sensitivity_analysis: edge_mask length mismatch");
110110
}
111111
return netgraph::core::sensitivity_analysis(g, src, dst,
112-
opts.placement, opts.require_capacity,
112+
opts.placement, opts.shortest_path,
113+
opts.require_capacity,
113114
opts.node_mask, opts.edge_mask);
114115
}
115116
};

src/max_flow.cpp

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -205,13 +205,15 @@ batch_max_flow(const StrictMultiDiGraph& g,
205205

206206
std::vector<std::pair<EdgeId, Flow>>
207207
sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
208-
FlowPlacement placement, bool require_capacity,
208+
FlowPlacement placement, bool shortest_path,
209+
bool require_capacity,
209210
std::span<const bool> node_mask,
210211
std::span<const bool> edge_mask) {
211212
// Step 1: Baseline analysis to identify saturated edges
213+
// Uses shortest_path mode to match the routing semantics being analyzed.
212214
auto [baseline_flow, summary] = calc_max_flow(
213215
g, src, dst, placement,
214-
/*shortest_path=*/false,
216+
shortest_path,
215217
require_capacity,
216218
/*with_edge_flows=*/false,
217219
/*with_reachable=*/false,
@@ -250,14 +252,14 @@ sensitivity_analysis(const StrictMultiDiGraph& g, NodeId src, NodeId dst,
250252
std::vector<std::pair<EdgeId, Flow>> results;
251253
results.reserve(candidates.size());
252254

253-
// Step 2: Iterate candidates
255+
// Step 2: Iterate candidates, testing flow reduction when each is removed
254256
for (EdgeId eid : candidates) {
255257
// Mask out the edge
256258
test_mask_buf[eid] = false;
257259

258260
auto [new_flow, _] = calc_max_flow(
259261
g, src, dst, placement,
260-
/*shortest_path=*/false,
262+
shortest_path,
261263
require_capacity,
262264
/*with_edge_flows=*/false,
263265
/*with_reachable=*/false,

tests/cpp/max_flow_tests.cpp

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1167,3 +1167,92 @@ TEST(MaxFlow, Sensitivity_PartialSaturation) {
11671167
}
11681168
EXPECT_EQ(count, 2);
11691169
}
1170+
1171+
TEST(MaxFlow, Sensitivity_ShortestPathVsMaxFlow) {
1172+
// Tests that shortest_path mode produces different sensitivity results
1173+
// than full max-flow mode when there are paths of different costs.
1174+
//
1175+
// Topology: S(0) -> A(1) -> T(2) [cost 1+1=2, cap 10 each]
1176+
// S(0) -> B(3) -> T(2) [cost 2+2=4, cap 5 each]
1177+
//
1178+
// With shortest_path=false (full max-flow):
1179+
// - Uses both paths: S->A->T (10) + S->B->T (5) = 15 total
1180+
// - All 4 edges are saturated and critical
1181+
//
1182+
// With shortest_path=true (single-pass, IP/IGP mode):
1183+
// - Only uses cheapest path: S->A->T (10)
1184+
// - Edges 2,3 (S->B->T path) are NOT used, so NOT critical
1185+
1186+
std::int32_t src[4] = {0, 1, 0, 3};
1187+
std::int32_t dst[4] = {1, 2, 3, 2};
1188+
double cap[4] = {10.0, 10.0, 5.0, 5.0};
1189+
std::int64_t cost[4] = {1, 1, 2, 2}; // S->A->T costs 2, S->B->T costs 4
1190+
1191+
auto g = StrictMultiDiGraph::from_arrays(4,
1192+
std::span(src, 4), std::span(dst, 4),
1193+
std::span(cap, 4), std::span(cost, 4));
1194+
1195+
auto be = make_cpu_backend();
1196+
Algorithms algs(be);
1197+
auto gh = algs.build_graph(g);
1198+
1199+
// Step 1: Verify baseline flow values with max_flow
1200+
// This validates the routing semantics before testing sensitivity
1201+
{
1202+
MaxFlowOptions opts;
1203+
opts.placement = FlowPlacement::Proportional;
1204+
opts.shortest_path = false;
1205+
auto [flow_full, _] = algs.max_flow(gh, 0, 2, opts);
1206+
EXPECT_NEAR(flow_full, 15.0, 1e-9) << "Full max-flow should be 15";
1207+
}
1208+
{
1209+
MaxFlowOptions opts;
1210+
opts.placement = FlowPlacement::Proportional;
1211+
opts.shortest_path = true;
1212+
auto [flow_sp, _] = algs.max_flow(gh, 0, 2, opts);
1213+
EXPECT_NEAR(flow_sp, 10.0, 1e-9) << "Shortest-path flow should be 10";
1214+
}
1215+
1216+
// Step 2: Test sensitivity with shortest_path=false (full max-flow)
1217+
{
1218+
MaxFlowOptions opts;
1219+
opts.placement = FlowPlacement::Proportional;
1220+
opts.shortest_path = false;
1221+
1222+
auto results = algs.sensitivity_analysis(gh, 0, 2, opts);
1223+
1224+
// All 4 edges should be saturated and critical
1225+
EXPECT_EQ(results.size(), 4u) << "Full max-flow should report all 4 edges as critical";
1226+
1227+
// Edges 0,1 (S->A->T path) have delta 10 when removed
1228+
// Edges 2,3 (S->B->T path) have delta 5 when removed
1229+
for (const auto& p : results) {
1230+
if (p.first == 0 || p.first == 1) {
1231+
EXPECT_NEAR(p.second, 10.0, 1e-9);
1232+
} else {
1233+
EXPECT_NEAR(p.second, 5.0, 1e-9);
1234+
}
1235+
}
1236+
}
1237+
1238+
// Step 3: Test sensitivity with shortest_path=true (IP/IGP mode)
1239+
{
1240+
MaxFlowOptions opts;
1241+
opts.placement = FlowPlacement::Proportional;
1242+
opts.shortest_path = true;
1243+
1244+
auto results = algs.sensitivity_analysis(gh, 0, 2, opts);
1245+
1246+
// Only edges 0,1 (S->A->T path) should be critical
1247+
// Edges 2,3 (S->B->T path) are not used under shortest-path routing
1248+
EXPECT_EQ(results.size(), 2u) << "Shortest-path mode should only report 2 edges as critical";
1249+
1250+
for (const auto& p : results) {
1251+
EXPECT_TRUE(p.first == 0 || p.first == 1)
1252+
<< "Edge " << p.first << " should not be critical in shortest-path mode";
1253+
// Baseline=10, removing either S->A or A->T forces traffic to S->B->T (cap 5)
1254+
// Delta = 10 - 5 = 5
1255+
EXPECT_NEAR(p.second, 5.0, 1e-9);
1256+
}
1257+
}
1258+
}

tests/py/test_sensitivity.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,7 @@ def test_sensitivity_partial():
5252
alg = ngc.Algorithms(ngc.Backend.cpu())
5353
gh = alg.build_graph(g)
5454

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, ...)
55+
# With all equal-cost edges, shortest_path=True/False give the same result.
6056

6157
res = alg.sensitivity_analysis(gh, 0, 3)
6258

@@ -89,3 +85,56 @@ def test_sensitivity_masked():
8985
assert len(res) == 1
9086
assert res[0][0] == 0
9187
assert res[0][1] == 10.0
88+
89+
90+
def test_sensitivity_shortest_path_vs_max_flow():
91+
"""Test that shortest_path mode produces different sensitivity results.
92+
93+
Topology: S(0) -> A(1) -> T(2) [cost 1+1=2, cap 10 each]
94+
S(0) -> B(3) -> T(2) [cost 2+2=4, cap 5 each]
95+
96+
With shortest_path=False (full max-flow):
97+
- Uses both paths: S->A->T (10) + S->B->T (5) = 15 total
98+
- All 4 edges are saturated and critical
99+
100+
With shortest_path=True (single-pass, IP/IGP mode):
101+
- Only uses cheapest path: S->A->T (10)
102+
- Edges 2,3 (S->B->T path) are NOT used, so NOT critical
103+
"""
104+
g = ngc.StrictMultiDiGraph.from_arrays(
105+
num_nodes=4,
106+
src=np.array([0, 1, 0, 3], dtype=np.int32),
107+
dst=np.array([1, 2, 3, 2], dtype=np.int32),
108+
capacity=np.array([10.0, 10.0, 5.0, 5.0], dtype=np.float64),
109+
cost=np.array([1, 1, 2, 2], dtype=np.int64), # S->A->T costs 2, S->B->T costs 4
110+
)
111+
alg = ngc.Algorithms(ngc.Backend.cpu())
112+
gh = alg.build_graph(g)
113+
114+
# Step 1: Verify baseline flow values with max_flow
115+
# This validates the routing semantics before testing sensitivity
116+
flow_full, _ = alg.max_flow(gh, 0, 2, shortest_path=False)
117+
flow_sp, _ = alg.max_flow(gh, 0, 2, shortest_path=True)
118+
119+
assert abs(flow_full - 15.0) < 1e-9, f"Full max-flow should be 15, got {flow_full}"
120+
assert abs(flow_sp - 10.0) < 1e-9, f"Shortest-path flow should be 10, got {flow_sp}"
121+
122+
# Step 2: Test sensitivity with shortest_path=False (full max-flow)
123+
res_full = alg.sensitivity_analysis(gh, 0, 2, shortest_path=False)
124+
125+
# All 4 edges should be saturated and critical
126+
assert len(res_full) == 4, "Full max-flow should report all 4 edges as critical"
127+
edge_ids_full = {eid for eid, _ in res_full}
128+
assert edge_ids_full == {0, 1, 2, 3}
129+
130+
# Step 3: Test sensitivity with shortest_path=True (IP/IGP mode)
131+
res_sp = alg.sensitivity_analysis(gh, 0, 2, shortest_path=True)
132+
133+
# Only edges 0,1 (S->A->T path) should be critical
134+
assert len(res_sp) == 2, "Shortest-path mode should only report 2 edges as critical"
135+
edge_ids_sp = {eid for eid, _ in res_sp}
136+
assert edge_ids_sp == {0, 1}, f"Expected edges 0,1 but got {edge_ids_sp}"
137+
138+
# Delta values: baseline=10, with edge removed traffic uses S->B->T (cap 5)
139+
for eid, delta in res_sp:
140+
assert abs(delta - 5.0) < 1e-9, f"Edge {eid} should have delta 5.0, got {delta}"

0 commit comments

Comments
 (0)