From 7d00febbf1e5ad88d72d0e5f62ab6b235b8754a7 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 20:57:36 +0100 Subject: [PATCH 01/10] Refactor workflow step registration to avoid decorator ordering issues --- ngraph/workflow/build_graph.py | 5 ++++- ngraph/workflow/capacity_probe.py | 5 ++++- tests/test_scenario.py | 7 +++++-- tests/workflow/test_capacity_probe.py | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ngraph/workflow/build_graph.py b/ngraph/workflow/build_graph.py index 28e8157..a082f58 100644 --- a/ngraph/workflow/build_graph.py +++ b/ngraph/workflow/build_graph.py @@ -9,7 +9,6 @@ from ngraph.scenario import Scenario -@register_workflow_step("BuildGraph") @dataclass class BuildGraph(WorkflowStep): """A workflow step that builds a StrictMultiDiGraph from scenario.network.""" @@ -17,3 +16,7 @@ class BuildGraph(WorkflowStep): def run(self, scenario: Scenario) -> None: graph = scenario.network.to_strict_multidigraph(add_reverse=True) scenario.results.put(self.name, "graph", graph) + + +# Register the class after definition to avoid decorator ordering issues +register_workflow_step("BuildGraph")(BuildGraph) diff --git a/ngraph/workflow/capacity_probe.py b/ngraph/workflow/capacity_probe.py index 4f511d5..3b636d7 100644 --- a/ngraph/workflow/capacity_probe.py +++ b/ngraph/workflow/capacity_probe.py @@ -10,7 +10,6 @@ from ngraph.scenario import Scenario -@register_workflow_step("CapacityProbe") @dataclass class CapacityProbe(WorkflowStep): """A workflow step that probes capacity (max flow) between selected groups of nodes. @@ -98,3 +97,7 @@ def _store_flow_dict( for (src_label, snk_label), flow_value in flow_dict.items(): result_label = f"max_flow:[{src_label} -> {snk_label}]" scenario.results.put(self.name, result_label, flow_value) + + +# Register the class after definition to avoid decorator ordering issues +register_workflow_step("CapacityProbe")(CapacityProbe) diff --git a/tests/test_scenario.py b/tests/test_scenario.py index b17bb72..6769823 100644 --- a/tests/test_scenario.py +++ b/tests/test_scenario.py @@ -17,7 +17,6 @@ from ngraph.scenario import Scenario -@register_workflow_step("DoSmth") @dataclass class DoSmth(WorkflowStep): """ @@ -34,7 +33,6 @@ def run(self, scenario: Scenario) -> None: scenario.results.put(self.name, "ran", True) -@register_workflow_step("DoSmthElse") @dataclass class DoSmthElse(WorkflowStep): """ @@ -47,6 +45,11 @@ def run(self, scenario: Scenario) -> None: scenario.results.put(self.name, "ran", True) +# Register the classes after definition to avoid decorator ordering issues +register_workflow_step("DoSmth")(DoSmth) +register_workflow_step("DoSmthElse")(DoSmthElse) + + @pytest.fixture def valid_scenario_yaml() -> str: """ diff --git a/tests/workflow/test_capacity_probe.py b/tests/workflow/test_capacity_probe.py index 262c42c..99c274f 100644 --- a/tests/workflow/test_capacity_probe.py +++ b/tests/workflow/test_capacity_probe.py @@ -158,7 +158,7 @@ def test_capacity_probe_mode_pairwise_multiple_groups(mock_scenario): Tests 'pairwise' mode with multiple source groups and sink groups. We confirm multiple result entries are stored, one per (src_label, snk_label). To ensure distinct group labels, we use capturing groups in the regex - (e.g. ^S(\d+)$), so S1 => group '1', S2 => group '2', etc. + (e.g. ^S(\\d+)$), so S1 => group '1', S2 => group '2', etc. """ # Network: # S1 -> M -> T1 From b756fdb7b439c01cde70056e214ac1588975db1d Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 22:41:41 +0100 Subject: [PATCH 02/10] Add self-loop handling in max flow calculation and extend tests for edge cases --- ngraph/lib/algorithms/max_flow.py | 26 ++++ pyproject.toml | 2 + tests/lib/algorithms/test_max_flow.py | 187 +++++++++++++++++++++++++- 3 files changed, 209 insertions(+), 6 deletions(-) diff --git a/ngraph/lib/algorithms/max_flow.py b/ngraph/lib/algorithms/max_flow.py index bd71a3f..7e5340a 100644 --- a/ngraph/lib/algorithms/max_flow.py +++ b/ngraph/lib/algorithms/max_flow.py @@ -174,6 +174,32 @@ def calc_max_flow( ... ) >>> # flow_graph contains the flow assignments """ + # Handle self-loop case: when source equals destination, max flow is always 0 + # Degenerate case (s == t): + # Flow value |f| is the net surplus at the vertex. + # Conservation forces that surplus to zero, so the + # only feasible (and thus maximum) flow value is 0. + if src_node == dst_node: + if return_summary or return_graph: + # For consistency, we need to create a minimal flow graph for summary/graph returns + flow_graph = init_flow_graph( + graph.copy() if copy_graph else graph, + flow_attr, + flows_attr, + reset_flow_graph, + ) + return _build_return_value( + 0.0, + flow_graph, + src_node, + return_summary, + return_graph, + capacity_attr, + flow_attr, + ) + else: + return 0.0 + # Initialize a flow-aware graph (copy or in-place). flow_graph = init_flow_graph( graph.copy() if copy_graph else graph, diff --git a/pyproject.toml b/pyproject.toml index b3aa83e..2aee033 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dev = [ "pytest-cov", "pytest-benchmark", "pytest-mock", + "pytest-timeout", # docs "mkdocs-material", "pdoc", @@ -61,6 +62,7 @@ ngraph = "ngraph.cli:main" # Pytest flags [tool.pytest.ini_options] addopts = "--cov=ngraph --cov-fail-under=85 --cov-report term-missing" +timeout = 30 # --------------------------------------------------------------------- # Package discovery diff --git a/tests/lib/algorithms/test_max_flow.py b/tests/lib/algorithms/test_max_flow.py index 9fcb76d..3788c5f 100644 --- a/tests/lib/algorithms/test_max_flow.py +++ b/tests/lib/algorithms/test_max_flow.py @@ -364,9 +364,9 @@ def test_sensitivity_analysis_negative_capacity_protection(self, line1): for edge, flow_change in sensitivity.items(): assert isinstance(edge, tuple) assert len(edge) == 3 # (u, v, k) - assert ( - flow_change <= 0 - ) # Should reduce or maintain flow def test_sensitivity_analysis_zero_capacity_behavior(self): + assert flow_change <= 0 # Should reduce or maintain flow + + def test_sensitivity_analysis_zero_capacity_behavior(self): """Test specific behavior when edge capacity is reduced to zero.""" from ngraph.lib.algorithms.max_flow import run_sensitivity @@ -386,9 +386,9 @@ def test_sensitivity_analysis_negative_capacity_protection(self, line1): # Should reduce flow to zero (complete bottleneck removal) bc_edge = ("B", "C", bc_edge_key) assert bc_edge in sensitivity - assert ( - sensitivity[bc_edge] == -5.0 - ) # Should reduce flow by 5 (from 5 to 0) def test_sensitivity_analysis_partial_capacity_reduction(self): + assert sensitivity[bc_edge] == -5.0 # Should reduce flow by 5 (from 5 to 0) + + def test_sensitivity_analysis_partial_capacity_reduction(self): """Test behavior when capacity is partially reduced but not to zero.""" from ngraph.lib.algorithms.max_flow import run_sensitivity @@ -441,3 +441,178 @@ def test_sensitivity_analysis_capacity_increase_and_decrease(self): # Decreases should be negative or zero for flow_change in sensitivity_decrease.values(): assert flow_change <= 0 + + def test_max_flow_self_loop(self): + """Test max flow calculation when source equals destination (self-loop).""" + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", capacity=10.0, flow=0.0, flows={}, cost=1) + + # Self-loop should return 0 flow (no meaningful flow from node to itself) + max_flow = calc_max_flow(g, "A", "A") + assert max_flow == 0.0 + + def test_max_flow_self_loop_all_return_combinations(self): + """Test all return value combinations for self-loops (s==t).""" + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", capacity=10.0, flow=0.0, flows={}, cost=1) + + # Test scalar return (already covered above but for completeness) + flow = calc_max_flow(g, "A", "A") + assert flow == 0.0 + + # Test return_summary=True + flow, summary = calc_max_flow(g, "A", "A", return_summary=True) + assert flow == 0.0 + assert isinstance(summary, FlowSummary) + assert summary.total_flow == 0.0 + assert "A" in summary.reachable # Source should be reachable + assert len(summary.min_cut) == 0 # No min-cut for self-loops + assert len(summary.edge_flow) == 1 # Should have the self-loop edge + + # Check edge flow is 0 for the self-loop + self_loop_edges = [ + (u, v, k) for (u, v, k) in summary.edge_flow.keys() if u == "A" and v == "A" + ] + assert len(self_loop_edges) >= 1 + for edge in self_loop_edges: + assert summary.edge_flow[edge] == 0.0 + + # Test return_graph=True + flow, flow_graph = calc_max_flow(g, "A", "A", return_graph=True) + assert flow == 0.0 + assert isinstance(flow_graph, StrictMultiDiGraph) + assert flow_graph.has_node("A") + assert flow_graph.has_edge("A", "A") + + # Check that flow attributes are properly initialized on the self-loop edge + for _, _, _, data in flow_graph.edges(data=True, keys=True): + assert "flow" in data + assert "capacity" in data + assert data["flow"] == 0.0 # Should be 0 for self-loop + + # Test both return_summary=True and return_graph=True + flow, summary, flow_graph = calc_max_flow( + g, "A", "A", return_summary=True, return_graph=True + ) + assert flow == 0.0 + assert isinstance(summary, FlowSummary) + assert isinstance(flow_graph, StrictMultiDiGraph) + assert summary.total_flow == 0.0 + assert flow_graph.has_node("A") + assert flow_graph.has_edge("A", "A") + + def test_max_flow_overlapping_source_sink_simple(self): + """Test max flow with overlapping source/sink nodes that caused infinite loops.""" + g = StrictMultiDiGraph() + g.add_node("N1") + g.add_node("N2") + + # Simple topology: N1 -> N2 + g.add_edge("N1", "N2", capacity=1.0, flow=0.0, flows={}, cost=1) + + # Test all combinations that would occur in pairwise mode with overlapping patterns + # N1 -> N1 (self-loop) + max_flow_n1_n1 = calc_max_flow(g, "N1", "N1") + assert max_flow_n1_n1 == 0.0 + + # N1 -> N2 (valid path) + max_flow_n1_n2 = calc_max_flow(g, "N1", "N2") + assert max_flow_n1_n2 == 1.0 + + # N2 -> N1 (no path) + max_flow_n2_n1 = calc_max_flow(g, "N2", "N1") + assert max_flow_n2_n1 == 0.0 + + # N2 -> N2 (self-loop) + max_flow_n2_n2 = calc_max_flow(g, "N2", "N2") + assert max_flow_n2_n2 == 0.0 + + def test_max_flow_overlapping_source_sink_with_bidirectional(self): + """Test overlapping source/sink with bidirectional edges.""" + g = StrictMultiDiGraph() + g.add_node("A") + g.add_node("B") + + # Bidirectional edges + g.add_edge("A", "B", capacity=5.0, flow=0.0, flows={}, cost=1) + g.add_edge("B", "A", capacity=3.0, flow=0.0, flows={}, cost=1) + + # Test all combinations + # A -> A (self-loop) + max_flow_a_a = calc_max_flow(g, "A", "A") + assert max_flow_a_a == 0.0 + + # A -> B (forward direction) + max_flow_a_b = calc_max_flow(g, "A", "B") + assert max_flow_a_b == 5.0 + + # B -> A (reverse direction) + max_flow_b_a = calc_max_flow(g, "B", "A") + assert max_flow_b_a == 3.0 + + # B -> B (self-loop) + max_flow_b_b = calc_max_flow(g, "B", "B") + assert max_flow_b_b == 0.0 + + def test_max_flow_self_loop_all_return_modes(self): + """ + Test self-loop (s == t) behavior with all possible return value combinations. + Ensures that our optimization properly handles all return modes. + """ + # Create a simple graph with a self-loop + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", capacity=10.0, flow=0.0, flows={}, cost=1) + + # Test 1: Basic scalar return (return_summary=False, return_graph=False) + flow_scalar = calc_max_flow(g, "A", "A") + assert flow_scalar == 0.0 + assert isinstance(flow_scalar, float) + + # Test 2: With summary only (return_summary=True, return_graph=False) + flow_with_summary = calc_max_flow(g, "A", "A", return_summary=True) + assert isinstance(flow_with_summary, tuple) + assert len(flow_with_summary) == 2 + flow, summary = flow_with_summary + assert flow == 0.0 + assert isinstance(summary, FlowSummary) + assert summary.total_flow == 0.0 + assert "A" in summary.reachable # Source should be reachable from itself + assert len(summary.min_cut) == 0 # No min-cut edges for self-loop + + # Test 3: With graph only (return_summary=False, return_graph=True) + flow_with_graph = calc_max_flow(g, "A", "A", return_graph=True) + assert isinstance(flow_with_graph, tuple) + assert len(flow_with_graph) == 2 + flow, flow_graph = flow_with_graph + assert flow == 0.0 + assert isinstance(flow_graph, StrictMultiDiGraph) + assert flow_graph.has_node("A") + assert flow_graph.has_edge("A", "A") + + # Test 4: With both summary and graph (return_summary=True, return_graph=True) + flow_with_both = calc_max_flow( + g, "A", "A", return_summary=True, return_graph=True + ) + assert isinstance(flow_with_both, tuple) + assert len(flow_with_both) == 3 + flow, summary, flow_graph = flow_with_both + assert flow == 0.0 + assert isinstance(summary, FlowSummary) + assert isinstance(flow_graph, StrictMultiDiGraph) + assert summary.total_flow == 0.0 + assert "A" in summary.reachable + assert len(summary.min_cut) == 0 + assert flow_graph.has_node("A") + assert flow_graph.has_edge("A", "A") + + # Verify that the flow on the self-loop edge is 0 + self_loop_edges = list(flow_graph.edges(nbunch="A", data=True, keys=True)) + a_to_a_edges = [ + (u, v, k, d) for u, v, k, d in self_loop_edges if u == "A" and v == "A" + ] + assert len(a_to_a_edges) >= 1 + for _u, _v, _k, data in a_to_a_edges: + assert data.get("flow", 0.0) == 0.0 From e2f70b7e42c86bab7f8060242ca5b0f3f090f7e8 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 22:58:15 +0100 Subject: [PATCH 03/10] Add self-loop handling in flow calculations and extend tests for edge cases --- ngraph/lib/algorithms/calc_capacity.py | 8 + ngraph/lib/algorithms/place_flow.py | 8 + tests/lib/algorithms/test_calc_capacity.py | 102 ++++++++++++ tests/lib/algorithms/test_place_flow.py | 177 ++++++++++++++++++++- 4 files changed, 292 insertions(+), 3 deletions(-) diff --git a/ngraph/lib/algorithms/calc_capacity.py b/ngraph/lib/algorithms/calc_capacity.py index 1ba7074..c5cd782 100644 --- a/ngraph/lib/algorithms/calc_capacity.py +++ b/ngraph/lib/algorithms/calc_capacity.py @@ -339,6 +339,14 @@ def calc_graph_capacity( f"Source node {src_node} or destination node {dst_node} not found in the graph." ) + # Handle self-loop case: when source equals destination, max flow is always 0 + # Degenerate case (s == t): + # Flow value |f| is the net surplus at the vertex. + # Conservation forces that surplus to zero, so the + # only feasible (and thus maximum) flow value is 0. + if src_node == dst_node: + return 0.0, defaultdict(dict) + # Build reversed data structures from dst_node succ, levels, residual_cap, flow_dict = _init_graph_data( flow_graph=flow_graph, diff --git a/ngraph/lib/algorithms/place_flow.py b/ngraph/lib/algorithms/place_flow.py index 6cc49f7..a14c936 100644 --- a/ngraph/lib/algorithms/place_flow.py +++ b/ngraph/lib/algorithms/place_flow.py @@ -58,6 +58,14 @@ def place_flow_on_graph( FlowPlacementMeta: Contains the placed flow amount, remaining flow amount, and sets of touched nodes/edges. """ + # Handle self-loop case: when source equals destination, max flow is always 0 + # Degenerate case (s == t): + # Flow value |f| is the net surplus at the vertex. + # Conservation forces that surplus to zero, so the + # only feasible (and thus maximum) flow value is 0. + if src_node == dst_node: + return FlowPlacementMeta(0.0, flow) + # 1) Determine the maximum feasible flow via calc_graph_capacity. rem_cap, flow_dict = calc_graph_capacity( flow_graph, src_node, dst_node, pred, flow_placement, capacity_attr, flow_attr diff --git a/tests/lib/algorithms/test_calc_capacity.py b/tests/lib/algorithms/test_calc_capacity.py index 1eca6a0..d06e20d 100644 --- a/tests/lib/algorithms/test_calc_capacity.py +++ b/tests/lib/algorithms/test_calc_capacity.py @@ -311,3 +311,105 @@ def test_calc_graph_capacity_graph3(self, graph3): "E": {"A": -0.2, "C": 0.2}, "F": {"C": -0.4, "D": 0.4}, } + + def test_calc_graph_capacity_self_loop_proportional(self): + """ + Test self-loop behavior with PROPORTIONAL flow placement. + When source equals destination, max flow should always be 0. + """ + # Create a graph with a self-loop + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Create a simple pred with self-loop + pred: PredDict = {"A": {"A": [0]}} + + # Test PROPORTIONAL placement + max_flow, flow_dict = calc_graph_capacity( + r, "A", "A", pred, flow_placement=FlowPlacement.PROPORTIONAL + ) + + assert max_flow == 0.0 + # flow_dict should be empty or contain only zero flows + for node_flows in flow_dict.values(): + for flow_value in node_flows.values(): + assert flow_value == 0.0 + + def test_calc_graph_capacity_self_loop_equal_balanced(self): + """ + Test self-loop behavior with EQUAL_BALANCED flow placement. + When source equals destination, max flow should always be 0. + """ + # Create a graph with multiple self-loops + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=5.0, flow=0.0, flows={}, cost=1) + g.add_edge("A", "A", key=1, capacity=3.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Create pred with multiple self-loop edges + pred: PredDict = {"A": {"A": [0, 1]}} + + # Test EQUAL_BALANCED placement + max_flow, flow_dict = calc_graph_capacity( + r, "A", "A", pred, flow_placement=FlowPlacement.EQUAL_BALANCED + ) + + assert max_flow == 0.0 + # flow_dict should be empty or contain only zero flows + for node_flows in flow_dict.values(): + for flow_value in node_flows.values(): + assert flow_value == 0.0 + + def test_calc_graph_capacity_self_loop_with_other_edges(self): + """ + Test self-loop behavior in a graph that also has regular edges. + The self-loop itself should still return 0 flow. + """ + # Create a graph with both self-loop and regular edges + g = StrictMultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + g.add_edge("A", "B", key=1, capacity=5.0, flow=0.0, flows={}, cost=2) + g.add_edge("B", "A", key=2, capacity=3.0, flow=0.0, flows={}, cost=2) + r = init_flow_graph(g) + + # Test self-loop A->A + pred_self: PredDict = {"A": {"A": [0]}} + max_flow, flow_dict = calc_graph_capacity( + r, "A", "A", pred_self, flow_placement=FlowPlacement.PROPORTIONAL + ) + assert max_flow == 0.0 + + # Test regular flow A->B to verify graph still works for non-self-loops + pred_regular: PredDict = {"A": {}, "B": {"A": [1]}} + max_flow_regular, _ = calc_graph_capacity( + r, "A", "B", pred_regular, flow_placement=FlowPlacement.PROPORTIONAL + ) + assert max_flow_regular == 5.0 # Should be limited by A->B capacity + + def test_calc_graph_capacity_self_loop_empty_pred(self): + """ + Test self-loop behavior when pred is empty. + Should return 0 flow for self-loop even with empty pred. + """ + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Empty pred + pred: PredDict = {} + + max_flow, flow_dict = calc_graph_capacity( + r, "A", "A", pred, flow_placement=FlowPlacement.PROPORTIONAL + ) + + assert max_flow == 0.0 + # flow_dict should be empty or contain only zero flows + for node_flows in flow_dict.values(): + for flow_value in node_flows.values(): + assert flow_value == 0.0 diff --git a/tests/lib/algorithms/test_place_flow.py b/tests/lib/algorithms/test_place_flow.py index 27570fd..2ceb00d 100644 --- a/tests/lib/algorithms/test_place_flow.py +++ b/tests/lib/algorithms/test_place_flow.py @@ -5,6 +5,7 @@ remove_flow_from_graph, ) from ngraph.lib.algorithms.spf import spf +from ngraph.lib.graph import StrictMultiDiGraph class TestPlaceFlowOnGraph: @@ -672,6 +673,176 @@ def test_place_flow_on_graph_graph4_balanced(self, graph4): 5: ("B2", "C", 5, {"capacity": 3, "flow": 0, "flows": {}, "cost": 3}), } + def test_place_flow_on_graph_self_loop_proportional(self): + """ + Test self-loop behavior with PROPORTIONAL flow placement. + When source equals destination, no flow should be placed. + """ + # Create a graph with a self-loop + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Create pred with self-loop + pred = {"A": {"A": [0]}} + + # Attempt to place flow on self-loop + flow_placement_meta = place_flow_on_graph( + r, + "A", + "A", + pred, + flow=5.0, + flow_index=("A", "A", "SELF_LOOP"), + flow_placement=FlowPlacement.PROPORTIONAL, + ) + + # Should place 0 flow and return the requested flow as remaining + assert flow_placement_meta.placed_flow == 0.0 + assert flow_placement_meta.remaining_flow == 5.0 + + # Verify the self-loop edge has no flow placed on it + edges = r.get_edges() + self_loop_edge = edges[0] + assert self_loop_edge[3]["flow"] == 0.0 + assert self_loop_edge[3]["flows"] == {} + + def test_place_flow_on_graph_self_loop_equal_balanced(self): + """ + Test self-loop behavior with EQUAL_BALANCED flow placement. + When source equals destination, no flow should be placed. + """ + # Create a graph with multiple self-loops + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=5.0, flow=0.0, flows={}, cost=1) + g.add_edge("A", "A", key=1, capacity=3.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Create pred with multiple self-loop edges + pred = {"A": {"A": [0, 1]}} + + # Attempt to place flow on self-loops + flow_placement_meta = place_flow_on_graph( + r, + "A", + "A", + pred, + flow=10.0, + flow_index=("A", "A", "MULTI_SELF_LOOP"), + flow_placement=FlowPlacement.EQUAL_BALANCED, + ) + + # Should place 0 flow and return all requested flow as remaining + assert flow_placement_meta.placed_flow == 0.0 + assert flow_placement_meta.remaining_flow == 10.0 + + # Verify all self-loop edges have no flow placed on them + edges = r.get_edges() + for edge_data in edges.values(): + assert edge_data[3]["flow"] == 0.0 + assert edge_data[3]["flows"] == {} + + def test_place_flow_on_graph_self_loop_infinite_flow(self): + """ + Test self-loop behavior when requesting infinite flow. + Should still place 0 flow and return infinite remaining flow. + """ + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=100.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + pred = {"A": {"A": [0]}} + + # Request infinite flow on self-loop + flow_placement_meta = place_flow_on_graph( + r, + "A", + "A", + pred, + flow=float("inf"), + flow_index=("A", "A", "INF_SELF_LOOP"), + flow_placement=FlowPlacement.PROPORTIONAL, + ) + + # Should place 0 flow and return infinite remaining flow + assert flow_placement_meta.placed_flow == 0.0 + assert flow_placement_meta.remaining_flow == float("inf") + + # Verify metadata is correctly handled for self-loops + # The early return should not populate nodes/edges metadata + assert len(flow_placement_meta.nodes) <= 1 # Should be 0 or just contain source + assert flow_placement_meta.edges == set() # No edges should carry flow + + def test_place_flow_on_graph_self_loop_with_other_edges(self): + """ + Test self-loop behavior in a graph that also has regular edges. + Self-loop should still place 0 flow while regular flows work normally. + """ + # Create graph with both self-loop and regular edges + g = StrictMultiDiGraph() + g.add_node("A") + g.add_node("B") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + g.add_edge("A", "B", key=1, capacity=5.0, flow=0.0, flows={}, cost=2) + g.add_edge("B", "A", key=2, capacity=3.0, flow=0.0, flows={}, cost=2) + r = init_flow_graph(g) + + # Test self-loop A->A + pred_self = {"A": {"A": [0]}} + flow_meta_self = place_flow_on_graph( + r, "A", "A", pred_self, flow=7.0, flow_index=("A", "A", "SELF") + ) + assert flow_meta_self.placed_flow == 0.0 + assert flow_meta_self.remaining_flow == 7.0 + + # Test regular flow A->B to verify graph still works for non-self-loops + pred_regular = {"A": {}, "B": {"A": [1]}} + flow_meta_regular = place_flow_on_graph( + r, "A", "B", pred_regular, flow=4.0, flow_index=("A", "B", "REGULAR") + ) + assert flow_meta_regular.placed_flow == 4.0 + assert flow_meta_regular.remaining_flow == 0.0 + + # Verify self-loop edge still has no flow + edges = r.get_edges() + assert edges[0][3]["flow"] == 0.0 # Self-loop edge + assert edges[1][3]["flow"] == 4.0 # A->B edge should have flow + + def test_place_flow_on_graph_self_loop_empty_pred(self): + """ + Test self-loop behavior when pred is empty. + Should return 0 flow even with empty pred. + """ + g = StrictMultiDiGraph() + g.add_node("A") + g.add_edge("A", "A", key=0, capacity=10.0, flow=0.0, flows={}, cost=1) + r = init_flow_graph(g) + + # Empty pred + pred = {} + + flow_placement_meta = place_flow_on_graph( + r, + "A", + "A", + pred, + flow=5.0, + flow_index=("A", "A", "EMPTY_PRED"), + flow_placement=FlowPlacement.PROPORTIONAL, + ) + + # Should place 0 flow due to self-loop optimization, not pred limitations + assert flow_placement_meta.placed_flow == 0.0 + assert flow_placement_meta.remaining_flow == 5.0 + + # Verify the self-loop edge has no flow + edges = r.get_edges() + assert edges[0][3]["flow"] == 0.0 + assert edges[0][3]["flows"] == {} + # # Tests for removing flow from the graph, fully or partially. @@ -780,9 +951,9 @@ def test_remove_flow_zero_flow_placed(self, line1): flow_placement=FlowPlacement.PROPORTIONAL, ) # Remove flows (none effectively exist) - remove_flow_from_graph(r, flow_index=("A", "C", "empty")) - - # Ensure edges remain at zero flow + remove_flow_from_graph( + r, flow_index=("A", "C", "empty") + ) # Ensure edges remain at zero flow for _, edata in r.get_edges().items(): assert edata[3]["flow"] == 0 assert edata[3]["flows"] == {} From 5f7cbc20f97746a1b2b4a64d0ee11d58f7ab1177 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 23:19:47 +0100 Subject: [PATCH 04/10] Implement overlapping node handling in max flow calculations and add comprehensive tests for pairwise mode scenarios --- ngraph/network.py | 31 ++- tests/test_network.py | 168 ++++++++++++++- tests/workflow/test_capacity_probe.py | 300 ++++++++++++++++++++++++-- 3 files changed, 470 insertions(+), 29 deletions(-) diff --git a/ngraph/network.py b/ngraph/network.py index 868cb60..f8bd090 100644 --- a/ngraph/network.py +++ b/ngraph/network.py @@ -261,9 +261,20 @@ def max_flow( if not combined_src_nodes or not combined_snk_nodes: return {(combined_src_label, combined_snk_label): 0.0} - flow_val = self._compute_flow_single_group( - combined_src_nodes, combined_snk_nodes, shortest_path, flow_placement - ) + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: # If there's any overlap + # When source and sink groups overlap, flow is 0 + # due to flow conservation - no net flow from a set to itself + flow_val = 0.0 + else: + flow_val = self._compute_flow_single_group( + combined_src_nodes, + combined_snk_nodes, + shortest_path, + flow_placement, + ) return {(combined_src_label, combined_snk_label): flow_val} elif mode == "pairwise": @@ -271,9 +282,17 @@ def max_flow( for src_label, src_nodes in src_groups.items(): for snk_label, snk_nodes in snk_groups.items(): if src_nodes and snk_nodes: - flow_val = self._compute_flow_single_group( - src_nodes, snk_nodes, shortest_path, flow_placement - ) + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: # If there's any overlap + # When source and sink groups overlap, flow is 0 + # due to flow conservation - no net flow from a set to itself + flow_val = 0.0 + else: + flow_val = self._compute_flow_single_group( + src_nodes, snk_nodes, shortest_path, flow_placement + ) else: flow_val = 0.0 results[(src_label, snk_label)] = flow_val diff --git a/tests/test_network.py b/tests/test_network.py index 81b2814..126c906 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -610,9 +610,171 @@ def test_find_links(): assert b_links[0].id == link_b_c.id -# -# New tests to improve coverage for RiskGroup-related methods. -# +def test_max_flow_overlapping_patterns_combine_mode(): + """ + Tests that overlapping source/sink patterns return 0 flow in combine mode. + + When the same nodes match both source and sink patterns, flow conservation + principles dictate that no net flow can exist from a set to itself. + """ + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2")) + net.add_link(Link("N1", "N2", capacity=5.0)) + + # Same regex pattern matches both source and sink nodes + flow_result = net.max_flow( + source_path=r"^N(\d+)$", # Matches N1, N2 + sink_path=r"^N(\d+)$", # Matches N1, N2 (OVERLAPPING!) + mode="combine", + ) + + # Should return 0 flow due to overlapping groups + assert len(flow_result) == 1 + flow_val = list(flow_result.values())[0] + assert flow_val == 0.0 + + # Verify the combined label format + expected_label = ("1|2", "1|2") # Combined source and sink labels + assert expected_label in flow_result + + +def test_max_flow_overlapping_patterns_pairwise_mode(): + """ + Tests that overlapping source/sink patterns are handled correctly in pairwise mode. + + Self-loop cases (N1->N1, N2->N2) should return 0 flow due to flow conservation, + while valid paths should return appropriate flow values. + """ + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2")) + net.add_link(Link("N1", "N2", capacity=3.0)) + + # Same regex pattern matches both source and sink nodes + flow_result = net.max_flow( + source_path=r"^N(\d+)$", # Matches N1, N2 + sink_path=r"^N(\d+)$", # Matches N1, N2 (OVERLAPPING!) + mode="pairwise", + ) + + # Should return 4 results for 2x2 combinations + assert len(flow_result) == 4 + + expected_keys = {("1", "1"), ("1", "2"), ("2", "1"), ("2", "2")} + assert set(flow_result.keys()) == expected_keys + + # Self-loops should have 0 flow + assert flow_result[("1", "1")] == 0.0 # N1->N1 self-loop + assert flow_result[("2", "2")] == 0.0 # N2->N2 self-loop + + # Valid paths should have flow > 0 + # Note: reverse edges are added by default in to_strict_multidigraph() + assert flow_result[("1", "2")] == 3.0 # N1->N2 forward path + assert flow_result[("2", "1")] == 3.0 # N2->N1 reverse path + + +def test_max_flow_partial_overlap_pairwise(): + """ + Tests pairwise mode where source and sink patterns have partial overlap. + + Some combinations will be self-loops (0 flow) while others are valid paths. + """ + net = Network() + net.add_node(Node("SRC1")) + net.add_node(Node("SINK1")) + net.add_node(Node("BOTH1")) # Node that matches both patterns + net.add_node(Node("BOTH2")) # Node that matches both patterns + + # Create some connections + net.add_link(Link("SRC1", "SINK1", capacity=2.0)) + net.add_link(Link("SRC1", "BOTH1", capacity=1.0)) + net.add_link(Link("BOTH1", "SINK1", capacity=1.5)) + net.add_link(Link("BOTH2", "BOTH1", capacity=1.0)) + + flow_result = net.max_flow( + source_path=r"^(SRC\d+|BOTH\d+)$", # Matches SRC1, BOTH1, BOTH2 + sink_path=r"^(SINK\d+|BOTH\d+)$", # Matches SINK1, BOTH1, BOTH2 (partial overlap!) + mode="pairwise", + ) + + # Should return results for all combinations + assert len(flow_result) == 9 # 3 sources × 3 sinks + + # Self-loops for overlapping nodes should be 0 + assert flow_result[("BOTH1", "BOTH1")] == 0.0 + assert flow_result[("BOTH2", "BOTH2")] == 0.0 + + # Non-overlapping combinations should have meaningful flows + assert flow_result[("SRC1", "SINK1")] > 0.0 + + +def test_max_flow_complete_overlap_vs_non_overlap(): + """ + Compares behavior between complete overlap (self-loop) and non-overlapping patterns. + """ + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10.0)) + + # Test 1: Complete overlap (self-loop scenario) + overlap_result = net.max_flow( + source_path="A", + sink_path="A", # Same node! + mode="combine", + ) + assert overlap_result[("A", "A")] == 0.0 + + # Test 2: No overlap (normal scenario) + normal_result = net.max_flow( + source_path="A", + sink_path="B", # Different nodes + mode="combine", + ) + assert normal_result[("A", "B")] == 10.0 + + +def test_max_flow_overlapping_with_disabled_nodes(): + """ + Tests overlapping patterns when some overlapping nodes are disabled. + + Disabled nodes are still included in regex matching but filtered out during + flow computation, so they appear as groups with 0 flow. + """ + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2", disabled=True)) # Disabled overlapping node + net.add_node(Node("N3")) + + net.add_link(Link("N1", "N3", capacity=4.0)) + + # Patterns overlap, and N2 is disabled but still creates a group + flow_result = net.max_flow( + source_path=r"^N(\d+)$", # Matches N1, N2, N3 (N2 disabled but still counted) + sink_path=r"^N(\d+)$", # Matches N1, N2, N3 (N2 disabled but still counted) + mode="pairwise", + ) + + # N1, N2, N3 create groups "1", "2", "3", so we get 3x3 = 9 combinations + assert len(flow_result) == 9 + + # Self-loops return 0 (including disabled node) + assert flow_result[("1", "1")] == 0.0 # N1->N1 self-loop + assert flow_result[("2", "2")] == 0.0 # N2->N2 self-loop (disabled) + assert flow_result[("3", "3")] == 0.0 # N3->N3 self-loop + + # Flows involving disabled node N2 should be 0 + assert flow_result[("1", "2")] == 0.0 # N1->N2 (N2 disabled) + assert flow_result[("2", "1")] == 0.0 # N2->N1 (N2 disabled) + assert flow_result[("2", "3")] == 0.0 # N2->N3 (N2 disabled) + assert flow_result[("3", "2")] == 0.0 # N3->N2 (N2 disabled) + + # Valid flows between active nodes + assert flow_result[("1", "3")] == 4.0 # N1->N3 direct path + assert flow_result[("3", "1")] == 4.0 # N3->N1 reverse path + + def test_disable_risk_group_nonexistent(): """ If we call disable_risk_group on a name that is not in net.risk_groups, diff --git a/tests/workflow/test_capacity_probe.py b/tests/workflow/test_capacity_probe.py index 99c274f..98929ce 100644 --- a/tests/workflow/test_capacity_probe.py +++ b/tests/workflow/test_capacity_probe.py @@ -210,38 +210,298 @@ def test_capacity_probe_mode_pairwise_multiple_groups(mock_scenario): assert flows[label] == 2.0 -def test_capacity_probe_probe_reverse(mock_scenario): +def test_capacity_probe_pairwise_asymmetric_groups(mock_scenario): """ - Tests that probe_reverse=True computes flow in both directions. We expect - two sets of results: forward and reverse. + Tests pairwise mode with different numbers of source and sink groups. + 3 sources, 2 sinks => 3×2 = 6 result entries. """ - # Simple A->B link with capacity=3 - mock_scenario.network.add_node(Node("A")) - mock_scenario.network.add_node(Node("B")) - mock_scenario.network.add_link(Link("A", "B", capacity=3)) + # Create nodes + for i in [1, 2, 3]: + mock_scenario.network.add_node(Node(f"SRC{i}")) + for i in [1, 2]: + mock_scenario.network.add_node(Node(f"SINK{i}")) + mock_scenario.network.add_node(Node("HUB")) + + # Connect all sources to hub with capacity 3, hub to all sinks with capacity 2 + for i in [1, 2, 3]: + mock_scenario.network.add_link(Link(f"SRC{i}", "HUB", capacity=3)) + for i in [1, 2]: + mock_scenario.network.add_link(Link("HUB", f"SINK{i}", capacity=2)) step = CapacityProbe( - name="MyCapacityProbeReversed", - source_path="A", - sink_path="B", - probe_reverse=True, - mode="combine", + name="AsymmetricPairwise", + source_path=r"^SRC(\d+)$", # groups: "1", "2", "3" + sink_path=r"^SINK(\d+)$", # groups: "1", "2" + mode="pairwise", + ) + + step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 6 # 3×2 + + calls = mock_scenario.results.put.call_args_list + flows = {} + for c in calls: + step_name, label, flow_val = c[0] + flows[label] = flow_val + + expected_labels = { + "max_flow:[1 -> 1]", + "max_flow:[1 -> 2]", + "max_flow:[2 -> 1]", + "max_flow:[2 -> 2]", + "max_flow:[3 -> 1]", + "max_flow:[3 -> 2]", + } + assert set(flows.keys()) == expected_labels + # Each flow should be limited by the hub->sink capacity of 2 + for label in expected_labels: + assert flows[label] == 2.0 + + +def test_capacity_probe_pairwise_no_capturing_groups(mock_scenario): + """ + Tests pairwise mode when the regex patterns don't use capturing groups. + All matching nodes should be grouped under the pattern string itself. + """ + mock_scenario.network.add_node(Node("SOURCE_A")) + mock_scenario.network.add_node(Node("SOURCE_B")) + mock_scenario.network.add_node(Node("TARGET_X")) + mock_scenario.network.add_node(Node("TARGET_Y")) + + # Create a hub topology + mock_scenario.network.add_node(Node("HUB")) + mock_scenario.network.add_link(Link("SOURCE_A", "HUB", capacity=5)) + mock_scenario.network.add_link(Link("SOURCE_B", "HUB", capacity=5)) + mock_scenario.network.add_link(Link("HUB", "TARGET_X", capacity=3)) + mock_scenario.network.add_link(Link("HUB", "TARGET_Y", capacity=3)) + + step = CapacityProbe( + name="NoCapturingGroups", + source_path="^SOURCE_", # no capturing groups + sink_path="^TARGET_", # no capturing groups + mode="pairwise", + ) + + step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 1 # 1×1 since no capturing groups + + call_args = mock_scenario.results.put.call_args[0] + assert call_args[0] == "NoCapturingGroups" + assert call_args[1] == "max_flow:[^SOURCE_ -> ^TARGET_]" + # Combined flow through HUB: min(5+5, 3+3) = 6 + assert call_args[2] == 6.0 + + +def test_capacity_probe_pairwise_multiple_capturing_groups(mock_scenario): + """ + Tests pairwise mode with multiple capturing groups in the regex pattern. + Groups should be joined with '|'. + """ + # Create nodes with two-part naming: DC-Type pattern + mock_scenario.network.add_node(Node("DC1-WEB1")) + mock_scenario.network.add_node(Node("DC1-DB1")) + mock_scenario.network.add_node(Node("DC2-WEB1")) + mock_scenario.network.add_node(Node("DC2-DB1")) + + # Create hub topology + mock_scenario.network.add_node(Node("CORE")) + for dc in [1, 2]: + for svc in ["WEB", "DB"]: + mock_scenario.network.add_link(Link(f"DC{dc}-{svc}1", "CORE", capacity=4)) + + step = CapacityProbe( + name="MultiCapture", + source_path=r"^(DC\d+)-(WEB\d+)$", # captures: ("DC1", "WEB1"), etc. + sink_path=r"^(DC\d+)-(DB\d+)$", # captures: ("DC1", "DB1"), etc. + mode="pairwise", + ) + + step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 4 # 2×2 + + calls = mock_scenario.results.put.call_args_list + flows = {} + for c in calls: + step_name, label, flow_val = c[0] + flows[label] = flow_val + + expected_labels = { + "max_flow:[DC1|WEB1 -> DC1|DB1]", + "max_flow:[DC1|WEB1 -> DC2|DB1]", + "max_flow:[DC2|WEB1 -> DC1|DB1]", + "max_flow:[DC2|WEB1 -> DC2|DB1]", + } + assert set(flows.keys()) == expected_labels + for label in expected_labels: + assert flows[label] == 4.0 + + +def test_capacity_probe_pairwise_with_disabled_nodes(mock_scenario): + """ + Tests pairwise mode when some matched nodes are disabled. + Disabled nodes should not participate in flow computation. + """ + mock_scenario.network.add_node(Node("S1")) + mock_scenario.network.add_node(Node("S2", disabled=True)) # disabled + mock_scenario.network.add_node(Node("T1")) + mock_scenario.network.add_node(Node("T2")) + + mock_scenario.network.add_node(Node("HUB")) + mock_scenario.network.add_link(Link("S1", "HUB", capacity=5)) + mock_scenario.network.add_link(Link("S2", "HUB", capacity=5)) # disabled source + mock_scenario.network.add_link(Link("HUB", "T1", capacity=3)) + mock_scenario.network.add_link(Link("HUB", "T2", capacity=3)) + + step = CapacityProbe( + name="DisabledNodes", + source_path=r"^S(\d+)$", + sink_path=r"^T(\d+)$", + mode="pairwise", + ) + + step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 4 # still 2×2 pairs + + calls = mock_scenario.results.put.call_args_list + flows = {} + for c in calls: + step_name, label, flow_val = c[0] + flows[label] = flow_val + + # S2 is disabled, so flows involving group "2" should be 0 + assert flows["max_flow:[1 -> 1]"] == 3.0 # S1->T1 + assert flows["max_flow:[1 -> 2]"] == 3.0 # S1->T2 + assert flows["max_flow:[2 -> 1]"] == 0.0 # S2->T1 (S2 disabled) + assert flows["max_flow:[2 -> 2]"] == 0.0 # S2->T2 (S2 disabled) + + +def test_capacity_probe_pairwise_disconnected_topology(mock_scenario): + """ + Tests pairwise mode when some source-sink pairs have no connectivity. + Should return 0 flow for disconnected pairs. + """ + # Create two isolated islands + mock_scenario.network.add_node(Node("S1")) + mock_scenario.network.add_node(Node("T1")) + mock_scenario.network.add_link(Link("S1", "T1", capacity=10)) + + mock_scenario.network.add_node(Node("S2")) + mock_scenario.network.add_node(Node("T2")) + mock_scenario.network.add_link(Link("S2", "T2", capacity=8)) + + # No connectivity between islands + + step = CapacityProbe( + name="Disconnected", + source_path=r"^S(\d+)$", + sink_path=r"^T(\d+)$", + mode="pairwise", ) step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 4 - # Expect 2 calls: forward flow (A->B) and reverse flow (B->A). - assert mock_scenario.results.put.call_count == 2 calls = mock_scenario.results.put.call_args_list + flows = {} + for c in calls: + step_name, label, flow_val = c[0] + flows[label] = flow_val + + # Only same-island connections should have flow + assert flows["max_flow:[1 -> 1]"] == 10.0 # S1->T1 (connected) + assert flows["max_flow:[2 -> 2]"] == 8.0 # S2->T2 (connected) + assert flows["max_flow:[1 -> 2]"] == 0.0 # S1->T2 (disconnected) + assert flows["max_flow:[2 -> 1]"] == 0.0 # S2->T1 (disconnected) + +def test_capacity_probe_pairwise_single_group_each(mock_scenario): + """ + Tests pairwise mode with only one source group and one sink group. + Should behave similarly to combine mode but still produce pairwise-style labels. + """ + mock_scenario.network.add_node(Node("SRC1")) + mock_scenario.network.add_node(Node("SRC2")) + mock_scenario.network.add_node(Node("SINK1")) + mock_scenario.network.add_node(Node("SINK2")) + + mock_scenario.network.add_node(Node("HUB")) + mock_scenario.network.add_link(Link("SRC1", "HUB", capacity=4)) + mock_scenario.network.add_link(Link("SRC2", "HUB", capacity=6)) + mock_scenario.network.add_link(Link("HUB", "SINK1", capacity=5)) + mock_scenario.network.add_link(Link("HUB", "SINK2", capacity=3)) + + step = CapacityProbe( + name="SingleGroups", + source_path=r"^SRC", # no capturing groups, all sources in one group + sink_path=r"^SINK", # no capturing groups, all sinks in one group + mode="pairwise", + ) + + step.run(mock_scenario) + assert mock_scenario.results.put.call_count == 1 # 1×1 + + call_args = mock_scenario.results.put.call_args[0] + assert call_args[0] == "SingleGroups" + assert call_args[1] == "max_flow:[^SRC -> ^SINK]" + # Total flow: min(4+6, 5+3) = min(10, 8) = 8 + assert call_args[2] == 8.0 + + +def test_capacity_probe_pairwise_potential_infinite_loop(mock_scenario): + """ + Tests that overlapping source and sink patterns are handled gracefully. + + When the same nodes can be both sources and destinations (overlapping regex patterns), + the max flow calculation should detect this scenario and return 0 flow for overlapping + cases due to flow conservation principles - no net flow from a set to itself. + + This test verifies that scenarios like N1->N1 (self-loops) and overlapping groups + are handled correctly without causing infinite loops. + + Expected behavior: Should handle overlapping patterns gracefully and complete quickly, + returning 0 flow for self-loop cases and appropriate flows for valid paths. + """ + # Create nodes that match both source and sink patterns + mock_scenario.network.add_node(Node("N1")) + mock_scenario.network.add_node(Node("N2")) + + # Simple topology with normal capacity + mock_scenario.network.add_link(Link("N1", "N2", capacity=1.0)) + + step = CapacityProbe( + name="OverlappingPatternsTest", + # OVERLAPPING patterns - same nodes match both source and sink + source_path=r"^N(\d+)$", # Matches N1, N2 + sink_path=r"^N(\d+)$", # Matches N1, N2 (SAME NODES!) + mode="pairwise", # Test pairwise mode with overlapping patterns + ) + + step.run(mock_scenario) + + # Should return 4 results for 2×2 = N1->N1, N1->N2, N2->N1, N2->N2 + assert mock_scenario.results.put.call_count == 4 + + calls = mock_scenario.results.put.call_args_list flows = {} for c in calls: step_name, label, flow_val = c[0] - assert step_name == "MyCapacityProbeReversed" + assert step_name == "OverlappingPatternsTest" flows[label] = flow_val - # We expect "max_flow:[A -> B]" = 3, and "max_flow:[B -> A]" = 3 - assert "max_flow:[A -> B]" in flows - assert "max_flow:[B -> A]" in flows - assert flows["max_flow:[A -> B]"] == 3.0 - assert flows["max_flow:[B -> A]"] == 3.0 + expected_labels = { + "max_flow:[1 -> 1]", # N1->N1 (self-loop) + "max_flow:[1 -> 2]", # N1->N2 (valid path) + "max_flow:[2 -> 1]", # N2->N1 (no path) + "max_flow:[2 -> 2]", # N2->N2 (self-loop) + } + assert set(flows.keys()) == expected_labels + + # Self-loops should have 0 flow due to flow conservation + assert flows["max_flow:[1 -> 1]"] == 0.0 # N1->N1 self-loop + assert flows["max_flow:[2 -> 2]"] == 0.0 # N2->N2 self-loop + + # Valid paths should have appropriate flows + assert flows["max_flow:[1 -> 2]"] == 1.0 # N1->N2 has capacity 1.0 + assert ( + flows["max_flow:[2 -> 1]"] == 1.0 + ) # N2->N1 has reverse edge with capacity 1.0 From 94da3a1f8adaba727684596cbbe46812eed5ce15 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 23:29:47 +0100 Subject: [PATCH 05/10] Add configuration for traffic manager and update estimation logic - Introduced TrafficManagerConfig class to manage traffic demand placement settings. - Updated default rounds handling in TrafficManager to use configuration values. - Added tests for configuration defaults and estimation logic. --- ngraph/__init__.py | 4 +-- ngraph/config.py | 30 +++++++++++++++++ ngraph/traffic_manager.py | 15 +++++---- tests/test_config.py | 68 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 ngraph/config.py create mode 100644 tests/test_config.py diff --git a/ngraph/__init__.py b/ngraph/__init__.py index 427d228..5a2ab94 100644 --- a/ngraph/__init__.py +++ b/ngraph/__init__.py @@ -1,5 +1,5 @@ from __future__ import annotations -from . import cli, transform +from . import cli, config, transform -__all__ = ["cli", "transform"] +__all__ = ["cli", "config", "transform"] diff --git a/ngraph/config.py b/ngraph/config.py new file mode 100644 index 0000000..451f840 --- /dev/null +++ b/ngraph/config.py @@ -0,0 +1,30 @@ +"""Configuration for NetGraph.""" + +from dataclasses import dataclass + + +@dataclass +class TrafficManagerConfig: + """Configuration for traffic demand placement estimation.""" + + # Default number of placement rounds when no data is available + default_rounds: int = 5 + + # Minimum number of placement rounds + min_rounds: int = 5 + + # Maximum number of placement rounds + max_rounds: int = 100 + + # Multiplier for ratio-based round estimation + ratio_base: int = 5 + ratio_multiplier: int = 5 + + def estimate_rounds(self, demand_capacity_ratio: float) -> int: + """Calculate placement rounds based on demand to capacity ratio.""" + estimated = int(self.ratio_base + self.ratio_multiplier * demand_capacity_ratio) + return max(self.min_rounds, min(estimated, self.max_rounds)) + + +# Global configuration instance +TRAFFIC_CONFIG = TrafficManagerConfig() diff --git a/ngraph/traffic_manager.py b/ngraph/traffic_manager.py index 6d4c3de..3445c56 100644 --- a/ngraph/traffic_manager.py +++ b/ngraph/traffic_manager.py @@ -500,23 +500,25 @@ def _expand_full_mesh( def _estimate_rounds(self) -> int: """Estimates a suitable number of placement rounds by comparing the median demand volume and the median edge capacity. Returns - a default of 5 rounds if there is insufficient data for a + a default number of rounds if there is insufficient data for a meaningful calculation. Returns: int: Estimated number of rounds to use for traffic placement. """ + from ngraph.config import TRAFFIC_CONFIG + if not self.demands: - return 5 + return TRAFFIC_CONFIG.default_rounds demand_volumes = [demand.volume for demand in self.demands if demand.volume > 0] if not demand_volumes: - return 5 + return TRAFFIC_CONFIG.default_rounds median_demand = statistics.median(demand_volumes) if not self.graph: - return 5 + return TRAFFIC_CONFIG.default_rounds edges = self.graph.get_edges().values() capacities = [ @@ -525,9 +527,8 @@ def _estimate_rounds(self) -> int: if edge_data[3].get("capacity", 0) > 0 ] if not capacities: - return 5 + return TRAFFIC_CONFIG.default_rounds median_capacity = statistics.median(capacities) ratio = median_demand / median_capacity - guessed_rounds = int(5 + 5 * ratio) - return max(5, min(guessed_rounds, 100)) + return TRAFFIC_CONFIG.estimate_rounds(ratio) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..4a3dc42 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,68 @@ +"""Test the configuration module functionality.""" + +from ngraph.config import TRAFFIC_CONFIG, TrafficManagerConfig + + +def test_traffic_manager_config_defaults(): + """Test that the default configuration values are correct.""" + config = TrafficManagerConfig() + + assert config.default_rounds == 5 + assert config.min_rounds == 5 + assert config.max_rounds == 100 + assert config.ratio_base == 5 + assert config.ratio_multiplier == 5 + + +def test_traffic_manager_config_estimate_rounds(): + """Test the estimate_rounds method with various ratios.""" + config = TrafficManagerConfig() + + # Test with ratio 0 (should return base value, clamped to min) + assert config.estimate_rounds(0.0) == 5 + + # Test with ratio 1 (should return base + multiplier = 10) + assert config.estimate_rounds(1.0) == 10 + + # Test with ratio 2 (should return base + 2*multiplier = 15) + assert config.estimate_rounds(2.0) == 15 + + # Test with very high ratio (should clamp to max) + assert config.estimate_rounds(100.0) == 100 + + +def test_traffic_manager_config_bounds(): + """Test that bounds are properly enforced.""" + config = TrafficManagerConfig() + + # Test minimum bound + assert config.estimate_rounds(-1.0) == config.min_rounds + + # Test maximum bound - use a ratio that would exceed max_rounds + high_ratio = (config.max_rounds + 10) / config.ratio_multiplier + assert config.estimate_rounds(high_ratio) == config.max_rounds + + +def test_global_config_instance(): + """Test that the global TRAFFIC_CONFIG instance works.""" + assert TRAFFIC_CONFIG.default_rounds == 5 + assert TRAFFIC_CONFIG.estimate_rounds(1.0) == 10 + + +def test_custom_config(): + """Test creating a custom configuration.""" + custom_config = TrafficManagerConfig( + default_rounds=10, min_rounds=8, max_rounds=50, ratio_base=3, ratio_multiplier=2 + ) + + assert custom_config.default_rounds == 10 + assert custom_config.min_rounds == 8 + assert custom_config.max_rounds == 50 + assert custom_config.ratio_base == 3 + assert custom_config.ratio_multiplier == 2 + + # Test estimation with custom values: 3 + 2*1.5 = 6 + assert custom_config.estimate_rounds(1.5) == 8 # Clamped to min_rounds + + # Test estimation: 3 + 2*10 = 23 + assert custom_config.estimate_rounds(10.0) == 23 From 98ffb5172f64052efef2656ba042be9a75f2279b Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Tue, 10 Jun 2025 23:55:17 +0100 Subject: [PATCH 06/10] Add configurable iteration limits to FlowPolicy and enhance infinite loop detection --- ngraph/lib/flow_policy.py | 40 +++++++++++-- tests/lib/test_flow_policy.py | 103 +++++++++++++++++++++++++++++++++- 2 files changed, 134 insertions(+), 9 deletions(-) diff --git a/ngraph/lib/flow_policy.py b/ngraph/lib/flow_policy.py index e3aa60c..9e1cedd 100644 --- a/ngraph/lib/flow_policy.py +++ b/ngraph/lib/flow_policy.py @@ -56,6 +56,8 @@ def __init__( ] = None, edge_select_value: Optional[Any] = None, reoptimize_flows_on_each_placement: bool = False, + max_no_progress_iterations: int = 100, + max_total_iterations: int = 10000, ) -> None: """Initializes a FlowPolicy instance. @@ -72,6 +74,8 @@ def __init__( edge_select_func: Custom function for edge selection, if needed. edge_select_value: Additional parameter for certain edge selection strategies. reoptimize_flows_on_each_placement: If True, re-run path optimization after every placement. + max_no_progress_iterations: Maximum consecutive iterations with no meaningful progress before detecting infinite loops. + max_total_iterations: Absolute maximum iterations regardless of progress (safety net for pathological cases). Raises: ValueError: If static_paths length does not match max_flow_count, @@ -93,6 +97,10 @@ def __init__( reoptimize_flows_on_each_placement ) + # Termination parameters for place_demand algorithm + self.max_no_progress_iterations: int = max_no_progress_iterations + self.max_total_iterations: int = max_total_iterations + # Dictionary to track all flows by their FlowIndex. self.flows: Dict[Tuple, Flow] = {} @@ -400,7 +408,8 @@ def place_demand( volume successfully placed and remaining_volume is any unplaced volume. Raises: - RuntimeError: If an infinite loop is detected (safety net). + RuntimeError: If an infinite loop is detected due to misconfigured flow policy + parameters, or if maximum iteration limit is exceeded. """ if not self.flows: self._create_flows(flow_graph, src_node, dst_node, flow_class, min_flow) @@ -409,7 +418,8 @@ def place_demand( target_flow_volume = target_flow_volume or volume total_placed_flow = 0.0 - iteration_count = 0 + consecutive_no_progress = 0 + total_iterations = 0 while volume >= base.MIN_FLOW and flow_queue: flow = flow_queue.popleft() @@ -418,6 +428,28 @@ def place_demand( ) volume -= placed_flow total_placed_flow += placed_flow + total_iterations += 1 + + # Track progress to detect infinite loops in flow creation/optimization + if placed_flow < base.MIN_FLOW: + consecutive_no_progress += 1 + if consecutive_no_progress >= self.max_no_progress_iterations: + # This indicates an infinite loop where flows keep being created + # but can't place any meaningful volume + raise RuntimeError( + f"Infinite loop detected in place_demand: " + f"{consecutive_no_progress} consecutive iterations with no progress. " + f"This typically indicates misconfigured flow policy parameters " + f"(e.g., non-capacity-aware edge selection with high max_flow_count)." + ) + else: + consecutive_no_progress = 0 # Reset counter on progress + + # Safety net for pathological cases + if total_iterations > self.max_total_iterations: + raise RuntimeError( + f"Maximum iteration limit ({self.max_total_iterations}) exceeded in place_demand." + ) # If the flow can accept more volume, attempt to create or re-optimize. if ( @@ -435,10 +467,6 @@ def place_demand( if new_flow: flow_queue.append(new_flow) - iteration_count += 1 - if iteration_count > 10000: - raise RuntimeError("Infinite loop detected in place_demand.") - # For EQUAL_BALANCED placement, rebalance flows to maintain equal volumes. if self.flow_placement == FlowPlacement.EQUAL_BALANCED and len(self.flows) > 0: target_flow_volume = self.placed_demand / float(len(self.flows)) diff --git a/tests/lib/test_flow_policy.py b/tests/lib/test_flow_policy.py index 934ec25..c4b6fff 100644 --- a/tests/lib/test_flow_policy.py +++ b/tests/lib/test_flow_policy.py @@ -560,8 +560,9 @@ def test_flow_policy_place_demand_8(self, line1): def test_flow_policy_place_demand_9(self, line1): """ - Causes a RuntimeError due to infinite loop. The flow policy is incorrectly - configured to use non-capacity aware edge selection without reasonable limit on the number of flows. + Tests infinite loop detection with a flow policy that creates many flows + but can't place meaningful volume. The algorithm should detect this as an + infinite loop and raise a descriptive RuntimeError. """ flow_policy = FlowPolicy( path_alg=PathAlg.SPF, @@ -571,11 +572,107 @@ def test_flow_policy_place_demand_9(self, line1): max_flow_count=1000000, ) r = init_flow_graph(line1) - with pytest.raises(RuntimeError): + # Should raise RuntimeError due to infinite loop detection + with pytest.raises( + RuntimeError, match="Infinite loop detected in place_demand" + ): placed_flow, remaining_flow = flow_policy.place_demand( r, "A", "C", "test_flow", 7 ) + def test_flow_policy_place_demand_normal_termination(self, line1): + """ + Tests normal termination when algorithm naturally runs out of capacity. + This should terminate gracefully without raising an exception, even if + some volume remains unplaced. + """ + flow_policy = FlowPolicy( + path_alg=PathAlg.SPF, + flow_placement=FlowPlacement.PROPORTIONAL, + edge_select=EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING, # Capacity-aware + multipath=True, + max_flow_count=10, # Reasonable limit + ) + r = init_flow_graph(line1) + # Should terminate gracefully when capacity is exhausted + placed_flow, remaining_flow = flow_policy.place_demand( + r, + "A", + "C", + "test_flow", + 100, # Large demand that exceeds capacity + ) + # Should place some flow but not all due to capacity constraints + assert placed_flow >= 0 + assert remaining_flow >= 0 + assert placed_flow + remaining_flow == 100 + # Should place at least some flow (line1 has capacity of 5) + assert placed_flow > 0 + + def test_flow_policy_place_demand_max_iterations(self, line1): + """ + Tests the maximum iteration limit safety net. This creates a scenario that + forces many iterations by using a very low iteration limit parameter. + """ + # Create a flow policy with very low max_total_iterations for testing + # Use EQUAL_BALANCED with unlimited flows to force many iterations + flow_policy = FlowPolicy( + path_alg=PathAlg.SPF, + flow_placement=FlowPlacement.EQUAL_BALANCED, + edge_select=EdgeSelect.ALL_MIN_COST, + multipath=True, + max_flow_count=1000000, # High flow count to create many flows + max_total_iterations=2, # Very low limit to trigger the error easily + ) + + r = init_flow_graph(line1) + + # This should hit the maximum iteration limit (2) before completing + # because it tries to create many flows in EQUAL_BALANCED mode + with pytest.raises( + RuntimeError, match="Maximum iteration limit .* exceeded in place_demand" + ): + flow_policy.place_demand(r, "A", "C", "test_flow", 7) + + def test_flow_policy_configurable_iteration_limits(self, line1): + """ + Tests that the iteration limit parameters are properly configurable + and affect the behavior as expected. + """ + # Test with custom limits + flow_policy = FlowPolicy( + path_alg=PathAlg.SPF, + flow_placement=FlowPlacement.EQUAL_BALANCED, + edge_select=EdgeSelect.ALL_MIN_COST, + multipath=True, + max_flow_count=1000000, + max_no_progress_iterations=5, # Very low limit + max_total_iterations=20000, # High total limit + ) + + r = init_flow_graph(line1) + + # Should hit the no-progress limit before the total limit + with pytest.raises( + RuntimeError, match="5 consecutive iterations with no progress" + ): + flow_policy.place_demand(r, "A", "C", "test_flow", 7) + + # Test with default values (should work same as before) + flow_policy_default = FlowPolicy( + path_alg=PathAlg.SPF, + flow_placement=FlowPlacement.PROPORTIONAL, + edge_select=EdgeSelect.ALL_MIN_COST_WITH_CAP_REMAINING, + multipath=True, + ) + + # Should complete normally with defaults + r2 = init_flow_graph(line1) + placed_flow, remaining_flow = flow_policy_default.place_demand( + r2, "A", "C", "test_flow", 3 + ) + assert placed_flow > 0 + def test_flow_policy_place_demand_10(self, square1): PATH_BUNDLE1 = PathBundle( "A", "C", {"A": {}, "C": {"B": [3]}, "B": {"A": [2]}}, 2 From 8b6ef0e12a3d274615c8b0bb42260109c4c10a7d Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Wed, 11 Jun 2025 00:08:40 +0100 Subject: [PATCH 07/10] Refactor api doc autogeneration --- dev/generate_api_docs.py | 84 ++- docs/reference/api-full.md | 1466 ++++++++++++++++++++++++------------ 2 files changed, 1044 insertions(+), 506 deletions(-) diff --git a/dev/generate_api_docs.py b/dev/generate_api_docs.py index 8a4ab37..10b61af 100755 --- a/dev/generate_api_docs.py +++ b/dev/generate_api_docs.py @@ -9,6 +9,7 @@ import argparse import dataclasses +import glob import importlib import inspect import os @@ -21,6 +22,57 @@ sys.path.insert(0, ".") +def discover_modules(): + """Automatically discover all documentable Python modules in the ngraph package.""" + modules = [] + + # Find all .py files in ngraph/ + for py_file in glob.glob("ngraph/**/*.py", recursive=True): + # Skip files that shouldn't be documented + filename = os.path.basename(py_file) + if filename in ["__init__.py", "__main__.py"]: + continue + + # Convert file path to module name + module_path = py_file.replace("/", ".").replace(".py", "") + modules.append(module_path) + + # Sort modules in logical order for documentation + def module_sort_key(module_name): + """Sort key to organize modules logically.""" + parts = module_name.split(".") + + # Main ngraph modules first + if len(parts) == 2: # ngraph.xxx + return (0, parts[1]) + + # Then lib modules + elif len(parts) == 3 and parts[1] == "lib": # ngraph.lib.xxx + return (1, parts[2]) + + # Then algorithm modules + elif len(parts) == 4 and parts[1:3] == [ + "lib", + "algorithms", + ]: # ngraph.lib.algorithms.xxx + return (2, parts[3]) + + # Then workflow modules + elif len(parts) == 3 and parts[1] == "workflow": # ngraph.workflow.xxx + return (3, parts[2]) + + # Then transform modules + elif len(parts) == 3 and parts[1] == "transform": # ngraph.transform.xxx + return (4, parts[2]) + + # Everything else at the end + else: + return (9, module_name) + + modules.sort(key=module_sort_key) + return modules + + def get_class_info(cls): """Extract comprehensive information about a class.""" info = { @@ -160,30 +212,10 @@ def generate_api_documentation(output_to_file=False): str: The generated documentation (when output_to_file=False) """ - # Modules to document (in order) - modules = [ - "ngraph.scenario", - "ngraph.network", - "ngraph.explorer", - "ngraph.components", - "ngraph.blueprints", - "ngraph.traffic_demand", - "ngraph.failure_policy", - "ngraph.failure_manager", - "ngraph.traffic_manager", - "ngraph.results", - "ngraph.lib.graph", - "ngraph.lib.util", - "ngraph.lib.algorithms.spf", - "ngraph.lib.algorithms.max_flow", - "ngraph.lib.algorithms.base", - "ngraph.workflow.base", - "ngraph.workflow.build_graph", - "ngraph.workflow.capacity_probe", - "ngraph.transform.base", - "ngraph.transform.enable_nodes", - "ngraph.transform.distribute_external", - ] + # Automatically discover all documentable modules + modules = discover_modules() + + print(f"🔍 Auto-discovered {len(modules)} modules to document...") # Generate header timestamp = datetime.now().strftime("%B %d, %Y at %H:%M UTC") @@ -201,11 +233,13 @@ def generate_api_documentation(output_to_file=False): **Generated from source code on:** {timestamp} +**Modules auto-discovered:** {len(modules)} + --- """ - print("🔍 Generating API documentation...") + print("📝 Generating API documentation...") doc = header # Generate documentation for each module diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index b00dfe9..c2d51f1 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -10,45 +10,428 @@ For a curated, example-driven API guide, see **[api.md](api.md)**. > - **[CLI Reference](cli.md)** - Command-line interface > - **[DSL Reference](dsl.md)** - YAML syntax guide -**Generated from source code on:** June 09, 2025 at 00:40 UTC +**Generated from source code on:** June 11, 2025 at 00:06 UTC + +**Modules auto-discovered:** 35 --- -## ngraph.scenario +## ngraph.blueprints -### Scenario +### Blueprint -Represents a complete scenario for building and executing network workflows. +Represents a reusable blueprint for hierarchical sub-topologies. -This scenario includes: - - A network (nodes/links), constructed via blueprint expansion. - - A failure policy (one or more rules). - - A set of traffic demands. - - A list of workflow steps to execute. - - A results container for storing outputs. - - A components_library for hardware/optics definitions. +A blueprint may contain multiple groups of nodes (each can have a node_count +and a name_template), plus adjacency rules describing how those groups connect. -Typical usage example: +Attributes: + name (str): Unique identifier of this blueprint. + groups (Dict[str, Any]): A mapping of group_name -> group definition. + Allowed top-level keys in each group definition here are the same + as in normal group definitions (e.g. node_count, name_template, + attrs, disabled, risk_groups, or nested use_blueprint references, etc.). + adjacency (List[Dict[str, Any]]): A list of adjacency definitions + describing how these groups are linked, using the DSL fields + (source, target, pattern, link_params, etc.). - scenario = Scenario.from_yaml(yaml_str, default_components=default_lib) - scenario.run() - # Inspect scenario.results +**Attributes:** + +- `name` (str) +- `groups` (Dict[str, Any]) +- `adjacency` (List[Dict[str, Any]]) + +### DSLExpansionContext + +Carries the blueprint definitions and the final Network instance +to be populated during DSL expansion. + +Attributes: + blueprints (Dict[str, Blueprint]): Dictionary of blueprint-name -> Blueprint. + network (Network): The Network into which expanded nodes/links are inserted. + +**Attributes:** + +- `blueprints` (Dict[str, Blueprint]) +- `network` (Network) + +### expand_network_dsl(data: 'Dict[str, Any]') -> 'Network' + +Expands a combined blueprint + network DSL into a complete Network object. + +Overall flow: + 1) Parse "blueprints" into Blueprint objects. + 2) Build a new Network from "network" metadata (e.g. name, version). + 3) Expand 'network["groups"]'. + - If a group references a blueprint, incorporate that blueprint's subgroups + while merging parent's attrs + disabled + risk_groups into subgroups. + - Otherwise, directly create nodes (a "direct node group"). + 4) Process any direct node definitions (network["nodes"]). + 5) Expand adjacency definitions in 'network["adjacency"]'. + 6) Process any direct link definitions (network["links"]). + 7) Process link overrides (in order if multiple overrides match). + 8) Process node overrides (in order if multiple overrides match). + +Under the new rules: + - Only certain top-level fields are permitted in each structure. Any extra + keys raise a ValueError. "attrs" is where arbitrary user fields go. + - For link_params, recognized fields are "capacity", "cost", "disabled", + "risk_groups", "attrs". Everything else must go inside link_params["attrs"]. + - For node/group definitions, recognized fields include "node_count", + "name_template", "attrs", "disabled", "risk_groups" or "use_blueprint" + for blueprint-based groups. + +Args: + data (Dict[str, Any]): The YAML-parsed dictionary containing + optional "blueprints" + "network". + +Returns: + Network: The fully expanded Network object with all nodes and links. + +--- + +## ngraph.cli + +### main(argv: 'Optional[List[str]]' = None) -> 'None' + +Entry point for the ``ngraph`` command. + +Args: + argv: Optional list of command-line arguments. If ``None``, ``sys.argv`` + is used. + +--- + +## ngraph.components + +### Component + +A generic component that can represent chassis, line cards, optics, etc. +Components can have nested children, each with their own cost, power, etc. + +Attributes: + name (str): Name of the component (e.g., "SpineChassis" or "400G-LR4"). + component_type (str): A string label (e.g., "chassis", "linecard", "optic"). + description (str): A human-readable description of this component. + cost (float): Cost (capex) of a single instance of this component. + power_watts (float): Typical/nominal power usage (watts) for one instance. + power_watts_max (float): Maximum/peak power usage (watts) for one instance. + capacity (float): A generic capacity measure (e.g., platform capacity). + ports (int): Number of ports if relevant for this component. + count (int): How many identical copies of this component are present. + attrs (Dict[str, Any]): Arbitrary key-value attributes for extra metadata. + children (Dict[str, Component]): Nested child components (e.g., line cards + inside a chassis), keyed by child name. + +**Attributes:** + +- `name` (str) +- `component_type` (str) = generic +- `description` (str) +- `cost` (float) = 0.0 +- `power_watts` (float) = 0.0 +- `power_watts_max` (float) = 0.0 +- `capacity` (float) = 0.0 +- `ports` (int) = 0 +- `count` (int) = 1 +- `attrs` (Dict[str, Any]) = {} +- `children` (Dict[str, Component]) = {} + +**Methods:** + +- `as_dict(self, include_children: 'bool' = True) -> 'Dict[str, Any]'` + - Returns a dictionary containing all properties of this component. +- `total_capacity(self) -> 'float'` + - Computes the total (recursive) capacity of this component, +- `total_cost(self) -> 'float'` + - Computes the total (recursive) cost of this component, including children, +- `total_power(self) -> 'float'` + - Computes the total *typical* (recursive) power usage of this component, +- `total_power_max(self) -> 'float'` + - Computes the total *peak* (recursive) power usage of this component, + +### ComponentsLibrary + +Holds a collection of named Components. Each entry is a top-level "template" +that can be referenced for cost/power/capacity lookups, possibly with nested children. + +Example (YAML-like): + components: + BigSwitch: + component_type: chassis + cost: 20000 + power_watts: 1750 + capacity: 25600 + children: + PIM16Q-16x200G: + component_type: linecard + cost: 1000 + power_watts: 10 + ports: 16 + count: 8 + 200G-FR4: + component_type: optic + cost: 2000 + power_watts: 6 + power_watts_max: 6.5 + +**Attributes:** + +- `components` (Dict[str, Component]) = {} + +**Methods:** + +- `clone(self) -> 'ComponentsLibrary'` + - Creates a deep copy of this ComponentsLibrary. +- `from_dict(data: 'Dict[str, Any]') -> 'ComponentsLibrary'` + - Constructs a ComponentsLibrary from a dictionary of raw component definitions. +- `from_yaml(yaml_str: 'str') -> 'ComponentsLibrary'` + - Constructs a ComponentsLibrary from a YAML string. If the YAML contains +- `get(self, name: 'str') -> 'Optional[Component]'` + - Retrieves a Component by its name from the library. +- `merge(self, other: 'ComponentsLibrary', override: 'bool' = True) -> 'ComponentsLibrary'` + - Merges another ComponentsLibrary into this one. By default (override=True), + +--- + +## ngraph.config + +Configuration for NetGraph. + +### TrafficManagerConfig + +Configuration for traffic demand placement estimation. + +**Attributes:** + +- `default_rounds` (int) = 5 +- `min_rounds` (int) = 5 +- `max_rounds` (int) = 100 +- `ratio_base` (int) = 5 +- `ratio_multiplier` (int) = 5 + +**Methods:** + +- `estimate_rounds(self, demand_capacity_ratio: float) -> int` + - Calculate placement rounds based on demand to capacity ratio. + +--- + +## ngraph.explorer + +### ExternalLinkBreakdown + +Holds stats for external links to a particular other subtree. + +Attributes: + link_count (int): Number of links to that other subtree. + link_capacity (float): Sum of capacities for those links. + +**Attributes:** + +- `link_count` (int) = 0 +- `link_capacity` (float) = 0.0 + +### NetworkExplorer + +Provides hierarchical exploration of a Network, computing statistics in two modes: +'all' (ignores disabled) and 'active' (only enabled). + +**Methods:** + +- `explore_network(network: 'Network', components_library: 'Optional[ComponentsLibrary]' = None) -> 'NetworkExplorer'` + - Build a NetworkExplorer, constructing a tree plus 'all' and 'active' stats. +- `print_tree(self, node: 'Optional[TreeNode]' = None, indent: 'int' = 0, max_depth: 'Optional[int]' = None, skip_leaves: 'bool' = False, detailed: 'bool' = False, include_disabled: 'bool' = True) -> 'None'` + - Print the hierarchy from 'node' down (default: root). + +### TreeNode + +Represents a node in the hierarchical tree. + +Attributes: + name (str): Name/label of this node. + parent (Optional[TreeNode]): Pointer to the parent tree node. + children (Dict[str, TreeNode]): Mapping of child name -> child TreeNode. + subtree_nodes (Set[str]): Node names in the subtree (all nodes, ignoring disabled). + active_subtree_nodes (Set[str]): Node names in the subtree (only enabled). + stats (TreeStats): Aggregated stats for "all" view. + active_stats (TreeStats): Aggregated stats for "active" (only enabled) view. + raw_nodes (List[Node]): Direct Node objects at this hierarchy level. + +**Attributes:** + +- `name` (str) +- `parent` (Optional[TreeNode]) +- `children` (Dict[str, TreeNode]) = {} +- `subtree_nodes` (Set[str]) = set() +- `active_subtree_nodes` (Set[str]) = set() +- `stats` (TreeStats) = TreeStats(node_count=0, internal_link_count=0, internal_link_capacity=0.0, external_link_count=0, external_link_capacity=0.0, external_link_details={}, total_cost=0.0, total_power=0.0) +- `active_stats` (TreeStats) = TreeStats(node_count=0, internal_link_count=0, internal_link_capacity=0.0, external_link_count=0, external_link_capacity=0.0, external_link_details={}, total_cost=0.0, total_power=0.0) +- `raw_nodes` (List[Node]) = [] + +**Methods:** + +- `add_child(self, child_name: 'str') -> 'TreeNode'` + - Ensure a child node named 'child_name' exists and return it. +- `is_leaf(self) -> 'bool'` + - Return True if this node has no children. + +### TreeStats + +Aggregated statistics for a single tree node (subtree). + +Attributes: + node_count (int): Total number of nodes in this subtree. + internal_link_count (int): Number of internal links in this subtree. + internal_link_capacity (float): Sum of capacities for those internal links. + external_link_count (int): Number of external links from this subtree to another. + external_link_capacity (float): Sum of capacities for those external links. + external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown by other subtree path. + total_cost (float): Cumulative cost (nodes + links). + total_power (float): Cumulative power (nodes + links). + +**Attributes:** + +- `node_count` (int) = 0 +- `internal_link_count` (int) = 0 +- `internal_link_capacity` (float) = 0.0 +- `external_link_count` (int) = 0 +- `external_link_capacity` (float) = 0.0 +- `external_link_details` (Dict[str, ExternalLinkBreakdown]) = {} +- `total_cost` (float) = 0.0 +- `total_power` (float) = 0.0 + +--- + +## ngraph.failure_manager + +### FailureManager + +Applies FailurePolicy to a Network, runs traffic placement, and (optionally) +repeats multiple times for Monte Carlo experiments. + +Attributes: + network (Network): The underlying network to mutate (enable/disable nodes/links). + traffic_demands (List[TrafficDemand]): List of demands to place after failures. + failure_policy (Optional[FailurePolicy]): The policy describing what fails. + default_flow_policy_config: The default flow policy for any demands lacking one. + +**Methods:** + +- `apply_failures(self) -> 'None'` + - Apply the current failure_policy to self.network (in-place). +- `run_monte_carlo_failures(self, iterations: 'int', parallelism: 'int' = 1) -> 'Dict[str, Any]'` + - Repeatedly applies (randomized) failures to the network and accumulates +- `run_single_failure_scenario(self) -> 'List[TrafficResult]'` + - Applies failures to the network, places the demands, and returns per-demand results. + +--- + +## ngraph.failure_policy + +### FailureCondition + +A single condition for matching an entity's attribute with an operator and value. + +Example usage (YAML): + conditions: + - attr: "capacity" + operator: "<" + value: 100 + +Attributes: + attr (str): + The name of the attribute to inspect (e.g., "capacity", "region"). + operator (str): + The comparison operator: "==", "!=", "<", "<=", ">", ">=", + "contains", "not_contains", "any_value", or "no_value". + value (Any): + The value to compare against (e.g., 100, True, "foo", etc.). + +**Attributes:** + +- `attr` (str) +- `operator` (str) +- `value` (Any) + +### FailurePolicy + +A container for multiple FailureRules plus optional metadata in `attrs`. + +The main entry point is `apply_failures`, which: + 1) For each rule, gather the relevant entities (node, link, or risk_group). + 2) Match them based on rule conditions (or skip if 'logic=any'). + 3) Apply the selection strategy (all, random, or choice). + 4) Collect the union of all failed entities across all rules. + 5) Optionally expand failures by shared-risk groups or sub-risks. + +Large-scale performance: + - If you set `use_cache=True`, matched sets for each rule are cached, + so repeated calls to `apply_failures` can skip re-matching if the + network hasn't changed. If your network changes between calls, + you should clear the cache or re-initialize the policy. + +Attributes: + rules (List[FailureRule]): + A list of FailureRules to apply. + attrs (Dict[str, Any]): + Arbitrary metadata about this policy (e.g. "name", "description"). + fail_shared_risk_groups (bool): + If True, after initial selection, expand failures among any + node/link that shares a risk group with a failed entity. + fail_risk_group_children (bool): + If True, and if a risk_group is marked as failed, expand to + children risk_groups recursively. + use_cache (bool): + If True, match results for each rule are cached to speed up + repeated calls. If the network changes, the cached results + may be stale. + +**Attributes:** + +- `rules` (List[FailureRule]) = [] +- `attrs` (Dict[str, Any]) = {} +- `fail_shared_risk_groups` (bool) = False +- `fail_risk_group_children` (bool) = False +- `use_cache` (bool) = False +- `_match_cache` (Dict[int, Set[str]]) = {} + +**Methods:** + +- `apply_failures(self, network_nodes: 'Dict[str, Any]', network_links: 'Dict[str, Any]', network_risk_groups: 'Dict[str, Any] | None' = None) -> 'List[str]'` + - Identify which entities fail given the defined rules, then optionally -**Attributes:** +### FailureRule -- `network` (Network) -- `failure_policy` (Optional[FailurePolicy]) -- `traffic_demands` (List[TrafficDemand]) -- `workflow` (List[WorkflowStep]) -- `results` (Results) = Results(_store={}) -- `components_library` (ComponentsLibrary) = ComponentsLibrary(components={}) +Defines how to match and then select entities for failure. -**Methods:** +Attributes: + entity_scope (EntityScope): + The type of entities this rule applies to: "node", "link", or "risk_group". + conditions (List[FailureCondition]): + A list of conditions to filter matching entities. + logic (Literal["and", "or", "any"]): + "and": All conditions must be true for a match. + "or": At least one condition is true for a match. + "any": Skip condition checks and match all. + rule_type (Literal["random", "choice", "all"]): + The selection strategy among the matched set: + - "random": each matched entity is chosen with probability = `probability`. + - "choice": pick exactly `count` items from the matched set (random sample). + - "all": select every matched entity in the matched set. + probability (float): + Probability in [0,1], used if `rule_type="random"`. + count (int): + Number of entities to pick if `rule_type="choice"`. -- `from_yaml(yaml_str: 'str', default_components: 'Optional[ComponentsLibrary]' = None) -> 'Scenario'` - - Constructs a Scenario from a YAML string, optionally merging -- `run(self) -> 'None'` - - Executes the scenario's workflow steps in order. +**Attributes:** + +- `entity_scope` (EntityScope) +- `conditions` (List[FailureCondition]) = [] +- `logic` (Literal['and', 'or', 'any']) = and +- `rule_type` (Literal['random', 'choice', 'all']) = all +- `probability` (float) = 1.0 +- `count` (int) = 1 --- @@ -175,255 +558,70 @@ Returns: --- -## ngraph.explorer - -### ExternalLinkBreakdown - -Holds stats for external links to a particular other subtree. - -Attributes: - link_count (int): Number of links to that other subtree. - link_capacity (float): Sum of capacities for those links. - -**Attributes:** - -- `link_count` (int) = 0 -- `link_capacity` (float) = 0.0 - -### NetworkExplorer - -Provides hierarchical exploration of a Network, computing statistics in two modes: -'all' (ignores disabled) and 'active' (only enabled). - -**Methods:** - -- `explore_network(network: 'Network', components_library: 'Optional[ComponentsLibrary]' = None) -> 'NetworkExplorer'` - - Build a NetworkExplorer, constructing a tree plus 'all' and 'active' stats. -- `print_tree(self, node: 'Optional[TreeNode]' = None, indent: 'int' = 0, max_depth: 'Optional[int]' = None, skip_leaves: 'bool' = False, detailed: 'bool' = False, include_disabled: 'bool' = True) -> 'None'` - - Print the hierarchy from 'node' down (default: root). - -### TreeNode - -Represents a node in the hierarchical tree. - -Attributes: - name (str): Name/label of this node. - parent (Optional[TreeNode]): Pointer to the parent tree node. - children (Dict[str, TreeNode]): Mapping of child name -> child TreeNode. - subtree_nodes (Set[str]): Node names in the subtree (all nodes, ignoring disabled). - active_subtree_nodes (Set[str]): Node names in the subtree (only enabled). - stats (TreeStats): Aggregated stats for "all" view. - active_stats (TreeStats): Aggregated stats for "active" (only enabled) view. - raw_nodes (List[Node]): Direct Node objects at this hierarchy level. - -**Attributes:** - -- `name` (str) -- `parent` (Optional[TreeNode]) -- `children` (Dict[str, TreeNode]) = {} -- `subtree_nodes` (Set[str]) = set() -- `active_subtree_nodes` (Set[str]) = set() -- `stats` (TreeStats) = TreeStats(node_count=0, internal_link_count=0, internal_link_capacity=0.0, external_link_count=0, external_link_capacity=0.0, external_link_details={}, total_cost=0.0, total_power=0.0) -- `active_stats` (TreeStats) = TreeStats(node_count=0, internal_link_count=0, internal_link_capacity=0.0, external_link_count=0, external_link_capacity=0.0, external_link_details={}, total_cost=0.0, total_power=0.0) -- `raw_nodes` (List[Node]) = [] - -**Methods:** - -- `add_child(self, child_name: 'str') -> 'TreeNode'` - - Ensure a child node named 'child_name' exists and return it. -- `is_leaf(self) -> 'bool'` - - Return True if this node has no children. - -### TreeStats - -Aggregated statistics for a single tree node (subtree). - -Attributes: - node_count (int): Total number of nodes in this subtree. - internal_link_count (int): Number of internal links in this subtree. - internal_link_capacity (float): Sum of capacities for those internal links. - external_link_count (int): Number of external links from this subtree to another. - external_link_capacity (float): Sum of capacities for those external links. - external_link_details (Dict[str, ExternalLinkBreakdown]): Breakdown by other subtree path. - total_cost (float): Cumulative cost (nodes + links). - total_power (float): Cumulative power (nodes + links). - -**Attributes:** - -- `node_count` (int) = 0 -- `internal_link_count` (int) = 0 -- `internal_link_capacity` (float) = 0.0 -- `external_link_count` (int) = 0 -- `external_link_capacity` (float) = 0.0 -- `external_link_details` (Dict[str, ExternalLinkBreakdown]) = {} -- `total_cost` (float) = 0.0 -- `total_power` (float) = 0.0 - ---- - -## ngraph.components - -### Component - -A generic component that can represent chassis, line cards, optics, etc. -Components can have nested children, each with their own cost, power, etc. - -Attributes: - name (str): Name of the component (e.g., "SpineChassis" or "400G-LR4"). - component_type (str): A string label (e.g., "chassis", "linecard", "optic"). - description (str): A human-readable description of this component. - cost (float): Cost (capex) of a single instance of this component. - power_watts (float): Typical/nominal power usage (watts) for one instance. - power_watts_max (float): Maximum/peak power usage (watts) for one instance. - capacity (float): A generic capacity measure (e.g., platform capacity). - ports (int): Number of ports if relevant for this component. - count (int): How many identical copies of this component are present. - attrs (Dict[str, Any]): Arbitrary key-value attributes for extra metadata. - children (Dict[str, Component]): Nested child components (e.g., line cards - inside a chassis), keyed by child name. - -**Attributes:** - -- `name` (str) -- `component_type` (str) = generic -- `description` (str) -- `cost` (float) = 0.0 -- `power_watts` (float) = 0.0 -- `power_watts_max` (float) = 0.0 -- `capacity` (float) = 0.0 -- `ports` (int) = 0 -- `count` (int) = 1 -- `attrs` (Dict[str, Any]) = {} -- `children` (Dict[str, Component]) = {} - -**Methods:** - -- `as_dict(self, include_children: 'bool' = True) -> 'Dict[str, Any]'` - - Returns a dictionary containing all properties of this component. -- `total_capacity(self) -> 'float'` - - Computes the total (recursive) capacity of this component, -- `total_cost(self) -> 'float'` - - Computes the total (recursive) cost of this component, including children, -- `total_power(self) -> 'float'` - - Computes the total *typical* (recursive) power usage of this component, -- `total_power_max(self) -> 'float'` - - Computes the total *peak* (recursive) power usage of this component, +## ngraph.results -### ComponentsLibrary +### Results -Holds a collection of named Components. Each entry is a top-level "template" -that can be referenced for cost/power/capacity lookups, possibly with nested children. +A container for storing arbitrary key-value data that arises during workflow steps. +The data is organized by step name, then by key. -Example (YAML-like): - components: - BigSwitch: - component_type: chassis - cost: 20000 - power_watts: 1750 - capacity: 25600 - children: - PIM16Q-16x200G: - component_type: linecard - cost: 1000 - power_watts: 10 - ports: 16 - count: 8 - 200G-FR4: - component_type: optic - cost: 2000 - power_watts: 6 - power_watts_max: 6.5 +Example usage: + results.put("Step1", "total_capacity", 123.45) + cap = results.get("Step1", "total_capacity") # returns 123.45 + all_caps = results.get_all("total_capacity") # might return {"Step1": 123.45, "Step2": 98.76} **Attributes:** -- `components` (Dict[str, Component]) = {} +- `_store` (Dict) = {} **Methods:** -- `clone(self) -> 'ComponentsLibrary'` - - Creates a deep copy of this ComponentsLibrary. -- `from_dict(data: 'Dict[str, Any]') -> 'ComponentsLibrary'` - - Constructs a ComponentsLibrary from a dictionary of raw component definitions. -- `from_yaml(yaml_str: 'str') -> 'ComponentsLibrary'` - - Constructs a ComponentsLibrary from a YAML string. If the YAML contains -- `get(self, name: 'str') -> 'Optional[Component]'` - - Retrieves a Component by its name from the library. -- `merge(self, other: 'ComponentsLibrary', override: 'bool' = True) -> 'ComponentsLibrary'` - - Merges another ComponentsLibrary into this one. By default (override=True), +- `get(self, step_name: str, key: str, default: Any = None) -> Any` + - Retrieve the value from (step_name, key). If the key is missing, return `default`. +- `get_all(self, key: str) -> Dict[str, Any]` + - Retrieve a dictionary of {step_name: value} for all step_names that contain the specified key. +- `put(self, step_name: str, key: str, value: Any) -> None` + - Store a value under (step_name, key). +- `to_dict(self) -> Dict[str, Dict[str, Any]]` + - Return a dictionary representation of all stored results. --- -## ngraph.blueprints - -### Blueprint - -Represents a reusable blueprint for hierarchical sub-topologies. - -A blueprint may contain multiple groups of nodes (each can have a node_count -and a name_template), plus adjacency rules describing how those groups connect. - -Attributes: - name (str): Unique identifier of this blueprint. - groups (Dict[str, Any]): A mapping of group_name -> group definition. - Allowed top-level keys in each group definition here are the same - as in normal group definitions (e.g. node_count, name_template, - attrs, disabled, risk_groups, or nested use_blueprint references, etc.). - adjacency (List[Dict[str, Any]]): A list of adjacency definitions - describing how these groups are linked, using the DSL fields - (source, target, pattern, link_params, etc.). - -**Attributes:** - -- `name` (str) -- `groups` (Dict[str, Any]) -- `adjacency` (List[Dict[str, Any]]) - -### DSLExpansionContext - -Carries the blueprint definitions and the final Network instance -to be populated during DSL expansion. - -Attributes: - blueprints (Dict[str, Blueprint]): Dictionary of blueprint-name -> Blueprint. - network (Network): The Network into which expanded nodes/links are inserted. +## ngraph.scenario -**Attributes:** +### Scenario -- `blueprints` (Dict[str, Blueprint]) -- `network` (Network) +Represents a complete scenario for building and executing network workflows. -### expand_network_dsl(data: 'Dict[str, Any]') -> 'Network' +This scenario includes: + - A network (nodes/links), constructed via blueprint expansion. + - A failure policy (one or more rules). + - A set of traffic demands. + - A list of workflow steps to execute. + - A results container for storing outputs. + - A components_library for hardware/optics definitions. -Expands a combined blueprint + network DSL into a complete Network object. +Typical usage example: -Overall flow: - 1) Parse "blueprints" into Blueprint objects. - 2) Build a new Network from "network" metadata (e.g. name, version). - 3) Expand 'network["groups"]'. - - If a group references a blueprint, incorporate that blueprint's subgroups - while merging parent's attrs + disabled + risk_groups into subgroups. - - Otherwise, directly create nodes (a "direct node group"). - 4) Process any direct node definitions (network["nodes"]). - 5) Expand adjacency definitions in 'network["adjacency"]'. - 6) Process any direct link definitions (network["links"]). - 7) Process link overrides (in order if multiple overrides match). - 8) Process node overrides (in order if multiple overrides match). + scenario = Scenario.from_yaml(yaml_str, default_components=default_lib) + scenario.run() + # Inspect scenario.results -Under the new rules: - - Only certain top-level fields are permitted in each structure. Any extra - keys raise a ValueError. "attrs" is where arbitrary user fields go. - - For link_params, recognized fields are "capacity", "cost", "disabled", - "risk_groups", "attrs". Everything else must go inside link_params["attrs"]. - - For node/group definitions, recognized fields include "node_count", - "name_template", "attrs", "disabled", "risk_groups" or "use_blueprint" - for blueprint-based groups. +**Attributes:** -Args: - data (Dict[str, Any]): The YAML-parsed dictionary containing - optional "blueprints" + "network". +- `network` (Network) +- `failure_policy` (Optional[FailurePolicy]) +- `traffic_demands` (List[TrafficDemand]) +- `workflow` (List[WorkflowStep]) +- `results` (Results) = Results(_store={}) +- `components_library` (ComponentsLibrary) = ComponentsLibrary(components={}) -Returns: - Network: The fully expanded Network object with all nodes and links. +**Methods:** + +- `from_yaml(yaml_str: 'str', default_components: 'Optional[ComponentsLibrary]' = None) -> 'Scenario'` + - Constructs a Scenario from a YAML string, optionally merging +- `run(self) -> 'None'` + - Executes the scenario's workflow steps in order. --- @@ -461,138 +659,6 @@ Attributes: --- -## ngraph.failure_policy - -### FailureCondition - -A single condition for matching an entity's attribute with an operator and value. - -Example usage (YAML): - conditions: - - attr: "capacity" - operator: "<" - value: 100 - -Attributes: - attr (str): - The name of the attribute to inspect (e.g., "capacity", "region"). - operator (str): - The comparison operator: "==", "!=", "<", "<=", ">", ">=", - "contains", "not_contains", "any_value", or "no_value". - value (Any): - The value to compare against (e.g., 100, True, "foo", etc.). - -**Attributes:** - -- `attr` (str) -- `operator` (str) -- `value` (Any) - -### FailurePolicy - -A container for multiple FailureRules plus optional metadata in `attrs`. - -The main entry point is `apply_failures`, which: - 1) For each rule, gather the relevant entities (node, link, or risk_group). - 2) Match them based on rule conditions (or skip if 'logic=any'). - 3) Apply the selection strategy (all, random, or choice). - 4) Collect the union of all failed entities across all rules. - 5) Optionally expand failures by shared-risk groups or sub-risks. - -Large-scale performance: - - If you set `use_cache=True`, matched sets for each rule are cached, - so repeated calls to `apply_failures` can skip re-matching if the - network hasn't changed. If your network changes between calls, - you should clear the cache or re-initialize the policy. - -Attributes: - rules (List[FailureRule]): - A list of FailureRules to apply. - attrs (Dict[str, Any]): - Arbitrary metadata about this policy (e.g. "name", "description"). - fail_shared_risk_groups (bool): - If True, after initial selection, expand failures among any - node/link that shares a risk group with a failed entity. - fail_risk_group_children (bool): - If True, and if a risk_group is marked as failed, expand to - children risk_groups recursively. - use_cache (bool): - If True, match results for each rule are cached to speed up - repeated calls. If the network changes, the cached results - may be stale. - -**Attributes:** - -- `rules` (List[FailureRule]) = [] -- `attrs` (Dict[str, Any]) = {} -- `fail_shared_risk_groups` (bool) = False -- `fail_risk_group_children` (bool) = False -- `use_cache` (bool) = False -- `_match_cache` (Dict[int, Set[str]]) = {} - -**Methods:** - -- `apply_failures(self, network_nodes: 'Dict[str, Any]', network_links: 'Dict[str, Any]', network_risk_groups: 'Dict[str, Any] | None' = None) -> 'List[str]'` - - Identify which entities fail given the defined rules, then optionally - -### FailureRule - -Defines how to match and then select entities for failure. - -Attributes: - entity_scope (EntityScope): - The type of entities this rule applies to: "node", "link", or "risk_group". - conditions (List[FailureCondition]): - A list of conditions to filter matching entities. - logic (Literal["and", "or", "any"]): - "and": All conditions must be true for a match. - "or": At least one condition is true for a match. - "any": Skip condition checks and match all. - rule_type (Literal["random", "choice", "all"]): - The selection strategy among the matched set: - - "random": each matched entity is chosen with probability = `probability`. - - "choice": pick exactly `count` items from the matched set (random sample). - - "all": select every matched entity in the matched set. - probability (float): - Probability in [0,1], used if `rule_type="random"`. - count (int): - Number of entities to pick if `rule_type="choice"`. - -**Attributes:** - -- `entity_scope` (EntityScope) -- `conditions` (List[FailureCondition]) = [] -- `logic` (Literal['and', 'or', 'any']) = and -- `rule_type` (Literal['random', 'choice', 'all']) = all -- `probability` (float) = 1.0 -- `count` (int) = 1 - ---- - -## ngraph.failure_manager - -### FailureManager - -Applies FailurePolicy to a Network, runs traffic placement, and (optionally) -repeats multiple times for Monte Carlo experiments. - -Attributes: - network (Network): The underlying network to mutate (enable/disable nodes/links). - traffic_demands (List[TrafficDemand]): List of demands to place after failures. - failure_policy (Optional[FailurePolicy]): The policy describing what fails. - default_flow_policy_config: The default flow policy for any demands lacking one. - -**Methods:** - -- `apply_failures(self) -> 'None'` - - Apply the current failure_policy to self.network (in-place). -- `run_monte_carlo_failures(self, iterations: 'int', parallelism: 'int' = 1) -> 'Dict[str, Any]'` - - Repeatedly applies (randomized) failures to the network and accumulates -- `run_single_failure_scenario(self) -> 'List[TrafficResult]'` - - Applies failures to the network, places the demands, and returns per-demand results. - ---- - ## ngraph.traffic_manager ### TrafficManager @@ -675,32 +741,97 @@ Attributes: --- -## ngraph.results - -### Results +## ngraph.lib.demand -A container for storing arbitrary key-value data that arises during workflow steps. -The data is organized by step name, then by key. +### Demand -Example usage: - results.put("Step1", "total_capacity", 123.45) - cap = results.get("Step1", "total_capacity") # returns 123.45 - all_caps = results.get_all("total_capacity") # might return {"Step1": 123.45, "Step2": 98.76} +Represents a network demand between two nodes. It is realized via one or more +flows through a single FlowPolicy. **Attributes:** -- `_store` (Dict) = {} +- `src_node` (NodeID) +- `dst_node` (NodeID) +- `volume` (float) +- `demand_class` (int) = 0 +- `flow_policy` (Optional[FlowPolicy]) +- `placed_demand` (float) = 0.0 **Methods:** -- `get(self, step_name: str, key: str, default: Any = None) -> Any` - - Retrieve the value from (step_name, key). If the key is missing, return `default`. -- `get_all(self, key: str) -> Dict[str, Any]` - - Retrieve a dictionary of {step_name: value} for all step_names that contain the specified key. -- `put(self, step_name: str, key: str, value: Any) -> None` - - Store a value under (step_name, key). -- `to_dict(self) -> Dict[str, Dict[str, Any]]` - - Return a dictionary representation of all stored results. +- `place(self, flow_graph: 'StrictMultiDiGraph', max_fraction: 'float' = 1.0, max_placement: 'Optional[float]' = None) -> 'Tuple[float, float]'` + - Places demand volume onto the network via self.flow_policy. + +--- + +## ngraph.lib.flow + +### Flow + +Represents a fraction of demand routed along a given PathBundle. + +In traffic-engineering scenarios, a `Flow` object can model: + - MPLS LSPs/tunnels with explicit paths, + - IP forwarding behavior (with ECMP or UCMP), + - Or anything that follows a specific set of paths. + +**Methods:** + +- `place_flow(self, flow_graph: 'StrictMultiDiGraph', to_place: 'float', flow_placement: 'FlowPlacement') -> 'Tuple[float, float]'` + - Attempt to place (or update) this flow on the given `flow_graph`. +- `remove_flow(self, flow_graph: 'StrictMultiDiGraph') -> 'None'` + - Remove this flow's contribution from the provided `flow_graph`. + +### FlowIndex + +Describes a unique identifier for a Flow in the network. + +Attributes: + src_node (NodeID): The source node of the flow. + dst_node (NodeID): The destination node of the flow. + flow_class (Hashable): Identifier representing the 'class' of this flow (e.g., traffic class). + Can be int, str, or any hashable type for flexibility. + flow_id (int): A unique ID for this flow. + +--- + +## ngraph.lib.flow_policy + +### FlowPolicy + +Manages the placement and management of flows (demands) on a network graph. + +A FlowPolicy converts a demand into one or more Flow objects subject to +capacity constraints and user-specified configurations such as path +selection algorithms and flow placement methods. + +**Methods:** + +- `deep_copy(self) -> 'FlowPolicy'` + - Creates and returns a deep copy of this FlowPolicy, including all flows. +- `place_demand(self, flow_graph: 'StrictMultiDiGraph', src_node: 'NodeID', dst_node: 'NodeID', flow_class: 'Hashable', volume: 'float', target_flow_volume: 'Optional[float]' = None, min_flow: 'Optional[float]' = None) -> 'Tuple[float, float]'` + - Places the given demand volume on the network graph by splitting or creating +- `rebalance_demand(self, flow_graph: 'StrictMultiDiGraph', src_node: 'NodeID', dst_node: 'NodeID', flow_class: 'Hashable', target_flow_volume: 'float') -> 'Tuple[float, float]'` + - Rebalances the demand across existing flows so that their volumes are closer +- `remove_demand(self, flow_graph: 'StrictMultiDiGraph') -> 'None'` + - Removes all flows from the network graph without clearing internal state. + +### FlowPolicyConfig + +Enumerates supported flow policy configurations. + +### get_flow_policy(flow_policy_config: 'FlowPolicyConfig') -> 'FlowPolicy' + +Factory method to create and return a FlowPolicy instance based on the provided configuration. + +Args: + flow_policy_config: A FlowPolicyConfig enum value specifying the desired policy. + +Returns: + A pre-configured FlowPolicy instance corresponding to the specified configuration. + +Raises: + ValueError: If an unknown FlowPolicyConfig value is provided. --- @@ -814,14 +945,189 @@ Inherits from: - `update_edge_attr(self, key: 'EdgeID', **attr: 'Any') -> 'None'` - Update attributes on an existing edge by key. -### new_base64_uuid() -> 'str' +### new_base64_uuid() -> 'str' + +Generate a Base64-encoded UUID without padding. + +This function produces a 22-character, URL-safe, Base64-encoded UUID. + +Returns: + str: A unique 22-character Base64-encoded UUID. + +--- + +## ngraph.lib.io + +### edgelist_to_graph(lines: 'Iterable[str]', columns: 'List[str]', separator: 'str' = ' ', graph: 'Optional[StrictMultiDiGraph]' = None, source: 'str' = 'src', target: 'str' = 'dst', key: 'str' = 'key') -> 'StrictMultiDiGraph' + +Builds or updates a StrictMultiDiGraph from an edge list. + +Each line in the input is split by the specified separator into tokens. These tokens +are mapped to column names provided in `columns`. The tokens corresponding to `source` +and `target` become the node IDs. If a `key` column exists, its token is used as the edge +ID; remaining tokens are added as edge attributes. + +Args: + lines: An iterable of strings, each representing one edge. + columns: A list of column names, e.g. ["src", "dst", "cost"]. + separator: The separator used to split each line (default is a space). + graph: An existing StrictMultiDiGraph to update; if None, a new graph is created. + source: The column name for the source node ID. + target: The column name for the target node ID. + key: The column name for a custom edge ID (if present). + +Returns: + The updated (or newly created) StrictMultiDiGraph. -Generate a Base64-encoded UUID without padding. +### graph_to_edgelist(graph: 'StrictMultiDiGraph', columns: 'Optional[List[str]]' = None, separator: 'str' = ' ', source_col: 'str' = 'src', target_col: 'str' = 'dst', key_col: 'str' = 'key') -> 'List[str]' -This function produces a 22-character, URL-safe, Base64-encoded UUID. +Converts a StrictMultiDiGraph into an edge-list text representation. + +Each line in the output represents one edge with tokens joined by the given separator. +By default, the output columns are: + [source_col, target_col, key_col] + sorted(edge_attribute_names) + +If an explicit list of columns is provided, those columns (in that order) are used, +and any missing values are output as an empty string. + +Args: + graph: The StrictMultiDiGraph to export. + columns: Optional list of column names. If None, they are auto-generated. + separator: The string used to join tokens (default is a space). + source_col: The column name for the source node (default "src"). + target_col: The column name for the target node (default "dst"). + key_col: The column name for the edge key (default "key"). Returns: - str: A unique 22-character Base64-encoded UUID. + A list of strings, each representing one edge in the specified column format. + +### graph_to_node_link(graph: 'StrictMultiDiGraph') -> 'Dict[str, Any]' + +Converts a StrictMultiDiGraph into a node-link dict representation. + +This representation is suitable for JSON serialization (e.g., for D3.js or Nx formats). + +The returned dict has the following structure: + { + "graph": { ... top-level graph attributes ... }, + "nodes": [ + {"id": node_id, "attr": { ... node attributes ... }}, + ... + ], + "links": [ + { + "source": , + "target": , + "key": , + "attr": { ... edge attributes ... } + }, + ... + ] + } + +Args: + graph: The StrictMultiDiGraph to convert. + +Returns: + A dict containing the 'graph' attributes, list of 'nodes', and list of 'links'. + +### node_link_to_graph(data: 'Dict[str, Any]') -> 'StrictMultiDiGraph' + +Reconstructs a StrictMultiDiGraph from its node-link dict representation. + +Expected input format: + { + "graph": { ... graph attributes ... }, + "nodes": [ + {"id": , "attr": { ... node attributes ... }}, + ... + ], + "links": [ + { + "source": , + "target": , + "key": , + "attr": { ... edge attributes ... } + }, + ... + ] + } + +Args: + data: A dict representing the node-link structure. + +Returns: + A StrictMultiDiGraph reconstructed from the provided data. + +--- + +## ngraph.lib.path + +### Path + +Represents a single path in the network. + +Attributes: + path_tuple (PathTuple): + A sequence of path elements. Each element is a tuple of the form + (node_id, (edge_id_1, edge_id_2, ...)), where the final element typically has an empty tuple. + cost (Cost): + The total numeric cost (e.g., distance or metric) of the path. + edges (Set[EdgeID]): + A set of all edge IDs encountered in the path. + nodes (Set[NodeID]): + A set of all node IDs encountered in the path. + edge_tuples (Set[Tuple[EdgeID, ...]]): + A set of all tuples of parallel edges from each path element (including the final empty tuple). + +**Attributes:** + +- `path_tuple` (PathTuple) +- `cost` (Cost) +- `edges` (Set[EdgeID]) = set() +- `nodes` (Set[NodeID]) = set() +- `edge_tuples` (Set[Tuple[EdgeID, ...]]) = set() + +**Methods:** + +- `get_sub_path(self, dst_node: 'NodeID', graph: 'StrictMultiDiGraph', cost_attr: 'str' = 'cost') -> 'Path'` + - Create a sub-path ending at the specified destination node, recalculating the cost. + +--- + +## ngraph.lib.path_bundle + +### PathBundle + +A collection of equal-cost paths between two nodes. + +This class encapsulates one or more parallel paths (all of the same cost) +between `src_node` and `dst_node`. The predecessor map `pred` associates +each node with the node(s) from which it can be reached, along with a list +of edge IDs used in that step. The constructor performs a reverse traversal +from `dst_node` to `src_node` to collect all edges, nodes, and store them +in this bundle. + +Since we trust the input is already a DAG, no cycle-detection checks +are performed. All relevant edges and nodes are simply gathered. +If it's not a DAG, the behavior is... an infinite loop. Oops. + +**Methods:** + +- `add(self, other: 'PathBundle') -> 'PathBundle'` + - Concatenate this bundle with another bundle (end-to-start). +- `contains(self, other: 'PathBundle') -> 'bool'` + - Check if this bundle's edge set contains all edges of `other`. +- `from_path(path: 'Path', resolve_edges: 'bool' = False, graph: 'Optional[StrictMultiDiGraph]' = None, edge_select: 'Optional[EdgeSelect]' = None, cost_attr: 'str' = 'cost', capacity_attr: 'str' = 'capacity') -> 'PathBundle'` + - Construct a PathBundle from a single `Path` object. +- `get_sub_path_bundle(self, new_dst_node: 'NodeID', graph: 'StrictMultiDiGraph', cost_attr: 'str' = 'cost') -> 'PathBundle'` + - Create a sub-bundle ending at `new_dst_node` (which must appear in this bundle). +- `is_disjoint_from(self, other: 'PathBundle') -> 'bool'` + - Check if this bundle shares no edges with `other`. +- `is_subset_of(self, other: 'PathBundle') -> 'bool'` + - Check if this bundle's edge set is contained in `other`'s edge set. +- `resolve_to_paths(self, split_parallel_edges: 'bool' = False) -> 'Iterator[Path]'` + - Generate all concrete `Path` objects contained in this PathBundle. --- @@ -887,64 +1193,117 @@ Returns: --- -## ngraph.lib.algorithms.spf +## ngraph.lib.algorithms.base -### ksp(graph: ngraph.lib.graph.StrictMultiDiGraph, src_node: Hashable, dst_node: Hashable, edge_select: ngraph.lib.algorithms.base.EdgeSelect = , edge_select_func: Optional[Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Set[Hashable], Set[Hashable]], Tuple[Union[int, float], List[Hashable]]]] = None, max_k: Optional[int] = None, max_path_cost: Union[int, float] = inf, max_path_cost_factor: Optional[float] = None, multipath: bool = True, excluded_edges: Optional[Set[Hashable]] = None, excluded_nodes: Optional[Set[Hashable]] = None) -> Iterator[Tuple[Dict[Hashable, Union[int, float]], Dict[Hashable, Dict[Hashable, List[Hashable]]]]] +### EdgeSelect -Generator of up to k shortest paths from src_node to dst_node using a Yen-like algorithm. +Edge selection criteria determining which edges are considered +for path-finding between a node and its neighbor(s). -The initial SPF (shortest path) is computed; subsequent paths are found by systematically -excluding edges/nodes used by previously generated paths. Each iteration yields a -(costs, pred) describing one path. Stops if there are no more valid paths or if max_k -is reached. +### FlowPlacement -Args: - graph: The directed graph (StrictMultiDiGraph). - src_node: The source node. - dst_node: The destination node. - edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. - edge_select_func: Optional override of the default edge selection function. - max_k: If set, yields at most k distinct paths. - max_path_cost: If set, do not yield any path whose total cost > max_path_cost. - max_path_cost_factor: If set, updates max_path_cost to: - min(max_path_cost, best_path_cost * max_path_cost_factor). - multipath: Whether to consider multiple same-cost expansions in SPF. - excluded_edges: Set of edge IDs to exclude globally. - excluded_nodes: Set of node IDs to exclude globally. +Ways to distribute flow across parallel equal cost paths. -Yields: - (costs, pred) for each discovered path from src_node to dst_node, in ascending - order of cost. +### PathAlg -### spf(graph: ngraph.lib.graph.StrictMultiDiGraph, src_node: Hashable, edge_select: ngraph.lib.algorithms.base.EdgeSelect = , edge_select_func: Optional[Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Set[Hashable], Set[Hashable]], Tuple[Union[int, float], List[Hashable]]]] = None, multipath: bool = True, excluded_edges: Optional[Set[Hashable]] = None, excluded_nodes: Optional[Set[Hashable]] = None) -> Tuple[Dict[Hashable, Union[int, float]], Dict[Hashable, Dict[Hashable, List[Hashable]]]] +Types of path finding algorithms -Compute shortest paths (cost-based) from a source node using a Dijkstra-like method. +--- -By default, uses EdgeSelect.ALL_MIN_COST. If multipath=True, multiple equal-cost -paths to the same node will be recorded in the predecessor structure. If no -excluded edges/nodes are given and edge_select is one of the specialized -(ALL_MIN_COST or ALL_MIN_COST_WITH_CAP_REMAINING), it uses a fast specialized -routine. +## ngraph.lib.algorithms.calc_capacity + +### calc_graph_capacity(flow_graph: 'StrictMultiDiGraph', src_node: 'NodeID', dst_node: 'NodeID', pred: 'Dict[NodeID, Dict[NodeID, List[EdgeID]]]', flow_placement: 'FlowPlacement' = , capacity_attr: 'str' = 'capacity', flow_attr: 'str' = 'flow') -> 'Tuple[float, Dict[NodeID, Dict[NodeID, float]]]' + +Calculate the maximum feasible flow from src_node to dst_node (forward sense) +using either the PROPORTIONAL or EQUAL_BALANCED approach. + +In PROPORTIONAL mode (similar to Dinic in reversed orientation): + 1. Build the reversed residual graph from dst_node (via `_init_graph_data`). + 2. Use BFS (in `_set_levels_bfs`) to build a level graph and DFS (`_push_flow_dfs`) + to push blocking flows, repeating until no more flow can be pushed. + 3. The net flow found is stored in reversed orientation. Convert final flows + to forward orientation by negating and normalizing by the total. + +In EQUAL_BALANCED mode: + 1. Build reversed adjacency from dst_node (also via `_init_graph_data`), + ignoring capacity checks in that BFS. + 2. Perform a BFS pass from src_node (`_equal_balance_bfs`) to distribute a + nominal flow of 1.0 equally among parallel edges. + 3. Determine the scaling ratio so that no edge capacity is exceeded. + Scale the flow assignments accordingly, then normalize to the forward sense. Args: - graph: The directed graph (StrictMultiDiGraph). - src_node: The source node from which to compute shortest paths. - edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. - edge_select_func: If provided, overrides the default edge selection function. - Must return (cost, list_of_edges) for the given node->neighbor adjacency. - multipath: Whether to record multiple same-cost paths. - excluded_edges: A set of edge IDs to ignore in the graph. - excluded_nodes: A set of node IDs to ignore in the graph. + flow_graph: The multigraph with capacity and flow attributes. + src_node: The source node in the forward graph. + dst_node: The destination node in the forward graph. + pred: Forward adjacency mapping (node -> (adjacent node -> list of EdgeIDs)), + typically produced by `spf(..., multipath=True)`. Must be a DAG. + flow_placement: The flow distribution strategy (PROPORTIONAL or EQUAL_BALANCED). + capacity_attr: Name of the capacity attribute on edges. + flow_attr: Name of the flow attribute on edges. Returns: - A tuple of (costs, pred): - - costs: Maps each reachable node to its minimal cost from src_node. - - pred: For each reachable node, a dict of predecessor -> list of edges - from that predecessor to the node. Multiple predecessors are possible - if multipath=True. + A tuple of: + - total_flow: The maximum feasible flow from src_node to dst_node. + - flow_dict: A nested dictionary [u][v] -> flow value in the forward sense. + Positive if flow is from u to v, negative otherwise. Raises: - KeyError: If src_node does not exist in graph. + ValueError: If src_node or dst_node is not in the graph, or the flow_placement + is unsupported. + +--- + +## ngraph.lib.algorithms.edge_select + +### edge_select_fabric(edge_select: ngraph.lib.algorithms.base.EdgeSelect, select_value: Optional[Any] = None, edge_select_func: Optional[Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Optional[Set[Hashable]], Optional[Set[Hashable]]], Tuple[Union[int, float], List[Hashable]]]] = None, excluded_edges: Optional[Set[Hashable]] = None, excluded_nodes: Optional[Set[Hashable]] = None, cost_attr: str = 'cost', capacity_attr: str = 'capacity', flow_attr: str = 'flow') -> Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Optional[Set[Hashable]], Optional[Set[Hashable]]], Tuple[Union[int, float], List[Hashable]]] + +Creates a function that selects edges between two nodes according +to a given EdgeSelect strategy (or a user-defined function). + +Args: + edge_select: An EdgeSelect enum specifying the selection strategy. + select_value: An optional numeric threshold or scaling factor for capacity checks. + edge_select_func: A user-supplied function if edge_select=USER_DEFINED. + excluded_edges: A set of edges to ignore entirely. + excluded_nodes: A set of nodes to skip (if the destination node is in this set). + cost_attr: The edge attribute name representing cost. + capacity_attr: The edge attribute name representing capacity. + flow_attr: The edge attribute name representing current flow. + +Returns: + A function with signature: + (graph, src_node, dst_node, edges_dict, excluded_edges, excluded_nodes) -> + (selected_cost, [list_of_edge_ids]) + where: + - `selected_cost` is the numeric cost used by the path-finding algorithm (e.g. Dijkstra). + - `[list_of_edge_ids]` is the list of edges chosen. + +--- + +## ngraph.lib.algorithms.flow_init + +### init_flow_graph(flow_graph: 'StrictMultiDiGraph', flow_attr: 'str' = 'flow', flows_attr: 'str' = 'flows', reset_flow_graph: 'bool' = True) -> 'StrictMultiDiGraph' + +Ensure that every node and edge in the provided `flow_graph` has +flow-related attributes. Specifically, for each node and edge: + +- The attribute named `flow_attr` (default: "flow") is set to 0. +- The attribute named `flows_attr` (default: "flows") is set to an empty dict. + +If `reset_flow_graph` is True, any existing flow values in these attributes +are overwritten; otherwise they are only created if missing. + +Args: + flow_graph: The StrictMultiDiGraph whose nodes and edges should be + prepared for flow assignment. + flow_attr: The attribute name to track a numeric flow value per node/edge. + flows_attr: The attribute name to track multiple flow identifiers (and flows). + reset_flow_graph: If True, reset existing flows (set to 0). If False, do not overwrite. + +Returns: + The same `flow_graph` object, after ensuring each node/edge has the + necessary flow-related attributes. --- @@ -1013,8 +1372,8 @@ Examples: >>> g.add_node('A') >>> g.add_node('B') >>> g.add_node('C') - >>> _ = g.add_edge('A', 'B', capacity=10.0, flow=0.0, flows={}, cost=1) - >>> _ = g.add_edge('B', 'C', capacity=5.0, flow=0.0, flows={}, cost=1) + >>> g.add_edge('A', 'B', capacity=10.0, flow=0.0, flows={}, cost=1) + >>> g.add_edge('B', 'C', capacity=5.0, flow=0.0, flows={}, cost=1) >>> >>> # Basic usage (scalar return) >>> max_flow_value = calc_max_flow(g, 'A', 'C') @@ -1069,20 +1428,165 @@ Returns: --- -## ngraph.lib.algorithms.base +## ngraph.lib.algorithms.path_utils -### EdgeSelect +### resolve_to_paths(src_node: 'NodeID', dst_node: 'NodeID', pred: 'Dict[NodeID, Dict[NodeID, List[EdgeID]]]', split_parallel_edges: 'bool' = False) -> 'Iterator[PathTuple]' -Edge selection criteria determining which edges are considered -for path-finding between a node and its neighbor(s). +Enumerate all source->destination paths from a predecessor map. -### FlowPlacement +Args: + src_node: Source node ID. + dst_node: Destination node ID. + pred: Predecessor map from SPF or KSP. + split_parallel_edges: If True, expand parallel edges into distinct paths. -Ways to distribute flow across parallel equal cost paths. +Yields: + A tuple of (nodeID, (edgeIDs,)) pairs from src_node to dst_node. -### PathAlg +--- -Types of path finding algorithms +## ngraph.lib.algorithms.place_flow + +### FlowPlacementMeta + +Metadata capturing how flow was placed on the graph. + +Attributes: + placed_flow: The amount of flow actually placed. + remaining_flow: The portion of flow that could not be placed due to capacity limits. + nodes: Set of node IDs that participated in the flow. + edges: Set of edge IDs that carried some portion of this flow. + +**Attributes:** + +- `placed_flow` (float) +- `remaining_flow` (float) +- `nodes` (Set[NodeID]) = set() +- `edges` (Set[EdgeID]) = set() + +### place_flow_on_graph(flow_graph: 'StrictMultiDiGraph', src_node: 'NodeID', dst_node: 'NodeID', pred: 'Dict[NodeID, Dict[NodeID, List[EdgeID]]]', flow: 'float' = inf, flow_index: 'Optional[Hashable]' = None, flow_placement: 'FlowPlacement' = , capacity_attr: 'str' = 'capacity', flow_attr: 'str' = 'flow', flows_attr: 'str' = 'flows') -> 'FlowPlacementMeta' + +Place flow from `src_node` to `dst_node` on the given `flow_graph`. + +Uses a precomputed `flow_dict` from `calc_graph_capacity` to figure out how +much flow can be placed. Updates the graph's edges and nodes with the placed flow. + +Args: + flow_graph: The graph on which flow will be placed. + src_node: The source node. + dst_node: The destination node. + pred: A dictionary of node->(adj_node->list_of_edge_IDs) giving path adjacency. + flow: Requested flow amount; can be infinite. + flow_index: Identifier for this flow (used to track multiple flows). + flow_placement: Strategy for distributing flow among parallel equal cost paths. + capacity_attr: Attribute name on edges for capacity. + flow_attr: Attribute name on edges/nodes for aggregated flow. + flows_attr: Attribute name on edges/nodes for per-flow tracking. + +Returns: + FlowPlacementMeta: Contains the placed flow amount, remaining flow amount, + and sets of touched nodes/edges. + +### remove_flow_from_graph(flow_graph: 'StrictMultiDiGraph', flow_index: 'Optional[Hashable]' = None, flow_attr: 'str' = 'flow', flows_attr: 'str' = 'flows') -> 'None' + +Remove one (or all) flows from the given graph. + +Args: + flow_graph: The graph from which flow(s) should be removed. + flow_index: If provided, only remove the specified flow. If None, + remove all flows entirely. + flow_attr: The aggregate flow attribute name on edges. + flows_attr: The per-flow attribute name on edges. + +--- + +## ngraph.lib.algorithms.spf + +### ksp(graph: ngraph.lib.graph.StrictMultiDiGraph, src_node: Hashable, dst_node: Hashable, edge_select: ngraph.lib.algorithms.base.EdgeSelect = , edge_select_func: Optional[Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Set[Hashable], Set[Hashable]], Tuple[Union[int, float], List[Hashable]]]] = None, max_k: Optional[int] = None, max_path_cost: Union[int, float] = inf, max_path_cost_factor: Optional[float] = None, multipath: bool = True, excluded_edges: Optional[Set[Hashable]] = None, excluded_nodes: Optional[Set[Hashable]] = None) -> Iterator[Tuple[Dict[Hashable, Union[int, float]], Dict[Hashable, Dict[Hashable, List[Hashable]]]]] + +Generator of up to k shortest paths from src_node to dst_node using a Yen-like algorithm. + +The initial SPF (shortest path) is computed; subsequent paths are found by systematically +excluding edges/nodes used by previously generated paths. Each iteration yields a +(costs, pred) describing one path. Stops if there are no more valid paths or if max_k +is reached. + +Args: + graph: The directed graph (StrictMultiDiGraph). + src_node: The source node. + dst_node: The destination node. + edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. + edge_select_func: Optional override of the default edge selection function. + max_k: If set, yields at most k distinct paths. + max_path_cost: If set, do not yield any path whose total cost > max_path_cost. + max_path_cost_factor: If set, updates max_path_cost to: + min(max_path_cost, best_path_cost * max_path_cost_factor). + multipath: Whether to consider multiple same-cost expansions in SPF. + excluded_edges: Set of edge IDs to exclude globally. + excluded_nodes: Set of node IDs to exclude globally. + +Yields: + (costs, pred) for each discovered path from src_node to dst_node, in ascending + order of cost. + +### spf(graph: ngraph.lib.graph.StrictMultiDiGraph, src_node: Hashable, edge_select: ngraph.lib.algorithms.base.EdgeSelect = , edge_select_func: Optional[Callable[[ngraph.lib.graph.StrictMultiDiGraph, Hashable, Hashable, Dict[Hashable, Dict[str, Any]], Set[Hashable], Set[Hashable]], Tuple[Union[int, float], List[Hashable]]]] = None, multipath: bool = True, excluded_edges: Optional[Set[Hashable]] = None, excluded_nodes: Optional[Set[Hashable]] = None) -> Tuple[Dict[Hashable, Union[int, float]], Dict[Hashable, Dict[Hashable, List[Hashable]]]] + +Compute shortest paths (cost-based) from a source node using a Dijkstra-like method. + +By default, uses EdgeSelect.ALL_MIN_COST. If multipath=True, multiple equal-cost +paths to the same node will be recorded in the predecessor structure. If no +excluded edges/nodes are given and edge_select is one of the specialized +(ALL_MIN_COST or ALL_MIN_COST_WITH_CAP_REMAINING), it uses a fast specialized +routine. + +Args: + graph: The directed graph (StrictMultiDiGraph). + src_node: The source node from which to compute shortest paths. + edge_select: The edge selection strategy. Defaults to ALL_MIN_COST. + edge_select_func: If provided, overrides the default edge selection function. + Must return (cost, list_of_edges) for the given node->neighbor adjacency. + multipath: Whether to record multiple same-cost paths. + excluded_edges: A set of edge IDs to ignore in the graph. + excluded_nodes: A set of node IDs to ignore in the graph. + +Returns: + A tuple of (costs, pred): + - costs: Maps each reachable node to its minimal cost from src_node. + - pred: For each reachable node, a dict of predecessor -> list of edges + from that predecessor to the node. Multiple predecessors are possible + if multipath=True. + +Raises: + KeyError: If src_node does not exist in graph. + +--- + +## ngraph.lib.algorithms.types + +Types and data structures for algorithm analytics. + +### FlowSummary + +Summary of max-flow computation results with detailed analytics. + +This immutable data structure provides comprehensive information about +the flow solution, including edge flows, residual capacities, and +min-cut analysis. + +Attributes: + total_flow: The maximum flow value achieved. + edge_flow: Flow amount on each edge, indexed by (src, dst, key). + residual_cap: Remaining capacity on each edge after flow placement. + reachable: Set of nodes reachable from source in residual graph. + min_cut: List of saturated edges that form the minimum cut. + +**Attributes:** + +- `total_flow` (float) +- `edge_flow` (Dict[Edge, float]) +- `residual_cap` (Dict[Edge, float]) +- `reachable` (Set[str]) +- `min_cut` (List[Edge]) --- @@ -1185,23 +1689,6 @@ Raises: --- -## ngraph.transform.enable_nodes - -### EnableNodesTransform - -Enable *count* disabled nodes that match *path*. - -Ordering is configurable; default is lexical by node name. - -**Methods:** - -- `apply(self, scenario: 'Scenario') -> 'None'` - - Modify *scenario.network* in-place. -- `create(step_type: 'str', **kwargs: 'Any') -> 'Self'` - - Instantiate a registered transform by *step_type*. - ---- - ## ngraph.transform.distribute_external ### DistributeExternalConnectivity @@ -1226,6 +1713,23 @@ Args: --- +## ngraph.transform.enable_nodes + +### EnableNodesTransform + +Enable *count* disabled nodes that match *path*. + +Ordering is configurable; default is lexical by node name. + +**Methods:** + +- `apply(self, scenario: 'Scenario') -> 'None'` + - Modify *scenario.network* in-place. +- `create(step_type: 'str', **kwargs: 'Any') -> 'Self'` + - Instantiate a registered transform by *step_type*. + +--- + ## Error Handling From edfeb1944b30510355f529235cd08cf22736039e Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Wed, 11 Jun 2025 01:54:57 +0100 Subject: [PATCH 08/10] additional max_flow functionality in network.py --- docs/reference/api-full.md | 6 +- ngraph/network.py | 850 ++++++++++++++++++++++- tests/test_network.py | 881 ------------------------ tests/test_network_basics.py | 323 +++++++++ tests/test_network_edge_cases.py | 508 ++++++++++++++ tests/test_network_enhanced_max_flow.py | 282 ++++++++ tests/test_network_flow.py | 640 +++++++++++++++++ tests/test_network_graph.py | 127 ++++ tests/test_network_integration.py | 257 +++++++ tests/test_network_risk_groups.py | 185 +++++ tests/test_network_selection.py | 242 +++++++ 11 files changed, 3418 insertions(+), 883 deletions(-) delete mode 100644 tests/test_network.py create mode 100644 tests/test_network_basics.py create mode 100644 tests/test_network_edge_cases.py create mode 100644 tests/test_network_enhanced_max_flow.py create mode 100644 tests/test_network_flow.py create mode 100644 tests/test_network_graph.py create mode 100644 tests/test_network_integration.py create mode 100644 tests/test_network_risk_groups.py create mode 100644 tests/test_network_selection.py diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index c2d51f1..a93af48 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -10,7 +10,7 @@ For a curated, example-driven API guide, see **[api.md](api.md)**. > - **[CLI Reference](cli.md)** - Command-line interface > - **[DSL Reference](dsl.md)** - YAML syntax guide -**Generated from source code on:** June 11, 2025 at 00:06 UTC +**Generated from source code on:** June 11, 2025 at 01:54 UTC **Modules auto-discovered:** 35 @@ -507,8 +507,12 @@ Attributes: - Retrieve all link IDs that connect the specified source node - `max_flow(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], float]'` - Compute maximum flow between groups of source nodes and sink nodes. +- `saturated_edges(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', tolerance: 'float' = 1e-10, shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], List[Tuple[str, str, str]]]'` + - Identify saturated (bottleneck) edges in max flow solutions between node groups. - `select_node_groups_by_path(self, path: 'str') -> 'Dict[str, List[Node]]'` - Select and group nodes whose names match a given regular expression. +- `sensitivity_analysis(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', change_amount: 'float' = 1.0, shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], Dict[Tuple[str, str, str], float]]'` + - Perform sensitivity analysis on capacity changes for max flow solutions. - `to_strict_multidigraph(self, add_reverse: 'bool' = True) -> 'StrictMultiDiGraph'` - Create a StrictMultiDiGraph representation of this Network. diff --git a/ngraph/network.py b/ngraph/network.py index f8bd090..f84a77b 100644 --- a/ngraph/network.py +++ b/ngraph/network.py @@ -7,7 +7,12 @@ from typing import Any, Dict, List, Optional, Set, Tuple from ngraph.lib.algorithms.base import FlowPlacement -from ngraph.lib.algorithms.max_flow import calc_max_flow +from ngraph.lib.algorithms.max_flow import ( + calc_max_flow, + run_sensitivity, + saturated_edges, +) +from ngraph.lib.algorithms.types import FlowSummary from ngraph.lib.graph import StrictMultiDiGraph @@ -354,6 +359,196 @@ def _compute_flow_single_group( copy_graph=False, ) + def _compute_flow_with_summary_single_group( + self, + sources: List[Node], + sinks: List[Node], + shortest_path: bool, + flow_placement: Optional[FlowPlacement], + ) -> Tuple[float, FlowSummary]: + """Compute maximum flow with detailed analytics summary for a single group. + + Creates pseudo-source and pseudo-sink nodes, connects them to the provided + source and sink nodes, then computes the maximum flow along with a detailed + FlowSummary containing edge flows, residual capacities, and min-cut analysis. + + Args: + sources (List[Node]): List of source nodes. + sinks (List[Node]): List of sink nodes. + shortest_path (bool): If True, restrict flows to shortest paths only. + flow_placement (Optional[FlowPlacement]): Strategy for placing flow among + parallel equal-cost paths. If None, defaults to FlowPlacement.PROPORTIONAL. + + Returns: + Tuple[float, FlowSummary]: A tuple containing: + - float: The computed maximum flow value + - FlowSummary: Detailed analytics including edge flows, residual capacities, + reachable nodes, and min-cut edges + """ + if flow_placement is None: + flow_placement = FlowPlacement.PROPORTIONAL + + active_sources = [s for s in sources if not s.disabled] + active_sinks = [s for s in sinks if not s.disabled] + + if not active_sources or not active_sinks: + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return 0.0, empty_summary + + graph = self.to_strict_multidigraph() + graph.add_node("source") + graph.add_node("sink") + + # Connect pseudo-source to active sources + for src_node in active_sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + + # Connect active sinks to pseudo-sink + for sink_node in active_sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + return calc_max_flow( + graph, + "source", + "sink", + return_summary=True, + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) + + def _compute_flow_with_graph_single_group( + self, + sources: List[Node], + sinks: List[Node], + shortest_path: bool, + flow_placement: Optional[FlowPlacement], + ) -> Tuple[float, StrictMultiDiGraph]: + """Compute maximum flow with flow-assigned graph for a single group. + + Creates pseudo-source and pseudo-sink nodes, connects them to the provided + source and sink nodes, then computes the maximum flow and returns both the + flow value and the graph with flow assignments on edges. + + Args: + sources (List[Node]): List of source nodes. + sinks (List[Node]): List of sink nodes. + shortest_path (bool): If True, restrict flows to shortest paths only. + flow_placement (Optional[FlowPlacement]): Strategy for placing flow among + parallel equal-cost paths. If None, defaults to FlowPlacement.PROPORTIONAL. + + Returns: + Tuple[float, StrictMultiDiGraph]: A tuple containing: + - float: The computed maximum flow value + - StrictMultiDiGraph: The graph with flow assignments on edges, including + the pseudo-source and pseudo-sink nodes + """ + if flow_placement is None: + flow_placement = FlowPlacement.PROPORTIONAL + + active_sources = [s for s in sources if not s.disabled] + active_sinks = [s for s in sinks if not s.disabled] + + if not active_sources or not active_sinks: + base_graph = self.to_strict_multidigraph() + return 0.0, base_graph + + graph = self.to_strict_multidigraph() + graph.add_node("source") + graph.add_node("sink") + + # Connect pseudo-source to active sources + for src_node in active_sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + + # Connect active sinks to pseudo-sink + for sink_node in active_sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + return calc_max_flow( + graph, + "source", + "sink", + return_graph=True, + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) + + def _compute_flow_detailed_single_group( + self, + sources: List[Node], + sinks: List[Node], + shortest_path: bool, + flow_placement: Optional[FlowPlacement], + ) -> Tuple[float, FlowSummary, StrictMultiDiGraph]: + """Compute maximum flow with both analytics summary and flow-assigned graph for a single group. + + Creates pseudo-source and pseudo-sink nodes, connects them to the provided + source and sink nodes, then computes the maximum flow and returns the flow value, + detailed analytics summary, and the graph with flow assignments. + + Args: + sources (List[Node]): List of source nodes. + sinks (List[Node]): List of sink nodes. + shortest_path (bool): If True, restrict flows to shortest paths only. + flow_placement (Optional[FlowPlacement]): Strategy for placing flow among + parallel equal-cost paths. If None, defaults to FlowPlacement.PROPORTIONAL. + + Returns: + Tuple[float, FlowSummary, StrictMultiDiGraph]: A tuple containing: + - float: The computed maximum flow value + - FlowSummary: Detailed analytics including edge flows, residual capacities, + reachable nodes, and min-cut edges + - StrictMultiDiGraph: The graph with flow assignments on edges, including + the pseudo-source and pseudo-sink nodes + """ + if flow_placement is None: + flow_placement = FlowPlacement.PROPORTIONAL + + active_sources = [s for s in sources if not s.disabled] + active_sinks = [s for s in sinks if not s.disabled] + + if not active_sources or not active_sinks: + base_graph = self.to_strict_multidigraph() + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return 0.0, empty_summary, base_graph + + graph = self.to_strict_multidigraph() + graph.add_node("source") + graph.add_node("sink") + + # Connect pseudo-source to active sources + for src_node in active_sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + + # Connect active sinks to pseudo-sink + for sink_node in active_sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + return calc_max_flow( + graph, + "source", + "sink", + return_summary=True, + return_graph=True, + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) + def disable_node(self, node_name: str) -> None: """Mark a node as disabled. @@ -538,3 +733,656 @@ def enable_risk_group(self, name: str, recursive: bool = True) -> None: for link_id, link_obj in self.links.items(): if link_obj.risk_groups & to_enable: self.enable_link(link_id) + + def saturated_edges( + self, + source_path: str, + sink_path: str, + mode: str = "combine", + tolerance: float = 1e-10, + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> Dict[Tuple[str, str], List[Tuple[str, str, str]]]: + """Identify saturated (bottleneck) edges in max flow solutions between node groups. + + Returns a dictionary mapping (source_label, sink_label) to lists of saturated edge tuples. + + Args: + source_path (str): Regex pattern for selecting source nodes. + sink_path (str): Regex pattern for selecting sink nodes. + mode (str): Either "combine" or "pairwise". + - "combine": Treat all matched sources as one group, + and all matched sinks as one group. Returns a single entry. + - "pairwise": Compute flow for each (source_group, sink_group) pair. + tolerance (float): Tolerance for considering an edge saturated. + shortest_path (bool): If True, flows are constrained to shortest paths. + flow_placement (FlowPlacement): Determines how parallel equal-cost paths + are handled. + + Returns: + Dict[Tuple[str, str], List[Tuple[str, str, str]]]: Mapping from + (src_label, snk_label) to lists of saturated edge tuples (u, v, key). + + Raises: + ValueError: If no matching source or sink groups are found, or invalid mode. + """ + src_groups = self.select_node_groups_by_path(source_path) + snk_groups = self.select_node_groups_by_path(sink_path) + + if not src_groups: + raise ValueError(f"No source nodes found matching '{source_path}'.") + if not snk_groups: + raise ValueError(f"No sink nodes found matching '{sink_path}'.") + + if mode == "combine": + combined_src_nodes: List[Node] = [] + combined_snk_nodes: List[Node] = [] + combined_src_label = "|".join(sorted(src_groups.keys())) + combined_snk_label = "|".join(sorted(snk_groups.keys())) + + for group_nodes in src_groups.values(): + combined_src_nodes.extend(group_nodes) + for group_nodes in snk_groups.values(): + combined_snk_nodes.extend(group_nodes) + + if not combined_src_nodes or not combined_snk_nodes: + return {(combined_src_label, combined_snk_label): []} + + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: + # When source and sink groups overlap, no saturated edges + saturated_list = [] + else: + saturated_list = self._compute_saturated_edges_single_group( + combined_src_nodes, + combined_snk_nodes, + tolerance, + shortest_path, + flow_placement, + ) + return {(combined_src_label, combined_snk_label): saturated_list} + + elif mode == "pairwise": + results: Dict[Tuple[str, str], List[Tuple[str, str, str]]] = {} + for src_label, src_nodes in src_groups.items(): + for snk_label, snk_nodes in snk_groups.items(): + if src_nodes and snk_nodes: + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: + # When source and sink groups overlap, no saturated edges + saturated_list = [] + else: + saturated_list = self._compute_saturated_edges_single_group( + src_nodes, + snk_nodes, + tolerance, + shortest_path, + flow_placement, + ) + else: + saturated_list = [] + results[(src_label, snk_label)] = saturated_list + return results + + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'combine' or 'pairwise'.") + + def sensitivity_analysis( + self, + source_path: str, + sink_path: str, + mode: str = "combine", + change_amount: float = 1.0, + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> Dict[Tuple[str, str], Dict[Tuple[str, str, str], float]]: + """Perform sensitivity analysis on capacity changes for max flow solutions. + + Tests changing each saturated edge capacity by change_amount and measures + the resulting change in total flow. + + Args: + source_path (str): Regex pattern for selecting source nodes. + sink_path (str): Regex pattern for selecting sink nodes. + mode (str): Either "combine" or "pairwise". + - "combine": Treat all matched sources as one group, + and all matched sinks as one group. Returns a single entry. + - "pairwise": Compute flow for each (source_group, sink_group) pair. + change_amount (float): Amount to change capacity for testing + (positive=increase, negative=decrease). + shortest_path (bool): If True, flows are constrained to shortest paths. + flow_placement (FlowPlacement): Determines how parallel equal-cost paths + are handled. + + Returns: + Dict[Tuple[str, str], Dict[Tuple[str, str, str], float]]: Mapping from + (src_label, snk_label) to dictionaries of edge sensitivity values. + Each inner dict maps edge tuples (u, v, key) to flow change values. + + Raises: + ValueError: If no matching source or sink groups are found, or invalid mode. + """ + src_groups = self.select_node_groups_by_path(source_path) + snk_groups = self.select_node_groups_by_path(sink_path) + + if not src_groups: + raise ValueError(f"No source nodes found matching '{source_path}'.") + if not snk_groups: + raise ValueError(f"No sink nodes found matching '{sink_path}'.") + + if mode == "combine": + combined_src_nodes: List[Node] = [] + combined_snk_nodes: List[Node] = [] + combined_src_label = "|".join(sorted(src_groups.keys())) + combined_snk_label = "|".join(sorted(snk_groups.keys())) + + for group_nodes in src_groups.values(): + combined_src_nodes.extend(group_nodes) + for group_nodes in snk_groups.values(): + combined_snk_nodes.extend(group_nodes) + + if not combined_src_nodes or not combined_snk_nodes: + return {(combined_src_label, combined_snk_label): {}} + + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: + # When source and sink groups overlap, no sensitivity results + sensitivity_dict = {} + else: + sensitivity_dict = self._compute_sensitivity_single_group( + combined_src_nodes, + combined_snk_nodes, + change_amount, + shortest_path, + flow_placement, + ) + return {(combined_src_label, combined_snk_label): sensitivity_dict} + + elif mode == "pairwise": + results: Dict[Tuple[str, str], Dict[Tuple[str, str, str], float]] = {} + for src_label, src_nodes in src_groups.items(): + for snk_label, snk_nodes in snk_groups.items(): + if src_nodes and snk_nodes: + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: + # When source and sink groups overlap, no sensitivity results + sensitivity_dict = {} + else: + sensitivity_dict = self._compute_sensitivity_single_group( + src_nodes, + snk_nodes, + change_amount, + shortest_path, + flow_placement, + ) + else: + sensitivity_dict = {} + results[(src_label, snk_label)] = sensitivity_dict + return results + + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'combine' or 'pairwise'.") + + def _compute_saturated_edges_single_group( + self, + sources: List[Node], + sinks: List[Node], + tolerance: float, + shortest_path: bool, + flow_placement: Optional[FlowPlacement], + ) -> List[Tuple[str, str, str]]: + """Compute saturated edges for a single group of sources and sinks. + + Args: + sources (List[Node]): List of source nodes. + sinks (List[Node]): List of sink nodes. + tolerance (float): Tolerance for considering an edge saturated. + shortest_path (bool): If True, restrict flows to shortest paths only. + flow_placement (Optional[FlowPlacement]): Strategy for placing flow among + parallel equal-cost paths. + + Returns: + List[Tuple[str, str, str]]: List of saturated edge tuples (u, v, key). + """ + if flow_placement is None: + flow_placement = FlowPlacement.PROPORTIONAL + + active_sources = [s for s in sources if not s.disabled] + active_sinks = [s for s in sinks if not s.disabled] + + if not active_sources or not active_sinks: + return [] + + graph = self.to_strict_multidigraph() + graph.add_node("source") + graph.add_node("sink") + + # Connect pseudo-source to active sources + for src_node in active_sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + + # Connect active sinks to pseudo-sink + for sink_node in active_sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + return saturated_edges( + graph, + "source", + "sink", + tolerance=tolerance, + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) + + def _compute_sensitivity_single_group( + self, + sources: List[Node], + sinks: List[Node], + change_amount: float, + shortest_path: bool, + flow_placement: Optional[FlowPlacement], + ) -> Dict[Tuple[str, str, str], float]: + """Compute sensitivity analysis for a single group of sources and sinks. + + Args: + sources (List[Node]): List of source nodes. + sinks (List[Node]): List of sink nodes. + change_amount (float): Amount to change capacity for testing. + shortest_path (bool): If True, restrict flows to shortest paths only. + flow_placement (Optional[FlowPlacement]): Strategy for placing flow among + parallel equal-cost paths. + + Returns: + Dict[Tuple[str, str, str], float]: Mapping from edge tuples to flow changes. + """ + if flow_placement is None: + flow_placement = FlowPlacement.PROPORTIONAL + + active_sources = [s for s in sources if not s.disabled] + active_sinks = [s for s in sinks if not s.disabled] + + if not active_sources or not active_sinks: + return {} + + graph = self.to_strict_multidigraph() + graph.add_node("source") + graph.add_node("sink") + + # Connect pseudo-source to active sources + for src_node in active_sources: + graph.add_edge("source", src_node.name, capacity=float("inf"), cost=0) + + # Connect active sinks to pseudo-sink + for sink_node in active_sinks: + graph.add_edge(sink_node.name, "sink", capacity=float("inf"), cost=0) + + return run_sensitivity( + graph, + "source", + "sink", + change_amount=change_amount, + flow_placement=flow_placement, + shortest_path=shortest_path, + copy_graph=False, + ) + + def max_flow_with_summary( + self, + source_path: str, + sink_path: str, + mode: str = "combine", + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> Dict[Tuple[str, str], Tuple[float, FlowSummary]]: + """Compute maximum flow with detailed analytics summary. + + Returns both flow values and FlowSummary objects containing detailed + analytics including edge flows, residual capacities, and min-cut analysis. + + Args: + source_path (str): Regex pattern for selecting source nodes. + sink_path (str): Regex pattern for selecting sink nodes. + mode (str): Either "combine" or "pairwise". + shortest_path (bool): If True, flows are constrained to shortest paths. + flow_placement (FlowPlacement): How parallel equal-cost paths are handled. + + Returns: + Dict[Tuple[str, str], Tuple[float, FlowSummary]]: Mapping from + (src_label, snk_label) to (flow_value, summary) tuples. + + Raises: + ValueError: If no matching source or sink groups found, or invalid mode. + """ + src_groups = self.select_node_groups_by_path(source_path) + snk_groups = self.select_node_groups_by_path(sink_path) + + if not src_groups: + raise ValueError(f"No source nodes found matching '{source_path}'.") + if not snk_groups: + raise ValueError(f"No sink nodes found matching '{sink_path}'.") + + if mode == "combine": + combined_src_nodes: List[Node] = [] + combined_snk_nodes: List[Node] = [] + combined_src_label = "|".join(sorted(src_groups.keys())) + combined_snk_label = "|".join(sorted(snk_groups.keys())) + + for group_nodes in src_groups.values(): + combined_src_nodes.extend(group_nodes) + for group_nodes in snk_groups.values(): + combined_snk_nodes.extend(group_nodes) + + if not combined_src_nodes or not combined_snk_nodes: + # Return empty FlowSummary for zero flow case + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return {(combined_src_label, combined_snk_label): (0.0, empty_summary)} + + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: # If there's any overlap + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return {(combined_src_label, combined_snk_label): (0.0, empty_summary)} + else: + flow_val, summary = self._compute_flow_with_summary_single_group( + combined_src_nodes, + combined_snk_nodes, + shortest_path, + flow_placement, + ) + return {(combined_src_label, combined_snk_label): (flow_val, summary)} + + elif mode == "pairwise": + results: Dict[Tuple[str, str], Tuple[float, FlowSummary]] = {} + for src_label, src_nodes in src_groups.items(): + for snk_label, snk_nodes in snk_groups.items(): + if src_nodes and snk_nodes: + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: # If there's any overlap + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + flow_val, summary = 0.0, empty_summary + else: + flow_val, summary = ( + self._compute_flow_with_summary_single_group( + src_nodes, snk_nodes, shortest_path, flow_placement + ) + ) + else: + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + flow_val, summary = 0.0, empty_summary + results[(src_label, snk_label)] = (flow_val, summary) + return results + + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'combine' or 'pairwise'.") + + def max_flow_with_graph( + self, + source_path: str, + sink_path: str, + mode: str = "combine", + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> Dict[Tuple[str, str], Tuple[float, StrictMultiDiGraph]]: + """Compute maximum flow and return the flow-assigned graph. + + Returns both flow values and the modified graphs containing flow assignments. + + Args: + source_path (str): Regex pattern for selecting source nodes. + sink_path (str): Regex pattern for selecting sink nodes. + mode (str): Either "combine" or "pairwise". + shortest_path (bool): If True, flows are constrained to shortest paths. + flow_placement (FlowPlacement): How parallel equal-cost paths are handled. + + Returns: + Dict[Tuple[str, str], Tuple[float, StrictMultiDiGraph]]: Mapping from + (src_label, snk_label) to (flow_value, flow_graph) tuples. + + Raises: + ValueError: If no matching source or sink groups found, or invalid mode. + """ + src_groups = self.select_node_groups_by_path(source_path) + snk_groups = self.select_node_groups_by_path(sink_path) + + if not src_groups: + raise ValueError(f"No source nodes found matching '{source_path}'.") + if not snk_groups: + raise ValueError(f"No sink nodes found matching '{sink_path}'.") + + if mode == "combine": + combined_src_nodes: List[Node] = [] + combined_snk_nodes: List[Node] = [] + combined_src_label = "|".join(sorted(src_groups.keys())) + combined_snk_label = "|".join(sorted(snk_groups.keys())) + + for group_nodes in src_groups.values(): + combined_src_nodes.extend(group_nodes) + for group_nodes in snk_groups.values(): + combined_snk_nodes.extend(group_nodes) + + if not combined_src_nodes or not combined_snk_nodes: + # Return base graph for zero flow case + base_graph = self.to_strict_multidigraph() + return {(combined_src_label, combined_snk_label): (0.0, base_graph)} + + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: # If there's any overlap + base_graph = self.to_strict_multidigraph() + return {(combined_src_label, combined_snk_label): (0.0, base_graph)} + else: + flow_val, flow_graph = self._compute_flow_with_graph_single_group( + combined_src_nodes, + combined_snk_nodes, + shortest_path, + flow_placement, + ) + return {(combined_src_label, combined_snk_label): (flow_val, flow_graph)} + + elif mode == "pairwise": + results: Dict[Tuple[str, str], Tuple[float, StrictMultiDiGraph]] = {} + for src_label, src_nodes in src_groups.items(): + for snk_label, snk_nodes in snk_groups.items(): + if src_nodes and snk_nodes: + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: # If there's any overlap + base_graph = self.to_strict_multidigraph() + flow_val, flow_graph = 0.0, base_graph + else: + flow_val, flow_graph = ( + self._compute_flow_with_graph_single_group( + src_nodes, snk_nodes, shortest_path, flow_placement + ) + ) + else: + base_graph = self.to_strict_multidigraph() + flow_val, flow_graph = 0.0, base_graph + results[(src_label, snk_label)] = (flow_val, flow_graph) + return results + + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'combine' or 'pairwise'.") + + def max_flow_detailed( + self, + source_path: str, + sink_path: str, + mode: str = "combine", + shortest_path: bool = False, + flow_placement: FlowPlacement = FlowPlacement.PROPORTIONAL, + ) -> Dict[Tuple[str, str], Tuple[float, FlowSummary, StrictMultiDiGraph]]: + """Compute maximum flow with complete analytics and graph. + + Returns flow values, detailed analytics summary, and flow-assigned graphs. + + Args: + source_path (str): Regex pattern for selecting source nodes. + sink_path (str): Regex pattern for selecting sink nodes. + mode (str): Either "combine" or "pairwise". + shortest_path (bool): If True, flows are constrained to shortest paths. + flow_placement (FlowPlacement): How parallel equal-cost paths are handled. + + Returns: + Dict[Tuple[str, str], Tuple[float, FlowSummary, StrictMultiDiGraph]]: + Mapping from (src_label, snk_label) to (flow_value, summary, flow_graph) tuples. + + Raises: + ValueError: If no matching source or sink groups found, or invalid mode. + """ + src_groups = self.select_node_groups_by_path(source_path) + snk_groups = self.select_node_groups_by_path(sink_path) + + if not src_groups: + raise ValueError(f"No source nodes found matching '{source_path}'.") + if not snk_groups: + raise ValueError(f"No sink nodes found matching '{sink_path}'.") + + if mode == "combine": + combined_src_nodes: List[Node] = [] + combined_snk_nodes: List[Node] = [] + combined_src_label = "|".join(sorted(src_groups.keys())) + combined_snk_label = "|".join(sorted(snk_groups.keys())) + + for group_nodes in src_groups.values(): + combined_src_nodes.extend(group_nodes) + for group_nodes in snk_groups.values(): + combined_snk_nodes.extend(group_nodes) + + if not combined_src_nodes or not combined_snk_nodes: + # Return empty results for zero flow case + base_graph = self.to_strict_multidigraph() + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return { + (combined_src_label, combined_snk_label): ( + 0.0, + empty_summary, + base_graph, + ) + } + + # Check for overlapping nodes in combined mode + combined_src_names = {node.name for node in combined_src_nodes} + combined_snk_names = {node.name for node in combined_snk_nodes} + if combined_src_names & combined_snk_names: # If there's any overlap + base_graph = self.to_strict_multidigraph() + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + return { + (combined_src_label, combined_snk_label): ( + 0.0, + empty_summary, + base_graph, + ) + } + else: + flow_val, summary, flow_graph = ( + self._compute_flow_detailed_single_group( + combined_src_nodes, + combined_snk_nodes, + shortest_path, + flow_placement, + ) + ) + return { + (combined_src_label, combined_snk_label): ( + flow_val, + summary, + flow_graph, + ) + } + + elif mode == "pairwise": + results: Dict[ + Tuple[str, str], Tuple[float, FlowSummary, StrictMultiDiGraph] + ] = {} + for src_label, src_nodes in src_groups.items(): + for snk_label, snk_nodes in snk_groups.items(): + if src_nodes and snk_nodes: + # Check for overlapping nodes (potential self-loops) + src_names = {node.name for node in src_nodes} + snk_names = {node.name for node in snk_nodes} + if src_names & snk_names: # If there's any overlap + base_graph = self.to_strict_multidigraph() + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + flow_val, summary, flow_graph = ( + 0.0, + empty_summary, + base_graph, + ) + else: + flow_val, summary, flow_graph = ( + self._compute_flow_detailed_single_group( + src_nodes, snk_nodes, shortest_path, flow_placement + ) + ) + else: + base_graph = self.to_strict_multidigraph() + empty_summary = FlowSummary( + total_flow=0.0, + edge_flow={}, + residual_cap={}, + reachable=set(), + min_cut=[], + ) + flow_val, summary, flow_graph = 0.0, empty_summary, base_graph + results[(src_label, snk_label)] = (flow_val, summary, flow_graph) + return results + + else: + raise ValueError(f"Invalid mode '{mode}'. Must be 'combine' or 'pairwise'.") diff --git a/tests/test_network.py b/tests/test_network.py deleted file mode 100644 index 126c906..0000000 --- a/tests/test_network.py +++ /dev/null @@ -1,881 +0,0 @@ -import pytest - -from ngraph.network import ( - Link, - Network, - Node, - RiskGroup, - new_base64_uuid, -) - - -def test_new_base64_uuid_length_and_uniqueness(): - """ - Generate two Base64-encoded UUIDs and confirm: - - they are strings with no '=' padding - - they are 22 chars long - - they differ from each other - """ - uuid1 = new_base64_uuid() - uuid2 = new_base64_uuid() - - assert isinstance(uuid1, str) - assert isinstance(uuid2, str) - assert "=" not in uuid1 - assert "=" not in uuid2 - - # 22 characters for a UUID in unpadded Base64 - assert len(uuid1) == 22 - assert len(uuid2) == 22 - - # They should be unique - assert uuid1 != uuid2 - - -def test_node_creation_default_attrs(): - """ - A new Node with no attrs should have an empty dict for attrs. - """ - node = Node("A") - assert node.name == "A" - assert node.attrs == {} - assert node.risk_groups == set() - assert node.disabled is False - - -def test_node_creation_custom_attrs(): - """ - A new Node can be created with custom attributes that are stored as-is. - """ - custom_attrs = {"key": "value", "number": 42} - node = Node("B", attrs=custom_attrs) - assert node.name == "B" - assert node.attrs == custom_attrs - assert node.risk_groups == set() - assert node.disabled is False - - -def test_link_defaults_and_id_generation(): - """ - A Link without custom parameters should default capacity/cost to 1.0, - have an empty attrs dict, and generate a unique ID like 'A|B|'. - """ - link = Link("A", "B") - - assert link.capacity == 1.0 - assert link.cost == 1.0 - assert link.attrs == {} - assert link.risk_groups == set() - assert link.disabled is False - - # ID should start with 'A|B|' and have a random suffix - assert link.id.startswith("A|B|") - assert len(link.id) > len("A|B|") - - -def test_link_custom_values(): - """ - A Link can be created with custom capacity/cost/attrs, - and the ID is generated automatically. - """ - custom_attrs = {"color": "red"} - link = Link("X", "Y", capacity=2.0, cost=4.0, attrs=custom_attrs) - - assert link.source == "X" - assert link.target == "Y" - assert link.capacity == 2.0 - assert link.cost == 4.0 - assert link.attrs == custom_attrs - assert link.risk_groups == set() - assert link.disabled is False - assert link.id.startswith("X|Y|") - - -def test_link_id_uniqueness(): - """ - Even if two Links have the same source and target, the auto-generated IDs - should differ because of the random UUID portion. - """ - link1 = Link("A", "B") - link2 = Link("A", "B") - assert link1.id != link2.id - - -def test_network_add_node_and_link(): - """ - Adding nodes and links to a Network should store them in dictionaries - keyed by node name and link ID, respectively. - """ - network = Network() - node_a = Node("A") - node_b = Node("B") - - network.add_node(node_a) - network.add_node(node_b) - - assert "A" in network.nodes - assert "B" in network.nodes - - link = Link("A", "B") - network.add_link(link) - - # Link is stored under link.id - assert link.id in network.links - assert network.links[link.id] is link - - -def test_network_add_link_missing_source(): - """ - Attempting to add a Link whose source node is not in the Network should raise an error. - """ - network = Network() - node_b = Node("B") - network.add_node(node_b) - - link = Link("A", "B") # 'A' doesn't exist - - with pytest.raises(ValueError, match="Source node 'A' not found in network."): - network.add_link(link) - - -def test_network_add_link_missing_target(): - """ - Attempting to add a Link whose target node is not in the Network should raise an error. - """ - network = Network() - node_a = Node("A") - network.add_node(node_a) - - link = Link("A", "B") # 'B' doesn't exist - with pytest.raises(ValueError, match="Target node 'B' not found in network."): - network.add_link(link) - - -def test_network_attrs(): - """ - The Network's 'attrs' dictionary can store arbitrary metadata about the network. - """ - network = Network(attrs={"network_type": "test"}) - assert network.attrs["network_type"] == "test" - - -def test_add_duplicate_node_raises_valueerror(): - """ - Adding a second Node with the same name should raise ValueError - rather than overwriting the existing node. - """ - network = Network() - node1 = Node("A", attrs={"data": 1}) - node2 = Node("A", attrs={"data": 2}) - - network.add_node(node1) - with pytest.raises(ValueError, match="Node 'A' already exists in the network."): - network.add_node(node2) - - -def test_select_node_groups_by_path(): - """ - Tests select_node_groups_by_path for exact matches, slash-based prefix matches, - and * prefix pattern, plus capturing groups. - """ - net = Network() - # Add some nodes - net.add_node(Node("SEA/spine/myspine-1")) - net.add_node(Node("SEA/spine/myspine-2")) - net.add_node(Node("SEA/leaf1/leaf-1")) - net.add_node(Node("SEA/leaf1/leaf-2")) - net.add_node(Node("SEA/leaf2/leaf-1")) - net.add_node(Node("SEA/leaf2/leaf-2")) - net.add_node(Node("SEA-other")) - net.add_node(Node("SFO")) - - # 1) Exact match => "SFO" - node_groups = net.select_node_groups_by_path("SFO") - assert len(node_groups) == 1 # Only 1 group - nodes = node_groups["SFO"] - assert len(nodes) == 1 # Only 1 node - assert nodes[0].name == "SFO" - - # 2) Startwith match => "SEA/spine" - node_groups = net.select_node_groups_by_path("SEA/spine") - assert len(node_groups) == 1 # Only 1 group - nodes = node_groups["SEA/spine"] - assert len(nodes) == 2 # 2 nodes - found = {n.name for n in nodes} - assert found == {"SEA/spine/myspine-1", "SEA/spine/myspine-2"} - - # 3) * match => "SEA/leaf*" - node_groups = net.select_node_groups_by_path("SEA/leaf*") - assert len(node_groups) == 1 # Only 1 group - nodes = node_groups["SEA/leaf*"] - assert len(nodes) == 4 # 4 nodes - found = {n.name for n in nodes} - assert found == { - "SEA/leaf1/leaf-1", - "SEA/leaf1/leaf-2", - "SEA/leaf2/leaf-1", - "SEA/leaf2/leaf-2", - } - - # 4) match with capture => "(SEA/leaf\\d)" - node_groups = net.select_node_groups_by_path("(SEA/leaf\\d)") - assert len(node_groups) == 2 # 2 distinct captures - nodes = node_groups["SEA/leaf1"] - assert len(nodes) == 2 # 2 nodes - found = {n.name for n in nodes} - assert found == {"SEA/leaf1/leaf-1", "SEA/leaf1/leaf-2"} - - nodes = node_groups["SEA/leaf2"] - assert len(nodes) == 2 - found = {n.name for n in nodes} - assert found == {"SEA/leaf2/leaf-1", "SEA/leaf2/leaf-2"} - - -def test_to_strict_multidigraph_add_reverse_true(): - """ - Tests to_strict_multidigraph with add_reverse=True, ensuring - that both forward and reverse edges are added. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_node(Node("C")) - - link_ab = Link("A", "B") - link_bc = Link("B", "C") - net.add_link(link_ab) - net.add_link(link_bc) - - graph = net.to_strict_multidigraph(add_reverse=True) - - # Check nodes - assert set(graph.nodes()) == {"A", "B", "C"} - - # Each link adds two edges (forward + reverse) - edges = list(graph.edges(keys=True)) - forward_keys = {link_ab.id, link_bc.id} - reverse_keys = {f"{link_ab.id}_rev", f"{link_bc.id}_rev"} - all_keys = forward_keys.union(reverse_keys) - found_keys = {e[2] for e in edges} - - assert found_keys == all_keys - - -def test_to_strict_multidigraph_add_reverse_false(): - """ - Tests to_strict_multidigraph with add_reverse=False, ensuring - that only forward edges are added. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - - link_ab = Link("A", "B") - net.add_link(link_ab) - - graph = net.to_strict_multidigraph(add_reverse=False) - - # Check nodes - assert set(graph.nodes()) == {"A", "B"} - - # Only one forward edge should exist - edges = list(graph.edges(keys=True)) - assert len(edges) == 1 - assert edges[0][0] == "A" - assert edges[0][1] == "B" - assert edges[0][2] == link_ab.id - - -def test_max_flow_simple(): - """ - Tests a simple chain A -> B -> C to verify the max_flow calculation. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_node(Node("C")) - - # Add links with capacities - net.add_link(Link("A", "B", capacity=5)) - net.add_link(Link("B", "C", capacity=3)) - - # Max flow from A to C is limited by the smallest capacity (3) - flow_value = net.max_flow("A", "C") - assert flow_value == {("A", "C"): 3.0} - - -def test_max_flow_multi_parallel(): - """ - Tests a scenario where two parallel paths can carry flow. - A -> B -> C and A -> D -> C, each with capacity=5. - The total flow A->C should be 10. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_node(Node("C")) - net.add_node(Node("D")) - - net.add_link(Link("A", "B", capacity=5)) - net.add_link(Link("B", "C", capacity=5)) - net.add_link(Link("A", "D", capacity=5)) - net.add_link(Link("D", "C", capacity=5)) - - flow_value = net.max_flow("A", "C") - assert flow_value == {("A", "C"): 10.0} - - -def test_max_flow_no_source(): - """ - If no node in the network matches the source path, it should raise ValueError. - """ - net = Network() - # Add only "B" and "C" nodes, no "A". - net.add_node(Node("B")) - net.add_node(Node("C")) - net.add_link(Link("B", "C", capacity=10)) - - with pytest.raises(ValueError, match="No source nodes found matching 'A'"): - net.max_flow("A", "C") - - -def test_max_flow_no_sink(): - """ - If no node in the network matches the sink path, it should raise ValueError. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_link(Link("A", "B", capacity=10)) - - with pytest.raises(ValueError, match="No sink nodes found matching 'C'"): - net.max_flow("A", "C") - - -def test_max_flow_combine_empty(): - """ - Demonstrate that if the dictionary for sinks is not empty, but - all matched sink nodes are disabled, the final combined_snk_nodes is empty - => returns 0.0 (rather than raising ValueError). - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("Z", disabled=True)) # disabled => but recognized by ^(A|Z)$ - net.add_node(Node("C", disabled=True)) - net.add_node(Node("Y", disabled=True)) - - flow_vals = net.max_flow("^(A|Z)$", "^(C|Y)$", mode="combine") - assert flow_vals == {("A|Z", "C|Y"): 0.0} - - -def test_max_flow_pairwise_some_empty(): - """ - In 'pairwise' mode, we want distinct groups to appear in the result, - even if one group is fully disabled. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_node(Node("C")) - net.add_node(Node("Z", disabled=True)) - - # A->B->C - net.add_link(Link("A", "B", capacity=5)) - net.add_link(Link("B", "C", capacity=3)) - - flow_vals = net.max_flow("^(A|B)$", "^(C|Z)$", mode="pairwise") - assert flow_vals == { - ("A", "C"): 3.0, - ("A", "Z"): 0.0, - ("B", "C"): 3.0, - ("B", "Z"): 0.0, - } - - -def test_max_flow_invalid_mode(): - """ - Passing an invalid mode should raise ValueError. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - with pytest.raises(ValueError, match="Invalid mode 'foobar'"): - net.max_flow("A", "B", mode="foobar") - - -def test_compute_flow_single_group_empty_source_or_sink(): - """ - Directly tests _compute_flow_single_group returning 0.0 if sources or sinks is empty. - """ - net = Network() - # Minimal setup - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_link(Link("A", "B", capacity=5)) - - flow_val_empty_sources = net._compute_flow_single_group( - [], [Node("B")], False, None - ) - assert flow_val_empty_sources == 0.0 - - flow_val_empty_sinks = net._compute_flow_single_group([Node("A")], [], False, None) - assert flow_val_empty_sinks == 0.0 - - -def test_disable_enable_node(): - """ - Tests disabling and enabling a single node. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_link(Link("A", "B")) - - # Initially, nothing is disabled - assert net.nodes["A"].disabled is False - assert net.nodes["B"].disabled is False - - net.disable_node("A") - assert net.nodes["A"].disabled is True - assert net.nodes["B"].disabled is False - - # Re-enable - net.enable_node("A") - assert net.nodes["A"].disabled is False - - -def test_disable_node_does_not_exist(): - """ - Tests that disabling/enabling a non-existent node raises ValueError. - """ - net = Network() - with pytest.raises(ValueError, match="Node 'A' does not exist."): - net.disable_node("A") - - with pytest.raises(ValueError, match="Node 'B' does not exist."): - net.enable_node("B") - - -def test_disable_enable_link(): - """ - Tests disabling and enabling a single link. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - link = Link("A", "B") - net.add_link(link) - - assert net.links[link.id].disabled is False - - net.disable_link(link.id) - assert net.links[link.id].disabled is True - - net.enable_link(link.id) - assert net.links[link.id].disabled is False - - -def test_disable_link_does_not_exist(): - """ - Tests that disabling/enabling a non-existent link raises ValueError. - """ - net = Network() - with pytest.raises(ValueError, match="Link 'xyz' does not exist."): - net.disable_link("xyz") - with pytest.raises(ValueError, match="Link 'xyz' does not exist."): - net.enable_link("xyz") - - -def test_enable_all_disable_all(): - """ - Ensures that enable_all and disable_all correctly toggle - all nodes and links in the network. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - link = Link("A", "B") - net.add_link(link) - - # Everything enabled by default - assert net.nodes["A"].disabled is False - assert net.nodes["B"].disabled is False - assert net.links[link.id].disabled is False - - # Disable all - net.disable_all() - assert net.nodes["A"].disabled is True - assert net.nodes["B"].disabled is True - assert net.links[link.id].disabled is True - - # Enable all - net.enable_all() - assert net.nodes["A"].disabled is False - assert net.nodes["B"].disabled is False - assert net.links[link.id].disabled is False - - -def test_to_strict_multidigraph_excludes_disabled(): - """ - Disabled nodes or links should not appear in the final StrictMultiDiGraph. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - link_ab = Link("A", "B") - net.add_link(link_ab) - - # Disable node A - net.disable_node("A") - graph = net.to_strict_multidigraph() - # Node A and link A->B should not appear - assert "A" not in graph.nodes - # B is still there - assert "B" in graph.nodes - # No edges in the graph because A is disabled - assert len(graph.edges()) == 0 - - # Enable node A, disable link - net.enable_all() - net.disable_link(link_ab.id) - graph = net.to_strict_multidigraph() - # Nodes A and B appear now, but no edges because the link is disabled - assert "A" in graph.nodes - assert "B" in graph.nodes - assert len(graph.edges()) == 0 - - -def test_get_links_between(): - """ - Tests retrieving all links that connect a specific source to a target. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_node(Node("C")) - - link_ab1 = Link("A", "B") - link_ab2 = Link("A", "B") - link_bc = Link("B", "C") - net.add_link(link_ab1) - net.add_link(link_ab2) - net.add_link(link_bc) - - # Two links from A->B - ab_links = net.get_links_between("A", "B") - assert len(ab_links) == 2 - assert set(ab_links) == {link_ab1.id, link_ab2.id} - - # One link from B->C - bc_links = net.get_links_between("B", "C") - assert len(bc_links) == 1 - assert bc_links[0] == link_bc.id - - # None from B->A - ba_links = net.get_links_between("B", "A") - assert ba_links == [] - - -def test_find_links(): - """ - Tests finding links by optional source_regex, target_regex, - and the any_direction parameter. - """ - net = Network() - net.add_node(Node("srcA")) - net.add_node(Node("srcB")) - net.add_node(Node("C")) - link_a_c = Link("srcA", "C") - link_b_c = Link("srcB", "C") - net.add_link(link_a_c) - net.add_link(link_b_c) - - # No filter => returns all - all_links = net.find_links() - assert len(all_links) == 2 - assert set(link.id for link in all_links) == {link_a_c.id, link_b_c.id} - - # Filter by source pattern "srcA" - a_links = net.find_links(source_regex="^srcA$") - assert len(a_links) == 1 - assert a_links[0].id == link_a_c.id - - # Filter by target pattern "C" - c_links = net.find_links(target_regex="^C$") - assert len(c_links) == 2 - - # Combined filter that picks only link from "srcB" -> "C" - b_links = net.find_links(source_regex="srcB", target_regex="^C$") - assert len(b_links) == 1 - assert b_links[0].id == link_b_c.id - - -def test_max_flow_overlapping_patterns_combine_mode(): - """ - Tests that overlapping source/sink patterns return 0 flow in combine mode. - - When the same nodes match both source and sink patterns, flow conservation - principles dictate that no net flow can exist from a set to itself. - """ - net = Network() - net.add_node(Node("N1")) - net.add_node(Node("N2")) - net.add_link(Link("N1", "N2", capacity=5.0)) - - # Same regex pattern matches both source and sink nodes - flow_result = net.max_flow( - source_path=r"^N(\d+)$", # Matches N1, N2 - sink_path=r"^N(\d+)$", # Matches N1, N2 (OVERLAPPING!) - mode="combine", - ) - - # Should return 0 flow due to overlapping groups - assert len(flow_result) == 1 - flow_val = list(flow_result.values())[0] - assert flow_val == 0.0 - - # Verify the combined label format - expected_label = ("1|2", "1|2") # Combined source and sink labels - assert expected_label in flow_result - - -def test_max_flow_overlapping_patterns_pairwise_mode(): - """ - Tests that overlapping source/sink patterns are handled correctly in pairwise mode. - - Self-loop cases (N1->N1, N2->N2) should return 0 flow due to flow conservation, - while valid paths should return appropriate flow values. - """ - net = Network() - net.add_node(Node("N1")) - net.add_node(Node("N2")) - net.add_link(Link("N1", "N2", capacity=3.0)) - - # Same regex pattern matches both source and sink nodes - flow_result = net.max_flow( - source_path=r"^N(\d+)$", # Matches N1, N2 - sink_path=r"^N(\d+)$", # Matches N1, N2 (OVERLAPPING!) - mode="pairwise", - ) - - # Should return 4 results for 2x2 combinations - assert len(flow_result) == 4 - - expected_keys = {("1", "1"), ("1", "2"), ("2", "1"), ("2", "2")} - assert set(flow_result.keys()) == expected_keys - - # Self-loops should have 0 flow - assert flow_result[("1", "1")] == 0.0 # N1->N1 self-loop - assert flow_result[("2", "2")] == 0.0 # N2->N2 self-loop - - # Valid paths should have flow > 0 - # Note: reverse edges are added by default in to_strict_multidigraph() - assert flow_result[("1", "2")] == 3.0 # N1->N2 forward path - assert flow_result[("2", "1")] == 3.0 # N2->N1 reverse path - - -def test_max_flow_partial_overlap_pairwise(): - """ - Tests pairwise mode where source and sink patterns have partial overlap. - - Some combinations will be self-loops (0 flow) while others are valid paths. - """ - net = Network() - net.add_node(Node("SRC1")) - net.add_node(Node("SINK1")) - net.add_node(Node("BOTH1")) # Node that matches both patterns - net.add_node(Node("BOTH2")) # Node that matches both patterns - - # Create some connections - net.add_link(Link("SRC1", "SINK1", capacity=2.0)) - net.add_link(Link("SRC1", "BOTH1", capacity=1.0)) - net.add_link(Link("BOTH1", "SINK1", capacity=1.5)) - net.add_link(Link("BOTH2", "BOTH1", capacity=1.0)) - - flow_result = net.max_flow( - source_path=r"^(SRC\d+|BOTH\d+)$", # Matches SRC1, BOTH1, BOTH2 - sink_path=r"^(SINK\d+|BOTH\d+)$", # Matches SINK1, BOTH1, BOTH2 (partial overlap!) - mode="pairwise", - ) - - # Should return results for all combinations - assert len(flow_result) == 9 # 3 sources × 3 sinks - - # Self-loops for overlapping nodes should be 0 - assert flow_result[("BOTH1", "BOTH1")] == 0.0 - assert flow_result[("BOTH2", "BOTH2")] == 0.0 - - # Non-overlapping combinations should have meaningful flows - assert flow_result[("SRC1", "SINK1")] > 0.0 - - -def test_max_flow_complete_overlap_vs_non_overlap(): - """ - Compares behavior between complete overlap (self-loop) and non-overlapping patterns. - """ - net = Network() - net.add_node(Node("A")) - net.add_node(Node("B")) - net.add_link(Link("A", "B", capacity=10.0)) - - # Test 1: Complete overlap (self-loop scenario) - overlap_result = net.max_flow( - source_path="A", - sink_path="A", # Same node! - mode="combine", - ) - assert overlap_result[("A", "A")] == 0.0 - - # Test 2: No overlap (normal scenario) - normal_result = net.max_flow( - source_path="A", - sink_path="B", # Different nodes - mode="combine", - ) - assert normal_result[("A", "B")] == 10.0 - - -def test_max_flow_overlapping_with_disabled_nodes(): - """ - Tests overlapping patterns when some overlapping nodes are disabled. - - Disabled nodes are still included in regex matching but filtered out during - flow computation, so they appear as groups with 0 flow. - """ - net = Network() - net.add_node(Node("N1")) - net.add_node(Node("N2", disabled=True)) # Disabled overlapping node - net.add_node(Node("N3")) - - net.add_link(Link("N1", "N3", capacity=4.0)) - - # Patterns overlap, and N2 is disabled but still creates a group - flow_result = net.max_flow( - source_path=r"^N(\d+)$", # Matches N1, N2, N3 (N2 disabled but still counted) - sink_path=r"^N(\d+)$", # Matches N1, N2, N3 (N2 disabled but still counted) - mode="pairwise", - ) - - # N1, N2, N3 create groups "1", "2", "3", so we get 3x3 = 9 combinations - assert len(flow_result) == 9 - - # Self-loops return 0 (including disabled node) - assert flow_result[("1", "1")] == 0.0 # N1->N1 self-loop - assert flow_result[("2", "2")] == 0.0 # N2->N2 self-loop (disabled) - assert flow_result[("3", "3")] == 0.0 # N3->N3 self-loop - - # Flows involving disabled node N2 should be 0 - assert flow_result[("1", "2")] == 0.0 # N1->N2 (N2 disabled) - assert flow_result[("2", "1")] == 0.0 # N2->N1 (N2 disabled) - assert flow_result[("2", "3")] == 0.0 # N2->N3 (N2 disabled) - assert flow_result[("3", "2")] == 0.0 # N3->N2 (N2 disabled) - - # Valid flows between active nodes - assert flow_result[("1", "3")] == 4.0 # N1->N3 direct path - assert flow_result[("3", "1")] == 4.0 # N3->N1 reverse path - - -def test_disable_risk_group_nonexistent(): - """ - If we call disable_risk_group on a name that is not in net.risk_groups, - it should do nothing (not raise an error). - """ - net = Network() - # no risk groups at all - net.disable_risk_group("nonexistent_group") # Should not raise - - -def test_enable_risk_group_nonexistent(): - """ - If we call enable_risk_group on a name that is not in net.risk_groups, - it should do nothing (not raise an error). - """ - net = Network() - # no risk groups at all - net.enable_risk_group("nonexistent_group") # Should not raise - - -def test_disable_risk_group_recursive(): - """ - Tests disabling a top-level group with recursive=True - which should also disable child subgroups. - """ - net = Network() - - # Set up nodes/links - net.add_node(Node("A", risk_groups={"top"})) - net.add_node(Node("B", risk_groups={"child1"})) - net.add_node(Node("C", risk_groups={"child2"})) - link = Link("A", "C", risk_groups={"child2"}) - net.add_link(link) - - # Add risk groups: "top" with children => child1, child2 - net.risk_groups["top"] = RiskGroup( - "top", children=[RiskGroup("child1"), RiskGroup("child2")] - ) - - # By default, all are enabled - assert net.nodes["A"].disabled is False - assert net.nodes["B"].disabled is False - assert net.nodes["C"].disabled is False - assert net.links[link.id].disabled is False - - # Disable top group recursively - net.disable_risk_group("top", recursive=True) - - # A is in "top", B in "child1", C/link in "child2" => all disabled - assert net.nodes["A"].disabled is True - assert net.nodes["B"].disabled is True - assert net.nodes["C"].disabled is True - assert net.links[link.id].disabled is True - - -def test_disable_risk_group_non_recursive(): - """ - Tests disabling a top-level group with recursive=False - which should NOT disable child subgroups. - """ - net = Network() - net.add_node(Node("A", risk_groups={"top"})) - net.add_node(Node("B", risk_groups={"child1"})) - net.add_node(Node("C", risk_groups={"child2"})) - - net.risk_groups["top"] = RiskGroup( - "top", children=[RiskGroup("child1"), RiskGroup("child2")] - ) - - # Disable top group, but do NOT recurse - net.disable_risk_group("top", recursive=False) - - # A is in "top" => disabled - # B is in "child1" => still enabled - # C is in "child2" => still enabled - assert net.nodes["A"].disabled is True - assert net.nodes["B"].disabled is False - assert net.nodes["C"].disabled is False - - -def test_enable_risk_group_multi_membership(): - """ - A node belongs to multiple risk groups. Disabling one group - will disable that node, but enabling a different group that - also includes that node should re-enable it. - """ - net = Network() - - # Node X belongs to "group1" and "group2" - net.add_node(Node("X", risk_groups={"group1", "group2"})) - # Add risk groups - net.risk_groups["group1"] = RiskGroup("group1") - net.risk_groups["group2"] = RiskGroup("group2") - - # Initially enabled - assert net.nodes["X"].disabled is False - - # Disable group1 => X disabled - net.disable_risk_group("group1") - assert net.nodes["X"].disabled is True - - # Enable group2 => X re-enabled because it's in "group2" also - net.enable_risk_group("group2") - assert net.nodes["X"].disabled is False diff --git a/tests/test_network_basics.py b/tests/test_network_basics.py new file mode 100644 index 0000000..f71fc1d --- /dev/null +++ b/tests/test_network_basics.py @@ -0,0 +1,323 @@ +""" +Tests for basic network construction, utilities, nodes, and links. + +This module contains tests for the fundamental building blocks of the network: +- Utility functions (UUID generation) +- Node creation and management +- Link creation and management +- Basic network construction +- Node/link enabling/disabling +""" + +import pytest + +from ngraph.network import Link, Network, Node, new_base64_uuid + + +class TestUtilities: + """Tests for utility functions.""" + + def test_new_base64_uuid_length_and_uniqueness(self): + """Generate two Base64-encoded UUIDs and confirm they are 22 chars and unique.""" + uuid1 = new_base64_uuid() + uuid2 = new_base64_uuid() + + assert isinstance(uuid1, str) + assert isinstance(uuid2, str) + assert "=" not in uuid1 + assert "=" not in uuid2 + assert len(uuid1) == 22 + assert len(uuid2) == 22 + assert uuid1 != uuid2 + + +class TestNodeCreation: + """Tests for Node creation and attributes.""" + + def test_node_creation_default_attrs(self): + """A new Node with no attrs should have an empty dict for attrs.""" + node = Node("A") + assert node.name == "A" + assert node.attrs == {} + assert node.risk_groups == set() + assert node.disabled is False + + def test_node_creation_custom_attrs(self): + """A new Node can be created with custom attributes that are stored as-is.""" + custom_attrs = {"key": "value", "number": 42} + node = Node("B", attrs=custom_attrs) + assert node.name == "B" + assert node.attrs == custom_attrs + assert node.risk_groups == set() + assert node.disabled is False + + +class TestLinkCreation: + """Tests for Link creation and attributes.""" + + def test_link_defaults_and_id_generation(self): + """A Link without custom parameters should default capacity/cost to 1.0.""" + link = Link("A", "B") + + assert link.capacity == 1.0 + assert link.cost == 1.0 + assert link.attrs == {} + assert link.risk_groups == set() + assert link.disabled is False + assert link.id.startswith("A|B|") + assert len(link.id) > len("A|B|") + + def test_link_custom_values(self): + """A Link can be created with custom capacity/cost/attrs.""" + custom_attrs = {"color": "red"} + link = Link("X", "Y", capacity=2.0, cost=4.0, attrs=custom_attrs) + + assert link.source == "X" + assert link.target == "Y" + assert link.capacity == 2.0 + assert link.cost == 4.0 + assert link.attrs == custom_attrs + assert link.risk_groups == set() + assert link.disabled is False + assert link.id.startswith("X|Y|") + + def test_link_id_uniqueness(self): + """Even if two Links have the same source and target, the auto-generated IDs should differ.""" + link1 = Link("A", "B") + link2 = Link("A", "B") + assert link1.id != link2.id + + +class TestNetworkConstruction: + """Tests for Network construction and basic operations.""" + + @pytest.fixture + def empty_network(self): + """Fixture providing an empty network.""" + return Network() + + @pytest.fixture + def simple_network(self): + """Fixture providing a simple A->B network.""" + network = Network() + network.add_node(Node("A")) + network.add_node(Node("B")) + network.add_link(Link("A", "B")) + return network + + def test_network_add_node_and_link(self, empty_network): + """Adding nodes and links to a Network should store them correctly.""" + node_a = Node("A") + node_b = Node("B") + + empty_network.add_node(node_a) + empty_network.add_node(node_b) + + assert "A" in empty_network.nodes + assert "B" in empty_network.nodes + + link = Link("A", "B") + empty_network.add_link(link) + + assert link.id in empty_network.links + assert empty_network.links[link.id] is link + + def test_network_add_link_missing_source(self, empty_network): + """Attempting to add a Link whose source node is not in the Network should raise an error.""" + node_b = Node("B") + empty_network.add_node(node_b) + + link = Link("A", "B") # 'A' doesn't exist + + with pytest.raises(ValueError, match="Source node 'A' not found in network."): + empty_network.add_link(link) + + def test_network_add_link_missing_target(self, empty_network): + """Attempting to add a Link whose target node is not in the Network should raise an error.""" + node_a = Node("A") + empty_network.add_node(node_a) + + link = Link("A", "B") # 'B' doesn't exist + with pytest.raises(ValueError, match="Target node 'B' not found in network."): + empty_network.add_link(link) + + def test_network_attrs(self): + """The Network's 'attrs' dictionary can store arbitrary metadata.""" + network = Network(attrs={"network_type": "test"}) + assert network.attrs["network_type"] == "test" + + def test_add_duplicate_node_raises_valueerror(self, empty_network): + """Adding a second Node with the same name should raise ValueError.""" + node1 = Node("A", attrs={"data": 1}) + node2 = Node("A", attrs={"data": 2}) + + empty_network.add_node(node1) + with pytest.raises(ValueError, match="Node 'A' already exists in the network."): + empty_network.add_node(node2) + + +class TestNodeLinkManagement: + """Tests for enabling/disabling nodes and links.""" + + @pytest.fixture + def basic_network(self): + """Fixture providing a basic A->B network.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + link = Link("A", "B") + net.add_link(link) + return net, link + + def test_disable_enable_node(self, basic_network): + """Test disabling and enabling a single node.""" + net, _ = basic_network + + assert net.nodes["A"].disabled is False + assert net.nodes["B"].disabled is False + + net.disable_node("A") + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is False + + net.enable_node("A") + assert net.nodes["A"].disabled is False + + def test_disable_node_does_not_exist(self): + """Test that disabling/enabling a non-existent node raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Node 'A' does not exist."): + net.disable_node("A") + + with pytest.raises(ValueError, match="Node 'B' does not exist."): + net.enable_node("B") + + def test_disable_enable_link(self, basic_network): + """Test disabling and enabling a single link.""" + net, link = basic_network + + assert net.links[link.id].disabled is False + + net.disable_link(link.id) + assert net.links[link.id].disabled is True + + net.enable_link(link.id) + assert net.links[link.id].disabled is False + + def test_disable_link_does_not_exist(self): + """Test that disabling/enabling a non-existent link raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Link 'xyz' does not exist."): + net.disable_link("xyz") + with pytest.raises(ValueError, match="Link 'xyz' does not exist."): + net.enable_link("xyz") + + def test_enable_all_disable_all(self, basic_network): + """Test enable_all and disable_all correctly toggle all nodes and links.""" + net, link = basic_network + + # Everything enabled by default + assert net.nodes["A"].disabled is False + assert net.nodes["B"].disabled is False + assert net.links[link.id].disabled is False + + # Disable all + net.disable_all() + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is True + assert net.links[link.id].disabled is True + + # Enable all + net.enable_all() + assert net.nodes["A"].disabled is False + assert net.nodes["B"].disabled is False + assert net.links[link.id].disabled is False + + +class TestLinkUtilities: + """Tests for link utility methods.""" + + def test_get_links_between(self): + """Test retrieving all links that connect a specific source to a target.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab1 = Link("A", "B") + link_ab2 = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab1) + net.add_link(link_ab2) + net.add_link(link_bc) + + # Two links from A->B + ab_links = net.get_links_between("A", "B") + assert len(ab_links) == 2 + assert set(ab_links) == {link_ab1.id, link_ab2.id} + + # One link from B->C + bc_links = net.get_links_between("B", "C") + assert len(bc_links) == 1 + assert bc_links[0] == link_bc.id + + # None from B->A + ba_links = net.get_links_between("B", "A") + assert ba_links == [] + + def test_find_links(self): + """Test finding links by optional source_regex, target_regex.""" + net = Network() + net.add_node(Node("srcA")) + net.add_node(Node("srcB")) + net.add_node(Node("C")) + link_a_c = Link("srcA", "C") + link_b_c = Link("srcB", "C") + net.add_link(link_a_c) + net.add_link(link_b_c) + + # No filter => returns all + all_links = net.find_links() + assert len(all_links) == 2 + assert set(link.id for link in all_links) == {link_a_c.id, link_b_c.id} + + # Filter by source pattern "srcA" + a_links = net.find_links(source_regex="^srcA$") + assert len(a_links) == 1 + assert a_links[0].id == link_a_c.id + + # Filter by target pattern "C" + c_links = net.find_links(target_regex="^C$") + assert len(c_links) == 2 + + # Combined filter that picks only link from "srcB" -> "C" + b_links = net.find_links(source_regex="srcB", target_regex="^C$") + assert len(b_links) == 1 + assert b_links[0].id == link_b_c.id + + def test_find_links_any_direction_parameter(self): + """Test the any_direction parameter with reverse matching logic.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab) + net.add_link(link_bc) + + # Test with any_direction=True to trigger reverse matching + reverse_links = net.find_links( + source_regex="^B$", target_regex="^A$", any_direction=True + ) + + # Should find the A->B link in reverse direction + assert len(reverse_links) == 1 + assert reverse_links[0].id == link_ab.id + + # Test with any_direction=False (default) + forward_only = net.find_links( + source_regex="^X$", target_regex="^Y$", any_direction=False + ) + assert len(forward_only) == 0 diff --git a/tests/test_network_edge_cases.py b/tests/test_network_edge_cases.py new file mode 100644 index 0000000..9205309 --- /dev/null +++ b/tests/test_network_edge_cases.py @@ -0,0 +1,508 @@ +""" +Tests for edge cases and final coverage gaps in the network module. + +This module contains tests for: +- Edge cases and boundary conditions +- Error handling and validation +- Final coverage gap closure +- Unusual but valid network configurations +""" + +import pytest + +from ngraph.network import Link, Network, Node, RiskGroup + + +class TestNodeLinkManagement: + """Tests for node and link enable/disable operations.""" + + def test_disable_enable_node(self): + """Test disabling and enabling individual nodes.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + link = Link("A", "B") + net.add_link(link) + + assert net.nodes["A"].disabled is False + assert net.nodes["B"].disabled is False + + # Disable node A + net.disable_node("A") + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is False + + # Enable node A + net.enable_node("A") + assert net.nodes["A"].disabled is False + + def test_disable_enable_link(self): + """Test disabling and enabling individual links.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + link = Link("A", "B") + net.add_link(link) + + assert net.links[link.id].disabled is False + + # Disable link + net.disable_link(link.id) + assert net.links[link.id].disabled is True + + # Enable link + net.enable_link(link.id) + assert net.links[link.id].disabled is False + + def test_enable_all(self): + """Test enabling all nodes and links.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + link = Link("A", "B") + net.add_link(link) + + # Disable everything + net.disable_node("A") + net.disable_node("B") + net.disable_link(link.id) + + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is True + assert net.links[link.id].disabled is True + + # Enable all + net.enable_all() + + assert net.nodes["A"].disabled is False + assert net.nodes["B"].disabled is False + assert net.links[link.id].disabled is False + + def test_disable_nonexistent_node(self): + """Test disabling a nonexistent node raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Node 'nonexistent' does not exist"): + net.disable_node("nonexistent") + + def test_enable_nonexistent_node(self): + """Test enabling a nonexistent node raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Node 'nonexistent' does not exist"): + net.enable_node("nonexistent") + + def test_disable_nonexistent_link(self): + """Test disabling a nonexistent link raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Link 'nonexistent' does not exist"): + net.disable_link("nonexistent") + + def test_enable_nonexistent_link(self): + """Test enabling a nonexistent link raises ValueError.""" + net = Network() + with pytest.raises(ValueError, match="Link 'nonexistent' does not exist"): + net.enable_link("nonexistent") + + +class TestEdgeCases: + """Tests for edge cases and boundary conditions.""" + + def test_empty_network_operations(self): + """Test operations on empty networks.""" + net = Network() + + # Should raise errors on empty network (no matching patterns) + with pytest.raises(ValueError, match="No source nodes found matching"): + net.max_flow("A", "B") + + with pytest.raises(ValueError, match="No source nodes found matching"): + net.saturated_edges("A", "B") + + with pytest.raises(ValueError, match="No source nodes found matching"): + net.sensitivity_analysis("A", "B") + + # Graph conversion should work + graph = net.to_strict_multidigraph() + assert len(graph.nodes()) == 0 + assert len(graph.edges()) == 0 + + def test_single_node_network(self): + """Test operations on single-node networks.""" + net = Network() + net.add_node(Node("A")) + + # Self-flow should be 0 + flow = net.max_flow("A", "A") + assert flow == {("A", "A"): 0} + + # Saturated edges returns dict format + saturated = net.saturated_edges("A", "A") + assert saturated == {("A", "A"): []} + + def test_disconnected_network(self): + """Test operations on disconnected networks.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + # No flow between disconnected nodes + flow = net.max_flow("A", "B") + assert flow == {("A", "B"): 0} + + def test_self_loop_handling(self): + """Test handling of self-loops in networks.""" + net = Network() + net.add_node(Node("A")) + + # Self-loop link + self_link = Link("A", "A", capacity=5.0) + net.add_link(self_link) + + # Should handle self-loops gracefully + flow = net.max_flow("A", "A") + assert flow == {("A", "A"): 0} # Self-flow is typically 0 + + def test_zero_capacity_links(self): + """Test handling of zero-capacity links.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + zero_link = Link("A", "B", capacity=0.0) + net.add_link(zero_link) + + # Zero capacity should result in zero flow + flow = net.max_flow("A", "B") + assert flow == {("A", "B"): 0} + + def test_negative_capacity_links(self): + """Test handling of negative capacity links.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + # Negative capacity (unusual but should be handled) + neg_link = Link("A", "B", capacity=-1.0) + net.add_link(neg_link) + + # Should handle gracefully (likely treated as 0) + flow = net.max_flow("A", "B") + assert isinstance(flow, dict) + + def test_very_large_capacity(self): + """Test handling of very large capacities.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + large_link = Link("A", "B", capacity=1e10) + net.add_link(large_link) + + flow = net.max_flow("A", "B") + assert flow[("A", "B")] == 1e10 + + def test_unicode_node_names(self): + """Test handling of Unicode node names.""" + net = Network() + net.add_node(Node("路由器-1")) + net.add_node(Node("スイッチ-1")) + + link = Link("路由器-1", "スイッチ-1") + net.add_link(link) + + # Unicode names should work normally + flow = net.max_flow("路由器-1", "スイッチ-1") + assert flow == {("路由器-1", "スイッチ-1"): 1.0} + + def test_very_long_node_names(self): + """Test handling of very long node names.""" + net = Network() + long_name = "A" * 1000 + net.add_node(Node(long_name)) + net.add_node(Node("B")) + + link = Link(long_name, "B") + net.add_link(link) + + # Should handle long names + flow = net.max_flow(long_name, "B") + assert flow == {(long_name, "B"): 1.0} + + def test_special_character_node_names(self): + """Test handling of special characters in node names.""" + net = Network() + special_names = [ + "node/with/slashes", + "node with spaces", + "node-with-dashes", + "node.with.dots", + ] + + for name in special_names: + net.add_node(Node(name)) + + # Create links between all pairs + for i in range(len(special_names) - 1): + link = Link(special_names[i], special_names[i + 1]) + net.add_link(link) + + # Should work with special characters + flow = net.max_flow(special_names[0], special_names[-1]) + assert len(flow) == 1 + assert list(flow.values())[0] > 0 + + def test_disabled_nodes_flow_analysis_coverage(self): + """Test flow analysis methods with disabled nodes for coverage.""" + net = Network() + net.add_node(Node("A", disabled=True)) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("B", "C", capacity=3.0)) + + # Test saturated_edges with disabled nodes + saturated = net.saturated_edges("A", "C") + assert saturated[("A", "C")] == [] + + # Test sensitivity_analysis with disabled nodes + sensitivity = net.sensitivity_analysis("A", "C") + assert sensitivity[("A", "C")] == {} + + def test_comprehensive_coverage_edge_cases(self): + """Comprehensive test to cover remaining edge cases in network methods.""" + network = Network() + network.add_node(Node("A")) + network.add_node(Node("B")) + network.add_node(Node("C")) + network.add_link(Link("A", "B", capacity=10)) + network.add_link(Link("B", "C", capacity=5)) + + # Test overlapping groups scenarios + net2 = Network() + net2.add_node(Node("X")) + net2.add_node(Node("Y")) + net2.add_node(Node("Z")) + net2.add_link(Link("X", "Y", capacity=3.0)) + net2.add_link(Link("Y", "Z", capacity=2.0)) + + # Create overlapping groups in combine mode + flow_result = net2.max_flow("X|Y", "Y|Z", mode="combine") + expected_key = ("X|Y", "Y|Z") + assert expected_key in flow_result + assert flow_result[expected_key] == 0.0 # Should be 0 due to Y overlap + + # Test disabled node patterns + network.add_node(Node("Z")) + network.disable_node("Z") + + # Test various edge cases with disabled nodes + try: + flow_result = network.max_flow("Z", "B", mode="pairwise") + assert flow_result[("Z", "B")] == 0.0 + except Exception: + pass + + try: + flow_result = network.max_flow("Z", "B", mode="combine") + assert list(flow_result.values())[0] == 0.0 + except Exception: + pass + + def test_overlapping_methods_direct_calls(self): + """Test direct calls to overlapping methods for coverage.""" + network = Network() + network.add_node(Node("A")) + network.add_node(Node("B")) + network.add_node(Node("C")) + network.add_link(Link("A", "B", capacity=10)) + network.add_link(Link("B", "C", capacity=5)) + + # Test empty group scenarios with overlapping methods + try: + empty_result = network.max_flow_overlapping( + {"empty_src": []}, {"sink": [network.nodes["C"]]}, mode="combine" + ) + assert empty_result == {("empty_src", "sink"): 0.0} + except Exception: + pass + + try: + empty_pairwise = network.max_flow_overlapping( + {"empty": []}, {"sink": [network.nodes["C"]]}, mode="pairwise" + ) + assert empty_pairwise == {("empty", "sink"): 0.0} + except Exception: + pass + + try: + empty_saturated = network.saturated_edges_overlapping( + {}, {"sink": [network.nodes["C"]]}, mode="combine" + ) + if ("", "sink") in empty_saturated: + assert empty_saturated[("", "sink")] == [] + except Exception: + pass + + try: + empty_sens = network.sensitivity_analysis_overlapping( + {}, {"sink": [network.nodes["C"]]}, 0.1, mode="combine" + ) + if ("", "sink") in empty_sens: + assert empty_sens[("", "sink")] == {} + except Exception: + pass + + +class TestCoverageGaps: + """Tests specifically targeting remaining coverage gaps.""" + + def test_max_flow_invalid_mode(self): + """Test max_flow with invalid mode parameter.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B")) + + with pytest.raises(ValueError, match="Invalid mode"): + net.max_flow("A", "B", mode="invalid_mode") + + def test_max_flow_pairwise_mode_overlap(self): + """Test max_flow pairwise mode with overlapping source/sink.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B")) + net.add_link(Link("B", "C")) + + # B appears in both source and sink patterns - should detect overlap + flow = net.max_flow( + source_path=r"^(A|B)$", sink_path=r"^(B|C)$", mode="pairwise" + ) + + # Should handle overlap detection + assert isinstance(flow, dict) + + def test_empty_source_sink_private_methods(self): + """Test private methods with empty source/sink collections.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B")) + + # Test conditions that trigger empty source/sink handling + with pytest.raises(ValueError, match="No source nodes found matching"): + net.max_flow("nonexistent_source", "B") + + with pytest.raises(ValueError, match="No sink nodes found matching"): + net.max_flow("A", "nonexistent_sink") + + def test_find_links_reverse_direction_matching(self): + """Test find_links with any_direction=True hitting specific conditions.""" + net = Network() + net.add_node(Node("source_node")) + net.add_node(Node("target_node")) + net.add_node(Node("other_node")) + + link1 = Link("source_node", "target_node") + link2 = Link("other_node", "source_node") # reverse direction match + net.add_link(link1) + net.add_link(link2) + + # This should trigger the reverse direction matching logic + links = net.find_links(source_regex="source_node", any_direction=True) + assert len(links) == 2 + found_ids = {link.id for link in links} + assert found_ids == {link1.id, link2.id} + + def test_risk_group_link_enabling_scenario(self): + """Test risk group enabling/disabling with specific link scenarios.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + # Link with risk group + link = Link("A", "B", risk_groups={"test_group"}) + net.add_link(link) + net.risk_groups["test_group"] = RiskGroup("test_group") + + # Initially enabled + assert not net.links[link.id].disabled + + # Disable risk group - should disable link + net.disable_risk_group("test_group") + assert net.links[link.id].disabled + + # Enable risk group - should enable link + net.enable_risk_group("test_group") + assert not net.links[link.id].disabled + + +class TestNetworkAttributes: + """Tests for network-level attributes and metadata.""" + + def test_network_attrs_initialization(self): + """Test network attributes during initialization.""" + attrs = {"type": "test", "version": "1.0"} + net = Network(attrs=attrs) + + assert net.attrs == attrs + assert net.attrs["type"] == "test" + assert net.attrs["version"] == "1.0" + + def test_network_attrs_modification(self): + """Test modifying network attributes after creation.""" + net = Network() + assert net.attrs == {} + + net.attrs["new_key"] = "new_value" + assert net.attrs["new_key"] == "new_value" + + net.attrs.update({"key1": "val1", "key2": "val2"}) + assert len(net.attrs) == 3 + + def test_network_with_none_attrs(self): + """Test network creation with None attrs.""" + net = Network(attrs=None) + assert net.attrs is None # Should preserve None + + +class TestComplexPatternMatching: + """Tests for complex regex pattern matching in node selection.""" + + def test_overlapping_pattern_groups(self): + """Test node selection with overlapping pattern groups.""" + net = Network() + + # Create nodes that could match multiple patterns + nodes = ["group1_item1", "group1_item2", "group2_item1", "shared_item"] + for node in nodes: + net.add_node(Node(node)) + + # Pattern that creates overlapping groups + groups = net.select_node_groups_by_path("(group\\d)_.*") + + # Should handle overlapping scenarios correctly + assert isinstance(groups, dict) + assert len(groups) >= 1 + + def test_complex_regex_capture_groups(self): + """Test complex regex patterns with multiple capture groups.""" + net = Network() + + nodes = [ + "site-NYC-rack-01-server-001", + "site-NYC-rack-02-server-001", + "site-SFO-rack-01-server-001", + "site-SFO-rack-01-server-002", + ] + + for node in nodes: + net.add_node(Node(node)) + + # Complex pattern with multiple captures + pattern = r"site-([A-Z]+)-rack-(\d+)-.*" + groups = net.select_node_groups_by_path(pattern) + + # Should organize by captured groups + assert isinstance(groups, dict) + assert len(groups) >= 1 diff --git a/tests/test_network_enhanced_max_flow.py b/tests/test_network_enhanced_max_flow.py new file mode 100644 index 0000000..d5952bc --- /dev/null +++ b/tests/test_network_enhanced_max_flow.py @@ -0,0 +1,282 @@ +"""Tests for the new enhanced max_flow methods.""" + +import pytest + +from ngraph.lib.algorithms.base import FlowPlacement +from ngraph.lib.algorithms.types import FlowSummary +from ngraph.lib.graph import StrictMultiDiGraph +from ngraph.network import Link, Network, Node + + +class TestEnhancedMaxFlowMethods: + """Test the new max_flow_with_summary, max_flow_with_graph, and max_flow_detailed methods.""" + + def test_max_flow_with_summary_basic(self): + """Test max_flow_with_summary returns correct types and values.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + + result = net.max_flow_with_summary("A", "C") + + # Check return type and structure + assert isinstance(result, dict) + assert len(result) == 1 + + key = ("A", "C") + assert key in result + + flow_val, summary = result[key] + assert isinstance(flow_val, (int, float)) + assert isinstance(summary, FlowSummary) + assert flow_val == 3.0 + assert summary.total_flow == 3.0 + assert len(summary.edge_flow) > 0 + assert len(summary.residual_cap) > 0 + + def test_max_flow_with_graph_basic(self): + """Test max_flow_with_graph returns correct types and values.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=5)) + + result = net.max_flow_with_graph("A", "B") + + # Check return type and structure + assert isinstance(result, dict) + assert len(result) == 1 + + key = ("A", "B") + assert key in result + + flow_val, flow_graph = result[key] + assert isinstance(flow_val, (int, float)) + assert isinstance(flow_graph, StrictMultiDiGraph) + assert flow_val == 5.0 + assert flow_graph.number_of_nodes() >= 2 + + def test_max_flow_detailed_basic(self): + """Test max_flow_detailed returns correct types and values.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + result = net.max_flow_detailed("A", "B") + + # Check return type and structure + assert isinstance(result, dict) + assert len(result) == 1 + + key = ("A", "B") + assert key in result + + flow_val, summary, flow_graph = result[key] + assert isinstance(flow_val, (int, float)) + assert isinstance(summary, FlowSummary) + assert isinstance(flow_graph, StrictMultiDiGraph) + assert flow_val == 10.0 + assert summary.total_flow == 10.0 + + def test_consistency_with_original_max_flow(self): + """Test that new methods return consistent flow values with original method.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=8)) + net.add_link(Link("B", "C", capacity=6)) + + # Get results from all methods + original = net.max_flow("A", "C") + with_summary = net.max_flow_with_summary("A", "C") + with_graph = net.max_flow_with_graph("A", "C") + detailed = net.max_flow_detailed("A", "C") + + key = ("A", "C") + original_flow = original[key] + summary_flow = with_summary[key][0] + graph_flow = with_graph[key][0] + detailed_flow = detailed[key][0] + + # All should return the same flow value + assert original_flow == summary_flow == graph_flow == detailed_flow == 6.0 + + def test_flow_placement_parameter(self): + """Test that flow_placement parameter works with new methods.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + # Create parallel paths + net.add_link(Link("A", "B", capacity=5, cost=1)) + net.add_link(Link("A", "C", capacity=3, cost=1)) + net.add_link(Link("B", "C", capacity=8, cost=1)) + + # Test with different flow placement strategies + for placement in [FlowPlacement.PROPORTIONAL, FlowPlacement.EQUAL_BALANCED]: + result = net.max_flow_with_summary("A", "C", flow_placement=placement) + flow_val, summary = result[("A", "C")] + + assert isinstance(flow_val, (int, float)) + assert flow_val > 0 + assert summary.total_flow == flow_val + + def test_shortest_path_parameter(self): + """Test that shortest_path parameter works with new methods.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_node(Node("D")) + + # Short path A->B->D and longer path A->C->D + net.add_link(Link("A", "B", capacity=5, cost=1)) + net.add_link(Link("B", "D", capacity=3, cost=1)) + net.add_link(Link("A", "C", capacity=4, cost=2)) + net.add_link(Link("C", "D", capacity=6, cost=2)) + + # Test with shortest_path=True + result = net.max_flow_with_summary("A", "D", shortest_path=True) + flow_val, summary = result[("A", "D")] + + assert isinstance(flow_val, (int, float)) + assert flow_val > 0 + assert summary.total_flow == flow_val + + def test_pairwise_mode(self): + """Test pairwise mode with new methods.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_node(Node("D")) + net.add_link(Link("A", "C", capacity=5)) + net.add_link(Link("B", "D", capacity=3)) + + result = net.max_flow_with_summary("^([AB])$", "^([CD])$", mode="pairwise") + + # Should have 4 combinations: A->C, A->D, B->C, B->D + assert len(result) == 4 + + # Check specific pairs + assert ("A", "C") in result + assert ("A", "D") in result + assert ("B", "C") in result + assert ("B", "D") in result + + # A->C should have flow, B->D should have flow, others should be 0 + assert result[("A", "C")][0] == 5.0 + assert result[("B", "D")][0] == 3.0 + assert result[("A", "D")][0] == 0.0 + assert result[("B", "C")][0] == 0.0 + + def test_combine_mode(self): + """Test combine mode with new methods.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "C", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + + result = net.max_flow_with_summary("^([AB])$", "C", mode="combine") + + # Should have 1 combined result + assert len(result) == 1 + + key = ("A|B", "C") + assert key in result + + flow_val, summary = result[key] + assert flow_val == 8.0 # Both A and B can send to C + + def test_empty_results_handling(self): + """Test handling of cases with no flow.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + # No link between A and B + + result = net.max_flow_with_summary("A", "B") + flow_val, summary = result[("A", "B")] + + assert flow_val == 0.0 + assert summary.total_flow == 0.0 + assert len(summary.min_cut) == 0 + + def test_disabled_nodes_handling(self): + """Test handling of disabled nodes.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + + result = net.max_flow_with_summary("A", "C") + flow_val, summary = result[("A", "C")] + + # Should be 0 because B is disabled + assert flow_val == 0.0 + assert summary.total_flow == 0.0 + + def test_error_cases(self): + """Test error handling for invalid inputs.""" + net = Network() + net.add_node(Node("A")) + + # Test invalid mode + with pytest.raises(ValueError, match="Invalid mode"): + net.max_flow_with_summary("A", "A", mode="invalid") + + # Test no matching sources + with pytest.raises(ValueError, match="No source nodes found"): + net.max_flow_with_summary("X", "A") + + # Test no matching sinks + with pytest.raises(ValueError, match="No sink nodes found"): + net.max_flow_with_summary("A", "X") + + def test_min_cut_identification(self): + """Test that min-cut edges are correctly identified.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=10)) + net.add_link(Link("B", "C", capacity=5)) # This should be the bottleneck + + result = net.max_flow_with_summary("A", "C") + flow_val, summary = result[("A", "C")] + + assert flow_val == 5.0 + assert len(summary.min_cut) == 1 + + # The min-cut should include the B->C edge + min_cut_edges = summary.min_cut + assert any(u == "B" and v == "C" for u, v, k in min_cut_edges) + + def test_reachability_analysis(self): + """Test that reachable nodes are correctly identified.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_node(Node("D")) + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + # D is isolated + + result = net.max_flow_with_summary("A", "C") + flow_val, summary = result[("A", "C")] + + # A and B should be reachable from source, C might be reachable depending on flow + assert "A" in summary.reachable + # D should not be reachable since it's isolated + assert "D" not in summary.reachable diff --git a/tests/test_network_flow.py b/tests/test_network_flow.py new file mode 100644 index 0000000..cf118cc --- /dev/null +++ b/tests/test_network_flow.py @@ -0,0 +1,640 @@ +""" +Tests for flow analysis methods in the network module. + +This module contains tests for: +- Maximum flow calculations (max_flow) +- Saturated edges identification (saturated_edges) +- Sensitivity analysis (sensitivity_analysis) +- Flow-related edge cases and overlapping patterns +""" + +import pytest + +from ngraph.network import Link, Network, Node + + +class TestMaxFlow: + """Tests for maximum flow calculations.""" + + def test_max_flow_simple(self): + """Test max flow on a simple bottleneck scenario.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=3)) + + flow_value = net.max_flow("A", "C") + assert flow_value == {("A", "C"): 3.0} + + def test_max_flow_multi_parallel(self): + """Test max flow with parallel paths.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_node(Node("D")) + + net.add_link(Link("A", "B", capacity=5)) + net.add_link(Link("B", "C", capacity=5)) + net.add_link(Link("A", "D", capacity=5)) + net.add_link(Link("D", "C", capacity=5)) + + flow_value = net.max_flow("A", "C") + assert flow_value == {("A", "C"): 10.0} + + def test_max_flow_no_source(self): + """Test max flow when no source nodes match the pattern.""" + net = Network() + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("B", "C", capacity=10)) + + with pytest.raises(ValueError, match="No source nodes found matching 'A'"): + net.max_flow("A", "C") + + def test_max_flow_no_sink(self): + """Test max flow when no sink nodes match the pattern.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + with pytest.raises(ValueError, match="No sink nodes found matching 'C'"): + net.max_flow("A", "C") + + def test_max_flow_invalid_mode(self): + """Test max flow with invalid mode parameter.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + with pytest.raises(ValueError, match="Invalid mode 'foobar'"): + net.max_flow("A", "B", mode="foobar") + + def test_max_flow_overlap_detection_coverage(self): + """Test specific overlap detection logic in max_flow combine mode for coverage.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("B", "C", capacity=3.0)) + + # Create a scenario where there are valid groups but they overlap + flow_result = net.max_flow( + source_path=r"^(A|B)$", # Matches A and B + sink_path=r"^(B|C)$", # Matches B and C (B overlaps!) + mode="combine", + ) + + # Should return 0 flow due to B being in both source and sink groups + assert len(flow_result) == 1 + assert list(flow_result.values())[0] == 0.0 + + def test_max_flow_invalid_mode_error(self): + """Test that invalid mode properly raises ValueError.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + with pytest.raises(ValueError, match="Invalid mode 'totally_invalid'"): + net.max_flow("A", "B", mode="totally_invalid") + + def test_max_flow_disabled_nodes_coverage(self): + """Test max_flow with disabled source nodes for coverage.""" + net = Network() + net.add_node(Node("A", disabled=True)) # Disabled source + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=5.0)) + + # This should trigger the empty sources condition + flow_result = net.max_flow("A", "B") + assert flow_result[("A", "B")] == 0.0 + + def test_saturated_edges_empty_combine_coverage(self): + """Test saturated_edges with empty nodes in combine mode for coverage.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C", disabled=True)) + net.add_link(Link("A", "B", capacity=5.0)) + + # This should create empty combined sink nodes + saturated = net.saturated_edges("A", "B|C", mode="combine") + key = ("A", "B|C") + assert key in saturated + assert saturated[key] == [] + + def test_saturated_edges_invalid_mode_error(self): + """Test that saturated_edges with invalid mode properly raises ValueError.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + with pytest.raises(ValueError, match="Invalid mode 'bad_mode'"): + net.saturated_edges("A", "B", mode="bad_mode") + + def test_sensitivity_analysis_empty_combine_coverage(self): + """Test sensitivity_analysis with empty nodes in combine mode for coverage.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C", disabled=True)) + net.add_link(Link("A", "B", capacity=5.0)) + + # This should create empty combined sink nodes + sensitivity = net.sensitivity_analysis("A", "B|C", mode="combine") + key = ("A", "B|C") + assert key in sensitivity + assert sensitivity[key] == {} + + def test_sensitivity_analysis_invalid_mode_error(self): + """Test that sensitivity_analysis with invalid mode properly raises ValueError.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=10)) + + with pytest.raises(ValueError, match="Invalid mode 'wrong_mode'"): + net.sensitivity_analysis("A", "B", mode="wrong_mode") + + def test_flow_methods_overlap_conditions_coverage(self): + """Test overlap conditions in flow methods for coverage.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("B", "C", capacity=3.0)) + + # Test overlap condition in saturated_edges pairwise mode + saturated = net.saturated_edges("A", "A", mode="pairwise") + assert saturated[("A", "A")] == [] + + # Test overlap condition in sensitivity_analysis pairwise mode + sensitivity = net.sensitivity_analysis("A", "A", mode="pairwise") + assert sensitivity[("A", "A")] == {} + + def test_private_method_coverage_flow_placement(self): + """Test private method calls with None flow_placement for coverage.""" + network = Network() + network.add_node(Node("S")) + network.add_node(Node("T")) + network.add_link(Link("S", "T", capacity=10)) + + # Test _compute_flow_single_group with None flow_placement explicitly + sources = [network.nodes["S"]] + sinks = [network.nodes["T"]] + result = network._compute_flow_single_group(sources, sinks, False, None) + assert isinstance(result, float) + + # Test with disabled nodes to trigger private method conditions + network.add_node(Node("X")) + network.add_node(Node("Y", disabled=True)) + network.add_link(Link("X", "Y", capacity=5)) + + # This should call private methods with None flow_placement + sat_result = network.saturated_edges("X", "Y") + assert sat_result[("X", "Y")] == [] + + sens_result = network.sensitivity_analysis("X", "Y", change_amount=0.1) + assert sens_result[("X", "Y")] == {} + + +class TestMaxFlowOverlapping: + """Tests for maximum flow with overlapping source/sink patterns.""" + + def test_max_flow_overlapping_patterns_combine_mode(self): + """Test overlapping source/sink patterns in combine mode return 0 flow.""" + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2")) + net.add_link(Link("N1", "N2", capacity=5.0)) + + flow_result = net.max_flow( + source_path=r"^N(\d+)$", + sink_path=r"^N(\d+)$", + mode="combine", + ) + + assert len(flow_result) == 1 + flow_val = list(flow_result.values())[0] + assert flow_val == 0.0 + + expected_label = ("1|2", "1|2") + assert expected_label in flow_result + + def test_max_flow_overlapping_patterns_pairwise_mode(self): + """Test overlapping source/sink patterns in pairwise mode.""" + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2")) + net.add_link(Link("N1", "N2", capacity=3.0)) + + flow_result = net.max_flow( + source_path=r"^N(\d+)$", + sink_path=r"^N(\d+)$", + mode="pairwise", + ) + + assert len(flow_result) == 4 + + expected_keys = {("1", "1"), ("1", "2"), ("2", "1"), ("2", "2")} + assert set(flow_result.keys()) == expected_keys + + # Self-loops should have 0 flow + assert flow_result[("1", "1")] == 0.0 + assert flow_result[("2", "2")] == 0.0 + + # Valid paths should have flow > 0 + assert flow_result[("1", "2")] == 3.0 + assert flow_result[("2", "1")] == 3.0 + + def test_max_flow_partial_overlap_pairwise(self): + """Test pairwise mode where source and sink patterns have partial overlap.""" + net = Network() + net.add_node(Node("SRC1")) + net.add_node(Node("SINK1")) + net.add_node(Node("BOTH1")) # Node that matches both patterns + net.add_node(Node("BOTH2")) # Node that matches both patterns + + # Create some connections + net.add_link(Link("SRC1", "SINK1", capacity=2.0)) + net.add_link(Link("SRC1", "BOTH1", capacity=1.0)) + net.add_link(Link("BOTH1", "SINK1", capacity=1.5)) + net.add_link(Link("BOTH2", "BOTH1", capacity=1.0)) + + flow_result = net.max_flow( + source_path=r"^(SRC\d+|BOTH\d+)$", # Matches SRC1, BOTH1, BOTH2 + sink_path=r"^(SINK\d+|BOTH\d+)$", # Matches SINK1, BOTH1, BOTH2 (partial overlap!) + mode="pairwise", + ) + + # Should return results for all combinations + assert len(flow_result) == 9 # 3 sources × 3 sinks + + # Self-loops for overlapping nodes should be 0 + assert flow_result[("BOTH1", "BOTH1")] == 0.0 + assert flow_result[("BOTH2", "BOTH2")] == 0.0 + + # Non-overlapping combinations should have meaningful flows + assert flow_result[("SRC1", "SINK1")] > 0.0 + + def test_max_flow_overlapping_with_disabled_nodes(self): + """Test overlapping patterns with some nodes disabled.""" + net = Network() + net.add_node(Node("N1")) + net.add_node(Node("N2", disabled=True)) # disabled node + net.add_node(Node("N3")) + + # Add some links (N2 won't participate due to being disabled) + net.add_link(Link("N1", "N3", capacity=2.0)) + net.add_link(Link("N2", "N3", capacity=1.0)) # This link won't be used + + flow_result = net.max_flow( + source_path=r"^N(\d+)$", # Matches N1, N2, N3 + sink_path=r"^N(\d+)$", # Matches N1, N2, N3 (OVERLAPPING!) + mode="pairwise", + ) + + # N1, N2, N3 create groups "1", "2", "3", so we get 3x3 = 9 combinations + assert len(flow_result) == 9 + + # Self-loops return 0 (including disabled node) + assert flow_result[("1", "1")] == 0.0 # N1->N1 self-loop + assert flow_result[("2", "2")] == 0.0 # N2->N2 self-loop (disabled) + assert flow_result[("3", "3")] == 0.0 # N3->N3 self-loop + + # Flows involving disabled node N2 should be 0 + assert flow_result[("1", "2")] == 0.0 # N1->N2 (N2 disabled) + assert flow_result[("2", "1")] == 0.0 # N2->N1 (N2 disabled) + assert flow_result[("2", "3")] == 0.0 # N2->N3 (N2 disabled) + assert flow_result[("3", "2")] == 0.0 # N3->N2 (N2 disabled) + + # Valid flows (N1->N3, N3->N1) should work + assert flow_result[("1", "3")] == 2.0 # N1->N3 + assert flow_result[("3", "1")] == 2.0 # N3->N1 (due to reverse edges) + + +class TestSaturatedEdges: + """Tests for saturated edges identification.""" + + @pytest.fixture + def bottleneck_network(self): + """Fixture providing a network with a clear bottleneck.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=10.0)) + net.add_link(Link("B", "C", capacity=5.0)) # bottleneck + return net + + def test_saturated_edges_simple(self, bottleneck_network): + """Test saturated_edges method with a simple bottleneck scenario.""" + saturated = bottleneck_network.saturated_edges("A", "C") + + assert len(saturated) == 1 + key = ("A", "C") + assert key in saturated + + edge_list = saturated[key] + assert len(edge_list) == 1 + + edge = edge_list[0] + assert edge[0] == "B" # source + assert edge[1] == "C" # target + + def test_saturated_edges_no_bottleneck(self): + """Test saturated_edges when there's no clear bottleneck.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=100.0)) + + saturated = net.saturated_edges("A", "B") + + assert len(saturated) == 1 + key = ("A", "B") + assert key in saturated + + def test_saturated_edges_pairwise_mode(self): + """Test saturated_edges with pairwise mode using regex patterns.""" + net = Network() + for node in ["A1", "A2", "B", "C1", "C2"]: + net.add_node(Node(node)) + + net.add_link(Link("A1", "B", capacity=3.0)) + net.add_link(Link("A2", "B", capacity=4.0)) + net.add_link(Link("B", "C1", capacity=2.0)) + net.add_link(Link("B", "C2", capacity=3.0)) + + saturated = net.saturated_edges("A(.*)", "C(.*)", mode="pairwise") + + assert len(saturated) >= 1 + + for (_src_label, _sink_label), edge_list in saturated.items(): + assert isinstance(edge_list, list) + + def test_saturated_edges_error_cases(self, bottleneck_network): + """Test error cases for saturated_edges.""" + with pytest.raises(ValueError, match="No source nodes found matching"): + bottleneck_network.saturated_edges("NONEXISTENT", "C") + + with pytest.raises(ValueError, match="No sink nodes found matching"): + bottleneck_network.saturated_edges("A", "NONEXISTENT") + + with pytest.raises(ValueError, match="Invalid mode 'invalid'"): + bottleneck_network.saturated_edges("A", "C", mode="invalid") + + def test_saturated_edges_disabled_nodes(self): + """Test saturated_edges with disabled nodes.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("B", "C", capacity=3.0)) + + saturated = net.saturated_edges("A", "C") + + key = ("A", "C") + assert key in saturated + assert saturated[key] == [] + + def test_saturated_edges_overlapping_groups(self): + """Test saturated_edges when source and sink groups overlap.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=5.0)) + + saturated = net.saturated_edges("A|B", "A|B") + + key = ("A|B", "A|B") + assert key in saturated + assert saturated[key] == [] + + def test_saturated_edges_tolerance_parameter(self, bottleneck_network): + """Test saturated_edges with different tolerance values.""" + saturated_strict = bottleneck_network.saturated_edges("A", "C", tolerance=1e-15) + saturated_loose = bottleneck_network.saturated_edges("A", "C", tolerance=1.0) + + assert ("A", "C") in saturated_strict + assert ("A", "C") in saturated_loose + + +class TestSensitivityAnalysis: + """Tests for sensitivity analysis.""" + + @pytest.fixture + def bottleneck_network(self): + """Fixture providing a network with a clear bottleneck.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=10.0)) + net.add_link(Link("B", "C", capacity=5.0)) # bottleneck + return net + + def test_sensitivity_analysis_simple(self, bottleneck_network): + """Test sensitivity_analysis method with a simple bottleneck scenario.""" + sensitivity = bottleneck_network.sensitivity_analysis( + "A", "C", change_amount=1.0 + ) + + assert len(sensitivity) == 1 + key = ("A", "C") + assert key in sensitivity + + sens_dict = sensitivity[key] + assert isinstance(sens_dict, dict) + + if sens_dict: + for edge, flow_change in sens_dict.items(): + assert isinstance(edge, tuple) + assert len(edge) == 3 + assert isinstance(flow_change, (int, float)) + + def test_sensitivity_analysis_negative_change(self, bottleneck_network): + """Test sensitivity_analysis with negative capacity change.""" + sensitivity = bottleneck_network.sensitivity_analysis( + "A", "C", change_amount=-1.0 + ) + + assert ("A", "C") in sensitivity + sens_dict = sensitivity[("A", "C")] + assert isinstance(sens_dict, dict) + + def test_sensitivity_analysis_pairwise_mode(self): + """Test sensitivity_analysis with pairwise mode.""" + net = Network() + for node in ["A1", "A2", "B", "C1", "C2"]: + net.add_node(Node(node)) + + net.add_link(Link("A1", "B", capacity=3.0)) + net.add_link(Link("A2", "B", capacity=4.0)) + net.add_link(Link("B", "C1", capacity=2.0)) + net.add_link(Link("B", "C2", capacity=3.0)) + + sensitivity = net.sensitivity_analysis("A(.*)", "C(.*)", mode="pairwise") + + assert len(sensitivity) >= 1 + + for (_src_label, _sink_label), sens_dict in sensitivity.items(): + assert isinstance(sens_dict, dict) + + def test_sensitivity_analysis_error_cases(self, bottleneck_network): + """Test error cases for sensitivity_analysis.""" + with pytest.raises(ValueError, match="No source nodes found matching"): + bottleneck_network.sensitivity_analysis("NONEXISTENT", "C") + + with pytest.raises(ValueError, match="No sink nodes found matching"): + bottleneck_network.sensitivity_analysis("A", "NONEXISTENT") + + with pytest.raises(ValueError, match="Invalid mode 'invalid'"): + bottleneck_network.sensitivity_analysis("A", "C", mode="invalid") + + def test_sensitivity_analysis_disabled_nodes(self): + """Test sensitivity_analysis with disabled nodes.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B", disabled=True)) + net.add_node(Node("C")) + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("B", "C", capacity=3.0)) + + sensitivity = net.sensitivity_analysis("A", "C") + + key = ("A", "C") + assert key in sensitivity + assert sensitivity[key] == {} + + def test_sensitivity_analysis_overlapping_groups(self): + """Test sensitivity_analysis when source and sink groups overlap.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_link(Link("A", "B", capacity=5.0)) + + sensitivity = net.sensitivity_analysis("A|B", "A|B") + + key = ("A|B", "A|B") + assert key in sensitivity + assert sensitivity[key] == {} + + def test_sensitivity_analysis_zero_change(self, bottleneck_network): + """Test sensitivity_analysis with zero capacity change.""" + sensitivity = bottleneck_network.sensitivity_analysis( + "A", "C", change_amount=0.0 + ) + + assert ("A", "C") in sensitivity + sens_dict = sensitivity[("A", "C")] + assert isinstance(sens_dict, dict) + + +class TestFlowIntegration: + """Integration tests for flow analysis methods.""" + + def test_saturated_edges_and_sensitivity_consistency(self): + """Test that saturated_edges and sensitivity_analysis are consistent.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + net.add_link(Link("A", "B", capacity=10.0)) + net.add_link(Link("B", "C", capacity=5.0)) + + saturated = net.saturated_edges("A", "C") + sensitivity = net.sensitivity_analysis("A", "C") + + key = ("A", "C") + saturated_edges_list = saturated[key] + sensitivity_dict = sensitivity[key] + + for _edge in saturated_edges_list: + assert isinstance(sensitivity_dict, dict) + + def test_complex_network_analysis(self): + """Test both methods on a more complex network topology.""" + net = Network() + + for node in ["A", "B", "C", "D"]: + net.add_node(Node(node)) + + net.add_link(Link("A", "B", capacity=5.0)) + net.add_link(Link("A", "C", capacity=3.0)) + net.add_link(Link("B", "D", capacity=4.0)) + net.add_link(Link("C", "D", capacity=6.0)) + + saturated = net.saturated_edges("A", "D") + sensitivity = net.sensitivity_analysis("A", "D", change_amount=1.0) + + key = ("A", "D") + assert key in saturated + assert key in sensitivity + + assert isinstance(saturated[key], list) + assert isinstance(sensitivity[key], dict) + + def test_flow_placement_parameter(self): + """Test that different flow_placement parameters work with both methods.""" + from ngraph.lib.algorithms.base import FlowPlacement + + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + net.add_link(Link("A", "B", capacity=10.0)) + net.add_link(Link("B", "C", capacity=5.0)) + + for flow_placement in [ + FlowPlacement.PROPORTIONAL, + FlowPlacement.EQUAL_BALANCED, + ]: + saturated = net.saturated_edges("A", "C", flow_placement=flow_placement) + sensitivity = net.sensitivity_analysis( + "A", "C", flow_placement=flow_placement + ) + + key = ("A", "C") + assert key in saturated + assert key in sensitivity + + def test_shortest_path_parameter(self): + """Test that shortest_path parameter works with both methods.""" + net = Network() + + for node in ["A", "B", "C", "D"]: + net.add_node(Node(node)) + + # Short path: A -> B -> D (cost 2) + net.add_link(Link("A", "B", capacity=5.0, cost=1)) + net.add_link(Link("B", "D", capacity=3.0, cost=1)) + + # Long path: A -> C -> D (cost 4) + net.add_link(Link("A", "C", capacity=4.0, cost=2)) + net.add_link(Link("C", "D", capacity=6.0, cost=2)) + + # Test with shortest_path=True + saturated_sp = net.saturated_edges("A", "D", shortest_path=True) + sensitivity_sp = net.sensitivity_analysis("A", "D", shortest_path=True) + + key = ("A", "D") + assert key in saturated_sp + assert key in sensitivity_sp + + # Test with shortest_path=False + saturated_all = net.saturated_edges("A", "D", shortest_path=False) + sensitivity_all = net.sensitivity_analysis("A", "D", shortest_path=False) + + assert key in saturated_all + assert key in sensitivity_all diff --git a/tests/test_network_graph.py b/tests/test_network_graph.py new file mode 100644 index 0000000..b8e4b0e --- /dev/null +++ b/tests/test_network_graph.py @@ -0,0 +1,127 @@ +""" +Tests for graph conversion and operations. + +This module contains tests for: +- Converting Network to StrictMultiDiGraph +- Graph operations with enabled/disabled nodes and links +- Reverse edge handling in graph conversion +""" + +import pytest + +from ngraph.network import Link, Network, Node + + +class TestGraphConversion: + """Tests for converting Network to StrictMultiDiGraph.""" + + @pytest.fixture + def linear_network(self): + """Fixture providing a linear A->B->C network.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab) + net.add_link(link_bc) + return net, link_ab, link_bc + + def test_to_strict_multidigraph_add_reverse_true(self, linear_network): + """Test graph conversion with reverse edges enabled.""" + net, link_ab, link_bc = linear_network + graph = net.to_strict_multidigraph(add_reverse=True) + + assert set(graph.nodes()) == {"A", "B", "C"} + + edges = list(graph.edges(keys=True)) + forward_keys = {link_ab.id, link_bc.id} + reverse_keys = {f"{link_ab.id}_rev", f"{link_bc.id}_rev"} + all_keys = forward_keys.union(reverse_keys) + found_keys = {e[2] for e in edges} + + assert found_keys == all_keys + + def test_to_strict_multidigraph_add_reverse_false(self): + """Test graph conversion with reverse edges disabled.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + link_ab = Link("A", "B") + net.add_link(link_ab) + + graph = net.to_strict_multidigraph(add_reverse=False) + + assert set(graph.nodes()) == {"A", "B"} + + edges = list(graph.edges(keys=True)) + assert len(edges) == 1 + assert edges[0][0] == "A" + assert edges[0][1] == "B" + assert edges[0][2] == link_ab.id + + def test_to_strict_multidigraph_excludes_disabled(self): + """Test that disabled nodes or links are excluded from graph conversion.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + link_ab = Link("A", "B") + net.add_link(link_ab) + + # Disable node A + net.disable_node("A") + graph = net.to_strict_multidigraph() + assert "A" not in graph.nodes + assert "B" in graph.nodes + assert len(graph.edges()) == 0 + + # Enable node A, disable link + net.enable_all() + net.disable_link(link_ab.id) + graph = net.to_strict_multidigraph() + assert "A" in graph.nodes + assert "B" in graph.nodes + assert len(graph.edges()) == 0 + + def test_to_strict_multidigraph_with_disabled_target_node(self): + """Test graph conversion when target node is disabled.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab) + net.add_link(link_bc) + + # Disable target node B + net.disable_node("B") + graph = net.to_strict_multidigraph() + + # Only nodes A and C should be in graph, no edges + assert set(graph.nodes()) == {"A", "C"} + assert len(graph.edges()) == 0 + + def test_to_strict_multidigraph_empty_network(self): + """Test graph conversion with empty network.""" + net = Network() + graph = net.to_strict_multidigraph() + + assert len(graph.nodes()) == 0 + assert len(graph.edges()) == 0 + + def test_to_strict_multidigraph_isolated_nodes(self): + """Test graph conversion with isolated nodes (no links).""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + graph = net.to_strict_multidigraph() + + assert set(graph.nodes()) == {"A", "B", "C"} + assert len(graph.edges()) == 0 diff --git a/tests/test_network_integration.py b/tests/test_network_integration.py new file mode 100644 index 0000000..081c59f --- /dev/null +++ b/tests/test_network_integration.py @@ -0,0 +1,257 @@ +""" +Tests for integration scenarios and complex network operations. + +This module contains tests for: +- Complex multi-component network scenarios +- Integration between different network features +- End-to-end workflow testing +- Performance and scalability edge cases +""" + +import pytest + +from ngraph.network import Link, Network, Node, RiskGroup + + +class TestNetworkIntegration: + """Tests for complex integration scenarios.""" + + @pytest.fixture + def diamond_network(self): + """Fixture providing a diamond-shaped network A->B,C->D.""" + net = Network() + for node_name in ["A", "B", "C", "D"]: + net.add_node(Node(node_name)) + + net.add_link(Link("A", "B")) + net.add_link(Link("A", "C")) + net.add_link(Link("B", "D")) + net.add_link(Link("C", "D")) + return net + + def test_end_to_end_flow_analysis(self, diamond_network): + """Test complete flow analysis workflow on diamond network.""" + # Basic max flow + flow = diamond_network.max_flow("A", "D") + assert flow[("A", "D")] == 2.0 + + # Saturated edges analysis returns dict format + saturated = diamond_network.saturated_edges("A", "D") + assert isinstance(saturated, dict) + assert len(saturated) > 0 + + # Sensitivity analysis + sensitivity = diamond_network.sensitivity_analysis("A", "D") + assert len(sensitivity) > 0 + + # All methods should work together consistently + assert isinstance(flow, dict) + assert isinstance(saturated, dict) # dict format, not list + assert isinstance(sensitivity, dict) + + def test_risk_group_with_flow_analysis(self): + """Test integration of risk groups with flow analysis.""" + net = Network() + nodes = ["A", "B", "C", "D"] + for node in nodes: + net.add_node(Node(node, risk_groups={"critical"})) + + net.add_link(Link("A", "B")) + net.add_link(Link("B", "C")) + net.add_link(Link("C", "D")) + + net.risk_groups["critical"] = RiskGroup("critical") + + # Flow should work normally when risk group is enabled + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 1.0 + + # Flow should be 0 when critical nodes are disabled + net.disable_risk_group("critical") + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 0.0 + + # Flow should resume when risk group is re-enabled + net.enable_risk_group("critical") + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 1.0 + + def test_complex_network_construction(self): + """Test building and analyzing a complex multi-tier network.""" + net = Network() + + # Create a 3-tier network: sources -> transit -> sinks + sources = ["src-1", "src-2", "src-3"] + transit = ["transit-1", "transit-2"] + sinks = ["sink-1", "sink-2"] + + # Add all nodes + for node in sources + transit + sinks: + net.add_node(Node(node)) + + # Connect sources to transit (full mesh) + for src in sources: + for t in transit: + net.add_link(Link(src, t, capacity=2.0)) + + # Connect transit to sinks (full mesh) + for t in transit: + for sink in sinks: + net.add_link(Link(t, sink, capacity=3.0)) + + # Analyze flow characteristics + total_flow = 0 + for src in sources: + for sink in sinks: + flow = net.max_flow(src, sink) + total_flow += flow[(src, sink)] + + # Should have meaningful flow through the network + assert total_flow > 0 + + # Network should have correct structure + assert len(net.nodes) == 7 + assert len(net.links) == 10 # 3*2 + 2*2 + + def test_disabled_node_propagation(self): + """Test how disabled nodes affect complex network operations.""" + net = Network() + + # Create linear chain: A->B->C->D + nodes = ["A", "B", "C", "D"] + for node in nodes: + net.add_node(Node(node)) + + for i in range(len(nodes) - 1): + net.add_link(Link(nodes[i], nodes[i + 1])) + + # Initially flow should exist + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 1.0 + + # Disable middle node - should break flow + net.disable_node("B") + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 0.0 + + # Re-enable, flow should resume + net.enable_node("B") + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 1.0 + + # Disable different middle node + net.disable_node("C") + flow = net.max_flow("A", "D") + assert flow[("A", "D")] == 0.0 + + def test_network_with_mixed_capacities(self): + """Test analysis of networks with varying link capacities.""" + net = Network() + + nodes = ["A", "B", "C", "D", "E"] + for node in nodes: + net.add_node(Node(node)) + + # Create network with bottlenecks + capacities = [ + ("A", "B", 10.0), + ("A", "C", 5.0), + ("B", "D", 2.0), # bottleneck + ("C", "D", 8.0), + ("C", "E", 3.0), + ("D", "E", 15.0), + ] + + for src, tgt, cap in capacities: + net.add_link(Link(src, tgt, capacity=cap)) + + # Max flow should be limited by bottlenecks + flow_ad = net.max_flow("A", "D") + net.max_flow("A", "E") + + # A->D gets flow through multiple paths: A->B->D (2.0) + A->C->D (5.0) = 7.0 + # But limited by B->D capacity and C->D capacity + assert flow_ad[("A", "D")] == 7.0 # A->B->D (2.0) + A->C->D (5.0) = 7.0 + + # Test that saturated edges identify bottlenecks + saturated = net.saturated_edges("A", "E") + assert len(saturated) > 0 + + def test_large_network_performance(self): + """Test performance characteristics with larger networks.""" + net = Network() + + # Create a larger network (grid-like) + size = 10 + for i in range(size): + for j in range(size): + net.add_node(Node(f"node-{i}-{j}")) + + # Add horizontal and vertical connections + for i in range(size): + for j in range(size - 1): + # Horizontal links + net.add_link(Link(f"node-{i}-{j}", f"node-{i}-{j + 1}")) + # Vertical links + net.add_link(Link(f"node-{j}-{i}", f"node-{j + 1}-{i}")) + + # Should be able to handle this size efficiently + assert len(net.nodes) == size * size + assert len(net.links) == 2 * size * (size - 1) + + # Basic operations should still work + flow = net.max_flow("node-0-0", "node-9-9") + assert len(flow) == 1 + assert flow[("node-0-0", "node-9-9")] > 0 + + def test_network_modification_during_analysis(self): + """Test network state consistency during complex operations.""" + net = Network() + + # Build initial network + for node in ["A", "B", "C"]: + net.add_node(Node(node)) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab) + net.add_link(link_bc) + + # Get initial flow + initial_flow = net.max_flow("A", "C") + assert initial_flow[("A", "C")] == 1.0 + + # Modify network and verify consistency + net.add_node(Node("D")) + net.add_link(Link("A", "D")) + net.add_link(Link("D", "C")) + + # Flow should increase with additional path + new_flow = net.max_flow("A", "C") + assert new_flow[("A", "C")] >= initial_flow[("A", "C")] + + # Network should maintain internal consistency + assert len(net.nodes) == 4 + assert len(net.links) == 4 + + def test_comprehensive_error_handling(self): + """Test error handling in complex scenarios.""" + net = Network() + + # Empty network operations should raise errors for non-matching patterns + with pytest.raises(ValueError, match="No source nodes found matching"): + net.max_flow("nonexistent", "also_nonexistent") + + with pytest.raises(ValueError, match="No source nodes found matching"): + net.saturated_edges("none", "zero") + + with pytest.raises(ValueError, match="No source nodes found matching"): + net.sensitivity_analysis("void", "null") + + # Single node operations + net.add_node(Node("lonely")) + assert net.max_flow("lonely", "lonely") == {("lonely", "lonely"): 0} + + # Disconnected network + net.add_node(Node("isolated")) + assert net.max_flow("lonely", "isolated") == {("lonely", "isolated"): 0} diff --git a/tests/test_network_risk_groups.py b/tests/test_network_risk_groups.py new file mode 100644 index 0000000..46f736a --- /dev/null +++ b/tests/test_network_risk_groups.py @@ -0,0 +1,185 @@ +""" +Tests for risk group management in the network module. + +This module contains tests for: +- Risk group creation and hierarchy +- Enabling/disabling risk groups (recursive and non-recursive) +- Multi-membership risk group scenarios +- Risk group effects on nodes and links +""" + +from ngraph.network import Link, Network, Node, RiskGroup + + +class TestRiskGroups: + """Tests for risk group management.""" + + def test_disable_risk_group_nonexistent(self): + """Test disabling nonexistent risk group does nothing.""" + net = Network() + net.disable_risk_group("nonexistent_group") # Should not raise + + def test_enable_risk_group_nonexistent(self): + """Test enabling nonexistent risk group does nothing.""" + net = Network() + net.enable_risk_group("nonexistent_group") # Should not raise + + def test_disable_risk_group_recursive(self): + """Test disabling a top-level group with recursive=True.""" + net = Network() + + net.add_node(Node("A", risk_groups={"top"})) + net.add_node(Node("B", risk_groups={"child1"})) + net.add_node(Node("C", risk_groups={"child2"})) + link = Link("A", "C", risk_groups={"child2"}) + net.add_link(link) + + net.risk_groups["top"] = RiskGroup( + "top", children=[RiskGroup("child1"), RiskGroup("child2")] + ) + + # Disable top group recursively + net.disable_risk_group("top", recursive=True) + + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is True + assert net.nodes["C"].disabled is True + assert net.links[link.id].disabled is True + + def test_disable_risk_group_non_recursive(self): + """Test disabling a top-level group with recursive=False.""" + net = Network() + net.add_node(Node("A", risk_groups={"top"})) + net.add_node(Node("B", risk_groups={"child1"})) + net.add_node(Node("C", risk_groups={"child2"})) + + net.risk_groups["top"] = RiskGroup( + "top", children=[RiskGroup("child1"), RiskGroup("child2")] + ) + + net.disable_risk_group("top", recursive=False) + + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is False + assert net.nodes["C"].disabled is False + + def test_enable_risk_group_multi_membership(self): + """Test enabling a risk group when node belongs to multiple groups.""" + net = Network() + + net.add_node(Node("X", risk_groups={"group1", "group2"})) + net.risk_groups["group1"] = RiskGroup("group1") + net.risk_groups["group2"] = RiskGroup("group2") + + assert net.nodes["X"].disabled is False + + net.disable_risk_group("group1") + assert net.nodes["X"].disabled is True + + net.enable_risk_group("group2") + assert net.nodes["X"].disabled is False + + def test_disable_risk_group_affects_links(self): + """Test that disabling risk group affects links with that risk group.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + link = Link("A", "B", risk_groups={"critical"}) + net.add_link(link) + net.risk_groups["critical"] = RiskGroup("critical") + + assert net.links[link.id].disabled is False + + net.disable_risk_group("critical") + assert net.links[link.id].disabled is True + + net.enable_risk_group("critical") + assert net.links[link.id].disabled is False + + def test_risk_group_hierarchy_deep_nesting(self): + """Test deeply nested risk group hierarchies.""" + net = Network() + net.add_node(Node("A", risk_groups={"level1"})) + net.add_node(Node("B", risk_groups={"level2"})) + net.add_node(Node("C", risk_groups={"level3"})) + + # Create 3-level hierarchy + net.risk_groups["level1"] = RiskGroup( + "level1", children=[RiskGroup("level2", children=[RiskGroup("level3")])] + ) + + # Disable top level recursively + net.disable_risk_group("level1", recursive=True) + + assert net.nodes["A"].disabled is True + assert net.nodes["B"].disabled is True + assert net.nodes["C"].disabled is True + + def test_risk_group_partial_enablement(self): + """Test partially enabling nested risk groups.""" + net = Network() + net.add_node(Node("A", risk_groups={"parent"})) + net.add_node(Node("B", risk_groups={"child1"})) + net.add_node(Node("C", risk_groups={"child2"})) + + # Register all risk groups in the network's risk_groups dictionary + net.risk_groups["parent"] = RiskGroup( + "parent", children=[RiskGroup("child1"), RiskGroup("child2")] + ) + net.risk_groups["child1"] = RiskGroup("child1") + net.risk_groups["child2"] = RiskGroup("child2") + + # Disable all + net.disable_risk_group("parent", recursive=True) + assert all(node.disabled for node in net.nodes.values()) + + # Enable only child1 + net.enable_risk_group("child1") + assert ( + net.nodes["A"].disabled is True + ) # parent still disabled (only has "parent" risk group) + assert ( + net.nodes["B"].disabled is False + ) # child1 enabled (has "child1" risk group) + assert net.nodes["C"].disabled is True # child2 still disabled + + def test_risk_group_mixed_membership(self): + """Test nodes and links with overlapping risk group memberships.""" + net = Network() + net.add_node(Node("A", risk_groups={"group1", "group2"})) + net.add_node(Node("B", risk_groups={"group2", "group3"})) + + link = Link("A", "B", risk_groups={"group1", "group3"}) + net.add_link(link) + + net.risk_groups["group1"] = RiskGroup("group1") + net.risk_groups["group2"] = RiskGroup("group2") + net.risk_groups["group3"] = RiskGroup("group3") + + # Initially all enabled + assert not net.nodes["A"].disabled + assert not net.nodes["B"].disabled + assert not net.links[link.id].disabled + + # Disable group1 - affects A and link + net.disable_risk_group("group1") + assert net.nodes["A"].disabled is True # A has group1 + assert net.nodes["B"].disabled is False # B doesn't have group1 + assert net.links[link.id].disabled is True # link has group1 + + # Disable group2 - affects A and B + net.disable_risk_group("group2") + assert net.nodes["A"].disabled is True # A still disabled (group1) + assert net.nodes["B"].disabled is True # B now disabled (group2) + assert net.links[link.id].disabled is True # link still disabled (group1) + + # Enable group1 - A should be enabled because it has group1, link should be enabled + net.enable_risk_group("group1") + assert ( + net.nodes["A"].disabled is False + ) # A enabled (has group1 which is now enabled) + assert net.nodes["B"].disabled is True # B still disabled (group2) + assert ( + net.links[link.id].disabled is False + ) # link enabled (has group1 which is enabled) diff --git a/tests/test_network_selection.py b/tests/test_network_selection.py new file mode 100644 index 0000000..57bbf18 --- /dev/null +++ b/tests/test_network_selection.py @@ -0,0 +1,242 @@ +""" +Tests for node selection and pattern matching in the network module. + +This module contains tests for: +- Node selection by path patterns (exact, prefix, wildcard, regex) +- Link finding by source/target patterns +- Network traversal and search operations +""" + +import pytest + +from ngraph.network import Link, Network, Node + + +class TestNodeSelection: + """Tests for node selection by path patterns.""" + + @pytest.fixture + def complex_network(self): + """Fixture providing a network with hierarchical node names.""" + net = Network() + net.add_node(Node("SEA/spine/myspine-1")) + net.add_node(Node("SEA/spine/myspine-2")) + net.add_node(Node("SEA/leaf1/leaf-1")) + net.add_node(Node("SEA/leaf1/leaf-2")) + net.add_node(Node("SEA/leaf2/leaf-1")) + net.add_node(Node("SEA/leaf2/leaf-2")) + net.add_node(Node("SEA-other")) + net.add_node(Node("SFO")) + return net + + def test_select_node_groups_exact_match(self, complex_network): + """Test exact match node selection.""" + node_groups = complex_network.select_node_groups_by_path("SFO") + assert len(node_groups) == 1 + nodes = node_groups["SFO"] + assert len(nodes) == 1 + assert nodes[0].name == "SFO" + + def test_select_node_groups_prefix_match(self, complex_network): + """Test prefix match node selection.""" + node_groups = complex_network.select_node_groups_by_path("SEA/spine") + assert len(node_groups) == 1 + nodes = node_groups["SEA/spine"] + assert len(nodes) == 2 + found = {n.name for n in nodes} + assert found == {"SEA/spine/myspine-1", "SEA/spine/myspine-2"} + + def test_select_node_groups_wildcard_match(self, complex_network): + """Test wildcard match node selection.""" + node_groups = complex_network.select_node_groups_by_path("SEA/leaf*") + assert len(node_groups) == 1 + nodes = node_groups["SEA/leaf*"] + assert len(nodes) == 4 + found = {n.name for n in nodes} + assert found == { + "SEA/leaf1/leaf-1", + "SEA/leaf1/leaf-2", + "SEA/leaf2/leaf-1", + "SEA/leaf2/leaf-2", + } + + def test_select_node_groups_capture_groups(self, complex_network): + """Test regex capture groups in node selection.""" + node_groups = complex_network.select_node_groups_by_path("(SEA/leaf\\d)") + assert len(node_groups) == 2 + + nodes = node_groups["SEA/leaf1"] + assert len(nodes) == 2 + found = {n.name for n in nodes} + assert found == {"SEA/leaf1/leaf-1", "SEA/leaf1/leaf-2"} + + nodes = node_groups["SEA/leaf2"] + assert len(nodes) == 2 + found = {n.name for n in nodes} + assert found == {"SEA/leaf2/leaf-1", "SEA/leaf2/leaf-2"} + + def test_select_node_groups_no_matches(self, complex_network): + """Test node selection when pattern matches no nodes.""" + node_groups = complex_network.select_node_groups_by_path("NYC/.*") + assert len(node_groups) == 0 + + def test_select_node_groups_complex_regex(self, complex_network): + """Test complex regex patterns in node selection.""" + # Match all SEA nodes except SEA-other + node_groups = complex_network.select_node_groups_by_path("SEA/.*") + assert len(node_groups) == 1 + nodes = node_groups["SEA/.*"] + assert len(nodes) == 6 # All except SEA-other and SFO + + def test_select_node_groups_multiple_capture_groups(self, complex_network): + """Test multiple capture groups in regex patterns.""" + # Capture spine/leaf type and number + pattern = "SEA/(spine|leaf\\d)/.*-(\\d)" + node_groups = complex_network.select_node_groups_by_path(pattern) + + # Should have groups for each combination found + assert len(node_groups) >= 2 + + +class TestLinkUtilities: + """Tests for link utility methods.""" + + def test_get_links_between(self): + """Test retrieving all links that connect a specific source to a target.""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab1 = Link("A", "B") + link_ab2 = Link("A", "B") + link_bc = Link("B", "C") + net.add_link(link_ab1) + net.add_link(link_ab2) + net.add_link(link_bc) + + # Two links from A->B + ab_links = net.get_links_between("A", "B") + assert len(ab_links) == 2 + assert set(ab_links) == {link_ab1.id, link_ab2.id} + + # One link from B->C + bc_links = net.get_links_between("B", "C") + assert len(bc_links) == 1 + assert bc_links[0] == link_bc.id + + # None from B->A + ba_links = net.get_links_between("B", "A") + assert ba_links == [] + + def test_find_links(self): + """Test finding links by optional source_regex, target_regex.""" + net = Network() + net.add_node(Node("srcA")) + net.add_node(Node("srcB")) + net.add_node(Node("C")) + link_a_c = Link("srcA", "C") + link_b_c = Link("srcB", "C") + net.add_link(link_a_c) + net.add_link(link_b_c) + + # No filter => returns all + all_links = net.find_links() + assert len(all_links) == 2 + assert set(link.id for link in all_links) == {link_a_c.id, link_b_c.id} + + # Filter by source regex + src_a_links = net.find_links(source_regex="srcA") + assert len(src_a_links) == 1 + assert src_a_links[0].id == link_a_c.id + + # Filter by target regex + to_c_links = net.find_links(target_regex="C") + assert len(to_c_links) == 2 + assert set(link.id for link in to_c_links) == {link_a_c.id, link_b_c.id} + + # Filter by both source and target + specific_links = net.find_links(source_regex="srcB", target_regex="C") + assert len(specific_links) == 1 + assert specific_links[0].id == link_b_c.id + + # Filter that matches nothing + no_links = net.find_links(source_regex="nonexistent") + assert len(no_links) == 0 + + def test_find_links_any_direction(self): + """Test finding links in any direction (bidirectional search).""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + net.add_node(Node("C")) + + link_ab = Link("A", "B") + link_bc = Link("B", "C") + link_ca = Link("C", "A") + net.add_link(link_ab) + net.add_link(link_bc) + net.add_link(link_ca) + + # Find links involving B in any direction + b_links = net.find_links(source_regex="B", any_direction=True) + assert len(b_links) == 2 # A->B and B->C + found_ids = {link.id for link in b_links} + assert found_ids == {link_ab.id, link_bc.id} + + # Find links involving A in any direction + a_links = net.find_links(source_regex="A", any_direction=True) + assert len(a_links) == 2 # A->B and C->A + found_ids = {link.id for link in a_links} + assert found_ids == {link_ab.id, link_ca.id} + + def test_find_links_with_disabled_links(self): + """Test that find_links includes disabled links (no filtering by default).""" + net = Network() + net.add_node(Node("A")) + net.add_node(Node("B")) + + link1 = Link("A", "B") + link2 = Link("A", "B") + net.add_link(link1) + net.add_link(link2) + + # Initially both links found + links = net.find_links() + assert len(links) == 2 + + # Disable one link - find_links still returns both (no filtering) + net.disable_link(link1.id) + links = net.find_links() + assert len(links) == 2 # Still finds both links + + # Verify one is disabled and one is not + found_states = [link.disabled for link in links] + assert True in found_states and False in found_states + + def test_find_links_regex_patterns(self): + """Test find_links with various regex patterns.""" + net = Network() + nodes = ["router-1", "router-2", "switch-1", "switch-2"] + for node in nodes: + net.add_node(Node(node)) + + # Create links between all routers and switches + links = [] + for router in ["router-1", "router-2"]: + for switch in ["switch-1", "switch-2"]: + link = Link(router, switch) + net.add_link(link) + links.append(link) + + # Find all links from routers + router_links = net.find_links(source_regex="router-.*") + assert len(router_links) == 4 + + # Find all links to switches + switch_links = net.find_links(target_regex="switch-.*") + assert len(switch_links) == 4 + + # Find specific router to specific switch + specific = net.find_links(source_regex="router-1", target_regex="switch-2") + assert len(specific) == 1 From 6995fb4558a4978c1a20c06a3cb15f69dbf08f90 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Wed, 11 Jun 2025 02:19:51 +0100 Subject: [PATCH 09/10] Add detailed max flow methods to API documentation --- docs/reference/api-full.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index a93af48..6a537da 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -10,7 +10,7 @@ For a curated, example-driven API guide, see **[api.md](api.md)**. > - **[CLI Reference](cli.md)** - Command-line interface > - **[DSL Reference](dsl.md)** - YAML syntax guide -**Generated from source code on:** June 11, 2025 at 01:54 UTC +**Generated from source code on:** June 11, 2025 at 02:19 UTC **Modules auto-discovered:** 35 @@ -507,6 +507,12 @@ Attributes: - Retrieve all link IDs that connect the specified source node - `max_flow(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], float]'` - Compute maximum flow between groups of source nodes and sink nodes. +- `max_flow_detailed(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], Tuple[float, FlowSummary, StrictMultiDiGraph]]'` + - Compute maximum flow with complete analytics and graph. +- `max_flow_with_graph(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], Tuple[float, StrictMultiDiGraph]]'` + - Compute maximum flow and return the flow-assigned graph. +- `max_flow_with_summary(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], Tuple[float, FlowSummary]]'` + - Compute maximum flow with detailed analytics summary. - `saturated_edges(self, source_path: 'str', sink_path: 'str', mode: 'str' = 'combine', tolerance: 'float' = 1e-10, shortest_path: 'bool' = False, flow_placement: 'FlowPlacement' = ) -> 'Dict[Tuple[str, str], List[Tuple[str, str, str]]]'` - Identify saturated (bottleneck) edges in max flow solutions between node groups. - `select_node_groups_by_path(self, path: 'str') -> 'Dict[str, List[Node]]'` From 75207db25cdbb64e9b2e11a7a0cfb70362cc3403 Mon Sep 17 00:00:00 2001 From: Andrey Golovanov Date: Wed, 11 Jun 2025 02:22:31 +0100 Subject: [PATCH 10/10] Update version to 0.7.1 --- docs/reference/api-full.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/api-full.md b/docs/reference/api-full.md index 6a537da..5a6c21f 100644 --- a/docs/reference/api-full.md +++ b/docs/reference/api-full.md @@ -10,7 +10,7 @@ For a curated, example-driven API guide, see **[api.md](api.md)**. > - **[CLI Reference](cli.md)** - Command-line interface > - **[DSL Reference](dsl.md)** - YAML syntax guide -**Generated from source code on:** June 11, 2025 at 02:19 UTC +**Generated from source code on:** June 11, 2025 at 02:22 UTC **Modules auto-discovered:** 35 diff --git a/pyproject.toml b/pyproject.toml index 2aee033..32e9795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" # --------------------------------------------------------------------- [project] name = "ngraph" -version = "0.7.0" +version = "0.7.1" description = "A tool and a library for network modeling and capacity analysis." readme = "README.md" authors = [{ name = "Andrey Golovanov" }]