Skip to content

Commit d37a357

Browse files
Add regressions for overflow and zero-cost cycles
Co-authored-by: Andrey G <networmix@gmail.com>
1 parent 26f8407 commit d37a357

6 files changed

Lines changed: 246 additions & 1 deletion

File tree

tests/cpp/flow_policy_tests.cpp

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include <gtest/gtest.h>
2+
#include <limits>
23

34
#include "netgraph/core/flow_graph.hpp"
45
#include "netgraph/core/flow_policy.hpp"
@@ -349,3 +350,36 @@ TEST(FlowPolicyCore, EqualBalanced_ShortestPath_IgnoresHigherCostTier) {
349350
// Only shortest tier edges should carry flow
350351
expect_edge_flows_by_uv(fg, {{0,1,10.0}, {1,4,10.0}, {0,2,0.0}, {2,4,0.0}});
351352
}
353+
354+
TEST(FlowPolicyCore, MaxPathCostFactor_DoesNotOverflowAndRejectValidPath) {
355+
const auto kMax = std::numeric_limits<Cost>::max();
356+
// Single edge with very large cost; max_path_cost_factor should not overflow
357+
// and incorrectly reject this valid path.
358+
std::int32_t num_nodes = 2;
359+
std::int32_t src_arr[1] = {0};
360+
std::int32_t dst_arr[1] = {1};
361+
double cap_arr[1] = {1.0};
362+
std::int64_t cost_arr[1] = {kMax - 5};
363+
std::int64_t ext_arr[1] = {0};
364+
auto g = StrictMultiDiGraph::from_arrays(num_nodes,
365+
std::span<const std::int32_t>(src_arr, 1),
366+
std::span<const std::int32_t>(dst_arr, 1),
367+
std::span<const double>(cap_arr, 1),
368+
std::span<const std::int64_t>(cost_arr, 1),
369+
std::span<const std::int64_t>(ext_arr, 1));
370+
FlowGraph fg(g);
371+
auto be = make_cpu_backend();
372+
auto algs = std::make_shared<Algorithms>(be);
373+
auto gh = algs->build_graph(g);
374+
ExecutionContext ctx(algs, gh);
375+
376+
FlowPolicyConfig cfg;
377+
cfg.flow_placement = FlowPlacement::Proportional;
378+
cfg.max_flow_count = 1;
379+
cfg.max_path_cost_factor = 2.0;
380+
FlowPolicy policy(ctx, cfg);
381+
382+
auto res = policy.place_demand(fg, 0, 1, 0, 1.0);
383+
EXPECT_NEAR(res.first, 1.0, 1e-9);
384+
EXPECT_NEAR(res.second, 0.0, 1e-9);
385+
}

tests/cpp/k_shortest_paths_tests.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,3 +305,24 @@ TEST(KShortestPaths, LargerOutOfOrderTopology) {
305305
}
306306
}
307307
}
308+
309+
TEST(KShortestPaths, OverflowSaturatesWithoutNegativeWrap) {
310+
const auto kMax = std::numeric_limits<Cost>::max();
311+
// Single path with mathematically overflowing sum.
312+
std::int32_t src_arr[2] = {0, 1};
313+
std::int32_t dst_arr[2] = {1, 2};
314+
double cap_arr[2] = {1.0, 1.0};
315+
std::int64_t cost_arr[2] = {kMax - 5, 10};
316+
auto g = StrictMultiDiGraph::from_arrays(3,
317+
std::span(src_arr, 2), std::span(dst_arr, 2),
318+
std::span(cap_arr, 2), std::span(cost_arr, 2));
319+
320+
auto items = k_shortest_paths(g, 0, 2, 1, std::nullopt, true);
321+
ASSERT_EQ(items.size(), 1u);
322+
const auto& [dist, dag] = items.front();
323+
324+
EXPECT_EQ(dist[0], 0);
325+
EXPECT_EQ(dist[1], kMax - 5);
326+
EXPECT_EQ(dist[2], kMax - 1) << "Overflow should saturate to finite max";
327+
expect_pred_dag_semantically_valid(g, dag, dist);
328+
}

tests/cpp/max_flow_tests.cpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,35 @@ TEST(MaxFlow, ECMP_TwoPaths_EqualBalanced_ShortestPath) {
219219
validate_flow_conservation(g, summary, 0, 3);
220220
}
221221

222+
TEST(MaxFlow, EqualBalanced_ZeroCostCycleStillFindsFeasibleFlow) {
223+
// Shortest-tier zero-cost cycle between 1 and 2:
224+
// 0->1 (0), 1->2 (0), 2->1 (0), exits 1->3 (1), 2->3 (1)
225+
std::int32_t src_arr[5] = {0, 1, 2, 2, 1};
226+
std::int32_t dst_arr[5] = {1, 2, 1, 3, 3};
227+
double cap_arr[5] = {1.0, 1.0, 1.0, 1.0, 1.0};
228+
std::int64_t cost_arr[5] = {0, 0, 0, 1, 1};
229+
auto g = StrictMultiDiGraph::from_arrays(4,
230+
std::span(src_arr, 5), std::span(dst_arr, 5),
231+
std::span(cap_arr, 5), std::span(cost_arr, 5));
232+
233+
auto be = make_cpu_backend();
234+
Algorithms algs(be);
235+
auto gh = algs.build_graph(g);
236+
237+
MaxFlowOptions opts_prop;
238+
opts_prop.placement = FlowPlacement::Proportional;
239+
opts_prop.shortest_path = false;
240+
auto [total_prop, _] = algs.max_flow(gh, 0, 3, opts_prop);
241+
EXPECT_NEAR(total_prop, 1.0, 1e-9);
242+
243+
MaxFlowOptions opts_eq;
244+
opts_eq.placement = FlowPlacement::EqualBalanced;
245+
opts_eq.shortest_path = false;
246+
auto [total_eq, __] = algs.max_flow(gh, 0, 3, opts_eq);
247+
EXPECT_NEAR(total_eq, 1.0, 1e-9)
248+
<< "Equal-balanced should not collapse to zero flow on zero-cost cycle";
249+
}
250+
222251
TEST(MaxFlow, ECMP_ThreePaths_Proportional_ShortestPath) {
223252
// Extends ECMP validation to 3 equal-cost paths
224253
auto g = make_n_disjoint_paths(3, 10.0);

tests/cpp/shortest_paths_tests.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#include <gtest/gtest.h>
22
#include <limits>
3+
#include <set>
34
#include "netgraph/core/shortest_paths.hpp"
45
#include "netgraph/core/strict_multidigraph.hpp"
56
#include "netgraph/core/backend.hpp"
@@ -298,3 +299,59 @@ TEST(ShortestPaths, RejectsMaskLengthMismatch) {
298299
opts2.edge_mask = std::span<const bool>(edge_mask.get(), static_cast<std::size_t>(g.num_edges() - 1));
299300
EXPECT_THROW({ (void)algs.spf(gh, 0, opts2); }, std::invalid_argument);
300301
}
302+
303+
TEST(ShortestPaths, OverflowSaturatesWithoutWrapping) {
304+
const auto kMax = std::numeric_limits<Cost>::max();
305+
// 0->1 has huge cost, 1->2 overflows if added, 0->2 is cheap alternative.
306+
std::int32_t src[3] = {0, 1, 0};
307+
std::int32_t dst[3] = {1, 2, 2};
308+
double cap[3] = {1.0, 1.0, 1.0};
309+
std::int64_t cost[3] = {kMax - 5, 10, 100};
310+
311+
auto g = StrictMultiDiGraph::from_arrays(3,
312+
std::span(src, 3), std::span(dst, 3),
313+
std::span(cap, 3), std::span(cost, 3));
314+
315+
EdgeSelection sel;
316+
sel.multi_edge = true;
317+
sel.require_capacity = false;
318+
sel.tie_break = EdgeTieBreak::Deterministic;
319+
320+
auto [dist, dag] = shortest_paths(g, 0, std::nullopt, true, sel, {}, {}, {});
321+
322+
EXPECT_EQ(dist[0], 0);
323+
EXPECT_EQ(dist[1], kMax - 5);
324+
EXPECT_EQ(dist[2], 100) << "Overflowed path must not wrap negative and win";
325+
326+
expect_pred_dag_semantically_valid(g, dag, dist);
327+
}
328+
329+
TEST(ShortestPaths, ResolveToPathsSkipsZeroCostCycleBackEdges) {
330+
// Topology with a zero-cost cycle on the shortest tier:
331+
// 0->1->2->1 and exits 1->3, 2->3.
332+
std::int32_t src[5] = {0, 1, 2, 2, 1};
333+
std::int32_t dst[5] = {1, 2, 1, 3, 3};
334+
double cap[5] = {1.0, 1.0, 1.0, 1.0, 1.0};
335+
std::int64_t cost[5] = {0, 0, 0, 1, 1};
336+
auto g = StrictMultiDiGraph::from_arrays(4,
337+
std::span(src, 5), std::span(dst, 5),
338+
std::span(cap, 5), std::span(cost, 5));
339+
340+
EdgeSelection sel;
341+
sel.multi_edge = true;
342+
sel.require_capacity = false;
343+
sel.tie_break = EdgeTieBreak::Deterministic;
344+
auto [dist, dag] = shortest_paths(g, 0, 3, true, sel, {}, {}, {});
345+
346+
expect_pred_dag_semantically_valid(g, dag, dist);
347+
348+
// Must terminate without max_paths and return only simple paths.
349+
auto paths = resolve_to_paths(dag, 0, 3, false, std::nullopt);
350+
EXPECT_EQ(paths.size(), 2u);
351+
for (const auto& path : paths) {
352+
std::set<NodeId> seen;
353+
for (const auto& [node, _] : path) {
354+
EXPECT_TRUE(seen.insert(node).second) << "Path contains a cycle";
355+
}
356+
}
357+
}

tests/cpp/test_utils.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
#include "netgraph/core/shortest_paths.hpp"
88
#include "netgraph/core/flow_graph.hpp"
99
#include "netgraph/core/max_flow.hpp"
10+
#include "netgraph/core/cost_utils.hpp"
1011

1112
namespace netgraph::core::test {
1213

@@ -429,7 +430,7 @@ inline void expect_pred_dag_semantically_valid(const StrictMultiDiGraph& g,
429430
auto d_v = dist[static_cast<std::size_t>(v)];
430431
auto d_p = dist[static_cast<std::size_t>(parent)];
431432
if (d_v < std::numeric_limits<Cost>::max() && d_p < std::numeric_limits<Cost>::max()) {
432-
EXPECT_EQ(d_v, d_p + edge_cost[eid])
433+
EXPECT_EQ(d_v, saturating_cost_add(d_p, edge_cost[eid]))
433434
<< "Distance inconsistency at node " << v;
434435
}
435436
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from __future__ import annotations
2+
3+
import numpy as np
4+
5+
import netgraph_core as ngc
6+
7+
8+
def _graph_overflow_with_alternative():
9+
kmax = np.iinfo(np.int64).max
10+
src = np.array([0, 1, 0], dtype=np.int32)
11+
dst = np.array([1, 2, 2], dtype=np.int32)
12+
cap = np.array([1.0, 1.0, 1.0], dtype=np.float64)
13+
cost = np.array([kmax - 5, 10, 100], dtype=np.int64)
14+
ext = np.arange(3, dtype=np.int64)
15+
return ngc.StrictMultiDiGraph.from_arrays(3, src, dst, cap, cost, ext)
16+
17+
18+
def _graph_overflow_single_path():
19+
kmax = np.iinfo(np.int64).max
20+
src = np.array([0, 1], dtype=np.int32)
21+
dst = np.array([1, 2], dtype=np.int32)
22+
cap = np.array([1.0, 1.0], dtype=np.float64)
23+
cost = np.array([kmax - 5, 10], dtype=np.int64)
24+
ext = np.arange(2, dtype=np.int64)
25+
return ngc.StrictMultiDiGraph.from_arrays(3, src, dst, cap, cost, ext)
26+
27+
28+
def _graph_zero_cost_cycle():
29+
src = np.array([0, 1, 2, 2, 1], dtype=np.int32)
30+
dst = np.array([1, 2, 1, 3, 3], dtype=np.int32)
31+
cap = np.array([1.0, 1.0, 1.0, 1.0, 1.0], dtype=np.float64)
32+
cost = np.array([0, 0, 0, 1, 1], dtype=np.int64)
33+
ext = np.arange(5, dtype=np.int64)
34+
return ngc.StrictMultiDiGraph.from_arrays(4, src, dst, cap, cost, ext)
35+
36+
37+
def test_spf_overflow_does_not_wrap_negative(algs, to_handle):
38+
g = _graph_overflow_with_alternative()
39+
gh = to_handle(g)
40+
dist, _ = algs.spf(gh, 0, dtype="int64")
41+
assert int(dist[2]) == 100
42+
43+
44+
def test_ksp_overflow_saturates_finite_cost(algs, to_handle):
45+
g = _graph_overflow_single_path()
46+
gh = to_handle(g)
47+
items = algs.ksp(gh, 0, 2, k=1, dtype="int64")
48+
assert len(items) == 1
49+
kmax = np.iinfo(np.int64).max
50+
assert int(items[0][0][2]) == int(kmax - 1)
51+
52+
53+
def test_max_flow_summary_costs_no_negative_wrap(algs, to_handle):
54+
g = _graph_overflow_single_path()
55+
gh = to_handle(g)
56+
flow, summary = algs.max_flow(gh, 0, 2)
57+
assert flow == 1.0
58+
assert int(summary.costs[0]) == int(np.iinfo(np.int64).max - 1)
59+
60+
61+
def test_flow_policy_max_cost_factor_handles_large_cost(algs, to_handle):
62+
g = _graph_overflow_single_path()
63+
gh = to_handle(g)
64+
fg = ngc.FlowGraph(g)
65+
cfg = ngc.FlowPolicyConfig(max_flow_count=1, max_path_cost_factor=2.0)
66+
policy = ngc.FlowPolicy(algs, gh, cfg)
67+
placed, left = policy.place_demand(fg, 0, 2, 0, 1.0)
68+
assert placed == 1.0
69+
assert left == 0.0
70+
71+
72+
def test_resolve_to_paths_terminates_on_zero_cost_cycle(algs, to_handle):
73+
g = _graph_zero_cost_cycle()
74+
gh = to_handle(g)
75+
_, dag = algs.spf(gh, 0, dst=3, multipath=True, dtype="int64")
76+
paths = dag.resolve_to_paths(0, 3)
77+
assert len(paths) == 2
78+
for path in paths:
79+
nodes = [int(n) for n, _ in path]
80+
assert len(nodes) == len(set(nodes))
81+
82+
83+
def test_equal_balanced_max_flow_zero_cost_cycle(algs, to_handle):
84+
g = _graph_zero_cost_cycle()
85+
gh = to_handle(g)
86+
flow_prop, _ = algs.max_flow(
87+
gh,
88+
0,
89+
3,
90+
flow_placement=ngc.FlowPlacement.PROPORTIONAL,
91+
shortest_path=False,
92+
require_capacity=True,
93+
)
94+
flow_eb, _ = algs.max_flow(
95+
gh,
96+
0,
97+
3,
98+
flow_placement=ngc.FlowPlacement.EQUAL_BALANCED,
99+
shortest_path=False,
100+
require_capacity=True,
101+
)
102+
assert flow_prop == 1.0
103+
assert flow_eb == 1.0

0 commit comments

Comments
 (0)