mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
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>
219 lines
8.3 KiB
Python
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"}
|