Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.10.0] - 2026-09-13

### Fixed

- **Flow Placement**: `EQUAL_BALANCED` built its split set from the DAG edges that still had residual, so re-placing on a DAG that contained a member filled by an earlier placement silently re-hashed over the remaining members. A forwarding table does not react to load: the split set is now every DAG edge with capacity, and a full member drives the single admission scale to 0, so nothing more is admitted on that DAG. Progression past a full member comes from recomputing the DAG with a residual-aware SPF, which `place_max_flow` and `FlowPolicy` already do, so every documented path is unchanged. The one observable change is `place_max_flow` with `require_capacity=false` and `shortest_path=false`, which filled a cost-only DAG in several passes and now places once, the single-pass answer that cost-only routing is documented to give.

### Removed

- **BREAKING**: `EQUAL_BALANCED_FIXED`, added in 0.9.0 as a separate mode with the behaviour above. `EQUAL_BALANCED` now has that behaviour, so the value is gone; `EQUAL_BALANCED_LOSSY` is now value 3.

## [0.9.0] - 2026-09-13

### Added
Expand Down
1 change: 0 additions & 1 deletion bindings/python/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) {
py::enum_<FlowPlacement>(m, "FlowPlacement")
.value("PROPORTIONAL", FlowPlacement::Proportional)
.value("EQUAL_BALANCED", FlowPlacement::EqualBalanced)
.value("EQUAL_BALANCED_FIXED", FlowPlacement::EqualBalancedFixed)
.value("EQUAL_BALANCED_LOSSY", FlowPlacement::EqualBalancedLossy);

py::class_<StrictMultiDiGraph>(m, "StrictMultiDiGraph")
Expand Down
6 changes: 3 additions & 3 deletions include/netgraph/core/flow_policy.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,9 @@ class FlowPolicy {
//
// With static paths configured the policy neither creates additional flows nor
// reoptimizes: max_path_cost, max_path_cost_factor, min_flow_count and
// reoptimize_flows_on_each_placement are inert. EqualBalanced and
// EqualBalancedFixed spread over the usable (up) bundles only and equalize the
// carried volume per bundle; EqualBalancedLossy offers each up bundle an equal
// reoptimize_flows_on_each_placement are inert. EqualBalanced spreads over the
// usable (up) bundles only and equalizes the carried volume per bundle;
// EqualBalancedLossy offers each up bundle an equal
// share and lets it carry what fits (no equalizing rebalance), so placed is the
// delivered total. flow_count() reports the usable count U; the supplied count
// N is the caller's, so down LSPs = N - flow_count().
Expand Down
12 changes: 4 additions & 8 deletions include/netgraph/core/flow_state.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,10 @@ class FlowState {
// where cap_rev[g] = min_edge_residual(g) * |E_g|
// (enforces equal per-edge shares),
// assigned[g] = unit-demand load on group g under equal splits.
// - Place once and return. We do NOT re-split/recompute after a bottleneck
// saturates. Re-invoking this on the updated residuals changes the effective
// next-hop set (progressive traffic-engineering behavior) and is outside
// "single-pass ECMP admission".
//
// EqualBalancedFixed uses the same global scale but takes the split set from
// the DAG edges with capacity rather than with residual, so a member that has
// been saturated since the DAG was built yields scale 0 and nothing is placed.
// - Place once and return. The split set is every DAG edge with capacity,
// so a member filled since the DAG was built yields scale 0 and nothing
// is placed; progression past a full member comes from recomputing the
// DAG with a residual-aware SPF, not from re-invoking this on a stale one.
//
// EqualBalancedLossy splits over the same capacity-based set without scaling:
// each edge carries min(share, residual), the excess is dropped, and the
Expand Down
31 changes: 13 additions & 18 deletions include/netgraph/core/types.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -57,27 +57,22 @@ struct FlowIndexHash {
//
// - Proportional may be used iteratively (e.g., for max-flow).
//
// - EqualBalanced builds its split set from the DAG edges that currently have
// residual, so re-invoking it on updated residuals shrinks the next-hop set
// (progressive behavior, used by place_max_flow and FlowPolicy fills).
// - EqualBalanced takes its split set from the DAG edges that have *capacity*
// (the topology's next-hop set) and admits with one global scale computed
// from residual headroom. A member filled since the DAG was built therefore
// blocks admission (scale 0): a forwarding table does not react to load.
// Callers that want to progress past a full member recompute the DAG with a
// residual-aware SPF, as place_max_flow and FlowPolicy do.
//
// - EqualBalancedFixed builds its split set from the DAG edges that have
// *capacity* (the topology's next-hop set), and admits with the same single
// global scale computed from residual headroom. A member saturated since the
// DAG was built therefore blocks admission entirely (scale 0): this is
// lossless hash-ECMP admission with a forwarding table that does not react
// to load.
//
// - EqualBalancedLossy also splits over the topology's next-hop set, but does
// not scale: every edge carries min(share, residual) and the excess is
// dropped, deficits propagate downstream, and the placed amount is the volume
// that reaches dst. This is best-effort hash-ECMP forwarding; the dropped
// share per edge is available through the drop trace of place_on_dag.
// - EqualBalancedLossy splits over the same next-hop set without a scale:
// every edge carries min(share, residual) and the excess is dropped,
// deficits propagate downstream, and the placed amount is the volume that
// reaches dst. This is best-effort hash-ECMP forwarding; the dropped share
// per edge is available through the drop trace of place_on_dag.
enum class FlowPlacement {
Proportional = 1, // Distribute flow proportionally to residual capacity (like ECMP with weights)
EqualBalanced = 2, // Split equally per parallel edge on a fixed DAG (single-pass ECMP admission)
EqualBalancedFixed = 3, // Equal split over the topology next-hop set; a saturated member blocks admission
EqualBalancedLossy = 4 // Equal split over the topology next-hop set; excess over residual is dropped
EqualBalanced = 2, // Equal split over the topology next-hop set, one lossless admission scale
EqualBalancedLossy = 3 // Equal split over the topology next-hop set; excess over residual is dropped
};

// Tie-breaking rule when multiple equal-cost edges exist between the same (u,v) pair.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build"

[project]
name = "netgraph-core"
version = "0.9.0"
version = "0.10.0"
description = "C++ implementation of graph algorithms for network flow analysis and traffic engineering with Python bindings"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
25 changes: 10 additions & 15 deletions python/netgraph_core/_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,18 +55,14 @@ class FlowPlacement:
May be used iteratively (e.g., for max-flow).

EQUAL_BALANCED (ECMP): Single-pass admission on a fixed shortest-path DAG (Dijkstra).
Computes one global scale so no edge is oversubscribed under equal
per-edge splits, places once, and stops. The split set is the DAG's
edges that still have residual, so re-invoking on updated residuals
changes the next-hop set (progressive traffic-engineering behavior).
The split set is the DAG's edges with capacity (the topology's next-hop
set). Computes one global scale so no edge is oversubscribed under
equal per-edge splits, places once, and stops; a member filled since
the DAG was computed drives the scale to 0, because a forwarding table
does not react to load. Progress past a full member by recomputing the
DAG with a residual-aware SPF (place_max_flow and FlowPolicy do).
ECMP = Equal-Cost Multi-Path; WCMP = Weighted-Cost Multi-Path.

EQUAL_BALANCED_FIXED: The same single-pass admission, but the split set is
the DAG's edges with capacity (the topology's next-hop set), so a
member saturated since the DAG was computed drives the scale to 0 and
nothing is admitted. Models lossless hash-ECMP with a forwarding table
that does not react to load.

EQUAL_BALANCED_LOSSY: Equal split over the same capacity-based set with no
scaling: each edge carries min(share, residual) and drops the excess,
deficits propagate downstream, and the placed amount is what reaches
Expand All @@ -76,7 +72,6 @@ class FlowPlacement:

PROPORTIONAL: ClassVar[FlowPlacement]
EQUAL_BALANCED: ClassVar[FlowPlacement]
EQUAL_BALANCED_FIXED: ClassVar[FlowPlacement]
EQUAL_BALANCED_LOSSY: ClassVar[FlowPlacement]
__members__: ClassVar[dict[str, FlowPlacement]]

Expand Down Expand Up @@ -281,10 +276,10 @@ def place_on_dag(

EqualBalanced is **single-pass ECMP admission** on the provided DAG:
we compute one global scale so no edge is oversubscribed under equal per-edge
splits, apply it once, and return. Re-invoking on updated residuals changes
the next-hop set (progressive behavior). EQUAL_BALANCED_FIXED keeps the
topology's next-hop set instead (a saturated member yields scale 0), and
EQUAL_BALANCED_LOSSY forwards best-effort over it (see FlowPlacement).
splits, apply it once, and return. The split set is the topology's
next-hop set, so a member already full yields scale 0; progress past it
by recomputing the DAG with residuals. EQUAL_BALANCED_LOSSY forwards
best-effort over the same set (see FlowPlacement).

Returns:
Amount of flow actually placed (may be less than requested).
Expand Down
3 changes: 1 addition & 2 deletions src/flow_policy.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ namespace {
// refresh logic (and, except for the lossy mode, the equalizing rebalance in
// place_demand); they differ only inside FlowState::place_on_dag.
constexpr bool is_equal_balanced(FlowPlacement p) noexcept {
return p == FlowPlacement::EqualBalanced || p == FlowPlacement::EqualBalancedFixed ||
p == FlowPlacement::EqualBalancedLossy;
return p == FlowPlacement::EqualBalanced || p == FlowPlacement::EqualBalancedLossy;
}
} // namespace

Expand Down
53 changes: 22 additions & 31 deletions src/flow_state.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,15 @@
FlowState — residual capacities and placement over a fixed graph.

Maintains per-edge residual capacity and cumulative edge flows. Supports
four placement strategies when pushing flow along an SPF DAG:
three placement strategies when pushing flow along an SPF DAG:
- Proportional: distribute flow proportionally to residual capacity,
processing nodes in topological order from source to destination.
- EqualBalanced: distribute flow equally across available parallel edges,
*single-pass ECMP admission* on a fixed DAG. We compute a single global
scale so no edge is oversubscribed, then return. Re-running on updated
residuals intentionally changes the allowed next-hop set (progressive/TE);
use place_max_flow() if you want that behavior.
- EqualBalancedFixed: the same single-pass admission, but the split set is
the DAG's edges with capacity (the topology's next-hop set) rather than
its edges with residual. A member saturated since the DAG was built
yields scale 0: lossless hash-ECMP admission with a load-blind
forwarding table.
- EqualBalanced: *single-pass ECMP admission* on a fixed DAG. The split set
is the DAG's edges with capacity (the topology's next-hop set); we
compute a single global scale so no edge is oversubscribed, then return.
A member filled since the DAG was built yields scale 0: a forwarding
table does not react to load. place_max_flow() progresses by recomputing
the DAG with a residual-aware SPF.
- EqualBalancedLossy: equal split over the same capacity-based set with no
scaling; each edge carries min(share, residual) and drops the excess,
deficits propagate downstream, and the placed amount is what reaches
Expand Down Expand Up @@ -132,12 +128,12 @@ struct GroupSet {
};

// Build grouped edges by (parent u, child v) that can reach destination t.
// Membership is decided by residual (edges that can still carry flow) unless
// members_by_capacity is set, in which case every DAG edge with capacity is a
// member and sum_cap/min_cap still reflect the current residual -- so a member
// saturated since the DAG was built contributes min_cap = 0. The fixed and
// lossy equal-balanced placements use the latter to model a forwarding table
// that does not react to load.
// Membership is decided by residual (edges that can still carry flow) for
// Proportional placement, or by capacity when members_by_capacity is set: every
// DAG edge with capacity is a member and sum_cap/min_cap still reflect the
// current residual, so a member filled since the DAG was built contributes
// min_cap = 0. The equal-balanced placements use the latter to model a
// forwarding table that does not react to load.
static void build_groups_residual(const StrictMultiDiGraph& g,
const PredDAG& dag, NodeId t,
const std::vector<Cap>& residual,
Expand Down Expand Up @@ -264,11 +260,9 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag,
const auto N = g_->num_nodes();
if (src < 0 || src >= N || dst < 0 || dst >= N || src == dst) return 0.0;

// The fixed and lossy equal-balanced placements model a forwarding table
// that does not react to load: their split set is every DAG edge with
// capacity, saturated or not.
const bool fixed_members = (placement == FlowPlacement::EqualBalancedFixed ||
placement == FlowPlacement::EqualBalancedLossy);
// The equal-balanced placements model a forwarding table that does not
// react to load: their split set is every DAG edge with capacity, full or not.
const bool fixed_members = (placement != FlowPlacement::Proportional);

// Build groups using current residual. `gs` is reused across rebuilds so its
// buffers keep their capacity for the whole call.
Expand Down Expand Up @@ -399,11 +393,10 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag,
}
placed = static_cast<Flow>(inflow[static_cast<std::size_t>(dst)]);
} else {
// EqualBalanced / EqualBalancedFixed placement: split flow equally across
// parallel edges, with topological accumulation to correctly handle
// reconvergent DAGs. The two differ only in the split set: EqualBalanced
// keeps the groups that still have headroom, EqualBalancedFixed keeps every
// group with capacity so that a saturated member drives the scale to 0.
// EqualBalanced placement: split flow equally across parallel edges, with
// topological accumulation to correctly handle reconvergent DAGs. Every
// group with capacity stays in the split, so a full member drives the
// global scale to 0.

// Build forward adjacency from parent u to child v for each group and
// compute aggregated reverse capacities per group.
Expand All @@ -414,10 +407,8 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag,
if (gr.eid_count == 0) continue;
// EB: group admissible total = min_edge_residual * |edges|
const double cap_rev = static_cast<double>(gr.min_cap) * static_cast<double>(gr.eid_count);
if (cap_rev >= kMinCap || fixed_members) {
succ[static_cast<std::size_t>(gr.to)].push_back(gi); // u -> v (group index)
rev_cap[gi] = cap_rev >= kMinCap ? cap_rev : 0.0;
}
succ[static_cast<std::size_t>(gr.to)].push_back(gi); // u -> v (group index)
rev_cap[gi] = cap_rev >= kMinCap ? cap_rev : 0.0;
}

// Compute reachability from src on this succ graph (to ignore disconnected parts).
Expand Down
40 changes: 18 additions & 22 deletions tests/cpp/flow_state_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ TEST(FlowState, RepeatedPlacementAfterResetAndIndependentInstancesStayStable) {
}

// ---------------------------------------------------------------------------
// EqualBalancedFixed / EqualBalancedLossy: load-blind hash-ECMP models.
// EqualBalanced / EqualBalancedLossy: load-blind hash-ECMP models.
// ---------------------------------------------------------------------------
#include "netgraph/core/flow_graph.hpp"

Expand All @@ -492,33 +492,29 @@ PredDAG cost_only_dag(const StrictMultiDiGraph& g, NodeId s, NodeId t) {
}
} // namespace

TEST(FlowState, EqualBalancedFixed_SaturatedMemberBlocksAdmission) {
TEST(FlowState, EqualBalanced_SaturatedMemberBlocksAdmission) {
auto g = make_unbalanced_pair();
auto dag = cost_only_dag(g, 0, 1);

FlowState fixed(g);
EXPECT_NEAR(fixed.place_on_dag(0, 1, dag, 20.0, FlowPlacement::EqualBalancedFixed), 20.0, 1e-9);
EXPECT_NEAR(fixed.edge_flow_view()[0], 10.0, 1e-9);
EXPECT_NEAR(fixed.edge_flow_view()[1], 10.0, 1e-9);
// The 10-unit member is saturated; equal hashing of any further demand would
// lose 1/2 of it, so nothing more is admitted losslessly.
EXPECT_NEAR(fixed.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalancedFixed), 0.0, 1e-9);
EXPECT_NEAR(fixed.edge_flow_view()[1], 10.0, 1e-9) << "no flow may leak onto the remaining member";

// The progressive mode drops the saturated member from the split instead.
FlowState progressive(g);
EXPECT_NEAR(progressive.place_on_dag(0, 1, dag, 20.0, FlowPlacement::EqualBalanced), 20.0, 1e-9);
EXPECT_NEAR(progressive.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalanced), 10.0, 1e-9);
EXPECT_NEAR(progressive.edge_flow_view()[1], 20.0, 1e-9);
FlowState fs(g);
EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 20.0, FlowPlacement::EqualBalanced), 20.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[0], 10.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[1], 10.0, 1e-9);
// The 10-unit member is full; equal hashing of any further demand would lose
// half of it, so nothing more is admitted losslessly on this DAG.
EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalanced), 0.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[1], 10.0, 1e-9) << "no flow may leak onto the remaining member";
}

TEST(FlowState, EqualBalancedFixed_SinglePassMatchesEqualBalancedOnFreshState) {
TEST(FlowState, EqualBalanced_ProgressesOnlyThroughResidualAwareSpf) {
// On a fresh residual-aware DAG the full member is gone and placement continues.
auto g = make_unbalanced_pair();
auto dag = cost_only_dag(g, 0, 1);
FlowState a(g), b(g);
EXPECT_NEAR(a.place_on_dag(0, 1, dag, 100.0, FlowPlacement::EqualBalanced), 20.0, 1e-9);
EXPECT_NEAR(b.place_on_dag(0, 1, dag, 100.0, FlowPlacement::EqualBalancedFixed), 20.0, 1e-9);
for (std::size_t i = 0; i < 2; ++i) EXPECT_NEAR(a.edge_flow_view()[i], b.edge_flow_view()[i], 1e-12);
FlowState fs(g);
EXPECT_NEAR(fs.place_on_dag(0, 1, cost_only_dag(g, 0, 1), 20.0, FlowPlacement::EqualBalanced), 20.0, 1e-9);
EdgeSelection sel; sel.multi_edge = true; sel.require_capacity = true; sel.tie_break = EdgeTieBreak::Deterministic;
auto [dist, dag] = shortest_paths(g, 0, 1, /*multipath=*/true, sel, fs.residual_view(), {}, {});
EXPECT_NEAR(fs.place_on_dag(0, 1, dag, 10.0, FlowPlacement::EqualBalanced), 10.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[1], 20.0, 1e-9);
}

TEST(FlowState, EqualBalancedLossy_FillAndDropReportsDrops) {
Expand Down
2 changes: 1 addition & 1 deletion tests/cpp/shortest_paths_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,7 @@ TEST(ShortestPathsTo, FanoutEdgesForceEverySourceIntoTheDag) {
// Lossless equal-balanced admission over the fan-out: shares 50/50 of 100;
// S2's branch admits 20 of 50 (link 3) so the whole demand scales to 0.4.
FlowState fs(g);
EXPECT_NEAR(fs.place_on_dag(0, 4, dag, 100.0, FlowPlacement::EqualBalancedFixed), 40.0, 1e-9);
EXPECT_NEAR(fs.place_on_dag(0, 4, dag, 100.0, FlowPlacement::EqualBalanced), 40.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[2], 20.0, 1e-9);
EXPECT_NEAR(fs.edge_flow_view()[3], 20.0, 1e-9);
}
Expand Down
Loading
Loading