mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 07:23:27 +08:00
- 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>
380 lines
13 KiB
Python
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)}",
|
|
)
|