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:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,824 @@
"""Canvas layout metrics: edge crossings, spacing, label/icon overlap.
Coordinates are API icon-center (x, y), matching TopologyPage neApiPosition.
Visual constants mirror web/src (TOPO_ICON=25, caption under glyph).
"""
from __future__ import annotations
import math
from typing import Any
# --- canvas visual budget (zoom=1, full LOD) ---
ICON_SIZE = 25.0
CAPTION_GAP = 1.0
CAPTION_NAME_H = 10.0 # ~8px font * 1.15 line-height
CAPTION_META_H = 8.0
CHAR_W = 4.8 # empirical for 8px UI font, Latin/digit NE names
# Comfortable center-to-center (infinite canvas — prefer airy over dense)
MIN_CENTER_DX = 160.0
MIN_CENTER_DY = 100.0
REC_CENTER_DX = 200.0
REC_CENTER_DY = 170.0
MIN_CENTER_DIST = 150.0 # euclidean floor between icon centers
# Node-center to non-incident edge segment (icon + pad); below → edge_clearance hit.
EDGE_CLEARANCE_THR = 40.0
EDGE_CLEARANCE_ENDPOINT_T = 0.05 # t near 0/1 ⇒ nn-like, skip (avoid double-count)
EDGE_CLEARANCE_SKIP_NE = 200_000 # n*e above this: skip full scan
# Edge axis: H/V metro look; within tol of axis counts as orthogonal.
EDGE_AXIS_TOL_DEG = 8.0
EDGE_AXIS_TOL_PX = 4.0
EDGE_AXIS_CREDIT_H = 1.0
EDGE_AXIS_CREDIT_V = 0.75 # horizontal preferred over vertical
def estimate_label_width(name: str) -> float:
n = max(1, len((name or "").strip()))
return max(40.0, n * CHAR_W)
def node_footprint(
name: str, *, with_meta: bool = False
) -> tuple[float, float, float, float]:
"""AABB relative to icon center: (min_x, min_y, max_x, max_y)."""
half_icon = ICON_SIZE / 2.0
lw = estimate_label_width(name) / 2.0
half_w = max(half_icon, lw)
top = -half_icon
bottom = half_icon + CAPTION_GAP + CAPTION_NAME_H
if with_meta:
bottom += CAPTION_META_H
return (-half_w, top, half_w, bottom)
def _orient(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float:
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
def segments_properly_intersect(
a: tuple[float, float],
b: tuple[float, float],
c: tuple[float, float],
d: tuple[float, float],
) -> bool:
"""True iff open segments ab and cd cross (shared endpoint ⇒ False)."""
if a == c or a == d or b == c or b == d:
return False
o1, o2 = _orient(a, b, c), _orient(a, b, d)
o3, o4 = _orient(c, d, a), _orient(c, d, b)
return o1 * o2 < 0 and o3 * o4 < 0
def point_segment_dist(
p: tuple[float, float], a: tuple[float, float], b: tuple[float, float]
) -> tuple[float, float]:
"""Distance from point ``p`` to segment ``ab`` and clamped projection ``t``∈[0,1]."""
ax, ay = a
bx, by = b
px, py = p
vx, vy = bx - ax, by - ay
wx, wy = px - ax, py - ay
L2 = vx * vx + vy * vy
if L2 < 1e-12:
return math.hypot(wx, wy), 0.0
t = max(0.0, min(1.0, (wx * vx + wy * vy) / L2))
return math.hypot(px - (ax + t * vx), py - (ay + t * vy)), t
def compute_edge_clearance(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
names: dict[str, str] | None = None,
thr: float = EDGE_CLEARANCE_THR,
endpoint_t: float = EDGE_CLEARANCE_ENDPOINT_T,
top_n: int = 5,
) -> dict[str, Any]:
"""Score nodes sitting too close to non-incident edge segments.
A hit = node N within ``thr`` of segment AB where N∉{A,B} and the
projection is not near an endpoint (endpoint nearness ≈ neighbour spacing).
"""
ids = [n for n in pos if n in pos]
n_nodes = len(ids)
n_links = len(links)
tip_ok = (
"非关联边不得擦过网元图标;d≥thr(默认 40)。"
"端点近距豁免(与 nn 分工)。阶段2:clear_edge_hits。"
)
if n_nodes == 0 or n_links == 0:
return {
"edge_clearance_hits": 0,
"nodes_hit": 0,
"min_clearance_p50": None,
"edge_clearance_score": 1.0,
"top_edge_hits": [],
"edge_clearance_tip": tip_ok,
"edge_clearance_skipped": False,
}
if n_nodes * n_links > EDGE_CLEARANCE_SKIP_NE:
return {
"edge_clearance_hits": None,
"nodes_hit": None,
"min_clearance_p50": None,
"edge_clearance_score": 1.0,
"top_edge_hits": [],
"edge_clearance_tip": (
f"skipped: n*e={n_nodes * n_links}>{EDGE_CLEARANCE_SKIP_NE}"
),
"edge_clearance_skipped": True,
}
name_map = names or {}
hits: list[dict[str, Any]] = []
clearances: list[float] = []
nodes_with_hit: set[str] = set()
thr_f = float(thr)
et = float(endpoint_t)
for nid in ids:
p = pos[nid]
best_d = float("inf")
for a, b in links:
if nid in (a, b) or a not in pos or b not in pos:
continue
d, t = point_segment_dist(p, pos[a], pos[b])
if t <= et or t >= 1.0 - et:
continue
if d < best_d:
best_d = d
if d < thr_f:
hits.append(
{
"fabric_node_id": nid,
"name": name_map.get(nid, nid),
"a_node_id": a,
"b_node_id": b,
"a_name": name_map.get(a, a),
"b_name": name_map.get(b, b),
"dist": round(d, 2),
"t": round(t, 4),
}
)
nodes_with_hit.add(nid)
if math.isfinite(best_d):
clearances.append(best_d)
hits.sort(key=lambda h: float(h["dist"]))
# Worst edge per node (sorted by dist)
per_node: list[dict[str, Any]] = []
seen_n: set[str] = set()
for h in hits:
nid = str(h["fabric_node_id"])
if nid in seen_n:
continue
seen_n.add(nid)
per_node.append(h)
hits_n = len(hits)
# Dual signal so hub chords with many segment hits actually hurt:
# 1) unique nodes hit / n (0 → 1, ≥20% → 0)
# 2) hits / links (≤0.02 → 1, ≥0.20 → 0) — separates sparse vs scrubbed eyes
hit_frac = len(nodes_with_hit) / max(n_nodes, 1)
hit_per_link = hits_n / max(n_links, 1)
if hit_frac <= 0.0:
node_s = 1.0
elif hit_frac >= 0.2:
node_s = 0.0
else:
node_s = 1.0 - hit_frac / 0.2
if hit_per_link <= 0.02:
inten_s = 1.0
elif hit_per_link >= 0.20:
inten_s = 0.0
else:
inten_s = 1.0 - (hit_per_link - 0.02) / 0.18
score = 0.45 * node_s + 0.55 * inten_s
def _pct(vals: list[float], p: float) -> float | None:
if not vals:
return None
vs = sorted(vals)
idx = min(len(vs) - 1, max(0, int(round((len(vs) - 1) * p))))
return round(vs[idx], 1)
return {
"edge_clearance_hits": hits_n,
"nodes_hit": len(nodes_with_hit),
"min_clearance_p50": _pct(clearances, 0.5),
"edge_clearance_score": round(score, 4),
"edge_clearance_node_score": round(node_s, 4),
"edge_clearance_intensity_score": round(inten_s, 4),
"hit_per_link": round(hit_per_link, 4),
"top_edge_hits": per_node[:top_n],
"hit_nodes": per_node,
"edge_clearance_tip": tip_ok,
"edge_clearance_skipped": False,
"edge_clearance_thr": thr_f,
}
def compute_edge_axis(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
names: dict[str, str] | None = None,
tol_deg: float = EDGE_AXIS_TOL_DEG,
tol_px: float = EDGE_AXIS_TOL_PX,
top_n: int = 5,
) -> dict[str, Any]:
"""Score edges for axis-alignment; prefer horizontal over vertical.
Classification (first match):
- horizontal: |dy|≤tol_px or angle-to-H ≤ tol_deg
- vertical: |dx|≤tol_px or angle-to-V ≤ tol_deg
- diagonal: else
Per-edge credit: H=1.0, V=0.75, diagonal=0. Score = mean credit.
"""
name_map = names or {}
tip = (
"边宜水平/垂直(地铁风);水平优先于垂直。"
f"容差≈{tol_deg:g}°或{tol_px:g}px。斜边拉低总分。"
)
if not links:
return {
"edge_axis_score": 1.0,
"axis_frac": 1.0,
"horiz_frac": 1.0,
"vert_frac": 0.0,
"diag_frac": 0.0,
"horiz_n": 0,
"vert_n": 0,
"diag_n": 0,
"top_skew_edges": [],
"edge_axis_tip": tip,
"edge_axis_tol_deg": float(tol_deg),
}
tol_rad = math.radians(max(0.1, float(tol_deg)))
tol_p = max(0.0, float(tol_px))
h_n = v_n = d_n = 0
credits: list[float] = []
skew: list[dict[str, Any]] = []
for a, b in links:
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:
# Degenerate: count as aligned (no geometry to fix)
h_n += 1
credits.append(EDGE_AXIS_CREDIT_H)
continue
adx, ady = abs(dx), abs(dy)
# Angle from nearest axis in [0, 45°]
ang_h = math.atan2(ady, adx) # 0=H, π/2=V
ang_v = abs(math.pi / 2 - ang_h)
near_h = ady <= tol_p or ang_h <= tol_rad
near_v = adx <= tol_p or ang_v <= tol_rad
if near_h and (not near_v or ang_h <= ang_v):
kind = "H"
h_n += 1
credits.append(EDGE_AXIS_CREDIT_H)
skew_deg = math.degrees(ang_h)
elif near_v:
kind = "V"
v_n += 1
credits.append(EDGE_AXIS_CREDIT_V)
skew_deg = math.degrees(ang_v)
else:
kind = "D"
d_n += 1
credits.append(0.0)
skew_deg = math.degrees(min(ang_h, ang_v))
skew.append(
{
"a_node_id": a,
"b_node_id": b,
"a_name": name_map.get(a, a),
"b_name": name_map.get(b, b),
"label": f"{name_map.get(a, a)}–{name_map.get(b, b)}",
"angle_from_axis_deg": round(skew_deg, 1),
"len": round(length, 1),
"kind": kind,
}
)
n = max(len(credits), 1)
score = sum(credits) / n
axis_n = h_n + v_n
total_n = h_n + v_n + d_n
skew.sort(key=lambda r: -float(r["angle_from_axis_deg"]))
return {
"edge_axis_score": round(score, 4),
"axis_frac": round(axis_n / max(total_n, 1), 4),
"horiz_frac": round(h_n / max(total_n, 1), 4),
"vert_frac": round(v_n / max(total_n, 1), 4),
"diag_frac": round(d_n / max(total_n, 1), 4),
"horiz_n": h_n,
"vert_n": v_n,
"diag_n": d_n,
"top_skew_edges": skew[:top_n],
"edge_axis_tip": tip,
"edge_axis_tol_deg": float(tol_deg),
"edge_axis_tol_px": float(tol_px),
}
def collapse_links(edges: list[dict[str, Any]]) -> list[tuple[str, str]]:
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for e in edges:
if not isinstance(e, dict):
continue
a = str(e.get("a_node_id") or e.get("a") or "").strip()
b = str(e.get("b_node_id") or e.get("b") or "").strip()
if not a or not b or a == b:
continue
key = (a, b) if a < b else (b, a)
if key in seen:
continue
seen.add(key)
out.append(key)
return out
def _seg_bbox(
p: tuple[float, float], q: tuple[float, float]
) -> tuple[float, float, float, float]:
return (
min(p[0], q[0]),
min(p[1], q[1]),
max(p[0], q[0]),
max(p[1], q[1]),
)
def _bbox_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 count_edge_crossings(
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
) -> int:
"""Full O(E²) crossing count with bbox prune (skips obvious non-hits)."""
segs: list[
tuple[tuple[float, float], tuple[float, float], tuple[float, float, float, float]]
] = []
for u, v in links:
if u in pos and v in pos:
p, q = pos[u], pos[v]
segs.append((p, q, _seg_bbox(p, q)))
n = 0
for i in range(len(segs)):
p1, p2, b1 = segs[i]
for j in range(i + 1, len(segs)):
p3, p4, b2 = segs[j]
if not _bbox_overlap(b1, b2):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
return n
def crossings_involving_node(
node: str,
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
) -> int:
"""Crossings on edges incident to ``node`` vs the rest of the graph.
Used for O(deg·E) trial scoring when one node moves.
"""
nbs = adj.get(node, ())
if not nbs or node not in pos:
return 0
incident = [(node, v) if node < v else (v, node) for v in nbs if v in pos]
if not incident:
return 0
inc_set = set(incident)
n = 0
p0 = pos[node]
for u, v in incident:
other = v if u == node else u
p1 = pos[other]
bb = _seg_bbox(p0, p1)
for a, b in links:
if (a, b) in inc_set or node in (a, b) or other in (a, b):
continue
if a not in pos or b not in pos:
continue
pa, pb = pos[a], pos[b]
if not _bbox_overlap(bb, _seg_bbox(pa, pb)):
continue
if segments_properly_intersect(p0, p1, pa, pb):
n += 1
return n
def crossings_after_node_move(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
node: str,
new_xy: tuple[float, float],
*,
current_total: int,
local_before: int | None = None,
) -> int:
"""Global crossing count after moving ``node`` — incremental, exact."""
if node not in pos:
return int(current_total)
before = (
int(local_before)
if local_before is not None
else crossings_involving_node(node, pos, links, adj)
)
trial = dict(pos)
trial[node] = new_xy
after = crossings_involving_node(node, trial, links, adj)
return int(current_total) - before + after
def crossing_participation(
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
) -> tuple[int, dict[str, int]]:
"""Count crossings + per-node hit counts (endpoint of a crossing edge)."""
n, node_hit, _edge_hit = crossing_participation_full(pos, links)
return n, node_hit
def crossing_participation_full(
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
) -> tuple[int, dict[str, int], dict[tuple[str, str], int]]:
"""Count crossings + per-node and per-edge hit counts.
Edge keys are undirected ``(min_id, max_id)``. An edge's hit count is how
many proper crossings that segment participates in.
"""
node_hit: dict[str, int] = {}
edge_hit: dict[tuple[str, str], int] = {}
n = 0
for i, (a, b) in enumerate(links):
if a not in pos or b not in pos:
continue
p1, p2 = pos[a], pos[b]
bb1 = _seg_bbox(p1, p2)
e1 = (a, b) if a < b else (b, a)
for c, d in links[i + 1 :]:
if len({a, b, c, d}) < 4 or c not in pos or d not in pos:
continue
p3, p4 = pos[c], pos[d]
if not _bbox_overlap(bb1, _seg_bbox(p3, p4)):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
e2 = (c, d) if c < d else (d, c)
for nid in (a, b, c, d):
node_hit[nid] = node_hit.get(nid, 0) + 1
edge_hit[e1] = edge_hit.get(e1, 0) + 1
edge_hit[e2] = edge_hit.get(e2, 0) + 1
return n, node_hit, edge_hit
def top_crossing_nodes(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
names: dict[str, str] | None = None,
adj: dict[str, set[str]] | None = None,
top_n: int = 5,
participation: dict[str, int] | None = None,
) -> list[dict[str, Any]]:
"""Top-N nodes by crossing participation — primary surgical untangle targets."""
hit = participation
if hit is None:
_cross, hit = crossing_participation(pos, links)
if not hit:
return []
names = names or {}
ranked = sorted(
hit.items(),
key=lambda kv: (-kv[1], names.get(kv[0], kv[0]), kv[0]),
)
out: list[dict[str, Any]] = []
for nid, hits in ranked[: max(0, int(top_n))]:
if adj is not None:
deg = len(adj.get(nid, ()))
else:
deg = sum(1 for u, v in links if nid in (u, v) and u in pos and v in pos)
xy = pos.get(nid, (0.0, 0.0))
out.append(
{
"fabric_node_id": nid,
"name": names.get(nid, nid),
"crossing_hits": int(hits),
"degree": int(deg),
"x": round(float(xy[0]), 1),
"y": round(float(xy[1]), 1),
}
)
return out
def top_crossing_edges(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
names: dict[str, str] | None = None,
top_n: int = 5,
edge_participation: dict[tuple[str, str], int] | None = None,
) -> list[dict[str, Any]]:
"""Top-N undirected edges by number of crossings they participate in."""
edge_hit = edge_participation
if edge_hit is None:
_n, _nodes, edge_hit = crossing_participation_full(pos, links)
if not edge_hit:
return []
names = names or {}
ranked = sorted(
edge_hit.items(),
key=lambda kv: (
-kv[1],
names.get(kv[0][0], kv[0][0]),
names.get(kv[0][1], kv[0][1]),
kv[0],
),
)
out: list[dict[str, Any]] = []
for (a, b), hits in ranked[: max(0, int(top_n))]:
ax, ay = pos.get(a, (0.0, 0.0))
bx, by = pos.get(b, (0.0, 0.0))
a_name = names.get(a, a)
b_name = names.get(b, b)
out.append(
{
"a_node_id": a,
"b_node_id": b,
"a_name": a_name,
"b_name": b_name,
"label": f"{a_name}<->{b_name}",
"crossing_hits": int(hits),
"ax": round(float(ax), 1),
"ay": round(float(ay), 1),
"bx": round(float(bx), 1),
"by": round(float(by), 1),
"mid_x": round((float(ax) + float(bx)) / 2.0, 1),
"mid_y": round((float(ay) + float(by)) / 2.0, 1),
}
)
return out
def _aabb_overlap(
ax0: float, ay0: float, ax1: float, ay1: float, bx0: float, by0: float, bx1: float, by1: float
) -> bool:
return ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0
def analyze_positions(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
with_meta: bool = False,
) -> dict[str, Any]:
"""Compute crossings + spacing/overlap stats for a view graph."""
pos: dict[str, tuple[float, float]] = {}
names: dict[str, str] = {}
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
try:
x = float(n.get("x") if n.get("x") is not None else 0.0)
y = float(n.get("y") if n.get("y") is not None else 0.0)
except (TypeError, ValueError):
x, y = 0.0, 0.0
pos[fid] = (x, y)
names[fid] = str(n.get("name") or n.get("label") or fid)
links = collapse_links(edges)
crossings, hit, edge_hit = crossing_participation_full(pos, links)
n_nodes = len(pos)
n_links = len(links)
adj: dict[str, set[str]] = {nid: set() for nid in pos}
for u, v in links:
if u in adj and v in adj:
adj[u].add(v)
adj[v].add(u)
top_x = top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
)
top_e = top_crossing_edges(
pos, links, names=names, top_n=5, edge_participation=edge_hit
)
# nearest-neighbor distances
ids = list(pos.keys())
nn: list[float] = []
close_pairs = 0
for i, a in enumerate(ids):
ax, ay = pos[a]
best = None
for b in ids:
if a == b:
continue
bx, by = pos[b]
d = math.hypot(ax - bx, ay - by)
if best is None or d < best:
best = d
if d < MIN_CENTER_DIST:
close_pairs += 1
if best is not None:
nn.append(best)
# close_pairs counted twice
close_pairs //= 2
overlap_pairs = 0
label_overlap_pairs = 0
for i, a in enumerate(ids):
ax, ay = pos[a]
fa = node_footprint(names[a], with_meta=with_meta)
for b in ids[i + 1 :]:
bx, by = pos[b]
fb = node_footprint(names[b], with_meta=with_meta)
if _aabb_overlap(
ax + fa[0],
ay + fa[1],
ax + fa[2],
ay + fa[3],
bx + fb[0],
by + fb[1],
bx + fb[2],
by + fb[3],
):
overlap_pairs += 1
# stricter: only caption bands (below icon)
a_cap = (ax + fa[0], ay + ICON_SIZE / 2 + CAPTION_GAP, ax + fa[2], ay + fa[3])
b_cap = (bx + fb[0], by + ICON_SIZE / 2 + CAPTION_GAP, bx + fb[2], by + fb[3])
if _aabb_overlap(*a_cap, *b_cap):
label_overlap_pairs += 1
xs = [p[0] for p in pos.values()] or [0.0]
ys = [p[1] for p in pos.values()] or [0.0]
nn_sorted = sorted(nn)
def pct(p: float) -> float | None:
if not nn_sorted:
return None
idx = min(len(nn_sorted) - 1, max(0, int(round((len(nn_sorted) - 1) * p))))
return round(nn_sorted[idx], 1)
crossings_per_link = round(crossings / n_links, 4) if n_links else 0.0
crossings_per_node = round(crossings / n_nodes, 4) if n_nodes else 0.0
bw = max(xs) - min(xs)
bh = max(ys) - min(ys)
area = max(bw * bh, 1.0)
# Ideal tile per NE ≈ recommended center pitch; util ∈ (0, ~1+].
space_utilization = round(n_nodes * REC_CENTER_DX * REC_CENTER_DY / area, 4)
return {
"node_count": n_nodes,
"link_count": n_links,
"edge_crossings": crossings,
"crossings_per_link": crossings_per_link,
"crossings_per_node": crossings_per_node,
"top_crossing_nodes": top_x,
"top_crossing_edges": top_e,
"bbox": [round(bw, 1), round(bh, 1)],
"nn_min": pct(0.0),
"nn_p50": pct(0.5),
"nn_p10": pct(0.1),
"pairs_closer_than_min_dist": close_pairs,
"footprint_overlap_pairs": overlap_pairs,
"label_overlap_pairs": label_overlap_pairs,
"space_utilization": space_utilization,
"spacing_guide": {
"icon_px": ICON_SIZE,
"min_center_dx": MIN_CENTER_DX,
"min_center_dy": MIN_CENTER_DY,
"recommended_center_dx": REC_CENTER_DX,
"recommended_center_dy": REC_CENTER_DY,
"min_center_dist": MIN_CENTER_DIST,
"note": (
"API x/y = icon center. Caption sits under icon (8px, nowrap). "
"Layout should keep centers ≥ recommended dx/dy so icons+names do not collide. "
"space_utilization = n*rec_dx*rec_dy / bbox_area (higher is denser; avoid empty zoom)."
),
},
"crossing_definition": (
"Undirected NE-NE links (port edges collapsed). "
"A crossing = two link segments properly intersect in the plane; "
"shared endpoints do not count."
),
}
def grade_layout(
metrics: dict[str, Any],
*,
ume_reference: bool = False,
) -> dict[str, Any]:
"""Pass/warn/fail vs spacing + crossing density.
Default: hard zero footprint/label overlap. Set ume_reference=True to tolerate
tiny residue when scoring engineer UME canvases.
"""
n = int(metrics.get("node_count") or 0)
cross = int(metrics.get("edge_crossings") or 0)
cpl = float(metrics.get("crossings_per_link") or 0.0)
overlaps = int(metrics.get("footprint_overlap_pairs") or 0)
label_overlaps = int(metrics.get("label_overlap_pairs") or 0)
nn_p50 = metrics.get("nn_p50")
util = metrics.get("space_utilization")
issues: list[str] = []
# Crossing budgets: absolute + crossings_per_link (UME KRO 201-500 p50≈0.16, p90≈0.16)
if n <= 50:
cross_warn, cross_fail = 5, 20
cpl_warn, cpl_fail = 0.05, 0.20
elif n <= 200:
cross_warn, cross_fail = 20, 80
cpl_warn, cpl_fail = 0.10, 0.25
elif n <= 500:
cross_warn, cross_fail = 80, 200
cpl_warn, cpl_fail = 0.16, 0.30
else:
cross_warn, cross_fail = 150, 400
cpl_warn, cpl_fail = 0.18, 0.35
if cross >= cross_fail or cpl >= cpl_fail:
cross_grade = "fail"
issues.append(f"crossings={cross}/cpl={cpl} (fail {cross_fail}/{cpl_fail})")
elif cross >= cross_warn or cpl >= cpl_warn:
cross_grade = "warn"
issues.append(f"crossings={cross}/cpl={cpl} (warn {cross_warn}/{cpl_warn})")
else:
cross_grade = "ok"
if ume_reference:
overlap_warn = max(3, n // 100)
overlap_fail = max(10, n // 40)
hard_overlap = overlaps > overlap_fail
soft_overlap = overlaps > overlap_warn
else:
# Absolute: icons/names must not overlap or block each other.
hard_overlap = overlaps > 0 or label_overlaps > 0
soft_overlap = False
if overlaps > 0:
issues.append(f"footprint_overlaps={overlaps}>0")
if label_overlaps > 0:
issues.append(f"label_overlaps={label_overlaps}>0")
util_warn = 0.08
util_fail = 0.04
util_f = float(util) if util is not None else None
util_bad = util_f is not None and util_f < util_fail
util_soft = util_f is not None and util_f < util_warn
if hard_overlap or util_bad or (nn_p50 is not None and float(nn_p50) < 80):
space_grade = "fail"
if util_bad:
issues.append(f"space_utilization={util_f}<{util_fail}")
if nn_p50 is not None and float(nn_p50) < 80:
issues.append(f"nn_p50={nn_p50}<80")
elif soft_overlap or util_soft or (nn_p50 is not None and float(nn_p50) < MIN_CENTER_DIST):
space_grade = "warn"
if soft_overlap and ume_reference:
issues.append(f"footprint_overlaps={overlaps}")
if util_soft:
issues.append(f"space_utilization={util_f}<{util_warn}")
if nn_p50 is not None and float(nn_p50) < MIN_CENTER_DIST:
issues.append(f"nn_p50={nn_p50}<{MIN_CENTER_DIST}")
else:
space_grade = "ok"
order = {"ok": 0, "warn": 1, "fail": 2}
overall = max([cross_grade, space_grade], key=lambda g: order[g])
return {
"overall": overall,
"crossing_grade": cross_grade,
"spacing_grade": space_grade,
"issues": issues,
"budgets": {
"cross_warn": cross_warn,
"cross_fail": cross_fail,
"min_center_dist": MIN_CENTER_DIST,
"recommended_dx": REC_CENTER_DX,
"recommended_dy": REC_CENTER_DY,
"overlap_hard_zero": not ume_reference,
"util_warn": util_warn,
"util_fail": util_fail,
},
"hint": (
"Fewer edge_crossings is better. Prefer crossings_per_link as size-normalized score. "
"Hard spacing: footprint_overlap_pairs=0 and label_overlap_pairs=0; "
"raise space_utilization without stacking icons."
),
}