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>
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""Path-based channels: maximal deg≤2 corridors as atomic layout units.
|
||
|
||
Inspired by Path-Based Framework (PBF): treat corridors as first-class
|
||
geometry (straight spines), not independent force-directed points.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||
|
||
|
||
def order_stubs_crossing_aware(
|
||
hub: str,
|
||
stubs: list[str],
|
||
adj: dict[str, set[str]],
|
||
member_set: set[str],
|
||
owner: dict[str, str],
|
||
) -> list[str]:
|
||
"""Stable stub order by foreign-neighbor hash angle (crossing-aware proxy)."""
|
||
if len(stubs) <= 2:
|
||
return sorted(stubs)
|
||
|
||
def score(s: str) -> tuple[float, str]:
|
||
terr = {n for n, o in owner.items() if o == s} | {s}
|
||
foreign: list[str] = []
|
||
for n in terr:
|
||
for v in adj.get(n, ()):
|
||
if v == hub or v in terr or v in member_set:
|
||
continue
|
||
foreign.append(v)
|
||
ang = (hash(s) % 1000) / 1000.0 * 2 * math.pi
|
||
if foreign:
|
||
ang = (hash(min(foreign)) % 1000) / 1000.0 * 2 * math.pi
|
||
return (ang, s)
|
||
|
||
return [s for _, s in sorted((score(s) for s in stubs), key=lambda t: t[0])]
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class Channel:
|
||
"""Ordered path hub…access… (portals may be high-degree)."""
|
||
|
||
node_ids: tuple[str, ...]
|
||
|
||
@property
|
||
def length(self) -> int:
|
||
return len(self.node_ids)
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"n": self.length,
|
||
"ends": [self.node_ids[0], self.node_ids[-1]] if self.node_ids else [],
|
||
"node_ids": list(self.node_ids)[:40],
|
||
}
|
||
|
||
|
||
def extract_channels(state: LayoutState) -> list[Channel]:
|
||
"""All maximal deg≤2 corridors (with portal hubs when unique)."""
|
||
paths = extract_chain_paths(state.adj)
|
||
out = [Channel(tuple(p)) for p in paths if len(p) >= 3]
|
||
out.sort(key=lambda c: (-c.length, c.node_ids[0] if c.node_ids else ""))
|
||
return out
|
||
|
||
|
||
def channels_touching(
|
||
channels: list[Channel], node_set: set[str]
|
||
) -> list[Channel]:
|
||
"""Channels with ≥2 nodes inside ``node_set`` (block-local)."""
|
||
return [c for c in channels if sum(1 for n in c.node_ids if n in node_set) >= 2]
|
||
|
||
|
||
def place_channel_ray(
|
||
path: list[str],
|
||
*,
|
||
origin: tuple[float, float],
|
||
ux: float,
|
||
uy: float,
|
||
step: float,
|
||
pinned: set[str] | None = None,
|
||
) -> dict[str, tuple[float, float]]:
|
||
"""Lay path along a unit ray from origin; skip pinned nodes (keep them).
|
||
|
||
Distance index: if path[0] is pinned (hub at origin), node at path[i]
|
||
sits at i*step; otherwise path[0] is at 1*step.
|
||
"""
|
||
pinned = pinned or set()
|
||
ox, oy = origin
|
||
out: dict[str, tuple[float, float]] = {}
|
||
hub_first = bool(path and path[0] in pinned)
|
||
for i, n in enumerate(path):
|
||
if n in pinned:
|
||
continue
|
||
k = i if hub_first else i + 1
|
||
out[n] = (ox + ux * step * k, oy + uy * step * k)
|
||
return out
|
||
|
||
|
||
def channels_report(state: LayoutState) -> dict[str, Any]:
|
||
ch = extract_channels(state)
|
||
nodes = {n for c in ch for n in c.node_ids}
|
||
return {
|
||
"channel_count": len(ch),
|
||
"channel_nodes": len(nodes),
|
||
"longest": ch[0].length if ch else 0,
|
||
"sample": [c.as_dict() for c in ch[:12]],
|
||
"tip": "通道是 deg≤2 走廊原子;channel_metro 按射线拉直,勿逐点力导。",
|
||
}
|
||
|
||
|
||
def _straighten_path_positions(
|
||
path: list[str],
|
||
pos: dict[str, tuple[float, float]],
|
||
*,
|
||
mode: str = "chord",
|
||
step: float | None = None,
|
||
bend_frac: float = 0.5,
|
||
) -> dict[str, tuple[float, float]] | None:
|
||
"""Reposition interior (and optionally ends) of path. Ends stay fixed for chord/L."""
|
||
pts = [pos[n] for n in path if n in pos]
|
||
if len(pts) < 3 or len(pts) != len(path):
|
||
return None
|
||
x0, y0 = pts[0]
|
||
x1, y1 = pts[-1]
|
||
n = len(path) - 1
|
||
out: dict[str, tuple[float, float]] = {}
|
||
if mode == "chord" or mode.startswith("chord_shift"):
|
||
dx, dy = x1 - x0, y1 - y0
|
||
plen = math.hypot(dx, dy) or 1.0
|
||
# lateral offset for chord_shift± (keeps ends fixed via taper)
|
||
off = 0.0
|
||
if mode.startswith("chord_shift"):
|
||
try:
|
||
off = float(mode.split(":", 1)[1])
|
||
except (IndexError, ValueError):
|
||
off = 0.0
|
||
px, py = (-dy / plen) * off, (dx / plen) * off
|
||
for i, nid in enumerate(path):
|
||
if i == 0 or i == len(path) - 1:
|
||
continue
|
||
t = i / n
|
||
# taper offset to 0 at ends (already skipping ends)
|
||
taper = math.sin(math.pi * t)
|
||
out[nid] = (
|
||
x0 + dx * t + px * taper,
|
||
y0 + dy * t + py * taper,
|
||
)
|
||
return out
|
||
if mode in {"L_hv", "L_vh"}:
|
||
# Orthogonal elbow; both ends fixed. bend_frac ∈ (0,1) picks knee along path.
|
||
bf = min(0.85, max(0.15, float(bend_frac)))
|
||
bend_i = max(1, min(n - 1, int(round(n * bf))))
|
||
for i, nid in enumerate(path):
|
||
if i == 0 or i == len(path) - 1:
|
||
continue
|
||
if mode == "L_hv":
|
||
# (x0,y0) → (x1,y0) → (x1,y1)
|
||
if i <= bend_i:
|
||
t = i / bend_i
|
||
out[nid] = (x0 + (x1 - x0) * t, y0)
|
||
else:
|
||
t = (i - bend_i) / (n - bend_i)
|
||
out[nid] = (x1, y0 + (y1 - y0) * t)
|
||
else:
|
||
# (x0,y0) → (x0,y1) → (x1,y1)
|
||
if i <= bend_i:
|
||
t = i / bend_i
|
||
out[nid] = (x0, y0 + (y1 - y0) * t)
|
||
else:
|
||
t = (i - bend_i) / (n - bend_i)
|
||
out[nid] = (x0 + (x1 - x0) * t, y1)
|
||
return out
|
||
if mode in {"horizontal", "vertical"}:
|
||
# Keep first endpoint; lay along axis with equal step
|
||
st = step or (
|
||
math.hypot(x1 - x0, y1 - y0) / max(n, 1)
|
||
if math.hypot(x1 - x0, y1 - y0) > 1
|
||
else 180.0
|
||
)
|
||
st = max(120.0, min(st, 280.0))
|
||
for i, nid in enumerate(path):
|
||
if i == 0:
|
||
continue
|
||
if mode == "horizontal":
|
||
out[nid] = (x0 + st * i, y0)
|
||
else:
|
||
out[nid] = (x0, y0 + st * i)
|
||
return out
|
||
return None
|
||
|
||
|
||
def _path_axis_credit(
|
||
path: list[str],
|
||
pos: dict[str, tuple[float, float]],
|
||
*,
|
||
tol_deg: float = 8.0,
|
||
tol_px: float = 4.0,
|
||
) -> float:
|
||
"""Mean H/V credit along path edges (H=1, V=0.75, diagonal=0)."""
|
||
import math
|
||
|
||
if len(path) < 2:
|
||
return 1.0
|
||
tol_rad = math.radians(max(0.1, float(tol_deg)))
|
||
tol_p = max(0.0, float(tol_px))
|
||
credits: list[float] = []
|
||
for i in range(len(path) - 1):
|
||
a, b = path[i], path[i + 1]
|
||
if a not in pos or b not in pos:
|
||
continue
|
||
ax, ay = pos[a]
|
||
bx, by = pos[b]
|
||
dx, dy = bx - ax, by - ay
|
||
length = math.hypot(dx, dy)
|
||
if length < 1e-9:
|
||
credits.append(1.0)
|
||
continue
|
||
adx, ady = abs(dx), abs(dy)
|
||
ang_h = math.atan2(ady, adx)
|
||
ang_v = abs(math.pi / 2 - ang_h)
|
||
if ady <= tol_p or ang_h <= tol_rad:
|
||
credits.append(1.0)
|
||
elif adx <= tol_p or ang_v <= tol_rad:
|
||
credits.append(0.75)
|
||
else:
|
||
credits.append(0.0)
|
||
return sum(credits) / max(len(credits), 1)
|
||
|
||
|
||
def straighten_channels_greedy(
|
||
state: LayoutState,
|
||
params: Any | None = None,
|
||
*,
|
||
step: float | None = None,
|
||
min_len: int = 3,
|
||
max_passes: int = 3,
|
||
):
|
||
"""Straighten deg≤2 channels.
|
||
|
||
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
|
||
accept H/V / L modes that raise path axis credit (metro look).
|
||
"""
|
||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, OpResult
|
||
|
||
params = params or LayoutParams()
|
||
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
|
||
pos = dict(state.positions)
|
||
links = list(state.links)
|
||
cross = count_edge_crossings(pos, links)
|
||
accepted: list[dict[str, Any]] = []
|
||
base_modes = (
|
||
"chord",
|
||
"horizontal",
|
||
"vertical",
|
||
"L_hv",
|
||
"L_vh",
|
||
)
|
||
bend_fracs = (0.35, 0.5, 0.65)
|
||
shift_offs = sorted(
|
||
{
|
||
-2.0 * step_px,
|
||
-1.5 * step_px,
|
||
-step_px,
|
||
-0.5 * step_px,
|
||
0.5 * step_px,
|
||
step_px,
|
||
1.5 * step_px,
|
||
2.0 * step_px,
|
||
-400.0,
|
||
-280.0,
|
||
280.0,
|
||
400.0,
|
||
}
|
||
)
|
||
|
||
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
|
||
|
||
for _pass in range(max(1, int(max_passes))):
|
||
raise_if_cancelled()
|
||
channels = extract_channels(state)
|
||
# Prefer longer kinked channels first
|
||
accepted_pass = 0
|
||
n_ch = max(1, len(channels))
|
||
for ci, ch in enumerate(channels):
|
||
if ci % 8 == 0:
|
||
raise_if_cancelled()
|
||
report_progress(
|
||
"straighten_channels",
|
||
pct=48.0 + 6.0 * ((_pass + ci / n_ch) / max(1, int(max_passes))),
|
||
message=f"pass {_pass + 1} ch {ci + 1}/{n_ch} x={cross}",
|
||
step=ci + 1,
|
||
total_steps=n_ch,
|
||
crossings=cross,
|
||
)
|
||
path = list(ch.node_ids)
|
||
if len(path) < min_len:
|
||
continue
|
||
if any(n not in pos for n in path):
|
||
continue
|
||
axis0 = _path_axis_credit(path, pos)
|
||
# Lexicographic key: fewer crossings, then higher axis credit.
|
||
best_key = (cross, -axis0)
|
||
best_pos = None
|
||
best_mode = None
|
||
modes = list(base_modes) + [f"chord_shift:{o:.1f}" for o in shift_offs]
|
||
for mode in modes:
|
||
fracs = bend_fracs if mode.startswith("L_") else (0.5,)
|
||
for bf in fracs:
|
||
delta = _straighten_path_positions(
|
||
path, pos, mode=mode, step=step_px, bend_frac=bf
|
||
)
|
||
if not delta:
|
||
continue
|
||
trial = dict(pos)
|
||
trial.update(delta)
|
||
c1 = count_edge_crossings(trial, links)
|
||
if c1 > cross:
|
||
continue
|
||
a1 = _path_axis_credit(path, trial)
|
||
key = (c1, -a1)
|
||
if key < best_key:
|
||
best_key = key
|
||
best_pos = trial
|
||
best_mode = f"{mode}@{bf:.2f}" if mode.startswith("L_") else mode
|
||
if best_pos is None:
|
||
continue
|
||
# light overlap fix; keep if still improved
|
||
st_try = state.copy()
|
||
st_try.positions = best_pos
|
||
stf = fix_overlaps_local(st_try, params).state
|
||
# restore pinned hubs (core/agg endpoints)
|
||
for end in (path[0], path[-1]):
|
||
if state.layers.get(end) in ("core", "agg") and end in pos:
|
||
stf.positions[end] = pos[end]
|
||
trial2 = {k: (float(v[0]), float(v[1])) for k, v in stf.positions.items()}
|
||
c2 = count_edge_crossings(trial2, links)
|
||
a2 = _path_axis_credit(path, trial2)
|
||
if (c2, -a2) >= (cross, -axis0):
|
||
continue
|
||
pos = trial2
|
||
cross = c2
|
||
accepted_pass += 1
|
||
accepted.append(
|
||
{
|
||
"ends": [path[0], path[-1]],
|
||
"n": len(path),
|
||
"mode": best_mode,
|
||
"crossings": cross,
|
||
"axis": round(a2, 3),
|
||
}
|
||
)
|
||
if accepted_pass == 0:
|
||
break
|
||
|
||
if not accepted:
|
||
return OpResult(
|
||
state=state,
|
||
moved=set(),
|
||
op="straighten_channels",
|
||
params={"accepted_n": 0},
|
||
note="no_channel_improved",
|
||
)
|
||
out = state.copy()
|
||
out.positions = pos
|
||
moved = {
|
||
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
|
||
}
|
||
out.meta = dict(out.meta or {})
|
||
out.meta["straighten_channels"] = {"accepted": accepted, "crossings": cross}
|
||
return OpResult(
|
||
state=out,
|
||
moved=moved,
|
||
op="straighten_channels",
|
||
params={"accepted_n": len(accepted), "accepted": accepted[:20], "crossings": cross},
|
||
note=f"straighten_channels:{len(accepted)} x->{cross}",
|
||
)
|
||
|
||
|
||
def straighten_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("min_len") is not None:
|
||
try:
|
||
out["min_len"] = int(overrides["min_len"])
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if overrides.get("max_passes") is not None:
|
||
try:
|
||
out["max_passes"] = int(overrides["max_passes"])
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return out
|