netx/netx_api/mcp_remote/topology/layout_ops/hierarchy.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

591 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Top-down hierarchy layout: contract → order sectors → expand.
At each hub level we only score crossings on the *contracted* graph
(parent + one representative per child territory). Internal edges of
children are ignored until that child is expanded. Accept/reject is
driven by contracted crossings only — global leaf crossings may rise
temporarily; that is intentional (fix lower levels later).
Expand is a *rigid* polar remapping of each stub territory onto the new
sector angle so local corridor geometry survives the reorder.
"""
from __future__ import annotations
import math
from collections import deque
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, segments_properly_intersect
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:
hx, hy = pos[hub]
x, y = pos[nid]
return math.atan2(y - hy, x - hx)
def _stub_territories(
hub: str,
members: set[str],
adj: dict[str, set[str]],
pinned: set[str],
) -> tuple[list[str], dict[str, str]]:
stubs = [
n
for n in adj.get(hub, ())
if n in members and n not in pinned
]
owner: dict[str, str] = {}
q: deque[str] = deque()
for stub in stubs:
owner[stub] = stub
q.append(stub)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v not in members or v == hub or v in owner or v in pinned:
continue
owner[v] = owner[u]
q.append(v)
return stubs, owner
def _owner_hub_map(
adj: dict[str, set[str]],
hubs: set[str],
) -> dict[str, str]:
"""BFS Voronoi: every node maps to nearest hub (ties: first reached)."""
owner: dict[str, str] = {h: h for h in hubs}
q: deque[str] = deque(hubs)
while q:
u = q.popleft()
ou = owner[u]
for v in adj.get(u, ()):
if v in owner:
continue
owner[v] = ou
q.append(v)
return owner
def _contracted_links(
hub: str,
stubs: list[str],
owner: dict[str, str],
adj: dict[str, set[str]],
pinned: set[str],
*,
hub_of: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Edges among {hub} ∪ stubs after contracting each stub territory.
Foreign endpoints outside this hub's territories collapse to their
owning hub (Voronoi), so cross-block edges are not dropped from the
contracted objective.
"""
terr: dict[str, set[str]] = {s: {s} for s in stubs}
for n, o in owner.items():
terr.setdefault(o, set()).add(n)
node_to_rep: dict[str, str] = {hub: hub}
for s, nodes in terr.items():
for n in nodes:
node_to_rep[n] = s
links: set[tuple[str, str]] = set()
for s in stubs:
links.add(tuple(sorted((hub, s))))
for s, nodes in terr.items():
for n in nodes:
for v in adj.get(n, ()):
if v == hub:
continue
if v in node_to_rep:
r = node_to_rep[v]
if r != s:
links.add(tuple(sorted((s, r))))
elif v in pinned:
links.add(tuple(sorted((s, v))))
elif hub_of is not None and v in hub_of:
fh = hub_of[v]
if fh != hub:
links.add(tuple(sorted((s, fh))))
return sorted(links)
def _contracted_crossings(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]] | None = None,
a0: float | None = None,
a1: float | None = None,
) -> int:
"""Place stub reps on an arc; count crossings of contracted links only."""
hx, hy = hub_xy
n = len(stubs_ord)
if n == 0:
return 0
if a0 is None or a1 is None:
a0, a1 = -math.pi * 0.85, math.pi * 0.85
pos: dict[str, tuple[float, float]] = {hub: (hx, hy)}
if foreign_pos:
pos.update(foreign_pos)
for i, s in enumerate(stubs_ord):
mid = 0.5 * (a0 + a1) if n == 1 else a0 + (a1 - a0) * i / (n - 1)
pos[s] = (hx + math.cos(mid) * radius, hy + math.sin(mid) * radius)
segs = [(a, b) for a, b in links if a in pos and b in pos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = pos[a], pos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
c += 1
return c
def _score_order(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> int:
return _contracted_crossings(
hub,
stubs_ord,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
def _candidate_orders(stubs: list[str], pos: dict[str, tuple[float, float]], hub: str) -> list[list[str]]:
if not stubs:
return []
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
orders: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
def _add(ord_: list[str]) -> None:
key = tuple(ord_)
if key not in seen:
seen.add(key)
orders.append(ord_)
for base in (geo, list(reversed(geo))):
for k in range(len(base)):
_add(base[k:] + base[:k])
# Adjacent swaps from geo.
for i in range(len(geo) - 1):
trial = list(geo)
trial[i], trial[i + 1] = trial[i + 1], trial[i]
_add(trial)
return orders
def _best_order_2opt(
hub: str,
stubs: list[str],
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> tuple[list[str], int]:
"""Greedy 2-opt / insert bakeoff seeded from candidate rotations."""
seeds = _candidate_orders(stubs, pos, hub)
best_ord = sorted(stubs, key=lambda s: _ang(pos, hub, s))
best_cc = _score_order(
hub, best_ord, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
for seed in seeds:
cur = list(seed)
cur_cc = _score_order(
hub, cur, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
improved = True
rounds = 0
while improved and rounds < 12:
improved = False
rounds += 1
n = len(cur)
for i in range(n):
for j in range(i + 2, n + (0 if i == 0 else 1)):
# reverse segment [i:j]
jj = j if j <= n else n
if jj - i < 2:
continue
trial = cur[:i] + list(reversed(cur[i:jj])) + cur[jj:]
if len(trial) != n:
continue
cc = _score_order(
hub,
trial,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
if cc < cur_cc:
cur, cur_cc = trial, cc
improved = True
break
if improved:
break
if cur_cc < best_cc:
best_cc = cur_cc
best_ord = cur
return best_ord, best_cc
def _contracted_crossings_at_pos(
hub: str,
stubs: list[str],
links: list[tuple[str, str]],
pos: dict[str, tuple[float, float]],
foreign_pos: dict[str, tuple[float, float]],
) -> int:
"""Contracted crossings using *actual* stub coordinates as representatives."""
cpos: dict[str, tuple[float, float]] = {hub: pos[hub]}
cpos.update(foreign_pos)
for s in stubs:
if s in pos:
cpos[s] = pos[s]
segs = [(a, b) for a, b in links if a in cpos and b in cpos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = cpos[a], cpos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, cpos[u], cpos[v]):
c += 1
return c
def _swap_territories(
hub: str,
a: str,
b: str,
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
) -> dict[str, tuple[float, float]]:
"""Exchange polar angles of two stub territories around hub (rigid)."""
hx, hy = pos[hub]
aa, ab = _ang(pos, hub, a), _ang(pos, hub, b)
delta = ab - aa
trial = dict(pos)
ta = {n for n, o in owner.items() if o == a} | {a}
tb = {n for n, o in owner.items() if o == b} | {b}
for n in ta:
if n not in pos or (n in pinned and n != a):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) + delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
for n in tb:
if n not in pos or (n in pinned and n != b):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) - delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
trial[hub] = (hx, hy)
return trial
def _realize_order_by_swaps(
hub: str,
stubs: list[str],
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
clinks: list[tuple[str, str]],
foreign: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
target_ord: list[str] | None = None,
allow_global_rise: bool = False,
) -> tuple[dict[str, tuple[float, float]], int, int]:
"""Greedy adjacent territory swaps; primary score = contracted@pos.
Search is driven by contracted crossings (lower-level edges ignored in
the objective). On an already-laid canvas (stage-2 polish) we also refuse
swaps that raise *global* crossings — otherwise leaf noise swamps the
map before lower levels can be re-expanded. Set allow_global_rise for
true build-from-skeleton top-down.
"""
trial = dict(pos)
before = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g_base = count_edge_crossings(trial, links)
target_idx = {s: i for i, s in enumerate(target_ord)} if target_ord else {}
for _ in range(max(2 * len(stubs), 8)):
geo = sorted(stubs, key=lambda s: _ang(trial, hub, s))
pair_idxs = list(range(len(geo) - 1))
if target_idx:
inv = [
i
for i in pair_idxs
if target_idx.get(geo[i], 0) > target_idx.get(geo[i + 1], 0)
]
pair_idxs = inv + [i for i in pair_idxs if i not in inv]
progressed = False
c0 = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g0 = count_edge_crossings(trial, links)
# Pick the adjacent swap with best contracted drop; tie-break by global.
best_cand = None
best_key = None
for i in pair_idxs:
cand = _swap_territories(hub, geo[i], geo[i + 1], owner, pinned, trial)
for h in pinned:
if h in pos:
cand[h] = pos[h]
cand[hub] = pos[hub]
c1 = _contracted_crossings_at_pos(hub, stubs, clinks, cand, foreign)
if c1 >= c0:
continue
g1 = count_edge_crossings(cand, links)
if not allow_global_rise and g1 > g0:
continue
key = (c0 - c1, g0 - g1)
if best_key is None or key > best_key:
best_key = key
best_cand = cand
if best_cand is not None:
trial = best_cand
progressed = True
if not progressed:
break
after = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
del g_base
return trial, before, after
def hierarchy_sectors_greedy(
state: LayoutState,
params: LayoutParams | None = None,
*,
step: float | None = None,
max_hubs: int = 20,
allow_global_rise: bool = False,
) -> OpResult:
"""Top-down: contract → order by contracted crossings → rigid expand.
Role beam offsets (pin_beam) belong *after* this pass, not inside it.
Default stage-2 polish keeps global from rising; pass allow_global_rise
for skeleton-time top-down where leaf crossings are deferred.
"""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch * 0.95, 170.0))
pos = dict(state.positions)
links = list(state.links)
adj = state.adj
global0 = count_edge_crossings(pos, links)
hubs = pick_hub_seeds(state, max_hubs=max_hubs)
cores = [h for h in hubs if state.layers.get(h) == "core"]
aggs = [h for h in hubs if state.layers.get(h) == "agg"]
# Top-down: cores first, then aggs, then remaining high-degree hubs.
order_hubs = list(cores) + [h for h in aggs if h not in cores]
for h in hubs:
if h not in order_hubs:
order_hubs.append(h)
pinned = set(hubs)
hub_of = _owner_hub_map(adj, pinned)
level_notes: list[dict[str, Any]] = []
any_level = False
contracted_gain = 0
for hub in order_hubs:
if hub not in pos:
continue
members = {hub}
q: deque[str] = deque([hub])
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v in members:
continue
if v in pinned and v != hub:
continue
members.add(v)
q.append(v)
for v in adj.get(hub, ()):
if v not in pinned:
members.add(v)
stubs, owner = _stub_territories(hub, members, adj, pinned)
if len(stubs) < 2:
continue
clinks = _contracted_links(
hub, stubs, owner, adj, pinned, hub_of=hub_of
)
foreign = {
n: pos[n]
for n in pinned
if n != hub and n in pos and any(n in e for e in clinks)
}
angs = [_ang(pos, hub, s) for s in stubs]
a0, a1 = min(angs) - 0.15, max(angs) + 0.15
if a1 - a0 < 0.8:
mid = sum(angs) / len(angs)
a0, a1 = mid - 1.1, mid + 1.1
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
base_cc = _score_order(
hub,
geo,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
best_ord, best_cc = _best_order_2opt(
hub,
stubs,
pos,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
# Abstract arc bakeoff only proposes a target cyclic order.
if best_cc >= base_cc:
continue
# Realize via adjacent territory swaps; accept on contracted@pos only.
trial, c_before, c_after = _realize_order_by_swaps(
hub,
stubs,
owner,
pinned,
pos,
clinks,
foreign,
links,
target_ord=best_ord,
allow_global_rise=allow_global_rise,
)
if c_after >= c_before:
continue
for h in pinned:
if h in pos:
trial[h] = pos[h]
trial[hub] = pos[hub]
# No per-level overlap fix — it fights sector moves and is slow on
# large canvases. Zero-overlap is ensured by the layout_tool wrapper.
g1 = count_edge_crossings(trial, links)
pos = trial
any_level = True
contracted_gain += c_before - c_after
level_notes.append(
{
"hub_id": hub,
"hub_name": state.names.get(hub, hub),
"stubs": len(stubs),
"contracted_before": c_before,
"contracted_after": c_after,
"contracted_arc_before": base_cc,
"contracted_arc_after": best_cc,
"global_after": g1,
}
)
if not any_level:
return OpResult(
state=state,
moved=set(),
op="hierarchy_sectors",
params={"accepted_n": 0, "contracted_gain": 0},
note="no_level_improved",
)
# One end-of-pass overlap polish (hubs stay pinned).
st_end = state.copy()
st_end.positions = pos
st_end = fix_overlaps_local(st_end, params).state
for h in pinned:
if h in pos:
st_end.positions[h] = pos[h]
pos = {k: (float(v[0]), float(v[1])) for k, v in st_end.positions.items()}
out = state.copy()
out.positions = pos
moved = {
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
}
g_end = count_edge_crossings(pos, links)
out.meta = dict(out.meta or {})
out.meta["hierarchy_sectors"] = {
"levels": level_notes,
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
}
return OpResult(
state=out,
moved=moved,
op="hierarchy_sectors",
params={
"accepted_n": len(level_notes),
"levels": level_notes[:12],
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
},
note=(
f"hierarchy_sectors:{len(level_notes)} levels "
f"contractedΔ={contracted_gain} x:{global0}->{g_end}"
),
)
def hierarchy_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("max_hubs") is not None:
try:
out["max_hubs"] = int(overrides["max_hubs"])
except (TypeError, ValueError):
pass
if "allow_global_rise" in overrides:
out["allow_global_rise"] = bool(overrides.get("allow_global_rise"))
return out