mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 10:53:21 +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>
233 lines
7 KiB
Python
233 lines
7 KiB
Python
"""Uniform bbox shrink toward eye portals — eye-safe compactness.
|
|
|
|
Hard gates (must all pass to accept a scale):
|
|
- global crossings must not rise
|
|
- footprint overlaps must stay zero
|
|
- frozen portal_ids stay put
|
|
|
|
Optional soft: prefer scales that do not worsen edge_clearance hits.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
|
count_edge_crossings,
|
|
compute_edge_clearance,
|
|
)
|
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
|
|
|
|
|
def _anchor(
|
|
pos: dict[str, tuple[float, float]],
|
|
frozen: set[str],
|
|
) -> tuple[float, float]:
|
|
pts = [pos[n] for n in frozen if n in pos]
|
|
if len(pts) >= 1:
|
|
return (
|
|
sum(p[0] for p in pts) / len(pts),
|
|
sum(p[1] for p in pts) / len(pts),
|
|
)
|
|
if not pos:
|
|
return (0.0, 0.0)
|
|
xs = [p[0] for p in pos.values()]
|
|
ys = [p[1] for p in pos.values()]
|
|
return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0)
|
|
|
|
|
|
def _area(pos: dict[str, tuple[float, float]]) -> float:
|
|
if len(pos) < 2:
|
|
return 1.0
|
|
x0, y0, x1, y1 = bbox(pos)
|
|
return max((x1 - x0) * (y1 - y0), 1.0)
|
|
|
|
|
|
def _clr_hits(pos: dict[str, tuple[float, float]], links, names) -> int:
|
|
ec = compute_edge_clearance(pos, links, names=names, top_n=1)
|
|
if ec.get("edge_clearance_skipped"):
|
|
return 10**9
|
|
return int(ec.get("edge_clearance_hits") or 0)
|
|
|
|
|
|
def compact_bbox(
|
|
state: LayoutState,
|
|
params: LayoutParams | None = None,
|
|
*,
|
|
frozen_ids: set[str] | None = None,
|
|
portal_ids: list[str] | None = None,
|
|
min_scale: float = 0.72,
|
|
step: float = 0.03,
|
|
max_clearance_slack: int = 80,
|
|
outlier_only: bool = True,
|
|
) -> OpResult:
|
|
"""Probe shrink toward portals; keep best gated layout.
|
|
|
|
``outlier_only``: only move nodes farther than ~median radius from the
|
|
portal anchor — avoids crushing the already-dense eye core into overlaps.
|
|
"""
|
|
import math
|
|
|
|
del params
|
|
st = state.copy()
|
|
pos0 = dict(st.positions)
|
|
names = st.names
|
|
links = list(st.links)
|
|
frozen: set[str] = set(frozen_ids or ())
|
|
for p in portal_ids or []:
|
|
if p:
|
|
frozen.add(str(p))
|
|
if not pos0:
|
|
return OpResult(
|
|
state=st,
|
|
moved=set(),
|
|
op="compact_bbox",
|
|
note="compact_bbox:empty",
|
|
params={"error": "empty"},
|
|
)
|
|
|
|
g0 = count_edge_crossings(pos0, links)
|
|
if _has_any_footprint_overlap(pos0, names):
|
|
return OpResult(
|
|
state=st,
|
|
moved=set(),
|
|
op="compact_bbox",
|
|
note="compact_bbox:overlaps_before",
|
|
params={
|
|
"error": "overlaps_before",
|
|
"hint": "Refuse shrink while footprints already overlap.",
|
|
},
|
|
)
|
|
clr0 = _clr_hits(pos0, links, names)
|
|
area0 = _area(pos0)
|
|
cx, cy = _anchor(pos0, frozen)
|
|
|
|
dists = {
|
|
n: math.hypot(x - cx, y - cy)
|
|
for n, (x, y) in pos0.items()
|
|
if n not in frozen
|
|
}
|
|
mobile: set[str]
|
|
if outlier_only and dists:
|
|
# Farthest-K only: percentile bands often crush mid-ring nodes into overlaps.
|
|
k = max(16, min(40, len(dists) // 6))
|
|
mobile = {
|
|
n
|
|
for n, _ in sorted(dists.items(), key=lambda kv: kv[1], reverse=True)[:k]
|
|
}
|
|
else:
|
|
mobile = set(dists.keys())
|
|
|
|
best_pos = pos0
|
|
best_meta: dict[str, Any] = {
|
|
"scale": 1.0,
|
|
"crossings": g0,
|
|
"clearance_hits": clr0,
|
|
"area": round(area0, 1),
|
|
"accepted": False,
|
|
}
|
|
best_key = (g0, area0, clr0)
|
|
|
|
scales: list[float] = []
|
|
s = 1.0 - float(step)
|
|
lo = max(0.5, float(min_scale))
|
|
while s >= lo - 1e-9:
|
|
scales.append(round(s, 4))
|
|
s -= float(step)
|
|
|
|
for scale in scales:
|
|
trial: dict[str, tuple[float, float]] = {}
|
|
for nid, (x, y) in pos0.items():
|
|
if nid in frozen or nid not in mobile:
|
|
trial[nid] = (x, y)
|
|
else:
|
|
trial[nid] = (
|
|
cx + (x - cx) * scale,
|
|
cy + (y - cy) * scale,
|
|
)
|
|
if _has_any_footprint_overlap(trial, names):
|
|
continue
|
|
g1 = count_edge_crossings(trial, links)
|
|
if g1 > g0:
|
|
continue
|
|
clr1 = _clr_hits(trial, links, names)
|
|
if clr1 > clr0 + max(0, int(max_clearance_slack)):
|
|
continue
|
|
area1 = _area(trial)
|
|
key = (g1, area1, clr1)
|
|
if key < best_key:
|
|
best_key = key
|
|
best_pos = trial
|
|
best_meta = {
|
|
"scale": scale,
|
|
"crossings": g1,
|
|
"clearance_hits": clr1,
|
|
"area": round(area1, 1),
|
|
"accepted": True,
|
|
}
|
|
|
|
moved = {
|
|
n
|
|
for n, xy in best_pos.items()
|
|
if n in pos0
|
|
and (
|
|
abs(xy[0] - pos0[n][0]) > 0.05
|
|
or abs(xy[1] - pos0[n][1]) > 0.05
|
|
)
|
|
}
|
|
st.positions = best_pos
|
|
st.last_moved = moved
|
|
meta = {
|
|
"mode": "compact_bbox",
|
|
"anchor": [round(cx, 1), round(cy, 1)],
|
|
"frozen_n": len(frozen),
|
|
"mobile_n": len(mobile),
|
|
"outlier_only": bool(outlier_only),
|
|
"start_crossings": g0,
|
|
"start_clearance_hits": clr0,
|
|
"start_area": round(area0, 1),
|
|
"end_crossings": int(best_meta["crossings"]),
|
|
"end_clearance_hits": int(best_meta["clearance_hits"]),
|
|
"end_area": best_meta["area"],
|
|
"scale": best_meta["scale"],
|
|
"moved_n": len(moved),
|
|
"accepted": bool(best_meta["accepted"]),
|
|
}
|
|
st.meta["compact_bbox"] = meta
|
|
return OpResult(
|
|
state=st,
|
|
moved=moved,
|
|
op="compact_bbox",
|
|
params=meta,
|
|
note=(
|
|
f"compact_bbox scale={meta['scale']} "
|
|
f"Δx={meta['end_crossings'] - g0} "
|
|
f"clr={clr0}→{meta['end_clearance_hits']} "
|
|
f"area={meta['start_area']}→{meta['end_area']}"
|
|
),
|
|
)
|
|
|
|
|
|
def compact_bbox_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
|
|
o = overrides or {}
|
|
portals = o.get("portal_ids") or o.get("portals") or []
|
|
if isinstance(portals, str):
|
|
portals = [portals]
|
|
frozen = o.get("frozen_ids") or []
|
|
if isinstance(frozen, str):
|
|
frozen = [frozen]
|
|
outlier = o.get("outlier_only")
|
|
if outlier is None:
|
|
outlier_only = True
|
|
else:
|
|
outlier_only = str(outlier).strip().lower() not in {"0", "false", "no", "off"}
|
|
return {
|
|
"portal_ids": [str(x) for x in portals if str(x).strip()],
|
|
"frozen_ids": {str(x) for x in frozen if str(x).strip()},
|
|
"min_scale": float(o.get("min_scale") or 0.72),
|
|
"step": float(o.get("step") or 0.03),
|
|
"max_clearance_slack": int(o.get("max_clearance_slack") or 80),
|
|
"outlier_only": outlier_only,
|
|
}
|