netx/netx_api/mcp_remote/topology/layout_ops/hotspots.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

380 lines
13 KiB
Python

"""Local hotspots: overlaps / dense cells — scope without touching whole canvas."""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import replace
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
from netx_api.mcp_remote.topology.layout_ops.scope import select_scope
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.transforms import (
explode_clusters,
pack_utilization,
resolve_overlaps,
)
def _aabb_overlap(
a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> bool:
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def overlapping_nodes(state: LayoutState) -> set[str]:
"""Nodes whose icon+label footprints collide."""
ids = list(state.positions)
fps: dict[str, tuple[float, float, float, float]] = {}
for n in ids:
x, y = state.positions[n]
fx0, fy0, fx1, fy1 = node_footprint(state.names.get(n, n))
fps[n] = (x + fx0, y + fy0, x + fx1, y + fy1)
hit: set[str] = set()
for i, a in enumerate(ids):
for b in ids[i + 1 :]:
if _aabb_overlap(fps[a], fps[b]):
hit.add(a)
hit.add(b)
return hit
def expand_hops(
seeds: set[str], adj: dict[str, set[str]], *, hops: int = 1
) -> set[str]:
out = set(seeds)
frontier = set(seeds)
for _ in range(max(0, hops)):
nxt: set[str] = set()
for u in frontier:
nxt |= set(adj.get(u, ()))
nxt -= out
out |= nxt
frontier = nxt
if not frontier:
break
return out
def dense_blocks(
state: LayoutState,
*,
cell_w: float = REC_CENTER_DX,
cell_h: float = REC_CENTER_DY,
min_count: int = 5,
) -> list[set[str]]:
"""Grid cells with too many centers — local overcrowding."""
if len(state.positions) < min_count:
return []
buckets: dict[tuple[int, int], set[str]] = defaultdict(set)
for n, (x, y) in state.positions.items():
buckets[(int(math.floor(x / cell_w)), int(math.floor(y / cell_h)))].add(n)
return [nodes for nodes in buckets.values() if len(nodes) >= min_count]
def close_clusters(
state: LayoutState, *, thr: float = 100.0
) -> list[set[str]]:
"""Union-find clusters of centers closer than thr (each cluster = one local scope)."""
ids = list(state.positions)
parent = {n: n for n in ids}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def uni(a: str, b: str) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
cell = max(thr, 40.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n, (x, y) in state.positions.items():
buckets[(int(x // cell), int(y // cell))].append(n)
for n, (x, y) in state.positions.items():
cx, cy = int(x // cell), int(y // cell)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m <= n:
continue
mx, my = state.positions[m]
if math.hypot(x - mx, y - my) < thr:
uni(n, m)
groups: dict[str, set[str]] = defaultdict(set)
for n in ids:
groups[find(n)].add(n)
return [g for g in groups.values() if len(g) >= 2]
def sprawled_component_scopes(
state: LayoutState,
*,
target_util: float,
min_size: int = 8,
max_size: int = 80,
) -> list[set[str]]:
"""Graph components with low util — skip huge ones (those need layout, not local pack)."""
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
comps = connected_components(set(state.positions), state.adj)
out: list[set[str]] = []
tile = REC_CENTER_DX * REC_CENTER_DY
for c in comps:
if len(c) < min_size or len(c) > max_size:
continue
sub = {n: state.positions[n] for n in c}
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
util = len(c) * tile / area
if util < target_util:
out.append(set(c))
return out
def _merge_scopes(scopes: list[set[str]], *, max_size: int = 64) -> list[set[str]]:
"""Merge intersecting scopes but never grow past max_size."""
merged: list[set[str]] = []
for s in scopes:
if not s or len(s) < 2:
continue
if len(s) > max_size:
continue
hit = None
for m in merged:
if s & m and len(m | s) <= max_size:
hit = m
break
if hit is None:
merged.append(set(s))
else:
hit |= s
return [m for m in merged if len(m) >= 2]
def hotspot_scopes(
state: LayoutState,
*,
hops: int = 1,
dense_min: int = 5,
close_thr: float = 100.0,
target_util: float | None = None,
max_scope: int = 64,
) -> list[set[str]]:
"""Local scopes only: overlap / close clusters / dense cells / small sprawled comps."""
seeds_list: list[set[str]] = []
ov = overlapping_nodes(state)
if ov and len(ov) <= max_scope:
seeds_list.append(ov)
seeds_list.extend(close_clusters(state, thr=close_thr))
seeds_list.extend(dense_blocks(state, min_count=dense_min))
if target_util is not None:
seeds_list.extend(
sprawled_component_scopes(
state, target_util=target_util, max_size=max_scope
)
)
if not seeds_list:
return []
expanded: list[set[str]] = []
for s in seeds_list:
if len(s) < 2 or len(s) > max_scope:
continue
e = expand_hops(s, state.adj, hops=hops) & set(state.positions)
if len(e) > max_scope:
e = set(s) # drop hop expansion if it balloons
expanded.append(e)
return _merge_scopes(expanded, max_size=max_scope)
def fix_overlaps_local(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
params = params or LayoutParams()
st = state.copy()
seeds = overlapping_nodes(st)
if not seeds:
return OpResult(
state=st,
moved=set(),
op="fix_overlaps_local",
params={"seeds": 0},
note="no overlaps",
)
c0 = count_edge_crossings(st.positions, st.links)
baseline = st.copy()
scope = expand_hops(seeds, st.adj, hops=1) & set(st.positions)
# Prefer moving smaller set; keep rest as obstacles
st = select_scope(st, mode="ids", node_ids=scope).state
p = replace(
params,
overlap_iters=max(220, int(params.overlap_iters)),
overlap_step=max(4.0, float(params.overlap_step)),
)
moved: set[str] = set()
r1 = resolve_overlaps(st, p, mode="free")
st, moved = r1.state, moved | r1.moved
r2 = resolve_overlaps(st, p, mode="lateral")
st, moved = r2.state, moved | r2.moved
# Still stuck? explode tiny coincidences inside scope, then resolve again
still = overlapping_nodes(st) & scope
if still:
st = select_scope(st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)).state
r3 = explode_clusters(st, p, thr=12.0, gap=max(40.0, p.cluster_gap), axis="along")
st, moved = r3.state, moved | r3.moved
r4 = resolve_overlaps(st, p, mode="free")
st, moved = r4.state, moved | r4.moved
r5 = resolve_overlaps(st, p, mode="lateral")
st, moved = r5.state, moved | r5.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
# Free/explode can shred metro geometry — fall back to lateral-only unstick.
if c1 > max(c0 * 1.35, c0 + 60):
st = baseline
st = select_scope(st, mode="ids", node_ids=scope).state
moved = set()
p2 = replace(p, overlap_iters=max(320, int(p.overlap_iters)))
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, r.moved
still = overlapping_nodes(st) & scope
if still:
st = select_scope(
st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)
).state
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, moved | r.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
left = overlapping_nodes(st)
return OpResult(
state=st,
moved=moved,
op="fix_overlaps_local",
params={
"seeds": len(seeds),
"scope_n": len(scope),
"moved_n": len(moved),
"overlaps_left": len(left),
"crossings_before": c0,
"crossings_after": c1,
},
note=f"local unstick seeds={len(seeds)} left={len(left)} cross={c0}->{c1}",
)
def _block_util(state: LayoutState, block: set[str]) -> float:
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
if len(block) < 2:
return 1.0
sub = {n: state.positions[n] for n in block if n in state.positions}
if len(sub) < 2:
return 1.0
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
return len(sub) * REC_CENTER_DX * REC_CENTER_DY / area
def _block_has_close(state: LayoutState, block: set[str], thr: float) -> bool:
ids = [n for n in block if n in state.positions]
for i, a in enumerate(ids):
ax, ay = state.positions[a]
for b in ids[i + 1 :]:
bx, by = state.positions[b]
if math.hypot(ax - bx, ay - by) < thr:
return True
return False
def relax_hotspots(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Per-hotspot local polish — never global compress.
- Sprawled block (low util): pack + resolve only
- Crowded block (close pairs / overlaps): mild explode + resolve (no pack)
"""
params = params or LayoutParams()
st = state.copy()
scopes = hotspot_scopes(
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
)
if not scopes:
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]
scopes = sorted(scopes, key=len, reverse=True)[:24]
total_moved: set[str] = set()
block_notes: list[dict[str, Any]] = []
close_thr = 55.0 # only true local crowding, not soft nn band
for i, block in enumerate(scopes):
if len(block) < 2:
continue
st = select_scope(st, mode="ids", node_ids=block).state
before_ov = len(overlapping_nodes(st) & block)
util = _block_util(st, block)
crowded = before_ov > 0 or _block_has_close(st, block, close_thr)
sprawled = util < params.target_util
mode = "pack" if sprawled and not crowded else ("unstick" if crowded else "skip")
moved_here: set[str] = set()
if mode == "unstick":
r1 = explode_clusters(
st, params, thr=close_thr, gap=max(40.0, params.cluster_gap), axis="along"
)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
r3 = resolve_overlaps(st, params, mode="free")
st = r3.state
moved_here |= r3.moved
elif mode == "pack":
r1 = pack_utilization(st, params)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
total_moved |= moved_here
after_ov = len(overlapping_nodes(st) & block)
block_notes.append(
{
"i": i,
"n": len(block),
"mode": mode,
"util": round(util, 4),
"ov_before": before_ov,
"ov_after": after_ov,
"moved": len(moved_here),
}
)
st = select_scope(st, mode="all").state
fin = fix_overlaps_local(st, params)
st = fin.state
total_moved |= fin.moved
return OpResult(
state=st,
moved=total_moved,
op="relax_hotspots",
params={"blocks": block_notes, "blocks_n": len(scopes), "moved_n": len(total_moved)},
note=f"relax blocks={len(scopes)} moved={len(total_moved)}",
)