Skip to content

Commit 092c2ce

Browse files
committed
Release v0.3.5: bug fix (see Changelog)
1 parent ff9cecf commit 092c2ce

7 files changed

Lines changed: 376 additions & 22 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.3.5] - 2026-02-08
9+
10+
### Fixed
11+
12+
- **K-Shortest Paths**: Fix off-by-one in Yen's prefix comparison and PredDAG CSR fill order. Paths visiting nodes out of numerical order (e.g., `[0,3,2]`) previously produced malformed predecessor DAGs.
13+
814
## [0.3.4] - 2026-02-02
915

1016
### Changed

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.3.4"
7+
version = "0.3.5"
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.11"

tests/cpp/k_shortest_paths_tests.cpp

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
#include <gtest/gtest.h>
2+
#include <limits>
3+
#include <set>
24
#include "netgraph/core/k_shortest_paths.hpp"
5+
#include "netgraph/core/shortest_paths.hpp"
36
#include "netgraph/core/backend.hpp"
47
#include "netgraph/core/algorithms.hpp"
58
#include "netgraph/core/strict_multidigraph.hpp"
@@ -197,3 +200,108 @@ TEST(KShortestPaths, LooplessPaths) {
197200
EXPECT_DOUBLE_EQ(paths[0].first[3], 3.0) << "Shortest path should have cost 3";
198201
}
199202
}
203+
204+
TEST(KShortestPaths, OutOfOrderNodePredDAGSemantics) {
205+
// Graph topology that forces paths visiting nodes out of numerical order.
206+
// Path 1 (shortest): 0 -> 1 -> 2 (cost 2, in-order)
207+
// Path 2 (longer): 0 -> 3 -> 2 (cost 4, visits node 3 before node 2)
208+
// This was the exact bug scenario: PredDAG fill for path [0,3,2] swapped
209+
// parent entries between nodes 2 and 3 when using a linear index.
210+
auto g = make_square_graph(1); // 0->1->2 (cost 2) vs 0->3->2 (cost 4)
211+
212+
auto items = k_shortest_paths(g, 0, 2, 5, std::nullopt, true);
213+
214+
ASSERT_GE(items.size(), 2) << "Should find at least 2 paths";
215+
216+
// Verify all PredDAGs have correct semantic structure
217+
for (std::size_t p = 0; p < items.size(); ++p) {
218+
const auto& [dist, dag] = items[p];
219+
SCOPED_TRACE("Path " + std::to_string(p));
220+
221+
expect_pred_dag_valid(dag, g.num_nodes());
222+
expect_pred_dag_semantically_valid(g, dag, dist);
223+
224+
// Source must have distance 0 and destination must be reachable
225+
EXPECT_EQ(dist[0], 0);
226+
EXPECT_LT(dist[2], std::numeric_limits<Cost>::max());
227+
228+
// Verify path can be reconstructed via resolve_to_paths
229+
auto concrete = resolve_to_paths(dag, 0, 2, /*split_parallel_edges=*/true);
230+
EXPECT_GE(concrete.size(), 1) << "resolve_to_paths should reconstruct at least one path";
231+
232+
// Verify reconstructed path is valid: consecutive nodes connected by claimed edges
233+
for (const auto& path : concrete) {
234+
ASSERT_GE(path.size(), 2); // at least src and dst
235+
EXPECT_EQ(path.front().first, 0) << "Path should start at source";
236+
EXPECT_EQ(path.back().first, 2) << "Path should end at destination";
237+
// Check edge connectivity
238+
for (std::size_t i = 0; i + 1 < path.size(); ++i) {
239+
auto from_node = path[i].first;
240+
auto to_node = path[i + 1].first;
241+
const auto& edges = path[i].second;
242+
for (auto eid : edges) {
243+
EXPECT_EQ(g.edge_src_view()[static_cast<std::size_t>(eid)], from_node);
244+
EXPECT_EQ(g.edge_dst_view()[static_cast<std::size_t>(eid)], to_node);
245+
}
246+
}
247+
}
248+
}
249+
}
250+
251+
TEST(KShortestPaths, LargerOutOfOrderTopology) {
252+
// Larger graph where multiple k-shortest paths visit nodes out of numerical order.
253+
// Topology (6 nodes):
254+
// 0 -> 5 -> 3 -> 1 (cost 3, severely out-of-order: 0,5,3,1)
255+
// 0 -> 2 -> 4 -> 1 (cost 6, also out-of-order: 0,2,4,1)
256+
// 0 -> 1 (cost 10, direct)
257+
std::int32_t src_arr[7] = {0, 5, 3, 0, 2, 4, 0};
258+
std::int32_t dst_arr[7] = {5, 3, 1, 2, 4, 1, 1};
259+
double cap_arr[7] = {1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0};
260+
std::int64_t cost_arr[7] = {1, 1, 1, 2, 2, 2, 10};
261+
262+
auto g = StrictMultiDiGraph::from_arrays(6,
263+
std::span(src_arr, 7), std::span(dst_arr, 7),
264+
std::span(cap_arr, 7), std::span(cost_arr, 7));
265+
266+
auto items = k_shortest_paths(g, 0, 1, 5, std::nullopt, true);
267+
268+
ASSERT_GE(items.size(), 2) << "Should find at least 2 paths";
269+
270+
// Verify costs are non-decreasing
271+
for (std::size_t i = 0; i + 1 < items.size(); ++i) {
272+
EXPECT_LE(items[i].first[1], items[i + 1].first[1])
273+
<< "Paths should be sorted by cost to destination";
274+
}
275+
276+
// The first path should be 0->5->3->1 (cost 3)
277+
EXPECT_EQ(items[0].first[1], 3) << "Shortest path cost should be 3";
278+
279+
// Verify all PredDAGs are semantically correct and can be resolved
280+
for (std::size_t p = 0; p < items.size(); ++p) {
281+
const auto& [dist, dag] = items[p];
282+
SCOPED_TRACE("Path " + std::to_string(p));
283+
284+
expect_pred_dag_valid(dag, g.num_nodes());
285+
expect_pred_dag_semantically_valid(g, dag, dist);
286+
287+
// Source must have distance 0 and destination must be reachable
288+
EXPECT_EQ(dist[0], 0);
289+
EXPECT_LT(dist[1], std::numeric_limits<Cost>::max());
290+
291+
auto concrete = resolve_to_paths(dag, 0, 1, true);
292+
EXPECT_GE(concrete.size(), 1) << "Should reconstruct at least one concrete path";
293+
294+
// Verify node connectivity in reconstructed paths
295+
for (const auto& path : concrete) {
296+
ASSERT_GE(path.size(), 2);
297+
EXPECT_EQ(path.front().first, 0);
298+
EXPECT_EQ(path.back().first, 1);
299+
// Verify no repeated nodes (simple path)
300+
std::set<NodeId> visited;
301+
for (const auto& [node, edges] : path) {
302+
EXPECT_TRUE(visited.insert(node).second)
303+
<< "Node " << node << " appears twice in path (cycle)";
304+
}
305+
}
306+
}
307+
}

tests/cpp/test_utils.hpp

Lines changed: 115 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -285,17 +285,23 @@ inline void expect_csr_valid(const StrictMultiDiGraph& g) {
285285
auto row = g.row_offsets_view();
286286
auto col = g.col_indices_view();
287287
auto aei = g.adj_edge_index_view();
288+
auto edge_src = g.edge_src_view();
289+
auto edge_dst = g.edge_dst_view();
290+
const auto M = static_cast<std::size_t>(g.num_edges());
291+
const auto N = static_cast<std::size_t>(g.num_nodes());
292+
293+
// --- Forward CSR structural checks ---
288294

289295
// Offsets should be monotonic
290-
EXPECT_EQ(row.size(), static_cast<std::size_t>(g.num_nodes() + 1));
296+
EXPECT_EQ(row.size(), N + 1);
291297
for (std::size_t i = 0; i < row.size() - 1; ++i) {
292298
EXPECT_LE(row[i], row[i + 1]) << "Row offsets not monotonic at " << i;
293299
}
294300

295301
// Total edges match
296-
EXPECT_EQ(row[row.size() - 1], g.num_edges());
297-
EXPECT_EQ(col.size(), static_cast<std::size_t>(g.num_edges()));
298-
EXPECT_EQ(aei.size(), static_cast<std::size_t>(g.num_edges()));
302+
EXPECT_EQ(static_cast<std::size_t>(row[row.size() - 1]), M);
303+
EXPECT_EQ(col.size(), M);
304+
EXPECT_EQ(aei.size(), M);
299305

300306
// All column indices and edge indices in range
301307
for (std::size_t i = 0; i < col.size(); ++i) {
@@ -304,6 +310,75 @@ inline void expect_csr_valid(const StrictMultiDiGraph& g) {
304310
EXPECT_GE(aei[i], 0) << "Invalid edge index at " << i;
305311
EXPECT_LT(aei[i], g.num_edges()) << "Edge index out of range at " << i;
306312
}
313+
314+
// --- Forward CSR semantic checks ---
315+
316+
// Bijection: every edge appears exactly once in the CSR
317+
std::vector<int> edge_count(M, 0);
318+
for (std::size_t u = 0; u < N; ++u) {
319+
auto s = static_cast<std::size_t>(row[u]);
320+
auto e = static_cast<std::size_t>(row[u + 1]);
321+
for (std::size_t j = s; j < e; ++j) {
322+
auto eid = static_cast<std::size_t>(aei[j]);
323+
ASSERT_LT(eid, M) << "Edge index out of range at CSR pos " << j;
324+
edge_count[eid]++;
325+
326+
// Source consistency: edge must originate from node u
327+
EXPECT_EQ(static_cast<std::size_t>(edge_src[eid]), u)
328+
<< "Forward CSR: edge " << eid << " in node " << u
329+
<< "'s adjacency has src=" << edge_src[eid];
330+
331+
// Destination consistency: col_indices must match edge destination
332+
EXPECT_EQ(col[j], edge_dst[eid])
333+
<< "Forward CSR: col_indices[" << j << "]=" << col[j]
334+
<< " doesn't match edge_dst[" << eid << "]=" << edge_dst[eid];
335+
}
336+
}
337+
for (std::size_t eid = 0; eid < M; ++eid) {
338+
EXPECT_EQ(edge_count[eid], 1)
339+
<< "Edge " << eid << " appears " << edge_count[eid]
340+
<< " times in forward CSR (expected exactly 1)";
341+
}
342+
343+
// --- Reverse CSR checks ---
344+
auto in_row = g.in_row_offsets_view();
345+
auto in_col = g.in_col_indices_view();
346+
auto in_aei = g.in_adj_edge_index_view();
347+
348+
EXPECT_EQ(in_row.size(), N + 1);
349+
for (std::size_t i = 0; i < in_row.size() - 1; ++i) {
350+
EXPECT_LE(in_row[i], in_row[i + 1]) << "Reverse row offsets not monotonic at " << i;
351+
}
352+
EXPECT_EQ(static_cast<std::size_t>(in_row[in_row.size() - 1]), M);
353+
EXPECT_EQ(in_col.size(), M);
354+
EXPECT_EQ(in_aei.size(), M);
355+
356+
// Bijection and consistency for reverse CSR
357+
std::vector<int> rev_edge_count(M, 0);
358+
for (std::size_t v = 0; v < N; ++v) {
359+
auto s = static_cast<std::size_t>(in_row[v]);
360+
auto e = static_cast<std::size_t>(in_row[v + 1]);
361+
for (std::size_t j = s; j < e; ++j) {
362+
auto eid = static_cast<std::size_t>(in_aei[j]);
363+
ASSERT_LT(eid, M) << "Reverse edge index out of range at CSR pos " << j;
364+
rev_edge_count[eid]++;
365+
366+
// Destination consistency: edge must point to node v
367+
EXPECT_EQ(static_cast<std::size_t>(edge_dst[eid]), v)
368+
<< "Reverse CSR: edge " << eid << " in node " << v
369+
<< "'s incoming list has dst=" << edge_dst[eid];
370+
371+
// Source consistency: in_col_indices must match edge source
372+
EXPECT_EQ(in_col[j], edge_src[eid])
373+
<< "Reverse CSR: in_col_indices[" << j << "]=" << in_col[j]
374+
<< " doesn't match edge_src[" << eid << "]=" << edge_src[eid];
375+
}
376+
}
377+
for (std::size_t eid = 0; eid < M; ++eid) {
378+
EXPECT_EQ(rev_edge_count[eid], 1)
379+
<< "Edge " << eid << " appears " << rev_edge_count[eid]
380+
<< " times in reverse CSR (expected exactly 1)";
381+
}
307382
}
308383

309384
inline void expect_pred_dag_valid(const PredDAG& dag, int num_nodes) {
@@ -325,6 +400,42 @@ inline void expect_pred_dag_valid(const PredDAG& dag, int num_nodes) {
325400
}
326401
}
327402

403+
// Extended PredDAG validation that also checks semantic correctness against a graph.
404+
// Verifies that each entry refers to an actual edge from parent to node, and that
405+
// distances are consistent (dist[v] == dist[parent] + edge_cost).
406+
inline void expect_pred_dag_semantically_valid(const StrictMultiDiGraph& g,
407+
const PredDAG& dag,
408+
const std::vector<Cost>& dist) {
409+
const auto N = g.num_nodes();
410+
const auto edge_src = g.edge_src_view();
411+
const auto edge_dst = g.edge_dst_view();
412+
const auto edge_cost = g.cost_view();
413+
414+
expect_pred_dag_valid(dag, N);
415+
416+
for (std::int32_t v = 0; v < N; ++v) {
417+
auto s = static_cast<std::size_t>(dag.parent_offsets[static_cast<std::size_t>(v)]);
418+
auto e = static_cast<std::size_t>(dag.parent_offsets[static_cast<std::size_t>(v) + 1]);
419+
for (std::size_t i = s; i < e; ++i) {
420+
auto parent = dag.parents[i];
421+
auto eid = static_cast<std::size_t>(dag.via_edges[i]);
422+
ASSERT_LT(eid, static_cast<std::size_t>(g.num_edges()));
423+
// Edge must go from parent -> v
424+
EXPECT_EQ(edge_src[eid], parent)
425+
<< "Node " << v << ": via_edge " << eid << " src mismatch";
426+
EXPECT_EQ(edge_dst[eid], v)
427+
<< "Node " << v << ": via_edge " << eid << " dst mismatch";
428+
// Distance consistency
429+
auto d_v = dist[static_cast<std::size_t>(v)];
430+
auto d_p = dist[static_cast<std::size_t>(parent)];
431+
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+
<< "Distance inconsistency at node " << v;
434+
}
435+
}
436+
}
437+
}
438+
328439
inline void expect_flow_conservation(const FlowGraph& fg, NodeId src, NodeId dst) {
329440
const auto& g = fg.graph();
330441
auto row = g.row_offsets_view();

tests/py/conftest.py

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,19 @@ def _to_dict(summary) -> dict[float, float]:
7777

7878
@pytest.fixture
7979
def assert_pred_dag_integrity():
80-
"""Return an assertion helper to validate PredDAG shapes and id ranges."""
80+
"""Return an assertion helper to validate PredDAG shapes, id ranges, and semantics.
8181
82-
def _assert(g: ngc.StrictMultiDiGraph, dag: ngc.PredDAG) -> None:
82+
Checks structural validity (sizes, ranges, monotonicity) AND semantic
83+
correctness: each parent entry must correspond to an actual edge in the
84+
graph from the claimed parent to the node, and if distances are provided,
85+
the distance must be consistent (dist[v] == dist[parent] + edge_cost).
86+
"""
87+
88+
def _assert(
89+
g: ngc.StrictMultiDiGraph,
90+
dag: ngc.PredDAG,
91+
dist: np.ndarray | None = None,
92+
) -> None:
8393
off = np.asarray(dag.parent_offsets)
8494
par = np.asarray(dag.parents)
8595
via = np.asarray(dag.via_edges)
@@ -95,6 +105,36 @@ def _assert(g: ngc.StrictMultiDiGraph, dag: ngc.PredDAG) -> None:
95105
assert int(par.min()) >= 0 and int(par.max()) < g.num_nodes()
96106
assert int(via.min()) >= 0 and int(via.max()) < g.num_edges()
97107

108+
# Semantic validation: each entry must reference an actual graph edge
109+
edge_src = np.asarray(g.edge_src_view())
110+
edge_dst = np.asarray(g.edge_dst_view())
111+
edge_cost = np.asarray(g.cost_view())
112+
for v in range(g.num_nodes()):
113+
s, e = int(off[v]), int(off[v + 1])
114+
for i in range(s, e):
115+
parent = int(par[i])
116+
eid = int(via[i])
117+
# The via_edge must go from parent -> v
118+
assert int(edge_src[eid]) == parent, (
119+
f"PredDAG entry for node {v}: via_edge {eid} has src="
120+
f"{int(edge_src[eid])}, expected parent={parent}"
121+
)
122+
assert int(edge_dst[eid]) == v, (
123+
f"PredDAG entry for node {v}: via_edge {eid} has dst="
124+
f"{int(edge_dst[eid])}, expected node={v}"
125+
)
126+
# Distance consistency (if distances provided)
127+
if dist is not None:
128+
d_v = float(dist[v])
129+
d_p = float(dist[parent])
130+
ec = float(edge_cost[eid])
131+
if d_v < 1e18 and d_p < 1e18: # skip unreachable
132+
assert abs(d_v - (d_p + ec)) < 1e-9, (
133+
f"PredDAG distance inconsistency at node {v}: "
134+
f"dist[{v}]={d_v} != dist[{parent}]={d_p} + "
135+
f"cost({eid})={ec} = {d_p + ec}"
136+
)
137+
98138
return _assert
99139

100140

0 commit comments

Comments
 (0)