Skip to content

Commit 4758249

Browse files
committed
Release v0.2.2: Fix EqualBalanced flow placement
1 parent cc01d9c commit 4758249

6 files changed

Lines changed: 165 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ 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.2] - 2025-12-05
9+
10+
### Fixed
11+
12+
- **Flow Placement**: EqualBalanced placement now correctly returns 0 when the shortest path has no capacity with `require_capacity=False`. Previously, flow could be incorrectly reported on partial paths that didn't reach the destination.
13+
814
## [0.2.1] - 2025-12-01
915

1016
### Fixed

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.2.1"
7+
version = "0.2.2"
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/_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.2.1"
5+
__version__ = "0.2.2"

src/flow_state.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,14 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag,
341341
}
342342
}
343343

344+
// Early exit if destination is not reachable with capacity.
345+
// With require_capacity=false, SPF includes zero-capacity edges in the DAG,
346+
// but those edges are filtered out during group building. If no path with
347+
// capacity reaches the destination, return 0.
348+
if (inflow[static_cast<std::size_t>(dst)] < kEpsilon) {
349+
return static_cast<Flow>(0.0);
350+
}
351+
344352
// Single-pass ECMP admission: scale the unit assignment by the smallest
345353
// per-group headroom so that no edge is oversubscribed under *fixed equal
346354
// per-edge splits*. Any further injection with the same splits would violate

tests/cpp/flow_state_tests.cpp

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,3 +377,58 @@ TEST(FlowState, EqualBalanced_DownstreamSplitEqualization) {
377377
EXPECT_NEAR(ef[3], 5.0, 1e-9); // 2->4
378378
EXPECT_NEAR(ef[4], 5.0, 1e-9); // 3->4
379379
}
380+
381+
TEST(FlowState, EqualBalanced_ZeroCapacityOnShortestPath_ReturnsZero) {
382+
// With require_capacity=false (true IP/IGP semantics), routing is cost-only.
383+
// If the shortest path has a zero-capacity edge, no flow can be placed.
384+
// Graph: 0->1 (cap 0, cost 1) -> 2 (cap 10, cost 1) [shortest path, but no capacity]
385+
// 0->3 (cap 100, cost 2) -> 2 (cap 100, cost 2) [longer path with capacity]
386+
std::int32_t src[4] = {0, 1, 0, 3};
387+
std::int32_t dst[4] = {1, 2, 3, 2};
388+
double cap[4] = {0.0, 10.0, 100.0, 100.0}; // 0->1 has zero capacity!
389+
std::int64_t cost[4] = {1, 1, 2, 2};
390+
auto g = StrictMultiDiGraph::from_arrays(4,
391+
std::span(src, 4), std::span(dst, 4),
392+
std::span(cap, 4), std::span(cost, 4));
393+
FlowState fs(g);
394+
395+
// Build DAG with require_capacity=false (cost-only routing, ignores capacity).
396+
// This simulates true IP/IGP behavior where routes are based purely on cost.
397+
EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic;
398+
auto [dist, dag] = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {});
399+
400+
// Verify SPF found a path (the zero-capacity one is included in the DAG).
401+
// The shortest path 0->1->2 has cost 2, while 0->3->2 has cost 4.
402+
EXPECT_LT(dist[2], std::numeric_limits<Cost>::max());
403+
404+
// Attempt EqualBalanced placement. Since the shortest path has no capacity,
405+
// and we're in single-tier mode (simulated by only having one DAG),
406+
// placement should return 0.
407+
Flow placed = fs.place_on_dag(0, 2, dag, std::numeric_limits<double>::infinity(), FlowPlacement::EqualBalanced);
408+
EXPECT_NEAR(placed, 0.0, 1e-9) << "EqualBalanced should return 0 when shortest path has no capacity";
409+
410+
// Verify no edge flows were placed
411+
auto ef = fs.edge_flow_view();
412+
for (std::size_t i = 0; i < static_cast<std::size_t>(g.num_edges()); ++i) {
413+
EXPECT_NEAR(ef[i], 0.0, 1e-9) << "Edge " << i << " should have no flow";
414+
}
415+
}
416+
417+
TEST(FlowState, Proportional_ZeroCapacityOnShortestPath_ReturnsZero) {
418+
// With require_capacity=false, if the shortest path has a zero-capacity edge,
419+
// no flow can be placed. Both placements behave consistently.
420+
std::int32_t src[4] = {0, 1, 0, 3};
421+
std::int32_t dst[4] = {1, 2, 3, 2};
422+
double cap[4] = {0.0, 10.0, 100.0, 100.0};
423+
std::int64_t cost[4] = {1, 1, 2, 2};
424+
auto g = StrictMultiDiGraph::from_arrays(4,
425+
std::span(src, 4), std::span(dst, 4),
426+
std::span(cap, 4), std::span(cost, 4));
427+
FlowState fs(g);
428+
429+
EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = false; sel.tie_break = EdgeTieBreak::Deterministic;
430+
auto [dist, dag] = shortest_paths(g, 0, 2, /*multipath=*/true, sel, {}, {}, {});
431+
432+
Flow placed = fs.place_on_dag(0, 2, dag, std::numeric_limits<double>::infinity(), FlowPlacement::Proportional);
433+
EXPECT_NEAR(placed, 0.0, 1e-9) << "Proportional should return 0 when shortest path has no capacity";
434+
}

tests/py/test_edge_cases.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,97 @@ def test_ksp_distance_dtype_int64_exact(algs):
8080
dist_i64 = np.asarray(dist_i64)
8181
assert dist_i64.dtype == np.int64
8282
assert int(dist_i64[2]) == int(big + big)
83+
84+
85+
def test_max_flow_zero_capacity_on_shortest_path_equal_balanced(algs, to_handle):
86+
"""EqualBalanced with require_capacity=False returns 0 when shortest path has no capacity.
87+
88+
With require_capacity=False (true IP/IGP semantics), routing is cost-only.
89+
If the shortest path has a zero-capacity edge, no flow can be placed.
90+
91+
Topology:
92+
Shortest path: 0->1->2 (cost 2, but 0->1 has cap=0)
93+
Longer path: 0->3->2 (cost 4, cap=100)
94+
"""
95+
src = np.array([0, 1, 0, 3], dtype=np.int32)
96+
dst = np.array([1, 2, 3, 2], dtype=np.int32)
97+
cap = np.array(
98+
[0.0, 10.0, 100.0, 100.0], dtype=np.float64
99+
) # 0->1 has zero capacity
100+
cost = np.array([1, 1, 2, 2], dtype=np.int64)
101+
g = ngc.StrictMultiDiGraph.from_arrays(4, src, dst, cap, cost)
102+
103+
# Test with EqualBalanced + require_capacity=False + shortest_path=True
104+
total, summary = algs.max_flow(
105+
to_handle(g),
106+
0,
107+
2,
108+
flow_placement=ngc.FlowPlacement.EQUAL_BALANCED,
109+
shortest_path=True,
110+
require_capacity=False,
111+
with_edge_flows=True,
112+
)
113+
114+
# Should return 0 because the shortest path (cost 2) has no capacity
115+
assert np.isclose(total, 0.0), (
116+
f"Expected 0 flow when shortest path has zero capacity, got {total}"
117+
)
118+
119+
# Verify no edge flows were placed
120+
edge_flows = np.asarray(summary.edge_flows)
121+
assert np.allclose(edge_flows, 0.0), f"Expected no edge flows, got {edge_flows}"
122+
123+
124+
def test_max_flow_zero_capacity_on_shortest_path_proportional(algs, to_handle):
125+
"""Proportional with require_capacity=False returns 0 when shortest path has no capacity.
126+
127+
With require_capacity=False, if the shortest path has a zero-capacity edge,
128+
no flow can be placed. Both placements behave consistently.
129+
"""
130+
src = np.array([0, 1, 0, 3], dtype=np.int32)
131+
dst = np.array([1, 2, 3, 2], dtype=np.int32)
132+
cap = np.array([0.0, 10.0, 100.0, 100.0], dtype=np.float64)
133+
cost = np.array([1, 1, 2, 2], dtype=np.int64)
134+
g = ngc.StrictMultiDiGraph.from_arrays(4, src, dst, cap, cost)
135+
136+
total, summary = algs.max_flow(
137+
to_handle(g),
138+
0,
139+
2,
140+
flow_placement=ngc.FlowPlacement.PROPORTIONAL,
141+
shortest_path=True,
142+
require_capacity=False,
143+
with_edge_flows=True,
144+
)
145+
146+
assert np.isclose(total, 0.0), (
147+
f"Expected 0 flow when shortest path has zero capacity, got {total}"
148+
)
149+
150+
151+
def test_max_flow_require_capacity_true_finds_alternative(algs, to_handle):
152+
"""With require_capacity=True, max_flow should find an alternative path with capacity.
153+
154+
This is the contrast test: when require_capacity=True, the algorithm should
155+
skip the zero-capacity edge and find the longer path that has capacity.
156+
"""
157+
src = np.array([0, 1, 0, 3], dtype=np.int32)
158+
dst = np.array([1, 2, 3, 2], dtype=np.int32)
159+
cap = np.array([0.0, 10.0, 100.0, 100.0], dtype=np.float64)
160+
cost = np.array([1, 1, 2, 2], dtype=np.int64)
161+
g = ngc.StrictMultiDiGraph.from_arrays(4, src, dst, cap, cost)
162+
163+
total, summary = algs.max_flow(
164+
to_handle(g),
165+
0,
166+
2,
167+
flow_placement=ngc.FlowPlacement.EQUAL_BALANCED,
168+
shortest_path=True,
169+
require_capacity=True, # This should skip zero-capacity edges
170+
with_edge_flows=True,
171+
)
172+
173+
# Should find the longer path 0->3->2 and place 100 flow
174+
assert np.isclose(total, 100.0), (
175+
f"Expected 100 flow via alternative path, got {total}"
176+
)

0 commit comments

Comments
 (0)