Skip to content

Commit 7a0e071

Browse files
committed
fixed LSP semantic and implementation
1 parent c90edaa commit 7a0e071

15 files changed

Lines changed: 2824 additions & 68 deletions

bindings/python/module.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
498498
FlowPlacement flow_placement,
499499
EdgeSelection selection,
500500
bool require_capacity,
501+
bool multipath,
501502
int min_flow_count,
502503
py::object max_flow_count,
503504
py::object max_path_cost,
@@ -514,6 +515,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
514515
cfg.flow_placement = flow_placement;
515516
cfg.selection = selection;
516517
cfg.require_capacity = require_capacity;
518+
cfg.multipath = multipath;
517519
cfg.min_flow_count = min_flow_count;
518520
if (!max_flow_count.is_none()) cfg.max_flow_count = py::cast<int>(max_flow_count);
519521
if (!max_path_cost.is_none()) cfg.max_path_cost = py::cast<Cost>(max_path_cost);
@@ -532,6 +534,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
532534
py::arg("flow_placement") = FlowPlacement::Proportional,
533535
py::arg("selection") = EdgeSelection{},
534536
py::arg("require_capacity") = true,
537+
py::arg("multipath") = true,
535538
py::arg("min_flow_count") = 1,
536539
py::arg("max_flow_count") = py::none(),
537540
py::arg("max_path_cost") = py::none(),
@@ -547,6 +550,7 @@ PYBIND11_MODULE(_netgraph_core, m) {
547550
.def_readwrite("flow_placement", &FlowPolicyConfig::flow_placement)
548551
.def_readwrite("selection", &FlowPolicyConfig::selection)
549552
.def_readwrite("require_capacity", &FlowPolicyConfig::require_capacity)
553+
.def_readwrite("multipath", &FlowPolicyConfig::multipath)
550554
.def_readwrite("min_flow_count", &FlowPolicyConfig::min_flow_count)
551555
.def_readwrite("max_flow_count", &FlowPolicyConfig::max_flow_count)
552556
.def_readwrite("max_path_cost", &FlowPolicyConfig::max_path_cost)

include/netgraph/core/flow_policy.hpp

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ struct FlowPolicyConfig {
3636
EdgeSelection selection { EdgeSelection{} };
3737
bool require_capacity { true }; // Require edges to have capacity (software-defined/traffic engineering).
3838
// Set false for cost-only routing (traditional IP/IGP shortest-paths).
39+
bool multipath { true }; // Enable individual flows to split across multiple equal-cost paths.
40+
// When true: each flow uses a DAG containing all equal-cost paths (hash-based ECMP).
41+
// When false: each flow uses a single path (tunnel-based ECMP, MPLS LSP semantics).
3942
int min_flow_count { 1 };
4043
std::optional<int> max_flow_count { std::nullopt };
4144
std::optional<Cost> max_path_cost { std::nullopt };
@@ -58,7 +61,7 @@ class FlowPolicy {
5861
FlowPolicy(const ExecutionContext& ctx, const FlowPolicyConfig& cfg)
5962
: ctx_(ctx),
6063
path_alg_(cfg.path_alg), flow_placement_(cfg.flow_placement), selection_(cfg.selection),
61-
require_capacity_(cfg.require_capacity), shortest_path_(cfg.shortest_path),
64+
require_capacity_(cfg.require_capacity), multipath_(cfg.multipath), shortest_path_(cfg.shortest_path),
6265
min_flow_count_(cfg.min_flow_count), max_flow_count_(cfg.max_flow_count), max_path_cost_(cfg.max_path_cost),
6366
max_path_cost_factor_(cfg.max_path_cost_factor), reoptimize_flows_on_each_placement_(cfg.reoptimize_flows_on_each_placement),
6467
max_no_progress_iterations_(cfg.max_no_progress_iterations), max_total_iterations_(cfg.max_total_iterations),
@@ -94,6 +97,7 @@ class FlowPolicy {
9497
FlowPlacement flow_placement,
9598
EdgeSelection selection,
9699
bool require_capacity = true,
100+
bool multipath = true,
97101
int min_flow_count = 1,
98102
std::optional<int> max_flow_count = std::nullopt,
99103
std::optional<Cost> max_path_cost = std::nullopt,
@@ -107,7 +111,7 @@ class FlowPolicy {
107111
double diminishing_returns_epsilon_frac = 1e-3)
108112
: ctx_(ctx),
109113
path_alg_(path_alg), flow_placement_(flow_placement), selection_(selection),
110-
require_capacity_(require_capacity), shortest_path_(shortest_path),
114+
require_capacity_(require_capacity), multipath_(multipath), shortest_path_(shortest_path),
111115
min_flow_count_(min_flow_count), max_flow_count_(max_flow_count), max_path_cost_(max_path_cost),
112116
max_path_cost_factor_(max_path_cost_factor), reoptimize_flows_on_each_placement_(reoptimize_flows_on_each_placement),
113117
max_no_progress_iterations_(max_no_progress_iterations), max_total_iterations_(max_total_iterations),
@@ -154,6 +158,7 @@ class FlowPolicy {
154158
FlowPlacement flow_placement_ { FlowPlacement::Proportional };
155159
EdgeSelection selection_ { EdgeSelection{} };
156160
bool require_capacity_ { true };
161+
bool multipath_ { true };
157162
bool shortest_path_ { false };
158163
int min_flow_count_ { 1 };
159164
std::optional<int> max_flow_count_ {};

python/netgraph_core/_docs.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,11 +266,23 @@ class FlowIndex:
266266

267267

268268
class FlowPolicyConfig:
269-
"""Configuration for FlowPolicy behavior."""
269+
"""Configuration for FlowPolicy behavior.
270+
271+
Key Parameters:
272+
multipath: Controls whether individual flows split across multiple equal-cost paths.
273+
- True (default): Hash-based ECMP - each flow uses a DAG with ALL equal-cost paths.
274+
Flow volume is split across these paths according to flow_placement strategy.
275+
This models router ECMP behavior where packets are hashed across next-hops.
276+
- False: Tunnel-based ECMP - each flow uses a SINGLE path (one tunnel/LSP).
277+
Multiple flows can share the same path. This models MPLS LSP semantics where
278+
each LSP follows one specific path, and ECMP means balancing ACROSS LSPs.
279+
"""
270280

271281
path_alg: PathAlg
272282
flow_placement: FlowPlacement
273283
selection: EdgeSelection
284+
require_capacity: bool
285+
multipath: bool
274286
min_flow_count: int
275287
max_flow_count: Optional[int]
276288
max_path_cost: Optional[int]

src/flow_policy.cpp

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,18 @@ std::optional<std::pair<PredDAG, Cost>> FlowPolicy::get_path_bundle(const FlowGr
5050

5151
// Use configured selection for per-adjacency edge behavior (multi-edge, tie-breaking).
5252
EdgeSelection sel = selection_;
53-
// For EqualBalanced, ensure we consider the full equal-cost next-hop set.
54-
if (flow_placement_ == FlowPlacement::EqualBalanced) {
53+
54+
// Enforce semantic consistency between multipath and multi_edge:
55+
// - Tunnel mode (multipath=false): force single edge per hop for true single-path semantics
56+
// - Hash-ECMP with EqualBalanced: use all equal-cost edges to maximize fanout
57+
if (!multipath_) {
58+
sel.multi_edge = false;
59+
} else if (flow_placement_ == FlowPlacement::EqualBalanced) {
5560
sel.multi_edge = true;
5661
}
62+
63+
// Respect capacity requirements from both config sources
64+
sel.require_capacity = (selection_.require_capacity || require_capacity_);
5765
// Decide whether we need residual-aware SPF.
5866
// Residual awareness is controlled by require_capacity_:
5967
// - require_capacity=true: Require edges to have capacity, routes adapt to residuals (SDN/TE behavior)
@@ -96,7 +104,7 @@ std::optional<std::pair<PredDAG, Cost>> FlowPolicy::get_path_bundle(const FlowGr
96104
// Set require_capacity directly (no casting needed - same field name)
97105
sel.require_capacity = require_capacity_;
98106
SpfOptions opts;
99-
opts.multipath = true;
107+
opts.multipath = multipath_; // Use configured multipath value (enables/disables flow splitting across equal-cost paths)
100108
opts.selection = sel;
101109
opts.dst = dst;
102110
opts.residual = require_residual ? residual : std::span<const Cap>();
@@ -278,14 +286,11 @@ std::pair<double,double> FlowPolicy::place_demand(FlowGraph& fg,
278286
if (no_progress>=max_no_progress_iterations_) break;
279287
continue;
280288
}
281-
// Proceed even if selection is not explicitly capacity-aware; placement
282-
// uses residuals, and DAG is refreshed with per-flow targets.
283-
//
284-
// EB note: we refresh the DAG each round using residual-aware SPF.
285-
// This prunes saturated next-hops and *changes the equal-split set*
286-
// (progressive behavior). This is useful for TE-like reoptimization,
287-
// but it is not the one-shot ECMP admission semantics. Gate or disable
288-
// this if strict single-pass ECMP on the initial DAG is required.
289+
// Refresh DAG based on current residuals for dynamic path selection.
290+
// This prunes saturated next-hops and updates path selection.
291+
// For multipath flows, this tracks saturated edges within the DAG.
292+
// For tunnel flows, this allows different tunnels to discover different paths
293+
// as residuals change, enabling natural fan-out across equal-cost paths.
289294
if (flow_placement_ == FlowPlacement::EqualBalanced && static_paths_.empty()) {
290295
if (auto pb = get_path_bundle(fg, f->src, f->dst, std::optional<double>(per_target))) {
291296
f->dag = std::move(pb->first);
@@ -352,6 +357,13 @@ std::pair<double,double> FlowPolicy::place_demand(FlowGraph& fg,
352357
if (iters >= max_total_iterations_) break;
353358
}
354359

360+
// Reoptimize all flows after placement if enabled
361+
if (reoptimize_flows_on_each_placement_) {
362+
for (auto& kv : flows_) {
363+
(void)reoptimize_flow(fg, kv.first, kMinFlow);
364+
}
365+
}
366+
355367
// For EQUAL_BALANCED placement, rebalance flows to maintain equal volumes.
356368
if (flow_placement_ == FlowPlacement::EqualBalanced && !flows_.empty()) {
357369
double target_eq = placed_demand() / static_cast<double>(flows_.size());

src/shortest_paths.cpp

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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.

tests/cpp/flow_policy_tests.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ TEST(FlowPolicyCore, Proportional_SingleDemand_MaxFlowCount1_UsesSinglePath) {
131131
auto be = make_cpu_backend(); auto algs = std::make_shared<Algorithms>(be); auto gh = algs->build_graph(g);
132132
ExecutionContext ctx(algs, gh);
133133
FlowPolicy policy(ctx, PathAlg::SPF, FlowPlacement::Proportional, sel,
134-
/*require_capacity=*/true, /*min_flow_count=*/1, /*max_flow_count=*/1);
134+
/*require_capacity=*/true, /*multipath=*/false, /*min_flow_count=*/1, /*max_flow_count=*/1);
135135
auto res = policy.place_demand(fg, 0, 2, 0, 2.0);
136136
EXPECT_NEAR(res.first, 2.0, 1e-9);
137137
EXPECT_NEAR(res.second, 0.0, 1e-9);

0 commit comments

Comments
 (0)