mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 22:10:48 +08:00
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>
This commit is contained in:
parent
918ece86c3
commit
52679a7cfc
150 changed files with 2367 additions and 2812 deletions
826
netx_api/mcp_remote/topology/layout_ops/press_crossings.py
Normal file
826
netx_api/mcp_remote/topology/layout_ops/press_crossings.py
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
"""Stage-2 crossing pressure without temp scripts.
|
||||
|
||||
Actions used by agents via layoutTopologyView:
|
||||
- press_hot_edges: rotate non-portal ends of top crossing edges about the other end
|
||||
- press_crossers: move nodes that participate in crossings against those hot edges
|
||||
- polish_crossings: straighten → hot_edges → crossers → untangle(portals)
|
||||
|
||||
Hot path uses incremental crossing deltas (O(deg·E) per trial) so KND-scale
|
||||
graphs (~500 links) stay interactive under MCP stdio timeouts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossings_after_node_move,
|
||||
crossings_involving_node,
|
||||
crossing_participation_full,
|
||||
node_footprint,
|
||||
segments_properly_intersect,
|
||||
top_crossing_edges,
|
||||
)
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings
|
||||
|
||||
|
||||
def park_phantom_nodes(state: LayoutState) -> set[str]:
|
||||
"""Park region: markers / absurd coords so they do not blow bbox/util."""
|
||||
moved: set[str] = set()
|
||||
for nid, (x, y) in list(state.positions.items()):
|
||||
if str(nid).startswith("region:") or abs(x) > 1e5 or abs(y) > 1e5:
|
||||
if not math.isfinite(x) or not math.isfinite(y) or abs(x) > 1e5 or abs(y) > 1e5:
|
||||
state.positions[nid] = (160.0, 160.0)
|
||||
moved.add(nid)
|
||||
elif str(nid).startswith("region:"):
|
||||
# keep finite but park markers to origin corner
|
||||
if abs(x) > 1e4 or abs(y) > 1e4:
|
||||
state.positions[nid] = (160.0, 160.0)
|
||||
moved.add(nid)
|
||||
return moved
|
||||
|
||||
|
||||
def _overlaps_any(
|
||||
node: str,
|
||||
pos: dict[str, tuple[float, float]],
|
||||
names: dict[str, str],
|
||||
) -> bool:
|
||||
x, y = pos[node]
|
||||
fa = node_footprint(names.get(node, ""))
|
||||
ax0, ay0, ax1, ay1 = x + fa[0], y + fa[1], x + fa[2], y + fa[3]
|
||||
for b, (bx, by) in pos.items():
|
||||
if b == node:
|
||||
continue
|
||||
if abs(bx - x) > 90 and abs(by - y) > 70:
|
||||
continue
|
||||
fb = node_footprint(names.get(b, ""))
|
||||
bx0, by0, bx1, by1 = bx + fb[0], by + fb[1], bx + fb[2], by + fb[3]
|
||||
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _rotate_about(
|
||||
hub: tuple[float, float], leaf: tuple[float, float], ang: float
|
||||
) -> tuple[float, float]:
|
||||
hx, hy = hub
|
||||
lx, ly = leaf
|
||||
dx, dy = lx - hx, ly - hy
|
||||
c, s = math.cos(ang), math.sin(ang)
|
||||
return (hx + dx * c - dy * s, hy + dx * s + dy * c)
|
||||
|
||||
|
||||
def frozen_portals_from_state(
|
||||
state: LayoutState, portal_ids: list[str] | None = None
|
||||
) -> set[str]:
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||
_hub_portals,
|
||||
frozen_ids_for_protect,
|
||||
)
|
||||
|
||||
if portal_ids:
|
||||
raw = {str(x) for x in portal_ids if str(x)}
|
||||
# Explicit dual-unit portals (~20) must stay frozen; only shrink if
|
||||
# caller passed the inflated multi-membership set.
|
||||
return _hub_portals(state, raw, cap=32)
|
||||
return frozen_ids_for_protect(state, "portals")
|
||||
|
||||
|
||||
def _large_graph_budget(n_links: int) -> dict[str, Any]:
|
||||
"""Shrink search when E is large so MCP stdio does not time out.
|
||||
|
||||
Target: full polish_crossings on ~500-link graphs finishes in ~15–25s.
|
||||
"""
|
||||
e = max(0, int(n_links))
|
||||
if e >= 1200:
|
||||
return {
|
||||
"hot_top_n": 6,
|
||||
"hot_max_moves": 10,
|
||||
"hot_max_sweeps": 1,
|
||||
"hot_angle_step": math.pi / 6,
|
||||
"hot_radii": [0.95, 1.2],
|
||||
"cross_top_n": 4,
|
||||
"cross_max_moves": 12,
|
||||
"cross_max_sweeps": 1,
|
||||
"cross_cand_cap": 48,
|
||||
"straighten": False,
|
||||
"skip_dual_full_x": True,
|
||||
"untangle_rounds": 18,
|
||||
"untangle_moves": 2,
|
||||
"untangle_rank_cap": 36,
|
||||
"untangle_angle_step": 30,
|
||||
}
|
||||
if e >= 400:
|
||||
return {
|
||||
"hot_top_n": 8,
|
||||
"hot_max_moves": 14,
|
||||
"hot_max_sweeps": 2,
|
||||
"hot_angle_step": math.pi / 8,
|
||||
"hot_radii": [0.9, 1.15, 1.35],
|
||||
"cross_top_n": 5,
|
||||
"cross_max_moves": 16,
|
||||
"cross_max_sweeps": 2,
|
||||
"cross_cand_cap": 72,
|
||||
# straighten_channels is O(channels×modes×crossings); on 1k+ link
|
||||
# metros it can stall for minutes with no cancel checkpoints.
|
||||
"straighten": False,
|
||||
"skip_dual_full_x": e >= 800,
|
||||
"untangle_rounds": 28,
|
||||
"untangle_moves": 3,
|
||||
"untangle_rank_cap": 48,
|
||||
"untangle_angle_step": 24,
|
||||
}
|
||||
if e >= 200:
|
||||
return {
|
||||
"hot_top_n": 8,
|
||||
"hot_max_moves": 14,
|
||||
"hot_max_sweeps": 2,
|
||||
"hot_angle_step": math.pi / 9,
|
||||
"hot_radii": [0.9, 1.1, 1.25],
|
||||
"cross_top_n": 4,
|
||||
"cross_max_moves": 14,
|
||||
"cross_max_sweeps": 2,
|
||||
"cross_cand_cap": 80,
|
||||
"straighten": False,
|
||||
"skip_dual_full_x": False,
|
||||
"untangle_rounds": 24,
|
||||
"untangle_moves": 3,
|
||||
"untangle_rank_cap": 60,
|
||||
"untangle_angle_step": 20,
|
||||
}
|
||||
return {
|
||||
"hot_top_n": 10,
|
||||
"hot_max_moves": 24,
|
||||
"hot_max_sweeps": 4,
|
||||
"hot_angle_step": math.pi / 12,
|
||||
"hot_radii": [0.85, 1.0, 1.15, 1.35],
|
||||
"cross_top_n": 6,
|
||||
"cross_max_moves": 40,
|
||||
"cross_max_sweeps": 6,
|
||||
"cross_cand_cap": 220,
|
||||
# Off by default: straighten flattens dual-unit petal eyes into H-chains.
|
||||
"straighten": False,
|
||||
"skip_dual_full_x": False,
|
||||
"untangle_rounds": 120,
|
||||
"untangle_moves": 5,
|
||||
"untangle_rank_cap": 140,
|
||||
"untangle_angle_step": 15,
|
||||
}
|
||||
|
||||
|
||||
def _has_petal_dual_eye(state: LayoutState) -> bool:
|
||||
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
|
||||
try:
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
units = find_dual_portal_units(state, max_units=24)
|
||||
if not units:
|
||||
return False
|
||||
n = max(1, len(state.positions) or len(state.names) or len(state.adj))
|
||||
for u in units:
|
||||
if classify_dual_unit(u) != "petal":
|
||||
continue
|
||||
if len(u.member_ids()) >= max(8, int(0.25 * n)):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def press_hot_edges(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
*,
|
||||
portal_ids: list[str] | None = None,
|
||||
top_n: int | None = None,
|
||||
max_moves: int | None = None,
|
||||
max_sweeps: int | None = None,
|
||||
) -> OpResult:
|
||||
"""Rotate non-portal ends of top crossing edges about the other endpoint."""
|
||||
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
|
||||
|
||||
del params
|
||||
st = state.copy()
|
||||
park_phantom_nodes(st)
|
||||
frozen = frozen_portals_from_state(st, portal_ids)
|
||||
pos = dict(st.positions)
|
||||
names = st.names
|
||||
links = st.links
|
||||
adj = st.adj
|
||||
budget = _large_graph_budget(len(links))
|
||||
# Cap overrides — giant metros must not explode search via params.top_n.
|
||||
top_n = min(
|
||||
int(top_n if top_n is not None else budget["hot_top_n"]),
|
||||
int(budget["hot_top_n"]) + (0 if len(links) >= 800 else 4),
|
||||
)
|
||||
max_moves = min(
|
||||
int(max_moves if max_moves is not None else budget["hot_max_moves"]),
|
||||
int(budget["hot_max_moves"]) + (0 if len(links) >= 800 else 6),
|
||||
)
|
||||
max_sweeps = min(
|
||||
int(max_sweeps if max_sweeps is not None else budget["hot_max_sweeps"]),
|
||||
int(budget["hot_max_sweeps"]),
|
||||
)
|
||||
angle_step = float(budget["hot_angle_step"])
|
||||
radii_scale = list(budget["hot_radii"])
|
||||
angles = [i * angle_step for i in range(1, max(2, int(round(2 * math.pi / angle_step))))]
|
||||
skip_dual = bool(budget.get("skip_dual_full_x"))
|
||||
|
||||
touch_heartbeat()
|
||||
start = count_edge_crossings(pos, links)
|
||||
cur = start
|
||||
moved: set[str] = set()
|
||||
accepted = 0
|
||||
|
||||
for _sweep in range(max(1, int(max_sweeps))):
|
||||
raise_if_cancelled()
|
||||
touch_heartbeat()
|
||||
_n, _nh, edge_hit = crossing_participation_full(pos, links)
|
||||
touch_heartbeat()
|
||||
tops = top_crossing_edges(
|
||||
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
|
||||
)
|
||||
sweep_moves = 0
|
||||
for ei, edge in enumerate(tops):
|
||||
if ei % 2 == 0:
|
||||
raise_if_cancelled()
|
||||
touch_heartbeat()
|
||||
a, b = str(edge["a_node_id"]), str(edge["b_node_id"])
|
||||
if a not in pos or b not in pos:
|
||||
continue
|
||||
cand_pairs: list[tuple[str, str]] = []
|
||||
if a not in frozen:
|
||||
cand_pairs.append((a, b))
|
||||
if b not in frozen:
|
||||
cand_pairs.append((b, a))
|
||||
if not cand_pairs:
|
||||
continue
|
||||
cand_pairs.sort(
|
||||
key=lambda pair: (
|
||||
0 if pair[1] in frozen else 1,
|
||||
len(adj.get(pair[0], ())),
|
||||
)
|
||||
)
|
||||
leaf, hub = cand_pairs[0]
|
||||
hub_xy, leaf_xy = pos[hub], pos[leaf]
|
||||
r0 = math.hypot(leaf_xy[0] - hub_xy[0], leaf_xy[1] - hub_xy[1]) or 200.0
|
||||
# Cap orbit radius so long spokes cannot fling leaves across a giant bbox.
|
||||
# Giant metros still need a higher reel target than 900 (bridges ≫5k).
|
||||
r_cap = 2200.0 if len(links) >= 400 else 1400.0
|
||||
r_use = min(r0, r_cap)
|
||||
max_disp = 1100.0 if len(links) >= 400 else 1100.0
|
||||
# Inward reel on long metro bridges needs a larger displacement budget
|
||||
# (otherwise max_disp rejects the only moves that cut crossings).
|
||||
if r0 > max_disp * 1.5:
|
||||
max_disp = max(max_disp, min(r0 * 0.55, r0 - 0.35 * r_use))
|
||||
local_before = crossings_involving_node(leaf, pos, links, adj)
|
||||
best: tuple[int, tuple[float, float]] | None = None
|
||||
best_dual: (
|
||||
tuple[int, tuple[float, float], tuple[float, float]] | None
|
||||
) = None
|
||||
|
||||
def _try_cand(cand: tuple[float, float], disp_cap: float | None = None) -> None:
|
||||
nonlocal best
|
||||
cap = max_disp if disp_cap is None else disp_cap
|
||||
if math.hypot(cand[0] - leaf_xy[0], cand[1] - leaf_xy[1]) > cap:
|
||||
return
|
||||
trial_pos = {**pos, leaf: cand}
|
||||
if _overlaps_any(leaf, trial_pos, names):
|
||||
return
|
||||
c1 = crossings_after_node_move(
|
||||
pos,
|
||||
links,
|
||||
adj,
|
||||
leaf,
|
||||
cand,
|
||||
current_total=cur,
|
||||
local_before=local_before,
|
||||
)
|
||||
if c1 < cur and (best is None or c1 < best[0]):
|
||||
best = (c1, cand)
|
||||
|
||||
# Reel-in along the spoke first (shorten without hunting angles).
|
||||
# Pulling straight toward the hub cannot fling the leaf across the
|
||||
# bbox, so allow it to travel all the way to the r_use target;
|
||||
# max_disp alone stops short (e.g. r0=8000 → cap 4400 < 5800 needed).
|
||||
if r0 > r_use + 50.0:
|
||||
reel_cap = max(max_disp, r0 - r_use)
|
||||
ux = (leaf_xy[0] - hub_xy[0]) / r0
|
||||
uy = (leaf_xy[1] - hub_xy[1]) / r0
|
||||
for rt in (
|
||||
r_use,
|
||||
r_use * 1.15,
|
||||
max(220.0, r0 * 0.25),
|
||||
max(220.0, r0 * 0.4),
|
||||
max(220.0, r0 * 0.55),
|
||||
max(220.0, r0 * 0.7),
|
||||
):
|
||||
rt = min(rt, r0 - 1.0)
|
||||
if rt < 120.0:
|
||||
continue
|
||||
_try_cand((hub_xy[0] + ux * rt, hub_xy[1] + uy * rt), reel_cap)
|
||||
|
||||
# Both ends walk toward the midpoint (metro bridges often need this).
|
||||
# Skip on giant E: each trial is a full O(E²) count and stalls MCP.
|
||||
if (not skip_dual) and r0 > 1500.0 and hub not in frozen:
|
||||
mx = 0.5 * (hub_xy[0] + leaf_xy[0])
|
||||
my = 0.5 * (hub_xy[1] + leaf_xy[1])
|
||||
ux = (leaf_xy[0] - hub_xy[0]) / r0
|
||||
uy = (leaf_xy[1] - hub_xy[1]) / r0
|
||||
px, py = -uy, ux
|
||||
dual_plans: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
for t in (0.15, 0.25, 0.35, 0.45, 0.55):
|
||||
dual_plans.append(
|
||||
(
|
||||
(
|
||||
leaf_xy[0] + t * (mx - leaf_xy[0]),
|
||||
leaf_xy[1] + t * (my - leaf_xy[1]),
|
||||
),
|
||||
(
|
||||
hub_xy[0] + t * (mx - hub_xy[0]),
|
||||
hub_xy[1] + t * (my - hub_xy[1]),
|
||||
),
|
||||
)
|
||||
)
|
||||
# Asymmetric: reel the free leaf harder than the hub.
|
||||
for tl, th in ((0.45, 0.15), (0.6, 0.2), (0.7, 0.25)):
|
||||
dual_plans.append(
|
||||
(
|
||||
(
|
||||
leaf_xy[0] + tl * (mx - leaf_xy[0]),
|
||||
leaf_xy[1] + tl * (my - leaf_xy[1]),
|
||||
),
|
||||
(
|
||||
hub_xy[0] + th * (mx - hub_xy[0]),
|
||||
hub_xy[1] + th * (my - hub_xy[1]),
|
||||
),
|
||||
)
|
||||
)
|
||||
for s in (-900.0, -520.0, -280.0, 280.0, 520.0, 900.0):
|
||||
dual_plans.append(
|
||||
(
|
||||
(leaf_xy[0] + px * s, leaf_xy[1] + py * s),
|
||||
(hub_xy[0] + px * s, hub_xy[1] + py * s),
|
||||
)
|
||||
)
|
||||
for leaf_c, hub_c in dual_plans:
|
||||
trial = {**pos, leaf: leaf_c, hub: hub_c}
|
||||
if _overlaps_any(leaf, trial, names) or _overlaps_any(
|
||||
hub, trial, names
|
||||
):
|
||||
continue
|
||||
c1 = count_edge_crossings(trial, links)
|
||||
if c1 < cur and (best_dual is None or c1 < best_dual[0]):
|
||||
best_dual = (c1, leaf_c, hub_c)
|
||||
|
||||
for ang in angles:
|
||||
for rs in radii_scale:
|
||||
rx, ry = _rotate_about(hub_xy, leaf_xy, ang)
|
||||
dx, dy = rx - hub_xy[0], ry - hub_xy[1]
|
||||
L = math.hypot(dx, dy) or 1.0
|
||||
cand = (
|
||||
hub_xy[0] + dx / L * r_use * min(rs, 1.25),
|
||||
hub_xy[1] + dy / L * r_use * min(rs, 1.25),
|
||||
)
|
||||
_try_cand(cand)
|
||||
if best is None:
|
||||
hx, hy = hub_xy
|
||||
lx, ly = leaf_xy
|
||||
dx, dy = lx - hx, ly - hy
|
||||
L = math.hypot(dx, dy) or 1.0
|
||||
px, py = -dy / L, dx / L
|
||||
for s in (-720.0, -480.0, -320.0, -200.0, -120.0, 120.0, 200.0, 320.0, 480.0, 720.0):
|
||||
_try_cand((lx + px * s, ly + py * s))
|
||||
# Prefer dual mid-walk when it beats single-end moves.
|
||||
if best_dual is not None and (
|
||||
best is None or best_dual[0] < best[0]
|
||||
):
|
||||
pos[leaf] = best_dual[1]
|
||||
pos[hub] = best_dual[2]
|
||||
cur = best_dual[0]
|
||||
moved.add(leaf)
|
||||
moved.add(hub)
|
||||
accepted += 1
|
||||
sweep_moves += 1
|
||||
if accepted >= max_moves:
|
||||
break
|
||||
continue
|
||||
if best is None:
|
||||
continue
|
||||
pos[leaf] = best[1]
|
||||
cur = best[0]
|
||||
moved.add(leaf)
|
||||
accepted += 1
|
||||
sweep_moves += 1
|
||||
if accepted >= max_moves:
|
||||
break
|
||||
if sweep_moves == 0 or accepted >= max_moves:
|
||||
break
|
||||
|
||||
st.positions = pos
|
||||
st.meta = dict(st.meta or {})
|
||||
st.meta["press_hot_edges"] = {
|
||||
"start_crossings": start,
|
||||
"end_crossings": cur,
|
||||
"accepted_moves": accepted,
|
||||
"frozen_n": len(frozen),
|
||||
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
|
||||
}
|
||||
return OpResult(
|
||||
state=st,
|
||||
moved=moved,
|
||||
op="press_hot_edges",
|
||||
params=st.meta["press_hot_edges"],
|
||||
note=f"press_hot_edges {start}->{cur} moves={accepted}",
|
||||
)
|
||||
|
||||
|
||||
def _edges_crossing(
|
||||
pos: dict[str, tuple[float, float]],
|
||||
links: list[tuple[str, str]],
|
||||
a: str,
|
||||
b: str,
|
||||
) -> list[tuple[str, str]]:
|
||||
if a not in pos or b not in pos:
|
||||
return []
|
||||
p1, p2 = pos[a], pos[b]
|
||||
out: list[tuple[str, str]] = []
|
||||
for c, d in links:
|
||||
if len({a, b, c, d}) < 4 or c not in pos or d not in pos:
|
||||
continue
|
||||
if segments_properly_intersect(p1, p2, pos[c], pos[d]):
|
||||
out.append((c, d))
|
||||
return out
|
||||
|
||||
|
||||
def _try_move_node(
|
||||
pos: dict[str, tuple[float, float]],
|
||||
names: dict[str, str],
|
||||
links: list[tuple[str, str]],
|
||||
adj: dict[str, set[str]],
|
||||
node: str,
|
||||
*,
|
||||
jump: float,
|
||||
cur: int,
|
||||
cand_cap: int = 220,
|
||||
) -> tuple[int, tuple[float, float]] | None:
|
||||
x0, y0 = pos[node]
|
||||
before_local = crossings_involving_node(node, pos, links, adj)
|
||||
if before_local <= 0:
|
||||
return None
|
||||
ang_step = 30 if cand_cap <= 60 else (20 if cand_cap <= 100 else 10)
|
||||
# Keep jumps local — long radii explode util on metro canvases.
|
||||
# Large graphs still need mid-range radii to clear hot-edge crossers.
|
||||
radii = (
|
||||
(120, 220, 360, 520)
|
||||
if cand_cap <= 80
|
||||
else (80, 140, 220, 320, 450, 600, 800, 1100)
|
||||
)
|
||||
cands: list[tuple[float, float]] = []
|
||||
for ang in range(0, 360, ang_step):
|
||||
for r in radii:
|
||||
if r > jump:
|
||||
continue
|
||||
rad = math.radians(ang)
|
||||
cands.append((x0 + r * math.cos(rad), y0 + r * math.sin(rad)))
|
||||
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
|
||||
if nbs:
|
||||
cx = sum(p[0] for p in nbs) / len(nbs)
|
||||
cy = sum(p[1] for p in nbs) / len(nbs)
|
||||
dx, dy = x0 - cx, y0 - cy
|
||||
L = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / L, dy / L
|
||||
px, py = -uy, ux
|
||||
for s in (-800.0, -500.0, -350.0, -200.0, 200.0, 350.0, 500.0, 800.0):
|
||||
cands.append((x0 + ux * s, y0 + uy * s))
|
||||
cands.append((x0 + px * s, y0 + py * s))
|
||||
if len(cands) > cand_cap:
|
||||
cands = cands[:: max(1, len(cands) // cand_cap)]
|
||||
best: tuple[int, tuple[float, float]] | None = None
|
||||
for cand in cands:
|
||||
if math.hypot(cand[0] - x0, cand[1] - y0) > jump + 1:
|
||||
continue
|
||||
trial_pos = {**pos, node: cand}
|
||||
if _overlaps_any(node, trial_pos, names):
|
||||
continue
|
||||
after_local = crossings_involving_node(node, trial_pos, links, adj)
|
||||
if after_local >= before_local:
|
||||
continue
|
||||
c1 = cur - before_local + after_local
|
||||
if c1 < cur and (best is None or c1 < best[0]):
|
||||
best = (c1, cand)
|
||||
return best
|
||||
|
||||
|
||||
def press_crossers(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
*,
|
||||
portal_ids: list[str] | None = None,
|
||||
top_n: int | None = None,
|
||||
max_moves: int | None = None,
|
||||
max_sweeps: int | None = None,
|
||||
) -> OpResult:
|
||||
"""Move non-portal nodes involved in crossings against top hot edges."""
|
||||
del params
|
||||
st = state.copy()
|
||||
park_phantom_nodes(st)
|
||||
frozen = frozen_portals_from_state(st, portal_ids)
|
||||
pos = dict(st.positions)
|
||||
names = st.names
|
||||
links = st.links
|
||||
adj = st.adj
|
||||
budget = _large_graph_budget(len(links))
|
||||
top_n = int(top_n if top_n is not None else budget["cross_top_n"])
|
||||
max_moves = int(max_moves if max_moves is not None else budget["cross_max_moves"])
|
||||
max_sweeps = int(max_sweeps if max_sweeps is not None else budget["cross_max_sweeps"])
|
||||
cand_cap = int(budget["cross_cand_cap"])
|
||||
|
||||
start = count_edge_crossings(pos, links)
|
||||
cur = start
|
||||
moved: set[str] = set()
|
||||
accepted = 0
|
||||
|
||||
for _sweep in range(max(1, int(max_sweeps))):
|
||||
_n, _nh, edge_hit = crossing_participation_full(pos, links)
|
||||
tops = top_crossing_edges(
|
||||
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
|
||||
)
|
||||
scores: dict[str, int] = {}
|
||||
for he in tops:
|
||||
a, b = str(he["a_node_id"]), str(he["b_node_id"])
|
||||
for c, d in _edges_crossing(pos, links, a, b):
|
||||
for nid in (c, d, a, b):
|
||||
if nid in frozen or nid not in pos:
|
||||
continue
|
||||
deg = len(adj.get(nid, ()))
|
||||
if deg >= 16:
|
||||
continue
|
||||
scores[nid] = scores.get(nid, 0) + int(he["crossing_hits"]) + max(
|
||||
0, 10 - deg
|
||||
)
|
||||
ranked = sorted(scores, key=lambda n: (-scores[n], len(adj.get(n, ())), n))
|
||||
node_cap = 16 if cand_cap <= 60 else (24 if cand_cap <= 100 else 40)
|
||||
improved = False
|
||||
for node in ranked[:node_cap]:
|
||||
jump = 1400.0 if len(adj.get(node, ())) <= 4 else 1000.0
|
||||
best = _try_move_node(
|
||||
pos, names, links, adj, node, jump=jump, cur=cur, cand_cap=cand_cap
|
||||
)
|
||||
if best is None:
|
||||
continue
|
||||
pos[node] = best[1]
|
||||
cur = best[0]
|
||||
moved.add(node)
|
||||
accepted += 1
|
||||
improved = True
|
||||
if accepted >= max_moves:
|
||||
break
|
||||
if not improved or accepted >= max_moves:
|
||||
break
|
||||
|
||||
st.positions = pos
|
||||
st.meta = dict(st.meta or {})
|
||||
st.meta["press_crossers"] = {
|
||||
"start_crossings": start,
|
||||
"end_crossings": cur,
|
||||
"accepted_moves": accepted,
|
||||
"frozen_n": len(frozen),
|
||||
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
|
||||
}
|
||||
return OpResult(
|
||||
state=st,
|
||||
moved=moved,
|
||||
op="press_crossers",
|
||||
params=st.meta["press_crossers"],
|
||||
note=f"press_crossers {start}->{cur} moves={accepted}",
|
||||
)
|
||||
|
||||
|
||||
def polish_crossings(
|
||||
state: LayoutState,
|
||||
params: LayoutParams | None = None,
|
||||
*,
|
||||
portal_ids: list[str] | None = None,
|
||||
straighten: bool | None = None,
|
||||
max_degree: int = 9,
|
||||
untangle_rounds: int | None = None,
|
||||
top_n: int | None = None,
|
||||
max_moves: int | None = None,
|
||||
max_sweeps: int | None = None,
|
||||
preserve_dual_eye: bool | None = None,
|
||||
) -> OpResult:
|
||||
"""Pipeline: park phantoms → (optional straighten) → hot → crossers → untangle.
|
||||
|
||||
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
|
||||
channel flattening destroys parallel-lane eye geometry.
|
||||
"""
|
||||
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
|
||||
|
||||
params = params or LayoutParams()
|
||||
st = state.copy()
|
||||
park_phantom_nodes(st)
|
||||
budget = _large_graph_budget(len(st.links))
|
||||
preserve = True if preserve_dual_eye is None else bool(preserve_dual_eye)
|
||||
petal_eye = False
|
||||
eye_portals: list[str] = []
|
||||
if preserve:
|
||||
try:
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
|
||||
units = find_dual_portal_units(st, max_units=24)
|
||||
n = max(1, len(st.positions) or len(st.names) or len(st.adj))
|
||||
for u in units:
|
||||
if classify_dual_unit(u) != "petal":
|
||||
continue
|
||||
if len(u.member_ids()) >= max(8, int(0.25 * n)):
|
||||
petal_eye = True
|
||||
eye_portals = [u.portal_a, u.portal_b]
|
||||
break
|
||||
except Exception:
|
||||
petal_eye = _has_petal_dual_eye(st)
|
||||
if portal_ids is None and eye_portals:
|
||||
portal_ids = eye_portals
|
||||
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
|
||||
if len(st.links) >= 800:
|
||||
do_straighten = False
|
||||
elif petal_eye and straighten is not True:
|
||||
do_straighten = False
|
||||
else:
|
||||
do_straighten = (
|
||||
bool(budget["straighten"]) if straighten is None else bool(straighten)
|
||||
)
|
||||
untangle_rounds = int(
|
||||
untangle_rounds if untangle_rounds is not None else budget["untangle_rounds"]
|
||||
)
|
||||
if len(st.links) >= 800:
|
||||
untangle_rounds = min(untangle_rounds, int(budget["untangle_rounds"]))
|
||||
top_n = (
|
||||
min(int(top_n), int(budget["hot_top_n"]))
|
||||
if top_n is not None
|
||||
else top_n
|
||||
)
|
||||
max_moves = (
|
||||
min(int(max_moves), int(budget["hot_max_moves"]))
|
||||
if max_moves is not None
|
||||
else max_moves
|
||||
)
|
||||
report_progress(
|
||||
"polish_start",
|
||||
pct=42.0,
|
||||
message=f"links={len(st.links)} rounds={untangle_rounds}",
|
||||
links=len(st.links),
|
||||
untangle_rounds=untangle_rounds,
|
||||
)
|
||||
start = count_edge_crossings(st.positions, st.links)
|
||||
trace: list[dict[str, Any]] = []
|
||||
moved: set[str] = set()
|
||||
|
||||
if do_straighten:
|
||||
from netx_api.mcp_remote.topology.layout_ops.channels import straighten_channels_greedy
|
||||
|
||||
raise_if_cancelled()
|
||||
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
|
||||
op = straighten_channels_greedy(st, params)
|
||||
st = op.state
|
||||
moved |= op.moved
|
||||
trace.append({"op": "straighten_channels", "note": op.note, **(op.params or {})})
|
||||
|
||||
raise_if_cancelled()
|
||||
report_progress("polish_hot_edges", pct=55.0, message="press_hot_edges")
|
||||
op_h = press_hot_edges(
|
||||
st,
|
||||
params,
|
||||
portal_ids=portal_ids,
|
||||
top_n=top_n,
|
||||
max_moves=max_moves,
|
||||
max_sweeps=max_sweeps,
|
||||
)
|
||||
st = op_h.state
|
||||
moved |= op_h.moved
|
||||
trace.append({"op": "press_hot_edges", "note": op_h.note, **(op_h.params or {})})
|
||||
|
||||
raise_if_cancelled()
|
||||
report_progress("polish_crossers", pct=62.0, message="press_crossers")
|
||||
op_c = press_crossers(
|
||||
st,
|
||||
params,
|
||||
portal_ids=portal_ids,
|
||||
top_n=top_n,
|
||||
max_moves=max_moves,
|
||||
max_sweeps=max_sweeps,
|
||||
)
|
||||
st = op_c.state
|
||||
moved |= op_c.moved
|
||||
trace.append({"op": "press_crossers", "note": op_c.note, **(op_c.params or {})})
|
||||
|
||||
focus: list[str] = []
|
||||
_n, node_hit, edge_hit = crossing_participation_full(st.positions, st.links)
|
||||
tops_e = top_crossing_edges(
|
||||
st.positions, st.links, names=st.names, top_n=5, edge_participation=edge_hit
|
||||
)
|
||||
for row in tops_e:
|
||||
focus.append(str(row["a_node_id"]))
|
||||
focus.append(str(row["b_node_id"]))
|
||||
ranked_nodes = sorted(node_hit.items(), key=lambda kv: -kv[1])[:5]
|
||||
focus.extend(nid for nid, _ in ranked_nodes)
|
||||
focus = list(dict.fromkeys(focus))
|
||||
|
||||
raise_if_cancelled()
|
||||
report_progress(
|
||||
"polish_untangle",
|
||||
pct=70.0,
|
||||
message=f"untangle rounds={untangle_rounds} focus={len(focus)}",
|
||||
untangle_rounds=untangle_rounds,
|
||||
focus_n=len(focus),
|
||||
)
|
||||
op_u = untangle_crossings(
|
||||
st,
|
||||
params,
|
||||
protect_rigid="portals",
|
||||
focus_ids=focus,
|
||||
max_rounds=untangle_rounds,
|
||||
max_degree=max_degree,
|
||||
target_crossings=40,
|
||||
max_jump=1000.0,
|
||||
moves_per_round=int(budget["untangle_moves"]),
|
||||
frozen_ids=frozen_portals_from_state(st, portal_ids) if portal_ids else None,
|
||||
rank_cap=int(budget["untangle_rank_cap"]),
|
||||
angle_step=int(budget["untangle_angle_step"]),
|
||||
refresh_every=3 if len(st.links) >= 200 else 1,
|
||||
)
|
||||
st = op_u.state
|
||||
moved |= op_u.moved
|
||||
trace.append({"op": "untangle", "note": op_u.note, **(op_u.params or {})})
|
||||
|
||||
end = count_edge_crossings(st.positions, st.links)
|
||||
report_progress(
|
||||
"polish_done",
|
||||
pct=74.0,
|
||||
message=f"crossings {start}->{end}",
|
||||
crossings_before=start,
|
||||
crossings_after=end,
|
||||
)
|
||||
st.meta = dict(st.meta or {})
|
||||
st.meta["polish_crossings"] = {
|
||||
"start_crossings": start,
|
||||
"end_crossings": end,
|
||||
"trace": trace,
|
||||
"focus_n": len(focus),
|
||||
"budget": budget,
|
||||
"straighten": do_straighten,
|
||||
"preserve_dual_eye": preserve,
|
||||
"petal_eye_detected": petal_eye,
|
||||
"untangle_rounds": untangle_rounds,
|
||||
}
|
||||
return OpResult(
|
||||
state=st,
|
||||
moved=moved,
|
||||
op="polish_crossings",
|
||||
params=st.meta["polish_crossings"],
|
||||
note=f"polish_crossings {start}->{end}",
|
||||
)
|
||||
|
||||
|
||||
def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
|
||||
o = overrides or {}
|
||||
out: dict[str, Any] = {}
|
||||
for key, cast in (
|
||||
("top_n", int),
|
||||
("max_moves", int),
|
||||
("max_sweeps", int),
|
||||
("max_degree", int),
|
||||
("untangle_rounds", int),
|
||||
):
|
||||
if key in o and o[key] is not None:
|
||||
try:
|
||||
out[key] = cast(o[key])
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if "straighten" in o:
|
||||
v = o["straighten"]
|
||||
out["straighten"] = (
|
||||
v
|
||||
if isinstance(v, bool)
|
||||
else str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if "preserve_dual_eye" in o:
|
||||
v = o["preserve_dual_eye"]
|
||||
out["preserve_dual_eye"] = (
|
||||
v
|
||||
if isinstance(v, bool)
|
||||
else str(v).strip().lower() in {"1", "true", "yes", "on"}
|
||||
)
|
||||
if "portal_ids" in o and isinstance(o["portal_ids"], list):
|
||||
out["portal_ids"] = [str(x) for x in o["portal_ids"] if str(x)]
|
||||
if "source_view_ids" in o and isinstance(o["source_view_ids"], list):
|
||||
out["source_view_ids"] = [str(x) for x in o["source_view_ids"] if str(x)]
|
||||
return out
|
||||
Loading…
Add table
Add a link
Reference in a new issue