Ship gated eye polish, fabric levels, and collection UI refresh.

Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

View file

@ -0,0 +1,55 @@
"""Tests for align_to_reference."""
from __future__ import annotations
from netx_topology_mcp.layout_ops.align_reference import align_to_reference
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.bundle_orbit import (
detect_chain_bundles,
detect_ring_chain_bundles,
orbit_bundle,
apply_bundle_pick,
bundle_orbit_until_progress,
)
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.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

@ -37,7 +37,7 @@ def test_collinear_midpoint_scores_edge_clearance_hit() -> None:
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.08) < 1e-9
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)
@ -118,9 +118,8 @@ def test_score_weights_include_edge_clearance() -> None:
"edge_axis_score": 0.2,
}
)
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
assert abs(s["weights"]["edge_clearance"] - 0.10) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
assert abs(s["weights"]["grid"] - 0.04) < 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(

View file

@ -0,0 +1,40 @@
"""Tests for gated compact_bbox."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.compact_bbox import compact_bbox
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.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

@ -1,4 +1,4 @@
"""Dual-portal eye units: detect, zero-cross layout, shared-portal compose."""
"""Dual-portal eye units: detect, CN-first max-cover layout, shared-portal compose."""
from __future__ import annotations
@ -42,6 +42,39 @@ def _eye_graph():
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
@ -60,6 +93,16 @@ def test_find_dual_portal_units_eye() -> None:
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)
@ -70,14 +113,141 @@ def test_layout_dual_unit_zero_crossings() -> None:
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
out = run_layout_on_graph(nodes, edges, action="layout_dual_unit")
assert out["ok"] is True
assert out["action"] == "layout_dual_unit"
loc = out.get("local") or {}
assert loc.get("accepted") is True
assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 0
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:
@ -85,13 +255,9 @@ def test_structure_reports_dual_units() -> None:
report = analyze_graph_structure(nodes, edges)
du = report.get("dual_units") or {}
assert int(du.get("unit_count") or 0) >= 1
assert report.get("advice", {}).get("prefer_dual_units") is True or du.get(
"unit_count", 0
) >= 1
def test_compose_merge_shared_portal_unique_coord() -> None:
"""Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
def test_shared_portal_compose_preserves_relative() -> None:
a = ComposeBlock(
key="unit-p1-p2",
positions={
@ -111,11 +277,8 @@ def test_compose_merge_shared_portal_unique_coord() -> None:
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
# Relative offset p2→p3 preserved after rigid glue (200 on x in local B).
dx = merged["p3"][0] - merged["p2"][0]
dy = merged["p3"][1] - merged["p2"][1]
assert abs(dx - 200.0) < 1e-6
assert abs(dy) < 1e-6
# b1 relative to p2 preserved.
assert abs(merged["b1"][0] - merged["p2"][0] - 100.0) < 1e-6
assert abs(merged["b1"][1] - merged["p2"][1] + 60.0) < 1e-6
# 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

@ -262,9 +262,52 @@ def test_tools_for_scopes_filters_write() -> None:
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_topology_mcp.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:

View file

@ -213,6 +213,221 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None:
assert 0.0 <= float(c["y"]) <= 80.0
def test_orbit_until_limit_params_defaults() -> None:
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.layout_ops.orbit_sweep import (
_W_CLR,

View file

@ -0,0 +1,84 @@
"""Tests for gated pull_far_chains."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
from netx_topology_mcp.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

@ -104,4 +104,4 @@ 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
assert len(names) >= 14

View file

@ -0,0 +1,146 @@
"""Tests for suggestSinkHubs batch ranking."""
from __future__ import annotations
from netx_topology_mcp.layout_ops.suggest_sink_hubs import (
pick_batch,
suggest_sink_hub_batches,
)
from netx_topology_mcp.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

@ -131,11 +131,33 @@ def test_score_includes_mid_tier_weights() -> None:
assert "edge_clearance" in s["parts"]
assert "edge_axis" in s["parts"]
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
assert abs(s["weights"]["rings"] - 0.10) < 1e-9
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
assert abs(s["weights"]["grid"] - 0.04) < 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,