netx/packages/netx-topology-mcp/tests/test_densify_sweep.py
oliver 79f929de0c Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.
Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-09 23:18:53 +08:00

219 lines
8.3 KiB
Python

"""Tests for inward densify_sweep top-3 / round / corridor scan."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.densify_sweep import (
apply_densify_pick,
densify_corridor_scan,
densify_sweep_node,
densify_sweep_round,
)
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.orbit_sweep import _incident_stretch
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
def _sparse_spoke():
"""Hub + three near stubs + one far spoke (stretchy)."""
nodes = [
{"fabric_node_id": "h", "name": "HHHHHH-EN-H", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a", "name": "AAAAAA-EN-A", "x": 180.0, "y": 0.0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-B", "x": 0.0, "y": 180.0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-C", "x": -180.0, "y": 0.0},
{"fabric_node_id": "far", "name": "FFFFFF-EN-F", "x": 2400.0, "y": 0.0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "h", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "far"},
]
return nodes, edges
def _two_island_bridge():
"""Two compact islands linked by a long bridge — corridor densify target."""
nodes = [
{"fabric_node_id": "a1", "name": "A1A1A1-EN-1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a2", "name": "A2A2A2-EN-2", "x": 160.0, "y": 0.0},
{"fabric_node_id": "a3", "name": "A3A3A3-EN-3", "x": 80.0, "y": 140.0},
{"fabric_node_id": "b1", "name": "B1B1B1-EN-1", "x": 4000.0, "y": 0.0},
{"fabric_node_id": "b2", "name": "B2B2B2-EN-2", "x": 4160.0, "y": 0.0},
{"fabric_node_id": "b3", "name": "B3B3B3-EN-3", "x": 4080.0, "y": 140.0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "a3"},
{"a_node_id": "a3", "b_node_id": "a1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "b3"},
{"a_node_id": "b3", "b_node_id": "b1"},
{"a_node_id": "a2", "b_node_id": "b1"},
]
return nodes, edges
def test_densify_sweep_node_pulls_inward() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
out = densify_sweep_node(
st,
"far",
protect_rigid="off",
max_pull=2000,
nn_floor=40.0,
)
assert out["ok"] is True
cands = out["candidates"]
assert 1 <= len(cands) <= 3
best = cands[0]
# Moved toward hub (x decreases).
assert best["x"] < 2400.0 - 50.0
assert best["crossings"]["global"] <= g0
assert best["delta"]["global"] <= 0
# Closer to hub than start.
d0 = 2400.0
d1 = abs(best["x"] - 0.0) # hub at 0
assert d1 < d0
def test_densify_nn_floor_rejects_crush() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# Huge nn_floor should leave few/no candidates (can't get near hub).
out = densify_sweep_node(
st,
"far",
protect_rigid="off",
max_pull=2000,
nn_floor=500.0,
)
assert out["ok"] is True
for c in out["candidates"]:
# Any accepted candidate must keep nn to all others ≥ floor.
trial = dict(st.positions)
trial["far"] = (c["x"], c["y"])
for oid, (ox, oy) in trial.items():
if oid == "far":
continue
d = ((c["x"] - ox) ** 2 + (c["y"] - oy) ** 2) ** 0.5
assert d >= 500.0 - 1e-3
def test_apply_densify_pick_moves() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
sweep = densify_sweep_node(st, "far", protect_rigid="off", max_pull=1800, nn_floor=40)
assert sweep["ok"] and sweep["candidates"]
op = apply_densify_pick(st, sweep, pick=1)
assert "far" in op.moved
chosen = sweep["candidates"][0]
assert abs(op.state.positions["far"][0] - chosen["x"]) < 0.2
def test_densify_round_lowers_stretch_not_x() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
params = LayoutParams(target_nn=155.0)
stretch0 = _incident_stretch("far", st.positions, st.adj, 155.0)
op = densify_sweep_round(
st,
params=params,
top_n=4,
max_degree=9,
protect_rigid="off",
max_pull=2000,
nn_floor=40.0,
focus_ids=["far"],
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
stretch1 = _incident_stretch("far", op.state.positions, op.state.adj, 155.0)
if op.moved:
assert stretch1 < stretch0
def test_corridor_scan_raises_util_or_reverts_clean() -> None:
nodes, edges = _two_island_bridge()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "ua", "node_ids": ["a1", "a2", "a3"], "pivots": []},
{"key": "ub", "node_ids": ["b1", "b2", "b3"], "pivots": []},
]
st.meta["compose_views"] = {"rigid_groups": groups}
g0 = count_edge_crossings(st.positions, st.links)
op = densify_corridor_scan(
st,
groups=groups,
corridor_caps=[800.0, 1600.0, 3200.0],
pulls=[0.5, 0.65],
iters=4,
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
meta = op.params or {}
assert g1 <= g0 + int(meta.get("x_slack") or 5)
if not meta.get("reverted"):
assert float(meta.get("end_util") or 0) >= float(meta.get("start_util") or 0)
assert meta.get("chosen")
def test_run_layout_densify_unpublished() -> None:
nodes, edges = _sparse_spoke()
try:
run_layout_on_graph(
nodes,
edges,
action="densify_sweep",
params={"node_id": "far", "protect_rigid": "off", "max_pull": 1800, "nn_floor": 40},
)
raise AssertionError("densify_sweep should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_catalog_omits_densify_sweep() -> None:
cat = list_layout_catalog()
assert "densify_sweep" not in cat["actions"]
def test_densify_default_protect_off_may_move_shared_portal() -> None:
from netx_topology_mcp.layout_ops.densify_sweep import densify_params_from_overrides
knobs = densify_params_from_overrides({"node_id": "hub"})
assert knobs["protect_rigid"] == "off"
nodes = [
{"fabric_node_id": "hub", "name": "HUBHUB-EN-1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a1", "name": "AAAAAA-EN-1", "x": 200.0, "y": 0.0},
{"fabric_node_id": "b1", "name": "BBBBBB-EN-1", "x": 2000.0, "y": 0.0},
{"fabric_node_id": "b2", "name": "BBBBBB-EN-2", "x": 2200.0, "y": 0.0},
]
edges = [
{"a_node_id": "hub", "b_node_id": "a1"},
{"a_node_id": "hub", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "ua", "node_ids": ["hub", "a1"], "pivots": ["hub"]},
{"key": "ub", "node_ids": ["hub", "b1", "b2"], "pivots": ["hub"]},
]
st.meta["compose_views"] = {"rigid_groups": groups}
# Default off: shared portal may densify (not shared_portal error).
ok = densify_sweep_node(st, "hub", groups=groups, max_pull=1800, nn_floor=40)
assert ok["ok"] is True
# Opt-in freeze still works.
blocked = densify_sweep_node(
st, "hub", protect_rigid="portals", groups=groups, frozen_ids={"hub"}
)
assert blocked["ok"] is False
assert blocked.get("error") in {"frozen", "shared_portal"}