From d8dc938ec411f40d2e1560ac3cc9fc28d52a1773 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Sun, 13 Sep 2026 00:58:06 +0100 Subject: [PATCH] EQUAL_BALANCED keeps the topology next-hop set; remove EQUAL_BALANCED_FIXED (0.10.0) 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. Progression past a full member comes from a residual-aware SPF, which place_max_flow and FlowPolicy already do. EQUAL_BALANCED_FIXED (0.9.0) carried exactly this behaviour as a separate mode and is removed; EQUAL_BALANCED_LOSSY is now value 3. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 10 ++ bindings/python/module.cpp | 1 - include/netgraph/core/flow_policy.hpp | 6 +- include/netgraph/core/flow_state.hpp | 12 +-- include/netgraph/core/types.hpp | 31 +++--- pyproject.toml | 2 +- python/netgraph_core/_docs.py | 25 ++--- src/flow_policy.cpp | 3 +- src/flow_state.cpp | 53 ++++------ tests/cpp/flow_state_tests.cpp | 40 ++++---- tests/cpp/shortest_paths_tests.cpp | 2 +- tests/py/test_equal_balanced_modes.py | 141 +++++++++++--------------- tests/py/test_spf_to.py | 2 +- 13 files changed, 145 insertions(+), 183 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed073b6..067042a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/bindings/python/module.cpp b/bindings/python/module.cpp index a55a71d..3cbd5ca 100644 --- a/bindings/python/module.cpp +++ b/bindings/python/module.cpp @@ -111,7 +111,6 @@ PYBIND11_MODULE(_netgraph_core, m, py::mod_gil_not_used()) { py::enum_(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_(m, "StrictMultiDiGraph") diff --git a/include/netgraph/core/flow_policy.hpp b/include/netgraph/core/flow_policy.hpp index 1c0b455..ccc8384 100644 --- a/include/netgraph/core/flow_policy.hpp +++ b/include/netgraph/core/flow_policy.hpp @@ -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(). diff --git a/include/netgraph/core/flow_state.hpp b/include/netgraph/core/flow_state.hpp index a9f746c..6fe9c20 100644 --- a/include/netgraph/core/flow_state.hpp +++ b/include/netgraph/core/flow_state.hpp @@ -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 diff --git a/include/netgraph/core/types.hpp b/include/netgraph/core/types.hpp index 64aa6d2..9835920 100644 --- a/include/netgraph/core/types.hpp +++ b/include/netgraph/core/types.hpp @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 214b299..312e5eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/python/netgraph_core/_docs.py b/python/netgraph_core/_docs.py index a69f652..984b099 100644 --- a/python/netgraph_core/_docs.py +++ b/python/netgraph_core/_docs.py @@ -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 @@ -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]] @@ -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). diff --git a/src/flow_policy.cpp b/src/flow_policy.cpp index 3578af4..1c6f3b3 100644 --- a/src/flow_policy.cpp +++ b/src/flow_policy.cpp @@ -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 diff --git a/src/flow_state.cpp b/src/flow_state.cpp index 08b2700..5932f64 100644 --- a/src/flow_state.cpp +++ b/src/flow_state.cpp @@ -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 @@ -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& residual, @@ -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. @@ -399,11 +393,10 @@ Flow FlowState::place_on_dag(NodeId src, NodeId dst, const PredDAG& dag, } placed = static_cast(inflow[static_cast(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. @@ -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(gr.min_cap) * static_cast(gr.eid_count); - if (cap_rev >= kMinCap || fixed_members) { - succ[static_cast(gr.to)].push_back(gi); // u -> v (group index) - rev_cap[gi] = cap_rev >= kMinCap ? cap_rev : 0.0; - } + succ[static_cast(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). diff --git a/tests/cpp/flow_state_tests.cpp b/tests/cpp/flow_state_tests.cpp index 10b206f..ac9658f 100644 --- a/tests/cpp/flow_state_tests.cpp +++ b/tests/cpp/flow_state_tests.cpp @@ -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" @@ -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) { diff --git a/tests/cpp/shortest_paths_tests.cpp b/tests/cpp/shortest_paths_tests.cpp index 5809fb1..18d496f 100644 --- a/tests/cpp/shortest_paths_tests.cpp +++ b/tests/cpp/shortest_paths_tests.cpp @@ -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); } diff --git a/tests/py/test_equal_balanced_modes.py b/tests/py/test_equal_balanced_modes.py index 6eff362..c186271 100644 --- a/tests/py/test_equal_balanced_modes.py +++ b/tests/py/test_equal_balanced_modes.py @@ -1,9 +1,9 @@ -"""EQUAL_BALANCED_FIXED and EQUAL_BALANCED_LOSSY placement semantics. +"""EQUAL_BALANCED and EQUAL_BALANCED_LOSSY placement semantics. Both model a hop-by-hop forwarding table that does not react to load: the -split set is every shortest-path edge with capacity. FIXED admits losslessly -(a saturated member blocks admission), LOSSY forwards best-effort (each member -carries what it can and the rest is dropped). +split set is every shortest-path edge with capacity. EQUAL_BALANCED admits +losslessly (a full member blocks admission on that DAG), EQUAL_BALANCED_LOSSY +forwards best-effort (each member carries what it can and the rest is dropped). """ from __future__ import annotations @@ -25,73 +25,81 @@ def _graph(src, dst, cap, cost, n): ) +SEL = ngc.EdgeSelection( + multi_edge=True, require_capacity=False, tie_break=ngc.EdgeTieBreak.DETERMINISTIC +) + + @pytest.fixture def pair(): """0 -> 1 over two equal-cost parallel edges: edge 0 cap 10, edge 1 cap 100.""" g = _graph([0, 0], [1, 1], [10.0, 100.0], [1, 1], 2) algs = ngc.Algorithms(ngc.Backend.cpu()) handle = algs.build_graph(g) - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) _, dag = algs.spf( - handle, src=0, dst=None, selection=sel, multipath=True, dtype="float64" + handle, src=0, dst=None, selection=SEL, multipath=True, dtype="float64" ) - return g, dag + return g, algs, handle, dag -def test_enum_exposes_new_modes(): - assert ngc.FlowPlacement.EQUAL_BALANCED_FIXED != ngc.FlowPlacement.EQUAL_BALANCED - assert ( - ngc.FlowPlacement.EQUAL_BALANCED_LOSSY != ngc.FlowPlacement.EQUAL_BALANCED_FIXED - ) - assert set(ngc.FlowPlacement.__members__) >= { +def test_enum_members(): + assert set(ngc.FlowPlacement.__members__) == { "PROPORTIONAL", "EQUAL_BALANCED", - "EQUAL_BALANCED_FIXED", "EQUAL_BALANCED_LOSSY", } -def test_fixed_blocks_after_member_saturates(pair): - g, dag = pair +def test_full_member_blocks_admission_on_the_same_dag(pair): + g, _, _, dag = pair fg = ngc.FlowGraph(g) - first = fg.place( - ngc.FlowIndex(0, 1, 0, 0), - 0, - 1, - dag, - 20.0, - ngc.FlowPlacement.EQUAL_BALANCED_FIXED, - ) - assert first == pytest.approx(20.0) - second = fg.place( - ngc.FlowIndex(0, 1, 0, 1), - 0, - 1, - dag, - 10.0, - ngc.FlowPlacement.EQUAL_BALANCED_FIXED, - ) - assert second == pytest.approx(0.0) + assert fg.place( + ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 20.0, ngc.FlowPlacement.EQUAL_BALANCED + ) == pytest.approx(20.0) + assert fg.place( + ngc.FlowIndex(0, 1, 0, 1), 0, 1, dag, 10.0, ngc.FlowPlacement.EQUAL_BALANCED + ) == pytest.approx(0.0) assert fg.edge_flow_view()[1] == pytest.approx(10.0) -def test_equal_balanced_still_progressive(pair): - g, dag = pair +def test_progress_comes_from_a_residual_aware_dag(pair): + g, algs, handle, dag = pair fg = ngc.FlowGraph(g) assert fg.place( ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 20.0, ngc.FlowPlacement.EQUAL_BALANCED ) == pytest.approx(20.0) + residual = np.ascontiguousarray(fg.residual_view(), dtype=np.float64) + _, fresh = algs.spf( + handle, + src=0, + dst=None, + selection=SEL, + residual=residual, + multipath=True, + dtype="float64", + ) assert fg.place( - ngc.FlowIndex(0, 1, 0, 1), 0, 1, dag, 10.0, ngc.FlowPlacement.EQUAL_BALANCED + ngc.FlowIndex(0, 1, 0, 1), 0, 1, fresh, 10.0, ngc.FlowPlacement.EQUAL_BALANCED ) == pytest.approx(10.0) + assert fg.edge_flow_view()[1] == pytest.approx(20.0) + + +def test_cost_only_max_flow_is_single_pass(pair): + """place_max_flow with require_capacity=False never re-places on the stale DAG.""" + g, _, _, _ = pair + fs = ngc.FlowState(g) + total = fs.place_max_flow( + 0, + 1, + flow_placement=ngc.FlowPlacement.EQUAL_BALANCED, + shortest_path=False, + require_capacity=False, + ) + assert total == pytest.approx(20.0) def test_lossy_delivers_and_reports_drops(pair): - g, dag = pair + g, _, _, dag = pair fg = ngc.FlowGraph(g) placed, drops = fg.place_with_drops( ngc.FlowIndex(0, 1, 0, 0), @@ -104,7 +112,6 @@ def test_lossy_delivers_and_reports_drops(pair): assert placed == pytest.approx(60.0) assert drops == [(0, pytest.approx(40.0))] assert fg.edge_flow_view().tolist() == pytest.approx([10.0, 50.0]) - # A later demand still hashes half onto the saturated member. placed2, drops2 = fg.place_with_drops( ngc.FlowIndex(0, 1, 0, 1), 0, @@ -118,7 +125,7 @@ def test_lossy_delivers_and_reports_drops(pair): def test_place_with_drops_is_empty_for_other_modes(pair): - g, dag = pair + g, _, _, dag = pair fg = ngc.FlowGraph(g) placed, drops = fg.place_with_drops( ngc.FlowIndex(0, 1, 0, 0), 0, 1, dag, 100.0, ngc.FlowPlacement.EQUAL_BALANCED @@ -128,7 +135,7 @@ def test_place_with_drops_is_empty_for_other_modes(pair): def test_lossy_ledger_holds_carried_volume_only(pair): - g, dag = pair + g, _, _, dag = pair fg = ngc.FlowGraph(g) idx = ngc.FlowIndex(0, 1, 0, 3) placed, _ = fg.place_with_drops( @@ -150,34 +157,19 @@ def test_cost_only_flow_policy_does_not_reroute_around_saturation(): cfg = ngc.FlowPolicyConfig() cfg.path_alg = ngc.PathAlg.SPF - cfg.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED_FIXED - cfg.selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) + cfg.flow_placement = ngc.FlowPlacement.EQUAL_BALANCED + cfg.selection = SEL cfg.require_capacity = False cfg.shortest_path = True cfg.min_flow_count = 1 cfg.max_flow_count = 1 fg = ngc.FlowGraph(g) - # Saturate the direct link with a foreign flow first. - sel = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) _, dag = algs.spf( - handle, src=0, dst=None, selection=sel, multipath=True, dtype="float64" + handle, src=0, dst=None, selection=SEL, multipath=True, dtype="float64" ) assert fg.place( - ngc.FlowIndex(0, 1, 9, 0), - 0, - 1, - dag, - 10.0, - ngc.FlowPlacement.EQUAL_BALANCED_FIXED, + ngc.FlowIndex(0, 1, 9, 0), 0, 1, dag, 10.0, ngc.FlowPlacement.EQUAL_BALANCED ) == pytest.approx(10.0) policy = ngc.FlowPolicy(algs, handle, cfg) @@ -186,18 +178,13 @@ def test_cost_only_flow_policy_does_not_reroute_around_saturation(): "cost-only routing must not discover the A->C->B detour" ) assert remaining == pytest.approx(50.0) - assert all(float(v[2]) == 1.0 for v in policy.flows.values()), ( - "the flow stays on the cost-1 path" - ) + assert all(float(v[2]) == 1.0 for v in policy.flows.values()) def test_lossy_static_paths_carry_what_fits_without_equalizing(): """Pinned routes under EQUAL_BALANCED_LOSSY: each LSP is offered its share - and delivers what fits; the equalizing rebalance of EQUAL_BALANCED does - not run, so placed is the delivered total. - - A -> B direct cap 10 (edge 0); A -> C -> B cap 100 (edges 1, 2). Demand 50 - over both routes: 25 offered each, 10 + 25 = 35 delivered. + and delivers what fits. A -> B direct cap 10 (edge 0); A -> C -> B cap 100 + (edges 1, 2). Demand 50 over both routes: 25 offered each, 10 + 25 = 35. """ g = _graph([0, 0, 2], [1, 2, 1], [10.0, 100.0, 100.0], [1, 1, 1], 3) algs = ngc.Algorithms(ngc.Backend.cpu()) @@ -208,11 +195,7 @@ def policy(placement): cfg = ngc.FlowPolicyConfig() cfg.path_alg = ngc.PathAlg.SPF cfg.flow_placement = placement - cfg.selection = ngc.EdgeSelection( - multi_edge=True, - require_capacity=False, - tie_break=ngc.EdgeTieBreak.DETERMINISTIC, - ) + cfg.selection = SEL cfg.require_capacity = False cfg.min_flow_count = 1 cfg.max_flow_count = 2 @@ -229,9 +212,7 @@ def policy(placement): assert fg.edge_flow_view().tolist() == pytest.approx([10.0, 25.0, 25.0]) fg = ngc.FlowGraph(g) - placed, _ = policy(ngc.FlowPlacement.EQUAL_BALANCED_FIXED).place_demand( - fg, 0, 1, 0, 50.0 - ) + placed, _ = policy(ngc.FlowPlacement.EQUAL_BALANCED).place_demand(fg, 0, 1, 0, 50.0) assert placed == pytest.approx(20.0, abs=1e-3), ( "lossless: equal carried share, bottleneck 10" ) diff --git a/tests/py/test_spf_to.py b/tests/py/test_spf_to.py index 4b294cd..664adb8 100644 --- a/tests/py/test_spf_to.py +++ b/tests/py/test_spf_to.py @@ -60,7 +60,7 @@ def test_fanout_forces_all_sources_and_admission_scales_globally(fanout_graph): 4, dag, 100.0, - ngc.FlowPlacement.EQUAL_BALANCED_FIXED, + ngc.FlowPlacement.EQUAL_BALANCED, ) # Even 50/50 split; S2's 20-unit link admits 20 of 50, so the demand scales to 0.4. assert placed == pytest.approx(40.0)