@@ -149,21 +149,23 @@ resolve_to_paths(const PredDAG& dag, NodeId src, NodeId dst,
149149} // namespace netgraph::core
150150
151151/*
152- shortest_paths — Dijkstra over a StrictMultiDiGraph with flexible selection .
152+ Dijkstra shortest path algorithm with capacity-aware tie-breaking .
153153
154154 Features:
155- - Optional residual-aware traversal (treat residual as capacity gate).
156- - Multipath mode collects all equal-cost predecessor edges per node.
157- - Single-path mode chooses one best edge per neighbor group with
158- deterministic tie-breaking (or by higher residual if requested).
159- - Optional early exit when a specific destination is provided.
155+ - Residual-aware traversal: uses dynamic residuals if provided, otherwise static capacity
156+ - Multipath mode: collects all equal-cost predecessor edges per node
157+ - Single-path mode: selects one edge per adjacency using:
158+ * Edge-level tie-breaking for parallel edges (PreferHigherResidual or Deterministic)
159+ * Node-level tie-breaking for equal-cost nodes (prefers higher bottleneck capacity)
160+ - Early exit when specific destination is reached
160161*/
161162#include " netgraph/core/shortest_paths.hpp"
162163#include < cmath>
163164#include < cstdint>
164165#include < limits>
165166#include < stdexcept>
166167#include < queue>
168+ #include < tuple>
167169#include < utility>
168170#include < vector>
169171#include " netgraph/core/constants.hpp"
@@ -188,11 +190,17 @@ shortest_paths_core(const StrictMultiDiGraph& g, NodeId src,
188190
189191 // Initialize distance array to infinity (max value).
190192 std::vector<Cost> dist (static_cast <std::size_t >(N), std::numeric_limits<Cost>::max ());
193+
194+ // Track minimum residual capacity along the path to each node (for node-level tie-breaking).
195+ // When distances are equal, prefer paths with higher bottleneck capacity.
196+ std::vector<Cap> min_residual_to_node (static_cast <std::size_t >(N), static_cast <Cap>(0 ));
197+
191198 const bool use_node_mask = (node_mask.size () == static_cast <std::size_t >(g.num_nodes ()));
192199 const bool use_edge_mask = (edge_mask.size () == static_cast <std::size_t >(g.num_edges ()));
193200 const bool src_allowed = (src >= 0 && src < N && (!use_node_mask || node_mask[static_cast <std::size_t >(src)]));
194201 if (src_allowed) {
195202 dist[static_cast <std::size_t >(src)] = static_cast <Cost>(0 );
203+ min_residual_to_node[static_cast <std::size_t >(src)] = std::numeric_limits<Cap>::max ();
196204 }
197205
198206 // pred_lists[v] stores predecessors for node v as (parent_node, [edges_from_parent]).
@@ -207,12 +215,14 @@ shortest_paths_core(const StrictMultiDiGraph& g, NodeId src,
207215 return {std::move (dist), std::move (dag)};
208216 }
209217
210- // Priority queue for Dijkstra (min-heap by cost).
211- // QItem is (cost, node). Lambda comparator inverts comparison for min-heap.
212- using QItem = std::pair<Cost, NodeId>;
213- auto cmp = [](const QItem& a, const QItem& b) { return a.first > b.first ; };
218+ // Priority queue for Dijkstra with capacity-aware node-level tie-breaking.
219+ // QItem is (cost, -residual, node). Negated residual ensures higher capacity = higher priority.
220+ // Lexicographic ordering: cost (minimize) -> residual (maximize) -> node (deterministic).
221+ // This naturally distributes flows across equal-cost paths based on available capacity.
222+ using QItem = std::tuple<Cost, Cap, NodeId>;
223+ auto cmp = [](const QItem& a, const QItem& b) { return a > b; };
214224 std::priority_queue<QItem, std::vector<QItem>, decltype (cmp)> pq (cmp);
215- pq.emplace (static_cast <Cost>(0 ), src);
225+ pq.emplace (static_cast <Cost>(0 ), -std::numeric_limits<Cap>:: max (), src);
216226 Cost best_dst_cost = std::numeric_limits<Cost>::max ();
217227 bool have_best_dst = false ;
218228 const bool early_exit = dst.has_value ();
@@ -224,16 +234,17 @@ shortest_paths_core(const StrictMultiDiGraph& g, NodeId src,
224234
225235 while (!pq.empty ()) {
226236 // Extract min-cost node from priority queue.
227- // Structured binding: auto [d_u, u] = ... destructures the pair.
228- auto [d_u, u] = pq.top (); pq.pop ();
237+ // Structured binding: auto [d_u, neg_res_u, u] = ... destructures the tuple.
238+ auto [d_u, neg_res_u, u] = pq.top (); pq.pop ();
239+ (void )neg_res_u; // Residual was only for tie-breaking in queue, not needed here
229240 if (u < 0 || u >= N) continue ;
230241 // Skip stale entries (node already processed at a lower cost).
231242 if (d_u > dist[static_cast <std::size_t >(u)]) continue ;
232243
233244 // Early exit optimization: record when we first reach destination.
234245 if (early_exit && u == dst_node && !have_best_dst) { best_dst_cost = d_u; have_best_dst = true ; }
235246 if (early_exit && u == dst_node) {
236- if (pq.empty () || pq.top (). first > best_dst_cost) break ; else continue ;
247+ if (pq.empty () || std::get< 0 >( pq.top ()) > best_dst_cost) break ; else continue ;
237248 }
238249
239250 // Iterate over u's outgoing edges using CSR row offsets.
@@ -304,21 +315,36 @@ shortest_paths_core(const StrictMultiDiGraph& g, NodeId src,
304315 if (!selected_edges.empty ()) {
305316 Cost new_cost = static_cast <Cost>(d_u + min_edge_cost);
306317 auto v_idx = static_cast <std::size_t >(v);
318+
319+ // Compute bottleneck capacity along path to v through u for node-level tie-breaking.
320+ // Uses dynamic residuals if provided, otherwise static capacity.
321+ // This allows tie-breaking by capacity even in cost-only routing mode.
322+ Cap max_edge_residual = static_cast <Cap>(0 );
323+ for (auto edge_id : selected_edges) {
324+ const Cap rem = has_residual ? residual[static_cast <std::size_t >(edge_id)]
325+ : cap[static_cast <std::size_t >(edge_id)];
326+ if (rem > max_edge_residual) max_edge_residual = rem;
327+ }
328+ Cap path_residual = std::min (min_residual_to_node[static_cast <std::size_t >(u)], max_edge_residual);
329+
307330 // Relaxation: found shorter path to v.
308331 if (new_cost < dist[v_idx]) {
309332 dist[v_idx] = new_cost;
333+ min_residual_to_node[v_idx] = path_residual;
310334 pred_lists[v_idx].clear ();
311335 pred_lists[v_idx].push_back ({u, std::move (selected_edges)});
312- pq.emplace (new_cost, v);
336+ pq.emplace (new_cost, -path_residual, v); // Negate residual for max-heap behavior
313337 }
314338 // Multipath: found equal-cost alternative path to v.
315339 else if (multipath && new_cost == dist[v_idx]) {
316340 pred_lists[v_idx].push_back ({u, std::move (selected_edges)});
341+ // Note: In multipath mode, we don't update min_residual_to_node because
342+ // we're collecting all equal-cost paths, not choosing based on residual.
317343 }
318344 }
319345 i = j; // Advance to next neighbor group
320346 }
321- if (have_best_dst) { if (pq.empty () || pq.top (). first > best_dst_cost) break ; }
347+ if (have_best_dst) { if (pq.empty () || std::get< 0 >( pq.top ()) > best_dst_cost) break ; }
322348 }
323349
324350 // Convert pred_lists to PredDAG using CSR-like layout.
0 commit comments