mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 02:10:46 +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
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue