mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 13:23:21 +08:00
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:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal file
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal 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
|
||||
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal file
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal file
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal file
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal 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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue