netx/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py
oliver b81e5869a6 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>
2026-08-12 16:13:05 +08:00

625 lines
23 KiB
Python

"""Graph structure stats for layout planning (shape / gravity / blocks).
Read-only: no coordinates required. Feeds analyzeTopologyViewLayout(detail=structure).
Order of judgment (important):
1) global / per-component **shape** (chains first — simplest & common)
2) hub gravity (core_bar / agg_bar / mixed) only when not chain-like
3) complex canvases = pack of blocks; lay each block, then compose
4) soft_blocks: hub territories inside giant CCs (+ optional igraph leftovers)
"""
from __future__ import annotations
from collections import defaultdict, deque
from statistics import mean, median
from typing import Any
from netx_topology_mcp.layout_metrics import collapse_links
from netx_topology_mcp.layout_ops.graph_util import infer_layer
def _pct(xs: list[int], p: float) -> float:
if not xs:
return 0.0
s = sorted(xs)
if len(s) == 1:
return float(s[0])
i = min(len(s) - 1, max(0, int(round((len(s) - 1) * p))))
return float(s[i])
def _connected_components(
ids: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for s in sorted(ids, key=lambda x: names.get(x, x)):
if s in seen:
continue
q: deque[str] = deque([s])
seen.add(s)
comp: list[str] = []
while q:
u = q.popleft()
comp.append(u)
for v in adj.get(u, ()):
if v not in seen:
seen.add(v)
q.append(v)
out.append(comp)
out.sort(key=lambda c: (-len(c), names.get(c[0], c[0])))
return out
def _farthest(
start: str, allowed: set[str], adj: dict[str, set[str]]
) -> tuple[tuple[str, int], dict[str, str | None]]:
q: deque[tuple[str, int]] = deque([(start, 0)])
seen = {start}
best = (start, 0)
parent: dict[str, str | None] = {start: None}
while q:
u, d = q.popleft()
if d > best[1]:
best = (u, d)
for v in adj.get(u, ()):
if v in allowed and v not in seen:
seen.add(v)
parent[v] = u
q.append((v, d + 1))
return best, parent
def _diameter_path(
comp: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
s = set(comp)
if not s:
return []
leaves = [n for n in comp if len(adj.get(n, ())) <= 1] or list(comp)
start = sorted(leaves, key=lambda n: names.get(n, n))[0]
(e1, _), _ = _farthest(start, s, adj)
(e2, _), parent = _farthest(e1, s, adj)
path = [e2]
while parent[path[-1]] is not None:
path.append(parent[path[-1]] or "")
path.reverse()
return [n for n in path if n]
def _block_shape(
comp: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
layers: dict[str, str],
) -> dict[str, Any]:
"""Classify one connected component: chain | star | mesh | tiny."""
n = len(comp)
if n <= 2:
return {
"shape": "tiny",
"node_count": n,
"link_count": sum(len(adj[u]) for u in comp) // 2,
"spine_len": n,
"chain_frac": 1.0,
"max_degree": max((len(adj[u]) for u in comp), default=0),
"mean_degree": round(mean(len(adj[u]) for u in comp), 2) if comp else 0.0,
}
degs = [len(adj[u]) for u in comp]
low = sum(1 for d in degs if d <= 2) / n
max_d = max(degs)
mean_d = mean(degs)
path = _diameter_path(comp, adj, names)
spine_len = len(path)
# nodes on diameter path / n — high ⇒ path-like
on_spine = spine_len / n
# edges ≈ n-1 ⇒ tree/path; denser ⇒ mesh
e = sum(degs) // 2
treeish = e <= n # forest/tree (path has e=n-1)
shape = "mesh"
# Path / corridor first (CN/AN on spine with deg≤5 still counts as chain)
nearly_path = mean_d <= 2.45 and low >= 0.62 and max_d <= 5 and e <= n + 2
if nearly_path or (treeish and low >= 0.65 and mean_d <= 2.5 and max_d <= 5):
shape = "chain"
elif treeish and low >= 0.72 and mean_d <= 2.35 and max_d <= 6:
shape = "chain"
elif on_spine >= 0.25 and low >= 0.68 and mean_d <= 2.4 and max_d <= 5:
shape = "chain"
elif max_d >= 7 and low >= 0.55 and on_spine < 0.4:
shape = "star"
elif max_d >= 8 or (mean_d >= 2.8 and e > n + max(2, n // 10)):
shape = "mesh"
else:
if on_spine >= 0.35 and low >= 0.6:
shape = "chain"
elif max_d >= 6 and low >= 0.5:
shape = "star"
hubs = sorted(
[u for u in comp if layers.get(u) in {"core", "agg"}],
key=lambda u: (-len(adj[u]), names.get(u, u)),
)[:4]
return {
"shape": shape,
"node_count": n,
"link_count": e,
"spine_len": spine_len,
"on_spine_frac": round(on_spine, 3),
"chain_frac": round(low, 3), # frac deg<=2
"max_degree": max_d,
"mean_degree": round(mean_d, 2),
"treeish": treeish,
"hub_ids": hubs,
"spine_ends": [path[0], path[-1]] if path else [],
"spine_end_names": [names.get(path[0], ""), names.get(path[-1], "")] if path else [],
}
def _bfs_territory(
hubs: list[str],
access: set[str],
adj: dict[str, set[str]],
) -> dict[str, str]:
"""Multi-source Voronoi on access: each access node → nearest hub (via stubs)."""
owner: dict[str, str] = {}
q: deque[str] = deque()
for h in sorted(hubs):
for nb in adj.get(h, ()):
if nb in access and nb not in owner:
owner[nb] = h
q.append(nb)
while q:
u = q.popleft()
h = owner[u]
for v in adj.get(u, ()):
if v in access and v not in owner:
owner[v] = h
q.append(v)
return owner
def analyze_graph_structure(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
hub_top_k: int = 12,
stub_top_k: int = 24,
) -> dict[str, Any]:
"""Return shape/blocks, hubs, gravity type, recipe preference."""
names: dict[str, str] = {}
layers: dict[str, str] = {}
ids: list[str] = []
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
for a, b in links:
if a in adj and b in adj:
adj[a].add(b)
adj[b].add(a)
deg = {i: len(adj[i]) for i in ids}
access = {i for i in ids if layers.get(i) == "access"}
layer_known = sum(
1 for i in ids if layers.get(i) in {"external", "core", "agg", "access"}
)
if layer_known < max(3, len(ids) // 5) and ids:
ranked = sorted(ids, key=lambda i: (-deg[i], names[i]))
hub_budget = max(2, min(8, len(ids) // 15 + 2))
for i in ranked[:hub_budget]:
if layers.get(i) == "other":
layers[i] = "agg"
for i in ids:
if layers.get(i) == "other" and deg[i] <= 2:
layers[i] = "access"
access = {i for i in ids if layers.get(i) == "access"}
# --- blocks / shape first ---
comps = _connected_components(ids, adj, names)
blocks: list[dict[str, Any]] = []
for idx, comp in enumerate(comps):
b = _block_shape(comp, adj, names, layers)
b["block_id"] = idx
b["sample_names"] = [names[n] for n in sorted(comp, key=lambda x: names[x])[:3]]
blocks.append(b)
n_nodes = max(len(ids), 1)
chain_nodes = sum(b["node_count"] for b in blocks if b["shape"] in {"chain", "tiny"})
star_nodes = sum(b["node_count"] for b in blocks if b["shape"] == "star")
mesh_nodes = sum(b["node_count"] for b in blocks if b["shape"] == "mesh")
chain_frac_nodes = chain_nodes / n_nodes
global_low = (sum(1 for i in ids if deg[i] <= 2) / n_nodes) if ids else 0.0
global_mean = mean(deg.values()) if deg else 0.0
global_max = max(deg.values()) if deg else 0
primary_shape = "mesh"
if chain_frac_nodes >= 0.55 or (global_low >= 0.72 and global_mean <= 2.35 and global_max <= 6):
primary_shape = "chains"
elif star_nodes / n_nodes >= 0.45:
primary_shape = "star"
elif mesh_nodes / n_nodes >= 0.45:
primary_shape = "mesh"
elif chain_frac_nodes >= 0.35:
primary_shape = "mixed_blocks" # chain corridors + some hub blocks
shape_block = {
"primary": primary_shape,
"component_count": len(comps),
"chain_node_frac": round(chain_frac_nodes, 3),
"star_node_frac": round(star_nodes / n_nodes, 3),
"mesh_node_frac": round(mesh_nodes / n_nodes, 3),
"deg_le2_frac": round(global_low, 3),
"mean_degree": round(global_mean, 2),
"max_degree": global_max,
"blocks": blocks[:40],
"strategy": (
"Lay each connected component as its own block: "
"chain→horizontal spine + short stubs; star→hub + petals; "
"mesh→corridor/compact. Pack blocks in rows. "
"Do NOT treat a chain canvas as hub-and-spoke rings."
),
}
by_layer: dict[str, list[str]] = defaultdict(list)
for i in ids:
by_layer[layers.get(i, "other")].append(i)
owner = _bfs_territory(
[i for i in ids if layers.get(i) in {"core", "agg"}],
access,
adj,
)
territory_count: dict[str, int] = defaultdict(int)
for h in owner.values():
territory_count[h] += 1
def layer_stats(layer: str) -> dict[str, Any]:
members = by_layer.get(layer, [])
if not members:
return {
"count": 0,
"max_degree": 0,
"mean_degree": 0.0,
"access_neighbor_sum": 0,
"access_cover": 0,
"access_cover_frac": 0.0,
"territory": 0,
"territory_frac": 0.0,
}
degs = [deg[i] for i in members]
covered: set[str] = set()
access_nb_sum = 0
terr = 0
for i in members:
nbs = [x for x in adj.get(i, ()) if x in access]
access_nb_sum += len(nbs)
covered.update(nbs)
terr += territory_count.get(i, 0)
n_access = max(len(access), 1)
return {
"count": len(members),
"max_degree": max(degs) if degs else 0,
"mean_degree": round(mean(degs), 2) if degs else 0.0,
"access_neighbor_sum": access_nb_sum,
"access_cover": len(covered),
"access_cover_frac": round(len(covered) / n_access, 3),
"territory": terr,
"territory_frac": round(terr / n_access, 3),
}
layer_block = {
"external": layer_stats("external"),
"core": layer_stats("core"),
"agg": layer_stats("agg"),
"access": {
"count": len(access),
"max_degree": max((deg[i] for i in access), default=0),
"mean_degree": round(mean([deg[i] for i in access]), 2) if access else 0.0,
},
"other": layer_stats("other"),
}
hub_scores: list[tuple[float, str]] = []
for i in ids:
if layers.get(i) not in {"core", "agg"}:
continue
an = sum(1 for x in adj.get(i, ()) if x in access)
score = deg[i] * 1.0 + an * 2.0 + territory_count.get(i, 0) * 0.5
hub_scores.append((score, i))
hub_scores.sort(key=lambda t: (-t[0], names[t[1]]))
hubs_out: list[dict[str, Any]] = []
for score, i in hub_scores[: max(1, min(40, hub_top_k))]:
an = sorted(
(x for x in adj.get(i, ()) if x in access),
key=lambda x: (-deg[x], names[x]),
)
hubs_out.append(
{
"fabric_node_id": i,
"name": names[i],
"layer": layers[i],
"degree": deg[i],
"access_neighbors": len(an),
"territory": territory_count.get(i, 0),
"score": round(score, 2),
"stub_ids": an[:8],
}
)
stub_rows: list[dict[str, Any]] = []
for h in hubs_out[:8]:
hid = h["fabric_node_id"]
for sid in h.get("stub_ids") or []:
stub_rows.append(
{
"hub_id": hid,
"hub_name": h["name"],
"stub_id": sid,
"stub_name": names.get(sid, sid),
"stub_degree": deg.get(sid, 0),
}
)
stub_rows.sort(key=lambda r: (-r["stub_degree"], r["hub_name"], r["stub_name"]))
stub_rows = stub_rows[: max(1, min(60, stub_top_k))]
core_s = layer_block["core"]
agg_s = layer_block["agg"]
rationale: list[str] = []
gravity_type = "unclear"
confidence = 0.35
anchor_layer = "access"
decorative: list[str] = []
geometry = "chain_rows"
recipe_pref = ["corridor", "compact", "rings"]
c_terr = float(core_s["territory_frac"])
a_terr = float(agg_s["territory_frac"])
c_deg = int(core_s["max_degree"])
a_deg = int(agg_s["max_degree"])
c_n = int(core_s["count"])
a_n = int(agg_s["count"])
# 1) Chain-first — never fall through to hub-bar on path graphs
if primary_shape == "chains":
gravity_type = "chains"
confidence = min(0.97, 0.55 + chain_frac_nodes * 0.4)
anchor_layer = "access"
geometry = "chain_rows"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append(
f"shape=chains; deg_le2_frac={global_low:.2f}; "
f"components={len(comps)}; lay spines+stubs per block"
)
elif primary_shape == "mixed_blocks":
gravity_type = "mixed_blocks"
confidence = 0.7
anchor_layer = "agg" if a_n else ("core" if c_n else "access")
geometry = "block_pack"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append("mixed chain blocks + hub blocks; process per component")
elif c_n == 0 and a_n > 0 and (a_terr >= 0.35 or a_deg >= 4):
gravity_type = "agg_bar"
confidence = min(0.95, 0.55 + a_terr * 0.4)
anchor_layer = "agg"
geometry = "agg_bar_mid"
recipe_pref = ["rings", "compact", "corridor"]
rationale.append("no_core_layer; agg holds access territory")
if a_n >= 2:
rationale.append(
"dual_agg: rings uses min-ring path nesting (parallel corridors) "
"before per-AN petals"
)
elif c_n > 0 and (
c_terr >= a_terr * 1.35 or (c_deg >= max(6, int(a_deg * 1.4)) and c_terr >= 0.25)
):
gravity_type = "core_bar"
confidence = min(0.95, 0.5 + c_terr * 0.45)
anchor_layer = "core"
if a_n > 0 and a_terr < 0.2 and a_deg <= max(4, c_deg // 2):
decorative.append("agg")
rationale.append("agg_low_attachment; treat as decorative bar")
core_hubs = [h for h in hubs_out if h["layer"] == "core"][:4]
if len(core_hubs) >= 2 and core_hubs[1]["degree"] >= max(
4, core_hubs[0]["degree"] * 0.5
):
geometry = "core_center"
rationale.append("dual_core_hubs; prefer horizontal beam at mid/upper-mid")
else:
geometry = "core_top"
rationale.append("core_holds_access; default core beam above access fans")
recipe_pref = ["compact", "corridor", "rings"]
rationale.append("core_territory_or_degree_dominates_agg")
rationale.append(
"prefer dual_units → sinkTopologyDualUnits; "
"small core: layout compact|corridor"
)
elif c_n > 0 and a_n > 0 and c_terr >= 0.15 and a_terr >= 0.15:
gravity_type = "mixed"
confidence = min(0.9, 0.45 + min(c_terr, a_terr) * 0.5)
anchor_layer = "core" if c_terr >= a_terr else "agg"
geometry = "core_center" if anchor_layer == "core" else "agg_bar_mid"
recipe_pref = ["compact", "rings", "corridor"]
rationale.append("both_core_and_agg_own_access_territory")
elif hubs_out and primary_shape == "star":
top = hubs_out[0]
gravity_type = "agg_bar" if top["layer"] == "agg" else "core_bar"
confidence = 0.55
anchor_layer = top["layer"]
geometry = "agg_bar_mid" if top["layer"] == "agg" else "core_center"
recipe_pref = (
["rings", "compact", "corridor"]
if top["layer"] == "agg"
else ["compact", "corridor", "rings"]
)
rationale.append("shape=star; hub-and-spoke")
elif hubs_out:
top = hubs_out[0]
gravity_type = "core_bar" if top["layer"] == "core" else "agg_bar"
confidence = 0.45
anchor_layer = top["layer"]
geometry = "core_center" if top["layer"] == "core" else "agg_bar_mid"
recipe_pref = (
["compact", "corridor", "rings"]
if top["layer"] == "core"
else ["rings", "compact", "corridor"]
)
rationale.append("fallback_top_hub_layer")
else:
gravity_type = "chains" if global_low >= 0.6 else "unclear"
geometry = "chain_rows" if gravity_type == "chains" else "compact"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append("no_clear_hubs; corridor baseline")
degs = list(deg.values())
chain_mode = gravity_type in {"chains", "mixed_blocks"}
advice = {
"pin_anchors": (
[]
if gravity_type == "chains"
else [h["fabric_node_id"] for h in hubs_out[:4]]
),
"preview_recipes": list(recipe_pref[:2]),
"skip_rings_first": gravity_type in {"core_bar", "chains", "mixed_blocks"},
"geometry": geometry,
"layout_over_rank": True,
"decompose_by_component": True,
"decompose_soft_blocks": primary_shape in {"star", "mesh", "mixed_blocks"}
or (len(comps) <= 2 and star_nodes >= 40),
"block_plan": [
{
"block_id": b["block_id"],
"shape": b["shape"],
"n": b["node_count"],
"how": (
"spine+short stubs, row pack"
if b["shape"] in {"chain", "tiny"}
else (
"hub center + petals"
if b["shape"] == "star"
else "corridor/compact then local untangle"
)
),
}
for b in blocks[:20]
],
"next": (
"CHAIN canvas: for each structure.shape.blocks item with shape=chain, "
"lay diameter spine horizontally, hang deg-1/short branches as stubs, "
"stack components as rows; preview corridor/compact (skip rings); "
"untangle residual."
if chain_mode
else (
"Giant star/core_bar: prefer structure.dual_units "
"(two portals + ≥2 interior-disjoint corridors). "
"Batch sinkTopologyDualUnits (layout_batch) → orbit_sweep → "
"polish_crossings → clear_edge_hits. "
"Portals may appear in multiple units. "
"Small graphs: layout compact|corridor|rings. "
"agg_bar: preview rings first."
)
),
"prefer_dual_units": (
not chain_mode
and (
primary_shape in {"star", "mesh", "mixed_blocks"}
or gravity_type in {"core_bar", "mixed", "mixed_blocks"}
or star_nodes >= 40
)
),
}
# Soft blocks inside giant CCs (hub BFS; igraph optional for leftovers).
soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False}
dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
try:
from netx_topology_mcp.layout_ops.dual_units import dual_units_report
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.partition import partition_report
st = build_state_from_nodes_edges(nodes, edges)
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"
soft_blocks = partition_report(st, mode=soft_mode)
dual_units = dual_units_report(st)
except Exception as exc: # pragma: no cover - defensive
soft_blocks = {
"block_count": 0,
"blocks": [],
"igraph": False,
"error": str(exc)[:120],
}
dual_units = {"unit_count": 0, "units": [], "error": str(exc)[:120]}
anchors = []
if gravity_type == "chains":
# expose spine ends of largest chain blocks as soft anchors
for b in blocks[:6]:
if b["shape"] not in {"chain", "tiny"}:
continue
for end, nm in zip(b.get("spine_ends") or [], b.get("spine_end_names") or []):
anchors.append(
{
"fabric_node_id": end,
"name": nm,
"layer": layers.get(end, "access"),
"degree": deg.get(end, 0),
"territory": 0,
"role": "spine_end",
}
)
else:
anchors = [
{
"fabric_node_id": h["fabric_node_id"],
"name": h["name"],
"layer": h["layer"],
"degree": h["degree"],
"territory": h["territory"],
}
for h in hubs_out[:6]
if h["layer"] == anchor_layer
or (gravity_type in {"mixed", "mixed_blocks"} and h["layer"] in {"core", "agg"})
]
return {
"node_count": len(ids),
"link_count": len(links),
"degree": {
"max": max(degs) if degs else 0,
"mean": round(mean(degs), 2) if degs else 0.0,
"median": float(median(degs)) if degs else 0.0,
"p90": _pct(degs, 0.9),
"le2_frac": round(global_low, 3),
},
"shape": shape_block,
"layers": layer_block,
"hubs": hubs_out,
"stubs": stub_rows,
"soft_blocks": soft_blocks,
"dual_units": dual_units,
"gravity": {
"type": gravity_type,
"confidence": round(confidence, 3),
"anchor_layer": anchor_layer,
"decorative_layers": decorative,
"geometry_hint": geometry,
"recipe_preference": recipe_pref,
"rationale": rationale,
"anchors": anchors,
},
"advice": advice,
"headline": (
f"shape={primary_shape} gravity={gravity_type} "
f"comps={len(comps)} geometry={geometry} try={'>'.join(recipe_pref[:2])}"
),
}