MCP: serve tools from the API process over Streamable HTTP at /mcp

- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,8 @@
"""Keep layout-job persistence inside pytest temporary directories."""
import pytest
@pytest.fixture(autouse=True)
def isolated_layout_job_dir(tmp_path, monkeypatch):
monkeypatch.setenv("NETX_LAYOUT_JOB_DIR", str(tmp_path / "layout-jobs"))

View file

@ -0,0 +1,55 @@
"""Tests for align_to_reference."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops.align_reference import align_to_reference
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
def test_align_similarity_maps_shared_nodes_to_portal_frame():
# Target: portals at 0 and 1000. Reference: same topology scaled/rotated.
nodes = [
{"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
{"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 2000.0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "p2"},
]
st = build_state_from_nodes_edges(nodes, edges)
# Reference: portals horizontal length 500, leaf above mid.
ref = {
"p1": (10.0, 10.0),
"p2": (510.0, 10.0),
"a": (260.0, 210.0),
}
op = align_to_reference(
st,
reference=ref,
portal_ids=["p1", "p2"],
mode="similarity",
freeze_portals=True,
)
assert op.params.get("shared_n") == 3
assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6
assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6
# Leaf should land near mid-x, positive y (scaled 2x from ref dy=200 → 400)
assert abs(op.state.positions["a"][0] - 500.0) < 1.0
assert op.state.positions["a"][1] > 100.0
def test_align_adopt_copies_reference_coords():
nodes = [
{"fabric_node_id": "p1", "name": "P1", "x": 999.0, "y": 999.0},
{"fabric_node_id": "p2", "name": "P2", "x": 1999.0, "y": 999.0},
{"fabric_node_id": "a", "name": "A", "x": 1500.0, "y": 1500.0},
]
edges = [{"a_node_id": "p1", "b_node_id": "p2"}]
st = build_state_from_nodes_edges(nodes, edges)
ref = {"p1": (100.0, 200.0), "p2": (300.0, 200.0), "a": (200.0, 400.0)}
op = align_to_reference(st, reference=ref, mode="adopt", park_missing=False)
# origin normalized: min was 100,200 → pad 40
assert abs(op.state.positions["p1"][0] - 40.0) < 1e-6
assert abs(op.state.positions["p1"][1] - 40.0) < 1e-6
assert abs(op.state.positions["a"][1] - 240.0) < 1e-6

View file

@ -0,0 +1,185 @@
"""Tests for chain / ring+chain bundle orbit (contract → sweep → expand)."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.bundle_orbit import (
detect_chain_bundles,
detect_ring_chain_bundles,
orbit_bundle,
apply_bundle_pick,
bundle_orbit_until_progress,
)
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
orbit_params_from_overrides,
orbit_sweep_until_limit,
)
def _crossed_chain():
"""Hub H with chain leaf that pierces a long chord a—b."""
# a———b horizontal at y=200; vertical chain crosses it between c1 and c2.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0},
{"fabric_node_id": "b", "name": "B", "x": 2000.0, "y": 200.0},
{"fabric_node_id": "h", "name": "H", "x": 1000.0, "y": 0.0},
{"fabric_node_id": "c1", "name": "C1", "x": 1000.0, "y": 80.0},
{"fabric_node_id": "c2", "name": "C2", "x": 1000.0, "y": 320.0},
{"fabric_node_id": "c3", "name": "C3", "x": 1000.0, "y": 480.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "h", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "c3"},
]
return nodes, edges
def _triangle_with_chain():
"""Triangle ABC with dangling chain off A that crosses foreign chord."""
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 400.0, "y": 0.0},
{"fabric_node_id": "b", "name": "B", "x": 0.0, "y": 300.0},
{"fabric_node_id": "c", "name": "C", "x": 800.0, "y": 300.0},
{"fabric_node_id": "d1", "name": "D1", "x": 400.0, "y": 200.0},
{"fabric_node_id": "d2", "name": "D2", "x": 400.0, "y": 400.0},
{"fabric_node_id": "x", "name": "X", "x": 0.0, "y": 200.0},
{"fabric_node_id": "y", "name": "Y", "x": 800.0, "y": 200.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "x", "b_node_id": "y"},
]
return nodes, edges
def test_detect_chain_bundle() -> None:
nodes, edges = _crossed_chain()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h"})
assert bundles
members = set(bundles[0].member_ids)
assert "c1" in members and "c3" in members
assert "h" not in members
def test_chain_bundle_orbit_cuts_crossing() -> None:
nodes, edges = _crossed_chain()
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)
assert g0 >= 1
bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"})
assert bundles
sweep = orbit_bundle(st, bundles[0], max_jump=2500, cand_cap=300, nn_floor=10.0)
assert sweep["ok"] is True
assert sweep.get("expand_mode") == "minimize_probe"
assert int(sweep.get("improving_n") or 0) >= 1
best = sweep["candidates"][0]
assert "placements" in best
assert float(best.get("expand_scale") or 1.0) <= 1.0
op = apply_bundle_pick(st, bundles[0], sweep, pick=1)
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 < g0
# Expand must not raise crossings vs before apply
assert g1 <= g0
def test_expand_refuses_if_crossings_rise() -> None:
nodes, edges = _crossed_chain()
st = build_state_from_nodes_edges(nodes, edges)
# Start cleared (no crossing), then force a bad expand back onto the chord.
st.positions = {
"a": (0.0, 200.0),
"b": (2000.0, 200.0),
"h": (1000.0, 0.0),
"c1": (1400.0, 80.0),
"c2": (1400.0, 200.0),
"c3": (1400.0, 320.0),
}
g0 = count_edge_crossings(st.positions, st.links)
assert g0 == 0
bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"})
assert bundles
bad = {
"candidates": [
{
"rank": 1,
"delta": {"global": -1},
"crossings": {"global": 0},
"expand_scale": 1.0,
"placements": {
"c1": (1000.0, 80.0),
"c2": (1000.0, 320.0),
"c3": (1000.0, 480.0),
},
}
]
}
op = apply_bundle_pick(st, bundles[0], bad, pick=1)
assert not op.moved
assert (op.params or {}).get("error") == "expand_raises_crossings"
assert count_edge_crossings(st.positions, st.links) == g0
def test_ring_chain_detect() -> None:
nodes, edges = _triangle_with_chain()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
bundles = detect_ring_chain_bundles(st, frozen={"b", "c"})
assert any(b.kind == "ring_chain" for b in bundles)
tip = next(b for b in bundles if b.kind == "ring_chain")
assert tip.tip_id == "a"
assert "d1" in tip.member_ids or "d2" in tip.member_ids
assert set(tip.base_ids) >= {"b", "c"}
def test_bundle_until_progress() -> None:
nodes, edges = _crossed_chain()
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)
op = bundle_orbit_until_progress(
st, frozen_ids={"h", "a", "b"}, max_jump=2500, nn_floor=10.0
)
assert op.moved
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 < g0
def test_until_limit_bundle_default_on() -> None:
knobs = orbit_params_from_overrides({"until_limit": True})
assert knobs.get("bundle") is True
assert knobs.get("bundle_max") == 10
def test_until_limit_uses_bundle_when_points_stall() -> None:
nodes, edges = _crossed_chain()
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)
op = orbit_sweep_until_limit(
st,
protect_rigid="off",
frozen_ids={"h", "a", "b"},
max_jump=2500,
max_degree=4,
max_moves=8,
stall_limit=4,
nn_floor=10.0,
bundle=True,
bundle_max=6,
)
meta = op.params or {}
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
# Either point or bundle should have moved if crossings existed
if g0 > 0:
assert int(meta.get("moves_n") or 0) >= 1 or g1 < g0

View file

@ -0,0 +1,137 @@
"""Tests for channel_metro (channels + ring faces + recipe)."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
from netx_api.mcp_remote.topology.layout_ops.channel_metro import build_channel_metro_skeleton
from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels, place_channel_ray
from netx_api.mcp_remote.topology.layout_ops.ring_faces import place_ring_rectangle
from netx_api.mcp_remote.topology.layout_tool import RECIPE_ALIASES, run_layout_on_graph
def _core_bar_channels():
"""Two cores, two ANs, each with a deg-2 corridor + one triangle ring."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 0, "y": 0},
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
# corridor a0-e0-e1
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
# corridor a1-e2-e3
{"a_node_id": "a1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "e3"},
# triangle ring on a0 side
{"a_node_id": "a0", "b_node_id": "e4"},
{"a_node_id": "e4", "b_node_id": "e5"},
{"a_node_id": "e5", "b_node_id": "a0"},
]
return nodes, edges
def test_extract_channels() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
ch = extract_channels(st)
assert len(ch) >= 1
assert any(c.length >= 3 for c in ch)
def test_place_channel_ray() -> None:
placed = place_channel_ray(
["h", "a", "b"],
origin=(0.0, 0.0),
ux=1.0,
uy=0.0,
step=100.0,
pinned={"h"},
)
assert "h" not in placed
assert placed["a"] == (100.0, 0.0)
assert placed["b"] == (200.0, 0.0)
def test_ring_rectangle() -> None:
from netx_api.mcp_remote.topology.layout_ops.ring_faces import RingFace
face = RingFace(("a", "b", "c", "d"))
pos = place_ring_rectangle(face, center=(0, 0), width=200, height=100)
assert set(pos) == {"a", "b", "c", "d"}
def test_build_channel_metro_places_all() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
op = build_channel_metro_skeleton(st, LayoutParams())
assert op.op == "build_channel_metro_skeleton"
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
assert op.state.meta.get("rings_mode") == "channel_metro"
# Cores present; beam pin may or may not accept depending on crossings
assert "c0" in op.state.positions and "c1" in op.state.positions
def test_recipe_alias_and_run_unpublished() -> None:
assert "channel_metro" not in RECIPE_ALIASES
nodes, edges = _core_bar_channels()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="channel_metro")
raise AssertionError("channel_metro should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_protect_rings_action_unpublished() -> None:
nodes, edges = _core_bar_channels()
for i, n in enumerate(nodes):
n["x"] = float(i * 40)
n["y"] = float((i % 3) * 30)
try:
run_layout_on_graph(nodes, edges, action="protect_rings")
raise AssertionError("protect_rings should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_recipe_channel_metro_v1_unpublished() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "channel_metro_v1", LayoutParams())
raise AssertionError("channel_metro_v1 should be unpublished")
except Exception as e:
assert "channel" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
def test_straighten_channels_action() -> None:
nodes, edges = _core_bar_channels()
# bent corridor a0-e0-e1
coords = {
"c0": (0, 0),
"c1": (400, 0),
"a0": (100, 200),
"a1": (500, 200),
"e0": (50, 400),
"e1": (300, 450),
"e2": (500, 400),
"e3": (700, 420),
"e4": (150, 300),
"e5": (200, 250),
}
for n in nodes:
x, y = coords[n["fabric_node_id"]]
n["x"], n["y"] = x, y
out = run_layout_on_graph(nodes, edges, action="straighten_channels")
assert out["ok"] is True
assert out["action"] == "straighten_channels"

View file

@ -0,0 +1,144 @@
"""edge_clearance metric + clear_edge_hits action."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import (
EDGE_CLEARANCE_THR,
compute_edge_clearance,
count_edge_crossings,
)
from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import clear_edge_hits
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats, score_layout_components
from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph
def _mksr_style() -> tuple[list[dict], list[dict]]:
"""M1MJ—MEPL horizontal trunk with MKSR sitting on the segment; DALH stub."""
nodes = [
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
{"fabric_node_id": "mksr", "name": "MKSR", "x": 200.0, "y": 6.0}, # on trunk
{"fabric_node_id": "dalh", "name": "DALH", "x": 200.0, "y": 200.0},
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
]
edges = [
{"a_node_id": "m1mj", "b_node_id": "mepl"},
{"a_node_id": "mepl", "b_node_id": "bbsn"},
{"a_node_id": "bbsn", "b_node_id": "mksr"},
{"a_node_id": "mksr", "b_node_id": "dalh"},
]
return nodes, edges
def test_collinear_midpoint_scores_edge_clearance_hit() -> None:
nodes, edges = _mksr_style()
m = analyze_layout_stats(nodes, edges)
assert int(m["edge_clearance_hits"] or 0) >= 1
assert float(m["edge_clearance_score"]) < 1.0
assert "edge_clearance" in m["score"]["parts"]
assert abs(m["score"]["weights"]["edge_clearance"] - 0.10) < 1e-9
assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
top = m.get("top_edge_hits") or []
assert any(r.get("fabric_node_id") == "mksr" for r in top)
def test_cleared_layout_has_zero_hits() -> None:
nodes = [
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
{"fabric_node_id": "mksr", "name": "MKSR", "x": 600.0, "y": 200.0},
{"fabric_node_id": "dalh", "name": "DALH", "x": 600.0, "y": 400.0},
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
]
edges = [
{"a_node_id": "m1mj", "b_node_id": "mepl"},
{"a_node_id": "mepl", "b_node_id": "bbsn"},
{"a_node_id": "bbsn", "b_node_id": "mksr"},
{"a_node_id": "mksr", "b_node_id": "dalh"},
]
m = analyze_layout_stats(nodes, edges)
assert int(m["edge_clearance_hits"] or 0) == 0
assert float(m["edge_clearance_score"]) == 1.0
assert m["report"]["edge_clearance"]["status"] == "ok"
def test_clear_edge_hits_moves_mksr_off_trunk() -> None:
nodes, edges = _mksr_style()
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
names = {n["fabric_node_id"]: n["name"] for n in nodes}
links = [("m1mj", "mepl"), ("mepl", "bbsn"), ("bbsn", "mksr"), ("mksr", "dalh")]
st = LayoutState(positions=pos, names=names, links=links)
x0 = count_edge_crossings(st.positions, st.links)
clr0 = compute_edge_clearance(st.positions, st.links, thr=EDGE_CLEARANCE_THR)
assert int(clr0["edge_clearance_hits"] or 0) >= 1
op = clear_edge_hits(st, LayoutParams(), thr=EDGE_CLEARANCE_THR, margin=20.0)
assert "mksr" in op.moved or int(op.params.get("hits_after") or 0) < int(
op.params.get("hits_before") or 0
)
x1 = count_edge_crossings(op.state.positions, op.state.links)
assert x1 <= x0
clr1 = compute_edge_clearance(op.state.positions, op.state.links, thr=EDGE_CLEARANCE_THR)
assert int(clr1["edge_clearance_hits"] or 0) < int(clr0["edge_clearance_hits"] or 0)
def test_run_layout_clear_edge_hits_apply_path() -> None:
nodes, edges = _mksr_style()
out = run_layout_on_graph(
nodes,
edges,
action="clear_edge_hits",
params={"thr": 40.0, "margin": 20.0, "top_n": 8},
)
assert out.get("ok") is not False
assert out.get("action") == "clear_edge_hits"
local = out.get("local") or {}
note = str(local.get("note") or "")
assert "clear_edge_hits" in note
def test_score_weights_include_edge_clearance() -> None:
s = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 0.2,
"edge_axis_score": 0.2,
}
)
assert abs(s["weights"]["edge_clearance"] - 0.10) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
assert abs(s["weights"]["nn"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
good = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 1.0,
"edge_axis_score": 1.0,
}
)
assert good["total"] > s["total"]

View file

@ -0,0 +1,40 @@
"""Tests for gated compact_bbox."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.compact_bbox import compact_bbox
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
def test_compact_bbox_shrinks_without_raising_crossings():
# Two portals + a far leaf; shrink should pull leaf inward.
nodes = [
{"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
{"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 0.0},
{"fabric_node_id": "leaf", "name": "Leaf", "x": 500.0, "y": 4000.0},
]
edges = [
{"a_fabric_node_id": "p1", "b_fabric_node_id": "a"},
{"a_fabric_node_id": "a", "b_fabric_node_id": "p2"},
{"a_fabric_node_id": "a", "b_fabric_node_id": "leaf"},
]
st = build_state_from_nodes_edges(nodes, edges)
g0 = count_edge_crossings(st.positions, st.links)
op = compact_bbox(
st,
portal_ids=["p1", "p2"],
min_scale=0.7,
step=0.05,
max_clearance_slack=50,
)
assert op.params.get("accepted") is True
assert op.params.get("scale", 1.0) < 1.0
assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6
assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6
assert op.state.positions["leaf"][1] < 4000.0
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
assert not _has_any_footprint_overlap(op.state.positions, op.state.names)

View file

@ -0,0 +1,129 @@
"""Tests for incremental Prim+orbit compose_orbit."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import (
compose_orbit_into_state,
compose_orbit_params_from_overrides,
crossings_touching,
orbit_pack_blocks,
)
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_compose_orbit() -> None:
assert "compose_orbit" not in ACTIONS
def test_orbit_params_parse_knobs() -> None:
knobs = compose_orbit_params_from_overrides(
{
"source_view_ids": ["a", "b"],
"pad": 400,
"angle_step": 45,
"radii": [1.0, 1.5],
"cand_cap": 40,
}
)
assert knobs["pad"] == 400
assert knobs["angle_step"] == 45
assert knobs["radii"] == (1.0, 1.5)
assert knobs["cand_cap"] == 40
assert knobs["source_view_ids"] == ["a", "b"]
def test_orbit_pack_glues_shared_portal() -> None:
unit_a = ComposeBlock(
key="ua",
positions={
"p": (0.0, 0.0),
"a1": (120.0, 0.0),
"a2": (120.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p": (0.0, 0.0),
"b1": (120.0, 0.0),
"b2": (120.0, -80.0),
},
)
links = [
("p", "a1"),
("a1", "a2"),
("p", "b1"),
("b1", "b2"),
("a2", "b2"),
]
merged, meta = orbit_pack_blocks(
[unit_a, unit_b],
pad=200.0,
links=links,
fabric_bridges=True,
angle_step=30,
cand_cap=60,
)
assert meta["mode"] == "compose_orbit"
assert meta["pack_mode"] == "orbit_attach"
assert meta["slots"] == 2
assert set(merged) == {"p", "a1", "a2", "b1", "b2"}
# Shared portal is a single point (first-owner / align).
assert "p" in merged
via = meta.get("merged_via") or {}
assert "orbit" in " ".join(str(v) for v in via.values()) or any(
"shared" in str(v) or "orbit" in str(v) for v in via.values()
)
def test_crossings_touching_partial() -> None:
# Two segments crossing; focus only on one endpoint set.
pos = {
"a": (0.0, 0.0),
"b": (10.0, 10.0),
"c": (0.0, 10.0),
"d": (10.0, 0.0),
}
links = [("a", "b"), ("c", "d")]
assert crossings_touching(pos, links, {"a", "b"}) == 1
assert crossings_touching(pos, links, {"z"}) == 0
def test_compose_orbit_into_state_and_run_layout() -> None:
nodes = [
{"id": "p", "x": 0, "y": 0},
{"id": "a1", "x": 100, "y": 0},
{"id": "b1", "x": 0, "y": 100},
]
edges = [{"source": "p", "target": "a1"}, {"source": "p", "target": "b1"}]
st = build_state_from_nodes_edges(nodes, edges)
blocks = [
ComposeBlock(key="ua", positions={"p": (0.0, 0.0), "a1": (100.0, 0.0)}),
ComposeBlock(key="ub", positions={"p": (0.0, 0.0), "b1": (100.0, 0.0)}),
]
op = compose_orbit_into_state(st, blocks, LayoutParams(), pad=150.0)
assert op.op == "compose_orbit"
assert "compose_views" in (op.state.meta or {})
assert (op.state.meta.get("compose_views") or {}).get("mode") == "compose_orbit"
try:
run_layout_on_graph(
nodes,
edges,
action="compose_orbit",
params={
"_compose_blocks": [
{"key": "ua", "positions": {"p": [0, 0], "a1": [100, 0]}},
{"key": "ub", "positions": {"p": [0, 0], "b1": [100, 0]}},
],
"rigid_polish": False,
"soft_polish": False,
"pad": 150,
"angle_step": 45,
},
)
raise AssertionError("compose_orbit should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,325 @@
"""Tests for world-map strip-pack compose_views."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
ComposeBlock,
compose_into_state,
compose_params_from_overrides,
strip_pack_blocks,
)
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_compose_views() -> None:
assert "compose_views" not in ACTIONS
def test_strip_pack_preserves_relative_geometry() -> None:
a = ComposeBlock(
key="va",
positions={
"a1": (10.0, 20.0),
"a2": (110.0, 20.0),
"a3": (10.0, 120.0),
},
)
b = ComposeBlock(
key="vb",
positions={
"b1": (0.0, 0.0),
"b2": (50.0, 0.0),
},
)
merged, meta = strip_pack_blocks([a, b], pad=100.0)
assert meta["slots"] == 2
assert set(merged) == {"a1", "a2", "a3", "b1", "b2"}
# Relative offsets inside block A preserved.
dx = merged["a2"][0] - merged["a1"][0]
dy = merged["a2"][1] - merged["a1"][1]
assert abs(dx - 100.0) < 1e-6
assert abs(dy) < 1e-6
# Blocks occupy distinct slots (centroids far apart vs pad).
ax = sum(merged[n][0] for n in ("a1", "a2", "a3")) / 3
bx = sum(merged[n][0] for n in ("b1", "b2")) / 2
ay = sum(merged[n][1] for n in ("a1", "a2", "a3")) / 3
by = sum(merged[n][1] for n in ("b1", "b2")) / 2
assert abs(ax - bx) > 50 or abs(ay - by) > 50
def test_strip_pack_first_owner_wins_duplicates() -> None:
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
# Portal-grow still glues the later shared block rigidly.
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
assert "shared" in merged
assert "a" in merged and "b" in merged
via = meta.get("merged_via") or {}
assert "rigid_shared" in via.values()
assert meta.get("order_mode") == "portal_grow"
def test_portal_grow_attaches_shared_before_orphan_misc() -> None:
"""Connected dual units grow together; huge misc without shared packs last."""
unit_ab = ComposeBlock(
key="ab",
positions={
"p_a": (0.0, 0.0),
"p_b": (200.0, 0.0),
"mid": (100.0, 40.0),
},
)
unit_bc = ComposeBlock(
key="bc",
positions={
"p_b": (0.0, 0.0),
"p_c": (200.0, 0.0),
"x1": (50.0, 30.0),
"x2": (100.0, 30.0),
"x3": (150.0, 30.0),
"x4": (80.0, -30.0),
},
)
misc = ComposeBlock(
key="misc",
positions={f"m{i}": (float(i) * 40.0, float(i % 3) * 40.0) for i in range(24)},
)
merged, meta = strip_pack_blocks(
[misc, unit_ab, unit_bc], pad=80.0, merge_shared=True
)
order = meta.get("order") or []
assert meta.get("order_mode") == "portal_grow"
# Misc is largest area but has no shared glue → last.
assert order[-1] == "misc"
# ab and bc share p_b → one of them merges rigidly onto the other.
via = meta.get("merged_via") or {}
assert via.get("ab") == "rigid_shared" or via.get("bc") == "rigid_shared"
# Shared portal has a single world coord.
assert "p_b" in merged
assert abs(merged["p_b"][0] - merged["p_b"][0]) < 1e-9
def test_portal_centroid_keeps_shared_units_near() -> None:
"""Units that share portals get closer origins than unrelated orphans."""
# Chain A—B—C via portals; orphan D has no shared nodes.
unit_a = ComposeBlock(
key="ua",
positions={
"p_ab": (0.0, 0.0),
"a1": (100.0, 0.0),
"a2": (100.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p_ab": (0.0, 0.0),
"p_bc": (200.0, 0.0),
"b1": (100.0, 40.0),
},
)
unit_c = ComposeBlock(
key="uc",
positions={
"p_bc": (0.0, 0.0),
"c1": (100.0, 0.0),
"c2": (100.0, 60.0),
},
)
orphan = ComposeBlock(
key="orphan",
positions={f"o{i}": (float(i) * 30.0, 0.0) for i in range(8)},
)
merged, meta = strip_pack_blocks(
[orphan, unit_c, unit_a, unit_b], pad=80.0, merge_shared=True
)
assert meta.get("pack_mode") == "portal_centroid"
origins = meta["origins"]
def _slot_c(key: str) -> tuple[float, float]:
sm = meta["slot_meta"][key]
ox, oy = origins[key]
return ox + 0.5 * sm["w"], oy + 0.5 * sm["h"]
ca, cb, cc, co = _slot_c("ua"), _slot_c("ub"), _slot_c("uc"), _slot_c("orphan")
# Portal-graph neighbors closer than the A–C skip.
d_ab = math.hypot(ca[0] - cb[0], ca[1] - cb[1])
d_bc = math.hypot(cb[0] - cc[0], cb[1] - cc[1])
d_ac = math.hypot(ca[0] - cc[0], ca[1] - cc[1])
assert d_ab < d_ac
assert d_bc < d_ac
# Orphans sit below the glued cluster.
glued_ymax = max(
origins[k][1] + meta["slot_meta"][k]["h"] for k in ("ua", "ub", "uc")
)
assert origins["orphan"][1] >= glued_ymax - 1e-6
# Rigid merge still glues the chain.
via = meta.get("merged_via") or {}
assert "rigid_shared" in via.values()
assert "p_ab" in merged and "p_bc" in merged
def test_fabric_bridges_pull_disjoint_units_closer() -> None:
"""No shared portal: fabric edge + higher bridge_boost / lower ideal_scale."""
unit_a = ComposeBlock(
key="ua",
positions={
"a0": (0.0, 0.0),
"a1": (120.0, 0.0),
"a2": (60.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"b0": (0.0, 0.0),
"b1": (120.0, 0.0),
"b2": (60.0, 80.0),
},
)
unit_c = ComposeBlock(
key="uc",
positions={
"c0": (0.0, 0.0),
"c1": (120.0, 0.0),
"c2": (60.0, 80.0),
},
)
# Only A↔B has a fabric spoke; C is an unrelated peer (orphan strip below).
links = [("a1", "b0")]
blocks = [unit_a, unit_b, unit_c]
def _origin_gap(meta: dict, ka: str, kb: str) -> float:
oa, ob = meta["origins"][ka], meta["origins"][kb]
return math.hypot(oa[0] - ob[0], oa[1] - ob[1])
loose, meta_loose = strip_pack_blocks(
blocks,
pad=200.0,
merge_shared=True,
links=links,
fabric_bridges=True,
bridge_boost=4.0,
ideal_scale=0.85,
)
tight, meta_tight = strip_pack_blocks(
blocks,
pad=200.0,
merge_shared=True,
links=links,
fabric_bridges=True,
bridge_boost=40.0,
ideal_scale=0.3,
)
off, meta_off = strip_pack_blocks(
blocks, pad=200.0, merge_shared=True, links=links, fabric_bridges=False
)
assert meta_off.get("fabric_bridges") is False
assert meta_tight.get("fabric_bridges") is True
assert meta_tight.get("bridge_boost") == 40.0
assert meta_tight.get("ideal_scale") == 0.3
# Tunable spring: denser knobs shrink A–B slot gap.
assert _origin_gap(meta_tight, "ua", "ub") < _origin_gap(meta_loose, "ua", "ub") * 0.75
# Fabric glue puts C below the A–B component (not interleaved in strip).
glued_ymax = max(
meta_tight["origins"][k][1] + meta_tight["slot_meta"][k]["h"]
for k in ("ua", "ub")
)
assert meta_tight["origins"]["uc"][1] >= glued_ymax - 1e-6
del loose, tight, off
def test_compose_params_from_overrides_knobs() -> None:
knobs = compose_params_from_overrides(
{
"pad": 400,
"fabric_bridges": "on",
"bridge_boost": 12,
"ideal_scale": 0.4,
"spring_iters": 100,
"merge_shared": False,
}
)
assert knobs["pad"] == 400.0
assert knobs["fabric_bridges"] is True
assert knobs["bridge_boost"] == 12.0
assert knobs["ideal_scale"] == 0.4
assert knobs["spring_iters"] == 100
assert knobs["merge_shared"] is False
def test_dual_portal_flip_shortens_external_bridge() -> None:
"""When two portals fix the chord, pick the bank that shortens fabric spokes."""
unit_a = ComposeBlock(
key="ua",
positions={
"p0": (0.0, 0.0),
"p1": (200.0, 0.0),
"t": (100.0, -160.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p0": (0.0, 0.0),
"p1": (200.0, 0.0),
"b": (100.0, 120.0),
},
)
links = [("b", "t")]
merged, _meta = strip_pack_blocks(
[unit_a, unit_b], pad=40.0, merge_shared=True, links=links
)
db = math.hypot(merged["b"][0] - merged["t"][0], merged["b"][1] - merged["t"][1])
# Unflipped bank would put b roughly opposite t (~280); flipped ~40.
assert db < 120.0
def test_strip_pack_merge_shared_false_keeps_first_only() -> None:
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=False)
assert meta.get("merge_shared") is False
assert "shared" in merged and "b" in merged
def test_compose_into_state_and_run_layout() -> None:
nodes = [
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2", "x": 10, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 10, "y": 0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
blocks = [
ComposeBlock("va", {"a1": (0.0, 0.0), "a2": (100.0, 0.0)}),
ComposeBlock("vb", {"b1": (0.0, 0.0), "b2": (80.0, 0.0)}),
]
op = compose_into_state(st, blocks, LayoutParams(), pad=200.0)
assert op.op == "compose_views"
assert op.params.get("slots") == 2
assert abs(op.state.positions["a2"][0] - op.state.positions["a1"][0] - 100.0) < 1e-6
try:
run_layout_on_graph(
nodes,
edges,
action="compose_views",
params={
"_compose_blocks": [
{"key": "va", "positions": {"a1": [0, 0], "a2": [100, 0]}},
{"key": "vb", "positions": {"b1": [0, 0], "b2": [80, 0]}},
],
"pad": 200,
},
)
raise AssertionError("compose_views should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,76 @@
"""core_bar beam-first placement in sugiyama._place_component."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _place_component
def _dual_core_multi_an():
"""Two cores on a bar, two ANs each with a small access petal."""
# c0 -- a0 -- e0
# | |
# c1 -- a1 -- e1
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a0", "b_node_id": "e2"},
{"a_node_id": "a1", "b_node_id": "e1"},
{"a_node_id": "a1", "b_node_id": "e3"},
]
return nodes, edges
def test_place_component_core_beam_collinear_cores() -> None:
nodes, edges = _dual_core_multi_an()
st = build_state_from_nodes_edges(nodes, edges)
assert st.layers["c0"] == "core" and st.layers["c1"] == "core"
assert st.layers["a0"] == "agg" and st.layers["a1"] == "agg"
ids = [n["fabric_node_id"] for n in nodes]
out = _place_component(ids, st, LayoutParams())
assert set(out) == set(ids)
# Cores approximately collinear on a horizontal beam
y0, y1 = out["c0"][1], out["c1"][1]
assert abs(y0 - y1) < 1.0
assert abs(out["c0"][0] - out["c1"][0]) > 100.0
# ANs hang off the beam (not packed into a core column): mean AN y away from beam
beam_y = (y0 + y1) / 2
an_y = (out["a0"][1] + out["a1"][1]) / 2
assert abs(an_y - beam_y) > 80.0
# Beam-first: both cores share one y; ANs are not co-located with cores as a column pack
assert abs(out["a0"][1] - beam_y) > 40.0
assert abs(out["a1"][1] - beam_y) > 40.0
def test_place_component_single_core_still_packs() -> None:
"""One core keeps the legacy AN-column pack path (no beam branch)."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c0", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a1", "b_node_id": "e1"},
]
st = build_state_from_nodes_edges(nodes, edges)
ids = [n["fabric_node_id"] for n in nodes]
out = _place_component(ids, st, LayoutParams())
assert set(out) == set(ids)
assert "c0" in out

View file

@ -0,0 +1,219 @@
"""Tests for inward densify_sweep top-3 / round / corridor scan."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.densify_sweep import (
apply_densify_pick,
densify_corridor_scan,
densify_sweep_node,
densify_sweep_round,
)
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _incident_stretch
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.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_api.mcp_remote.topology.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"}

View file

@ -0,0 +1,172 @@
"""Same-canvas dual_mass: petal/straight beautify in place."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges, run_recipe
from netx_api.mcp_remote.topology.layout_ops.dual_mass import layout_dual_mass
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
DualUnit,
beautify_dual_unit_positions,
classify_dual_unit,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, RECIPE_ALIASES, run_layout_on_graph
def _two_eye_graph():
"""Two dual-portal petals sharing portal p2 + one leftover."""
nodes = [
{"fabric_node_id": "p1", "name": "AN-P1", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "AN-P2", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "p3", "name": "AN-P3", "role": "an", "x": 200, "y": 0},
{"fabric_node_id": "a1", "name": "EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "EN-C2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d1", "name": "EN-D1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d2", "name": "EN-D2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "z1", "name": "EN-Z1", "role": "en", "x": 999, "y": 999},
]
edges = [
# eye p1–p2
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
# eye p2–p3
{"a_node_id": "p2", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p3"},
{"a_node_id": "p2", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "d2", "b_node_id": "p3"},
# leftover hanging off p3
{"a_node_id": "p3", "b_node_id": "z1"},
]
return nodes, edges
def test_recipe_alias_dual_mass() -> None:
# dual_mass kept as module; unpublished from public recipe/action surface.
assert "dual_mass" not in RECIPE_ALIASES
assert "dual_mass" not in ACTIONS
def test_classify_petal_and_straight() -> None:
petal = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
)
assert classify_dual_unit(petal) == "petal"
chain = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1"] + [f"n{i}" for i in range(10)] + ["p2"]],
)
assert classify_dual_unit(chain) == "straight"
short = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"]],
)
assert classify_dual_unit(short) == "straight"
def test_beautify_petal_ellipse_bands() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
unit = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
)
pos = beautify_dual_unit_positions(st, unit, LayoutParams())
# Portals sit on the x-axis, a left of b (the eye's chord).
assert abs(pos["p1"][1]) < 1e-6 and abs(pos["p2"][1]) < 1e-6
assert pos["p1"][0] < pos["p2"][0]
# Each corridor is one band on its own side of the chord.
assert pos["a1"][1] * pos["b1"][1] < 0
for x, y in (("a1", "a2"), ("b1", "b2")):
assert pos[x][1] * pos[y][1] > 0
assert abs(pos[x][1]) > 1.0
# Runs a→b, strictly inside the portals (hollow eye, no node on the chord ends).
assert pos["p1"][0] < pos[x][0] < pos[y][0] < pos["p2"][0]
links = [(e["a_node_id"], e["b_node_id"]) for e in edges if e["a_node_id"] in pos and e["b_node_id"] in pos]
assert count_edge_crossings(pos, links) == 0
def test_dual_mass_refine_reports_drift() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
# Seed a spread layout first (optional repark).
op1 = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
op2 = layout_dual_mass(
op1.state, LayoutParams(), mode="refine", rounds=2, stable_drift=1e9
)
assert op2.params.get("mode_first") == "refine"
assert int(op2.params.get("rounds_ran") or 0) >= 1
assert isinstance(op2.params.get("centroid_drift"), list)
kinds = op2.params.get("kinds") or {}
assert int(kinds.get("petal") or 0) >= 1
def test_layout_dual_mass_beautify_and_meta() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_mass(st, LayoutParams(), mode="full")
assert op.op == "layout_dual_mass"
assert int(op.params.get("units_n") or 0) >= 1
assert op.state.meta.get("dual_mass")
cv = op.state.meta.get("compose_views") or {}
assert cv.get("mass_groups")
assert op.state.meta.get("mass_field")
assert op.params.get("role") == "beautify"
p1 = op.state.positions["p1"]
p2 = op.state.positions["p2"]
dist = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
assert dist >= 80.0
assert int(op.params.get("unit_ok") or 0) >= 1
def test_run_recipe_dual_mass_v1_unpublished() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "dual_mass_v1", LayoutParams())
raise AssertionError("dual_mass_v1 should be unpublished from RECIPES")
except Exception as e:
assert "dual_mass" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
def test_run_layout_on_graph_dual_mass_unpublished() -> None:
nodes, edges = _two_eye_graph()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="dual_mass")
raise AssertionError("dual_mass recipe should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_layout_dual_mass_module_still_works() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
assert op.op == "layout_dual_mass"
pos = op.state.positions
assert "p1" in pos and "p2" in pos
members = {"p1", "p2", "a1", "a2", "b1", "b2"}
links = [
(e["a_node_id"], e["b_node_id"])
for e in edges
if e["a_node_id"] in members and e["b_node_id"] in members
]
assert count_edge_crossings({k: pos[k] for k in members}, links) == 0

View file

@ -0,0 +1,284 @@
"""Dual-portal eye units: detect, CN-first max-cover layout, shared-portal compose."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, strip_pack_blocks
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
find_dual_portal_units,
layout_dual_unit,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def _eye_graph():
"""Two AN portals + three interior-disjoint access corridors."""
nodes = [
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t1", "name": "BTM-EN-T1", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p2"},
{"a_node_id": "a1", "b_node_id": "t1"}, # tail off corridor
]
return nodes, edges
def _cn_eye_graph():
"""CN pair covers more NEs than a small AN ring — detection must prefer CN."""
nodes = [
{"fabric_node_id": "cn1", "name": "BTM-CN1", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "cn2", "name": "BTM-CN2", "role": "cn", "x": 100, "y": 0},
{"fabric_node_id": "an1", "name": "BTM-AN1", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "an2", "name": "BTM-AN2", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "BTM-EN1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "BTM-EN2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e3", "name": "BTM-EN3", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e4", "name": "BTM-EN4", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e5", "name": "BTM-EN5", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e6", "name": "BTM-EN6", "role": "en", "x": 0, "y": 0},
]
# Two CN↔CN corridors via AN+EN, plus a tiny AN–AN eye that would steal
# interiors if AN rings were claimed first.
edges = [
{"a_node_id": "cn1", "b_node_id": "cn2"},
{"a_node_id": "cn1", "b_node_id": "an1"},
{"a_node_id": "an1", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "an2"},
{"a_node_id": "an2", "b_node_id": "cn2"},
{"a_node_id": "cn1", "b_node_id": "e3"},
{"a_node_id": "e3", "b_node_id": "e4"},
{"a_node_id": "e4", "b_node_id": "cn2"},
{"a_node_id": "an1", "b_node_id": "e5"},
{"a_node_id": "e5", "b_node_id": "e6"},
{"a_node_id": "e6", "b_node_id": "an2"},
]
return nodes, edges
def test_actions_include_layout_dual_unit() -> None:
assert "layout_dual_unit" in ACTIONS
def test_find_dual_portal_units_eye() -> None:
nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
units = find_dual_portal_units(st)
assert len(units) >= 1
u = units[0]
assert {u.portal_a, u.portal_b} == {"p1", "p2"}
assert len(u.paths) >= 2
interiors = set()
for p in u.paths:
interiors |= set(p[1:-1])
assert "a1" in interiors or "b1" in interiors or "c1" in interiors
def test_find_prefers_cn_eye_max_cover() -> None:
nodes, edges = _cn_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
units = find_dual_portal_units(st)
assert units
u0 = units[0]
assert {u0.portal_a, u0.portal_b} == {"cn1", "cn2"}
assert len(u0.member_ids()) >= 8
def test_layout_dual_unit_zero_crossings() -> None:
nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(st, LayoutParams())
assert op.params.get("accepted") is True
members = set()
unit = op.params.get("unit") or {}
members.update(unit.get("node_ids") or [])
unit_links = [e for e in op.state.links if e[0] in members and e[1] in members]
x = count_edge_crossings(op.state.positions, unit_links)
# Path-planar 3-corridor eye should stay clean under ellipse bands.
assert x == 0
assert op.params.get("zero_cross") is True
def test_petal_ellipse_hollow_axis_portals_only() -> None:
"""Shared portal-adj nodes stay off the mid-chord (CN gravity / hollow eye)."""
nodes = [
{"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "s", "name": "SHARED-AN", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "u1", "name": "U1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "u2", "name": "U2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d1", "name": "D1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d2", "name": "D2", "role": "en", "x": 0, "y": 0},
]
# Two corridors share first hop `s`: p1-s-u1-u2-p2 and p1-s-d1-d2-p2.
edges = [
{"a_node_id": "p1", "b_node_id": "s"},
{"a_node_id": "s", "b_node_id": "u1"},
{"a_node_id": "u1", "b_node_id": "u2"},
{"a_node_id": "u2", "b_node_id": "p2"},
{"a_node_id": "s", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "d2", "b_node_id": "p2"},
]
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
pos = op.state.positions
# Axis = portals only; shared AN sits on an arc band.
assert abs(pos["p1"][1]) < 1e-6 and abs(pos["p2"][1]) < 1e-6
assert abs(pos["s"][1]) > 50.0
assert pos["u1"][1] * pos["d1"][1] < 0
assert abs(pos["u1"][1]) > 200.0
assert abs(pos["d1"][1]) > 200.0
def test_short_corridor_an_not_on_mid_axis() -> None:
"""CN–AN–CN must not drop the AN onto (0,0) mid-chord."""
nodes = [
{"fabric_node_id": "cn1", "name": "CN1", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "cn2", "name": "CN2", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "an1", "name": "AN1", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "an2", "name": "AN2", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "E1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "E2", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "cn1", "b_node_id": "an1"},
{"a_node_id": "an1", "b_node_id": "cn2"},
{"a_node_id": "cn1", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "cn2"},
{"a_node_id": "cn1", "b_node_id": "an2"},
{"a_node_id": "an2", "b_node_id": "cn2"},
]
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(
st, LayoutParams(pitch=200.0, lane=300.0), portal_a="cn1", portal_b="cn2"
)
pos = op.state.positions
assert abs(pos["an1"][1]) > 50.0
assert abs(pos["an2"][1]) > 50.0
on_axis = {n for n, (_x, y) in pos.items() if abs(y) < 1e-6}
assert on_axis <= {"cn1", "cn2"}
# Hollow-ish: short ANs should not sit at exact mid vertical.
assert abs(pos["an1"][0]) > 1.0
assert abs(pos["an2"][0]) > 1.0
def test_long_tail_parks_outside_eye_rings() -> None:
"""Long deg≤2 tails must sit outside corridor envelope (no ring pierce)."""
nodes = [
{"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t0", "name": "T0", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t1", "name": "T1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t2", "name": "T2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t3", "name": "T3", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t4", "name": "T4", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
# Long tail hanging off upper corridor mid a1.
{"a_node_id": "a1", "b_node_id": "t0"},
{"a_node_id": "t0", "b_node_id": "t1"},
{"a_node_id": "t1", "b_node_id": "t2"},
{"a_node_id": "t2", "b_node_id": "t3"},
{"a_node_id": "t3", "b_node_id": "t4"},
]
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0))
pos = op.state.positions
corridor = {"p1", "p2", "a1", "a2", "b1", "b2"}
eye_ry = max(abs(pos[n][1]) for n in corridor if n in pos)
eye_rx = max(abs(pos[n][0]) for n in corridor if n in pos)
for tid in ("t0", "t1", "t2", "t3", "t4"):
x, y = pos[tid]
assert abs(x) >= eye_rx * 0.9 or abs(y) >= eye_ry * 0.9, (tid, x, y, eye_rx, eye_ry)
def test_layout_dual_unit_accepts_residual_cross_by_default() -> None:
"""With require_zero_cross=false (default), accepted stays true even if x>0."""
nodes, edges = _cn_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(
st,
LayoutParams(),
portal_a="cn1",
portal_b="cn2",
require_zero_cross=False,
)
assert op.params.get("accepted") is True
assert "unit_crossings" in op.params
# Hard gate still available for callers that want the old behavior.
op_hard = layout_dual_unit(
st,
LayoutParams(),
portal_a="cn1",
portal_b="cn2",
require_zero_cross=True,
)
if int(op_hard.params.get("unit_crossings") or 0) > 0:
assert op_hard.params.get("accepted") is False
else:
assert op_hard.params.get("accepted") is True
def test_structure_reports_dual_units() -> None:
nodes, edges = _eye_graph()
report = analyze_graph_structure(nodes, edges)
du = report.get("dual_units") or {}
assert int(du.get("unit_count") or 0) >= 1
def test_shared_portal_compose_preserves_relative() -> None:
a = ComposeBlock(
key="unit-p1-p2",
positions={
"p1": (0.0, 0.0),
"p2": (200.0, 0.0),
"a1": (100.0, 80.0),
},
)
b = ComposeBlock(
key="unit-p2-p3",
positions={
"p2": (0.0, 0.0),
"p3": (200.0, 0.0),
"b1": (100.0, -60.0),
},
)
merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
assert meta.get("merge_shared") is True
assert "p2" in merged and "p1" in merged and "p3" in merged
# Shared portal merge keeps both blocks; relative spans stay order-of-magnitude.
span_p = ((merged["p3"][0] - merged["p2"][0]) ** 2 + (merged["p3"][1] - merged["p2"][1]) ** 2) ** 0.5
span_b = ((merged["b1"][0] - merged["p2"][0]) ** 2 + (merged["b1"][1] - merged["p2"][1]) ** 2) ** 0.5
assert abs(span_p - 200.0) < 10.0
assert abs(span_b - ((100.0**2 + 60.0**2) ** 0.5)) < 10.0

View file

@ -0,0 +1,52 @@
"""edge_axis: prefer H/V edges, horizontal over vertical."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_axis
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
def test_all_horizontal_scores_one() -> None:
pos = {"a": (0.0, 0.0), "b": (200.0, 0.0), "c": (400.0, 2.0)} # 2px ≈ H
links = [("a", "b"), ("b", "c")]
m = compute_edge_axis(pos, links)
assert m["horiz_n"] == 2
assert m["diag_n"] == 0
assert float(m["edge_axis_score"]) == 1.0
def test_diagonal_scores_zero() -> None:
pos = {"a": (0.0, 0.0), "b": (100.0, 100.0)}
m = compute_edge_axis(pos, [("a", "b")])
assert m["diag_n"] == 1
assert float(m["edge_axis_score"]) == 0.0
assert m["top_skew_edges"]
def test_horizontal_beats_vertical() -> None:
h = compute_edge_axis(
{"a": (0.0, 0.0), "b": (200.0, 0.0)},
[("a", "b")],
)
v = compute_edge_axis(
{"a": (0.0, 0.0), "b": (0.0, 200.0)},
[("a", "b")],
)
assert float(h["edge_axis_score"]) > float(v["edge_axis_score"])
assert float(v["edge_axis_score"]) == 0.75
def test_analyze_report_includes_edge_axis() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 0.0},
{"fabric_node_id": "b", "name": "B", "x": 100.0, "y": 100.0},
{"fabric_node_id": "c", "name": "C", "x": 200.0, "y": 0.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
m = analyze_layout_stats(nodes, edges)
assert "edge_axis" in m["score"]["parts"]
assert m["report"]["edge_axis"]["status"] in {"warn", "fail"}
assert int(m["diag_n"] or 0) >= 1

View file

@ -0,0 +1,89 @@
"""Tests for fold_chain sector sweep + tentacle fold."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops.fold_chain import fold_chain_into_sector
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph
def _star_with_tentacle() -> tuple[list[dict], list[dict]]:
"""Hub H with ring of leaves + long deg2 tentacle S0..S4."""
nodes = [
{"fabric_node_id": "H", "name": "PLG-HUB-AN1-Z", "x": 0, "y": 0},
{"fabric_node_id": "A", "name": "PLG-AAA-EN1-Z", "x": 200, "y": 0},
{"fabric_node_id": "B", "name": "PLG-BBB-EN1-Z", "x": 0, "y": 200},
{"fabric_node_id": "C", "name": "PLG-CCC-EN1-Z", "x": -200, "y": 0},
{"fabric_node_id": "D", "name": "PLG-DDD-EN1-Z", "x": 0, "y": -200},
# tentacle stretched far east (pierces through ring)
{"fabric_node_id": "S0", "name": "PLG-S0-EN1-Z", "x": 80, "y": 0},
{"fabric_node_id": "S1", "name": "PLG-S1-EN1-Z", "x": 260, "y": 0},
{"fabric_node_id": "S2", "name": "PLG-S2-EN1-Z", "x": 440, "y": 0},
{"fabric_node_id": "S3", "name": "PLG-S3-EN1-Z", "x": 620, "y": 0},
{"fabric_node_id": "S4", "name": "PLG-S4-EN1-Z", "x": 800, "y": 0},
# filler leaf in south gap candidate
{"fabric_node_id": "E", "name": "PLG-EEE-EN1-Z", "x": 140, "y": 140},
{"fabric_node_id": "F", "name": "PLG-FFF-EN1-Z", "x": -140, "y": 140},
]
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": "D"},
{"a_node_id": "H", "b_node_id": "E"},
{"a_node_id": "H", "b_node_id": "F"},
{"a_node_id": "H", "b_node_id": "S0"},
{"a_node_id": "S0", "b_node_id": "S1"},
{"a_node_id": "S1", "b_node_id": "S2"},
{"a_node_id": "S2", "b_node_id": "S3"},
{"a_node_id": "S3", "b_node_id": "S4"},
]
return nodes, edges
def test_fold_chain_places_whole_chain_on_arc() -> None:
nodes, edges = _star_with_tentacle()
st = build_state_from_nodes_edges(nodes, edges)
op = fold_chain_into_sector(
st,
LayoutParams(target_nn=155.0),
hub_id="H",
stub_id="S0",
prefer_mid_deg=90.0,
r_arc=300.0,
chord=180.0,
)
meta = op.state.meta["fold_chain"]
assert meta["hub_id"] == "H"
assert meta["chain"] == ["S0", "S1", "S2", "S3", "S4"]
assert len(meta["folded"]) == 5
hx, hy = op.state.positions["H"]
radii = [
math.hypot(op.state.positions[n][0] - hx, op.state.positions[n][1] - hy)
for n in meta["chain"]
]
assert all(abs(r - 300.0) < 1.0 for r in radii)
# Chain should not remain as a straight east ray
assert op.state.positions["S4"][0] < 700
def test_fold_chain_action_unpublished() -> None:
nodes, edges = _star_with_tentacle()
try:
run_layout_on_graph(
nodes,
edges,
action="fold_chain",
params={"hub_id": "H", "stub_id": "S0", "prefer_mid_deg": 120},
)
raise AssertionError("fold_chain should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_catalog_omits_fold_chain() -> None:
cat = list_layout_catalog()
assert "fold_chain" not in cat["actions"]

View file

@ -0,0 +1,141 @@
"""Force densify: edge attract + local repulse + semi-rigid bodies."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops.force_densify import force_densify_round
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_tool import ACTIONS
def test_actions_include_force_densify() -> None:
assert "force_densify" not in ACTIONS
def _sparse_two_units():
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 4200, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 4400, "y": 80},
{"fabric_node_id": "b3", "name": "B3", "x": 4600, "y": -60},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2", "b3"]),
]
)
st.meta = {"compose_views": {"rigid_groups": groups}}
return st, groups
def test_force_densify_shrinks_bridge() -> None:
st, groups = _sparse_two_units()
L0 = math.hypot(
st.positions["b1"][0] - st.positions["a2"][0],
st.positions["b1"][1] - st.positions["a2"][1],
)
before = score_state(st, fast=True)
util0 = float((before.get("summary") or {}).get("util") or 0.0)
op = force_densify_round(
st,
groups=groups,
iters=16,
step=0.5,
max_step=280.0,
ideal_len=280.0,
nn_floor=90.0,
attract_k=1.4,
repulse_k=0.6,
gravity_k=0.9,
rigid_strength=0.95,
deform=0.05,
protect_rigid="off",
x_slack=500,
)
assert op.op == "force_densify"
assert not (op.params or {}).get("reverted"), op.note
L1 = math.hypot(
op.state.positions["b1"][0] - op.state.positions["a2"][0],
op.state.positions["b1"][1] - op.state.positions["a2"][1],
)
assert L1 < L0 * 0.92, f"bridge not shortened: {L0:.0f}->{L1:.0f}"
after = score_state(op.state, fast=True)
util1 = float((after.get("summary") or {}).get("util") or 0.0)
assert util1 >= util0 - 1e-6
def test_edge_spring_pushes_crushed_edge() -> None:
"""Too-short edge should lengthen (push), not only pull long ones."""
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 40, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 2000, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
L0 = math.hypot(
st.positions["b"][0] - st.positions["a"][0],
st.positions["b"][1] - st.positions["a"][1],
)
op = force_densify_round(
st,
groups=[],
iters=12,
step=0.45,
max_step=80.0,
ideal_len=160.0,
nn_floor=80.0,
attract_k=1.5,
repulse_k=0.4,
gravity_k=0.0,
rigid_strength=0.0,
deform=0.0,
protect_rigid="off",
x_slack=200,
)
L1 = math.hypot(
op.state.positions["b"][0] - op.state.positions["a"][0],
op.state.positions["b"][1] - op.state.positions["a"][1],
)
assert L1 > L0 + 15.0, f"crushed edge not pushed: {L0:.1f}->{L1:.1f}"
def test_semi_rigid_preserves_internal_ratios() -> None:
st, groups = _sparse_two_units()
def _pair(pos):
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
return d12 / max(d13, 1e-6)
r0 = _pair(st.positions)
op = force_densify_round(
st,
groups=groups,
iters=10,
rigid_strength=1.0,
deform=0.0,
ideal_len=180.0,
attract_k=1.2,
x_slack=500,
)
r1 = _pair(op.state.positions)
assert abs(r1 - r0) < 0.05, f"rigid shape drifted: {r0:.3f}->{r1:.3f}"

View file

@ -0,0 +1,123 @@
"""Tests for stage-2 hierarchy_sectors (contract → order → expand)."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.hierarchy import (
_candidate_orders,
_contracted_crossings,
_contracted_links,
_stub_territories,
hierarchy_sectors_greedy,
)
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def _crossed_star():
"""Hub with 4 stubs whose geo order crosses two inter-stub chords."""
# Hub at origin; stubs placed so angular order A,C,B,D crosses A-B with C-D.
nodes = [
{"fabric_node_id": "h", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "X-AN0-Y", "x": 200, "y": -40},
{"fabric_node_id": "b", "name": "X-AN1-Y", "x": 40, "y": 200},
{"fabric_node_id": "c", "name": "X-AN2-Y", "x": -200, "y": 40},
{"fabric_node_id": "d", "name": "X-AN3-Y", "x": -40, "y": -200},
{"fabric_node_id": "a1", "name": "X-EN0-Y", "x": 360, "y": -60},
{"fabric_node_id": "b1", "name": "X-EN1-Y", "x": 60, "y": 360},
{"fabric_node_id": "c1", "name": "X-EN2-Y", "x": -360, "y": 60},
{"fabric_node_id": "d1", "name": "X-EN3-Y", "x": -60, "y": -360},
]
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": "d"},
{"a_node_id": "a", "b_node_id": "a1"},
{"a_node_id": "b", "b_node_id": "b1"},
{"a_node_id": "c", "b_node_id": "c1"},
{"a_node_id": "d", "b_node_id": "d1"},
# Cross chords between territories (contracted as a↔b and c↔d or similar)
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
return nodes, edges
def test_actions_include_hierarchy_sectors() -> None:
assert "hierarchy_sectors" not in ACTIONS
def test_contracted_links_ignore_internal() -> None:
nodes, edges = _crossed_star()
st = build_state_from_nodes_edges(nodes, edges)
members = set(st.positions)
stubs, owner = _stub_territories("h", members, st.adj, pinned={"h"})
assert set(stubs) == {"a", "b", "c", "d"}
assert owner["a1"] == "a"
links = _contracted_links("h", stubs, owner, st.adj, pinned={"h"})
# Internal a—a1 must not appear; hub—stub and inter-stub do.
assert ("a", "a1") not in links
assert ("a", "h") in links
assert ("a", "b") in links
def test_candidate_orders_cover_rotations() -> None:
stubs = ["a", "b", "c", "d"]
pos = {
"h": (0.0, 0.0),
"a": (1.0, 0.0),
"b": (0.0, 1.0),
"c": (-1.0, 0.0),
"d": (0.0, -1.0),
}
orders = _candidate_orders(stubs, pos, "h")
assert len(orders) >= 4
assert any(o[0] == "a" for o in orders)
def test_contracted_crossings_sensitive_to_order() -> None:
links = [("a", "h"), ("b", "h"), ("c", "h"), ("d", "h"), ("a", "b"), ("c", "d")]
good = _contracted_crossings(
"h",
["a", "b", "c", "d"],
links,
radius=100.0,
hub_xy=(0.0, 0.0),
a0=-3.0,
a1=3.0,
)
# Force a bad interleaved order on the same arc.
bad = _contracted_crossings(
"h",
["a", "c", "b", "d"],
links,
radius=100.0,
hub_xy=(0.0, 0.0),
a0=-3.0,
a1=3.0,
)
assert bad >= good
def test_hierarchy_sectors_accepts_on_contracted_gain() -> None:
nodes, edges = _crossed_star()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = hierarchy_sectors_greedy(st, LayoutParams())
assert op.op == "hierarchy_sectors"
assert "accepted_n" in op.params
# Gate is contracted, not global: if a level was accepted, contracted fell.
for lvl in op.params.get("levels") or []:
assert lvl["contracted_after"] < lvl["contracted_before"]
# Smoke: crossings countable after expand.
assert count_edge_crossings(op.state.positions, op.state.links) >= 0
def test_run_layout_on_graph_hierarchy_sectors_unpublished() -> None:
nodes, edges = _crossed_star()
try:
run_layout_on_graph(nodes, edges, action="hierarchy_sectors")
raise AssertionError("hierarchy_sectors should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,314 @@
"""Local hotspot / overlap fix tests."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
fix_overlaps_local,
relax_hotspots,
)
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph
def test_fix_overlaps_local_pulls_apart() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 3, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 2000, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
assert len(overlapping_nodes(st)) >= 2
far_before = st.positions["c"]
out = fix_overlaps_local(st, LayoutParams(overlap_iters=200, overlap_step=5.0))
assert len(overlapping_nodes(out.state)) == 0
# distant node should barely move (outside 1-hop of a-b... c is 1-hop from b)
# c may move if in expand; at least a/b separated
assert abs(out.state.positions["a"][0] - out.state.positions["b"][0]) > 10
def test_run_layout_action_pack_utilization_unpublished() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="pack_utilization",
preset="balanced",
params={
"target_util": 0.15,
"pack_min_scale": 0.2,
"pack_iters": 6,
"shrink_corridors": True,
"corridor_cap": 2000,
"pull": 0.6,
},
)
raise AssertionError("pack_utilization should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_pack_utilization_transform_still_works() -> None:
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.layout_ops.transforms import pack_utilization
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
st = build_state_from_nodes_edges(nodes, edges)
op = pack_utilization(
st,
LayoutParams(target_util=0.15, pack_min_scale=0.2, pack_iters=6),
)
assert op.op == "pack_utilization"
xs = [p[0] for p in op.state.positions.values()]
ys = [p[1] for p in op.state.positions.values()]
assert max(xs) - min(xs) < 16000
assert max(ys) - min(ys) < 8000
local = {"mode": (op.params or {}).get("mode"), "op": op.params or {}}
op = local.get("op") or {}
# Corridor shrink reports bbox_area_ratio; uniform reports util_after.
if op.get("bbox_area_ratio") is not None:
assert float(op["bbox_area_ratio"]) >= 1.0
else:
assert float(op.get("util_after") or 0) > float(op.get("util_before") or 0)
def test_shrink_long_corridors_pulls_islands() -> None:
from netx_api.mcp_remote.topology.layout_ops.rigid_units import shrink_long_corridors
# Two dense islands connected by one long bridge.
nodes = [
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 8000, "y": 8000},
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 8200, "y": 8000},
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 8000, "y": 8200},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a1", "b_node_id": "a3"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
{"a_node_id": "a1", "b_node_id": "b1"}, # long corridor
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
d0 = math.hypot(
st.positions["a1"][0] - st.positions["b1"][0],
st.positions["a1"][1] - st.positions["b1"][1],
)
out = shrink_long_corridors(
st,
edge_len_cap=1200,
pull=0.6,
iters=3,
min_island=3,
max_bridges=4,
accept_crossings=False,
)
d1 = math.hypot(
out.state.positions["a1"][0] - out.state.positions["b1"][0],
out.state.positions["a1"][1] - out.state.positions["b1"][1],
)
assert d1 < d0 * 0.55
# Intra-island spacing preserved.
assert abs(
math.hypot(
out.state.positions["a1"][0] - out.state.positions["a2"][0],
out.state.positions["a1"][1] - out.state.positions["a2"][1],
)
- 200.0
) < 1.0
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.5
def test_shrink_long_corridors_unit_exclusive() -> None:
from netx_api.mcp_remote.topology.layout_ops.rigid_units import shrink_long_corridors
# Shared portal + two exclusive clusters far apart (metro dual-unit shape).
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 10000, "y": 10000},
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 10200, "y": 10000},
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 10000, "y": 10200},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a1", "b_node_id": "a3"},
{"a_node_id": "p", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
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": "u0", "node_ids": ["p", "a1", "a2", "a3"], "pivots": ["p"]},
{"key": "u1", "node_ids": ["p", "b1", "b2", "b3"], "pivots": ["p"]},
]
d0 = math.hypot(
st.positions["a1"][0] - st.positions["b1"][0],
st.positions["a1"][1] - st.positions["b1"][1],
)
out = shrink_long_corridors(
st,
edge_len_cap=1500,
pull=0.55,
iters=4,
min_island=3,
max_bridges=4,
groups=groups,
accept_crossings=True,
)
d1 = math.hypot(
out.state.positions["a1"][0] - out.state.positions["b1"][0],
out.state.positions["a1"][1] - out.state.positions["b1"][1],
)
assert (out.params or {}).get("island_mode") == "unit_exclusive"
assert d1 < d0 * 0.75
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.2
# Portal may move less / stay; exclusives keep relative spacing.
assert abs(
math.hypot(
out.state.positions["a1"][0] - out.state.positions["a2"][0],
out.state.positions["a1"][1] - out.state.positions["a2"][1],
)
- 200.0
) < 1.0
def test_densify_rigid_groups_raises_util() -> None:
from netx_api.mcp_remote.topology.layout_ops.rigid_units import densify_rigid_groups
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 10000, "y": 10000},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 10200, "y": 10000},
]
edges = [
{"a_node_id": "p", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "p", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
]
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": "u0", "node_ids": ["p", "a", "b"], "pivots": ["p"]},
{"key": "u1", "node_ids": ["p", "c", "d"], "pivots": ["p"]},
]
xs0 = [st.positions[n][0] for n in st.positions]
ys0 = [st.positions[n][1] for n in st.positions]
area0 = (max(xs0) - min(xs0)) * (max(ys0) - min(ys0))
out = densify_rigid_groups(st, groups, scale=0.5)
xs1 = [out.state.positions[n][0] for n in out.state.positions]
ys1 = [out.state.positions[n][1] for n in out.state.positions]
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
assert area1 < area0 * 0.85
# Intra-unit gap preserved (a-b still ~200).
assert abs(
math.hypot(
out.state.positions["a"][0] - out.state.positions["b"][0],
out.state.positions["a"][1] - out.state.positions["b"][1],
)
- 200.0
) < 1.0
def test_run_layout_pack_compress_long_edges_unpublished() -> None:
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 5000, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 0, "y": 5000},
]
edges = [
{"a_node_id": "p", "b_node_id": "a"},
{"a_node_id": "p", "b_node_id": "b"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="pack_utilization",
preset="balanced",
params={
"portal_ids": ["p"],
"shrink_corridors": True,
"corridor_cap": 900,
"pull": 0.6,
"compress_iters": 4,
},
)
raise AssertionError("pack_utilization should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_layout_action_fix_overlaps() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 2, "y": 1},
{"fabric_node_id": "c", "name": "CCCCCC-AN-1", "x": 400, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
out = run_layout_on_graph(nodes, edges, action="fix_overlaps", preset="balanced")
assert out["ok"] is True
assert out["action"] == "fix_overlaps"
assert out["overlap"]["footprint_pairs"] == 0
# Should not rebuild corridor skeleton to origin-scale extremes
xs = [p["x"] for p in out["positions"]]
assert max(xs) - min(xs) < 5000
def test_relax_hotspots_moves_only_dense() -> None:
# 6 coinciding + 1 disconnected far island
nodes = [
{"fabric_node_id": f"n{i}", "name": f"N{i}-EN-x", "x": float(i % 2), "y": 0.0}
for i in range(6)
]
nodes.append({"fabric_node_id": "far", "name": "FAR-EN-z", "x": 8000.0, "y": 0.0})
nodes.append({"fabric_node_id": "far2", "name": "FAR2-EN-z", "x": 8200.0, "y": 0.0})
edges = [{"a_node_id": f"n{i}", "b_node_id": f"n{i+1}"} for i in range(5)]
edges.append({"a_node_id": "far", "b_node_id": "far2"})
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
far0 = st.positions["far"]
out = relax_hotspots(st, LayoutParams(cluster_gap=40.0, target_util=0.1))
# disconnected far island should stay put (not in dense hotspot)
assert abs(out.state.positions["far"][0] - far0[0]) < 1.0

View file

@ -0,0 +1,262 @@
"""Background layout jobs + slim payload helpers."""
from __future__ import annotations
import time
from unittest.mock import patch
from netx_api.mcp_remote.topology.http_tools import _layout_topology_view, _slim_layout_payload
from netx_api.mcp_remote.topology.layout_jobs import (
cancel_job,
get_job,
is_cancelled,
job_public,
report_progress,
start_job,
)
def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None:
from netx_api.mcp_remote.topology import layout_jobs as lj
lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()})
lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"] == "backend_write_failed"
assert out["result"]["applied"] is False
def test_spawn_failure_is_persisted_as_terminal_error() -> None:
from netx_api.mcp_remote.topology import layout_jobs as lj
with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"].startswith("worker_start_failed:")
assert not lj._args_path(jid).exists()
def test_fast_worker_terminal_progress_is_not_overwritten() -> None:
from types import SimpleNamespace
from netx_api.mcp_remote.topology import layout_jobs as lj
def spawn(argv, **kwargs):
jid = argv[-1]
lj.finish_job(jid, result={"ok": True, "applied": True})
return SimpleNamespace(pid=123)
with patch.object(lj.subprocess, "Popen", side_effect=spawn):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
out = lj.job_public(jid)
assert out["status"] == "done"
assert out["progress"]["phase"] == "done"
assert out["progress"]["pct"] == 100.0
def test_pruning_preserves_active_jobs() -> None:
from netx_api.mcp_remote.topology import layout_jobs as lj
lj._write_job_disk({"job_id": "active", "status": "running"})
lj._write_job_disk({"job_id": "completed", "status": "done"})
with patch.object(lj, "_MAX_JOBS", 1):
lj._prune_disk()
assert lj._read_job_disk("active") is not None
assert lj._read_job_disk("completed") is None
def test_start_job_and_poll() -> None:
def runner() -> dict:
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
time.sleep(0.05)
return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3}
jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1})
assert jid
saw_progress = False
for _ in range(40):
pub = job_public(jid)
assert pub is not None
assert "elapsed_ms" in pub
assert "progress" in pub
assert "heartbeat_age_ms" in pub
if (pub.get("progress") or {}).get("phase") == "mid":
saw_progress = True
if pub["status"] != "running":
break
time.sleep(0.05)
assert saw_progress
job = get_job(jid)
assert job is not None
assert job["status"] == "done"
assert job["result"]["applied"] is True
st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert st.get("ok") is True
assert st.get("status") == "done"
assert st.get("result", {}).get("applied") is True
assert isinstance(st.get("progress"), dict)
def test_cancel_job_cooperative() -> None:
started = time.time()
def runner() -> dict:
report_progress("work", pct=10.0, message="before sleep")
for _ in range(40):
if is_cancelled():
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled
raise_if_cancelled()
time.sleep(0.05)
return {"ok": True, "applied": True}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
time.sleep(0.08)
out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}})
assert out.get("ok") is True
assert out.get("cancel_requested") is True
for _ in range(40):
pub = job_public(jid)
assert pub is not None
if pub["status"] == "cancelled":
break
time.sleep(0.05)
pub = job_public(jid)
assert pub is not None
assert pub["status"] == "cancelled"
assert time.time() - started < 3.0
def test_slim_layout_payload_drops_guide() -> None:
fat = {
"ok": True,
"action": "compose_views",
"node_count": 1200,
"guide": {"how_to_read": "x" * 200},
"tried": [1, 2, 3],
"params_used": {"lane": 1},
"crossing": {
"status": "fail",
"score": 0,
"edge_crossings": 9,
"crossings_per_link": 0.1,
"top_nodes": [{"id": "a"}] * 20,
"top_edges": [{"id": "e"}] * 20,
"tip": "long",
},
"summary": {"total": 40},
}
slim = _slim_layout_payload(fat)
assert slim.get("slim") is True
assert "guide" not in slim
assert "tried" not in slim
assert len(slim["crossing"]["top_nodes"]) <= 5
def test_compose_views_is_not_public() -> None:
# compose_* was retired from the public surface (dual-unit drain is the main path).
out = _layout_topology_view(
{
"view_id": "full",
"action": "compose_views",
"mode": "apply",
"params": {"source_view_ids": [f"s{i:02d}" for i in range(12)]},
}
)
assert out.get("ok") is False
assert out.get("error") == "unknown_action:compose_views"
def test_orbit_background_returns_job_id() -> None:
graph = {
"ok": True,
"nodes": [
{"fabric_node_id": f"n{i}", "x": float(i * 100), "y": 0.0, "label": f"N{i}"}
for i in range(6)
],
"edges": [
{"id": f"e{i}", "a_node_id": f"n{i}", "b_node_id": f"n{i + 1}"} for i in range(5)
],
}
def fake_start(**kwargs):
assert kwargs["action"] == "orbit_sweep"
assert kwargs["view_id"] == "full"
tool_args = kwargs.get("tool_args")
assert isinstance(tool_args, dict)
# The worker re-runs the same call synchronously.
assert tool_args["params"].get("_force_sync") is True
assert "background" not in tool_args["params"]
return "abc123job"
with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}), patch(
"netx_api.mcp_remote.topology.http_tools.start_job", side_effect=fake_start
):
out = _layout_topology_view(
{
"view_id": "full",
"action": "orbit_sweep",
"mode": "apply",
"params": {"background": True},
}
)
assert out.get("ok") is True, out
assert out.get("status") == "running"
assert out.get("job_id") == "abc123job"
assert out.get("applied") is False
def test_job_status_missing() -> None:
out = _layout_topology_view({"action": "job_status", "params": {}})
assert out.get("ok") is False
assert out.get("error") == "job_id_required"
def test_job_status_unknown() -> None:
out = _layout_topology_view(
{"action": "job_status", "params": {"job_id": "does-not-exist"}}
)
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_cancel_missing() -> None:
out = cancel_job("missing-job")
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_soft_stale_keeps_running_and_cancelable() -> None:
"""stale is a poll warning only -?must not freeze status or block cancel."""
from netx_api.mcp_remote.topology import layout_jobs as lj
def runner() -> dict:
time.sleep(0.3)
return {"ok": True, "applied": False}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
with lj._LOCK: # noqa: SLF001 -?test injects silent heartbeat on disk
job = lj._read_job_disk(jid) or lj._JOBS[jid]
job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5)
lj._JOBS[jid] = job
lj._write_job_disk(job)
pub = job_public(jid)
assert pub is not None
assert pub["stale"] is True
assert pub["status"] == "running"
cancelled = cancel_job(jid)
assert cancelled.get("ok") is True
assert cancelled.get("status") == "cancelling"
for _ in range(40):
pub2 = job_public(jid)
assert pub2 is not None
if pub2["status"] in {"cancelled", "done", "error"}:
break
time.sleep(0.05)
assert job_public(jid)["status"] == "cancelled"

View file

@ -0,0 +1,525 @@
"""Unit tests for composable layout_ops atoms."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_metrics import grade_layout
from netx_api.mcp_remote.topology.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
run_recipe,
score_state,
)
from netx_api.mcp_remote.topology.layout_ops.transforms import (
resolve_overlaps,
scale_region,
select_pins,
)
def _line_graph(n: int = 6, gap: float = 40.0):
nodes = []
edges = []
for i in range(n):
nodes.append(
{
"fabric_node_id": f"n{i}",
"name": f"X-EN{i}-Y",
"x": i * gap,
"y": 0.0,
}
)
if i:
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
return nodes, edges
def test_scale_region_only_unpinned() -> None:
nodes, edges = _line_graph(4, gap=100.0)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.pinned = {"n0", "n1"}
before = dict(st.positions)
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=True)
assert out.state.positions["n0"] == before["n0"]
assert out.state.positions["n1"] == before["n1"]
assert out.state.positions["n2"] != before["n2"]
assert "n2" in out.moved
def test_resolve_overlaps_clears_stack() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC", "x": 200, "y": 200},
]
edges = [{"a_node_id": "a", "b_node_id": "c"}]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.pinned = {"c"}
m0 = score_state(st)
assert m0["footprint_overlap_pairs"] >= 1
out = resolve_overlaps(st, LayoutParams(overlap_iters=120, overlap_step=12.0))
m1 = score_state(out.state)
assert m1["footprint_overlap_pairs"] == 0
assert out.state.positions["c"] == st.positions["c"]
def test_select_pins_modes() -> None:
nodes, edges = _line_graph(8)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.layers = {n["fabric_node_id"]: "access" for n in nodes}
st.layers["n0"] = "agg"
st.spine = {"n1", "n2", "n3"}
r = select_pins(st, mode="spine")
assert "n0" in r.state.pinned
assert "n1" in r.state.pinned
def test_grade_hard_zero_overlap() -> None:
m = {
"node_count": 10,
"link_count": 9,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 2,
"label_overlap_pairs": 0,
"nn_p50": 160,
"space_utilization": 0.2,
}
g = grade_layout(m)
assert g["spacing_grade"] == "fail"
g2 = grade_layout(m, ume_reference=True)
assert g2["spacing_grade"] in {"ok", "warn", "fail"}
def test_select_scope_limits_moves() -> None:
from netx_api.mcp_remote.topology.layout_ops.scope import select_scope
from netx_api.mcp_remote.topology.layout_ops.transforms import scale_region
nodes, edges = _line_graph(6, gap=100.0)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st = select_scope(st, mode="ids", node_ids={"n0", "n1", "n2"}).state
before = dict(st.positions)
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=False)
assert out.state.positions["n5"] == before["n5"]
assert out.state.positions["n0"] != before["n0"]
def test_explode_clusters_breaks_stack() -> None:
nodes = [
{"fabric_node_id": "a", "name": "X-EN0-A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "X-EN1-B", "x": 1, "y": 0},
{"fabric_node_id": "c", "name": "X-EN2-C", "x": 400, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
from netx_api.mcp_remote.topology.layout_ops.transforms import explode_clusters
out = explode_clusters(st, LayoutParams(), thr=5.0, gap=50.0)
d = math.hypot(
out.state.positions["a"][0] - out.state.positions["b"][0],
out.state.positions["a"][1] - out.state.positions["b"][1],
)
assert d >= 40.0
def test_place_on_rect_edge_axis_aligned() -> None:
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _place_on_rect_edge
order = [f"e{i}" for i in range(8)]
pos = _place_on_rect_edge(order, 0.0, 0.0, 400.0, 200.0)
assert len(pos) == 8
for x, y in pos.values():
on_h = (abs(y - 0.0) < 1e-6 or abs(y - 200.0) < 1e-6) and -1e-6 <= x <= 400.0 + 1e-6
on_v = (abs(x - 0.0) < 1e-6 or abs(x - 400.0) < 1e-6) and -1e-6 <= y <= 200.0 + 1e-6
assert on_h or on_v
def test_extract_dangling_feeder_leaf_to_attach() -> None:
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _extract_dangling_feeders
# Leaf e4 → … → attach e0 (on ring/hub). Not a ring corridor.
ens = [f"e{i}" for i in range(5)]
adj = {
"e0": {"e1", "r1", "r2"},
"e1": {"e0", "e2"},
"e2": {"e1", "e3"},
"e3": {"e2", "e4"},
"e4": {"e3"},
"r1": {"e0", "r2"},
"r2": {"e0", "r1"},
}
names = {n: n for n in adj}
chains = _extract_dangling_feeders(
ens + ["r1", "r2"], adj, names, attach_set={"e0", "r1", "r2"}, min_len=2
)
assert chains
long = max(chains, key=len)
assert long[0] == "e4"
assert long[-1] == "e0"
assert long == ["e4", "e3", "e2", "e1", "e0"]
def test_eject_portal_neighbor_outside_ring() -> None:
"""PNBR-like stub on a portal must leave the ring interior."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _eject_intruders_from_rings
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
# Trapezoid ring a—b with bottom corridor; stub s attached to a sits inside.
groups = [
{
"portals": ("a", "b"),
"paths": [["a", "b"], ["a", "p1", "p2", "b"]],
}
]
pos = {
"a": (0.0, 0.0),
"b": (400.0, 0.0),
"p1": (100.0, 150.0),
"p2": (300.0, 150.0),
"s": (120.0, 40.0), # inside polygon
}
adj = {"a": {"b", "p1", "s"}, "b": {"a", "p2"}, "p1": {"a", "p2"}, "p2": {"p1", "b"}, "s": {"a"}}
edges = [("a", "b"), ("a", "p1"), ("p1", "p2"), ("p2", "b"), ("a", "s")]
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
# Outside: left of portal a (outer side away from b).
assert out["s"][0] < out["a"][0]
def test_triangle_ring_stub_must_not_pierce_chord() -> None:
"""Minimal triangle ring: portal stub edge must not cross the third side."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import (
_eject_intruders_from_rings,
_stub_crosses_ring,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
# Triangle a-v-b with direct a—b; stub s on b pierces a—v.
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
pos = {
"a": (0.0, 0.0),
"b": (300.0, 0.0),
"v": (150.0, 120.0),
"s": (50.0, 80.0), # s—b crosses a—v
"out": (-200.0, 0.0),
}
adj = {
"a": {"b", "v"},
"b": {"a", "v", "s"},
"v": {"a", "b"},
"s": {"b", "out"},
"out": {"s"},
}
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("b", "s"), ("s", "out")]
assert _stub_crosses_ring("s", "b", pos, ["a", "b", "v"])
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
assert not _stub_crosses_ring("s", "b", out, ["a", "b", "v"])
def test_triangle_apex_flips_off_foreign_chord() -> None:
"""VOTI-like apex must sit on the side that foreign portal edges do not hit."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _orient_ring_sides
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
# Foreign f—b runs under the chord; apex v below is pierced / crossed.
pos = {
"a": (0.0, 0.0),
"b": (300.0, 0.0),
"v": (150.0, 120.0),
"f": (-100.0, 60.0),
}
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("f", "b")]
assert segments_properly_intersect(pos["a"], pos["v"], pos["f"], pos["b"])
out = _orient_ring_sides(
pos, groups, edges, pinned={"a", "b", "v"}, max_interiors=3, push=40.0
)
assert out["v"][1] < 0.0 # flipped above the chord
assert not segments_properly_intersect(out["a"], out["v"], out["f"], out["b"])
def test_eject_one_ring_must_not_pierce_another() -> None:
"""Ejecting from a large ring must not park the stub through a triangle."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import (
_eject_intruders_from_rings,
_stub_crosses_ring,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
# Big ring a—b via p1-p2; triangle a-v-b; stub s inside big ring.
groups = [
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "p1", "p2", "b"]]},
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]},
]
pos = {
"a": (0.0, 0.0),
"b": (400.0, 0.0),
"p1": (80.0, 200.0),
"p2": (320.0, 200.0),
"v": (200.0, -120.0),
"s": (120.0, 40.0), # inside big ring; naive eject can cross a—v
}
adj = {
"a": {"b", "p1", "v", "s"},
"b": {"a", "p2", "v"},
"p1": {"a", "p2"},
"p2": {"p1", "b"},
"v": {"a", "b"},
"s": {"a"},
}
edges = [
("a", "b"),
("a", "p1"),
("p1", "p2"),
("p2", "b"),
("a", "v"),
("v", "b"),
("a", "s"),
]
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "v"])
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "p2", "p1"])
def test_an_side_ring_not_mistaken_for_feeder() -> None:
"""AN—EN ring leg must be a ring unit, not a dangling feeder (no X with chain)."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import (
_extract_dangling_feeders,
_find_two_portal_ring_groups,
_ring_nodes_from_groups,
)
# SPB(AN)-TNM direct + SPB-SRIN-ADAK-JROS-TNM; chain TNM-TNMS-SMDA
nodes = {
"spb": "X-AN-SPB",
"tnm": "X-EN-TNM",
"tnms": "X-EN-TNMS",
"smda": "X-EN-SMDA",
"jros": "X-EN-JROS",
"adak": "X-EN-ADAK",
"srin": "X-EN-SRIN",
}
adj = {
"spb": {"tnm", "srin", "other"},
"tnm": {"spb", "tnms", "jros"},
"tnms": {"tnm", "smda"},
"smda": {"tnms"},
"jros": {"tnm", "adak"},
"adak": {"jros", "srin"},
"srin": {"adak", "spb"},
"other": {"spb"},
}
ens = ["tnm", "tnms", "smda", "jros", "adak", "srin"]
an_set = {"spb"}
groups = _find_two_portal_ring_groups(ens, adj, nodes, an_set)
assert groups
ring = _ring_nodes_from_groups(groups)
assert {"tnm", "jros", "adak", "srin", "spb"} <= ring
attach = ring | an_set
feeders = _extract_dangling_feeders(
ens, adj, nodes, attach, min_len=2, an_set=an_set
)
bodies = {n for f in feeders for n in f[:-1]}
# Ring leg must not be consumed as a feeder body.
assert not ({"jros", "adak", "srin"} & bodies)
# True dangling chain off TNM remains.
assert any(f[0] == "smda" or "smda" in f for f in feeders)
def test_two_portal_rings_nest_smallest_inner() -> None:
"""Shared portals: shortest corridor innermost, longer outward."""
from netx_api.mcp_remote.topology.layout_ops.sugiyama import (
_find_two_portal_ring_groups,
_place_two_portal_ring_groups,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
# Portals p1,p2 with three disjoint corridors (sizes 1 / 2 / 3 mids).
ens = ["p1", "p2", "a1", "b1", "b2", "c1", "c2", "c3"]
adj = {
"p1": {"a1", "b1", "c1", "an"},
"p2": {"a1", "b2", "c3", "x"},
"a1": {"p1", "p2"},
"b1": {"p1", "b2"},
"b2": {"b1", "p2"},
"c1": {"p1", "c2"},
"c2": {"c1", "c3"},
"c3": {"c2", "p2"},
"an": {"p1"},
"x": {"p2"},
}
names = {n: n for n in list(adj)}
groups = _find_two_portal_ring_groups(ens, adj, names, {"an"})
assert groups
g0 = next(g for g in groups if set(g["portals"]) == {"p1", "p2"})
paths = g0["paths"]
assert len(paths) >= 2
# Shortest path first.
assert all(len(paths[i]) <= len(paths[i + 1]) for i in range(len(paths) - 1))
pos = {
"p1": (0.0, 0.0),
"p2": (600.0, 0.0),
"a1": (300.0, 10.0),
"b1": (200.0, 20.0),
"b2": (400.0, 20.0),
"c1": (150.0, 30.0),
"c2": (300.0, 30.0),
"c3": (450.0, 30.0),
}
edges = [("p1", "a1"), ("a1", "p2"), ("p1", "b1"), ("b1", "b2"), ("b2", "p2"),
("p1", "c1"), ("c1", "c2"), ("c2", "c3"), ("c3", "p2")]
out, pinned = _place_two_portal_ring_groups(
pos, ens, adj, names, {"an"}, LayoutParams(), edges
)
assert pinned
# Trapezoid nest: shortest path closer to midline; outer band wider.
my = (out["p1"][1] + out["p2"][1]) / 2
assert abs(out["a1"][1] - my) <= abs(out["c2"][1] - my) + 1e-6
outer_span = abs(out["c3"][0] - out["c1"][0])
mid_span = abs(out["b2"][0] - out["b1"][0])
assert outer_span + 1e-6 >= mid_span
def test_chain_first_keeps_feeder_sequential() -> None:
"""Pure feeder chain stays a coherent polyline after rings recipe."""
nodes = [{"fabric_node_id": "an", "name": "X-AN1-Y"}]
edges = []
for i in range(5):
nodes.append({"fabric_node_id": f"e{i}", "name": f"X-EN{i}-Y"})
edges.append({"a_node_id": "an", "b_node_id": "e0"})
for i in range(4):
edges.append({"a_node_id": f"e{i}", "b_node_id": f"e{i+1}"})
st = build_state_from_nodes_edges(nodes, edges)
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
assert len(st2.positions) == 6
# Consecutive chain edges should stay roughly axis-aligned (not hop-scrambled).
axis = 0
for i in range(4):
a, b = st2.positions[f"e{i}"], st2.positions[f"e{i+1}"]
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
if dx < 1e-6 or dy < 1e-6:
axis += 1
assert axis >= 3
assert int(final.get("edge_crossings") or 0) == 0
def test_agg_rings_recipe_places_dual_agg_ring() -> None:
# Two ANs + 4-EN ring + one chain stub → dual-hub min-rings
nodes = [
{"fabric_node_id": "an1", "name": "X-AN1-Y"},
{"fabric_node_id": "an2", "name": "X-AN2-Y"},
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
{"fabric_node_id": "e3", "name": "X-EN3-Y"},
{"fabric_node_id": "e4", "name": "X-EN4-Y"},
]
edges = [
{"a_node_id": "an1", "b_node_id": "an2"},
{"a_node_id": "an1", "b_node_id": "e0"},
{"a_node_id": "an2", "b_node_id": "e2"},
{"a_node_id": "e0", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "e3"},
{"a_node_id": "e3", "b_node_id": "e0"},
{"a_node_id": "e1", "b_node_id": "e4"},
]
st = build_state_from_nodes_edges(nodes, edges)
params = LayoutParams()
st2, trace, final = run_recipe(st, "agg_rings_v1", params)
assert any(t.get("op") == "build_ring_skeleton" for t in trace)
assert st2.meta.get("rings_mode") == "min_rings"
assert len(st2.positions) == 7
# Dual hubs on a horizontal bar; corridors nest above/below
assert abs(st2.positions["an1"][1] - st2.positions["an2"][1]) < 1e-6
assert st2.positions["an1"][0] < st2.positions["an2"][0]
assert "edge_crossings" in final
assert int(final.get("edge_crossings") or 0) == 0
assert int(final.get("footprint_overlap_pairs") or 0) == 0
def test_min_rings_dual_hub_parallel_paths_zero_cross() -> None:
"""Classic dual-AN parallel corridors + side stubs → 0 crossings via rings."""
# anL / anR with 3 disjoint corridors + left stub chain off anL
nodes = [
{"fabric_node_id": "anL", "name": "Z-PLAU-AN1-X"},
{"fabric_node_id": "anR", "name": "Z-ATP-AN1-X"},
{"fabric_node_id": "orphan", "name": "Z-BSR-AN1-X"},
]
edges = []
# short upper: anL-a1-a2-anR
for i, nid in enumerate(["a1", "a2"]):
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "anR"},
]
# mid lower: anL-b1-b2-b3-anR
for nid in ["b1", "b2", "b3"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "b3"},
{"a_node_id": "b3", "b_node_id": "anR"},
]
# long upper-outer: anL-c1..c4-anR
for nid in ["c1", "c2", "c3", "c4"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "c3"},
{"a_node_id": "c3", "b_node_id": "c4"},
{"a_node_id": "c4", "b_node_id": "anR"},
]
# side stub off anL
for nid in ["s1", "s2"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "s1"},
{"a_node_id": "s1", "b_node_id": "s2"},
]
st = build_state_from_nodes_edges(nodes, edges)
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
assert st2.meta.get("rings_mode") == "min_rings"
assert int(final.get("edge_crossings") or 0) == 0
assert int(final.get("footprint_overlap_pairs") or 0) == 0
# orphan AN parked, not on the bar mid
assert "orphan" in st2.positions
assert abs(st2.positions["anL"][1] - st2.positions["anR"][1]) < 1e-6
def test_tiny_recipe_runs() -> None:
# Mini AN+EN star for skeleton path
nodes = [
{"fabric_node_id": "an", "name": "X-AN1-Y"},
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
]
edges = [
{"a_node_id": "an", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
]
st = build_state_from_nodes_edges(nodes, edges)
params = LayoutParams(width_mul=2.0, height_mul=1.5, lane=120.0, target_util=0.05)
st2, trace, final = run_recipe(st, "smd_corridor_v1", params)
assert len(trace) >= 5
assert len(st2.positions) == 4
assert "edge_crossings" in final
assert "space_utilization" in final
# Zero-overlap is a tuning target of resolve_overlaps; recipe must complete.
assert all("op" in t for t in trace)

View file

@ -0,0 +1,34 @@
"""Tests for agent layout sight (crossing hotspots / blocks)."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_sight import build_sight, list_crossings
def test_list_crossings_and_drag_candidates() -> None:
# Two segments that properly cross: a—b horizontal, c—d vertical.
nodes = [
{"fabric_node_id": "a", "name": "X-A-Y", "x": 0.0, "y": 50.0},
{"fabric_node_id": "b", "name": "X-B-Y", "x": 100.0, "y": 50.0},
{"fabric_node_id": "c", "name": "X-C-Y", "x": 50.0, "y": 0.0},
{"fabric_node_id": "d", "name": "X-D-Y", "x": 50.0, "y": 100.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
out = list_crossings(nodes, edges, limit=10)
assert out["crossings_listed"] == 1
assert out["crossings"][0]["e1"]["a_name"] == "A"
assert len(out.get("top_nodes") or []) == 4
assert out["top_nodes"][0]["crossing_hits"] >= 1
assert len(out.get("top_edges") or []) == 2
assert out["top_edges"][0]["crossing_hits"] >= 1
assert out["drag_candidates"]
cand = out["drag_candidates"][0]
assert "suggest_xy" in cand and len(cand["suggest_xy"]) >= 1
assert "delta_crossings_est" in cand
assert "x" in cand["suggest_xy"][0] and "delta_crossings_est" in cand["suggest_xy"][0]
sight = build_sight(nodes, edges, mode="both", limit=10, cell=200.0)
assert "hotspots" in sight and "blocks" in sight
assert sight["blocks"]["blocks"]

View file

@ -0,0 +1,132 @@
"""Tests for topology layout statistics / composite score."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_stats import (
analyze_layout_stats,
build_layout_report,
score_layout_components,
)
def test_dense_vs_sparse_score() -> None:
# Compact 2x2 grid at recommended pitch
dense_nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
]
dense = analyze_layout_stats(dense_nodes, edges)
sparse_nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 20000, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 17000},
{"fabric_node_id": "d", "name": "D", "x": 20000, "y": 17000},
]
sparse = analyze_layout_stats(sparse_nodes, edges)
assert dense["space_utilization"] > sparse["space_utilization"]
assert dense["whitespace_index"] < sparse["whitespace_index"]
assert dense["score"]["total"] > sparse["score"]["total"]
assert dense["grid_occupancy"] > sparse["grid_occupancy"]
def test_overlap_hard_gates_score() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
]
edges = [{"a_node_id": "a", "b_node_id": "b"}]
m = analyze_layout_stats(nodes, edges)
assert m["footprint_overlap_pairs"] >= 1
assert m["score"]["parts"]["overlap"] == 0.0
assert m["score"]["total"] < 20.0
def test_score_components_rank_key() -> None:
good = {
"node_count": 100,
"edge_crossings": 10,
"crossings_per_link": 0.05,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
}
s = score_layout_components(good)
assert s["total"] > 50
assert s["rank_key"][0] == 0
def test_top_crossing_nodes_in_summary() -> None:
# Classic X: AC and BD cross at center → all four endpoints participate.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
m = analyze_layout_stats(nodes, edges)
assert m["edge_crossings"] == 1
top = m.get("top_crossing_nodes") or []
assert len(top) == 4
assert all(int(r["crossing_hits"]) >= 1 for r in top)
assert (m.get("summary") or {}).get("top_crossing")
assert (m.get("report") or {}).get("crossing", {}).get("top_nodes")
top_e = m.get("top_crossing_edges") or []
assert len(top_e) == 2
assert all(int(r["crossing_hits"]) == 1 for r in top_e)
assert (m.get("summary") or {}).get("top_crossing_edges")
assert (m.get("report") or {}).get("crossing", {}).get("top_edges")
def test_unified_report_facets() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
]
full = analyze_layout_stats(nodes, edges)
r = full["report"]
assert set(r) >= {
"verdict",
"size",
"overlap",
"crossing",
"spacing",
"sparsity",
"edges",
"chains",
"rings",
"score",
"guide",
}
assert r["overlap"]["status"] == "ok"
assert r["sparsity"]["status"] in {"ok", "warn", "fail"}
assert "chain" in r["score"]["parts"]
assert "rings" in r["score"]["parts"]
built = build_layout_report(full)
assert built["verdict"]["total"] == r["verdict"]["total"]

View file

@ -0,0 +1,123 @@
"""Structure / gravity detection for layout planning."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
def _nodes_edges_core_bar():
"""Two cores own access rings; ANs are low-degree decorative."""
nodes = [
{"fabric_node_id": "c1", "name": "X-CN1-a", "role": "core", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "X-CN2-a", "role": "core", "x": 200, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-a", "role": "aggregation", "x": 100, "y": 50},
{"fabric_node_id": "a2", "name": "X-AN2-a", "role": "aggregation", "x": 300, "y": 50},
]
edges = [
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "c2", "b_node_id": "a2"},
]
# Access petals hanging on cores
for i in range(12):
eid = f"e{i}"
nodes.append(
{
"fabric_node_id": eid,
"name": f"X-EN{i}-a",
"role": "access",
"x": i * 40,
"y": 200,
}
)
hub = "c1" if i < 6 else "c2"
edges.append({"a_node_id": hub, "b_node_id": eid})
if i % 2 == 1:
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
return nodes, edges
def _nodes_edges_agg_bar():
"""No cores; ANs own access rings."""
nodes = [
{"fabric_node_id": "a1", "name": "Y-AN1-a", "role": "aggregation", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "Y-AN2-a", "role": "aggregation", "x": 400, "y": 0},
]
edges = [{"a_node_id": "a1", "b_node_id": "a2"}]
for i in range(16):
eid = f"e{i}"
nodes.append(
{
"fabric_node_id": eid,
"name": f"Y-EN{i}-a",
"role": "access",
"x": i * 30,
"y": 200,
}
)
hub = "a1" if i < 8 else "a2"
edges.append({"a_node_id": hub, "b_node_id": eid})
if i % 2 == 1:
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
return nodes, edges
def test_core_bar_gravity() -> None:
nodes, edges = _nodes_edges_core_bar()
s = analyze_graph_structure(nodes, edges)
assert s["gravity"]["type"] == "core_bar"
assert s["gravity"]["anchor_layer"] == "core"
assert "agg" in s["gravity"]["decorative_layers"] or s["layers"]["agg"]["territory_frac"] < 0.2
assert s["gravity"]["recipe_preference"][0] in {"compact", "corridor"}
assert s["advice"]["skip_rings_first"] is True
assert s["gravity"]["geometry_hint"] in {"core_center", "core_top"}
def test_agg_bar_gravity() -> None:
nodes, edges = _nodes_edges_agg_bar()
s = analyze_graph_structure(nodes, edges)
assert s["gravity"]["type"] == "agg_bar"
assert s["gravity"]["anchor_layer"] == "agg"
assert s["gravity"]["recipe_preference"][0] == "rings"
assert s["advice"]["skip_rings_first"] is False
assert len(s["hubs"]) >= 2
def test_hubs_include_stubs() -> None:
nodes, edges = _nodes_edges_agg_bar()
s = analyze_graph_structure(nodes, edges, hub_top_k=4, stub_top_k=10)
assert s["hubs"][0]["access_neighbors"] >= 1
assert s["stubs"]
assert "headline" in s
def _nodes_edges_chains():
"""Several path components (SID-TOB-like): mostly deg≤2, not hub-spoke."""
nodes: list[dict] = []
edges: list[dict] = []
# three chains of lengths 12 / 8 / 5; one has a CN/AN on the path (still chain)
chains = [
[("c0", "Z-CN1-a", "core")]
+ [(f"a{i}", f"Z-EN{i}-a", "access") for i in range(1, 12)],
[(f"b{i}", f"Y-EN{i}-a", "access") for i in range(8)],
[(f"d{i}", f"X-EN{i}-a", "access") for i in range(5)],
]
for ch in chains:
for fid, name, role in ch:
nodes.append({"fabric_node_id": fid, "name": name, "role": role, "x": 0, "y": 0})
for i in range(len(ch) - 1):
edges.append({"a_node_id": ch[i][0], "b_node_id": ch[i + 1][0]})
return nodes, edges
def test_chains_gravity_before_hub_fallback() -> None:
nodes, edges = _nodes_edges_chains()
s = analyze_graph_structure(nodes, edges)
assert s["shape"]["primary"] == "chains"
assert s["gravity"]["type"] == "chains"
assert s["gravity"]["geometry_hint"] == "chain_rows"
assert s["gravity"]["recipe_preference"][0] == "corridor"
assert s["advice"]["skip_rings_first"] is True
assert s["advice"]["decompose_by_component"] is True
assert s["shape"]["component_count"] == 3
assert all(b["shape"] in {"chain", "tiny"} for b in s["shape"]["blocks"])

View file

@ -0,0 +1,185 @@
"""Tests for layoutTopologyView MCP wrapper."""
from __future__ import annotations
import json
from unittest.mock import patch
from netx_api.mcp_remote.topology.http_tools import call_http_tool
from netx_api.mcp_remote.topology.layout_tool import build_params, list_layout_catalog, resolve_recipe, run_layout_on_graph
def test_catalog_and_aliases() -> None:
cat = list_layout_catalog()
assert "rings" in cat["recipes"]
assert "compact" in cat["recipes"]
assert "corridor" in cat["recipes"]
assert "unstick" in cat["recipes"]
assert "compact_soft" not in cat["recipes"]
assert "ortho_metro" not in cat["recipes"]
assert "dual_mass" not in cat["recipes"]
assert "balanced" in cat["presets"]
assert "fix_overlaps" in cat["actions"]
assert "orbit_sweep" in cat["actions"]
assert "polish_crossings" in cat["actions"]
assert "clear_edge_hits" in cat["actions"]
assert "mass_merge" not in cat["actions"]
assert "compose_orbit" not in cat["actions"]
assert resolve_recipe("rings") == "agg_rings_v1"
assert resolve_recipe("compact") == "smd_corridor_compact_v1"
assert resolve_recipe("corridor") == "smd_corridor_v1"
try:
resolve_recipe("compact_soft")
raise AssertionError("compact_soft should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
p = build_params(preset="dense")
assert p.target_util >= 0.12
def test_run_layout_on_small_graph() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 10, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 20, "y": 0},
{"fabric_node_id": "d", "name": "D-EN-3", "x": 30, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
]
out = run_layout_on_graph(nodes, edges, recipe="corridor", preset="balanced")
assert out["ok"] is True
assert len(out["positions"]) == 4
assert "verdict" in out
assert out["overlap"]["status"] in {"ok", "warn", "fail"}
def test_layout_topology_view_catalog_tool() -> None:
out = call_http_tool("layoutTopologyView", {"catalog": True})
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert "recipes" in payload
assert "rings" in payload["recipes"]
assert "compact" in payload["recipes"]
assert "actions" in payload
assert "fix_overlaps" in payload["actions"]
def test_layout_topology_view_preview() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
],
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "recipe": "corridor", "preset": "loose", "mode": "preview"},
)
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["applied"] is False
assert payload["mode"] == "preview"
assert payload["verdict"]["total"] is not None
def test_local_polish_refuses_crossing_regression() -> None:
"""untangle apply must not silently worsen crossings."""
nodes = [
{"fabric_node_id": "h", "name": "H-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A-EN-1", "x": -200, "y": -40},
{"fabric_node_id": "b", "name": "B-EN-2", "x": 200, "y": -40},
{"fabric_node_id": "c", "name": "C-EN-3", "x": -200, "y": 200},
{"fabric_node_id": "d", "name": "D-EN-4", "x": 200, "y": 200},
{"fabric_node_id": "e", "name": "E-EN-5", "x": 0, "y": 100},
]
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": "d"},
{"a_node_id": "a", "b_node_id": "d"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "e", "b_node_id": "a"},
{"a_node_id": "e", "b_node_id": "b"},
]
graph = {"view": {"id": "v1", "name": "demo", "folder_id": "f1"}, "nodes": nodes, "edges": edges}
bad = run_layout_on_graph(nodes, edges, action="untangle")
worse = dict(bad)
worse["crossing"] = {**(bad.get("crossing") or {}), "edge_crossings": 9999}
worse["positions"] = bad["positions"]
def fake_http(method, path, body=None, **_kw):
if method == "GET":
return {"ok": True, "data": graph}
raise AssertionError("PATCH must not run on crossing regression")
with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http):
with patch(
"netx_api.mcp_remote.topology.http_tools.run_layout_on_graph",
return_value=worse,
):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "action": "untangle", "mode": "apply"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("ok") is False
assert payload.get("error") == "crossing_regression"
assert payload.get("applied") is False
def test_unpublished_action_rejected() -> None:
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "action": "mass_merge", "mode": "preview"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("ok") is False
assert "unknown_action" in str(payload.get("error") or "")
assert "mass_merge" not in (payload.get("actions") or {})
def test_layout_topology_view_apply_patches() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
],
}
calls: list[tuple] = []
def fake_http(method, path, body=None, **_kw):
calls.append((method, path, body))
if method == "GET":
return {"ok": True, "data": graph}
return {"ok": True, "data": {"updated": len((body or {}).get("positions") or [])}}
with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "recipe": "compact", "preset": "balanced", "mode": "apply"},
)
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["applied"] is True
assert payload["updated"] >= 3
assert any(m == "PATCH" for m, _p, _b in calls)

View file

@ -0,0 +1,331 @@
"""Mass field roles + soft merge (attract/repulse instead of rigid islands)."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
annotate_dual_unit,
build_mass_field,
capture_pass,
evolve_chains_to_rings,
)
from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round
from netx_api.mcp_remote.topology.layout_tool import ACTIONS
def test_actions_include_mass_merge() -> None:
assert "mass_merge" not in ACTIONS
def test_annotate_dual_unit_roles() -> None:
unit = DualUnit(
portal_a="pa",
portal_b="pb",
paths=[
["pa", "r1", "r2", "pb"],
["pa", "r3", "r4", "pb"],
["pa", "r5", "r6", "r7", "pb"],
],
tails=[["t1", "t2"]],
unit_id=0,
)
ann = annotate_dual_unit(unit)
assert ann["nest_depth"] == 3
assert ann["nodes"]["pa"]["role"] == "core"
assert ann["nodes"]["r1"]["role"] == "ring"
assert ann["nodes"]["t1"]["role"] == "chain"
assert ann["nodes"]["r1"]["attract"] > ann["nodes"]["t1"]["attract"]
assert ann["nodes"]["pa"]["attract"] > ann["nodes"]["r1"]["attract"]
def _two_block_state():
# Strong left dual eye + weak right chain attached by bridge.
nodes = [
{"fabric_node_id": "pa", "name": "PA", "x": 0, "y": 0, "layer": "agg"},
{"fabric_node_id": "pb", "name": "PB", "x": 400, "y": 0, "layer": "agg"},
{"fabric_node_id": "r1", "name": "R1", "x": 120, "y": 80, "layer": "access"},
{"fabric_node_id": "r2", "name": "R2", "x": 280, "y": 80, "layer": "access"},
{"fabric_node_id": "r3", "name": "R3", "x": 120, "y": -80, "layer": "access"},
{"fabric_node_id": "r4", "name": "R4", "x": 280, "y": -80, "layer": "access"},
{"fabric_node_id": "t1", "name": "T1", "x": 500, "y": 0, "layer": "access"},
{"fabric_node_id": "t2", "name": "T2", "x": 700, "y": 0, "layer": "access"},
{"fabric_node_id": "w1", "name": "W1", "x": 2200, "y": 40, "layer": "access"},
{"fabric_node_id": "w2", "name": "W2", "x": 2400, "y": -40, "layer": "access"},
{"fabric_node_id": "wc", "name": "WC", "x": 2000, "y": 0, "layer": "agg"},
]
edges = [
{"a_node_id": "pa", "b_node_id": "r1"},
{"a_node_id": "r1", "b_node_id": "r2"},
{"a_node_id": "r2", "b_node_id": "pb"},
{"a_node_id": "pa", "b_node_id": "r3"},
{"a_node_id": "r3", "b_node_id": "r4"},
{"a_node_id": "r4", "b_node_id": "pb"},
{"a_node_id": "pb", "b_node_id": "t1"},
{"a_node_id": "t1", "b_node_id": "t2"},
{"a_node_id": "t2", "b_node_id": "wc"},
{"a_node_id": "wc", "b_node_id": "w1"},
{"a_node_id": "wc", "b_node_id": "w2"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = [
{
"key": "strong",
"node_ids": ["pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"],
"pivots": ["pa", "pb"],
"cores": ["pa", "pb"],
"soft": True,
},
{
"key": "weak",
"node_ids": ["wc", "w1", "w2"],
"pivots": ["wc"],
"cores": ["wc"],
"soft": True,
},
]
unit = DualUnit(
portal_a="pa",
portal_b="pb",
paths=[
["pa", "r1", "r2", "pb"],
["pa", "r3", "r4", "pb"],
],
tails=[["t1", "t2"]],
unit_id=0,
)
mass = build_mass_field(st, units=[unit], groups=groups)
st.meta = {
"compose_views": {
"rigid_groups": groups,
"mass_groups": groups,
"soft": True,
},
"mass_field": mass,
}
return st, groups, mass
def test_build_mass_field_tags_chain_and_bridge() -> None:
st, groups, mass = _two_block_state()
assert mass["nodes"]["t1"]["role"] == "chain"
assert mass["nodes"]["r1"]["role"] == "ring"
bridge = mass["edges"].get("t2|wc") or mass["edges"].get("wc|t2")
assert bridge is not None
assert bridge["role"] == "bridge"
def test_capture_can_steal_chain_toward_strong_core() -> None:
st, groups, mass = _two_block_state()
# Pull weak leaves next to strong core so capture fires.
st.positions["w1"] = (450.0, 20.0)
st.positions["w2"] = (460.0, -20.0)
st.positions["wc"] = (480.0, 0.0)
mass2, report = capture_pass(
st,
mass,
kappa_node=1.0,
kappa_block=1.5,
rho_ideal=8.0,
ideal_len=200.0,
)
home = {}
for g in mass2["groups"]:
for n in g["node_ids"]:
if n not in ("pa", "pb", "wc"):
home.setdefault(n, g["key"])
# At least some movement of membership or block absorb.
assert report["stolen_nodes"] + report["stolen_blocks"] >= 1 or any(
home.get(n) == "strong" for n in ("w1", "w2")
)
def test_evolve_chain_to_ring_when_second_corridor() -> None:
# Single corridor + dangling path that completes a second corridor.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0, "layer": "agg"},
{"fabric_node_id": "b", "name": "B", "x": 300, "y": 0, "layer": "agg"},
{"fabric_node_id": "u1", "name": "U1", "x": 100, "y": 60, "layer": "access"},
{"fabric_node_id": "u2", "name": "U2", "x": 200, "y": 60, "layer": "access"},
{"fabric_node_id": "d1", "name": "D1", "x": 100, "y": -60, "layer": "access"},
{"fabric_node_id": "d2", "name": "D2", "x": 200, "y": -60, "layer": "access"},
]
edges = [
{"a_node_id": "a", "b_node_id": "u1"},
{"a_node_id": "u1", "b_node_id": "u2"},
{"a_node_id": "u2", "b_node_id": "b"},
{"a_node_id": "a", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "d2", "b_node_id": "b"},
]
st = build_state_from_nodes_edges(nodes, edges)
# Pretend lower path was tagged chain initially.
mass = {
"nodes": {
"a": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
"b": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
"u1": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
"u2": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
"d1": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
"d2": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
},
"edges": {},
"groups": [],
}
out = evolve_chains_to_rings(st, mass)
# If dual-unit finder sees both corridors, d1/d2 should promote.
if (out.get("evolve") or {}).get("promoted_nodes", 0) > 0:
assert out["nodes"]["d1"]["role"] == "ring"
assert out["nodes"]["d1"]["attract"] > 0.7
def test_mass_merge_shortens_bridge() -> None:
st, groups, _mass = _two_block_state()
L0 = math.hypot(
st.positions["wc"][0] - st.positions["t2"][0],
st.positions["wc"][1] - st.positions["t2"][1],
)
op = mass_merge_round(
st,
groups=groups,
iters=14,
step=0.45,
max_step=220.0,
ideal_len=280.0,
nn_floor=90.0,
attract_k=1.2,
repulse_k=0.7,
gravity_k=0.6,
group_sep_k=0.4,
global_gravity_k=0.0,
core_pull_k=0.8,
protect_rigid="off",
evolve_every=4,
kappa_node=1.1,
kappa_block=1.8,
x_slack=800,
capture=False,
cluster_seed=False,
use_dual_units=True,
)
assert op.op == "mass_merge"
assert not (op.params or {}).get("reverted"), op.note
L1 = math.hypot(
op.state.positions["wc"][0] - op.state.positions["t2"][0],
op.state.positions["wc"][1] - op.state.positions["t2"][1],
)
assert L1 < L0 * 0.95 or (op.params or {}).get("end_geo", 0) >= (
op.params or {}
).get("start_geo", 0)
def test_mass_merge_cluster_centroids_round_pack() -> None:
"""Dual-unit clusters reseated on a round pack (not a strip lattice)."""
st, groups, _mass = _two_block_state()
# Force a strip of group centroids.
for n in ("pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"):
st.positions[n] = (st.positions[n][0], 0.0)
for n in ("wc", "w1", "w2"):
st.positions[n] = (st.positions[n][0] + 5000.0, 5.0)
op = mass_merge_round(
st,
groups=groups,
iters=8,
fa2=True,
use_dual_units=True,
cluster_seed=True,
sep_ideal=800.0,
group_sep_k=1.2,
global_gravity_k=0.4,
capture=False,
x_slack=2000,
)
assert (op.params or {}).get("use_dual_units") is True
assert not (op.params or {}).get("reverted"), op.note
# Two cluster centroids should not sit on a long horizontal line only.
c0 = (
0.5 * (op.state.positions["pa"][0] + op.state.positions["pb"][0]),
0.5 * (op.state.positions["pa"][1] + op.state.positions["pb"][1]),
)
c1 = (
(op.state.positions["wc"][0] + op.state.positions["w1"][0] + op.state.positions["w2"][0])
/ 3.0,
(op.state.positions["wc"][1] + op.state.positions["w1"][1] + op.state.positions["w2"][1])
/ 3.0,
)
assert math.hypot(c0[0] - c1[0], c0[1] - c1[1]) > 200.0
def test_mass_merge_fa2_spreads_stacks() -> None:
"""FA2 mode: coincident nodes get unpacked; lin-log + long-range repulse run."""
st, groups, _mass = _two_block_state()
for n in st.positions:
st.positions[n] = (10.0, 10.0)
op = mass_merge_round(
st,
groups=groups,
iters=10,
fa2=True,
scaling=10.0,
linlog=True,
use_dual_units=True,
gravity_k=0.6,
group_sep_k=1.2,
group_pack_k=1.3,
sep_ideal=800.0,
global_gravity_k=0.0,
core_pull_k=0.1,
cluster_seed=True,
capture=False,
x_slack=2000,
)
assert op.op == "mass_merge"
assert not (op.params or {}).get("reverted"), op.note
assert (op.params or {}).get("fa2") is True
# Not a single hairball.
xs = [op.state.positions[n][0] for n in op.state.positions]
ys = [op.state.positions[n][1] for n in op.state.positions]
assert max(xs) - min(xs) > 200.0 or max(ys) - min(ys) > 200.0
assert float(op.params.get("end_sep_nn") or 0) > 80.0
def test_mass_merge_separates_collapsed_groups() -> None:
"""Two dual-unit groups stacked on top of each other → seed+sep spreads them."""
st, groups, _mass = _two_block_state()
# Collapse weak block onto strong block (hairball seed).
for n in ("wc", "w1", "w2"):
st.positions[n] = (
st.positions["pa"][0] + 40.0,
st.positions["pa"][1] + 20.0,
)
c0 = (
0.5 * (st.positions["pa"][0] + st.positions["pb"][0]),
0.5 * (st.positions["pa"][1] + st.positions["pb"][1]),
)
c1 = (
(st.positions["wc"][0] + st.positions["w1"][0] + st.positions["w2"][0]) / 3.0,
(st.positions["wc"][1] + st.positions["w1"][1] + st.positions["w2"][1]) / 3.0,
)
d0 = math.hypot(c0[0] - c1[0], c0[1] - c1[1])
op = mass_merge_round(
st,
groups=groups,
iters=12,
use_dual_units=True,
gravity_k=0.9,
group_sep_k=1.2,
group_pack_k=0.8,
sep_ideal=900.0,
global_gravity_k=0.0,
core_pull_k=0.05,
protect_rigid="off",
capture=False,
cluster_seed=True,
x_slack=2000,
)
assert not (op.params or {}).get("reverted"), op.note
assert float(op.params.get("end_sep_nn") or 0) > d0 + 80.0
# Not blown to absurd spacing.
assert float(op.params.get("end_sep_nn") or 0) < 4000.0

View file

@ -0,0 +1,608 @@
"""Tests for netx topology MCP tools (in-process handlers + /mcp)."""
from __future__ import annotations
import json
from unittest.mock import patch
from netx_api.mcp_remote.registry import tools_for_request
from netx_api.mcp_remote.topology.http_tools import (
HTTP_MCP_TOOLS,
call_http_tool,
resolve_draw_target,
tools_for_scopes,
)
def test_tool_list_has_draw_and_query_tools() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert len(names) == 16
assert "sinkTopologyDualUnits" in names
assert "suggestSinkHubs" in names
assert "classifyTopologyFabricNodes" in names
assert "copyTopologyViewNodes" in names
assert "createTopologyFolder" in names
assert "createTopologyView" not in names
assert "listTopologyViews" not in names
assert "queryTopologyFabricNodes" in names
assert "listTopologyFabricNodes" not in names
assert "searchTopologyFabricNodes" not in names
assert "getTopologyFabricSummary" not in names
assert "addTopologyViewNodes" in names
assert "updateTopologyViewPositions" in names
assert "queryTopologyEdges" in names
assert "analyzeTopologyViewLayout" in names
assert "layoutTopologyView" in names
assert "getTopologyTree" in names
assert "createTopologyManualEdge" not in names
assert "populateTopologyView" not in names
folder_tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "createTopologyFolder")
assert "Does not create a canvas" not in str(folder_tool.get("description") or "")
assert "view_id" in str(folder_tool.get("description") or "")
def test_create_folder_requires_name() -> None:
out = call_http_tool("createTopologyFolder", {})
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "name_required"
def test_create_folder_posts_region_and_resolves_view() -> None:
tree = {
"root": {
"id": "sys-root",
"kind": "root",
"children": [
{
"id": "f1",
"name": "华北",
"kind": "region",
"views": [],
"ne_count": 0,
"children": [
{
"id": "rm1",
"name": "根图",
"kind": "region",
"is_system": True,
"ne_count": 0,
"views": [{"id": "v-root-map", "name": "根图", "kind": "physical"}],
"children": [],
}
],
}
],
}
}
def fake_http(method: str, path: str, body: dict | None = None, **_kwargs):
if method == "POST" and path == "/v1/topology/folders":
return {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}}
if method == "GET" and path == "/v1/topology/tree":
return {"ok": True, "data": tree}
return {"ok": False, "error": f"unexpected {method} {path}"}
with patch("netx_api.mcp_remote.topology.http_tools.http_json", side_effect=fake_http) as mock_http:
out = call_http_tool(
"createTopologyFolder",
{"name": "华北", "sort_order": 1, "locale": "zh"},
)
assert mock_http.call_count == 2
post = mock_http.call_args_list[0]
assert post[0][0] == "POST"
assert post[0][1] == "/v1/topology/folders"
body = post[1]["body"]
assert body["name"] == "华北"
assert body["kind"] == "region"
assert body["sort_order"] == 1
assert body["locale"] == "zh"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["id"] == "f1"
assert payload["view_id"] == "v-root-map"
assert payload["canvas_folder_id"] == "rm1"
assert "view_id" in str(payload.get("hint") or "")
def test_resolve_draw_target_nested_region() -> None:
tree = {
"root": {
"id": "sys",
"children": [
{
"id": "nav",
"views": [],
"children": [
{
"id": "rm",
"name": "Root map",
"is_system": True,
"views": [{"id": "v0", "kind": "physical"}],
"children": [
{
"id": "zone",
"ne_count": 3,
"views": [{"id": "vz", "kind": "physical"}],
"children": [],
}
],
}
],
}
],
}
}
tip = resolve_draw_target(tree, "zone")
assert tip["view_id"] == "vz"
assert tip["canvas_folder_id"] == "zone"
assert tip["ne_count"] == 3
def test_add_nodes_rejects_managed_ume_ids() -> None:
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "managed_ne_ids": ["m1"], "fabric_node_ids": ["f1"]},
)
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "fabric_nodes_only"
def test_add_nodes_posts_fabric_ids_only() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ok": True, "added": 2, "return_graph": False}}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"},
)
body = mock_http.call_args[1]["body"]
assert body["fabric_node_ids"] == ["f1", "f2"]
assert body["managed_ne_ids"] == []
assert body["ume_ne_ids"] == []
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_add_nodes_filter_posts_without_ids() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {"ok": True, "added": 10, "matched": 40, "next_offset": 10, "truncated": True},
}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "keyword": "BJ-", "limit": 10, "offset": 0},
)
body = mock_http.call_args[1]["body"]
assert body["keyword"] == "BJ-"
assert body["fabric_node_ids"] == []
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["added"] == 10
assert payload["next_offset"] == 10
def test_update_positions_layout_filter() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 5}}
out = call_http_tool(
"updateTopologyViewPositions",
{"view_id": "v1", "layout": "grid", "keyword": "core", "origin_x": 0, "origin_y": 0},
)
body = mock_http.call_args[1]["body"]
assert body["layout"] == "grid"
assert body["keyword"] == "core"
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["updated"] == 5
def test_create_view_tool_removed() -> None:
try:
call_http_tool("createTopologyView", {"name": "map1", "folder_id": "f1"})
raise AssertionError("createTopologyView should be unregistered")
except ValueError as e:
assert "unknown tool" in str(e)
def test_query_edges_defaults_to_adjacency() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"total": 2,
"page": 1,
"page_size": 100,
"items": [
{
"a_node_id": "A",
"b_node_id": "B",
"a_name": "ne-a",
"b_name": "ne-b",
"a_ip": "1.1.1.1",
"b_ip": "2.2.2.2",
"a_port": "p1",
"b_port": "p2",
},
{
"a_node_id": "B",
"b_node_id": "A",
"a_name": "ne-b",
"b_name": "ne-a",
"a_port": "p3",
"b_port": "p4",
},
],
},
}
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["detail"] == "adjacency"
assert payload["link_count"] == 1
assert payload["links"][0]["link_count"] == 2
assert "items" not in payload
assert payload["peer_count"] == 1
assert payload["peers"][0]["node_id"] == "B"
assert payload["peers"][0]["link_count"] == 2
assert payload["peers_complete"] is True
out_ports = call_http_tool(
"queryTopologyEdges", {"node_id": "A", "detail": "ports", "page_size": 100}
)
ports = json.loads(out_ports["content"][0]["text"])
assert ports["detail"] == "ports"
assert len(ports["items"]) == 2
def test_tools_for_scopes_filters_write() -> None:
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
assert "queryTopologyEdges" in read_only
assert "queryTopologyFabricNodes" in read_only
assert "createTopologyFolder" not in read_only
assert "classifyTopologyFabricNodes" not in read_only
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" not in write
assert "createTopologyFolder" in write
assert "classifyTopologyFabricNodes" in write
def test_classify_topology_fabric_nodes_match_tag() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"pattern": "CORE",
"match_field": "name",
"total_matched": 1,
"samples": [{"id": "n1", "name": "CORE-1", "attrs": {"x": 1}}],
"fabric_node_ids": ["n1"],
},
}
out = call_http_tool(
"classifyTopologyFabricNodes",
{"action": "match", "pattern": "CORE", "match_field": "name"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("action") == "match"
assert payload.get("fabric_node_ids") == ["n1"]
assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/match")
mock_http.return_value = {
"ok": True,
"data": {"dry_run": True, "matched": 1, "updated": 0, "level": 1.0, "samples": []},
}
out = call_http_tool(
"classifyTopologyFabricNodes",
{
"action": "tag",
"fabric_node_ids": ["n1"],
"level": 1.0,
"dry_run": True,
},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("action") == "tag"
assert payload.get("dry_run") is True
assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/tags/bulk")
assert mock_http.call_args[1]["body"]["level"] == 1.0
def test_query_fabric_nodes_modes() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"node_count": 3, "edge_count": 2}}
out = call_http_tool("queryTopologyFabricNodes", {"mode": "summary"})
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "summary"
assert mock_http.call_args[0][1] == "/v1/topology/fabric/summary"
mock_http.return_value = {
"ok": True,
"data": {"items": [{"id": "n1", "name": "A"}], "total": 1, "page": 1, "page_size": 50},
}
out = call_http_tool("queryTopologyFabricNodes", {"q": "A"})
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "search"
assert "/search" in mock_http.call_args[0][1]
out = call_http_tool(
"queryTopologyFabricNodes",
{"mode": "list", "keyword": "core", "role": "core"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "list"
assert mock_http.call_args[0][1] == "/v1/topology/fabric/nodes"
assert mock_http.call_args[1]["params"]["keyword"] == "core"
def test_get_topology_tree_compacts_by_default() -> None:
tree = {
"root": {
"id": "r",
"name": "Network",
"kind": "root",
"ne_count": 2,
"external_ref": "drop-me",
"views": [],
"children": [
{
"id": "a",
"name": "RootA",
"kind": "region",
"ne_count": 2,
"views": [],
"children": [
{
"id": "rm",
"name": "根图",
"kind": "region",
"is_system": True,
"ne_count": 2,
"views": [{"id": "v1", "name": "根图", "kind": "physical", "node_count": 2}],
"children": [
{
"id": "deep",
"name": "Deep",
"kind": "region",
"ne_count": 0,
"views": [],
"children": [],
}
],
}
],
}
],
}
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": tree}
out = call_http_tool("getTopologyTree", {"max_depth": 1})
payload = json.loads(out["content"][0]["text"])
assert payload["compact"] is True
assert "external_ref" not in payload["root"]
child = payload["root"]["children"][0]
assert child["name"] == "RootA"
# depth 1: grandchildren collapsed
assert child["children_truncated"] == 1
assert child["children"] == []
def test_get_topology_view_defaults_to_summary() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "folder_id": "f1", "kind": "physical", "node_count": 2},
"nodes": [
{"fabric_node_id": "n1", "name": "A", "ip": "1.1.1.1", "x": 1, "y": 2},
{"fabric_node_id": "n2", "name": "B", "ip": "2.2.2.2", "x": 3, "y": 4},
],
"edges": [{"id": "e1", "a_node_id": "n1", "b_node_id": "n2", "display_label": "huge" * 50}],
"outside_peers": [{"fabric_node_id": "n3"}],
"truncated": False,
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool("getTopologyView", {"view_id": "v1"})
payload = json.loads(out["content"][0]["text"])
assert payload["detail"] == "summary"
assert payload["node_count"] == 2
assert payload["edge_count"] == 1
assert payload["link_count"] == 1
assert payload["links"][0]["a_node_id"] == "n1"
assert payload["links"][0]["b_node_id"] == "n2"
assert "edges" not in payload
assert len(payload["sample_nodes"]) == 2
out_full = call_http_tool("getTopologyView", {"view_id": "v1", "detail": "full"})
full = json.loads(out_full["content"][0]["text"])
assert full["detail"] == "full"
assert len(full["edges"]) == 1
def test_project_neighbors_defaults_to_summary() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "node_count": 3},
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
"edges": [],
"outside_peers": [],
"truncated": True,
"truncate_reason": "membership_cap",
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool("projectTopologyNeighbors", {"view_id": "v1"})
payload = json.loads(out["content"][0]["text"])
assert payload["detail"] == "summary"
assert payload["projected"] is True
assert payload["truncated"] is True
assert "edges" not in payload
def test_project_neighbors_passes_region_folder_and_reports_skips() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "node_count": 2},
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
"edges": [],
"outside_peers": [],
"out_of_region_skipped": 3,
"out_of_region_sample": [
{"fabric_node_id": "x1", "name": "X", "region_folder_id": "other"}
],
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool(
"projectTopologyNeighbors",
{"view_id": "v1", "region_folder_id": "reg-A"},
)
payload = json.loads(out["content"][0]["text"])
assert payload["projected"] is True
assert payload["region_folder_id"] == "reg-A"
assert payload["out_of_region_skipped"] == 3
assert payload["out_of_region_sample"][0]["fabric_node_id"] == "x1"
assert mock_http.call_args.kwargs["body"]["region_folder_id"] == "reg-A"
def test_neighborhood_returns_links_not_ports() -> None:
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"center_node_id": "A",
"depth": 1,
"nodes": [
{
"id": "A",
"name": "ne-a",
"ip": "1.1.1.1",
"attrs": {"sources": ["ume"], "huge": "x" * 200},
"world_x": 1,
"world_y": 2,
}
],
"edges": [
{
"id": "e1",
"a_node_id": "A",
"b_node_id": "B",
"source": "ume",
"status": "active",
"a_port": "p1",
"attrs": {"display_label": "LONG" * 40},
},
{
"id": "e2",
"a_node_id": "A",
"b_node_id": "B",
"source": "ume",
"status": "active",
"a_port": "p2",
},
],
},
}
out = call_http_tool("queryTopologyNeighborhood", {"node_id": "A"})
payload = json.loads(out["content"][0]["text"])
assert payload["node_count"] == 1
assert payload["edge_count"] == 2
assert payload["link_count"] == 1
assert payload["links"][0]["link_count"] == 2
assert "edges" not in payload
assert "attrs" not in payload["nodes"][0]
assert "world_x" not in payload["nodes"][0]
def test_topology_group_lists_sixteen_tools() -> None:
tools = tools_for_request(None, frozenset({"topology"}))
assert len(tools) == 16
names = {t["name"] for t in tools}
assert "suggestSinkHubs" in names
assert "classifyTopologyFabricNodes" in names
assert "createTopologyView" not in names
assert "listTopologyViews" not in names
assert "queryTopologyFabricNodes" in names
assert "createTopologyFolder" in names
assert "analyzeTopologyViewLayout" in names
assert "layoutTopologyView" in names
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
read_only = tools_for_request(frozenset({"ne:read"}), frozenset({"topology"}))
read_names = {t["name"] for t in read_only}
assert "getTopologyView" in read_names
assert "layoutTopologyView" not in read_names
def test_layout_metrics_crossing_and_spacing() -> None:
from netx_api.mcp_remote.topology.layout_metrics import analyze_positions, grade_layout, segments_properly_intersect
assert segments_properly_intersect((0, 0), (2, 2), (0, 2), (2, 0))
assert not segments_properly_intersect((0, 0), (1, 0), (1, 0), (2, 0)) # shared endpoint
# X crossing
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 200},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
m = analyze_positions(nodes, edges)
assert m["edge_crossings"] == 1
assert m["link_count"] == 2
g = grade_layout(m)
assert g["overall"] in {"ok", "warn", "fail"}
def test_analyze_topology_view_layout_single() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "N-A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "N-B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "N-C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "N-D", "x": 200, "y": 200},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
],
}
with patch("netx_api.mcp_remote.topology.http_tools.http_json", return_value={"ok": True, "data": graph}):
out = call_http_tool("analyzeTopologyViewLayout", {"view_id": "v1"})
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["size"]["links"] == 4
assert payload["crossing"]["edge_crossings"] == 0
assert payload["overlap"]["status"] == "ok"
assert "sparsity" in payload
assert "verdict" in payload
assert "total" in payload["verdict"]
def test_update_positions_omits_label_and_locked_unless_given() -> None:
from netx_api.mcp_remote.topology.http_tools import _update_topology_view_positions
with patch("netx_api.mcp_remote.topology.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 3}}
_update_topology_view_positions(
{
"view_id": "v1",
"positions": [
{"fabric_node_id": "a", "x": 1, "y": 2},
{"fabric_node_id": "b", "x": 3, "y": 4, "label": "", "locked": False},
{"fabric_node_id": "c", "x": 5, "y": 6, "label": "Core-1", "locked": True},
],
}
)
sent = mock_http.call_args[1]["body"]["positions"]
assert sent[0] == {"fabric_node_id": "a", "x": 1.0, "y": 2.0}
assert sent[1]["label"] == "" and sent[1]["locked"] is False
assert sent[2]["label"] == "Core-1" and sent[2]["locked"] is True

View file

@ -0,0 +1,36 @@
"""layoutTopologyView action=move_nodes registration + validation."""
from __future__ import annotations
from netx_api.mcp_remote.topology.http_tools import HTTP_MCP_TOOLS, _move_topology_view_nodes
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog
def test_move_nodes_in_layout_schema_and_catalog() -> None:
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
enum = tool["inputSchema"]["properties"]["action"]["enum"]
assert "move_nodes" in enum
assert "sink_nodes" not in enum
cat = list_layout_catalog()
assert "move_nodes" in cat["actions"]
assert "sink_nodes" not in cat["actions"]
def test_move_nodes_requires_ids_and_views() -> None:
assert _move_topology_view_nodes({}).get("error") == "view_id_and_source_view_id_required"
assert (
_move_topology_view_nodes(
{
"view_id": "a",
"source_view_id": "a",
"params": {"fabric_node_ids": ["x"]},
}
).get("error")
== "source_and_dest_must_differ"
)
assert (
_move_topology_view_nodes(
{"view_id": "a", "source_view_id": "b", "params": {}}
).get("error")
== "fabric_node_ids_required"
)

View file

@ -0,0 +1,448 @@
"""Tests for polar orbit_sweep top-3 suggest / round apply."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
apply_orbit_pick,
orbit_sweep_node,
orbit_sweep_round,
)
from netx_api.mcp_remote.topology.layout_tool import run_layout_on_graph
def _crossed_pair():
# a—b horizontal crosses c—d vertical at center; free node e tethered to c.
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 200.0, "y": 0.0},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0},
{"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "c", "b_node_id": "e"},
]
return nodes, edges
def test_orbit_sweep_node_returns_top3_with_gain() -> None:
nodes, edges = _crossed_pair()
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)
assert g0 >= 1
# Move endpoint d — polar sweep should find a non-crossing slot.
out = orbit_sweep_node(
st,
"d",
protect_rigid="off",
max_jump=500,
angle_step=15,
nn_floor=20.0,
)
assert out["ok"] is True
cands = out["candidates"]
assert 1 <= len(cands) <= 3
assert cands[0]["rank"] == 1
assert "x" in cands[0] and "y" in cands[0]
assert cands[0]["crossings"]["global"] <= g0
# Prefer at least one improving candidate on this toy cross.
assert out["improving_n"] >= 1 or cands[0]["delta"]["global"] <= 0
def test_orbit_top3_angular_diversity() -> None:
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
out = orbit_sweep_node(
st, "d", protect_rigid="off", max_jump=600, min_angle_sep=35.0
)
cands = out["candidates"]
if len(cands) >= 2:
for i in range(len(cands)):
for j in range(i + 1, len(cands)):
a = float(cands[i]["angle_deg"])
b = float(cands[j]["angle_deg"])
d = abs(a - b) % 360.0
d = d if d <= 180 else 360 - d
r0 = max(float(cands[i]["r"]), 1.0)
r1 = max(float(cands[j]["r"]), 1.0)
ratio = max(r0, r1) / min(r0, r1)
# Either angularly separated or clearly different radius.
assert d >= 34.0 or ratio >= 1.29
def test_apply_orbit_pick_moves_to_rank2() -> None:
nodes, edges = _crossed_pair()
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 = orbit_sweep_node(st, "d", protect_rigid="off", max_jump=600)
assert sweep["ok"]
cands = sweep["candidates"]
assert len(cands) >= 1
pick = 2 if len(cands) >= 2 else 1
op = apply_orbit_pick(st, sweep, pick=pick)
assert "d" in op.moved
chosen = cands[pick - 1]
assert abs(op.state.positions["d"][0] - chosen["x"]) < 0.2
assert abs(op.state.positions["d"][1] - chosen["y"]) < 0.2
def test_orbit_sweep_round_does_not_raise_crossings() -> None:
nodes, edges = _crossed_pair()
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)
op = orbit_sweep_round(
st,
top_n=4,
max_degree=9,
protect_rigid="off",
max_jump=600,
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
assert (op.params or {}).get("end_crossings") == g1
def test_run_layout_orbit_sweep_preview_keeps_coords() -> None:
nodes, edges = _crossed_pair()
out = run_layout_on_graph(
nodes,
edges,
action="orbit_sweep",
params={"node_id": "d", "protect_rigid": "off", "max_jump": 600},
)
assert out["ok"] is True
assert out["action"] == "orbit_sweep"
sweep = (out.get("local") or {}).get("sweep") or {}
assert sweep.get("candidates")
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
assert abs(by_id["d"]["x"] - 200.0) < 0.2
assert abs(by_id["d"]["y"] - 400.0) < 0.2
def test_run_layout_orbit_sweep_pick_applies() -> None:
nodes, edges = _crossed_pair()
out = run_layout_on_graph(
nodes,
edges,
action="orbit_sweep",
params={
"node_id": "d",
"pick": 1,
"protect_rigid": "off",
"max_jump": 600,
},
)
assert out["ok"] is True
pick = (out.get("local") or {}).get("pick")
assert pick == 1
sweep = (out.get("local") or {}).get("sweep") or {}
chosen = (sweep.get("candidates") or [{}])[0]
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
# normalize_origin shifts all coords; check relative to fixed peer a.
assert abs((by_id["d"]["x"] - by_id["a"]["x"]) - (chosen["x"] - 0.0)) < 0.2
assert abs((by_id["d"]["y"] - by_id["a"]["y"]) - (chosen["y"] - 200.0)) < 0.2
def test_catalog_lists_orbit_sweep() -> None:
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog
cat = list_layout_catalog()
assert "orbit_sweep" in cat["actions"]
def test_orbit_default_protect_off_ignores_portal_freeze() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_params_from_overrides
knobs = orbit_params_from_overrides({"node_id": "d", "portal_ids": ["d"]})
assert knobs["protect_rigid"] == "off"
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# frozen_ids present but protect default off → still sweeps.
out = orbit_sweep_node(st, "d", frozen_ids={"d"}, max_jump=500, nn_floor=20.0)
assert out["ok"] is True
assert out.get("candidates")
frozen = orbit_sweep_node(
st, "d", protect_rigid="portals", frozen_ids={"d"}, max_jump=500
)
assert frozen["ok"] is False
assert frozen.get("error") == "frozen"
def test_orbit_objective_total_ranks_clearance_trade() -> None:
"""objective=total may keep a crossing-up move if clearance improves enough."""
# Hub h near non-incident segment a—b; moving h right cuts clearance hits
# but can add a mild cross with c—d. Crossing-only ranking would reject it.
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 0.0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 200.0, "y": -200.0},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 200.0},
{"fabric_node_id": "h", "name": "HHHHHH-EN-5", "x": 200.0, "y": 20.0},
{"fabric_node_id": "t", "name": "TTTTTT-EN-6", "x": 200.0, "y": 300.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "h", "b_node_id": "t"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
by_cross = orbit_sweep_node(st, "h", max_jump=400, nn_floor=20.0, objective="crossing")
by_total = orbit_sweep_node(st, "h", max_jump=400, nn_floor=20.0, objective="total")
assert by_cross["ok"] is True and by_total["ok"] is True
assert by_total.get("objective") == "total"
# Tiny graph: clearance runs, so ranked candidates carry verdict_partial.
assert by_total.get("candidates")
assert all("verdict_partial" in c for c in by_total["candidates"])
# y_band plumbing
banded = orbit_sweep_node(
st, "h", max_jump=400, nn_floor=20.0, objective="total", y_min=0.0, y_max=80.0
)
assert banded["ok"] is True
assert banded.get("y_band") == [0.0, 80.0]
for c in banded.get("candidates") or []:
assert 0.0 <= float(c["y"]) <= 80.0
def test_orbit_until_limit_params_defaults() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_params_from_overrides
knobs = orbit_params_from_overrides({"until_limit": True})
assert knobs["until_limit"] is True
assert knobs["protect_rigid"] == "portals"
assert knobs["objective"] == "crossing"
assert knobs["max_degree"] == 14
assert knobs["max_jump"] == 4000.0
assert knobs["max_stretch"] == 32.0
assert knobs["cand_cap"] == 360
assert knobs["top_k"] == 8
# until_stall alias
assert orbit_params_from_overrides({"until_stall": 1})["until_limit"] is True
def test_orbit_far_field_can_cut_long_chord() -> None:
"""Node whose improving slot is outside local polar rings."""
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0},
{"fabric_node_id": "b", "name": "B", "x": 4000.0, "y": 200.0},
{"fabric_node_id": "c", "name": "C", "x": 2000.0, "y": 0.0},
{"fabric_node_id": "leaf", "name": "LEAF", "x": 2000.0, "y": 400.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "leaf"},
]
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)
assert g0 >= 1
out = orbit_sweep_node(
st,
"leaf",
protect_rigid="off",
max_jump=5000,
cand_cap=400,
top_k=5,
nn_floor=10.0,
)
assert out["ok"] is True
assert int(out.get("improving_n") or 0) >= 1
best = out["candidates"][0]
assert int((best.get("delta") or {}).get("global") or 0) < 0
def test_orbit_sweep_until_limit_cuts_crossings() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit
nodes, edges = _crossed_pair()
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)
op = orbit_sweep_until_limit(
st,
protect_rigid="off",
max_jump=600,
max_degree=9,
max_moves=10,
stall_limit=4,
max_stretch=30.0,
nn_floor=20.0,
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
meta = op.params or {}
assert meta.get("mode") == "until_limit"
assert g1 <= g0
assert meta.get("end_crossings") == g1
assert meta.get("stop_reason") in {
"stall",
"no_candidates",
"max_moves",
}
def test_run_layout_until_limit_preview_moves_in_result() -> None:
nodes, edges = _crossed_pair()
g0_nodes = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# Build edges list for crossing count on input
st = build_state_from_nodes_edges(nodes, edges)
st.positions = dict(g0_nodes)
g0 = count_edge_crossings(st.positions, st.links)
out = run_layout_on_graph(
nodes,
edges,
action="orbit_sweep",
params={
"until_limit": True,
"protect_rigid": "off",
"max_jump": 600,
"max_moves": 8,
"stall_limit": 4,
"nn_floor": 20.0,
},
)
assert out["ok"] is True
local = out.get("local") or {}
assert local.get("until_limit") is True
meta = local.get("meta") or local.get("op") or {}
assert meta.get("mode") == "until_limit"
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
# Relative geometry: crossings should not rise vs original.
pos1 = {nid: (float(by_id[nid]["x"]), float(by_id[nid]["y"])) for nid in by_id}
g1 = count_edge_crossings(pos1, st.links)
assert g1 <= g0
def test_select_stretch_pick_prefers_lower_stretch() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _select_stretch_pick
sweep = {
"improving_n": 2,
"candidates": [
{
"delta": {"global": -2},
"stretch": 40.0,
"ov": False,
"x": 1,
"y": 1,
"crossings": {"global": 1},
},
{
"delta": {"global": -2},
"stretch": 5.0,
"ov": False,
"x": 2,
"y": 2,
"crossings": {"global": 1},
},
],
}
picked = _select_stretch_pick(sweep, max_stretch=24.0, min_delta=1)
assert picked is not None
pick_i, cand = picked
assert pick_i == 2
assert cand["stretch"] == 5.0
def test_until_limit_freezes_portal_ids() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# Freeze both endpoints of the vertical crossing edge.
op = orbit_sweep_until_limit(
st,
protect_rigid="portals",
frozen_ids={"c", "d"},
max_jump=600,
max_moves=6,
stall_limit=3,
nn_floor=20.0,
)
moved = set(op.moved or ())
assert "c" not in moved and "d" not in moved
def test_ids_matching_freeze_layers_levels() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
ids_matching_freeze_layers_levels,
)
nodes = [
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0, "level": 1},
{"fabric_node_id": "c11", "name": "X-CN2-Y", "x": 10, "y": 0, "level": 1.1},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 20, "y": 0, "level": 2},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 30, "y": 0, "level": 3},
]
edges = [{"a_node_id": "c1", "b_node_id": "a1"}]
st = build_state_from_nodes_edges(nodes, edges)
assert st.layers["c1"] == "core"
assert st.levels["c11"] == 1.1
by_layer = ids_matching_freeze_layers_levels(st, freeze_layers=["core", "agg"])
assert by_layer == {"c1", "c11", "a1"}
by_maj = ids_matching_freeze_layers_levels(st, freeze_levels=[1, 2])
assert "c1" in by_maj and "c11" in by_maj and "a1" in by_maj
assert "e1" not in by_maj
by_exact = ids_matching_freeze_layers_levels(st, freeze_levels=[1.1])
assert by_exact == {"c11"}
# aliases
assert "c1" in ids_matching_freeze_layers_levels(st, freeze_layers=["CN"])
def test_until_limit_freeze_layers_blocks_core() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_sweep_until_limit
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0, "level": 3},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0, "level": 3},
{"fabric_node_id": "c", "name": "CCCCCC-CN-3", "x": 200.0, "y": 0.0, "level": 1},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0, "level": 3},
{"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0, "level": 3},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "c", "b_node_id": "e"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = orbit_sweep_until_limit(
st,
protect_rigid="off",
freeze_layers=["core"],
max_jump=600,
max_moves=8,
stall_limit=4,
nn_floor=20.0,
)
assert "c" not in set(op.moved or ())
assert (op.params or {}).get("freeze_layers") == ["core"]
def test_verdict_partial_weights_match_layout_stats() -> None:
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_W_CLR,
_W_CROSS,
_crossing_part_score,
_verdict_partial,
)
assert abs(_W_CROSS - 0.18) < 1e-9
assert abs(_W_CLR - 0.08) < 1e-9
# Zero crossings on small graph → crossing part 1.0
assert _crossing_part_score(0, n_links=10, n_nodes=10) == 1.0
# Perfect clearance + perfect crossing
assert abs(_verdict_partial(0, 1.0, n_links=10, n_nodes=10) - (_W_CROSS + _W_CLR)) < 1e-9
# Worse crossings lower the partial
assert _verdict_partial(5, 1.0, n_links=10, n_nodes=10) < _verdict_partial(
0, 1.0, n_links=10, n_nodes=10
)

View file

@ -0,0 +1,113 @@
"""Tests for ortho_metro (multi-layer H/V metro recipe)."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
from netx_api.mcp_remote.topology.layout_ops.ortho_metro import (
_axis_ok,
build_ortho_metro_skeleton,
)
from netx_api.mcp_remote.topology.layout_tool import RECIPE_ALIASES, run_layout_on_graph
def _core_bar_graph():
"""Two CN cores + AN stubs + one deg-2 corridor + one triangle."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "t0", "name": "X-AN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "t1", "name": "X-AN3-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
# triangle on a0
{"a_node_id": "a0", "b_node_id": "t0"},
{"a_node_id": "t0", "b_node_id": "t1"},
{"a_node_id": "t1", "b_node_id": "a0"},
]
return nodes, edges
def test_recipe_alias() -> None:
# ortho_metro module kept; unpublished from public recipe aliases.
assert "ortho_metro" not in RECIPE_ALIASES
def test_build_ortho_metro_places_all() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams())
assert op.op == "build_ortho_metro_skeleton"
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
assert op.state.meta.get("rings_mode") == "ortho_metro"
meta = op.state.meta.get("ortho_metro") or {}
assert int(meta.get("axis_edges") or 0) + int(meta.get("diag_edges") or 0) == len(
op.state.links
)
def test_ortho_metro_mostly_axis_aligned() -> None:
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=True)
pos = op.state.positions
axis = 0
diag = 0
for a, b in op.state.links:
if _axis_ok(pos[a], pos[b]):
axis += 1
else:
diag += 1
# Ortho first; clearance may break one chord to kill edge-through-node.
assert axis >= len(op.state.links) - 2
assert diag <= 2
clr = compute_edge_clearance(pos, op.state.links, names=op.state.names, thr=40.0)
assert int(clr.get("edge_clearance_hits") or 0) == 0
def test_ortho_metro_no_footprint_stack() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(pitch=200, side=170))
ids = list(op.state.positions)
for i, a in enumerate(ids):
for b in ids[i + 1 :]:
x1, y1 = op.state.positions[a]
x2, y2 = op.state.positions[b]
assert math.hypot(x2 - x1, y2 - y1) >= 140.0
def test_run_recipe_and_layout_tool_unpublished() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "ortho_metro_v1", LayoutParams())
raise AssertionError("ortho_metro_v1 should be unpublished")
except Exception as e:
assert "ortho" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="ortho_metro")
raise AssertionError("ortho_metro recipe should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_skip_triangles_false_tries_all() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=False)
# Still places everyone; may leave 0 diags if coplanar H on triangle.
assert len(op.state.positions) == len(nodes)

View file

@ -0,0 +1,166 @@
"""Soft partition: hub territory (always) + optional igraph."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops import (
build_state_from_nodes_edges,
igraph_available,
list_blocks,
pack_block_centers,
partition_soft_blocks,
)
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
def _star_graph():
# Two hubs + private stubs + shared leftover chain
nodes = [
{"fabric_node_id": "h1", "name": "H1-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "h2", "name": "H2-AN-1", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 20},
{"fabric_node_id": "a2", "name": "A2-EN-1", "x": 0, "y": 40},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 100, "y": 20},
{"fabric_node_id": "b2", "name": "B2-EN-1", "x": 100, "y": 40},
{"fabric_node_id": "z1", "name": "Z1-EN-1", "x": 50, "y": 80},
{"fabric_node_id": "z2", "name": "Z2-EN-1", "x": 50, "y": 100},
]
edges = [
{"a_node_id": "h1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "h2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "h1", "b_node_id": "h2"},
{"a_node_id": "a2", "b_node_id": "z1"},
{"a_node_id": "z1", "b_node_id": "z2"},
]
return nodes, edges
def test_hub_territory_splits_by_hub() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="hub_territory")
methods = {b.method for b in blocks}
assert "hub_territory" in methods
by_hub = {b.hub_id: set(b.node_ids) for b in blocks if b.hub_id}
assert "h1" in by_hub and "h2" in by_hub
assert "a1" in by_hub["h1"] and "a2" in by_hub["h1"]
assert "b1" in by_hub["h2"] and "b2" in by_hub["h2"]
# z* claimed via a2 → h1 territory (first BFS from stubs)
assert "z1" in by_hub["h1"] or any(
"z1" in b.node_ids for b in blocks if b.method == "leftover"
)
def test_list_blocks_hub_territory_mode() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
sets = list_blocks(st, mode="hub_territory")
assert len(sets) >= 2
assert all(isinstance(s, set) and s for s in sets)
def test_pack_block_centers_grid_fallback() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="hub_territory")
centers = pack_block_centers(st, blocks)
assert len(centers) == len(blocks)
assert all(isinstance(xy, tuple) and len(xy) == 2 for xy in centers.values())
def test_structure_includes_soft_blocks() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges)
sb = struct.get("soft_blocks") or {}
assert sb.get("block_count", 0) >= 2
assert "blocks" in sb
assert "igraph" in sb
assert struct["advice"].get("decompose_soft_blocks") in {True, False}
def test_leiden_mode_falls_back_without_crash() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="leiden")
assert blocks
# With or without igraph, we still get a partition.
assert sum(len(b.node_ids) for b in blocks) >= len(st.positions)
_ = igraph_available() # smoke
def test_resolve_auto_small_graph_stays_component() -> None:
from netx_api.mcp_remote.topology.layout_ops import resolve_block_mode
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
# 8 nodes < 40 → auto keeps component
assert resolve_block_mode(st, "auto") == "component"
def test_pack_soft_blocks_moves_territories_apart() -> None:
from netx_api.mcp_remote.topology.layout_ops import pack_soft_blocks
nodes, edges = _star_graph()
# Stack both hubs on top of each other so pack must separate
for n in nodes:
if n["fabric_node_id"] in {"h1", "a1", "a2"}:
n["x"], n["y"] = 0.0, 0.0
else:
n["x"], n["y"] = 5.0, 5.0
st = build_state_from_nodes_edges(nodes, edges)
before = dict(st.positions)
out = pack_soft_blocks(st, mode="hub_territory")
assert out.op == "pack_soft_blocks"
assert len(out.moved) >= 2
# centroids of the two hub blocks should diverge
blocks = partition_soft_blocks(out.state, mode="hub_territory")
hubs = [b for b in blocks if b.hub_id]
assert len(hubs) >= 2
def centroid(ids):
pts = [out.state.positions[i] for i in ids if i in out.state.positions]
return (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
c0, c1 = centroid(hubs[0].node_ids), centroid(hubs[1].node_ids)
dist = ((c0[0] - c1[0]) ** 2 + (c0[1] - c1[1]) ** 2) ** 0.5
assert dist > 50.0
# something moved vs stacked start
assert any(out.state.positions[n] != before[n] for n in out.moved)
def test_pack_soft_blocks_preserves_spread_constellation() -> None:
from netx_api.mcp_remote.topology.layout_ops import pack_soft_blocks
nodes, edges = _star_graph()
# Already-spread hubs: preserve strategy densifies but keeps left/right order
for n in nodes:
fid = n["fabric_node_id"]
if fid in {"h1", "a1", "a2"}:
n["x"], n["y"] = 0.0, 0.0
elif fid in {"h2", "b1", "b2"}:
n["x"], n["y"] = 2000.0, 0.0
else:
n["x"], n["y"] = 1000.0, 400.0
st = build_state_from_nodes_edges(nodes, edges)
out = pack_soft_blocks(st, mode="hub_territory", gap=420.0, strategy="auto")
assert out.params.get("strategy") == "preserve"
h1 = out.state.positions["h1"]
h2 = out.state.positions["h2"]
assert h1[0] < h2[0]
# densified toward gap, not remapped onto a fresh grid origin
assert abs(h2[0] - h1[0]) < 2000.0
assert abs(h2[0] - h1[0]) > 200.0
def test_compact_soft_recipe_unpublished() -> None:
from netx_api.mcp_remote.topology.layout_tool import resolve_recipe
try:
resolve_recipe("compact_soft")
raise AssertionError("compact_soft should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)

View file

@ -0,0 +1,84 @@
"""Tests for stage-2 pin_beam + soft_petals actions."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid
from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def _starish():
"""Two cores + two AN hubs each with a few stubs (positions intentionally messy)."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 100, "y": 500},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 900, "y": 100},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 200, "y": 800},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 800, "y": 50},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 50, "y": 900},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 350, "y": 950},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 750, "y": 20},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 950, "y": 40},
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 250, "y": 700},
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 850, "y": 200},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a0", "b_node_id": "e1"},
{"a_node_id": "a0", "b_node_id": "e4"},
{"a_node_id": "a1", "b_node_id": "e2"},
{"a_node_id": "a1", "b_node_id": "e3"},
{"a_node_id": "a1", "b_node_id": "e5"},
{"a_node_id": "e0", "b_node_id": "e4"},
{"a_node_id": "e2", "b_node_id": "e5"},
]
return nodes, edges
def test_actions_include_pin_and_petals() -> None:
assert "pin_beam" not in ACTIONS
assert "soft_petals" not in ACTIONS
def test_pin_beam_aligns_cores() -> None:
nodes, edges = _starish()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = pin_beam_rigid(st, LayoutParams())
assert op.op == "pin_beam"
ys = [op.state.positions["c0"][1], op.state.positions["c1"][1]]
# Either accepted with near-collinear cores, or refused (no_improvement) keeping input
if op.params.get("accepted"):
assert abs(ys[0] - ys[1]) < 5.0
else:
assert op.note in {"no_improvement", "need_ge_2_cores"}
def test_soft_petals_runs() -> None:
nodes, edges = _starish()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = soft_petals_greedy(st, LayoutParams())
assert op.op == "soft_petals"
assert "accepted_n" in op.params
def test_run_layout_on_graph_pin_beam_unpublished() -> None:
nodes, edges = _starish()
try:
run_layout_on_graph(nodes, edges, action="pin_beam")
raise AssertionError("pin_beam should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_layout_on_graph_soft_petals_unpublished() -> None:
nodes, edges = _starish()
try:
run_layout_on_graph(nodes, edges, action="soft_petals")
raise AssertionError("soft_petals should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,162 @@
"""press_hot_edges / press_crossers / polish_crossings MCP actions."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
crossings_after_node_move,
)
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.press_crossings import (
_large_graph_budget,
park_phantom_nodes,
press_hot_edges,
)
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_press_polish() -> None:
assert "press_hot_edges" not in ACTIONS
assert "press_crossers" not in ACTIONS
assert "polish_crossings" in ACTIONS
def test_park_phantom_region_nodes() -> None:
st = build_state_from_nodes_edges(
[
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{
"fabric_node_id": "region:x",
"name": "Units",
"x": 1e12,
"y": 100,
},
],
[],
)
moved = park_phantom_nodes(st)
assert "region:x" in moved
assert abs(st.positions["region:x"][0]) < 1000
def test_crossings_after_node_move_matches_full_recount() -> None:
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 400, "y": 100},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "a", "b_node_id": "d"},
]
st = build_state_from_nodes_edges(nodes, edges)
cur = count_edge_crossings(st.positions, st.links)
for xy in ((200.0, -40.0), (50.0, 50.0), (300.0, 300.0)):
inc = crossings_after_node_move(
st.positions, st.links, st.adj, "a", xy, current_total=cur
)
trial = dict(st.positions)
trial["a"] = xy
assert inc == count_edge_crossings(trial, st.links)
def test_large_graph_budget_scales_down() -> None:
small = _large_graph_budget(50)
huge = _large_graph_budget(500)
assert huge["hot_max_sweeps"] < small["hot_max_sweeps"]
assert huge["untangle_rounds"] < small["untangle_rounds"]
assert huge["untangle_rank_cap"] < small["untangle_rank_cap"]
assert huge["cross_cand_cap"] < small["cross_cand_cap"]
def test_press_hot_edges_reduces_x_crossing() -> None:
# Classic X: rotate one endpoint about the other can uncross.
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
]
# h-a and b-c cross; freeze hub h as portal
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "b"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.meta = {
"compose_views": {
"rigid_groups": [
{"key": "u1", "node_ids": ["h", "a", "b"], "pivots": ["h"]},
]
}
}
before = 1 # at least the X
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=8, max_sweeps=3)
assert op.state is not None
assert int(op.params.get("end_crossings") or 0) <= before + 2
def test_press_hot_edges_reels_in_long_spoke() -> None:
"""max_disp must not block inward pulls that shorten metro bridges."""
import math
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "leaf", "name": "LEAF", "x": 8000, "y": 0},
# Barrier segment the long spoke crosses.
{"fabric_node_id": "p", "name": "P", "x": 4000, "y": -800},
{"fabric_node_id": "q", "name": "Q", "x": 4000, "y": 800},
{"fabric_node_id": "anchor", "name": "A", "x": -200, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "leaf"},
{"a_node_id": "p", "b_node_id": "q"},
{"a_node_id": "h", "b_node_id": "anchor"},
]
st = build_state_from_nodes_edges(nodes, edges)
# Pad with dummy edges so large-graph budget (E>=400) applies.
for i in range(420):
a, b = f"d{i}", f"d{i+1}"
st.positions[a] = (float(i), 5000.0)
st.positions[b] = (float(i) + 1.0, 5000.0)
st.names[a] = a
st.names[b] = b
st.links.append((a, b))
st.adj.setdefault(a, []).append(b)
st.adj.setdefault(b, []).append(a)
before = count_edge_crossings(st.positions, st.links)
assert before >= 1
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=6, max_sweeps=2)
after_len = math.hypot(
op.state.positions["leaf"][0] - op.state.positions["h"][0],
op.state.positions["leaf"][1] - op.state.positions["h"][1],
)
assert after_len < 4000.0 or int(op.params.get("end_crossings") or before) < before
def test_run_layout_polish_crossings() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "a", "b_node_id": "b"},
]
out = run_layout_on_graph(
nodes,
edges,
action="polish_crossings",
params={"straighten": True, "max_degree": 7, "untangle_rounds": 40},
)
assert out["ok"] is True
assert out["action"] == "polish_crossings"
assert (out.get("overlap") or {}).get("footprint_pairs", 0) == 0

View file

@ -0,0 +1,84 @@
"""Tests for gated pull_far_chains."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import pull_far_chains
def test_pull_far_chains_shortens_corridor_without_raising_crossings():
# Portal pair + hub + long deg-2 tail stretching south.
nodes = [
{"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
{"fabric_node_id": "hub", "name": "Hub", "x": 500.0, "y": 200.0},
{"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 1200.0},
{"fabric_node_id": "b", "name": "B", "x": 500.0, "y": 2400.0},
{"fabric_node_id": "c", "name": "C", "x": 500.0, "y": 3600.0},
{"fabric_node_id": "tip", "name": "Tip", "x": 500.0, "y": 4800.0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "hub"},
{"a_node_id": "p2", "b_node_id": "hub"},
{"a_node_id": "hub", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "tip"},
]
st = build_state_from_nodes_edges(nodes, edges)
g0 = count_edge_crossings(st.positions, st.links)
y0 = st.positions["tip"][1]
area0 = abs(
(max(p[0] for p in st.positions.values()) - min(p[0] for p in st.positions.values()))
* (max(p[1] for p in st.positions.values()) - min(p[1] for p in st.positions.values()))
)
op = pull_far_chains(
st,
portal_ids=["p1", "p2"],
max_chains=4,
min_tip_radius=1000.0,
scales=(0.9, 0.8, 0.7),
max_clearance_slack=100,
)
assert op.params.get("chains_accepted", 0) >= 1
assert op.state.positions["tip"][1] < y0
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
assert not _has_any_footprint_overlap(op.state.positions, op.state.names)
area1 = abs(
(
max(p[0] for p in op.state.positions.values())
- min(p[0] for p in op.state.positions.values())
)
* (
max(p[1] for p in op.state.positions.values())
- min(p[1] for p in op.state.positions.values())
)
)
assert area1 < area0
def test_pull_far_chains_moves_isolates():
nodes = [
{"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0},
{"fabric_node_id": "iso", "name": "Iso", "x": 500.0, "y": 5000.0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "p2"},
]
st = build_state_from_nodes_edges(nodes, edges)
y0 = st.positions["iso"][1]
op = pull_far_chains(
st,
portal_ids=["p1", "p2"],
max_chains=4,
min_tip_radius=1000.0,
scales=(0.8, 0.7),
max_clearance_slack=100,
pull_isolates=True,
)
assert op.params.get("isolates_pulled", 0) >= 1
assert op.state.positions["iso"][1] < y0

View file

@ -0,0 +1,145 @@
"""Bridge-orbit densify: drag rigid exclusive body about external tip."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.rigid_orbit import (
rigid_orbit_candidates_for_group,
rigid_orbit_round,
)
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_rigid_orbit() -> None:
assert "rigid_orbit" not in ACTIONS
def _sparse_two_units() -> tuple:
# Unit A near origin; unit B far right; long bridge a2—b1.
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 3200, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 3400, "y": 60},
{"fabric_node_id": "b3", "name": "B3", "x": 3600, "y": -40},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2", "b3"]),
]
)
st.meta = {"compose_views": {"rigid_groups": groups}}
return st, groups
def test_candidates_prefer_inward_pull() -> None:
st, groups = _sparse_two_units()
g = next(g for g in groups if g["key"] == "vb")
parsed = {
"key": g["key"],
"members": g["node_ids"],
"pivots": g["pivots"],
"exclusive": [n for n in g["node_ids"] if n not in g["pivots"]],
"shared": list(g["pivots"]),
}
# vb has no shared pivots with va (p not in vb) — exclusive = all members
cands = rigid_orbit_candidates_for_group(
st.positions,
st.links,
parsed,
angle_step=45,
radii=(0.5, 0.7, 1.0),
bridges_per_group=2,
cand_cap=40,
x_slack=200,
)
assert cands, "expected bridge-orbit candidates for far unit"
# Best should densify (area_ratio > 1) when pulling toward a2 tip
assert cands[0]["area_ratio"] >= 1.0 or cands[0]["radius_scale"] < 1.0
def test_rigid_orbit_round_shrinks_bbox() -> None:
st, groups = _sparse_two_units()
before = score_state(st, fast=True)
util0 = float((before.get("summary") or {}).get("util") or 0.0)
xs = [p[0] for p in st.positions.values()]
ys = [p[1] for p in st.positions.values()]
area0 = (max(xs) - min(xs)) * (max(ys) - min(ys))
op = rigid_orbit_round(
st,
groups=groups,
top_n=4,
bridges_per_group=2,
angle_step=30,
radii=(0.45, 0.6, 0.75, 0.9),
x_slack=500,
max_accepts=4,
)
assert op.op == "rigid_orbit"
# Similarity: pairwise distance ratios inside vb stay equal (polar scale).
def _pair_lens(pos: dict) -> tuple[float, float]:
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
return d12, d13
L12_0, L13_0 = _pair_lens(st.positions)
L12_1, L13_1 = _pair_lens(op.state.positions)
assert L12_0 > 1 and L13_0 > 1
r12 = L12_1 / L12_0
r13 = L13_1 / L13_0
assert abs(r12 - r13) < 1e-5
if not (op.params or {}).get("reverted"):
xs1 = [p[0] for p in op.state.positions.values()]
ys1 = [p[1] for p in op.state.positions.values()]
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
after = score_state(op.state, fast=True)
util1 = float((after.get("summary") or {}).get("util") or 0.0)
assert area1 < area0 * 0.98 or util1 > util0 + 1e-6
def test_run_layout_rigid_orbit_action() -> None:
st, groups = _sparse_two_units()
nodes = [
{
"fabric_node_id": nid,
"name": st.names.get(nid, nid),
"x": st.positions[nid][0],
"y": st.positions[nid][1],
}
for nid in st.positions
]
edges = [{"a_node_id": a, "b_node_id": b} for a, b in st.links]
try:
run_layout_on_graph(
nodes,
edges,
action="rigid_orbit",
params={
"_rigid_membership": [
{"key": g["key"], "node_ids": g["node_ids"], "pivots": g["pivots"]}
for g in groups
],
"top_n": 4,
"x_slack": 500,
"radii": [0.5, 0.7, 0.9],
},
)
raise AssertionError("rigid_orbit should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,151 @@
"""Sub-regions stay rigid after compose / rigid_untangle."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, strip_pack_blocks
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
groups_from_membership,
rigid_untangle_groups,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams
from netx_api.mcp_remote.topology.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_rigid_untangle() -> None:
assert "rigid_untangle" not in ACTIONS
def test_protect_portals_freezes_shared_only() -> None:
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
st = LayoutState(
positions={"p": (0, 0), "a": (1, 0), "b": (2, 0)},
names={},
adj={},
links=[],
layers={},
meta={
"compose_views": {
"rigid_groups": [
{"key": "va", "node_ids": ["p", "a"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b"], "pivots": ["p"]},
]
}
},
)
assert frozen_ids_for_protect(st, "portals") == {"p"}
assert frozen_ids_for_protect(st, "all") == {"p", "a", "b"}
assert frozen_ids_for_protect(st, "off") == set()
# Shared corridor `c` is multi-membership but not a pivot — must stay movable.
st.meta["compose_views"]["rigid_groups"] = [
{"key": "va", "node_ids": ["p", "a", "c"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b", "c"], "pivots": ["p"]},
]
assert frozen_ids_for_protect(st, "portals") == {"p"}
assert "c" not in frozen_ids_for_protect(st, "portals")
def test_compose_emits_rigid_groups() -> None:
a = ComposeBlock(
"va",
{"p": (0.0, 0.0), "a1": (100.0, 50.0), "a2": (200.0, 0.0)},
)
b = ComposeBlock(
"vb",
{"p": (0.0, 0.0), "b1": (100.0, -40.0), "b2": (200.0, 0.0)},
)
_merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
groups = meta.get("rigid_groups") or []
assert len(groups) == 2
by_key = {g["key"]: g for g in groups}
assert "p" in by_key["vb"]["pivots"]
assert set(by_key["va"]["node_ids"]) >= {"p", "a1", "a2"}
def test_rigid_untangle_preserves_relative_geometry() -> None:
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 100, "y": 50},
{"fabric_node_id": "a2", "name": "A2", "x": 200, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 500, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2"]),
]
)
# Capture relative offset inside va
dx0 = st.positions["a2"][0] - st.positions["a1"][0]
dy0 = st.positions["a2"][1] - st.positions["a1"][1]
op = rigid_untangle_groups(st, LayoutParams(), groups=groups, max_rounds=8, step=200.0)
dx1 = op.state.positions["a2"][0] - op.state.positions["a1"][0]
dy1 = op.state.positions["a2"][1] - op.state.positions["a1"][1]
# Distance preserved (rigid); angle may flip 90° so check length
import math
assert abs(math.hypot(dx1, dy1) - math.hypot(dx0, dy0)) < 1e-6
def test_rigid_fan_out_reduces_bbox_stack() -> None:
"""Two eyes glued on one portal should prefer a non-stacked angle."""
from netx_api.mcp_remote.topology.layout_ops.rigid_units import rigid_fan_out_portals
# Same portal p; unit A along +x, unit B also along +x (stacked).
pos = {
"p": (0.0, 0.0),
"a1": (100.0, 20.0),
"a2": (200.0, 0.0),
"b1": (100.0, 20.0),
"b2": (200.0, 0.0),
}
groups = [
{"key": "va", "node_ids": ["p", "a1", "a2"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b1", "b2"], "pivots": ["p"]},
]
links = [("p", "a1"), ("a1", "a2"), ("p", "b1"), ("b1", "b2")]
out, n = rigid_fan_out_portals(pos, groups, links)
assert n >= 1
# After fan-out, B should not sit on A's exclusive stack.
assert abs(out["b1"][0] - out["a1"][0]) > 1.0 or abs(
out["b1"][1] - out["a1"][1]
) > 1.0
def test_run_layout_rigid_untangle() -> None:
nodes = [
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2", "x": 100, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 400, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 500, "y": 0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="rigid_untangle",
params={
"_rigid_membership": [
{"key": "va", "node_ids": ["a1", "a2"]},
{"key": "vb", "node_ids": ["b1", "b2"]},
]
},
)
raise AssertionError("rigid_untangle should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,107 @@
"""Tests for sinkTopologyDualUnits selection helpers + tool registration."""
from __future__ import annotations
from netx_api.mcp_remote.topology.http_tools import HTTP_MCP_TOOLS
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
batch_node_ids,
layout_and_pack_batch,
leftover_batch_ids,
park_positions,
select_dual_unit_batch,
)
def _u(uid: int, a: str, b: str, *mids: str) -> DualUnit:
path = [a, *mids, b] if mids else [a, b]
return DualUnit(portal_a=a, portal_b=b, paths=[path], tails=[], unit_id=uid)
def test_select_prefers_less_shared_portals() -> None:
units = [
_u(1, "X", "A", "m1", "m2", "m3", "m4", "m5", "m6"),
_u(2, "X", "B", "n1", "n2", "n3", "n4", "n5", "n6"),
_u(3, "C", "D", "p1", "p2", "p3", "p4", "p5", "p6"),
]
picked = select_dual_unit_batch(units, max_units=1, min_nodes=8, max_nodes=80)
assert len(picked) == 1
assert picked[0].unit_id == 3
def test_select_respects_batch_cap() -> None:
units = [
_u(1, "A", "B", "m1", "m2", "m3", "m4", "m5", "m6"),
_u(2, "C", "D", "n1", "n2", "n3", "n4", "n5", "n6"),
_u(3, "E", "F", "p1", "p2", "p3", "p4", "p5", "p6"),
]
picked = select_dual_unit_batch(
units, max_units=3, min_nodes=8, max_nodes=80, max_batch_nodes=20
)
ids = batch_node_ids(picked)
assert len(ids) <= 20
assert len(picked) >= 1
def test_leftover_and_park() -> None:
left = leftover_batch_ids(
["a", "region:x", "b", "c"], max_batch_nodes=2, exclude_ids={"b"}
)
assert left == ["a", "c"]
pos = park_positions(
{"a": (10.0, 10.0), "c": (30.0, 10.0)},
["a", "c"],
sink_pos={"z": (100.0, 50.0)},
pad=40.0,
)
assert len(pos) == 2
# Orbit block-sweep: not fixed right — just leave sink tip with clearance.
assert all("x" in p and "y" in p for p in pos)
assert max(abs(p["x"] - 100.0) for p in pos) >= 40.0 or max(
abs(p["y"] - 50.0) for p in pos
) >= 40.0
def test_layout_and_pack_batch_zero_cross_units() -> None:
nodes = [
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p2"},
]
st = build_state_from_nodes_edges(nodes, edges)
units = find_dual_portal_units(st)
assert units
world, reports, attach = layout_and_pack_batch(
st, units[:1], sink_pos={"z": (500.0, 0.0)}, pad=50.0, links=list(st.links)
)
assert world
assert attach.get("via") in {"orbit_orphan", "orbit_portal", "orbit_dual", "seed"}
assert reports and reports[0].get("accepted") is True
members = units[0].member_ids()
unit_links = [e for e in st.links if e[0] in members and e[1] in members]
assert count_edge_crossings(world, unit_links) == 0
def test_tools_registered() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
assert len(names) >= 14

View file

@ -0,0 +1,146 @@
"""Tests for suggestSinkHubs batch ranking."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import (
pick_batch,
suggest_sink_hub_batches,
)
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
def _star_graph():
# CN portals + TJP-like agg hub with 3 EN stubs + small GENA hub with 1 stub
nodes = [
{"fabric_node_id": "cn1", "name": "X-CN1-Y", "level": 1, "x": 0, "y": 0},
{"fabric_node_id": "cn2", "name": "X-CN2-Y", "level": 1, "x": 100, "y": 0},
{"fabric_node_id": "tjp", "name": "X-TJP-AN1-Y", "level": 2, "x": 50, "y": 100},
{"fabric_node_id": "e1", "name": "X-E1-EN1-Y", "level": 3, "x": 0, "y": 200},
{"fabric_node_id": "e2", "name": "X-E2-EN1-Y", "level": 3, "x": 50, "y": 200},
{"fabric_node_id": "e3", "name": "X-E3-EN1-Y", "level": 3, "x": 100, "y": 200},
{"fabric_node_id": "gena", "name": "X-GENA-AN1-Y", "level": 2, "x": 200, "y": 100},
{"fabric_node_id": "e4", "name": "X-E4-EN1-Y", "level": 3, "x": 200, "y": 200},
]
edges = [
{"a_node_id": "cn1", "b_node_id": "cn2"},
{"a_node_id": "cn1", "b_node_id": "tjp"},
{"a_node_id": "cn2", "b_node_id": "tjp"},
{"a_node_id": "tjp", "b_node_id": "e1"},
{"a_node_id": "tjp", "b_node_id": "e2"},
{"a_node_id": "tjp", "b_node_id": "e3"},
{"a_node_id": "cn2", "b_node_id": "gena"},
{"a_node_id": "gena", "b_node_id": "e4"},
]
return nodes, edges
def test_suggest_ranks_largest_territory_first() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
src = {n["fabric_node_id"] for n in nodes}
report = suggest_sink_hub_batches(
hubs=struct["hubs"],
soft_blocks=struct["soft_blocks"],
source_ids=src,
sink_ids=set(),
dual_units=struct.get("dual_units"),
min_territory=1,
top_n=5,
)
assert report["ok"] is True
assert report["batch_count"] >= 1
top = report["batches"][0]
# TJP should beat GENA; CN portals must not lead
assert top["hub_id"] == "tjp"
assert "cn1" not in top["fabric_node_ids"]
assert "cn2" not in top["fabric_node_ids"]
assert set(top["fabric_node_ids"]) >= {"tjp", "e1", "e2", "e3"}
def test_suggest_drops_ids_already_on_sink() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
src = {n["fabric_node_id"] for n in nodes}
report = suggest_sink_hub_batches(
hubs=struct["hubs"],
soft_blocks=struct["soft_blocks"],
source_ids=src,
sink_ids={"e1", "e2"},
exclude_ids={"cn1", "cn2"},
min_territory=1,
top_n=5,
)
top = pick_batch(report, 1)
assert top is not None
assert top["hub_id"] == "tjp"
assert "e1" not in top["fabric_node_ids"]
assert "e2" not in top["fabric_node_ids"]
assert "e3" in top["fabric_node_ids"]
def test_suggest_skips_portal_as_hub_leader() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
src = {n["fabric_node_id"] for n in nodes}
report = suggest_sink_hub_batches(
hubs=struct["hubs"],
soft_blocks=struct["soft_blocks"],
source_ids=src,
exclude_ids={"tjp"}, # force skip TJP as leader id
dual_units={"units": [{"portal_a": "cn1", "portal_b": "cn2"}]},
min_territory=1,
top_n=5,
)
hubs = [b["hub_id"] for b in report["batches"]]
assert "cn1" not in hubs and "cn2" not in hubs
# Excluded hub may still emit stub-only batch (hub id not in move list)
tjp_batch = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
assert tjp_batch is not None
assert "tjp" not in tjp_batch["fabric_node_ids"]
assert set(tjp_batch["fabric_node_ids"]) >= {"e1", "e2", "e3"}
def test_suggest_moves_stubs_under_sunk_hub() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
src = {n["fabric_node_id"] for n in nodes}
report = suggest_sink_hub_batches(
hubs=struct["hubs"],
soft_blocks=struct["soft_blocks"],
source_ids=src,
sink_ids={"tjp", "e1", "e2"}, # hub already on sink
exclude_ids={"cn1", "cn2"},
min_territory=1,
top_n=5,
)
tjp = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None)
assert tjp is not None
assert tjp["already_on_sink"] is True
assert tjp["fabric_node_ids"] == ["e3"]
def test_suggest_orphan_leftovers_batch() -> None:
nodes, edges = _star_graph()
# Add disconnected orphans on source
nodes = list(nodes) + [
{"fabric_node_id": "iso1", "name": "X-ISO1-EN1-Y", "level": 3, "x": 900, "y": 900},
{"fabric_node_id": "iso2", "name": "X-ISO2-EN1-Y", "level": 3, "x": 950, "y": 950},
]
struct = analyze_graph_structure(nodes, edges, hub_top_k=10)
src = {n["fabric_node_id"] for n in nodes}
# Everything except orphans already on sink
sink = src - {"iso1", "iso2"}
report = suggest_sink_hub_batches(
hubs=struct["hubs"],
soft_blocks=struct["soft_blocks"],
source_ids=src,
sink_ids=sink,
exclude_ids={"cn1", "cn2"},
min_territory=0,
top_n=8,
)
assert report["orphan_n"] == 2
orphan = next((b for b in report["batches"] if b.get("orphan")), None)
assert orphan is not None
assert set(orphan["fabric_node_ids"]) == {"iso1", "iso2"}
assert orphan["block_method"] == "orphan_leftovers"

View file

@ -0,0 +1,263 @@
"""HTTP contract and partial-failure regression tests; no backend required."""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from netx_api.mcp_remote.topology import http_tools as tools
def payload(name, args):
result = tools.call_http_tool(name, args)
return result, json.loads(result["content"][0]["text"])
@pytest.mark.parametrize("name,args", [
("getTopologyTree", {}),
("getTopologyView", {"view_id": "v"}),
("createTopologyFolder", {"name": "测试"}),
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("updateTopologyViewPositions", {"view_id": "v", "layout": "grid"}),
("projectTopologyNeighbors", {"view_id": "v"}),
("queryTopologyFabricNodes", {"mode": "list"}),
("classifyTopologyFabricNodes", {"action": "apply_rules"}),
("queryTopologyNeighborhood", {"node_id": "n"}),
("queryTopologyEdges", {}),
("layoutTopologyView", {"view_id": "v"}),
("suggestSinkHubs", {"source_view_id": "s"}),
("analyzeTopologyViewLayout", {"view_id": "v"}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t"}),
])
def test_business_failure_is_mcp_error(name, args):
with patch.object(tools, "http_json", return_value={
"ok": True, "data": {"ok": False, "error": "backend_rejected"},
}):
result, data = payload(name, args)
assert result.get("isError") is True, (name, data)
assert data["ok"] is False
assert data["error"] == "backend_rejected"
@pytest.mark.parametrize("name,args,method", [
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("updateTopologyViewPositions", {"view_id": "v", "positions": [
{"fabric_node_id": f"n{i}", "x": i, "y": 0} for i in range(501)
]}, "PATCH"),
])
def test_chunk_failure_stops_later_mutations(name, args, method):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": {"ok": True, "added": 200, "updated": 200, "removed": 200}},
{"ok": True, "data": {"ok": False, "error": "write_failed"}},
]) as call:
result, data = payload(name, args)
assert result.get("isError") is True
assert call.call_count == 2
assert call.call_args.args[0] == method
assert data["error"] == "write_failed"
def graph(node="n"):
return {"ok": True, "nodes": [{"fabric_node_id": node, "x": 1, "y": 2}], "edges": []}
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_position_failure_preserves_source(name, args):
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": graph() if path.endswith("/s") else {"nodes": [], "edges": []}}
raise AssertionError(f"Unexpected mutation {method} {path}")
with patch.object(tools, "http_json", side_effect=http), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": True}
), patch.object(tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}), patch.object(
tools, "_patch_positions_chunked", return_value={"ok": False, "error": "patch_denied", "updated": 0}
), patch.object(tools, "_fabric_bridges_into", return_value=[]), patch.object(
tools, "_remove_topology_view_nodes"
) as remove:
result, data = payload(name, args)
assert result.get("isError") is True, data
assert data["error"] == "patch_denied"
assert data["added"] == 1
remove.assert_not_called()
def test_move_capacity_error_stays_error():
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": False, "error": "cap_denied"}
), patch.object(tools, "_add_topology_view_nodes") as add:
result, data = payload("layoutTopologyView", {
"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply",
"params": {"fabric_node_ids": ["n"]},
})
assert result.get("isError") is True
assert data["error"] == "cap_denied"
add.assert_not_called()
def test_invalid_add_does_not_change_capacity():
with patch.object(tools, "http_json") as http:
result, _ = payload("addTopologyViewNodes", {"view_id": "v", "max_nodes": 2000})
assert result.get("isError") is True
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("getTopologyView", {"view_id": "v", "sample": 0}),
("projectTopologyNeighbors", {"view_id": "v", "sample": 0, "dry_run": True}),
])
def test_zero_sample_and_projection_preview(name, args):
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}) as http:
result, data = payload(name, args)
assert result.get("isError") is not True
assert data["sample_nodes"] == []
if name == "projectTopologyNeighbors":
assert http.call_args.kwargs["body"]["dry_run"] is True
assert data["dry_run"] is True
def test_invalid_projection_detail_does_not_write():
with patch.object(tools, "http_json") as http:
result, data = payload("projectTopologyNeighbors", {"view_id": "v", "detail": "raw"})
assert result.get("isError") is True
assert data["error"] == "detail_invalid"
http.assert_not_called()
def test_zero_updated_is_not_reported_as_successful_count():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"ok": True, "updated": 0, "skipped_locked": 1}}):
assert tools._patch_positions_chunked("v", [{"fabric_node_id": "n", "x": 1, "y": 2}])["updated"] == 0
def test_apply_rules_uses_query_flags():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"updated": 1}}) as http:
payload("classifyTopologyFabricNodes", {"action": "apply_rules", "skip_manual": False, "fill_empty_only": True})
assert http.call_args.args == ("POST", "/v1/topology/classify/apply")
assert http.call_args.kwargs["params"] == {"skip_manual": False, "fill_empty_only": True}
@pytest.mark.parametrize("action", ["resolve_overlaps", "sink_nodes", "align_to_reference", "compose_views", "compose_orbit"])
def test_removed_layout_actions_are_not_advertised_or_called(action):
with patch.object(tools, "http_json") as http:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": action})
assert result.get("isError") is True
assert data["error"] == f"unknown_action:{action}"
assert action not in data["actions"]
schema = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")["inputSchema"]
assert action not in schema["properties"]["action"]["enum"]
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("queryTopologyFabricNodes", {"mode": "find"}),
("classifyTopologyFabricNodes", {"action": "apply"}),
("queryTopologyEdges", {"detail": "raw"}),
])
def test_removed_query_aliases_do_not_call_backend(name, args):
with patch.object(tools, "http_json") as http:
result, _ = payload(name, args)
assert result.get("isError") is True
http.assert_not_called()
def test_explicit_background_layout_starts_worker():
canvas = graph()
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
tools, "start_job", return_value="test-job"
) as start, patch.object(tools, "job_public", return_value=None), patch.object(tools, "run_layout_on_graph") as compute:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is not True
assert data["job_id"] == "test-job"
assert data["applied"] is False
assert start.call_args.kwargs["tool_args"]["params"]["_force_sync"] is True
compute.assert_not_called()
def test_background_spawn_error_is_immediately_reported():
from netx_api.mcp_remote.topology import layout_jobs as jobs
canvas = graph()
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
jobs.subprocess, "Popen", side_effect=OSError("worker unavailable")
):
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is True
assert data["status"] == "error"
assert data["error"].startswith("worker_start_failed:")
assert data["job_id"]
def test_background_apply_poll_and_persistent_view_readback():
"""Execute worker orchestration inline against an in-memory HTTP backend."""
from types import SimpleNamespace
from netx_api.mcp_remote.topology import layout_jobs as jobs
from netx_api.mcp_remote.topology.layout_job_worker import main as worker_main
canvas = {"nodes": [
{"fabric_node_id": "n", "x": 0, "y": 0},
{"fabric_node_id": "n2", "x": 100, "y": 200},
], "edges": []}
positions = [
{"fabric_node_id": "n", "x": 20, "y": 30},
{"fabric_node_id": "n2", "x": 220, "y": 330},
]
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": canvas}
assert method == "PATCH" and path == "/v1/topology/views/v/positions"
canvas["nodes"] = kwargs["body"]["positions"]
return {"ok": True, "data": {"ok": True, "updated": 2}}
def spawn(argv, **kwargs):
assert worker_main(["--job-id", argv[-1]]) == 0
return SimpleNamespace(pid=123)
with patch.object(tools, "http_json", side_effect=http), patch.object(
jobs.subprocess, "Popen", side_effect=spawn
), patch.object(tools, "run_layout_on_graph", return_value={"ok": True, "positions": positions, "overlap": {}}):
_, started = payload("layoutTopologyView", {
"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True},
})
jobs._JOBS.clear()
_, polled = payload("layoutTopologyView", {"action": "job_status", "params": {"job_id": started["job_id"]}})
_, view = payload("getTopologyView", {"view_id": "v", "detail": "full"})
assert polled["status"] == "done"
assert polled["result"]["applied"] is True
assert polled["result"]["updated"] == 2
assert view["nodes"] == positions
def test_hub_schema_matches_default_exclusion_behavior():
tool = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "suggestSinkHubs")
assert tool["inputSchema"]["properties"]["use_source_dual_portals"]["default"] is False
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_failed_final_readback_reports_completed_writes(name, args):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": graph()}, {"ok": True, "data": {"nodes": [], "edges": []}},
{"ok": False, "error": "read_timeout"}, {"ok": False, "error": "read_timeout"},
]), patch.object(tools, "_ensure_view_max_nodes", return_value={"ok": True}), patch.object(
tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}
), patch.object(tools, "_patch_positions_chunked", return_value={"ok": True, "updated": 1}), patch.object(
tools, "_remove_topology_view_nodes", return_value={"ok": True, "removed": 1}
), patch.object(tools, "_fabric_bridges_into", return_value=[]):
result, data = payload(name, args)
assert result.get("isError") is True
assert data["error"] == "view_readback_failed"
assert data["write_completed"] is True

View file

@ -0,0 +1,201 @@
"""Tests for chain cohesion + min-ring integrity mid-tier metrics."""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats, score_layout_components
from netx_api.mcp_remote.topology.layout_topology_quality import (
compute_chain_cohesion,
compute_ring_integrity,
extract_chain_paths,
)
def _pos_links_from_nodes(nodes, edges):
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
links = [(e["a_node_id"], e["b_node_id"]) for e in edges]
return pos, links
def test_straight_chain_scores_high() -> None:
# hub + straight corridor
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
from collections import defaultdict
adj: dict[str, set[str]] = defaultdict(set)
for a, b in links:
adj[a].add(b)
adj[b].add(a)
assert extract_chain_paths(adj)
q = compute_chain_cohesion(pos, links)
assert q["score"] >= 0.9
assert q["kink_count"] == 0
def test_kinked_chain_scores_lower() -> None:
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 200},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
bent = compute_chain_cohesion(pos, links)
# straighten
nodes2 = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
pos2, _ = _pos_links_from_nodes(nodes2, edges)
straight = compute_chain_cohesion(pos2, links)
assert straight["score"] > bent["score"]
assert bent["kink_count"] >= 1
def test_ring_pierce_detected() -> None:
# Square cycle + a chord that crosses through (diagonal would share verts;
# use an external edge that crosses one side).
# Cycle: a-b-c-d-a. External: e--f crosses a-b vertically.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
{"fabric_node_id": "e", "name": "E", "x": 100, "y": -50},
{"fabric_node_id": "f", "name": "F", "x": 100, "y": 50},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "a"},
{"a_node_id": "e", "b_node_id": "f"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
pierced = compute_ring_integrity(pos, links)
assert pierced["ring_count"] >= 1
assert pierced["rings_pierced"] >= 1
assert pierced["score"] < 1.0
# move e-f away → no pierce
nodes2 = [
*nodes[:4],
{"fabric_node_id": "e", "name": "E", "x": 300, "y": -50},
{"fabric_node_id": "f", "name": "F", "x": 300, "y": 50},
]
pos2, _ = _pos_links_from_nodes(nodes2, edges)
clean = compute_ring_integrity(pos2, links)
assert clean["rings_pierced"] == 0
assert clean["score"] == 1.0
def test_score_includes_mid_tier_weights() -> None:
s = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 0.2,
"rings_score": 0.2,
"edge_clearance_score": 0.2,
"edge_axis_score": 0.2,
}
)
assert "chain" in s["parts"]
assert "rings" in s["parts"]
assert "edge_clearance" in s["parts"]
assert "edge_axis" in s["parts"]
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
# default profile: rings down, clearance up vs legacy 0.10/0.08
assert s["weights"]["rings"] <= 0.08
assert s["weights"]["edge_clearance"] >= 0.08
eye = score_layout_components(
{
"node_count": 200,
"edge_crossings": 50,
"crossings_per_link": 0.15,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.05,
"hull_utilization": 0.09,
"grid_occupancy": 0.05,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.5,
"chain_score": 0.6,
"rings_score": 0.3,
"edge_clearance_score": 0.5,
"edge_axis_score": 0.3,
"axis_frac": 0.35,
},
score_profile="eye",
)
assert eye["score_profile"] == "eye"
assert eye["weights"]["edge_axis"] < s["weights"]["edge_axis"]
good = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 1.0,
"edge_axis_score": 1.0,
}
)
assert good["total"] > s["total"]
def test_report_exposes_chains_and_rings() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 400, "y": 0},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
r = analyze_layout_stats(nodes, edges)["report"]
assert "chains" in r
assert "rings" in r
assert "chain" in r["score"]["parts"]
assert "rings" in r["score"]["parts"]
assert r["chains"]["status"] in {"ok", "warn", "fail"}
assert r["rings"]["status"] in {"ok", "warn", "fail"}

View file

@ -0,0 +1,54 @@
"""Untangle must not fling nodes across the canvas."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.untangle import _MAX_JUMP, _candidates, untangle_crossings
def test_candidates_stay_near_node() -> None:
pos = {
"a": (0.0, 0.0),
"b": (5000.0, 0.0), # far neighbor — old code used dx*3.5 → 17k jump
"c": (2500.0, 800.0),
}
adj = {"c": {"a", "b"}, "a": {"c"}, "b": {"c"}}
import random
cands = _candidates(pos, "c", adj, random.Random(1))
assert cands
x0, y0 = pos["c"]
for x, y in cands:
assert math.hypot(x - x0, y - y0) <= _MAX_JUMP + 1e-6
def test_untangle_preserves_bbox_order() -> None:
# Long chain with one intentional crossing-ish layout; moves stay local.
nodes = []
edges = []
for i in range(8):
nodes.append(
{
"fabric_node_id": f"n{i}",
"name": f"N{i}-EN-1",
"x": float(i * 200),
"y": 0.0 if i % 2 == 0 else 40.0,
}
)
if i:
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
# add a long chord that crosses nothing badly but gives degree
edges.append({"a_node_id": "n0", "b_node_id": "n7"})
st = build_state_from_nodes_edges(nodes, edges)
before = dict(st.positions)
xs0 = [p[0] for p in before.values()]
ys0 = [p[1] for p in before.values()]
span0 = max(max(xs0) - min(xs0), max(ys0) - min(ys0))
out = untangle_crossings(st, max_rounds=40, max_degree=7, target_crossings=0)
xs1 = [p[0] for p in out.state.positions.values()]
ys1 = [p[1] for p in out.state.positions.values()]
span1 = max(max(xs1) - min(xs1), max(ys1) - min(ys1))
# Must not explode bbox by >2x
assert span1 <= span0 * 2.0 + 800.0