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