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