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,5 @@
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
__version__ = "0.1.51"
# Bump when public catalog/actions change so agents know to restart stdio.
NETX_MCP_REV = "2026-08-12-from-scratch-polish"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,92 @@
"""Subprocess entry for durable layout jobs (survives MCP parent restart)."""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="netx_api.mcp_remote.topology.layout_job_worker")
parser.add_argument("--job-id", required=True)
args = parser.parse_args(argv)
job_id = str(args.job_id).strip()
if not job_id:
return 2
from netx_api.mcp_remote.topology.layout_jobs import (
_JobCancelled,
_args_path,
_read_job_disk,
_write_job_disk,
bind_job,
finish_job,
is_cancelled,
report_progress,
touch_heartbeat,
unbind_job,
)
args_file = _args_path(job_id)
if not args_file.is_file():
finish_job(job_id, error="job_args_missing")
return 1
try:
tool_args = json.loads(args_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
finish_job(job_id, error=f"job_args_invalid:{exc}")
return 1
token = bind_job(job_id)
import threading
stop_beat = threading.Event()
def _beat() -> None:
while not stop_beat.wait(15.0):
touch_heartbeat()
threading.Thread(target=_beat, name=f"job-beat-{job_id}", daemon=True).start()
try:
job = _read_job_disk(job_id) or {"job_id": job_id, "status": "running"}
job["pid"] = __import__("os").getpid()
job["heartbeat_at"] = time.time()
job["progress"] = {
**(job.get("progress") or {}),
"phase": "running",
"message": "worker bound",
"updated_at": time.time(),
"pct": 1.0,
}
_write_job_disk(job)
report_progress("running", pct=1.0, message="worker started")
if is_cancelled():
raise _JobCancelled()
# Late import: pulls http_tools + layout stack.
from netx_api.mcp_remote.topology.http_tools import _layout_topology_view
result = _layout_topology_view(tool_args)
finish_job(job_id, result=result, cancelled=is_cancelled())
return 0 if result.get("ok") else 1
except _JobCancelled:
finish_job(job_id, cancelled=True)
return 0
except Exception as exc: # noqa: BLE001
finish_job(job_id, error=f"job_exception:{exc}")
return 1
finally:
stop_beat.set()
unbind_job(token)
try:
args_file.unlink(missing_ok=True)
except OSError:
pass
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,496 @@
"""Durable background jobs for long layoutTopologyView calls.
Cursor MCP tool calls often die around ~60s, and Cursor may also **respawn**
the MCP process between tool calls — in-memory threads then vanish.
Design:
- Job records live as JSON under ``NETX_LAYOUT_JOB_DIR`` (default
``<netx>/data/runtime/layout_jobs``).
- Workers run in a **subprocess** so they survive MCP parent restarts.
- Poll via ``job_status`` (same contract); cancel is cooperative via the
JSON ``cancel_requested`` flag.
Remote: any sticky MCP worker that shares the job dir can poll; true
multi-host still wants API-hosted jobs later.
"""
from __future__ import annotations
import contextvars
import json
import os
import subprocess
import sys
import threading
import time
import uuid
from pathlib import Path
from typing import Any, Callable
_LOCK = threading.Lock()
_JOBS: dict[str, dict[str, Any]] = {} # hot cache; disk is source of truth
_MAX_JOBS = 48
_STALE_AFTER_S = 90.0
_CURRENT_JOB_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"layout_job_id", default=None
)
def _job_dir() -> Path:
env = (os.getenv("NETX_LAYOUT_JOB_DIR") or "").strip()
if env:
p = Path(env)
else:
# netx_api/mcp_remote/topology → repo data/
here = Path(__file__).resolve()
repo = here.parents[4] # .../netx
p = repo / "data" / "runtime" / "layout_jobs"
p.mkdir(parents=True, exist_ok=True)
return p
def _job_path(job_id: str) -> Path:
return _job_dir() / f"{job_id}.json"
def _args_path(job_id: str) -> Path:
return _job_dir() / f"{job_id}.args.json"
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
tmp.replace(path)
def _read_job_disk(job_id: str) -> dict[str, Any] | None:
path = _job_path(job_id)
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def _write_job_disk(job: dict[str, Any]) -> None:
jid = str(job.get("job_id") or "").strip()
if not jid:
return
_atomic_write(_job_path(jid), job)
def _prune_disk() -> None:
files = sorted(_job_dir().glob("*.json"), key=lambda p: p.stat().st_mtime)
# ignore *.args.json
files = [p for p in files if not p.name.endswith(".args.json")]
if len(files) <= _MAX_JOBS:
return
# Active jobs must remain pollable and cancellable even when history fills up.
removable = [
path for path in files
if (_read_job_disk(path.stem) or {}).get("status") in {"done", "error", "cancelled"}
]
for path in removable[: max(0, len(files) - _MAX_JOBS)]:
try:
path.unlink(missing_ok=True)
path.with_name(path.stem + ".args.json").unlink(missing_ok=True)
except OSError:
pass
def _public_snapshot(job: dict[str, Any]) -> dict[str, Any]:
now = time.time()
started = float(job.get("started_at") or now)
finished = job.get("finished_at")
status = str(job.get("status") or "unknown")
heartbeat = float(job.get("heartbeat_at") or started)
elapsed_ms = job.get("elapsed_ms")
if elapsed_ms is None:
end = float(finished) if finished is not None else now
elapsed_ms = int(max(0.0, end - started) * 1000)
heartbeat_age_ms = int(max(0.0, now - heartbeat) * 1000)
stale = status in {"running", "cancelling"} and (now - heartbeat) >= _STALE_AFTER_S
progress = dict(job.get("progress") or {})
out = {
"job_id": job.get("job_id"),
"status": status,
"action": job.get("action"),
"view_id": job.get("view_id"),
"started_at": job.get("started_at"),
"finished_at": job.get("finished_at"),
"elapsed_ms": elapsed_ms,
"heartbeat_at": job.get("heartbeat_at"),
"heartbeat_age_ms": heartbeat_age_ms,
"stale": stale,
"cancel_requested": bool(job.get("cancel_requested")),
"progress": progress,
"meta": dict(job.get("meta") or {}),
"error": job.get("error"),
"pid": job.get("pid"),
}
if status in {"done", "error", "cancelled"}:
out["result"] = job.get("result")
return out
def current_job_id() -> str | None:
return _CURRENT_JOB_ID.get()
def report_progress(
phase: str,
*,
pct: float | None = None,
message: str = "",
step: int | None = None,
total_steps: int | None = None,
**extra: Any,
) -> None:
jid = _CURRENT_JOB_ID.get()
if not jid:
return
now = time.time()
phase_s = str(phase or "").strip() or "running"
prog: dict[str, Any] = {
"phase": phase_s,
"message": str(message or "")[:240],
"updated_at": now,
}
if pct is not None:
try:
prog["pct"] = max(0.0, min(100.0, float(pct)))
except (TypeError, ValueError):
pass
if step is not None:
try:
prog["step"] = int(step)
except (TypeError, ValueError):
pass
if total_steps is not None:
try:
prog["total_steps"] = int(total_steps)
except (TypeError, ValueError):
pass
for k, v in extra.items():
if v is None:
continue
if isinstance(v, (str, int, float, bool)):
prog[k] = v
elif isinstance(v, (list, tuple)) and len(v) <= 12:
prog[k] = list(v)
with _LOCK:
job = _read_job_disk(jid) or _JOBS.get(jid)
if not job or job.get("status") not in {"running", "cancelling"}:
return
prev = dict(job.get("progress") or {})
prev.update(prog)
job["progress"] = prev
job["heartbeat_at"] = now
_JOBS[jid] = job
_write_job_disk(job)
def touch_heartbeat() -> None:
jid = _CURRENT_JOB_ID.get()
if not jid:
return
with _LOCK:
job = _read_job_disk(jid) or _JOBS.get(jid)
if job and job.get("status") in {"running", "cancelling"}:
job["heartbeat_at"] = time.time()
_JOBS[jid] = job
_write_job_disk(job)
def is_cancelled() -> bool:
jid = _CURRENT_JOB_ID.get()
if not jid:
return False
with _LOCK:
job = _read_job_disk(jid) or _JOBS.get(jid)
if not job:
return False
return bool(job.get("cancel_requested"))
def cancel_job(job_id: str) -> dict[str, Any]:
jid = str(job_id or "").strip()
if not jid:
return {"ok": False, "error": "job_id_required"}
with _LOCK:
job = _read_job_disk(jid) or _JOBS.get(jid)
if not job:
return {"ok": False, "error": "job_not_found", "job_id": jid}
status = str(job.get("status") or "")
if status in {"done", "error", "cancelled"}:
return {
"ok": True,
"job_id": jid,
"status": status,
"cancel_requested": bool(job.get("cancel_requested")),
"hint": "Job already finished; cancel is a no-op.",
}
job["cancel_requested"] = True
job["status"] = "cancelling"
job["heartbeat_at"] = time.time()
job["progress"] = {
**(job.get("progress") or {}),
"phase": "cancelling",
"message": "cancel requested",
"updated_at": time.time(),
}
_JOBS[jid] = job
_write_job_disk(job)
return {
"ok": True,
"job_id": jid,
"status": "cancelling",
"cancel_requested": True,
"hint": "Cooperative cancel armed; worker exits at next checkpoint.",
}
class _JobCancelled(Exception):
"""Internal: cooperative cancel before/during runner."""
def raise_if_cancelled() -> None:
if is_cancelled():
raise _JobCancelled()
def bind_job(job_id: str) -> contextvars.Token:
"""Worker entry: bind ContextVar for report_progress / is_cancelled."""
return _CURRENT_JOB_ID.set(str(job_id))
def unbind_job(token: contextvars.Token) -> None:
_CURRENT_JOB_ID.reset(token)
def finish_job(
job_id: str,
*,
result: dict[str, Any] | None = None,
error: str | None = None,
cancelled: bool = False,
) -> None:
"""Worker finalizer — write terminal status to disk."""
jid = str(job_id or "").strip()
with _LOCK:
job = _read_job_disk(jid) or _JOBS.get(jid) or {"job_id": jid}
started = float(job.get("started_at") or time.time())
now = time.time()
applied = bool(isinstance(result, dict) and result.get("applied"))
if cancelled and not applied:
job["status"] = "cancelled"
job["error"] = "cancelled"
job["result"] = result or {
"ok": False,
"error": "cancelled",
"job_id": jid,
"applied": False,
}
elif error and not (isinstance(result, dict) and result.get("ok")):
job["status"] = "error"
job["error"] = str(error)[:500]
job["result"] = result or {
"ok": False,
"error": job["error"],
"job_id": jid,
}
else:
job["status"] = "done" if (result or {}).get("ok") else "error"
job["result"] = result
if cancelled and applied and isinstance(job["result"], dict):
job["result"] = {
**job["result"],
"cancel_requested": True,
"hint": (
str(job["result"].get("hint") or "")
+ " (cancel requested after apply; write kept)"
).strip(),
}
if not (result or {}).get("ok"):
job["error"] = (result or {}).get("error") or error or "job_failed"
job["finished_at"] = now
job["elapsed_ms"] = int((now - started) * 1000)
job["heartbeat_at"] = now
job["progress"] = {
**(job.get("progress") or {}),
"phase": job["status"],
"pct": 100.0 if job["status"] == "done" else (job.get("progress") or {}).get("pct"),
"message": str(job.get("error") or job["status"]),
"updated_at": now,
}
_JOBS[jid] = job
_write_job_disk(job)
def start_job(
*,
action: str,
view_id: str,
runner: Callable[[], dict[str, Any]] | None = None,
meta: dict[str, Any] | None = None,
tool_args: dict[str, Any] | None = None,
) -> str:
"""Start a durable subprocess job.
``tool_args`` should be the full layoutTopologyView args with
``params._force_sync=True`` so the worker runs the heavy path inline.
``runner`` is only used in unit tests (in-process fallback).
"""
job_id = uuid.uuid4().hex[:16]
now = time.time()
job = {
"job_id": job_id,
"status": "running",
"action": action,
"view_id": view_id,
"started_at": now,
"finished_at": None,
"elapsed_ms": None,
"heartbeat_at": now,
"cancel_requested": False,
"progress": {
"phase": "queued",
"pct": 0.0,
"message": "starting worker",
"updated_at": now,
},
"result": None,
"error": None,
"meta": dict(meta or {}),
"pid": None,
}
with _LOCK:
_prune_disk()
_JOBS[job_id] = job
_write_job_disk(job)
# Unit-test / sync path: in-process thread when no tool_args.
if tool_args is None and runner is not None:
return _start_thread_job(job_id, action, view_id, runner)
if tool_args is None:
raise ValueError("tool_args_or_runner_required")
args_payload = dict(tool_args)
params = dict(args_payload.get("params") or {})
params["_force_sync"] = True
params.pop("background", None)
args_payload["params"] = params
args_payload["mode"] = "apply"
args_payload["action"] = action
args_payload["view_id"] = view_id
_atomic_write(_args_path(job_id), args_payload)
env = os.environ.copy()
env.setdefault("PYTHONUTF8", "1")
env.setdefault("PYTHONIOENCODING", "utf-8")
# Ensure worker can import the same package tree.
src = str(Path(__file__).resolve().parents[1])
prev = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = src + (os.pathsep + prev if prev else "")
creationflags = 0
if sys.platform == "win32":
# Detach from MCP console; survive parent exit.
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) | getattr(
subprocess, "DETACHED_PROCESS", 0x00000008
)
try:
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_api.mcp_remote.topology.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
except OSError as exc:
finish_job(job_id, error=f"worker_start_failed:{exc}")
_args_path(job_id).unlink(missing_ok=True)
# The caller receives a pollable terminal job instead of an orphaned running record.
return job_id
with _LOCK:
job = _read_job_disk(job_id) or job
job["pid"] = proc.pid
# A short computation can finish before Popen returns to the parent.
# Keep its terminal progress/result intact.
if job.get("status") in {"running", "cancelling"}:
job["progress"] = {
**(job.get("progress") or {}),
"message": f"worker pid={proc.pid}",
"updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
_JOBS[job_id] = job
_write_job_disk(job)
return job_id
def _start_thread_job(
job_id: str,
action: str,
view_id: str,
runner: Callable[[], dict[str, Any]],
) -> str:
stop_beat = threading.Event()
def _heartbeat_ticker() -> None:
while not stop_beat.wait(15.0):
touch_heartbeat()
with _LOCK:
job = _read_job_disk(job_id)
if not job or job.get("status") not in {"running", "cancelling"}:
return
def _run() -> None:
token = bind_job(job_id)
try:
report_progress("running", pct=1.0, message="runner started")
raise_if_cancelled()
result = runner()
finish_job(
job_id,
result=result,
cancelled=is_cancelled(),
)
except _JobCancelled:
finish_job(job_id, cancelled=True)
except Exception as exc: # noqa: BLE001
finish_job(job_id, error=f"job_exception:{exc}")
finally:
stop_beat.set()
unbind_job(token)
threading.Thread(
target=_heartbeat_ticker, name=f"layout-job-beat-{job_id}", daemon=True
).start()
threading.Thread(target=_run, name=f"layout-job-{job_id}", daemon=True).start()
return job_id
def get_job(job_id: str) -> dict[str, Any] | None:
with _LOCK:
job = _read_job_disk(job_id)
if job:
_JOBS[str(job_id)] = job
return dict(job) if job else None
def job_public(job_id: str) -> dict[str, Any] | None:
with _LOCK:
job = _read_job_disk(job_id)
if not job:
return None
_JOBS[str(job_id)] = job
return _public_snapshot(job)

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."
),
}

View file

@ -0,0 +1,77 @@
"""Composable topology layout atoms + multipass recipes.
Usage:
state = build_state_from_nodes_edges(nodes, edges)
state, trace, score = run_recipe(state, "smd_corridor_v1", LayoutParams(...))
positions = positions_for_api(state)
Scoped / block ops:
select_scope(mode=component|bbox|layer|ids|all)
list_blocks(mode=component|hub_territory|leiden|soft|…)
foreach_blocks in recipes runs sub-passes per block (default=CC).
Soft partition: layout_ops.partition (hub BFS + optional igraph).
"""
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.partition import (
igraph_available,
pack_block_centers,
pack_soft_blocks,
partition_report,
partition_soft_blocks,
resolve_block_mode,
)
from netx_api.mcp_remote.topology.layout_ops.recipe import (
RECIPES,
REGISTRY,
agg_rings_v1_passes,
positions_for_api,
run_recipe,
smd_corridor_compact_v1_passes,
smd_corridor_unstick_v1_passes,
smd_corridor_v1_passes,
)
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, select_scope
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
compose_into_state,
strip_pack_blocks,
)
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
find_dual_portal_units,
layout_dual_unit,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
__all__ = [
"LayoutParams",
"LayoutState",
"OpResult",
"REGISTRY",
"RECIPES",
"agg_rings_v1_passes",
"build_ring_skeleton",
"build_state_from_nodes_edges",
"compose_into_state",
"find_dual_portal_units",
"fix_overlaps_local",
"igraph_available",
"layout_dual_unit",
"list_blocks",
"pack_block_centers",
"pack_soft_blocks",
"partition_report",
"partition_soft_blocks",
"positions_for_api",
"relax_hotspots",
"resolve_block_mode",
"run_recipe",
"score_state",
"select_scope",
"strip_pack_blocks",
"smd_corridor_v1_passes",
"smd_corridor_compact_v1_passes",
"smd_corridor_unstick_v1_passes",
]

View file

@ -0,0 +1,279 @@
"""Align a canvas to a reference layout (e.g. hand golden / UME).
Uses shared ``fabric_node_id``s. Preferred mode ``similarity``: map reference
portal chord → target portal chord (scale+rotate+translate), then place every
shared node from the transformed reference. Target-only leftovers stay put
(or park toward nearest aligned neighbour).
This is the escape hatch when gated local polish stalls: reuse known-good
geometry instead of more until_limit.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
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 _dist(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def _similarity_from_portals(
ref: dict[str, tuple[float, float]],
tgt_portals: dict[str, tuple[float, float]],
portal_ids: list[str],
) -> tuple[float, float, float, float, float, float, float] | None:
"""Return (cos, sin, scale, tx, ty, rx0, ry0) mapping ref → target via two portals.
x' = scale * R * (x - r0) + t0
"""
if len(portal_ids) < 2:
return None
a, b = portal_ids[0], portal_ids[1]
if a not in ref or b not in ref or a not in tgt_portals or b not in tgt_portals:
return None
r0 = ref[a]
r1 = ref[b]
t0 = tgt_portals[a]
t1 = tgt_portals[b]
rdx, rdy = r1[0] - r0[0], r1[1] - r0[1]
tdx, tdy = t1[0] - t0[0], t1[1] - t0[1]
rlen = math.hypot(rdx, rdy)
tlen = math.hypot(tdx, tdy)
if rlen < 1e-6 or tlen < 1e-6:
return None
scale = tlen / rlen
ang = math.atan2(tdy, tdx) - math.atan2(rdy, rdx)
return (math.cos(ang), math.sin(ang), scale, t0[0], t0[1], r0[0], r0[1])
def _apply_sim(
xy: tuple[float, float],
sim: tuple[float, float, float, float, float, float, float],
) -> tuple[float, float]:
cos_a, sin_a, scale, tx, ty, rx0, ry0 = sim
dx, dy = xy[0] - rx0, xy[1] - ry0
return (
tx + scale * (dx * cos_a - dy * sin_a),
ty + scale * (dx * sin_a + dy * cos_a),
)
def _procrustes_similarity(
src: dict[str, tuple[float, float]],
dst: dict[str, tuple[float, float]],
ids: list[str],
) -> tuple[float, float, float, float, float, float, float] | None:
"""Umeyama-like 2D similarity from matched point pairs (ids in both)."""
pts = [i for i in ids if i in src and i in dst]
if len(pts) < 2:
return None
sx = sum(src[i][0] for i in pts) / len(pts)
sy = sum(src[i][1] for i in pts) / len(pts)
dx = sum(dst[i][0] for i in pts) / len(pts)
dy = sum(dst[i][1] for i in pts) / len(pts)
var_s = 0.0
cross = 0.0 # complex: sum conj(s)*d
# Using: scale*R maps (s-mean_s) → (d-mean_d)
sum_xx = sum_yy = sum_xy = sum_yx = 0.0
for i in pts:
sx0, sy0 = src[i][0] - sx, src[i][1] - sy
dx0, dy0 = dst[i][0] - dx, dst[i][1] - dy
var_s += sx0 * sx0 + sy0 * sy0
sum_xx += sx0 * dx0
sum_yy += sy0 * dy0
sum_xy += sx0 * dy0
sum_yx += sy0 * dx0
if var_s < 1e-8:
return None
# R = [[c,-s],[s,c]]; from SVD of covariance — 2D closed form:
# mu = atan2(sum_xy - sum_yx, sum_xx + sum_yy) ... use complex
re = sum_xx + sum_yy
im = sum_xy - sum_yx
ang = math.atan2(im, re)
cos_a, sin_a = math.cos(ang), math.sin(ang)
# scale = trace(R^T Cov) / var_s
scale = (re * cos_a + im * sin_a) / var_s
if scale < 1e-6:
scale = 1.0
# x' = scale R (x - mean_s) + mean_d
return (cos_a, sin_a, scale, dx, dy, sx, sy)
def align_to_reference(
state: LayoutState,
params: LayoutParams | None = None,
*,
reference: dict[str, tuple[float, float]],
portal_ids: list[str] | None = None,
mode: str = "similarity",
park_missing: bool = True,
freeze_portals: bool = True,
) -> OpResult:
"""Rewrite ``state.positions`` from ``reference`` geometry.
``mode``:
- ``similarity``: portal (or Procrustes) similarity; keep target portals
fixed when ``freeze_portals`` and portals provided.
- ``adopt``: copy reference coords for shared ids (normalize origin ≥40).
"""
del params
st = state.copy()
pos = dict(st.positions)
ref = {k: v for k, v in reference.items() if k in pos or True}
portals = [str(p) for p in (portal_ids or []) if str(p).strip()]
shared = sorted(set(pos) & set(ref))
if len(shared) < 2:
return OpResult(
state=st,
moved=set(),
op="align_reference",
note="align_reference:too_few_shared",
params={"error": "too_few_shared", "shared_n": len(shared)},
)
mode_k = str(mode or "similarity").strip().lower() or "similarity"
g0 = count_edge_crossings(pos, st.links)
area0 = 0.0
if len(pos) >= 2:
x0, y0, x1, y1 = bbox(pos)
area0 = max((x1 - x0) * (y1 - y0), 1.0)
new_pos = dict(pos)
moved: set[str] = set()
sim = None
meta_mode = mode_k
if mode_k in {"adopt", "copy", "absolute"}:
xs = [ref[i][0] for i in shared]
ys = [ref[i][1] for i in shared]
ox, oy = min(xs), min(ys)
pad = 40.0
for nid in shared:
new_pos[nid] = (ref[nid][0] - ox + pad, ref[nid][1] - oy + pad)
moved.add(nid)
meta_mode = "adopt"
else:
# similarity
if len(portals) >= 2 and all(p in ref and p in pos for p in portals[:2]):
sim = _similarity_from_portals(ref, pos, portals[:2])
if sim is None:
# Procrustes on hubs: prefer portals if present else all shared
pivot = [p for p in portals if p in shared] if portals else shared
if len(pivot) < 2:
pivot = shared
sim = _procrustes_similarity(ref, pos, pivot[: min(32, len(pivot))])
if sim is None:
return OpResult(
state=st,
moved=set(),
op="align_reference",
note="align_reference:no_transform",
params={"error": "no_transform", "shared_n": len(shared)},
)
for nid in shared:
if freeze_portals and nid in portals[:2]:
continue # keep target portals pinned
new_pos[nid] = _apply_sim(ref[nid], sim)
moved.add(nid)
meta_mode = "similarity"
# Target-only: park near nearest aligned neighbour
only_tgt = [n for n in pos if n not in ref]
parked = 0
if park_missing and only_tgt and shared:
for nid in only_tgt:
# nearest shared by old distance
nb = min(
shared,
key=lambda s: _dist(pos[nid], pos[s]),
)
old_dx = pos[nid][0] - pos[nb][0]
old_dy = pos[nid][1] - pos[nb][1]
# shrink orphan offset toward new nb (keeps relative stub)
new_pos[nid] = (new_pos[nb][0] + old_dx * 0.85, new_pos[nb][1] + old_dy * 0.85)
moved.add(nid)
parked += 1
# Optional flip across portal chord if it lowers crossings
if (
meta_mode == "similarity"
and len(portals) >= 2
and portals[0] in new_pos
and portals[1] in new_pos
):
pa, pb = new_pos[portals[0]], new_pos[portals[1]]
flipped = dict(new_pos)
ax, ay = pa
bx, by = pb
dx, dy = bx - ax, by - ay
llen2 = dx * dx + dy * dy
if llen2 > 1e-8:
for nid, (x, y) in new_pos.items():
if nid in portals[:2]:
continue
# reflect point across line AB
t = ((x - ax) * dx + (y - ay) * dy) / llen2
projx, projy = ax + t * dx, ay + t * dy
flipped[nid] = (2 * projx - x, 2 * projy - y)
g_a = count_edge_crossings(new_pos, st.links)
g_b = count_edge_crossings(flipped, st.links)
if g_b < g_a:
new_pos = flipped
meta_mode = "similarity_flipped"
st.positions = new_pos
st.last_moved = moved
g1 = count_edge_crossings(new_pos, st.links)
ov = _has_any_footprint_overlap(new_pos, st.names)
x0, y0, x1, y1 = bbox(new_pos) if len(new_pos) >= 2 else (0, 0, 1, 1)
area1 = max((x1 - x0) * (y1 - y0), 1.0)
meta: dict[str, Any] = {
"mode": meta_mode,
"shared_n": len(shared),
"moved_n": len(moved),
"parked_n": parked,
"target_only_n": len(only_tgt),
"ref_only_n": len(set(ref) - set(pos)),
"portal_ids": portals[:4],
"freeze_portals": bool(freeze_portals),
"start_crossings": g0,
"end_crossings": g1,
"overlaps": bool(ov),
"start_area": round(area0, 1),
"end_area": round(area1, 1),
"scale": round(float(sim[2]), 4) if sim else None,
}
st.meta["align_reference"] = meta
return OpResult(
state=st,
moved=moved,
op="align_reference",
params=meta,
note=(
f"align_reference mode={meta_mode} shared={len(shared)} "
f"moved={len(moved)} x={g0}→{g1} area={meta['start_area']}→{meta['end_area']}"
),
)
def align_reference_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]
return {
"portal_ids": [str(x) for x in portals if str(x).strip()],
"mode": str(o.get("mode") or o.get("align_mode") or "similarity").strip().lower(),
"park_missing": bool(o.get("park_missing", True)),
"freeze_portals": bool(o.get("freeze_portals", True)),
"reference_view_id": str(
o.get("reference_view_id") or o.get("ref_view_id") or ""
).strip(),
}

View file

@ -0,0 +1,801 @@
"""Contract pure chains / ring+chain into a super-node, orbit, then expand.
When single-point ``orbit_sweep`` stalls, long chords are often owned by a
*corridor* (deg≤2 chain) or a small ring with a dangling chain. Moving any
interior node alone barely changes global crossings; moving the whole bundle
as a unit can.
Expand rule (hard)
------------------
Expansion must **not** raise global crossings. If a full-length expand invades
crowded space, probe **minimized** packings (shorter step along the ray) and
only accept a candidate that clears the gate after expand. No silent “apply
now, fix later”.
Modes
-----
- **chain**: tip/centroid samples → expand mobile nodes on anchor→tip ray
with scale probes.
- **ring_chain**: triangle tip (+ dangling chain) sweeps; ring base stays;
expand chain outward with the same minimize-probe.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
top_crossing_nodes,
)
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_MAX_JUMP_CAP,
_far_field_guides,
_polar_grid,
)
from netx_api.mcp_remote.topology.layout_ops.ring_faces import extract_ring_faces
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
# Compact-first: tight packs before full-length (fits crowded pockets).
# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
_EXPAND_SCALES = (0.35, 0.45, 0.55, 0.7, 0.85, 1.0)
_MIN_STEP = 80.0
_TIP_SAMPLE_CAP = 48
@dataclass(frozen=True)
class Bundle:
"""Mobile corridor with ordered path and optional fixed anchor."""
kind: str # chain | ring_chain
member_ids: tuple[str, ...]
tip_id: str
base_ids: tuple[str, ...] = ()
path: tuple[str, ...] = () # ordered anchor…tip (anchor may be fixed)
anchor_id: str | None = None
@property
def key(self) -> str:
return f"{self.kind}:{self.tip_id}:{len(self.member_ids)}"
def _centroid(
pos: dict[str, tuple[float, float]], ids: list[str] | tuple[str, ...]
) -> tuple[float, float] | None:
pts = [pos[n] for n in ids if n in pos]
if not pts:
return None
return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts))
def _mean_step(path: list[str] | tuple[str, ...], pos: dict[str, tuple[float, float]]) -> float:
pts = [pos[n] for n in path if n in pos]
if len(pts) < 2:
return 140.0
total = 0.0
for i in range(len(pts) - 1):
total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
return max(_MIN_STEP, total / (len(pts) - 1))
def _walk_dangling_chain(
start: str,
adj: dict[str, set[str]],
*,
blocked: set[str],
) -> list[str]:
"""From ``start`` (just outside blocked), walk a deg≤2 corridor away."""
if start in blocked or start not in adj:
return []
path = [start]
prev = None
cur = start
while True:
nbs = [v for v in adj.get(cur, ()) if v != prev and v not in blocked]
if len(path) == 1:
low = [v for v in nbs if len(adj.get(v, ())) <= 2]
if len(low) == 1:
nxt = low[0]
elif len(nbs) == 1 and len(adj.get(nbs[0], ())) <= 2:
nxt = nbs[0]
else:
break
else:
if len(adj.get(cur, ())) > 2:
break
if len(nbs) != 1:
break
nxt = nbs[0]
if len(adj.get(nxt, ())) > 2 and nxt not in blocked:
break
path.append(nxt)
prev, cur = cur, nxt
if len(path) > 40:
break
return path
def detect_chain_bundles(
adj: dict[str, set[str]],
pos: dict[str, tuple[float, float]],
*,
frozen: set[str],
min_nodes: int = 3,
) -> list[Bundle]:
"""Pure (or portal-ended) chains → mobile interiors as expandable bundles."""
out: list[Bundle] = []
seen: set[frozenset[str]] = set()
for full in extract_chain_paths(adj):
if len(full) < min_nodes:
continue
# Orient: high-deg / frozen portal first when possible.
ordered = list(full)
d0 = len(adj.get(ordered[0], ()))
d1 = len(adj.get(ordered[-1], ()))
if d1 > d0 or (ordered[-1] in frozen and ordered[0] not in frozen):
ordered = list(reversed(ordered))
mobile = [
n
for n in ordered
if n in pos
and n not in frozen
and not (len(adj.get(n, ())) > 2 and n in {ordered[0], ordered[-1]})
]
if len(mobile) < max(2, min_nodes - 1):
continue
key = frozenset(mobile)
if key in seen:
continue
seen.add(key)
# Tip = free end of the oriented path (last mobile).
tip = mobile[-1]
bases = [n for n in (ordered[0], ordered[-1]) if n not in key and n in pos]
anchor = None
if ordered[0] not in key and ordered[0] in pos:
anchor = ordered[0]
elif bases:
anchor = bases[0]
# Path for expand: anchor (optional) + mobiles in corridor order.
if anchor:
# mobiles already follow corridor from hub side
path = (anchor, *mobile)
else:
path = tuple(mobile)
out.append(
Bundle(
kind="chain",
member_ids=tuple(mobile),
tip_id=tip,
base_ids=tuple(bases),
path=path,
anchor_id=anchor,
)
)
out.sort(key=lambda b: (-len(b.member_ids), b.tip_id))
return out
def detect_ring_chain_bundles(
state: LayoutState,
*,
frozen: set[str],
max_ring_len: int = 5,
) -> list[Bundle]:
"""Ring + dangling chain → sweep the attachment tip (triangle vertex)."""
adj = state.adj
pos = state.positions
out: list[Bundle] = []
seen: set[frozenset[str]] = set()
faces = extract_ring_faces(state, max_len=max_ring_len, max_cycles=60)
for face in faces:
rset = set(face.node_ids)
if len(rset) < 3:
continue
for tip in face.node_ids:
if tip in frozen or tip not in pos:
continue
outs = [v for v in adj.get(tip, ()) if v not in rset]
for seed in outs:
chain = _walk_dangling_chain(seed, adj, blocked=rset)
if len(chain) < 1:
continue
mobile = [tip, *chain]
mobile = [n for n in mobile if n not in frozen and n in pos]
if len(mobile) < 2:
continue
key = frozenset(mobile)
if key in seen:
continue
seen.add(key)
base = tuple(n for n in face.node_ids if n != tip)
# path: tip is anchor of the dangling chain (ring vertex stays
# the triangle tip we move); chain packs outward from tip.
path = tuple([tip, *[n for n in chain if n in key]])
out.append(
Bundle(
kind="ring_chain",
member_ids=tuple(mobile),
tip_id=tip,
base_ids=base,
path=path,
anchor_id=tip,
)
)
out.sort(
key=lambda b: (
0 if len(b.base_ids) == 2 else 1,
-len(b.member_ids),
b.tip_id,
)
)
return out
def detect_bundles(
state: LayoutState,
*,
frozen: set[str] | None = None,
) -> list[Bundle]:
frozen = set(frozen or ())
chains = detect_chain_bundles(state.adj, state.positions, frozen=frozen)
rings = detect_ring_chain_bundles(state, frozen=frozen)
used: set[str] = set()
out: list[Bundle] = []
for b in list(rings) + list(chains):
members = set(b.member_ids)
if members & used:
continue
if any(n in frozen for n in b.member_ids):
continue
used |= members
out.append(b)
return out
def _bundle_ok(
members: set[str],
trial: dict[str, tuple[float, float]],
names: dict[str, str],
nn_floor: float,
) -> bool:
"""Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders."""
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_box,
_centers_may_overlap,
)
from netx_api.mcp_remote.topology.layout_metrics import node_footprint
# Apply refuses any footprint overlap — members must clear each other too.
mem_list = [n for n in members if n in trial]
for i, n in enumerate(mem_list):
ax0, ay0, ax1, ay1 = _box(n, trial, names)
nx, ny = trial[n]
fa = node_footprint(names.get(n, ""))
# vs other members
for m in mem_list[i + 1 :]:
mx, my = trial[m]
fb = node_footprint(names.get(m, ""))
if not _centers_may_overlap(nx, ny, mx, my, fa, fb):
continue
bx0, by0, bx1, by1 = _box(m, trial, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return False
# vs outsiders + nn_floor
floor2 = nn_floor * nn_floor if nn_floor > 0 else 0.0
for b, (x, y) in trial.items():
if b in members:
continue
if floor2 and (x - nx) * (x - nx) + (y - ny) * (y - ny) < floor2:
return False
fb = node_footprint(names.get(b, ""))
if not _centers_may_overlap(nx, ny, x, y, fa, fb):
continue
bx0, by0, bx1, by1 = _box(b, trial, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return False
return True
def _expand_path_placements(
path: list[str],
*,
origin: tuple[float, float],
ux: float,
uy: float,
step: float,
mobile: set[str],
tip_xy: tuple[float, float] | None = None,
pin_tip: bool = False,
) -> dict[str, tuple[float, float]]:
"""Place mobile nodes along ray; optionally pin free tip at tip_xy."""
ox, oy = origin
out: dict[str, tuple[float, float]] = {}
movables = [n for n in path if n in mobile]
if not movables:
return out
if pin_tip and tip_xy is not None and len(movables) >= 1:
# Chord pack: first mobile near origin+step, last at tip_xy.
tx, ty = tip_xy
if len(movables) == 1:
out[movables[0]] = (tx, ty)
return out
# Keep tip fixed; distribute interiors on chord origin→tip.
for i, n in enumerate(movables):
t = (i + 1) / len(movables)
# start a bit off the anchor
out[n] = (ox + (tx - ox) * t, oy + (ty - oy) * t)
out[movables[-1]] = (tx, ty)
return out
for i, n in enumerate(movables):
k = i + 1
out[n] = (ox + ux * step * k, oy + uy * step * k)
return out
def _probe_expand(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
names: dict[str, str],
bundle: Bundle,
*,
tip_xy: tuple[float, float],
global0: int,
nn_floor: float,
min_delta: int,
) -> dict[str, Any] | None:
"""Try full→minimized expands toward tip_xy; keep first non-increasing best.
Returns candidate dict with placements, or None if every expand raises
crossings / invades.
"""
members = [n for n in bundle.member_ids if n in pos]
mem_set = set(members)
if len(members) < 2:
return None
path = [n for n in (bundle.path or bundle.member_ids) if n in pos or n == bundle.anchor_id]
if not path:
path = list(members)
anchor = bundle.anchor_id
if anchor and anchor in pos:
ox, oy = pos[anchor]
elif bundle.base_ids:
bpts = [pos[n] for n in bundle.base_ids if n in pos]
if not bpts:
c0 = _centroid(pos, members)
if not c0:
return None
ox, oy = c0
else:
ox = sum(p[0] for p in bpts) / len(bpts)
oy = sum(p[1] for p in bpts) / len(bpts)
else:
# No fixed anchor: use opposite end of path as soft origin (old tip side).
fixed = [n for n in path if n not in mem_set and n in pos]
if fixed:
ox, oy = pos[fixed[0]]
else:
c0 = _centroid(pos, members)
if not c0:
return None
ox, oy = c0
tx, ty = tip_xy
dx, dy = tx - ox, ty - oy
dist = math.hypot(dx, dy)
if dist < 60.0:
return None
ux, uy = dx / dist, dy / dist
base_step = _mean_step(path if len(path) >= 2 else members, pos)
n_mobile = len(members)
for scale in _EXPAND_SCALES:
step = max(_MIN_STEP, base_step * float(scale))
# Prefer tip-pinned chord pack: interiors on origin→tip.
# Minimized scales shorten tip reach so the footprint fits clearance.
max_reach = step * max(1, n_mobile)
reach = min(dist, max_reach) if scale < 0.99 else dist
tip_use = (ox + ux * reach, oy + uy * reach)
placed = _expand_path_placements(
path,
origin=(ox, oy),
ux=ux,
uy=uy,
step=step,
mobile=mem_set,
tip_xy=tip_use,
pin_tip=True,
)
if len(placed) < len(mem_set):
# fallback: step-only for missing
for n in members:
if n not in placed:
continue
if not placed:
continue
trial = dict(pos)
trial.update(placed)
# _bundle_ok already rejects outsider footprint invasion + nn_floor.
if not _bundle_ok(mem_set, trial, names, nn_floor):
continue
g1 = count_edge_crossings(trial, links)
# Hard rule: expand must not raise crossings.
if g1 > global0:
continue
delta = g1 - global0
if delta > -max(1, int(min_delta)):
continue
return {
"x": round(tip_use[0], 1),
"y": round(tip_use[1], 1),
"r": round(math.hypot(tip_use[0] - ox, tip_use[1] - oy), 1),
"angle_deg": round(math.degrees(math.atan2(uy, ux)) % 360.0, 1),
"crossings": {"global": g1},
"delta": {"global": int(delta)},
"stretch": round(step / max(base_step, 1.0), 3),
"expand_scale": round(float(scale), 3),
"step": round(step, 1),
"placements": {
k: (round(v[0], 1), round(v[1], 1)) for k, v in placed.items()
},
"origin": [round(ox, 1), round(oy, 1)],
}
return None
def orbit_bundle(
state: LayoutState,
bundle: Bundle,
*,
max_jump: float = 4000.0,
angle_step: int = 20,
cand_cap: int = 220,
nn_floor: float = 28.0,
min_delta: int = 1,
y_min: float | None = None,
y_max: float | None = None,
) -> dict[str, Any]:
"""Sample tip directions; probe minimized expands; keep improving only."""
pos = dict(state.positions)
names = dict(state.names)
links = list(state.links)
members = tuple(n for n in bundle.member_ids if n in pos)
if len(members) < 2:
return {"ok": False, "error": "bundle_too_small", "bundle": bundle.key}
c0 = _centroid(pos, members)
if c0 is None:
return {"ok": False, "error": "no_centroid", "bundle": bundle.key}
cx, cy = c0
jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP))
global0 = count_edge_crossings(pos, links)
# Tip seed samples around current tip / centroid.
tip0 = pos.get(bundle.tip_id, (cx, cy))
ext_nbs: set[str] = set()
mem_set = set(members)
for n in members:
for v in state.adj.get(n, ()):
if v not in mem_set and v in pos:
ext_nbs.add(v)
pseudo = f"__bundle__{bundle.tip_id}"
c_adj = {pseudo: set(ext_nbs)}
for v in ext_nbs:
c_adj.setdefault(v, set()).add(pseudo)
c_pos = {pseudo: tip0, **{v: pos[v] for v in ext_nbs}}
samples: list[tuple[float, float]] = []
for sx, sy, _r, _ang in _far_field_guides(c_pos, pseudo, c_adj, jump):
samples.append((sx, sy))
for sx, sy, _r, _ang in _polar_grid(
tip0[0], tip0[1], jump=jump, angle_step=max(12, int(angle_step))
):
samples.append((sx, sy))
samples.append((cx, cy))
if bundle.base_ids:
base_pts = [pos[n] for n in bundle.base_ids if n in pos]
if base_pts:
bx = sum(p[0] for p in base_pts) / len(base_pts)
by = sum(p[1] for p in base_pts) / len(base_pts)
dx, dy = tip0[0] - bx, tip0[1] - by
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
for dist in (0.35 * jump, 0.6 * jump, 0.9 * jump):
samples.append((tip0[0] + ux * dist, tip0[1] + uy * dist))
samples.append((tip0[0] - ux * dist, tip0[1] - uy * dist))
seen: set[tuple[int, int]] = set()
scored: list[dict[str, Any]] = []
tip_cap = min(int(cand_cap), _TIP_SAMPLE_CAP)
for sx, sy in samples:
if y_min is not None and sy < y_min:
continue
if y_max is not None and sy > y_max:
continue
key = (int(round(sx / 12.0) * 12), int(round(sy / 12.0) * 12))
if key in seen:
continue
seen.add(key)
if len(seen) > tip_cap:
break
if math.hypot(sx - tip0[0], sy - tip0[1]) < 40.0 and math.hypot(
sx - cx, sy - cy
) < 40.0:
continue
probed = _probe_expand(
pos,
links,
names,
bundle,
tip_xy=(sx, sy),
global0=global0,
nn_floor=nn_floor,
min_delta=min_delta,
)
if probed is None:
continue
if int(probed["delta"]["global"]) >= 0:
continue
scored.append(probed)
# Enough improving tips — rank later.
if len(scored) >= 8:
break
scored.sort(
key=lambda c: (
int(c["delta"]["global"]),
float(c.get("expand_scale") or 1.0),
float(c.get("r") or 0.0),
)
)
improving = [
c
for c in scored
if int(c["delta"]["global"]) <= -max(1, int(min_delta))
]
top = improving[:5]
for i, c in enumerate(top, start=1):
c["rank"] = i
return {
"ok": True,
"bundle": bundle.key,
"kind": bundle.kind,
"tip_id": bundle.tip_id,
"tip_name": names.get(bundle.tip_id, bundle.tip_id),
"member_n": len(members),
"member_ids": list(members)[:40],
"base_ids": list(bundle.base_ids)[:12],
"anchor_id": bundle.anchor_id,
"centroid0": [round(cx, 1), round(cy, 1)],
"crossings_before": global0,
"candidates": top,
"improving_n": len(improving),
"sampled": len(seen),
"max_jump": jump,
"expand_mode": "minimize_probe",
}
def apply_bundle_pick(
state: LayoutState,
bundle: Bundle,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
pos = dict(state.positions)
cands = list(sweep.get("candidates") or [])
if not cands:
return OpResult(
state=state,
moved=set(),
op="bundle_orbit",
params={"error": "no_candidates", "bundle": bundle.key},
note="bundle_orbit:no_candidates",
)
idx = max(1, min(len(cands), int(pick))) - 1
cand = cands[idx]
placements = cand.get("placements") or {}
if not placements:
return OpResult(
state=state,
moved=set(),
op="bundle_orbit",
params={"error": "no_placements", "bundle": bundle.key},
note="bundle_orbit:no_placements",
)
# Safety: re-score expand; refuse if crossings rose (stale / race).
trial = dict(pos)
moved: set[str] = set()
for nid, xy in placements.items():
if nid not in trial:
continue
trial[nid] = (float(xy[0]), float(xy[1]))
moved.add(nid)
g0 = count_edge_crossings(pos, state.links)
g1 = count_edge_crossings(trial, state.links)
if g1 > g0:
return OpResult(
state=state,
moved=set(),
op="bundle_orbit",
params={
"error": "expand_raises_crossings",
"bundle": bundle.key,
"start_crossings": g0,
"end_crossings": g1,
"hint": "Expand probe failed gate; try smaller scale / other tip.",
},
note="bundle_orbit:expand_raises_crossings",
)
# Minimize-expand must not leave footprint overlaps (apply gate).
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
if _has_any_footprint_overlap(trial, state.names):
return OpResult(
state=state,
moved=set(),
op="bundle_orbit",
params={
"error": "expand_invades_space",
"bundle": bundle.key,
"start_crossings": g0,
"end_crossings": g1,
"expand_scale": cand.get("expand_scale"),
"hint": "Space too tight; tip/placement wrong — probe smaller scale or other tip.",
},
note="bundle_orbit:expand_invades_space",
)
st = state.copy()
st.positions = trial
st.last_moved = moved
meta = {
"mode": "bundle_orbit",
"kind": bundle.kind,
"bundle": bundle.key,
"tip_id": bundle.tip_id,
"pick": idx + 1,
"member_n": len(moved),
"delta": int(g1 - g0),
"crossings": int(g1),
"expand_scale": cand.get("expand_scale"),
"step": cand.get("step"),
"expand_mode": "minimize_probe",
}
st.meta["bundle_orbit"] = meta
return OpResult(
state=st,
moved=moved,
op="bundle_orbit",
params=meta,
note=(
f"bundle_orbit {bundle.kind} tip={bundle.tip_id} "
f"n={len(moved)} scale={cand.get('expand_scale')} Δ={meta['delta']}"
),
)
def rank_bundles_by_hotspots(
bundles: list[Bundle],
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
names: dict[str, str],
*,
top_n: int = 12,
) -> list[Bundle]:
"""Prefer bundles whose tip / members participate in crossings."""
if not bundles:
return []
from netx_api.mcp_remote.topology.layout_metrics import crossing_participation
_n, node_hit = crossing_participation(pos, links)[:2]
hot = {
str(r["fabric_node_id"]): int(r.get("crossing_hits") or 0)
for r in top_crossing_nodes(
pos, links, names=names, top_n=40, participation=node_hit
)
}
def score(b: Bundle) -> tuple[int, int, int]:
tip_h = int(node_hit.get(b.tip_id) or hot.get(b.tip_id) or 0)
mem_h = sum(int(node_hit.get(n) or 0) for n in b.member_ids)
return (tip_h + mem_h, len(b.member_ids), 1 if b.kind == "ring_chain" else 0)
ranked = sorted(bundles, key=score, reverse=True)
return ranked[: max(1, int(top_n))]
def bundle_orbit_until_progress(
state: LayoutState,
*,
frozen_ids: set[str] | None = None,
max_jump: float = 5000.0,
max_bundles: int = 10,
min_delta: int = 1,
cand_cap: int = 200,
angle_step: int = 20,
nn_floor: float = 28.0,
params: LayoutParams | None = None,
) -> OpResult:
"""Try hotspot-ranked bundles until one expand-gated move applies."""
del params
st = state.copy()
frozen = set(frozen_ids or ())
bundles = detect_bundles(st, frozen=frozen)
ranked = rank_bundles_by_hotspots(
bundles, st.positions, st.links, st.names, top_n=max_bundles
)
global0 = count_edge_crossings(st.positions, st.links)
tried: list[dict[str, Any]] = []
for b in ranked:
if any(n in frozen for n in b.member_ids):
continue
sweep = orbit_bundle(
st,
b,
max_jump=max_jump,
angle_step=angle_step,
cand_cap=cand_cap,
nn_floor=nn_floor,
min_delta=min_delta,
)
tried.append(
{
"bundle": b.key,
"kind": b.kind,
"improving_n": int(sweep.get("improving_n") or 0),
}
)
if int(sweep.get("improving_n") or 0) <= 0:
continue
op = apply_bundle_pick(st, b, sweep, pick=1)
if not op.moved:
tried[-1]["apply_error"] = (op.params or {}).get("error")
continue
end_g = count_edge_crossings(op.state.positions, op.state.links)
if end_g > global0:
# Should be unreachable due to apply gate; skip defensively.
continue
meta = {
**(op.params or {}),
"start_crossings": global0,
"end_crossings": end_g,
"tried": tried[:20],
"bundle_n": len(bundles),
}
op.state.meta["bundle_orbit"] = meta
return OpResult(
state=op.state,
moved=op.moved,
op="bundle_orbit",
params=meta,
note=op.note,
)
return OpResult(
state=st,
moved=set(),
op="bundle_orbit",
params={
"mode": "bundle_orbit",
"start_crossings": global0,
"end_crossings": global0,
"delta": 0,
"tried": tried[:20],
"bundle_n": len(bundles),
"stop_reason": "no_candidates",
"expand_mode": "minimize_probe",
},
note="bundle_orbit:no_candidates",
)

View file

@ -0,0 +1,72 @@
"""channel_metro: corridor skeleton → core beam pin → channel soft petals.
Composes proven atoms for giant star / core_bar canvases where ~90% of
nodes sit on deg≤2 corridors.
"""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid
from netx_api.mcp_remote.topology.layout_ops.ring_faces import eject_intruders, extract_ring_faces
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
def build_channel_metro_skeleton(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Compose: Tutte skeleton → pin_beam → soft_petals → ring eject → fix ov."""
params = params or LayoutParams()
channels = extract_channels(state)
rings = extract_ring_faces(state, max_len=5, max_cycles=40)
sk = build_skeleton(state, params)
st = sk.state
beam = pin_beam_rigid(st, params)
st = beam.state
petals = soft_petals_greedy(st, params)
st = petals.state
cores = {n for n, ly in st.layers.items() if ly == "core"}
aggs = {n for n, ly in st.layers.items() if ly == "agg"}
st.positions = eject_intruders(
dict(st.positions),
rings,
push=max(params.side * 0.25, 36.0),
protected=cores | aggs,
)
st = fix_overlaps_local(st, params).state
st = normalize_origin(st, params).state
cross = count_edge_crossings(st.positions, st.links)
st.meta = dict(st.meta or {})
st.meta["rings_mode"] = "channel_metro"
st.meta["channel_metro"] = {
"channels": len(channels),
"rings": len(rings),
"pin_beam": beam.note,
"soft_petals": petals.note,
"crossings": cross,
"skeleton": sk.note,
}
return OpResult(
state=st,
moved=set(st.positions.keys()),
op="build_channel_metro_skeleton",
params={
"channel_count": len(channels),
"ring_count": len(rings),
"crossings": cross,
"pin_beam": beam.params,
"soft_petals": petals.params,
},
note=f"channel_metro ch={len(channels)} {beam.note}; {petals.note} x={cross}",
)

View file

@ -0,0 +1,404 @@
"""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

View file

@ -0,0 +1,652 @@
"""Eject nodes that sit on / too close to non-incident edges.
Default: orthogonal eject. With ``preserve_axis`` + grid pitch/side, only accept
moves that keep incident H/V edges and snap to the metro grid — safe after
``ortho_metro`` (the old 60px nudge was breaking right angles).
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
EDGE_CLEARANCE_THR,
REC_CENTER_DX,
REC_CENTER_DY,
compute_edge_clearance,
count_edge_crossings,
node_footprint,
point_segment_dist,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_AXIS_TOL = 8.0
def clear_edge_params_from_overrides(params: dict[str, Any] | None) -> dict[str, Any]:
p = params or {}
out: dict[str, Any] = {
"top_n": int(p.get("top_n") or 12),
"thr": float(p.get("thr") or EDGE_CLEARANCE_THR),
"margin": float(p.get("margin") or 20.0),
"max_moves": int(p.get("max_moves") or 24),
"preserve_axis": bool(p.get("preserve_axis", False)),
"rounds": int(p.get("rounds") or 1),
}
if p.get("pitch") is not None:
out["pitch"] = float(p["pitch"])
if p.get("side") is not None:
out["side"] = float(p["side"])
portals = p.get("portal_ids") or p.get("portals") or []
if isinstance(portals, str):
portals = [portals]
frozen = set(str(x) for x in (p.get("frozen_ids") or []) if str(x).strip())
frozen |= {str(x) for x in portals if str(x).strip()}
out["frozen_ids"] = frozen
out["max_eject_degree"] = int(p.get("max_eject_degree") or 5)
return out
def _axis_ok(a: tuple[float, float], b: tuple[float, float], tol: float = _AXIS_TOL) -> bool:
return abs(a[0] - b[0]) <= tol or abs(a[1] - b[1]) <= tol
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) > 120 and abs(by - y) > 100:
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 _incident_axis_score(
nid: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
) -> int:
s = 0
for nb in adj.get(nid) or ():
if nb in pos and _axis_ok(pos[nid], pos[nb]):
s += 1
return s
def _global_axis_score(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
) -> int:
return sum(
1
for a, b in links
if a in pos and b in pos and _axis_ok(pos[a], pos[b])
)
def _on_open_segment(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
thr: float,
) -> bool:
d, t = point_segment_dist(p, a, b)
return d < thr and 0.05 < t < 0.95
def _candidates_orthogonal(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
target: float,
) -> list[tuple[float, float]]:
"""Axis-aligned eject candidates at distance ``target`` from segment AB."""
ax, ay = a
bx, by = b
px, py = p
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
cands: list[tuple[float, float]] = []
if L < 1e-9:
cands.extend(
[
(px + target, py),
(px - target, py),
(px, py + target),
(px, py - target),
]
)
return cands
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (L * L)))
fx, fy = ax + t * dx, ay + t * dy
cands.extend(
[
(fx, fy + target),
(fx, fy - target),
(fx + target, fy),
(fx - target, fy),
]
)
ux, uy = -dy / L, dx / L
cands.append((fx + ux * target, fy + uy * target))
cands.append((fx - ux * target, fy - uy * target))
if abs(dx) >= abs(dy):
cands.insert(0, (px, fy + target if py >= fy else fy - target))
cands.insert(1, (px, fy - target if py >= fy else fy + target))
else:
cands.insert(0, (fx + target if px >= fx else fx - target, py))
cands.insert(1, (fx - target if px >= fx else fx + target, py))
seen: set[tuple[float, float]] = set()
out: list[tuple[float, float]] = []
for xy in cands:
key = (round(xy[0], 2), round(xy[1], 2))
if key in seen:
continue
seen.add(key)
out.append((float(xy[0]), float(xy[1])))
return out
def _candidates_grid_ortho(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
pitch: float,
side: float,
max_steps: int = 8,
neighbor_xy: list[tuple[float, float]] | None = None,
) -> list[tuple[float, float]]:
"""Eject perpendicular to H/V trunk by whole grid steps (preserve metro)."""
ax, ay = a
bx, by = b
px, py = p
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
cands: list[tuple[float, float]] = []
# Prefer landing that still shares H/V with a neighbor (keep incident ortho).
for nxy in neighbor_xy or ():
nx, ny = nxy
if horiz:
for sign in (1, -1):
cands.append((nx, py + sign * side)) # V to neighbor, off row
cands.append((nx, py + sign * 2 * side))
elif vert:
for sign in (1, -1):
cands.append((px + sign * pitch, ny)) # H to neighbor, off col
cands.append((px + sign * 2 * pitch, ny))
if horiz:
for s in range(1, max_steps + 1):
for sign in (1, -1):
cands.append((px, py + sign * s * side))
cands.append((px + sign * s * pitch, py + sign * side))
cands.append((px + sign * s * pitch, py - sign * side))
elif vert:
for s in range(1, max_steps + 1):
for sign in (1, -1):
cands.append((px + sign * s * pitch, py))
cands.append((px + sign * pitch, py + sign * s * side))
cands.append((px - sign * pitch, py + sign * s * side))
else:
return _candidates_orthogonal(p, a, b, target=max(side, pitch) * 0.5)
if horiz:
cands = [(px, py + side), (px, py - side)] + cands
elif vert:
cands = [(px + pitch, py), (px - pitch, py)] + cands
seen: set[tuple[float, float]] = set()
out: list[tuple[float, float]] = []
for xy in cands:
key = (round(xy[0], 1), round(xy[1], 1))
if key in seen:
continue
seen.add(key)
out.append(xy)
return out
def clear_edge_hits(
state: LayoutState,
params: LayoutParams,
*,
top_n: int = 12,
thr: float = EDGE_CLEARANCE_THR,
margin: float = 20.0,
max_moves: int = 24,
preserve_axis: bool = False,
pitch: float | None = None,
side: float | None = None,
rounds: int = 1,
frozen_ids: set[str] | None = None,
max_eject_degree: int = 5,
) -> OpResult:
"""Move nodes off non-incident edges; gate on crossings + overlaps.
``preserve_axis=True``: snap to pitch/side grid, keep incident H/V count,
multi-round until no progress — use after ``ortho_metro``.
``frozen_ids``: never eject these (eye portals).
``max_eject_degree``: skip hubs at/above this degree (default 5).
"""
del params
st = state.copy()
pos = dict(st.positions)
names = st.names
links = list(st.links)
adj = st.adj
frozen = {str(x) for x in (frozen_ids or ()) if str(x).strip()}
max_eject_degree = max(2, int(max_eject_degree))
pitch_f = float(pitch if pitch is not None else REC_CENTER_DX)
side_f = float(side if side is not None else REC_CENTER_DY)
rounds_n = max(1, int(rounds))
if preserve_axis:
rounds_n = max(rounds_n, 6)
top_n = max(top_n, 40)
max_moves = max(max_moves, 80)
clr0 = compute_edge_clearance(pos, links, names=names, thr=thr, top_n=max(top_n, 5))
hits_before = int(clr0.get("edge_clearance_hits") or 0)
if clr0.get("edge_clearance_skipped"):
return OpResult(
state=st,
moved=set(),
op="clear_edge_hits",
note="clear_edge_hits:skipped_large",
params={"hits_before": hits_before, "skipped": True},
)
if hits_before <= 0:
return OpResult(
state=st,
moved=set(),
op="clear_edge_hits",
note="clear_edge_hits:noop",
params={"hits_before": 0, "hits_after": 0, "moved_n": 0},
)
moved: set[str] = set()
accepted: list[dict[str, Any]] = []
target = float(thr) + float(margin)
x0 = count_edge_crossings(pos, links)
ax0 = _global_axis_score(pos, links)
# Never allow crossing rise — even with preserve_axis (metro snap).
x_slack = 0
deg = {n: len(adj.get(n) or ()) for n in pos}
def _move_budget(nid: str) -> float:
d = deg.get(nid, 0)
if d >= 5:
return max(pitch_f, side_f) * 1.5
if d >= 3:
return max(pitch_f, side_f) * 3.0
return max(pitch_f, side_f) * 8.0
def _within_budget(nid: str, xy: tuple[float, float], origin: tuple[float, float]) -> bool:
return math.hypot(xy[0] - origin[0], xy[1] - origin[1]) <= _move_budget(nid) + 1e-6
for _rnd in range(rounds_n):
full = compute_edge_clearance(pos, links, names=names, thr=thr, top_n=top_n)
ordered = list(full.get("hit_nodes") or full.get("top_edge_hits") or [])
if not ordered:
break
# Prefer ejecting low-degree obstacles first (keep core hubs stable).
ordered.sort(
key=lambda h: (
float(h.get("dist") or 0.0),
deg.get(str(h.get("fabric_node_id")), 99),
str(h.get("fabric_node_id")),
)
)
progress = False
moves_this = 0
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
if nid not in pos:
continue
if nid in frozen:
continue
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if a not in pos or b not in pos:
continue
p0 = pos[nid]
if not _on_open_segment(p0, pos[a], pos[b], thr=thr) and float(h.get("dist") or 99) >= thr:
continue
# High-degree hubs on a chord: skip unless caller raises cap.
if deg.get(nid, 0) >= int(max_eject_degree):
continue
if preserve_axis:
nbr_xy = [
pos[nb]
for nb in (adj.get(nid) or ())
if nb in pos and nb not in (a, b)
]
cands = _candidates_grid_ortho(
p0,
pos[a],
pos[b],
pitch=pitch_f,
side=side_f,
neighbor_xy=nbr_xy,
)
else:
cands = _candidates_orthogonal(p0, pos[a], pos[b], target=target)
inc0 = _incident_axis_score(nid, pos, adj)
for xy in cands:
if not _within_budget(nid, xy, p0):
continue
trial = dict(pos)
trial[nid] = xy
if _overlaps_any(nid, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack:
continue
d1, t1 = point_segment_dist(xy, pos[a], pos[b])
if 0.05 < t1 < 0.95 and d1 < thr:
continue
# Must leave ALL non-incident open segments (not just a-b).
still_hit = False
for ea, eb in links:
if nid in (ea, eb) or ea not in trial or eb not in trial:
continue
if _on_open_segment(xy, trial[ea], trial[eb], thr=thr):
still_hit = True
break
if still_hit:
continue
if preserve_axis:
inc1 = _incident_axis_score(nid, trial, adj)
if inc1 < inc0:
continue
ax1 = _global_axis_score(trial, links)
if ax1 < ax0 - 2: # allow small axis trade for clearance
continue
pos[nid] = xy
moved.add(nid)
moves_this += 1
progress = True
x0 = x1
if preserve_axis:
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": nid,
"from": [round(p0[0], 1), round(p0[1], 1)],
"to": [round(xy[0], 1), round(xy[1], 1)],
"edge": [a, b],
"dist_before": round(float(h["dist"]), 2),
"dist_after": round(d1, 2),
}
)
break
if not progress and preserve_axis:
# Phase 2: translate the whole H/V trunk off the obstacle row/col
# (keeps the edge axis-aligned; frees every node that sat on it).
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
ax, ay = pos[a]
bx, by = pos[b]
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
if not horiz and not vert:
continue
# Prefer moving the lower-degree endpoint pair as a rigid H/V bar
max_trunk_steps = 2 if max(deg.get(a, 0), deg.get(b, 0)) >= 4 else 4
for s in range(1, max_trunk_steps + 1):
deltas = (
[(0.0, s * side_f), (0.0, -s * side_f)]
if horiz
else [(s * pitch_f, 0.0), (-s * pitch_f, 0.0)]
)
for dx, dy in deltas:
trial = dict(pos)
trial[a] = (ax + dx, ay + dy)
trial[b] = (bx + dx, by + dy)
if _overlaps_any(a, trial, names) or _overlaps_any(b, trial, names):
continue
if _global_axis_score(trial, links) < ax0 - 2:
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack:
continue
d1, t1 = point_segment_dist(pos[nid], trial[a], trial[b])
# obstacle stays put; edge moved away
if 0.05 < t1 < 0.95 and d1 < thr:
continue
# also: no OTHER node should sit on the moved trunk
blocked = False
for n2, p2 in trial.items():
if n2 in (a, b):
continue
dd, tt = point_segment_dist(p2, trial[a], trial[b])
if dd < thr and 0.05 < tt < 0.95:
blocked = True
break
if blocked:
continue
pos[a], pos[b] = trial[a], trial[b]
moved.add(a)
moved.add(b)
moves_this += 2
progress = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": a,
"with": b,
"from": [round(ax, 1), round(ay, 1)],
"to": [round(pos[a][0], 1), round(pos[a][1], 1)],
"edge": [a, b],
"mode": "translate_trunk",
"cleared": nid,
}
)
break
if progress:
break
if progress:
break
if not progress and preserve_axis:
# Phase 3: break the occluding H/V chord (prefer low-deg endpoint).
# Prefer this over ejecting hubs — layout sense > pure clearance force.
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
ax, ay = pos[a]
bx, by = pos[b]
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
if not horiz and not vert:
continue
ends = sorted([a, b], key=lambda n: (deg.get(n, 0), n))
cleared_local = False
for end in ends:
ox, oy = pos[end]
# Hubs: only 1 grid step off the shared track (keep layout sense).
max_step = 1 if deg.get(end, 0) >= 5 else (2 if deg.get(end, 0) >= 3 else 4)
for step in range(1, max_step + 1):
step_deltas = (
[(0.0, step * side_f), (0.0, -step * side_f)]
if horiz
else [(step * pitch_f, 0.0), (-step * pitch_f, 0.0)]
)
for dx, dy in step_deltas:
trial = dict(pos)
trial[end] = (ox + dx, oy + dy)
if _overlaps_any(end, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack + 6:
continue
if _on_open_segment(pos[nid], trial[a], trial[b], thr=thr):
continue
blocked = False
for n2, p2 in trial.items():
if n2 in (a, b):
continue
if _on_open_segment(p2, trial[a], trial[b], thr=thr):
blocked = True
break
if blocked:
continue
pos[end] = trial[end]
moved.add(end)
moves_this += 1
progress = True
cleared_local = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": end,
"from": [round(ox, 1), round(oy, 1)],
"to": [
round(pos[end][0], 1),
round(pos[end][1], 1),
],
"edge": [a, b],
"mode": "break_chord",
"cleared": nid,
}
)
break
if cleared_local:
break
if cleared_local:
break
if progress:
break
if not progress and preserve_axis:
# Phase 4: local force-eject low-deg obstacles only.
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if deg.get(nid, 0) >= 4:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
p0 = pos[nid]
nbr_xy = [pos[nb] for nb in (adj.get(nid) or ()) if nb in pos]
cands = _candidates_grid_ortho(
p0,
pos[a],
pos[b],
pitch=pitch_f,
side=side_f,
neighbor_xy=nbr_xy,
max_steps=6,
)
for xy in cands:
if not _within_budget(nid, xy, p0):
continue
trial = dict(pos)
trial[nid] = xy
if _overlaps_any(nid, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack + 4:
continue
if _on_open_segment(xy, trial[a], trial[b], thr=thr):
continue
still_hit = False
for ea, eb in links:
if nid in (ea, eb) or ea not in trial or eb not in trial:
continue
if _on_open_segment(xy, trial[ea], trial[eb], thr=thr):
still_hit = True
break
if still_hit:
continue
pos[nid] = xy
moved.add(nid)
moves_this += 1
progress = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": nid,
"from": [round(p0[0], 1), round(p0[1], 1)],
"to": [round(xy[0], 1), round(xy[1], 1)],
"edge": [a, b],
"mode": "force_eject",
"dist_before": round(float(h["dist"]), 2),
}
)
break
if progress:
break
if not progress:
break
st.positions = pos
clr1 = compute_edge_clearance(pos, links, names=names, thr=thr)
hits_after = int(clr1.get("edge_clearance_hits") or 0)
st.meta = dict(st.meta or {})
st.meta["clear_edge_hits"] = {
"hits_before": hits_before,
"hits_after": hits_after,
"moved_n": len(moved),
"accepted": accepted[:40],
"thr": thr,
"margin": margin,
"preserve_axis": preserve_axis,
}
return OpResult(
state=st,
moved=moved,
op="clear_edge_hits",
note=f"clear_edge_hits {hits_before}->{hits_after} moved={len(moved)}"
+ (" axis" if preserve_axis else ""),
params={
"hits_before": hits_before,
"hits_after": hits_after,
"moved_n": len(moved),
"accepted": accepted[:40],
"thr": thr,
"margin": margin,
"top_n": top_n,
"preserve_axis": preserve_axis,
},
)

View file

@ -0,0 +1,233 @@
"""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,
}

View file

@ -0,0 +1,628 @@
"""Incremental compose: attach staging blocks one-by-one with orbit search.
Unlike spring strip-pack ``compose_views``, this grows the world in Prim portal
order. After each block is rigidly aligned onto shared portals, exclusive nodes
are swept on concentric circles / angle grids about the portal pivot (same
spirit as ``orbit_sweep``) and the candidate with fewest partial crossings is
kept.
Degrees of freedom by glue:
- 0 shared: place near hull, then orbit whole block about nearest bridge tip
- 1 shared: rotate+scale-radius of exclusive members about the portal
- 2+ shared: chord lock + flip bank + small bulge rotations about chord mid
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
segments_properly_intersect,
)
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
ComposeBlock,
_membership_counts,
_portal_grow_order,
_rank_shared_pivots,
_reflect_about_axis,
_rigid_align_to_world,
compose_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_DEFAULT_ANGLE_STEP = 30
_DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95)
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 not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def crossings_touching(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
focus: set[str],
) -> int:
"""Count crossings where at least one endpoint is in ``focus`` (partial QA)."""
if not focus or not links:
return 0
focus_segs: list[
tuple[tuple[float, float], tuple[float, float], tuple[float, float, float, float]]
] = []
other_segs: list[
tuple[tuple[float, float], tuple[float, float], tuple[float, float, float, float]]
] = []
for u, v in links:
if u not in pos or v not in pos:
continue
p, q = pos[u], pos[v]
bb = _seg_bbox(p, q)
if u in focus or v in focus:
focus_segs.append((p, q, bb))
else:
other_segs.append((p, q, bb))
n = 0
# focus vs other
for p1, p2, b1 in focus_segs:
for p3, p4, b2 in other_segs:
if not _bbox_overlap(b1, b2):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
# focus vs focus (exclusive internal)
for i in range(len(focus_segs)):
p1, p2, b1 = focus_segs[i]
for j in range(i + 1, len(focus_segs)):
p3, p4, b2 = focus_segs[j]
if not _bbox_overlap(b1, b2):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
return n
def _bridge_len(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> float:
total = 0.0
for a, b in links:
if a in new_ids and b in old_ids:
if a in pos and b in pos:
total += math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
elif b in new_ids and a in old_ids:
if a in pos and b in pos:
total += math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
return total
def _apply_polar(
base: dict[str, tuple[float, float]],
members: list[str],
pivot: tuple[float, float],
*,
angle: float,
radius_scale: float,
) -> dict[str, tuple[float, float]]:
"""Rotate members about pivot and scale their distance from pivot."""
cos_a, sin_a = math.cos(angle), math.sin(angle)
cx, cy = pivot
out = dict(base)
for n in members:
if n not in out:
continue
x, y = out[n]
dx, dy = x - cx, y - cy
rx = (dx * cos_a - dy * sin_a) * radius_scale
ry = (dx * sin_a + dy * cos_a) * radius_scale
out[n] = (cx + rx, cy + ry)
return out
def _footprint_hits(
pos: dict[str, tuple[float, float]],
new_ids: set[str],
old_ids: set[str],
*,
min_dist: float = 90.0,
) -> int:
"""Cheap exclusive-vs-old center collisions (not full AABB)."""
hits = 0
md2 = min_dist * min_dist
old_pts = [(pos[n][0], pos[n][1]) for n in old_ids if n in pos]
for n in new_ids:
if n not in pos:
continue
x, y = pos[n]
for ox, oy in old_pts:
dx, dy = x - ox, y - oy
if dx * dx + dy * dy < md2:
hits += 1
break
return hits
def _score_attach(
trial: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> tuple[int, int, float]:
x = crossings_touching(trial, links, new_ids)
ov = _footprint_hits(trial, new_ids, old_ids)
br = _bridge_len(trial, links, new_ids, old_ids)
return (x, ov, br)
def _orbit_candidates_one_portal(
world: dict[str, tuple[float, float]],
aligned: dict[str, tuple[float, float]],
portal: str,
exclusive: list[str],
*,
angle_step: int,
radii: tuple[float, ...],
) -> list[dict[str, tuple[float, float]]]:
if portal not in world:
return [aligned]
pivot = world[portal]
base = dict(world)
for nid, xy in aligned.items():
if nid == portal:
continue
base[nid] = xy
# Keep portal frozen.
base[portal] = world[portal]
cands = [dict(base)]
step = max(10, min(90, int(angle_step)))
for deg in range(0, 360, step):
if deg == 0:
angles = [0.0]
else:
angles = [math.radians(deg)]
for ang in angles:
for rs in radii:
if deg == 0 and abs(rs - 1.0) < 1e-9:
continue
trial = _apply_polar(base, exclusive, pivot, angle=ang, radius_scale=rs)
trial[portal] = world[portal]
cands.append(trial)
return cands
def _orbit_candidates_two_portal(
world: dict[str, tuple[float, float]],
aligned: dict[str, tuple[float, float]],
portals: list[str],
exclusive: list[str],
local: dict[str, tuple[float, float]],
*,
angle_step: int,
) -> list[dict[str, tuple[float, float]]]:
"""Chord-locked: base + flip + small bulge twists about chord midpoint."""
p0, p1 = portals[0], portals[1]
if p0 not in world or p1 not in world:
return [aligned]
base = dict(world)
for nid, xy in aligned.items():
if nid in (p0, p1):
continue
base[nid] = xy
base[p0] = world[p0]
base[p1] = world[p1]
cands = [dict(base)]
flipped = _reflect_about_axis(base, exclusive + [p0, p1], world[p0], world[p1])
flipped[p0] = world[p0]
flipped[p1] = world[p1]
cands.append(flipped)
# Small rotations of exclusive bulge about chord midpoint (keep portals).
mx = 0.5 * (world[p0][0] + world[p1][0])
my = 0.5 * (world[p0][1] + world[p1][1])
step = max(15, min(45, int(angle_step)))
for src in (base, flipped):
for deg in (-step, step, 2 * step, -2 * step):
trial = _apply_polar(
src, exclusive, (mx, my), angle=math.radians(deg), radius_scale=1.0
)
trial[p0] = world[p0]
trial[p1] = world[p1]
cands.append(trial)
return cands
def _orbit_candidates_orphan(
world: dict[str, tuple[float, float]],
local: dict[str, tuple[float, float]],
*,
pad: float,
angle_step: int,
radii: tuple[float, ...],
links: list[tuple[str, str]],
) -> list[dict[str, tuple[float, float]]]:
"""No shared portal: park outside bbox then orbit about nearest tip."""
if not world:
# Seed: place local as-is near origin.
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
placed = {n: (x - min_x + 40.0, y - min_y + 40.0) for n, (x, y) in local.items()}
return [placed]
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
min_x, max_x = min(wxs), max(wxs)
min_y, max_y = min(wys), max(wys)
# Default park below bbox.
lxs = [p[0] for p in local.values()]
lys = [p[1] for p in local.values()]
lmin_x, lmin_y = min(lxs), min(lys)
ox, oy = min_x, max_y + float(pad)
placed0 = {
n: (ox + (x - lmin_x), oy + (y - lmin_y)) for n, (x, y) in local.items()
}
# Bridge tip: fabric edge from local ids into world.
local_ids = set(local)
tip = None
for a, b in links:
if a in local_ids and b in world:
tip = world[b]
break
if b in local_ids and a in world:
tip = world[a]
break
if tip is None:
tip = (0.5 * (min_x + max_x), max_y)
members = list(local.keys())
# Always tip-orbit sweep + score (no sticky/default park index).
cx0 = sum(placed0[n][0] for n in members) / len(members)
cy0 = sum(placed0[n][1] for n in members) / len(members)
step = max(15, min(60, int(angle_step)))
base_r = max(200.0, float(pad))
cands: list[dict[str, tuple[float, float]]] = []
for deg in range(0, 360, step):
ang = math.radians(deg)
for rs in radii:
r = base_r * rs
tx = tip[0] + r * math.cos(ang)
ty = tip[1] + r * math.sin(ang)
dx, dy = tx - cx0, ty - cy0
trial = dict(world)
for n in members:
x, y = placed0[n]
trial[n] = (x + dx, y + dy)
cands.append(trial)
return cands or [dict(world) | placed0]
def _pick_best(
cands: list[dict[str, tuple[float, float]]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> tuple[dict[str, tuple[float, float]], tuple[int, int, float], int]:
"""Rescore every candidate; return the best (no sticky pick)."""
if not cands:
return {}, (0, 0, 0.0), -1
best = cands[0]
best_sc = _score_attach(best, links, new_ids, old_ids)
best_i = 0
for i, c in enumerate(cands[1:], start=1):
sc = _score_attach(c, links, new_ids, old_ids)
if sc < best_sc:
best, best_sc, best_i = c, sc, i
return best, best_sc, best_i
def _thin_candidates(
cands: list[dict[str, tuple[float, float]]],
cand_cap: int,
) -> list[dict[str, tuple[float, float]]]:
"""Even subsample — do not privilege index 0."""
if len(cands) <= cand_cap:
return cands
step = max(1, (len(cands) + cand_cap - 1) // cand_cap)
return cands[::step][:cand_cap]
def orbit_pack_blocks(
blocks: list[ComposeBlock],
*,
pad: float = 500.0,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] = _DEFAULT_RADII,
cand_cap: int = 80,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Grow world block-by-block with orbit attach search."""
if not blocks:
return {}, {"slots": 0, "nodes": 0, "mode": "compose_orbit"}
valid = [b for b in blocks if b.positions]
if not valid:
return {}, {"slots": 0, "nodes": 0, "mode": "compose_orbit"}
slot_meta: dict[str, dict[str, float]] = {}
for b in valid:
xs = [p[0] for p in b.positions.values()]
ys = [p[1] for p in b.positions.values()]
w = max(max(xs) - min(xs), 1.0) + float(pad)
h = max(max(ys) - min(ys), 1.0) + float(pad)
slot_meta[b.key] = {"w": w, "h": h, "area": w * h, "n": float(len(b.positions))}
membership = _membership_counts(valid)
link_list = list(links or [])
key_order = _portal_grow_order(
valid,
slot_meta,
membership,
links=link_list if fabric_bridges else None,
fabric_bridges=fabric_bridges,
)
by_key = {b.key: b for b in valid}
world: dict[str, tuple[float, float]] = {}
owner: dict[str, str] = {}
group_nodes: dict[str, list[str]] = {k: [] for k in key_order}
group_pivots: dict[str, list[str]] = {k: [] for k in key_order}
attach_trace: list[dict[str, Any]] = []
merged_via: dict[str, str] = {}
for key in key_order:
block = by_key[key]
local = dict(block.positions)
shared_raw = [nid for nid in local if nid in world]
shared = _rank_shared_pivots(shared_raw, membership)
old_ids = set(world)
exclusive = [n for n in local if n not in set(shared)]
if not world:
# Seed
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
for nid, (x, y) in local.items():
world[nid] = (x - min_x + 40.0, y - min_y + 40.0)
owner[nid] = key
group_nodes[key].append(nid)
group_pivots[key] = list(shared[:2]) if shared else []
merged_via[key] = "seed"
attach_trace.append(
{
"key": key,
"via": "seed",
"shared_n": len(shared),
"cands": 1,
"pick": 0,
"score": [0, 0, 0.0],
}
)
continue
if shared:
prefer = None
if world:
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
prefer = (0.5 * (min(wxs) + max(wxs)), 0.5 * (min(wys) + max(wys)))
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=link_list,
)
if len(shared) >= 2:
cands = _orbit_candidates_two_portal(
world,
aligned,
shared[:2],
exclusive,
local,
angle_step=angle_step,
)
via = "orbit_dual"
else:
cands = _orbit_candidates_one_portal(
world,
aligned,
shared[0],
exclusive,
angle_step=angle_step,
radii=radii,
)
via = "orbit_portal"
portal_pivots = [n for n in shared if membership.get(n, 1) >= 2][:8]
group_pivots[key] = portal_pivots or list(shared[:2])
else:
cands = _orbit_candidates_orphan(
world,
local,
pad=pad,
angle_step=angle_step,
radii=radii,
links=link_list,
)
via = "orbit_orphan"
group_pivots[key] = []
cands = _thin_candidates(cands, cand_cap)
new_ids = set(exclusive) if exclusive else set(local) - old_ids
best, best_sc, best_i = _pick_best(cands, link_list, new_ids, old_ids)
for nid, xy in best.items():
if nid not in world:
world[nid] = xy
owner[nid] = key
# portals already in world keep first-owner coords
group_nodes[key].append(nid)
# Ensure shared listed
for nid in shared:
if nid not in group_nodes[key]:
group_nodes[key].append(nid)
merged_via[key] = via
attach_trace.append(
{
"key": key,
"via": via,
"shared_n": len(shared),
"shared": shared[:4],
"cands": len(cands),
"best_i": best_i,
"score": [best_sc[0], best_sc[1], round(best_sc[2], 1)],
"exclusive_n": len(exclusive),
}
)
rigid_groups = [
{
"key": k,
"node_ids": sorted(set(group_nodes[k])),
"pivots": sorted(set(group_pivots.get(k) or [])),
"via": merged_via.get(k),
"soft": True,
}
for k in key_order
if group_nodes.get(k)
]
mass_groups = [
{
**g,
"cores": list(g.get("pivots") or []),
"soft": True,
}
for g in rigid_groups
]
# Final global crossing for meta (cheap enough once).
final_x = count_edge_crossings(world, link_list) if link_list else 0
meta: dict[str, Any] = {
"slots": len(key_order),
"nodes": len(world),
"pad": float(pad),
"merge_shared": True,
"mode": "compose_orbit",
"order_mode": "portal_grow",
"pack_mode": "orbit_attach",
"order": key_order,
"angle_step": int(angle_step),
"radii": list(radii),
"attach_trace": attach_trace[:80],
"rigid_groups": rigid_groups,
"mass_groups": mass_groups,
"soft": True,
"merged_via": merged_via,
"final_crossings": final_x,
"tip": (
"compose_orbit: Prim portal order + portal align seed; prefer "
"mass_merge (core/ring/chain attract) over exclusive rigid polish."
),
}
return world, meta
def compose_orbit_into_state(
state: LayoutState,
blocks: list[ComposeBlock],
params: LayoutParams | None = None,
*,
pad: float = 500.0,
fabric_bridges: bool = True,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] | None = None,
cand_cap: int = 80,
) -> OpResult:
del params
merged, meta = orbit_pack_blocks(
blocks,
pad=pad,
links=list(state.links or []),
fabric_bridges=fabric_bridges,
angle_step=angle_step,
radii=radii or _DEFAULT_RADII,
cand_cap=cand_cap,
)
if not merged:
return OpResult(
state=state,
moved=set(),
op="compose_orbit",
params=meta,
note="no_blocks",
)
out = state.copy()
moved: set[str] = set()
for nid, xy in merged.items():
if nid not in out.positions:
out.positions[nid] = xy
moved.add(nid)
continue
if out.positions[nid] != xy:
out.positions[nid] = xy
moved.add(nid)
out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta
try:
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
mass = build_mass_field(out, groups=meta.get("mass_groups") or [])
out.meta["mass_field"] = mass
out.meta["compose_views"] = attach_mass_to_compose_meta(meta, mass)
except Exception:
pass
out.last_moved = moved
return OpResult(
state=out,
moved=moved,
op="compose_orbit",
params={**meta, "moved_n": len(moved)},
note=(
f"compose_orbit:slots={meta.get('slots')} nodes={meta.get('nodes')} "
f"x={meta.get('final_crossings')}"
),
)
def compose_orbit_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
base = compose_params_from_overrides(overrides)
o = overrides or {}
try:
base["angle_step"] = int(o.get("angle_step") or _DEFAULT_ANGLE_STEP)
except (TypeError, ValueError):
base["angle_step"] = _DEFAULT_ANGLE_STEP
raw_r = o.get("radii")
if isinstance(raw_r, (list, tuple)) and raw_r:
try:
base["radii"] = tuple(float(x) for x in raw_r)
except (TypeError, ValueError):
base["radii"] = _DEFAULT_RADII
else:
base["radii"] = _DEFAULT_RADII
try:
base["cand_cap"] = max(12, min(200, int(o.get("cand_cap") or 80)))
except (TypeError, ValueError):
base["cand_cap"] = 80
return base

View file

@ -0,0 +1,796 @@
"""Compose multiple view layouts via portal-aware packing + shared-node merge.
Dual-unit staging views intentionally overlap on portals. Pack order grows by
shared membership (Prim-style), then block origins come from a spring layout on
the portal-weighted block graph so units that share hubs sit near each other.
Orphan/misc blocks strip-pack below the glued component — cutting long
cross-unit edges that area-first square packing leaves behind.
When ``merge_shared`` (default), later blocks that share fabric_node_ids are
rigidly translated/rotated onto the first owner's world coordinates.
"""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
DEFAULT_SLOT_PAD = 600.0
@dataclass(frozen=True)
class ComposeBlock:
"""One staging canvas / dual-unit view to pack."""
key: str
positions: dict[str, tuple[float, float]] # fabric_node_id -> local xy
def _membership_counts(blocks: list[ComposeBlock]) -> dict[str, int]:
counts: dict[str, int] = {}
for b in blocks:
for nid in b.positions:
counts[nid] = counts.get(nid, 0) + 1
return counts
def _shared_weight(
ids_a: set[str],
ids_b: set[str],
membership: dict[str, int],
) -> float:
"""Higher when blocks share portal-like hubs (multi-membership nodes)."""
shared = ids_a & ids_b
if not shared:
return 0.0
# membership>=2 are glue points; boost hubs that appear in many units.
return float(sum(max(1, membership.get(n, 1)) ** 2 for n in shared))
def _fabric_bridge_hits(
ids_a: set[str],
ids_b: set[str],
links: list[tuple[str, str]],
) -> int:
"""Count fabric edges with one end in A and the other in B."""
if not links or not ids_a or not ids_b:
return 0
n = 0
for a, b in links:
if (a in ids_a and b in ids_b) or (a in ids_b and b in ids_a):
n += 1
return n
def _portal_grow_order(
blocks: list[ComposeBlock],
slot_meta: dict[str, dict[str, float]],
membership: dict[str, int],
*,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
) -> list[str]:
"""Order blocks by shared-portal growth (Prim), not raw area.
Seed = block with strongest multi-membership glue (ignore orphan-only
misc), then attach max shared-weight neighbor. Fabric bridges (optional)
also count as glue so long-edge peers attach before pure orphans.
"""
if not blocks:
return []
id_sets = {b.key: set(b.positions) for b in blocks}
remaining = {b.key for b in blocks}
link_list = list(links or []) if fabric_bridges else []
def glue_mass(key: str) -> float:
return float(
sum(
membership.get(n, 1) ** 2
for n in id_sets[key]
if membership.get(n, 1) >= 2
)
)
def fabric_to(ids: set[str], other: set[str]) -> float:
if not link_list:
return 0.0
return float(_fabric_bridge_hits(ids, other, link_list))
glued = [k for k in remaining if glue_mass(k) > 0]
if not glued and link_list:
# Prefer a block that participates in any inter-block fabric edge.
seeded = [
k
for k in remaining
if any(
_fabric_bridge_hits(id_sets[k], id_sets[o], link_list) > 0
for o in remaining
if o != k
)
]
glued = seeded
if glued:
seed = max(glued, key=lambda k: (glue_mass(k), slot_meta[k]["area"], k))
else:
seed = max(remaining, key=lambda k: (slot_meta[k]["area"], k))
order = [seed]
remaining.remove(seed)
placed_nodes = set(id_sets[seed])
while remaining:
best_key = None
best_score: tuple[float, float, float, float, str] | None = None
for k in remaining:
w = _shared_weight(id_sets[k], placed_nodes, membership)
fb = fabric_to(id_sets[k], placed_nodes)
rest_nodes: set[str] = set()
for o in remaining:
if o != k:
rest_nodes |= id_sets[o]
w_rest = (
_shared_weight(id_sets[k], rest_nodes, membership)
if w <= 0 and fb <= 0
else 0.0
)
fb_rest = (
fabric_to(id_sets[k], rest_nodes) if w <= 0 and fb <= 0 else 0.0
)
# Orphans (no portal/fabric glue): larger → later.
if w <= 0 and fb <= 0 and w_rest <= 0 and fb_rest <= 0:
area_term = -slot_meta[k]["area"]
else:
area_term = slot_meta[k]["area"]
score = (w + fb * 4.0, w_rest + fb_rest * 2.0, area_term, fb, k)
if best_score is None or score > best_score:
best_score = score
best_key = k
assert best_key is not None
order.append(best_key)
remaining.remove(best_key)
placed_nodes |= id_sets[best_key]
return order
def _rank_shared_pivots(
shared: list[str], membership: dict[str, int]
) -> list[str]:
"""Prefer multi-unit hubs as rigid-align pivots (not corridor alpha order)."""
return sorted(shared, key=lambda n: (-membership.get(n, 1), n))
def _block_graph(
blocks: list[ComposeBlock],
membership: dict[str, int],
*,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
) -> dict[str, dict[str, float]]:
"""Undirected weighted graph: portal glue + optional fabric-bridge boost.
``bridge_boost`` is MCP-tunable: each inter-block fabric edge adds this
much weight so long-spoke peers sit near each other in the spring pack.
"""
id_sets = {b.key: set(b.positions) for b in blocks}
keys = [b.key for b in blocks]
adj: dict[str, dict[str, float]] = {k: {} for k in keys}
boost = max(0.0, float(bridge_boost))
link_list = list(links or []) if fabric_bridges and boost > 0 else []
for i, ka in enumerate(keys):
for kb in keys[i + 1 :]:
w = _shared_weight(id_sets[ka], id_sets[kb], membership)
if link_list:
w += boost * float(
_fabric_bridge_hits(id_sets[ka], id_sets[kb], link_list)
)
if w <= 0:
continue
adj[ka][kb] = w
adj[kb][ka] = w
return adj
def _reflect_about_axis(
pos: dict[str, tuple[float, float]],
members: list[str],
p0: tuple[float, float],
p1: tuple[float, float],
) -> dict[str, tuple[float, float]]:
ax, ay = p0
bx, by = p1
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
if L2 < 1e-12:
return dict(pos)
out = dict(pos)
for n in members:
if n not in out:
continue
x, y = out[n]
t = ((x - ax) * dx + (y - ay) * dy) / L2
px, py = ax + t * dx, ay + t * dy
out[n] = (2 * px - x, 2 * py - y)
return out
def _external_bridge_cost(
placed: dict[str, tuple[float, float]],
world: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
) -> float:
"""Sum of squared lengths for edges that leave this block into ``world``."""
if not links or not new_ids:
return 0.0
cost = 0.0
for a, b in links:
a_new = a in new_ids
b_new = b in new_ids
if a_new == b_new:
continue
pa = placed.get(a) if a_new else world.get(a)
pb = placed.get(b) if b_new else world.get(b)
if pa is None or pb is None:
continue
dx = pa[0] - pb[0]
dy = pa[1] - pb[1]
cost += dx * dx + dy * dy
return cost
def _portal_centroid_origins(
key_order: list[str],
slot_meta: dict[str, dict[str, float]],
graph: dict[str, dict[str, float]],
*,
pad: float,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> dict[str, tuple[float, float]]:
"""Spring-embed block centroids on the portal/fabric graph; orphans strip below.
Origins are used for strip_pack seeds (and as a soft hint before merge).
``ideal_scale`` / ``spring_iters`` are MCP-tunable (smaller scale → denser).
"""
if not key_order:
return {}
glued = [k for k in key_order if graph.get(k)]
orphans = [k for k in key_order if k not in set(glued)]
origins: dict[str, tuple[float, float]] = {}
if not glued:
# Fall back to square strip for everything.
n = len(key_order)
cols = max(1, int(math.ceil(math.sqrt(n))))
col_w = [0.0] * cols
row_h: dict[int, float] = defaultdict(float)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
col_w[c] = max(col_w[c], slot_meta[key]["w"])
row_h[r] = max(row_h[r], slot_meta[key]["h"])
cox = [0.0]
for c in range(cols - 1):
cox.append(cox[-1] + col_w[c])
roy = {0: 0.0}
for r in range(1, (n // cols) + 2):
roy[r] = roy.get(r - 1, 0.0) + row_h.get(r - 1, 0.0)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
origins[key] = (cox[c], roy[r])
return origins
scale = max(0.25, min(float(ideal_scale), 1.2))
iters = max(20, min(int(spring_iters), 240))
# Ideal edge length ~ mean diagonal of the two slots.
def ideal(a: str, b: str) -> float:
da = math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
db = math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
return scale * (da + db) + float(pad)
# Init on a circle scaled by total size.
n_g = len(glued)
span = sum(math.hypot(slot_meta[k]["w"], slot_meta[k]["h"]) for k in glued) / max(
n_g, 1
)
radius = max(span * 0.7 * math.sqrt(n_g), 400.0)
pos: dict[str, list[float]] = {}
for i, key in enumerate(glued):
ang = 2.0 * math.pi * i / n_g
pos[key] = [radius * math.cos(ang), radius * math.sin(ang)]
# Lightweight spring / Fruchterman-style iterations (no numpy).
max_w = max((w for nbrs in graph.values() for w in nbrs.values()), default=1.0)
for _ in range(iters):
disp = {k: [0.0, 0.0] for k in glued}
# Attractive along portal / fabric edges.
for a in glued:
for b, w in graph.get(a, {}).items():
if b not in pos or a >= b:
continue
dx = pos[b][0] - pos[a][0]
dy = pos[b][1] - pos[a][1]
dist = math.hypot(dx, dy) or 1e-6
L = ideal(a, b)
# Stronger weight → stronger pull toward ideal length.
force = (dist - L) * (0.15 + 0.35 * (w / max_w))
ux, uy = dx / dist, dy / dist
disp[a][0] += ux * force
disp[a][1] += uy * force
disp[b][0] -= ux * force
disp[b][1] -= uy * force
# Mild repulsion so slots do not stack.
for i, a in enumerate(glued):
for b in glued[i + 1 :]:
dx = pos[b][0] - pos[a][0]
dy = pos[b][1] - pos[a][1]
dist = math.hypot(dx, dy) or 1e-6
min_d = 0.45 * (
math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
+ math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
)
if dist >= min_d:
continue
push = (min_d - dist) * 0.35
ux, uy = dx / dist, dy / dist
disp[a][0] -= ux * push
disp[a][1] -= uy * push
disp[b][0] += ux * push
disp[b][1] += uy * push
for k in glued:
pos[k][0] += max(-800.0, min(800.0, disp[k][0]))
pos[k][1] += max(-800.0, min(800.0, disp[k][1]))
# Convert centers → top-left origins (slot min corner).
for key in glued:
cx, cy = pos[key]
origins[key] = (
cx - 0.5 * slot_meta[key]["w"],
cy - 0.5 * slot_meta[key]["h"],
)
# Orphans: horizontal strip under the glued bbox.
if orphans:
xs = [origins[k][0] for k in glued]
ys = [origins[k][1] for k in glued]
ys2 = [origins[k][1] + slot_meta[k]["h"] for k in glued]
base_y = max(ys2) + float(pad)
base_x = min(xs)
x_cursor = base_x
for key in orphans:
origins[key] = (x_cursor, base_y)
x_cursor += slot_meta[key]["w"] + 0.25 * float(pad)
# Normalize so min corner is near origin.
min_x = min(o[0] for o in origins.values())
min_y = min(o[1] for o in origins.values())
return {k: (x - min_x + 40.0, y - min_y + 40.0) for k, (x, y) in origins.items()}
def _rigid_align_to_world(
local: dict[str, tuple[float, float]],
world: dict[str, tuple[float, float]],
shared: list[str],
*,
prefer_center: tuple[float, float] | None = None,
links: list[tuple[str, str]] | None = None,
) -> dict[str, tuple[float, float]]:
"""Translate (+ rotate about first shared) so shared nodes match world.
With two+ shared pivots, orientation follows the portal chord; also try the
flip across that chord and keep the side with fewer crossings on the
partial world (then shorter external bridges / closer to ``prefer_center``).
With a single pivot, rotate so the block centroid aims at ``prefer_center``.
"""
if not shared:
return dict(local)
p0 = shared[0]
lx0, ly0 = local[p0]
wx0, wy0 = world[p0]
angle = 0.0
if len(shared) >= 2:
p1 = shared[1]
ldx, ldy = local[p1][0] - lx0, local[p1][1] - ly0
wdx, wdy = world[p1][0] - wx0, world[p1][1] - wy0
if (ldx * ldx + ldy * ldy) > 1e-12 and (wdx * wdx + wdy * wdy) > 1e-12:
angle = math.atan2(wdy, wdx) - math.atan2(ldy, ldx)
elif prefer_center is not None and len(local) >= 2:
# Local centroid relative to portal.
cx = sum(p[0] for p in local.values()) / len(local)
cy = sum(p[1] for p in local.values()) / len(local)
ldx, ldy = cx - lx0, cy - ly0
tdx = prefer_center[0] - wx0
tdy = prefer_center[1] - wy0
if (ldx * ldx + ldy * ldy) > 1e-8 and (tdx * tdx + tdy * tdy) > 1e-8:
angle = math.atan2(tdy, tdx) - math.atan2(ldy, ldx)
cos_a, sin_a = math.cos(angle), math.sin(angle)
out: dict[str, tuple[float, float]] = {}
for nid, (lx, ly) in local.items():
dx, dy = lx - lx0, ly - ly0
rx = dx * cos_a - dy * sin_a
ry = dx * sin_a + dy * cos_a
out[nid] = (wx0 + rx, wy0 + ry)
if len(shared) < 2:
return out
# Dual-portal flip: same chord, opposite bank.
shared_set = set(shared)
new_ids = {n for n in local if n not in shared_set}
link_list = list(links or [])
def _score(cand: dict[str, tuple[float, float]]) -> tuple[int, float, float]:
trial = dict(world)
for nid, xy in cand.items():
if nid in shared_set:
continue
trial[nid] = xy
xcount = (
count_edge_crossings(trial, link_list) if link_list else 0
)
bridge = _external_bridge_cost(cand, world, link_list, new_ids)
if prefer_center is None:
aim = 0.0
else:
excl = [cand[n] for n in new_ids if n in cand]
if not excl:
aim = 0.0
else:
cx = sum(p[0] for p in excl) / len(excl)
cy = sum(p[1] for p in excl) / len(excl)
aim = (cx - prefer_center[0]) ** 2 + (cy - prefer_center[1]) ** 2
return (int(xcount), bridge, aim)
flipped = _reflect_about_axis(
out, list(local.keys()), world[shared[0]], world[shared[1]]
)
for p in shared:
if p in world:
flipped[p] = world[p]
if _score(flipped) < _score(out):
return flipped
return out
def strip_pack_blocks(
blocks: list[ComposeBlock],
*,
pad: float = DEFAULT_SLOT_PAD,
merge_shared: bool = True,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Pack blocks; return merged positions + meta.
With ``merge_shared`` (default): later blocks that share node ids with
already-placed nodes are rigidly aligned (portal merge). First owner wins
for shared coordinates. Blocks without shared anchors use strip-pack slots.
Pack order prefers portal connectivity growth so dual units that share
hubs stay glued; large orphan/misc blocks are placed last. Optional
``links`` + ``fabric_bridges`` add fabric-bridge weight to the spring and
guide dual-portal flip by partial-graph crossings (then bridge length).
MCP knobs: ``pad``, ``bridge_boost``, ``ideal_scale``, ``spring_iters``,
``fabric_bridges``.
"""
if not blocks:
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
slot_meta: dict[str, dict[str, float]] = {}
valid: list[ComposeBlock] = []
for b in blocks:
if not b.positions:
continue
xs = [p[0] for p in b.positions.values()]
ys = [p[1] for p in b.positions.values()]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
w = max(max_x - min_x, 1.0) + float(pad)
h = max(max_y - min_y, 1.0) + float(pad)
slot_meta[b.key] = {
"min_x": min_x,
"min_y": min_y,
"w": w,
"h": h,
"area": w * h,
"n": float(len(b.positions)),
}
valid.append(b)
if not valid:
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
membership = _membership_counts(valid)
link_list = list(links or [])
do_fabric = bool(fabric_bridges)
boost = max(0.0, float(bridge_boost))
if merge_shared:
key_order = _portal_grow_order(
valid,
slot_meta,
membership,
links=link_list,
fabric_bridges=do_fabric,
)
order_mode = "portal_grow"
graph = _block_graph(
valid,
membership,
links=link_list,
fabric_bridges=do_fabric,
bridge_boost=boost,
)
origins = _portal_centroid_origins(
key_order,
slot_meta,
graph,
pad=float(pad),
ideal_scale=float(ideal_scale),
spring_iters=int(spring_iters),
)
pack_mode = "portal_centroid"
cols = 0
else:
key_order = [
b.key
for b in sorted(valid, key=lambda b: (-slot_meta[b.key]["area"], b.key))
]
order_mode = "area"
pack_mode = "strip"
n = max(1, len(key_order))
cols = max(1, int(math.ceil(math.sqrt(n))))
col_widths: list[float] = [0.0] * cols
row_heights: dict[int, float] = defaultdict(float)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
meta = slot_meta[key]
col_widths[c] = max(col_widths[c], meta["w"])
row_heights[r] = max(row_heights[r], meta["h"])
col_origin = [0.0]
for c in range(cols - 1):
col_origin.append(col_origin[-1] + col_widths[c])
row_origin: dict[int, float] = {0: 0.0}
max_row = max((i // cols for i in range(n)), default=0)
for r in range(1, max_row + 2):
row_origin[r] = row_origin.get(r - 1, 0.0) + row_heights.get(r - 1, 0.0)
origins = {}
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
origins[key] = (col_origin[c], row_origin[r])
by_key = {b.key: b for b in valid}
out: dict[str, tuple[float, float]] = {}
owner: dict[str, str] = {}
merged_via: dict[str, str] = {}
align_meta: list[dict[str, Any]] = []
# Per-block members as placed (whole sub-region = rigid body).
group_nodes: dict[str, list[str]] = {k: [] for k in key_order}
group_pivots: dict[str, list[str]] = {k: [] for k in key_order}
for key in key_order:
block = by_key[key]
meta = slot_meta[key]
shared_raw = [nid for nid in block.positions if nid in out]
shared = _rank_shared_pivots(shared_raw, membership)
if merge_shared and shared:
ox, oy = origins[key]
prefer = (
ox + 0.5 * meta["w"],
oy + 0.5 * meta["h"],
)
aligned = _rigid_align_to_world(
block.positions,
out,
shared,
prefer_center=prefer,
links=link_list,
)
placed_new = 0
for nid, xy in aligned.items():
group_nodes[key].append(nid)
if nid in out:
continue
out[nid] = xy
owner[nid] = key
placed_new += 1
# Freeze pivots = high-membership shared (true portals), not every
# overlapping corridor id — soft polish needs corridors movable.
portal_pivots = [n for n in shared if membership.get(n, 1) >= 2][:8]
group_pivots[key] = portal_pivots or list(shared[:2])
merged_via[key] = "rigid_shared"
align_meta.append(
{
"key": key,
"shared": shared[:6],
"shared_n": len(shared),
"placed_new": placed_new,
"pivot_n": len(group_pivots[key]),
}
)
continue
ox, oy = origins[key]
for nid, (lx, ly) in block.positions.items():
group_nodes[key].append(nid)
if nid in out:
group_pivots[key].append(nid)
continue
out[nid] = (ox + (lx - meta["min_x"]), oy + (ly - meta["min_y"]))
owner[nid] = key
merged_via[key] = "strip_pack"
rigid_groups = [
{
"key": k,
"node_ids": sorted(set(group_nodes[k])),
"pivots": sorted(set(group_pivots.get(k) or [])),
"via": merged_via.get(k),
"soft": True,
}
for k in key_order
if group_nodes.get(k)
]
mass_groups = [
{**g, "cores": list(g.get("pivots") or []), "soft": True}
for g in rigid_groups
]
return out, {
"slots": len(key_order),
"nodes": len(out),
"pad": float(pad),
"merge_shared": bool(merge_shared),
"fabric_bridges": do_fabric if merge_shared else False,
"bridge_boost": boost if merge_shared else 0.0,
"ideal_scale": float(ideal_scale) if merge_shared else None,
"spring_iters": int(spring_iters) if merge_shared else None,
"order_mode": order_mode,
"pack_mode": pack_mode,
"cols": cols,
"order": key_order,
"origins": {k: [round(v[0], 2), round(v[1], 2)] for k, v in origins.items()},
"owner": {k: owner[k] for k in list(owner)[:40]},
"merged_via": merged_via,
"alignments": align_meta[:20],
"rigid_groups": rigid_groups,
"mass_groups": mass_groups,
"soft": True,
"tip": (
"Compose: portal+fabric spring seed; prefer mass_merge "
"(core/ring/chain attract) then polish_crossings."
),
"slot_meta": {
k: {
"w": round(v["w"], 1),
"h": round(v["h"], 1),
"area": round(v["area"], 1),
"n": int(v["n"]),
}
for k, v in slot_meta.items()
},
}
def blocks_from_position_maps(
maps: list[tuple[str, dict[str, tuple[float, float]]]],
) -> list[ComposeBlock]:
return [ComposeBlock(key=k, positions=dict(pos)) for k, pos in maps if pos]
def compose_into_state(
state: LayoutState,
blocks: list[ComposeBlock],
params: LayoutParams | None = None,
*,
pad: float = DEFAULT_SLOT_PAD,
merge_shared: bool = True,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> OpResult:
"""Write strip-packed / merge-aligned coords onto ``state``."""
del params
merged, meta = strip_pack_blocks(
blocks,
pad=pad,
merge_shared=merge_shared,
links=list(state.links or []),
fabric_bridges=fabric_bridges,
bridge_boost=bridge_boost,
ideal_scale=ideal_scale,
spring_iters=spring_iters,
)
if not merged:
return OpResult(
state=state,
moved=set(),
op="compose_views",
params=meta,
note="no_blocks",
)
out = state.copy()
moved: set[str] = set()
for nid, xy in merged.items():
if nid not in out.positions:
out.positions[nid] = xy
moved.add(nid)
continue
if out.positions[nid] != xy:
out.positions[nid] = xy
moved.add(nid)
out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta
try:
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
mass = build_mass_field(out, groups=meta.get("mass_groups") or [])
out.meta["mass_field"] = mass
out.meta["compose_views"] = attach_mass_to_compose_meta(meta, mass)
except Exception:
pass
out.last_moved = moved
return OpResult(
state=out,
moved=moved,
op="compose_views",
params={**meta, "moved_n": len(moved)},
note=(
f"compose_views:slots={meta.get('slots')} nodes={meta.get('nodes')} "
f"merge_shared={meta.get('merge_shared')} order={meta.get('order_mode')}"
),
)
def compose_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("pad") is not None:
try:
out["pad"] = float(overrides["pad"])
except (TypeError, ValueError):
pass
for flag in ("merge_shared", "fabric_bridges"):
if flag not in overrides:
continue
v = overrides[flag]
if isinstance(v, bool):
out[flag] = v
else:
out[flag] = str(v).strip().lower() in {"1", "true", "yes", "on"}
if overrides.get("bridge_boost") is not None:
try:
out["bridge_boost"] = max(0.0, float(overrides["bridge_boost"]))
except (TypeError, ValueError):
pass
if overrides.get("ideal_scale") is not None:
try:
out["ideal_scale"] = max(0.25, min(1.2, float(overrides["ideal_scale"])))
except (TypeError, ValueError):
pass
if overrides.get("spring_iters") is not None:
try:
out["spring_iters"] = max(20, min(240, int(overrides["spring_iters"])))
except (TypeError, ValueError):
pass
src = overrides.get("source_view_ids")
if isinstance(src, list):
out["source_view_ids"] = [str(x).strip() for x in src if str(x).strip()]
elif isinstance(src, str) and src.strip():
out["source_view_ids"] = [s.strip() for s in src.split(",") if s.strip()]
return out

View file

@ -0,0 +1,781 @@
"""Inward densify sweep: top-3 single-node pulls + corridor_cap scan.
Agent workflow: preview → pick rank 1..3 → apply; round=true auto-applies #1
when global crossings do not rise and stretch falls; phase=corridor / corridor=true
sweeps shrink_long_corridors params for util gain.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
crossings_involving_node,
)
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_diversify_top,
_eval_candidate,
_incident_stretch,
)
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
shrink_long_corridors,
)
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_PULL = 900.0
_MAX_PULL_CAP = 2200.0
_PULL_FRACS = (0.12, 0.22, 0.35, 0.5, 0.7)
_ANGLE_JITTERS = (-30.0, -15.0, 0.0, 15.0, 30.0)
_DEFAULT_CORRIDOR_CAPS = (1200.0, 1600.0, 2200.0, 3200.0)
_DEFAULT_PULLS = (0.35, 0.5, 0.65)
def _protect_is_off(protect_rigid: bool | str) -> bool:
return protect_rigid in (False, "false", "off", "none", "0")
def _resolve_frozen(
st: LayoutState,
protect_rigid: bool | str,
frozen_ids: set[str] | None,
) -> set[str]:
"""Portal/rigid freeze only when protect is on; off ignores portal_ids inject."""
if _protect_is_off(protect_rigid):
return set()
frozen: set[str] = set(frozen_ids or ())
if not frozen:
frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen
def _groups_from_state(state: LayoutState) -> list[dict[str, Any]]:
return groups_from_compose_meta((state.meta or {}).get("compose_views"))
def _membership_maps(
groups: list[dict[str, Any]],
valid: set[str],
) -> tuple[dict[str, list[str]], set[str], dict[str, tuple[float, float]]]:
"""Return node→group_keys, shared portals, group_key→exclusive centroid xy."""
counts: dict[str, int] = {}
g_members: dict[str, list[str]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
g_members[key] = members
for n in members:
counts[n] = counts.get(n, 0) + 1
shared = {n for n, c in counts.items() if c > 1}
# Need positions later — centroids filled by caller.
return (
{n: [k for k, ms in g_members.items() if n in ms] for n in counts},
shared,
{},
)
def _exclusive_centroids(
groups: list[dict[str, Any]],
pos: dict[str, tuple[float, float]],
shared: set[str],
) -> dict[str, tuple[float, float]]:
out: dict[str, tuple[float, float]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in pos]
exclusive = [n for n in members if n not in shared]
use = exclusive if len(exclusive) >= 1 else members
if not use:
continue
cx = sum(pos[n][0] for n in use) / len(use)
cy = sum(pos[n][1] for n in use) / len(use)
out[key] = (cx, cy)
return out
def _anchor_for_node(
nid: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
*,
node_groups: dict[str, list[str]],
centroids: dict[str, tuple[float, float]],
global_cx: float,
global_cy: float,
) -> tuple[float, float, str]:
nbs = [pos[v] for v in adj.get(nid, ()) if v in pos]
if nbs:
ax = sum(p[0] for p in nbs) / len(nbs)
ay = sum(p[1] for p in nbs) / len(nbs)
return ax, ay, "neighbors"
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
return cx, cy, "unit"
return global_cx, global_cy, "global"
def _inward_samples(
x0: float,
y0: float,
ax: float,
ay: float,
*,
max_pull: float,
) -> list[tuple[float, float, float, float]]:
"""Samples on ray toward anchor; never past the anchor; capped by max_pull."""
dx, dy = ax - x0, ay - y0
dist = math.hypot(dx, dy)
if dist < 8.0:
return []
ux, uy = dx / dist, dy / dist
px, py = -uy, ux
out: list[tuple[float, float, float, float]] = []
base_ang = math.degrees(math.atan2(uy, ux)) % 360.0
for t in _PULL_FRACS:
pull = min(dist * t, max_pull)
if pull < 12.0:
continue
# Stay short of the anchor (leave 4px slack).
pull = min(pull, dist - 4.0)
if pull < 12.0:
continue
for dang in _ANGLE_JITTERS:
if dang == 0.0:
nx = x0 + ux * pull
ny = y0 + uy * pull
ang = base_ang
else:
rad = math.radians(base_ang + dang)
c, s = math.cos(rad), math.sin(rad)
nx = x0 + c * pull
ny = y0 + s * pull
# Reject if farther from anchor than start.
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
ang = (base_ang + dang) % 360.0
r = math.hypot(nx - x0, ny - y0)
if r < 12.0 or r > max_pull + 1:
continue
out.append((nx, ny, r, ang))
# Mild perpendicular nudges at mid pull (still closer to anchor).
mid = min(dist * 0.35, max_pull)
if mid >= 20.0:
for sign in (-1.0, 1.0):
nx = x0 + ux * mid + px * sign * mid * 0.25
ny = y0 + uy * mid + py * sign * mid * 0.25
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
r = math.hypot(nx - x0, ny - y0)
if 12.0 <= r <= max_pull + 1:
ang = math.degrees(math.atan2(ny - y0, nx - x0)) % 360.0
out.append((nx, ny, r, ang))
return out
def _densify_score_key(c: dict[str, Any]) -> tuple:
# Crossings first; then shorter incident stretch; prefer larger inward pull.
return (
int(c["crossings"]["global"]),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
-float(c.get("r") or 0.0),
)
def densify_sweep_node(
state: LayoutState,
node_id: str,
*,
params: LayoutParams | None = None,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
cand_cap: int = 180,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
top_k: int = 3,
groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Inward pull candidates for one node; return diversified top-k.
Default ``protect_rigid=off`` so multi-round densify may move portals;
other layout actions keep portal freeze. Pass ``protect_rigid=portals``
to opt back into rigid protection.
"""
params = params or LayoutParams()
st = state
nid = str(node_id).strip()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
if nid not in pos:
return {"ok": False, "error": "node_not_on_view", "node_id": nid}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
if nid in frozen:
return {
"ok": False,
"error": "frozen",
"node_id": nid,
"hint": "portal/rigid frozen; protect_rigid=off or pick a corridor node",
}
pull = float(max_pull if max_pull is not None else _MAX_PULL)
pull = max(80.0, min(pull, _MAX_PULL_CAP))
nbs_cap = pull * 2.5
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
# With groups + protect on: shared portals stay frozen.
if groups and nid in shared and not _protect_is_off(protect_rigid):
return {
"ok": False,
"error": "shared_portal",
"node_id": nid,
"hint": "shared portals frozen; densify defaults protect_rigid=off",
}
centroids = _exclusive_centroids(groups, pos, shared)
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
ax, ay, anchor_kind = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
stretch0 = _incident_stretch(nid, pos, adj, target_nn)
samples = _inward_samples(x0, y0, ax, ay, max_pull=pull)
# Also try unit centroid if neighbor anchor was used.
if anchor_kind == "neighbors":
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
samples.extend(_inward_samples(x0, y0, cx, cy, max_pull=pull))
seen: set[tuple[int, int]] = set()
uniq: list[tuple[float, float, float, float]] = []
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
uniq.append((sx, sy, r, ang))
if len(uniq) >= cand_cap:
break
scored: list[dict[str, Any]] = []
for sx, sy, r, ang in uniq:
c = _eval_candidate(
nid,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=nn_floor,
nbs_cap=nbs_cap,
)
if c is None:
continue
# Densify: reject moves that raise global crossings.
if int(c["delta"]["global"]) > 0:
continue
scored.append(c)
scored.sort(key=_densify_score_key)
# Prefer stretch drop among non-rising-x candidates.
improving = [
c
for c in scored
if c["delta"]["global"] <= 0 and float(c.get("stretch") or 99) < stretch0 - 1e-4
]
pool = improving if improving else scored
top = _diversify_top(pool, k=max(1, int(top_k)), min_angle_sep=min_angle_sep)
return {
"ok": True,
"node_id": nid,
"name": names.get(nid, nid),
"x0": round(x0, 1),
"y0": round(y0, 1),
"degree": len(adj.get(nid, ())),
"anchor": {"x": round(ax, 1), "y": round(ay, 1), "kind": anchor_kind},
"stretch_before": round(stretch0, 3),
"crossings_before": {"global": int(global0), "incident": int(local0)},
"candidates": top,
"sampled": len(seen),
"improving_n": len(improving),
"max_pull": pull,
"nn_floor": nn_floor,
"hint": (
"prefer rank1 (inward, x not up, stretch down); pick 2/3 if label/util. "
"apply with params.pick=1|2|3."
),
}
def apply_densify_pick(
state: LayoutState,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
st = state.copy()
nid = str(sweep.get("node_id") or "")
cands = list(sweep.get("candidates") or [])
if not nid or nid not in st.positions or not cands:
return OpResult(
state=st,
moved=set(),
op="densify_sweep",
note="densify_sweep:noop",
params={"error": "no_candidates"},
)
idx = max(1, min(int(pick), len(cands))) - 1
chosen = cands[idx]
st.positions[nid] = (float(chosen["x"]), float(chosen["y"]))
st.last_moved = {nid}
st.meta["densify_sweep"] = {
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
}
return OpResult(
state=st,
moved={nid},
op="densify_sweep",
params={
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
},
note=(
f"densify_sweep pick={idx + 1} "
f"g{sweep.get('crossings_before', {}).get('global')}->"
f"{chosen['crossings']['global']}"
),
)
def densify_sweep_round(
state: LayoutState,
*,
params: LayoutParams | None = None,
top_n: int = 16,
max_degree: int = 8,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
groups: list[dict[str, Any]] | None = None,
) -> OpResult:
"""Scan sparse/high-stretch nodes; auto-apply #1 if x not up and stretch drops.
Default ``protect_rigid=off`` (may move portals). Opt in with portals/all.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
centroids = _exclusive_centroids(groups, pos, shared)
if groups and not _protect_is_off(protect_rigid):
frozen |= shared
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
global0 = count_edge_crossings(pos, links)
focus = {str(x) for x in (focus_ids or []) if str(x)}
scored_nodes: list[tuple[float, str]] = []
for nid in pos:
if nid in frozen:
continue
deg = len(adj.get(nid, ()))
if deg >= max_degree or deg == 0:
continue
if groups and nid not in node_groups and not focus:
continue
stretch = _incident_stretch(nid, pos, adj, target_nn)
ax, ay, _ = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
dist = math.hypot(pos[nid][0] - ax, pos[nid][1] - ay)
if stretch < 1.15 and dist < 180 and nid not in focus:
continue
# Higher stretch / farther from anchor first; focus boost.
pri = 0.0 if nid in focus else 1.0
scored_nodes.append((-stretch * 10 - dist / 500.0 + pri * 100, nid))
scored_nodes.sort()
movable = [nid for _, nid in scored_nodes[: max(1, int(top_n))]]
moved: set[str] = set()
trace: list[dict[str, Any]] = []
for nid in movable:
st.positions = pos
sweep = densify_sweep_node(
st,
nid,
params=params,
max_pull=max_pull,
nn_floor=nn_floor,
min_angle_sep=min_angle_sep,
protect_rigid="off",
frozen_ids=frozen,
groups=groups,
)
if not sweep.get("ok"):
trace.append({"node_id": nid, "skipped": sweep.get("error")})
continue
cands = list(sweep.get("candidates") or [])
if not cands:
trace.append({"node_id": nid, "skipped": "no_candidates"})
continue
best = cands[0]
stretch_b = float(sweep.get("stretch_before") or 0)
stretch_a = float(best.get("stretch") or 0)
if int(best["delta"]["global"]) > 0:
trace.append(
{
"node_id": nid,
"skipped": "crossing_up",
"best_delta": best["delta"],
}
)
continue
if stretch_a >= stretch_b - 1e-4:
# Allow pure global drop even if stretch flat.
if int(best["delta"]["global"]) >= 0:
trace.append(
{
"node_id": nid,
"skipped": "no_stretch_gain",
"stretch_before": stretch_b,
"stretch_after": stretch_a,
}
)
continue
pos[nid] = (float(best["x"]), float(best["y"]))
moved.add(nid)
trace.append(
{
"node_id": nid,
"name": names.get(nid, nid),
"applied": True,
"pick": 1,
"xy": [best["x"], best["y"]],
"delta": best["delta"],
"stretch": {"before": stretch_b, "after": stretch_a},
}
)
st.positions = pos
st.last_moved = moved
end_g = count_edge_crossings(pos, links)
meta = {
"start_crossings": global0,
"end_crossings": end_g,
"moved_n": len(moved),
"scanned_n": len(movable),
"trace": trace,
"top_n": top_n,
"max_degree": max_degree,
"nn_floor": nn_floor,
}
st.meta["densify_sweep"] = meta
return OpResult(
state=st,
moved=moved,
op="densify_sweep_round",
params=meta,
note=f"densify_sweep_round {global0}->{end_g} moved={len(moved)}/{len(movable)}",
)
def densify_corridor_scan(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
corridor_caps: list[float] | None = None,
pulls: list[float] | None = None,
iters: int = 6,
x_slack: int | None = None,
) -> OpResult:
"""Sweep corridor_cap×pull; keep best util with ov=0 and tight crossing slack."""
st0 = state.copy()
groups = groups if groups is not None else _groups_from_state(st0)
before = score_state(st0)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = int((before.get("summary") or {}).get("overlaps") or 0)
if before_ov == 0:
before_ov = len(overlapping_nodes(st0))
xs = [p[0] for p in st0.positions.values()]
ys = [p[1] for p in st0.positions.values()]
area0 = max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
caps = list(corridor_caps) if corridor_caps else list(_DEFAULT_CORRIDOR_CAPS)
pull_list = list(pulls) if pulls else list(_DEFAULT_PULLS)
slack = (
max(5, int(before_x * 0.05))
if x_slack is None
else max(0, int(x_slack))
)
trials: list[dict[str, Any]] = []
best: dict[str, Any] | None = None
for cap in caps:
for pull in pull_list:
op = shrink_long_corridors(
st0,
edge_len_cap=float(cap),
pull=float(pull),
iters=max(1, int(iters)),
max_bridges=8 if groups else 12,
min_island=2 if groups else 6,
groups=groups or None,
accept_crossings=True,
)
fin = score_state(op.state)
util = float((fin.get("summary") or {}).get("util") or 0.0)
x1 = int((fin.get("summary") or {}).get("crossings") or 0)
ov = len(overlapping_nodes(op.state))
xs1 = [p[0] for p in op.state.positions.values()]
ys1 = [p[1] for p in op.state.positions.values()]
area1 = max(max(xs1) - min(xs1), 1e-6) * max(max(ys1) - min(ys1), 1e-6)
area_ratio = area0 / area1
row = {
"corridor_cap": float(cap),
"pull": float(pull),
"util": util,
"crossings": x1,
"overlaps": ov,
"moved_n": len(op.moved),
"bbox_area_ratio": round(area_ratio, 4),
"note": op.note,
}
trials.append(row)
if ov > 0:
continue
if x1 > before_x + slack:
continue
# Accept if util rises at all, or bbox shrinks ≥0.8% (metro
# corridors often move area×1.01 before util clears 1%).
util_up = util > before_util + 1e-6
area_up = area_ratio >= 1.008
if not util_up and not area_up:
continue
rank = (util, area_ratio, -x1)
if best is None or rank > best["rank"]:
best = {
"rank": rank,
"state": op.state,
"moved": set(op.moved),
"row": row,
}
if best is None:
meta = {
"start_crossings": before_x,
"end_crossings": before_x,
"start_util": before_util,
"end_util": before_util,
"reverted": True,
"reason": "no_util_gain",
"trials": trials,
"x_slack": slack,
}
st0.meta["densify_corridor"] = meta
return OpResult(
state=st0,
moved=set(),
op="densify_corridor_scan",
params=meta,
note="densify_corridor_scan:reverted no_util_gain",
)
st = best["state"]
fin = score_state(st)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
meta = {
"start_crossings": before_x,
"end_crossings": end_x,
"start_util": before_util,
"end_util": end_util,
"reverted": False,
"chosen": best["row"],
"trials": trials,
"x_slack": slack,
"moved_n": len(best["moved"]),
}
st.meta = dict(st.meta or {})
st.meta["densify_corridor"] = meta
st.last_moved = best["moved"]
return OpResult(
state=st,
moved=best["moved"],
op="densify_corridor_scan",
params=meta,
note=(
f"densify_corridor_scan util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} "
f"cap={best['row']['corridor_cap']:.0f} pull={best['row']['pull']}"
),
)
def densify_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("node_id") is not None:
out["node_id"] = str(o.get("node_id") or "").strip()
elif o.get("fabric_node_id") is not None:
out["node_id"] = str(o.get("fabric_node_id") or "").strip()
if o.get("pick") is not None:
try:
out["pick"] = max(1, min(3, int(o["pick"])))
except (TypeError, ValueError):
out["pick"] = 1
for flag in ("round", "corridor"):
if o.get(flag) is not None:
out[flag] = str(o.get(flag)).lower() not in {
"0",
"false",
"no",
"off",
"",
} or o.get(flag) is True
phase = str(o.get("phase") or "").strip().lower()
if phase in {"corridor", "corridors", "shrink"}:
out["corridor"] = True
elif phase in {"intra", "node", "round"}:
out["round"] = out.get("round", True)
for key, cast, default in (
("top_n", int, 16),
("max_degree", int, 8),
("cand_cap", int, 180),
("top_k", int, 3),
("iters", int, 6),
):
if key not in o or o[key] is None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
out[key] = default
if o.get("max_pull") is not None:
try:
out["max_pull"] = float(o["max_pull"])
except (TypeError, ValueError):
pass
if o.get("nn_floor") is not None:
try:
out["nn_floor"] = float(o["nn_floor"])
except (TypeError, ValueError):
out["nn_floor"] = 60.0
else:
out["nn_floor"] = 60.0
if o.get("min_angle_sep") is not None:
try:
out["min_angle_sep"] = float(o["min_angle_sep"])
except (TypeError, ValueError):
out["min_angle_sep"] = 35.0
else:
out["min_angle_sep"] = 35.0
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
else:
# densify breaks rigid by default (opt in with protect_rigid=portals).
out["protect_rigid"] = "off"
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
raw_p = o.get("portal_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
caps = o.get("corridor_caps") or o.get("corridor_cap_list")
if isinstance(caps, list) and caps:
try:
out["corridor_caps"] = [float(x) for x in caps]
except (TypeError, ValueError):
pass
elif o.get("corridor_cap") is not None:
try:
out["corridor_caps"] = [float(o["corridor_cap"])]
except (TypeError, ValueError):
pass
pulls = o.get("pulls") or o.get("pull_list")
if isinstance(pulls, list) and pulls:
try:
out["pulls"] = [float(x) for x in pulls]
except (TypeError, ValueError):
pass
elif o.get("pull") is not None:
try:
out["pulls"] = [float(o["pull"])]
except (TypeError, ValueError):
pass
if o.get("x_slack") is not None:
try:
out["x_slack"] = int(o["x_slack"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,451 @@
"""Same-canvas dual-unit beautify: parallel lanes + straight chains.
Role: after gravity (`mass_merge`) has clustered the canvas, reshape each
dual-unit neighborhood in place — multi-corridor → H/V lanes; chains →
straight (no 回字). Does **not** redistribute the whole canvas by default.
Modes:
- refine (default / auto when spread): beautify onto current world portals
- full / repark: optional orbit_pack (legacy redistribute) then beautify
Typical agent flow:
mass_merge (gravity) → polish_crossings → dual_mass ↔ polish
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import orbit_pack_blocks
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
DualUnit,
beautify_dual_unit_positions,
classify_dual_unit,
find_dual_portal_units,
)
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _unit_links(
unit: DualUnit,
adj: dict[str, tuple[str, ...]],
) -> list[tuple[str, str]]:
members = unit.member_ids()
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for n in members:
for v in adj.get(n, ()):
if v not in members:
continue
pair = (n, v) if n <= v else (v, n)
if pair in seen:
continue
seen.add(pair)
out.append(pair)
return out
def _misc_grid_positions(
node_ids: list[str],
*,
pitch: float = 200.0,
) -> dict[str, tuple[float, float]]:
if not node_ids:
return {}
cols = max(1, int(math.ceil(math.sqrt(len(node_ids)))))
pos: dict[str, tuple[float, float]] = {}
for i, nid in enumerate(sorted(node_ids)):
r, c = divmod(i, cols)
pos[nid] = (c * pitch, r * pitch)
return pos
def _unit_centroids(
positions: dict[str, tuple[float, float]],
units: list[DualUnit],
) -> dict[int, tuple[float, float]]:
out: dict[int, tuple[float, float]] = {}
for u in units:
ids = [n for n in u.member_ids() if n in positions]
if not ids:
continue
xs = [positions[n][0] for n in ids]
ys = [positions[n][1] for n in ids]
out[int(u.unit_id)] = (sum(xs) / len(xs), sum(ys) / len(ys))
return out
def _centroid_drift(
before: dict[int, tuple[float, float]],
after: dict[int, tuple[float, float]],
) -> dict[str, float]:
keys = sorted(set(before) & set(after))
if not keys:
return {"mean": 0.0, "max": 0.0, "n": 0.0}
dists = [
math.hypot(after[k][0] - before[k][0], after[k][1] - before[k][1])
for k in keys
]
return {
"mean": round(sum(dists) / len(dists), 2),
"max": round(max(dists), 2),
"n": float(len(dists)),
}
def _knobs_from_meta(state: LayoutState) -> dict[str, Any]:
raw = (state.meta or {}).get("_dual_mass") or {}
return dict(raw) if isinstance(raw, dict) else {}
def _resolve_mode(state: LayoutState, mode: str) -> str:
m = str(mode or "auto").strip().lower() or "auto"
if m in {"full", "seed", "pack", "repark"}:
return "full"
if m in {"refine", "sweep", "stabilize", "beautify"}:
return "refine"
# auto: beautify in place once the canvas is already spread / has meta.
if (state.meta or {}).get("dual_mass"):
return "refine"
xs = [p[0] for p in state.positions.values()]
ys = [p[1] for p in state.positions.values()]
if not xs:
return "refine"
span = max(max(xs) - min(xs), max(ys) - min(ys))
# Collapsed dump → optional repark; otherwise in-place beautify.
return "full" if span < 800.0 else "refine"
def _beautify_blocks(
st: LayoutState,
units: list[DualUnit],
params: LayoutParams,
) -> tuple[list[ComposeBlock], int, int, set[str], dict[str, int]]:
blocks: list[ComposeBlock] = []
unit_ok = 0
unit_fail = 0
covered: set[str] = set()
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
for u in units:
kind = classify_dual_unit(u)
kinds[kind] = kinds.get(kind, 0) + 1
local = beautify_dual_unit_positions(st, u, params)
links_u = _unit_links(u, st.adj)
x_u = count_edge_crossings(local, links_u) if links_u else 0
if x_u != 0:
unit_fail += 1
else:
unit_ok += 1
covered |= set(local.keys())
blocks.append(ComposeBlock(key=f"u{u.unit_id}", positions=local))
return blocks, unit_ok, unit_fail, covered, kinds
def _refine_beautify_onto_world(
st: LayoutState,
units: list[DualUnit],
params: LayoutParams,
) -> tuple[LayoutState, set[str], int, int, dict[str, int]]:
"""Beautify each unit rigidly onto current portal world coords (no re-orbit)."""
world = dict(st.positions)
moved: set[str] = set()
unit_ok = 0
unit_fail = 0
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
for u in units:
kind = classify_dual_unit(u)
kinds[kind] = kinds.get(kind, 0) + 1
local = beautify_dual_unit_positions(st, u, params)
links_u = _unit_links(u, st.adj)
x_u = count_edge_crossings(local, links_u) if links_u else 0
if x_u != 0:
unit_fail += 1
else:
unit_ok += 1
shared = [p for p in (u.portal_a, u.portal_b) if p in world and p in local]
if not shared:
continue
prefer = None
if len(shared) == 1 and len(local) >= 2:
cx = sum(p[0] for p in world.values()) / max(len(world), 1)
cy = sum(p[1] for p in world.values()) / max(len(world), 1)
prefer = (cx, cy)
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=list(st.links or []),
)
for nid, xy in aligned.items():
if nid in shared:
continue
if world.get(nid) != xy:
moved.add(nid)
world[nid] = xy
out = st.copy()
out.positions = world
return out, moved, unit_ok, unit_fail, kinds
def _ensure_mass_meta(
st: LayoutState,
units: list[DualUnit],
groups: list[dict[str, Any]] | None = None,
) -> tuple[LayoutState, dict[str, Any], list[dict[str, Any]]]:
grp = list(groups or [])
if not grp:
for u in units:
ids = sorted(u.member_ids())
grp.append(
{
"key": f"u{u.unit_id}",
"node_ids": ids,
"pivots": [u.portal_a, u.portal_b],
"cores": [u.portal_a, u.portal_b],
"soft": True,
}
)
mass = build_mass_field(st, units=units, groups=grp)
st = st.copy()
st.meta = dict(st.meta or {})
st.meta["mass_field"] = mass
packish = {
"mode": "dual_mass",
"mass_groups": mass.get("groups") or grp,
"rigid_groups": grp,
"soft": True,
}
st.meta["compose_views"] = attach_mass_to_compose_meta(packish, mass)
return st, mass, grp
def layout_dual_mass(
state: LayoutState,
params: LayoutParams | None = None,
*,
pad: float = 500.0,
fabric_bridges: bool = True,
angle_step: int = 30,
mass_merge: bool = False,
mass_iters: int = 12,
mode: str = "auto",
rounds: int = 1,
gravity_first: bool = False,
stable_drift: float = 120.0,
) -> OpResult:
"""Beautify dual-portal neighborhoods (petal / 回 / straight) in place."""
params = params or LayoutParams()
knobs = _knobs_from_meta(state)
if "mode" in knobs:
mode = str(knobs["mode"])
if "rounds" in knobs:
rounds = int(knobs["rounds"] or 1)
if "mass_iters" in knobs:
mass_iters = int(knobs["mass_iters"] or mass_iters)
if "pad" in knobs:
pad = float(knobs["pad"] or pad)
if "angle_step" in knobs:
angle_step = int(knobs["angle_step"] or angle_step)
if "mass_merge" in knobs:
v = knobs["mass_merge"]
mass_merge = bool(v) if not isinstance(v, str) else v.strip().lower() not in {
"0",
"false",
"no",
"off",
}
if "gravity_first" in knobs:
v = knobs["gravity_first"]
gravity_first = (
bool(v)
if not isinstance(v, str)
else v.strip().lower() in {"1", "true", "yes", "on"}
)
if "stable_drift" in knobs:
stable_drift = float(knobs["stable_drift"] or stable_drift)
st = state.copy()
units = find_dual_portal_units(st)
if not units:
return OpResult(
state=st,
moved=set(),
op="layout_dual_mass",
params={"units_n": 0},
note="no_dual_units",
)
resolved = _resolve_mode(st, mode)
rounds = max(1, min(int(rounds), 8))
moved: set[str] = set()
unit_ok = 0
unit_fail = 0
leftovers: list[str] = []
pack_meta: dict[str, Any] = {}
mass_meta: dict[str, Any] | None = None
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
drifts: list[dict[str, float]] = []
stable = False
if gravity_first:
st, mass, groups = _ensure_mass_meta(st, units)
mop = mass_merge_round(
st,
groups=mass.get("groups") or groups,
iters=max(4, int(mass_iters) // 2),
protect_rigid="portals",
gravity_k=0.45,
core_pull_k=1.2,
)
st = mop.state
moved |= mop.moved
mass_meta = mop.params if isinstance(mop.params, dict) else None
for ri in range(rounds):
cens_before = _unit_centroids(st.positions, units)
pass_mode = resolved if ri == 0 else "refine"
if pass_mode == "full":
blocks, unit_ok, unit_fail, covered, kinds = _beautify_blocks(
st, units, params
)
leftovers = [
n
for n in st.positions
if n not in covered and not str(n).startswith("region:")
]
if leftovers:
blocks.append(
ComposeBlock(
key="misc",
positions=_misc_grid_positions(
leftovers, pitch=max(float(params.pitch), 180.0)
),
)
)
merged, pack_meta = orbit_pack_blocks(
blocks,
pad=float(pad),
links=list(st.links or []),
fabric_bridges=bool(fabric_bridges),
angle_step=int(angle_step),
)
if not merged:
return OpResult(
state=st,
moved=moved,
op="layout_dual_mass",
params={
"units_n": len(units),
"pack": pack_meta,
"mode": pass_mode,
"kinds": kinds,
},
note="pack_empty",
)
for nid, xy in merged.items():
if st.positions.get(nid) != xy:
moved.add(nid)
st.positions[nid] = xy
for nid in state.positions:
if nid not in st.positions:
st.positions[nid] = state.positions[nid]
groups = pack_meta.get("mass_groups") or pack_meta.get("rigid_groups") or []
st, mass, groups = _ensure_mass_meta(st, units, groups)
else:
st, eye_moved, unit_ok, unit_fail, kinds = _refine_beautify_onto_world(
st, units, params
)
moved |= eye_moved
st, mass, groups = _ensure_mass_meta(st, units)
if mass_merge and len(st.positions) >= 3:
mop = mass_merge_round(
st,
groups=mass.get("groups") or groups,
iters=max(1, int(mass_iters)),
protect_rigid="portals",
)
st = mop.state
moved |= mop.moved
mass_meta = mop.params if isinstance(mop.params, dict) else None
cens_after = _unit_centroids(st.positions, units)
drift = _centroid_drift(cens_before, cens_after)
drifts.append(drift)
if drift["n"] > 0 and drift["mean"] <= float(stable_drift) and ri > 0:
stable = True
break
resolved = "refine"
fop = fix_overlaps_local(st, params)
st = fop.state
moved |= fop.moved
report = {
"units_n": len(units),
"unit_ok": unit_ok,
"unit_fail_cross": unit_fail,
"kinds": kinds,
"mode": resolved if rounds == 1 else "multi",
"mode_first": _resolve_mode(state, mode),
"rounds_ran": len(drifts),
"rounds_requested": rounds,
"centroid_drift": drifts,
"stable": stable,
"stable_drift": float(stable_drift),
"misc_n": len(leftovers),
"pack_slots": pack_meta.get("slots"),
"pack_final_crossings": pack_meta.get("final_crossings"),
"mass_merge": mass_meta,
"mass_groups_n": len(
(st.meta.get("compose_views") or {}).get("mass_groups") or []
),
"gravity_first": bool(gravity_first),
"role": "beautify",
}
st.meta = dict(st.meta or {})
st.meta["dual_mass"] = report
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="layout_dual_mass",
params=report,
note=(
f"layout_dual_mass units={len(units)} kinds={kinds} "
f"mode={report['mode_first']} rounds={report['rounds_ran']} "
f"stable={stable} moved={len(moved)}"
),
)
def dual_mass_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pick dual_mass knobs from layout params."""
if not overrides:
return {}
out: dict[str, Any] = {}
for k in (
"mode",
"rounds",
"mass_iters",
"pad",
"angle_step",
"mass_merge",
"gravity_first",
"stable_drift",
"fabric_bridges",
):
if k in overrides:
out[k] = overrides[k]
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,295 @@
"""Fold a deg≤2 tentacle into the emptiest cheap sector around a hub.
Agent workflow (stage 2): nearest-ring angular sweep → push low-degree blockers
radially out → place the whole chain on an arc in that sector.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _deg(st: LayoutState) -> dict[str, int]:
return {nid: len(st.adj.get(nid, ())) for nid in st.positions}
def _chain_from(st: LayoutState, hub: str, stub: str, deg: dict[str, int]) -> list[str]:
path = [stub]
prev, cur = hub, stub
while True:
nxt = [x for x in st.adj.get(cur, ()) if x != prev and deg.get(x, 0) <= 2]
if len(nxt) != 1:
break
path.append(nxt[0])
prev, cur = cur, nxt[0]
return path
def _pick_hub(st: LayoutState, deg: dict[str, int], hub_id: str | None) -> str:
if hub_id and hub_id in st.positions:
return hub_id
# Prefer highest degree; break ties by name for stability.
ranked = sorted(
st.positions.keys(),
key=lambda n: (-deg.get(n, 0), st.names.get(n, ""), n),
)
if not ranked:
raise ValueError("fold_chain:empty_graph")
return ranked[0]
def _first_hop_toward(
st: LayoutState, hub: str, target: str
) -> str | None:
"""BFS: first neighbor of hub on a path to target."""
if target in st.adj.get(hub, ()):
return target
from collections import deque
q = deque([hub])
prev: dict[str, str | None] = {hub: None}
while q:
u = q.popleft()
for v in st.adj.get(u, ()):
if v in prev:
continue
prev[v] = u
if v == target:
# Walk back to hop after hub.
cur = target
while prev[cur] is not None and prev[cur] != hub:
cur = prev[cur]
return cur
q.append(v)
return None
def _pick_stub(
st: LayoutState,
hub: str,
deg: dict[str, int],
stub_id: str | None,
*,
min_len: int,
) -> tuple[str, list[str]]:
if stub_id and stub_id in st.positions:
hop = _first_hop_toward(st, hub, stub_id)
if hop is None:
raise ValueError(f"fold_chain:stub_unreachable:{stub_id}")
chain = _chain_from(st, hub, hop, deg)
return hop, chain
best: list[str] = []
best_stub = ""
for nb in sorted(st.adj.get(hub, ()), key=lambda n: (st.names.get(n, ""), n)):
if deg.get(nb, 0) > 2:
continue
chain = _chain_from(st, hub, nb, deg)
if len(chain) > len(best):
best = chain
best_stub = nb
if len(best) < min_len:
raise ValueError(
f"fold_chain:no_tentacle_from_hub:{hub}:need>={min_len},got={len(best)}"
)
return best_stub, best
def _ang_delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def fold_chain_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pull fold_chain knobs from layout params overrides."""
o = overrides or {}
out: dict[str, Any] = {}
def _f(key: str) -> float | None:
if key not in o or o[key] is None:
return None
try:
return float(o[key])
except (TypeError, ValueError):
return None
def _i(key: str) -> int | None:
if key not in o or o[key] is None:
return None
try:
return int(o[key])
except (TypeError, ValueError):
return None
for key in ("hub_id", "stub_id", "chain_start"):
if key in o and o[key] is not None and str(o[key]).strip():
out[key] = str(o[key]).strip()
for key in ("prefer_mid_deg", "r_arc", "chord", "push_r", "min_gap_deg"):
v = _f(key)
if v is not None:
out[key] = v
for key in ("ring_k", "min_chain_len", "protect_deg_ge"):
v = _i(key)
if v is not None:
out[key] = v
if "clear_only" in o and o["clear_only"] is not None:
out["clear_only"] = str(o["clear_only"]).strip().lower() in {
"1",
"true",
"yes",
} or o["clear_only"] is True
if isinstance(o.get("protected_ids"), list):
out["protected_ids"] = [str(x).strip() for x in o["protected_ids"] if str(x).strip()]
return out
def fold_chain_into_sector(
st: LayoutState,
params: LayoutParams,
*,
hub_id: str | None = None,
stub_id: str | None = None,
chain_start: str | None = None,
prefer_mid_deg: float | None = None,
r_arc: float | None = None,
chord: float | None = None,
push_r: float | None = None,
ring_k: int = 14,
min_gap_deg: float = 26.0,
min_chain_len: int = 3,
clear_only: bool = False,
protected_ids: list[str] | None = None,
protect_deg_ge: int = 4,
) -> OpResult:
"""Sweep nearest ring, clear cheapest large gap, fold deg≤2 chain onto arc."""
st = st.copy()
deg = _deg(st)
hub = _pick_hub(st, deg, hub_id)
stub = stub_id or chain_start
stub, chain = _pick_stub(st, hub, deg, stub, min_len=int(min_chain_len))
chain_set = set(chain)
hx, hy = st.positions[hub]
protected = {hub, *chain_set}
for pid in protected_ids or []:
if str(pid).strip():
protected.add(str(pid).strip())
# Auto-protect high-degree nodes near hub
for nid, d in deg.items():
if d >= int(protect_deg_ge):
protected.add(nid)
others: list[tuple[float, float, str]] = []
for nid, (x, y) in st.positions.items():
if nid == hub or nid in chain_set:
continue
r = math.hypot(x - hx, y - hy)
ang = math.degrees(math.atan2(y - hy, x - hx)) % 360.0
others.append((r, ang, nid))
others.sort()
k = max(4, min(int(ring_k), len(others)))
ring = sorted(others[:k], key=lambda t: t[1]) if others else []
gaps: list[tuple[float, float, float, float, str, str]] = []
if len(ring) >= 2:
for i in range(len(ring)):
a0 = ring[i][1]
a1 = ring[(i + 1) % len(ring)][1]
gap = (a1 - a0) % 360.0
mid = (a0 + gap / 2.0) % 360.0
lid, rid = ring[i][2], ring[(i + 1) % len(ring)][2]
cost = 0.0
for bid in (lid, rid):
if bid in protected or deg.get(bid, 0) >= int(protect_deg_ge):
cost += 4.0
elif bid in st.adj.get(hub, ()):
cost += 1.0
gaps.append((cost, -gap, mid, gap, lid, rid))
gaps.sort()
else:
# Empty / sparse: open southish default sector.
mid = float(prefer_mid_deg) if prefer_mid_deg is not None else 90.0
gaps = [(0.0, -60.0, mid % 360.0, 60.0, "", "")]
def mid_penalty(mid: float) -> float:
if prefer_mid_deg is None:
return 0.0
return _ang_delta(mid, float(prefer_mid_deg)) / 30.0
cand = [g for g in gaps if g[3] >= float(min_gap_deg)] or gaps[:3]
cand.sort(key=lambda g: (g[0], mid_penalty(g[2]), -g[3]))
cost, _, sector_mid, best_gap, lid, rid = cand[0]
sector_half = max(min(best_gap / 2.0 - 2.0, 38.0), 28.0)
target_nn = float(params.target_nn or 155.0)
r_push = float(push_r) if push_r is not None else max(480.0, target_nn * 3.0)
arc_r = float(r_arc) if r_arc is not None else max(280.0, target_nn * 2.0)
chord_len = float(chord) if chord is not None else max(170.0, target_nn * 1.15)
moved: set[str] = set()
pushed: list[str] = []
def in_sector(ang: float) -> bool:
return _ang_delta(ang, sector_mid) <= sector_half
for r, ang, nid in others:
if not in_sector(ang) or nid in protected or deg.get(nid, 0) >= 3:
continue
if r >= r_push:
continue
nr = max(r_push, r + 200.0)
rad = math.radians(ang)
st.positions[nid] = (hx + nr * math.cos(rad), hy + nr * math.sin(rad))
moved.add(nid)
pushed.append(nid)
folded: list[str] = []
if not clear_only:
n = len(chain)
d_ang = math.degrees(2 * math.asin(min(0.95, chord_len / (2.0 * arc_r))))
span = d_ang * (n - 1)
a0 = sector_mid - span / 2.0
for i, nid in enumerate(chain):
ang = math.radians((a0 + i * d_ang) % 360.0)
st.positions[nid] = (hx + arc_r * math.cos(ang), hy + arc_r * math.sin(ang))
moved.add(nid)
folded.append(nid)
st.last_moved = set(moved)
st.meta["fold_chain"] = {
"hub_id": hub,
"stub_id": stub,
"chain": chain,
"sector_mid": round(sector_mid, 2),
"sector_half": round(sector_half, 2),
"gap_deg": round(best_gap, 2),
"gap_cost": cost,
"gap_bounds": [lid, rid],
"prefer_mid_deg": prefer_mid_deg,
"r_arc": arc_r,
"chord": chord_len,
"push_r": r_push,
"pushed": pushed,
"folded": folded,
"clear_only": bool(clear_only),
"ring_k": k,
}
note = (
f"fold_chain hub={hub[:8]} chain={len(chain)} "
f"mid={sector_mid:.0f} gap={best_gap:.0f} pushed={len(pushed)}"
)
return OpResult(
state=st,
moved=moved,
op="fold_chain_into_sector",
params={
"hub_id": hub,
"stub_id": stub,
"sector_mid": sector_mid,
"gap_deg": best_gap,
"clear_only": bool(clear_only),
},
note=note,
)

View file

@ -0,0 +1,599 @@
"""Semi-rigid force densify: bidirectional edge springs + local repulsion.
Metro util is killed by long bridges; uniform pack crushes nn into overlaps,
rigid shrink spikes crossings. This runs a few damped force iterations:
- **Edge spring (双向)**: Hookean toward ``ideal_len`` — too long → pull
together, too short → push apart (push can also densify by expanding
crushed corridors into readable spacing while gravity/long edges shrink
the global bbox).
- **Node repulse**: spatial-hash neighbors push apart below ~1.35×nn_floor.
- **Gravity**: free nodes gently pull toward free-centroid (bbox extremities).
- **Semi-rigid**: dual-unit exclusive bodies share a group translation
(``rigid_strength``) plus optional per-node deform residual.
- **Mass / weights**: hubs move less / repulse stronger; leaves follow springs.
Gates: util or bbox gain within soft crossing budget; then polish.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
)
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6
_DEFAULT_ITERS = 12
_DEFAULT_STEP = 0.28
_DEFAULT_MAX_STEP = 100.0
_DEFAULT_RIGID = 0.9
_DEFAULT_DEFORM = 0.12
_DEFAULT_ATTRACT = 0.85
_DEFAULT_REPULSE = 1.35
_DEFAULT_NN_FLOOR = 90.0
# Only pull edges longer than this multiple of nn_floor (metro bridges).
_IDEAL_NN_MUL = 6.0
_MAX_EDGE_PULL = 90.0
def _bbox_area(pos: dict[str, tuple[float, float]]) -> float:
pts = [
p
for p in pos.values()
if abs(p[0]) <= _COORD_ABS_MAX
and abs(p[1]) <= _COORD_ABS_MAX
and math.isfinite(p[0])
and math.isfinite(p[1])
]
if len(pts) < 2:
return 1.0
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
def _parse_groups(
groups: list[dict[str, Any]],
valid: set[str],
) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
raw: list[tuple[str, list[str], list[str]]] = []
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
pivots = [str(p) for p in (g.get("pivots") or []) if str(p) in valid]
for n in members:
counts[n] = counts.get(n, 0) + 1
raw.append((key, members, pivots))
shared = {n for n, c in counts.items() if c > 1}
out: list[dict[str, Any]] = []
for key, members, pivots in raw:
exclusive = [n for n in members if n not in shared]
out.append(
{
"key": key,
"members": members,
"pivots": pivots or [n for n in members if n in shared],
"exclusive": exclusive,
"shared": [n for n in members if n in shared],
}
)
return out
def _mass_and_weights(
st: LayoutState,
ids: list[str],
) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
"""Return mass, attract_w, repulse_w per node."""
mass: dict[str, float] = {}
att: dict[str, float] = {}
rep: dict[str, float] = {}
for n in ids:
deg = len(st.adj.get(n, ()))
layer = str(st.layers.get(n) or "")
# Hubs = heavy + strong territory; leaves = light + strong spring follow.
m = 1.0 + 0.28 * float(deg)
if layer in ("core", "agg") or deg >= 8:
m *= 1.35
a_w = 0.65
r_w = 1.55
elif deg <= 2:
m *= 0.75
a_w = 1.35
r_w = 0.85
else:
a_w = 1.0
r_w = 1.0
mass[n] = m
att[n] = a_w
rep[n] = r_w
return mass, att, rep
def _spatial_bins(
pos: dict[str, tuple[float, float]],
ids: list[str],
cell: float,
) -> dict[tuple[int, int], list[str]]:
bins: dict[tuple[int, int], list[str]] = {}
inv = 1.0 / max(cell, 1e-6)
for n in ids:
x, y = pos[n]
key = (int(math.floor(x * inv)), int(math.floor(y * inv)))
bins.setdefault(key, []).append(n)
return bins
def _accumulate_forces(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
ids: list[str],
*,
ideal_len: float,
nn_floor: float,
attract_k: float,
repulse_k: float,
gravity_k: float,
att_w: dict[str, float],
rep_w: dict[str, float],
frozen: set[str],
) -> dict[str, tuple[float, float]]:
fx = {n: 0.0 for n in ids}
fy = {n: 0.0 for n in ids}
id_set = set(ids)
# Weak gravity toward free-node centroid (shrinks bbox extremities).
free = [n for n in ids if n not in frozen and n in pos]
if gravity_k > 1e-9 and len(free) >= 2:
gcx = sum(pos[n][0] for n in free) / len(free)
gcy = sum(pos[n][1] for n in free) / len(free)
for n in free:
dx, dy = gcx - pos[n][0], gcy - pos[n][1]
dist = math.hypot(dx, dy)
if dist < nn_floor * 2.0:
continue
# Stronger pull for outliers far from center.
mag = gravity_k * min(dist * 0.02, _MAX_EDGE_PULL * 0.8)
fx[n] += (dx / dist) * mag
fy[n] += (dy / dist) * mag
# Bidirectional edge spring: L>ideal pull; L<ideal push (Hookean).
# Dead-band around ideal avoids jitter; push uses same attract_k scale
# (repulse_k still handles non-adjacent crowding).
dead = max(ideal_len * 0.06, nn_floor * 0.25)
for a, b in links:
if a not in pos or b not in pos:
continue
if a not in id_set and b not in id_set:
continue
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
if L < 1e-6:
# Coincident endpoints: invent a tiny axis so push can separate.
dx, dy, L = 1.0, 0.0, 1.0
delta = L - ideal_len
if abs(delta) <= dead:
continue
ux, uy = dx / L, dy / L
# Cap |δ| so ultra-long bridges / crushed stubs don't explode.
capped = max(-_MAX_EDGE_PULL * 8.0, min(delta, _MAX_EDGE_PULL * 12.0))
# Sign: positive δ → pull a toward b (force on a along +u);
# negative δ → push a away from b (force on a along -u).
mag = attract_k * min(abs(capped) * 0.045, _MAX_EDGE_PULL)
if capped < 0:
# Slightly softer push than pull (avoid blasting clusters open).
mag *= 0.75
sign = 1.0 if capped > 0 else -1.0
wa = att_w.get(a, 1.0) if a not in frozen else 0.0
wb = att_w.get(b, 1.0) if b not in frozen else 0.0
wsum = wa + wb
if wsum <= 1e-12:
continue
fa = mag * (wa / wsum)
fb = mag * (wb / wsum)
# On a: toward b when pulling (sign>0); away when pushing (sign<0).
if a in fx and a not in frozen:
fx[a] += ux * fa * sign
fy[a] += uy * fa * sign
if b in fx and b not in frozen:
fx[b] -= ux * fb * sign
fy[b] -= uy * fb * sign
# Local repulsion — tight floor only (avoid pushing bbox outward).
r0 = max(nn_floor * 1.35, 100.0)
cell = max(r0, 1.0)
bins = _spatial_bins(pos, ids, cell)
for (cx, cy), bucket in bins.items():
neighbors: list[str] = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
neighbors.extend(bins.get((cx + dx, cy + dy), ()))
seen: set[tuple[str, str]] = set()
for a in bucket:
if a in frozen:
continue
ax, ay = pos[a]
for b in neighbors:
if b == a or b in frozen:
continue
pair = (a, b) if a < b else (b, a)
if pair in seen:
continue
seen.add(pair)
bx, by = pos[b]
dx, dy = ax - bx, ay - by
d = math.hypot(dx, dy)
if d >= r0 or d < 1e-6:
continue
ux, uy = dx / d, dy / d
mag = repulse_k * min(r0 - d, r0) * 0.4 * (
(rep_w.get(a, 1.0) + rep_w.get(b, 1.0)) * 0.5
)
mag = min(mag, _MAX_EDGE_PULL * 0.9)
if a not in frozen:
fx[a] += ux * mag
fy[a] += uy * mag
if b not in frozen:
fx[b] -= ux * mag
fy[b] -= uy * mag
return {n: (fx[n], fy[n]) for n in ids}
def _apply_semi_rigid(
deltas: dict[str, tuple[float, float]],
groups: list[dict[str, Any]],
frozen: set[str],
*,
rigid_strength: float,
deform: float,
) -> dict[str, tuple[float, float]]:
"""Blend group mean translation with per-node residual."""
out = dict(deltas)
rs = max(0.0, min(1.0, float(rigid_strength)))
df = max(0.0, min(1.0, float(deform)))
# When rigid=1 and deform=0 → pure mean translate for exclusive.
for g in groups:
excl = [n for n in g.get("exclusive") or [] if n in out and n not in frozen]
if len(excl) < 2:
continue
mx = sum(out[n][0] for n in excl) / len(excl)
my = sum(out[n][1] for n in excl) / len(excl)
for n in excl:
ix, iy = out[n]
# rs=1,df=0 → pure group translate; df>0 allows limited shape stretch.
out[n] = (
rs * mx + (1.0 - rs) * ix + df * rs * (ix - mx),
rs * my + (1.0 - rs) * iy + df * rs * (iy - my),
)
return out
def force_densify_round(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
iters: int = _DEFAULT_ITERS,
step: float = _DEFAULT_STEP,
max_step: float = _DEFAULT_MAX_STEP,
ideal_len: float | None = None,
nn_floor: float = _DEFAULT_NN_FLOOR,
attract_k: float = _DEFAULT_ATTRACT,
repulse_k: float = _DEFAULT_REPULSE,
gravity_k: float = 0.55,
rigid_strength: float = _DEFAULT_RIGID,
deform: float = _DEFAULT_DEFORM,
protect_rigid: bool | str = "portals",
x_slack: int | None = None,
damping: float = 0.85,
) -> OpResult:
"""Run damped attract/repulse densify with semi-rigid dual-unit bodies."""
from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled,
report_progress,
touch_heartbeat,
)
st = state.copy()
groups = groups if groups is not None else groups_from_compose_meta(
(st.meta or {}).get("compose_views")
)
valid = {
n
for n, (x, y) in st.positions.items()
if abs(x) <= _COORD_ABS_MAX
and abs(y) <= _COORD_ABS_MAX
and math.isfinite(x)
and math.isfinite(y)
}
ids = sorted(valid)
if len(ids) < 3:
return OpResult(
state=st, moved=set(), op="force_densify", note="force_densify:too_few"
)
parsed = _parse_groups(groups or [], valid)
frozen = frozen_ids_for_protect(st, protect_rigid) if protect_rigid not in (
False,
"false",
"off",
"none",
"0",
) else set()
# Pin region phantoms.
for n in list(ids):
if str(n).startswith("region:"):
frozen.add(n)
before = score_state(st, fast=True)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = len(overlapping_nodes(st))
area0 = _bbox_area(st.positions)
slack = (
max(12, int(before_x * 0.1))
if x_slack is None
else max(0, int(x_slack))
)
# Default ideal targets long corridors only (not near-nn edges).
ideal = (
float(ideal_len)
if ideal_len is not None
else max(float(nn_floor) * _IDEAL_NN_MUL, 520.0)
)
mass, att_w, rep_w = _mass_and_weights(st, ids)
pos = {n: (float(st.positions[n][0]), float(st.positions[n][1])) for n in ids}
links = list(st.links)
n_iters = max(1, min(40, int(iters)))
step_k = max(0.05, min(1.0, float(step)))
cap = max(20.0, float(max_step))
damp = max(0.2, min(0.98, float(damping)))
report_progress(
"force_densify",
pct=48.0,
message=(
f"iters={n_iters} ideal={ideal:.0f} "
f"rigid={rigid_strength:.2f} deform={deform:.2f}"
),
nodes=len(ids),
groups=len(parsed),
)
vel = {n: (0.0, 0.0) for n in ids}
moved: set[str] = set()
# Keep best checkpoint within x_slack (smaller bbox / higher util proxy).
_cell = 90.0 * 90.0
util_proxy0 = len(ids) * _cell / max(area0, 1e-6)
best_pos = dict(pos)
# tier=2 within slack, tier=1 soft densify, tier=0 baseline
best_key = (0, util_proxy0, -area0, -before_x)
best_meta_x = before_x
best_meta_util = util_proxy0
best_iter = 0
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
for it in range(n_iters):
raise_if_cancelled()
touch_heartbeat()
if it % 2 == 0:
report_progress(
"force_densify",
pct=48.0 + 24.0 * (it / max(n_iters, 1)),
message=f"iter {it + 1}/{n_iters}",
iter=it + 1,
)
forces = _accumulate_forces(
pos,
links,
ids,
ideal_len=ideal,
nn_floor=float(nn_floor),
attract_k=float(attract_k),
repulse_k=float(repulse_k),
gravity_k=float(gravity_k),
att_w=att_w,
rep_w=rep_w,
frozen=frozen,
)
deltas: dict[str, tuple[float, float]] = {}
for n in ids:
if n in frozen:
deltas[n] = (0.0, 0.0)
continue
fx, fy = forces[n]
m = max(mass.get(n, 1.0), 0.2)
dx, dy = (fx / m) * step_k, (fy / m) * step_k
vx = damp * vel[n][0] + dx
vy = damp * vel[n][1] + dy
spd = math.hypot(vx, vy)
if spd > cap:
s = cap / spd
vx, vy = vx * s, vy * s
vel[n] = (vx, vy)
deltas[n] = (vx, vy)
deltas = _apply_semi_rigid(
deltas,
parsed,
frozen,
rigid_strength=rigid_strength,
deform=deform,
)
for n, (dx, dy) in deltas.items():
if n in frozen:
continue
spd = math.hypot(dx, dy)
if spd > cap:
s = cap / spd
dx, dy = dx * s, dy * s
if abs(dx) + abs(dy) < 1e-4:
continue
x, y = pos[n]
pos[n] = (x + dx, y + dy)
moved.add(n)
# Checkpoint every 2 iters (and last). Prefer within x_slack; else
# still keep densifying states (metro: short x rise → polish after).
if it % 2 == 1 or it == n_iters - 1:
raise_if_cancelled()
touch_heartbeat()
x_now = count_edge_crossings(pos, links)
area_now = _bbox_area(pos)
util_proxy = len(ids) * _cell / max(area_now, 1e-6)
densified = util_proxy > util_proxy0 * 1.002 or area_now < area0 * 0.998
if not densified:
continue
soft_cap = before_x + max(slack * 3, int(before_x * 0.45) + 40)
if x_now > soft_cap:
continue
tier = 2 if x_now <= before_x + slack else 1
key = (tier, util_proxy, -area_now, -x_now)
if key > best_key:
best_key = key
best_pos = dict(pos)
best_meta_x = x_now
best_meta_util = util_proxy
best_iter = it + 1
pos = best_pos
st.positions = {**st.positions, **pos}
fin = score_state(st, fast=True)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
end_ov = len(overlapping_nodes(st))
area1 = _bbox_area(st.positions)
area_ratio = area0 / max(area1, 1e-6)
util_up = end_util > before_util + 1e-6
area_up = area_ratio >= 1.004
x_down = end_x < before_x
# Soft final gate: densify may spend up to soft_cap crossings; polish next.
soft_final = before_x + max(slack * 3, int(before_x * 0.45) + 40)
x_ok = end_x <= soft_final
gain = util_up or area_up or x_down
meta = {
"reverted": False,
"start_util": before_util,
"end_util": end_util,
"start_crossings": before_x,
"end_crossings": end_x,
"start_overlaps": before_ov,
"end_overlaps": end_ov,
"bbox_area_ratio": round(area_ratio, 4),
"moved_n": len(moved),
"iters": n_iters,
"best_iter": best_iter,
"ideal_len": round(ideal, 1),
"nn_floor": float(nn_floor),
"attract_k": float(attract_k),
"repulse_k": float(repulse_k),
"gravity_k": float(gravity_k),
"rigid_strength": float(rigid_strength),
"deform": float(deform),
"x_slack": slack,
"x_soft_cap": soft_final,
"checkpoint_x": best_meta_x,
"checkpoint_util_proxy": round(best_meta_util, 6),
"protect_rigid": (
protect_rigid
if isinstance(protect_rigid, str)
else ("portals" if protect_rigid else "off")
),
"groups_n": len(parsed),
"frozen_n": len(frozen),
}
def _revert(reason: str) -> OpResult:
meta["reverted"] = True
meta["reason"] = reason
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["force_densify"] = meta
return OpResult(
state=st0,
moved=set(),
op="force_densify",
params=meta,
note=f"force_densify:reverted {reason}",
)
if best_iter == 0 and not gain:
return _revert("no_gain")
if not x_ok:
return _revert("crossing_rise")
if not gain:
return _revert("no_gain")
# Residual overlaps OK if densified — layout_tool ensure_zero_overlap repairs.
st.meta = dict(st.meta or {})
st.meta["force_densify"] = meta
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="force_densify",
params=meta,
note=(
f"force_densify util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} area×{area_ratio:.3f} moved={len(moved)}"
),
)
def force_densify_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
for key, lo, hi, cast in (
("iters", 1, 40, int),
("step", 0.05, 1.0, float),
("max_step", 20.0, 800.0, float),
("ideal_len", 40.0, 4000.0, float),
("nn_floor", 40.0, 400.0, float),
("attract_k", 0.05, 4.0, float),
("repulse_k", 0.05, 4.0, float),
("gravity_k", 0.0, 3.0, float),
("rigid_strength", 0.0, 1.0, float),
("deform", 0.0, 1.0, float),
("damping", 0.2, 0.98, float),
("x_slack", 0, 5000, int),
):
if o.get(key) is None:
continue
try:
v = cast(o[key])
out[key] = max(lo, min(hi, v))
except (TypeError, ValueError):
pass
if "protect_rigid" in o:
v = o.get("protect_rigid")
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v or "portals").strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = "portals"
groups = o.get("rigid_groups") or o.get("_rigid_groups")
if isinstance(groups, list):
out["groups"] = groups
return out

View file

@ -0,0 +1,200 @@
"""Graph helpers for layout ops."""
from __future__ import annotations
from collections import deque
from netx_api.mcp_remote.topology.layout_ops.level_util import infer_layer
__all__ = [
"infer_layer",
"bbox",
"connected_components",
"order_ans",
"chain_order",
]
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
return min(xs), min(ys), max(xs), max(ys)
def connected_components(nodes: set[str], g: dict[str, set[str]]) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for s in sorted(nodes):
if s in seen:
continue
q = deque([s])
seen.add(s)
comp: list[str] = []
while q:
u = q.popleft()
comp.append(u)
for v in g.get(u, ()):
if v in nodes and v not in seen:
seen.add(v)
q.append(v)
out.append(comp)
out.sort(key=lambda c: -len(c))
return out
def order_ans(
ans: list[str], ens: set[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
if len(ans) <= 1:
return list(ans)
def dist(a: str, b: str) -> int:
q = deque([(a, 0)])
seen = {a}
while q:
u, d = q.popleft()
for v in g.get(u, ()):
if v == b:
return d + 1
if v in seen:
continue
if v in ens or v in ans:
seen.add(v)
q.append((v, d + 1))
return 10**6
start = max(ans, key=lambda a: (len([x for x in g.get(a, ()) if x in ens]), names[a]))
seq = [start]
rest = set(ans) - {start}
while rest:
last = seq[-1]
nxt = min(rest, key=lambda a: (dist(last, a), names[a]))
seq.append(nxt)
rest.remove(nxt)
return seq
def chain_order(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Covering walk (may include all nodes). Prefer spine_backbone for true spine."""
s = set(nodes)
if not s:
return []
sub = {n: [x for x in g.get(n, ()) if x in s] for n in nodes}
ends = [n for n in nodes if len(sub[n]) <= 1] or list(nodes)
start = sorted(ends, key=lambda n: (len(sub[n]), names.get(n, n)))[0]
ordered = [start]
prev = None
cur = start
seen = {start}
while len(ordered) < len(nodes):
nxts = [x for x in sub[cur] if x != prev and x not in seen]
if not nxts:
cand = [v for u in ordered for v in sub[u] if v not in seen]
if not cand:
rest = [n for n in nodes if n not in seen]
if not rest:
break
nxts = [sorted(rest, key=lambda n: names.get(n, n))[0]]
else:
nxts = [sorted(cand, key=lambda n: names.get(n, n))[0]]
prev = None
nxts.sort(key=lambda n: names.get(n, n))
prev, cur = cur, nxts[0]
ordered.append(cur)
seen.add(cur)
return ordered
def spine_backbone(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Longest shortest-path among `nodes` (approx diameter). Side branches stay off-spine."""
s = set(nodes)
if not s:
return []
if len(s) == 1:
return list(s)
def bfs(src: str) -> tuple[dict[str, int], dict[str, str | None]]:
dist = {src: 0}
parent: dict[str, str | None] = {src: None}
q = deque([src])
while q:
u = q.popleft()
for v in g.get(u, ()):
if v not in s or v in dist:
continue
dist[v] = dist[u] + 1
parent[v] = u
q.append(v)
return dist, parent
# eccentricity from an arbitrary end, then from farthest
seed = sorted(nodes, key=lambda n: (len([x for x in g.get(n, ()) if x in s]), names.get(n, n)))[0]
d1, _ = bfs(seed)
a = max(d1, key=lambda n: (d1[n], names.get(n, n)))
d2, parent = bfs(a)
b = max(d2, key=lambda n: (d2[n], names.get(n, n)))
path = [b]
while parent.get(path[-1]) is not None:
path.append(parent[path[-1]] or "")
path.reverse()
return [n for n in path if n]
def build_state_from_nodes_edges(
nodes: list[dict], edges: list[dict]
) -> "LayoutState": # noqa: F821
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
from netx_api.mcp_remote.topology.layout_ops.level_util import _parse_level
names: dict[str, str] = {}
layers: dict[str, str] = {}
levels: dict[str, float] = {}
ids: list[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
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
lv = _parse_level(n.get("level"))
if lv is not None:
levels[fid] = float(lv)
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
for a, b in links:
if a in adj and b in adj:
adj[a].add(b)
adj[b].add(a)
positions: dict[str, tuple[float, float]] = {}
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
if n.get("x") is not None and n.get("y") is not None:
try:
positions[fid] = (float(n["x"]), float(n["y"]))
except (TypeError, ValueError):
pass
return LayoutState(
positions=positions,
names=names,
layers=layers,
levels=levels,
links=[(a, b) for a, b in links if a in adj and b in adj],
adj=adj,
meta={"ids": ids},
)

View file

@ -0,0 +1,591 @@
"""Top-down hierarchy layout: contract → order sectors → expand.
At each hub level we only score crossings on the *contracted* graph
(parent + one representative per child territory). Internal edges of
children are ignored until that child is expanded. Accept/reject is
driven by contracted crossings only — global leaf crossings may rise
temporarily; that is intentional (fix lower levels later).
Expand is a *rigid* polar remapping of each stub territory onto the new
sector angle so local corridor geometry survives the reorder.
"""
from __future__ import annotations
import math
from collections import deque
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, segments_properly_intersect
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:
hx, hy = pos[hub]
x, y = pos[nid]
return math.atan2(y - hy, x - hx)
def _stub_territories(
hub: str,
members: set[str],
adj: dict[str, set[str]],
pinned: set[str],
) -> tuple[list[str], dict[str, str]]:
stubs = [
n
for n in adj.get(hub, ())
if n in members and n not in pinned
]
owner: dict[str, str] = {}
q: deque[str] = deque()
for stub in stubs:
owner[stub] = stub
q.append(stub)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v not in members or v == hub or v in owner or v in pinned:
continue
owner[v] = owner[u]
q.append(v)
return stubs, owner
def _owner_hub_map(
adj: dict[str, set[str]],
hubs: set[str],
) -> dict[str, str]:
"""BFS Voronoi: every node maps to nearest hub (ties: first reached)."""
owner: dict[str, str] = {h: h for h in hubs}
q: deque[str] = deque(hubs)
while q:
u = q.popleft()
ou = owner[u]
for v in adj.get(u, ()):
if v in owner:
continue
owner[v] = ou
q.append(v)
return owner
def _contracted_links(
hub: str,
stubs: list[str],
owner: dict[str, str],
adj: dict[str, set[str]],
pinned: set[str],
*,
hub_of: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Edges among {hub} ∪ stubs after contracting each stub territory.
Foreign endpoints outside this hub's territories collapse to their
owning hub (Voronoi), so cross-block edges are not dropped from the
contracted objective.
"""
terr: dict[str, set[str]] = {s: {s} for s in stubs}
for n, o in owner.items():
terr.setdefault(o, set()).add(n)
node_to_rep: dict[str, str] = {hub: hub}
for s, nodes in terr.items():
for n in nodes:
node_to_rep[n] = s
links: set[tuple[str, str]] = set()
for s in stubs:
links.add(tuple(sorted((hub, s))))
for s, nodes in terr.items():
for n in nodes:
for v in adj.get(n, ()):
if v == hub:
continue
if v in node_to_rep:
r = node_to_rep[v]
if r != s:
links.add(tuple(sorted((s, r))))
elif v in pinned:
links.add(tuple(sorted((s, v))))
elif hub_of is not None and v in hub_of:
fh = hub_of[v]
if fh != hub:
links.add(tuple(sorted((s, fh))))
return sorted(links)
def _contracted_crossings(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]] | None = None,
a0: float | None = None,
a1: float | None = None,
) -> int:
"""Place stub reps on an arc; count crossings of contracted links only."""
hx, hy = hub_xy
n = len(stubs_ord)
if n == 0:
return 0
if a0 is None or a1 is None:
a0, a1 = -math.pi * 0.85, math.pi * 0.85
pos: dict[str, tuple[float, float]] = {hub: (hx, hy)}
if foreign_pos:
pos.update(foreign_pos)
for i, s in enumerate(stubs_ord):
mid = 0.5 * (a0 + a1) if n == 1 else a0 + (a1 - a0) * i / (n - 1)
pos[s] = (hx + math.cos(mid) * radius, hy + math.sin(mid) * radius)
segs = [(a, b) for a, b in links if a in pos and b in pos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = pos[a], pos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
c += 1
return c
def _score_order(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> int:
return _contracted_crossings(
hub,
stubs_ord,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
def _candidate_orders(stubs: list[str], pos: dict[str, tuple[float, float]], hub: str) -> list[list[str]]:
if not stubs:
return []
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
orders: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
def _add(ord_: list[str]) -> None:
key = tuple(ord_)
if key not in seen:
seen.add(key)
orders.append(ord_)
for base in (geo, list(reversed(geo))):
for k in range(len(base)):
_add(base[k:] + base[:k])
# Adjacent swaps from geo.
for i in range(len(geo) - 1):
trial = list(geo)
trial[i], trial[i + 1] = trial[i + 1], trial[i]
_add(trial)
return orders
def _best_order_2opt(
hub: str,
stubs: list[str],
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> tuple[list[str], int]:
"""Greedy 2-opt / insert bakeoff seeded from candidate rotations."""
seeds = _candidate_orders(stubs, pos, hub)
best_ord = sorted(stubs, key=lambda s: _ang(pos, hub, s))
best_cc = _score_order(
hub, best_ord, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
for seed in seeds:
cur = list(seed)
cur_cc = _score_order(
hub, cur, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
improved = True
rounds = 0
while improved and rounds < 12:
improved = False
rounds += 1
n = len(cur)
for i in range(n):
for j in range(i + 2, n + (0 if i == 0 else 1)):
# reverse segment [i:j]
jj = j if j <= n else n
if jj - i < 2:
continue
trial = cur[:i] + list(reversed(cur[i:jj])) + cur[jj:]
if len(trial) != n:
continue
cc = _score_order(
hub,
trial,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
if cc < cur_cc:
cur, cur_cc = trial, cc
improved = True
break
if improved:
break
if cur_cc < best_cc:
best_cc = cur_cc
best_ord = cur
return best_ord, best_cc
def _contracted_crossings_at_pos(
hub: str,
stubs: list[str],
links: list[tuple[str, str]],
pos: dict[str, tuple[float, float]],
foreign_pos: dict[str, tuple[float, float]],
) -> int:
"""Contracted crossings using *actual* stub coordinates as representatives."""
cpos: dict[str, tuple[float, float]] = {hub: pos[hub]}
cpos.update(foreign_pos)
for s in stubs:
if s in pos:
cpos[s] = pos[s]
segs = [(a, b) for a, b in links if a in cpos and b in cpos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = cpos[a], cpos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, cpos[u], cpos[v]):
c += 1
return c
def _swap_territories(
hub: str,
a: str,
b: str,
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
) -> dict[str, tuple[float, float]]:
"""Exchange polar angles of two stub territories around hub (rigid)."""
hx, hy = pos[hub]
aa, ab = _ang(pos, hub, a), _ang(pos, hub, b)
delta = ab - aa
trial = dict(pos)
ta = {n for n, o in owner.items() if o == a} | {a}
tb = {n for n, o in owner.items() if o == b} | {b}
for n in ta:
if n not in pos or (n in pinned and n != a):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) + delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
for n in tb:
if n not in pos or (n in pinned and n != b):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) - delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
trial[hub] = (hx, hy)
return trial
def _realize_order_by_swaps(
hub: str,
stubs: list[str],
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
clinks: list[tuple[str, str]],
foreign: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
target_ord: list[str] | None = None,
allow_global_rise: bool = False,
) -> tuple[dict[str, tuple[float, float]], int, int]:
"""Greedy adjacent territory swaps; primary score = contracted@pos.
Search is driven by contracted crossings (lower-level edges ignored in
the objective). On an already-laid canvas (stage-2 polish) we also refuse
swaps that raise *global* crossings — otherwise leaf noise swamps the
map before lower levels can be re-expanded. Set allow_global_rise for
true build-from-skeleton top-down.
"""
trial = dict(pos)
before = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g_base = count_edge_crossings(trial, links)
target_idx = {s: i for i, s in enumerate(target_ord)} if target_ord else {}
for _ in range(max(2 * len(stubs), 8)):
geo = sorted(stubs, key=lambda s: _ang(trial, hub, s))
pair_idxs = list(range(len(geo) - 1))
if target_idx:
inv = [
i
for i in pair_idxs
if target_idx.get(geo[i], 0) > target_idx.get(geo[i + 1], 0)
]
pair_idxs = inv + [i for i in pair_idxs if i not in inv]
progressed = False
c0 = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g0 = count_edge_crossings(trial, links)
# Pick the adjacent swap with best contracted drop; tie-break by global.
best_cand = None
best_key = None
for i in pair_idxs:
cand = _swap_territories(hub, geo[i], geo[i + 1], owner, pinned, trial)
for h in pinned:
if h in pos:
cand[h] = pos[h]
cand[hub] = pos[hub]
c1 = _contracted_crossings_at_pos(hub, stubs, clinks, cand, foreign)
if c1 >= c0:
continue
g1 = count_edge_crossings(cand, links)
if not allow_global_rise and g1 > g0:
continue
key = (c0 - c1, g0 - g1)
if best_key is None or key > best_key:
best_key = key
best_cand = cand
if best_cand is not None:
trial = best_cand
progressed = True
if not progressed:
break
after = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
del g_base
return trial, before, after
def hierarchy_sectors_greedy(
state: LayoutState,
params: LayoutParams | None = None,
*,
step: float | None = None,
max_hubs: int = 20,
allow_global_rise: bool = False,
) -> OpResult:
"""Top-down: contract → order by contracted crossings → rigid expand.
Role beam offsets (pin_beam) belong *after* this pass, not inside it.
Default stage-2 polish keeps global from rising; pass allow_global_rise
for skeleton-time top-down where leaf crossings are deferred.
"""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch * 0.95, 170.0))
pos = dict(state.positions)
links = list(state.links)
adj = state.adj
global0 = count_edge_crossings(pos, links)
hubs = pick_hub_seeds(state, max_hubs=max_hubs)
cores = [h for h in hubs if state.layers.get(h) == "core"]
aggs = [h for h in hubs if state.layers.get(h) == "agg"]
# Top-down: cores first, then aggs, then remaining high-degree hubs.
order_hubs = list(cores) + [h for h in aggs if h not in cores]
for h in hubs:
if h not in order_hubs:
order_hubs.append(h)
pinned = set(hubs)
hub_of = _owner_hub_map(adj, pinned)
level_notes: list[dict[str, Any]] = []
any_level = False
contracted_gain = 0
for hub in order_hubs:
if hub not in pos:
continue
members = {hub}
q: deque[str] = deque([hub])
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v in members:
continue
if v in pinned and v != hub:
continue
members.add(v)
q.append(v)
for v in adj.get(hub, ()):
if v not in pinned:
members.add(v)
stubs, owner = _stub_territories(hub, members, adj, pinned)
if len(stubs) < 2:
continue
clinks = _contracted_links(
hub, stubs, owner, adj, pinned, hub_of=hub_of
)
foreign = {
n: pos[n]
for n in pinned
if n != hub and n in pos and any(n in e for e in clinks)
}
angs = [_ang(pos, hub, s) for s in stubs]
a0, a1 = min(angs) - 0.15, max(angs) + 0.15
if a1 - a0 < 0.8:
mid = sum(angs) / len(angs)
a0, a1 = mid - 1.1, mid + 1.1
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
base_cc = _score_order(
hub,
geo,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
best_ord, best_cc = _best_order_2opt(
hub,
stubs,
pos,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
# Abstract arc bakeoff only proposes a target cyclic order.
if best_cc >= base_cc:
continue
# Realize via adjacent territory swaps; accept on contracted@pos only.
trial, c_before, c_after = _realize_order_by_swaps(
hub,
stubs,
owner,
pinned,
pos,
clinks,
foreign,
links,
target_ord=best_ord,
allow_global_rise=allow_global_rise,
)
if c_after >= c_before:
continue
for h in pinned:
if h in pos:
trial[h] = pos[h]
trial[hub] = pos[hub]
# No per-level overlap fix — it fights sector moves and is slow on
# large canvases. Zero-overlap is ensured by the layout_tool wrapper.
g1 = count_edge_crossings(trial, links)
pos = trial
any_level = True
contracted_gain += c_before - c_after
level_notes.append(
{
"hub_id": hub,
"hub_name": state.names.get(hub, hub),
"stubs": len(stubs),
"contracted_before": c_before,
"contracted_after": c_after,
"contracted_arc_before": base_cc,
"contracted_arc_after": best_cc,
"global_after": g1,
}
)
if not any_level:
return OpResult(
state=state,
moved=set(),
op="hierarchy_sectors",
params={"accepted_n": 0, "contracted_gain": 0},
note="no_level_improved",
)
# One end-of-pass overlap polish (hubs stay pinned).
st_end = state.copy()
st_end.positions = pos
st_end = fix_overlaps_local(st_end, params).state
for h in pinned:
if h in pos:
st_end.positions[h] = pos[h]
pos = {k: (float(v[0]), float(v[1])) for k, v in st_end.positions.items()}
out = state.copy()
out.positions = pos
moved = {
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
}
g_end = count_edge_crossings(pos, links)
out.meta = dict(out.meta or {})
out.meta["hierarchy_sectors"] = {
"levels": level_notes,
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
}
return OpResult(
state=out,
moved=moved,
op="hierarchy_sectors",
params={
"accepted_n": len(level_notes),
"levels": level_notes[:12],
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
},
note=(
f"hierarchy_sectors:{len(level_notes)} levels "
f"contractedΔ={contracted_gain} x:{global0}->{g_end}"
),
)
def hierarchy_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("max_hubs") is not None:
try:
out["max_hubs"] = int(overrides["max_hubs"])
except (TypeError, ValueError):
pass
if "allow_global_rise" in overrides:
out["allow_global_rise"] = bool(overrides.get("allow_global_rise"))
return out

View file

@ -0,0 +1,380 @@
"""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)}",
)

View file

@ -0,0 +1,209 @@
"""Map fabric level / role / name → layout layer key; level y-bands."""
from __future__ import annotations
import math
import re
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_ROLE_TO_LAYER = {
"external": "external",
"core": "core",
"cn": "core",
"aggregation": "agg",
"aggregate": "agg",
"agg": "agg",
"an": "agg",
"access": "access",
"en": "access",
"edge": "access",
"cpe": "access",
}
# Top → bottom on canvas (smaller fabric level sits higher).
_BAND_ORDER = ("external", "core", "agg", "access", "other")
def _parse_level(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, str):
s = value.strip()
if not s:
return None
try:
value = float(s)
except ValueError:
return None
try:
lv = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(lv):
return None
return lv
def infer_layer(
name: str,
role: str | None = None,
level: float | None = None,
) -> str:
"""Map level / role / name token → external|core|agg|access|other."""
lv = _parse_level(level)
if lv is not None:
maj = int(math.floor(lv))
if maj <= 0:
return "external"
if maj == 1:
return "core"
if maj == 2:
return "agg"
return "access"
r = str(role or "").strip().lower()
if r in _ROLE_TO_LAYER:
return _ROLE_TO_LAYER[r]
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
if not m:
return "other"
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
def apply_level_bands(
state: LayoutState,
params: LayoutParams | None = None,
*,
y0: float = 120.0,
band_gap: float = 320.0,
pitch: float | None = None,
preserve_x: bool = True,
layers: tuple[str, ...] | None = None,
max_per_row: int = 24,
row_gap: float | None = None,
) -> OpResult:
"""Snap nodes into horizontal bands by layer (external→…→access).
Large bands wrap into multiple rows (``max_per_row``) so hundreds of
access nodes are not crushed onto one y-line. Within a row, x is either
kept (preserve_x) then gently de-overlapped to ``pitch``, or re-spaced
by name order.
"""
params = params or LayoutParams()
step = float(pitch if pitch is not None else max(params.pitch, 200.0))
rgap = float(row_gap if row_gap is not None else max(step * 0.85, 170.0))
per_row = max(4, int(max_per_row or 24))
order = tuple(layers) if layers else _BAND_ORDER
by: dict[str, list[str]] = {ly: [] for ly in order}
for nid in state.positions:
ly = state.layers.get(nid) or "other"
if ly not in by:
ly = "other"
by.setdefault(ly, [])
by[ly].append(nid)
pos = dict(state.positions)
moved: set[str] = set()
band_notes: list[dict[str, Any]] = []
y_cursor = float(y0)
def _place_row(ids: list[str], y: float) -> None:
nonlocal moved
if not ids:
return
if preserve_x:
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
xs = [float(pos[n][0]) for n in ids]
# Enforce min pitch left→right without reversing order.
fixed: list[float] = []
for i, x in enumerate(xs):
if i == 0:
fixed.append(x)
else:
fixed.append(max(x, fixed[-1] + step))
for n, x in zip(ids, fixed):
nxt = (x, y)
if nxt != pos[n]:
moved.add(n)
pos[n] = nxt
else:
ids = sorted(ids, key=lambda n: state.names.get(n, n))
for i, n in enumerate(ids):
nxt = (40.0 + i * step, y)
if nxt != pos.get(n):
moved.add(n)
pos[n] = nxt
for ly in order:
ids = by.get(ly) or []
if not ids:
continue
if preserve_x:
ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n)))
else:
ids = sorted(ids, key=lambda n: state.names.get(n, n))
rows = [ids[i : i + per_row] for i in range(0, len(ids), per_row)]
y_band0 = y_cursor
for ri, row in enumerate(rows):
_place_row(row, y_cursor + ri * rgap)
band_h = max(0, len(rows) - 1) * rgap
band_notes.append(
{
"layer": ly,
"count": len(ids),
"y": y_band0,
"rows": len(rows),
"y_max": y_band0 + band_h,
}
)
y_cursor = y_band0 + band_h + float(band_gap)
out = state.copy()
out.positions = pos
out.meta = dict(out.meta or {})
out.meta["level_bands"] = {
"bands": band_notes,
"preserve_x": preserve_x,
"max_per_row": per_row,
}
return OpResult(
state=out,
moved=moved,
op="level_bands",
params={
"bands": band_notes,
"preserve_x": preserve_x,
"y0": y0,
"band_gap": band_gap,
"pitch": step,
"max_per_row": per_row,
"moved_n": len(moved),
},
note=f"level_bands:{len(band_notes)} bands moved={len(moved)}",
)
def level_bands_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
for key, cast in (
("y0", float),
("band_gap", float),
("pitch", float),
("row_gap", float),
("max_per_row", int),
):
if overrides.get(key) is not None:
try:
out[key] = cast(overrides[key])
except (TypeError, ValueError):
pass
if "preserve_x" in overrides:
out["preserve_x"] = bool(overrides.get("preserve_x"))
raw = overrides.get("layers")
if isinstance(raw, (list, tuple)) and raw:
out["layers"] = tuple(str(x) for x in raw if str(x).strip())
return out

View file

@ -0,0 +1,591 @@
"""Per-node / per-edge attract+repulse mass field for soft compose merge.
Replaces uniform exclusive-island rigidity: cores pull hard, rings hold shape,
chains are tearable and may evolve into rings (raising mass).
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
# role -> (attract, repulse, mass)
_ROLE_NODE: dict[str, tuple[float, float, float]] = {
"core": (4.0, 1.8, 3.0),
"ring": (2.4, 1.2, 1.8),
"chain": (0.7, 0.6, 0.5),
"free": (1.0, 1.0, 1.0),
}
_ROLE_EDGE: dict[str, tuple[float, float]] = {
"ring": (2.6, 1.0),
"chain": (0.5, 0.4),
"bridge": (1.2, 0.8),
"plain": (1.0, 1.0),
}
_ROLE_RANK = {"free": 0, "chain": 1, "ring": 2, "core": 3}
_STAB = {"chain": 0.35, "ring": 1.2, "core": 3.0, "free": 0.7}
def _edge_key(a: str, b: str) -> str:
return f"{a}|{b}" if a <= b else f"{b}|{a}"
def _nest_boost(path_count: int) -> float:
extra = max(0, int(path_count) - 2)
return 1.0 + 0.15 * float(extra)
def annotate_dual_unit(unit: DualUnit) -> dict[str, Any]:
"""Role tags for one DualUnit (portals→core, paths→ring, tails→chain)."""
nest = max(0, len(unit.paths))
boost = _nest_boost(nest)
nodes: dict[str, dict[str, Any]] = {}
edges: dict[str, dict[str, Any]] = {}
def _set_node(nid: str, role: str) -> None:
cur = nodes.get(nid)
if cur is None or _ROLE_RANK.get(role, 0) > _ROLE_RANK.get(
str(cur.get("role") or "free"), 0
):
a, r, m = _ROLE_NODE[role]
if role == "ring":
a *= boost
m *= 1.0 + 0.08 * max(0, nest - 2)
nodes[nid] = {
"role": role,
"attract": round(a, 4),
"repulse": round(r, 4),
"mass": round(m, 4),
"unit_id": unit.unit_id,
}
def _set_edge(u: str, v: str, role: str) -> None:
if u == v:
return
key = _edge_key(u, v)
cur = edges.get(key)
if cur is None or _ROLE_RANK.get(role, 0) > _ROLE_RANK.get(
str(cur.get("role") or "plain"), 0
):
ae, re = _ROLE_EDGE[role]
if role == "ring":
ae *= boost
edges[key] = {
"role": role,
"attract": round(ae, 4),
"repulse": round(re, 4),
"a": u if u <= v else v,
"b": v if u <= v else u,
}
for p in (unit.portal_a, unit.portal_b):
_set_node(p, "core")
for path in unit.paths:
for i, n in enumerate(path):
if n in (unit.portal_a, unit.portal_b):
_set_node(n, "core")
else:
_set_node(n, "ring")
if i + 1 < len(path):
_set_edge(path[i], path[i + 1], "ring")
for chain in unit.tails:
prev = None
# Attach edge from nearest portal/core if chain starts at neighbor — handled
# when full graph links are known; here tag chain nodes + consecutive edges.
for n in chain:
_set_node(n, "chain")
if prev is not None:
_set_edge(prev, n, "chain")
prev = n
return {
"unit_id": unit.unit_id,
"nest_depth": nest,
"portal_a": unit.portal_a,
"portal_b": unit.portal_b,
"nodes": nodes,
"edges": edges,
}
def merge_mass_dicts(
*parts: dict[str, dict[str, Any]],
rank_key: str = "role",
rank_map: dict[str, int] | None = None,
) -> dict[str, dict[str, Any]]:
ranks = rank_map or _ROLE_RANK
out: dict[str, dict[str, Any]] = {}
for part in parts:
for k, row in part.items():
cur = out.get(k)
if cur is None or ranks.get(str(row.get(rank_key) or ""), 0) > ranks.get(
str(cur.get(rank_key) or ""), 0
):
out[k] = dict(row)
return out
def _degree_tweak(
state: LayoutState,
nodes: dict[str, dict[str, Any]],
) -> None:
for n, row in nodes.items():
deg = len(state.adj.get(n, ()))
layer = str(state.layers.get(n) or "")
m = float(row.get("mass") or 1.0)
a = float(row.get("attract") or 1.0)
if layer in ("core", "agg") or deg >= 8:
m *= 1.15
a *= 1.08
elif deg <= 2 and str(row.get("role")) == "chain":
m *= 0.92
a *= 0.95
row["mass"] = round(m, 4)
row["attract"] = round(a, 4)
def build_mass_field(
state: LayoutState,
*,
units: list[DualUnit] | None = None,
groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build mass_field from dual units (+ optional compose groups).
Pass ``units=[]`` to skip dual-unit detection (plain FA2 mass tags only).
"""
found = units if units is not None else find_dual_portal_units(state)
node_parts: list[dict[str, dict[str, Any]]] = []
edge_parts: list[dict[str, dict[str, Any]]] = []
unit_rows: list[dict[str, Any]] = []
for u in found:
ann = annotate_dual_unit(u)
unit_rows.append(
{
"unit_id": ann["unit_id"],
"nest_depth": ann["nest_depth"],
"portal_a": ann["portal_a"],
"portal_b": ann["portal_b"],
}
)
node_parts.append(ann["nodes"])
edge_parts.append(ann["edges"])
nodes = merge_mass_dicts(*node_parts) if node_parts else {}
edges = merge_mass_dicts(
*edge_parts, rank_key="role", rank_map={"plain": 0, "bridge": 1, "chain": 2, "ring": 3}
) if edge_parts else {}
# Tag remaining nodes + mark fabric bridges between different group homes.
home = home_map_from_groups(groups or [])
for n in state.positions:
if n not in nodes:
nodes[n] = {
"role": "free",
"attract": 1.0,
"repulse": 1.0,
"mass": 1.0,
}
for a, b in state.links:
key = _edge_key(a, b)
if key in edges:
continue
ha, hb = home.get(a), home.get(b)
role = "bridge" if ha and hb and ha != hb else "plain"
ae, re = _ROLE_EDGE[role]
edges[key] = {
"role": role,
"attract": ae,
"repulse": re,
"a": a if a <= b else b,
"b": b if a <= b else a,
}
_degree_tweak(state, nodes)
soft_groups = []
for g in groups or []:
key = str(g.get("key") or "")
members = [str(x) for x in (g.get("node_ids") or g.get("members") or [])]
pivots = [str(x) for x in (g.get("pivots") or [])]
cores = [
n
for n in members
if str((nodes.get(n) or {}).get("role")) == "core" or n in pivots
]
if not cores:
cores = list(pivots)
soft_groups.append(
{
"key": key,
"node_ids": members,
"pivots": pivots,
"cores": cores,
"soft": True,
}
)
return {
"nodes": nodes,
"edges": edges,
"units": unit_rows,
"groups": soft_groups,
"tip": (
"mass_field: core/ring/chain attract+repulse; use mass_merge "
"(not exclusive rigid islands) after compose"
),
}
def home_map_from_groups(groups: list[dict[str, Any]]) -> dict[str, str]:
"""First group wins for multi-membership; portals often multi-home."""
home: dict[str, str] = {}
counts: dict[str, int] = {}
for g in groups:
key = str(g.get("key") or "")
for n in g.get("node_ids") or g.get("members") or []:
nid = str(n)
counts[nid] = counts.get(nid, 0) + 1
if nid not in home:
home[nid] = key
# Shared portals: keep first home but mark multi via absence of exclusive.
return home
def group_effective_mass(
group: dict[str, Any],
nodes: dict[str, dict[str, Any]],
) -> float:
cores = list(group.get("cores") or group.get("pivots") or [])
if not cores:
# Fall back to top attract members.
members = [str(x) for x in (group.get("node_ids") or [])]
members.sort(
key=lambda n: -float((nodes.get(n) or {}).get("attract") or 0.0)
)
cores = members[:2]
total = 0.0
for c in cores:
row = nodes.get(c) or {}
total += float(row.get("mass") or 1.0) * float(row.get("attract") or 1.0)
return max(total, 0.1)
def core_centroid(
group: dict[str, Any],
pos: dict[str, tuple[float, float]],
nodes: dict[str, dict[str, Any]],
) -> tuple[float, float] | None:
cores = [c for c in (group.get("cores") or group.get("pivots") or []) if c in pos]
if not cores:
members = [n for n in (group.get("node_ids") or []) if n in pos]
if not members:
return None
members.sort(
key=lambda n: -float((nodes.get(n) or {}).get("attract") or 0.0)
)
cores = members[:3]
if not cores:
return None
cx = sum(pos[c][0] for c in cores) / len(cores)
cy = sum(pos[c][1] for c in cores) / len(cores)
return (cx, cy)
def evolve_chains_to_rings(
state: LayoutState,
mass: dict[str, Any],
) -> dict[str, Any]:
"""Promote chain nodes that sit on newly detected dual-portal corridors."""
units = find_dual_portal_units(state)
nodes = dict(mass.get("nodes") or {})
edges = dict(mass.get("edges") or {})
promoted_n = 0
promoted_e = 0
for u in units:
ann = annotate_dual_unit(u)
for nid, row in ann["nodes"].items():
if str(row.get("role")) != "ring":
continue
cur = nodes.get(nid) or {}
if str(cur.get("role")) == "chain":
nodes[nid] = row
promoted_n += 1
elif nid not in nodes or _ROLE_RANK.get(
str(row.get("role")), 0
) > _ROLE_RANK.get(str(cur.get("role") or "free"), 0):
nodes[nid] = row
for key, row in ann["edges"].items():
if str(row.get("role")) != "ring":
continue
cur = edges.get(key) or {}
if str(cur.get("role")) == "chain":
edges[key] = row
promoted_e += 1
elif key not in edges:
edges[key] = row
_degree_tweak(state, nodes)
out = dict(mass)
out["nodes"] = nodes
out["edges"] = edges
out["evolve"] = {
"promoted_nodes": promoted_n,
"promoted_edges": promoted_e,
"units_n": len(units),
}
return out
def capture_pass(
state: LayoutState,
mass: dict[str, Any],
*,
kappa_node: float = 1.25,
kappa_block: float = 2.0,
rho_ideal: float = 6.0,
ideal_len: float = 540.0,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Rewrite soft group membership: steal nodes / whole weak blocks."""
groups = [dict(g) for g in (mass.get("groups") or [])]
if len(groups) < 2:
return mass, {"stolen_nodes": 0, "stolen_blocks": 0}
nodes = mass.get("nodes") or {}
pos = state.positions
# Rebuild member lists as sets
by_key: dict[str, dict[str, Any]] = {}
for g in groups:
key = str(g.get("key") or "")
g["node_ids"] = [str(x) for x in (g.get("node_ids") or [])]
g["_set"] = set(g["node_ids"])
by_key[key] = g
masses = {k: group_effective_mass(g, nodes) for k, g in by_key.items()}
cents = {k: core_centroid(g, pos, nodes) for k, g in by_key.items()}
# Home: exclusive preferred (membership count==1)
count: dict[str, int] = {}
for g in groups:
for n in g["node_ids"]:
count[n] = count.get(n, 0) + 1
home: dict[str, str] = {}
for g in groups:
k = str(g.get("key") or "")
for n in g["node_ids"]:
if count.get(n, 0) == 1:
home[n] = k
elif n not in home:
home[n] = k
stolen_nodes = 0
for n, gkey in list(home.items()):
if count.get(n, 0) > 1:
continue # shared portals stay
role = str((nodes.get(n) or {}).get("role") or "free")
if role == "core":
continue
if n not in pos:
continue
nx, ny = pos[n]
hold = masses.get(gkey, 0.1) * _STAB.get(role, 0.7)
best_h = None
best_pull = 0.0
for hk, hc in cents.items():
if hk == gkey or hc is None:
continue
d = math.hypot(nx - hc[0], ny - hc[1])
pull = masses.get(hk, 0.1) / (d + 1.0)
if pull > best_pull:
best_pull = pull
best_h = hk
if best_h is None:
continue
if best_pull > kappa_node * max(hold, 1e-6):
# Move membership
by_key[gkey]["_set"].discard(n)
by_key[best_h]["_set"].add(n)
home[n] = best_h
stolen_nodes += 1
stolen_blocks = 0
# Block capture: weak whole group re-parented (merge members into strong).
keys = list(by_key.keys())
for i, wk in enumerate(keys):
for sk in keys[i + 1 :]:
for weak, strong in ((wk, sk), (sk, wk)):
mw, ms = masses.get(weak, 0.1), masses.get(strong, 0.1)
if ms < kappa_block * mw:
continue
cw, cs = cents.get(weak), cents.get(strong)
if cw is None or cs is None:
continue
dist = math.hypot(cw[0] - cs[0], cw[1] - cs[1])
if dist > rho_ideal * ideal_len:
continue
# Contact: any bridge edge or shared portal
contact = False
wset = by_key[weak]["_set"]
sset = by_key[strong]["_set"]
if wset & sset:
contact = True
else:
for a, b in state.links:
if (a in wset and b in sset) or (b in wset and a in sset):
contact = True
break
if not contact:
continue
# Absorb exclusive weak members into strong (keep soft parent key
# on strong; clear weak exclusive into strong set).
moved = [n for n in list(wset) if count.get(n, 0) == 1]
if len(moved) < 2:
continue
for n in moved:
by_key[weak]["_set"].discard(n)
by_key[strong]["_set"].add(n)
home[n] = strong
stolen_blocks += 1
masses[strong] = group_effective_mass(by_key[strong], nodes)
masses[weak] = group_effective_mass(by_key[weak], nodes)
cents[strong] = core_centroid(by_key[strong], pos, nodes)
cents[weak] = core_centroid(by_key[weak], pos, nodes)
break
new_groups = []
for g in groups:
key = str(g.get("key") or "")
members = sorted(by_key[key]["_set"])
pivots = [p for p in (g.get("pivots") or []) if p in by_key[key]["_set"]]
cores = [
n
for n in members
if str((nodes.get(n) or {}).get("role")) == "core" or n in pivots
]
new_groups.append(
{
"key": key,
"node_ids": members,
"pivots": pivots,
"cores": cores or pivots,
"soft": True,
}
)
out = dict(mass)
out["groups"] = new_groups
report = {
"stolen_nodes": stolen_nodes,
"stolen_blocks": stolen_blocks,
"kappa_node": kappa_node,
"kappa_block": kappa_block,
}
return out, report
def geo_score(
state: LayoutState,
mass: dict[str, Any],
) -> float:
"""Higher is better: ring edge length consistency + core compactness."""
nodes = mass.get("nodes") or {}
edges = mass.get("edges") or {}
pos = state.positions
ring_lens: list[float] = []
chain_lens: list[float] = []
for key, row in edges.items():
a, b = str(row.get("a") or ""), str(row.get("b") or "")
if a not in pos or b not in pos:
# key form
if "|" in key:
a, b = key.split("|", 1)
else:
continue
if a not in pos or b not in pos:
continue
L = math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
role = str(row.get("role") or "plain")
if role == "ring":
ring_lens.append(L)
elif role == "chain":
chain_lens.append(L)
score = 0.0
if len(ring_lens) >= 2:
mean = sum(ring_lens) / len(ring_lens)
var = sum((x - mean) ** 2 for x in ring_lens) / len(ring_lens)
score += 100.0 / (1.0 + math.sqrt(var) / max(mean, 1.0))
elif ring_lens:
score += 40.0
# Penalize very long chains (should be tearable / reattached).
if chain_lens:
mean_c = sum(chain_lens) / len(chain_lens)
score -= min(40.0, mean_c / 80.0)
# Core neighborhood compactness
cores = [n for n, r in nodes.items() if str(r.get("role")) == "core" and n in pos]
if len(cores) >= 2:
cx = sum(pos[n][0] for n in cores) / len(cores)
cy = sum(pos[n][1] for n in cores) / len(cores)
spread = sum(math.hypot(pos[n][0] - cx, pos[n][1] - cy) for n in cores) / len(
cores
)
score += 80.0 / (1.0 + spread / 400.0)
return float(score)
def attach_mass_to_compose_meta(
meta: dict[str, Any],
mass: dict[str, Any],
) -> dict[str, Any]:
"""Write mass_groups + mass_field onto compose meta (keep rigid_groups)."""
out = dict(meta)
groups = mass.get("groups") or []
if not groups and out.get("rigid_groups"):
groups = [
{**g, "soft": True, "cores": list(g.get("pivots") or [])}
for g in out["rigid_groups"]
if isinstance(g, dict)
]
mass = dict(mass)
mass["groups"] = groups
out["mass_groups"] = groups
out["mass_field"] = {
"nodes_n": len(mass.get("nodes") or {}),
"edges_n": len(mass.get("edges") or {}),
"units": mass.get("units") or [],
"tip": mass.get("tip"),
}
# Full field stored at state.meta["mass_field"] by caller.
out["soft"] = True
return out
def groups_from_mass_or_rigid(meta: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Prefer soft mass_groups, fall back to rigid_groups."""
if not meta:
return []
raw = meta.get("mass_groups") or meta.get("rigid_groups") or []
out: list[dict[str, Any]] = []
for g in raw:
if not isinstance(g, dict):
continue
nodes = [str(x) for x in (g.get("node_ids") or []) if str(x)]
if len(nodes) < 2:
continue
pivots = [str(x) for x in (g.get("pivots") or []) if str(x)]
out.append(
{
"key": str(g.get("key") or ""),
"node_ids": nodes,
"pivots": pivots,
"cores": [str(x) for x in (g.get("cores") or pivots) if str(x)],
"soft": bool(g.get("soft", True)),
}
)
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,372 @@
"""Dual-hub minimal-ring layering (PLAU↔ATP style).
When two aggregation hubs share ≥2 nearly interior-disjoint corridors,
nest those paths on alternating ellipse bands and park leftover side
chains in free left/right sectors. Preferable to per-AN petal pack for
agg_bar metro rings (0-crossing when the graph is path-planar).
"""
from __future__ import annotations
import math
from collections import defaultdict, deque
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _all_simple_paths(
src: str,
dst: str,
adj: dict[str, set[str]],
*,
cutoff: int = 16,
forbid: set[str] | None = None,
) -> list[list[str]]:
forbid = set(forbid or ())
paths: list[list[str]] = []
stack: list[tuple[str, list[str]]] = [(src, [src])]
while stack:
u, path = stack.pop()
if len(path) > cutoff:
continue
for v in adj.get(u, ()):
if v in path or v in forbid:
continue
np = path + [v]
if v == dst:
paths.append(np)
else:
stack.append((v, np))
return paths
def _path_core(path: list[str]) -> set[str]:
"""Exclusive midpoints (drop portal-adjacent hops that rings often share)."""
mid = path[1:-1]
if not mid:
return set()
if len(mid) <= 2:
return set(mid)
return set(mid[1:-1])
def cover_hub_paths(
hub_a: str,
hub_b: str,
adj: dict[str, set[str]],
names: dict[str, str],
*,
cutoff: int = 16,
forbid: set[str] | None = None,
) -> list[list[str]]:
"""Greedy path cover between hubs; cores disjoint (portal-adjacent may share)."""
paths = _all_simple_paths(hub_a, hub_b, adj, cutoff=cutoff, forbid=forbid)
paths = [p for p in paths if p[1:-1]] # skip direct hub-hub
paths.sort(key=lambda p: (len(p), "".join(names.get(x, x) for x in p)))
picked: list[list[str]] = []
used_core: set[str] = set()
for p in paths:
core = _path_core(p)
if not core or core & used_core:
continue
picked.append(p)
used_core |= core
picked.sort(key=len)
return picked
def pick_dual_hubs(state: LayoutState) -> tuple[str, str] | None:
"""Top two agg (else core) hubs by degree; need both present in graph."""
cand = [
n
for n, ly in state.layers.items()
if ly == "agg" and n in state.adj
]
if len(cand) < 2:
cand = [
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n in state.adj
]
if len(cand) < 2:
return None
cand.sort(
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n))
)
a, b = cand[0], cand[1]
if a == b:
return None
# Prefer name-stable left/right
if state.names.get(a, a) > state.names.get(b, b):
a, b = b, a
return a, b
def min_rings_eligible(
state: LayoutState,
*,
min_paths: int = 2,
min_cover_frac: float = 0.35,
) -> dict[str, Any] | None:
"""Return plan dict if dual-hub path cover is worth running."""
hubs = pick_dual_hubs(state)
if not hubs:
return None
hub_a, hub_b = hubs
# Ignore other high-layer nodes as path interiors (orphan ANs park later).
other_hubs = {
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n not in (hub_a, hub_b)
}
paths = cover_hub_paths(
hub_a, hub_b, state.adj, state.names, forbid=other_hubs
)
if len(paths) < min_paths:
return None
used = {n for p in paths for n in p[1:-1]}
access = {n for n, ly in state.layers.items() if ly == "access"}
if not access:
return None
cover = len(used & access) / max(1, len(access))
# Low cover with many paths is still a star/hub petal job (e.g. BTM),
# not dual-hub corridor nesting — fall through to ume_petals.
if cover < min_cover_frac:
return None
return {
"hub_a": hub_a,
"hub_b": hub_b,
"paths": paths,
"cover_frac": cover,
"path_count": len(paths),
}
def _comps(nodes: set[str], adj: dict[str, set[str]], names: dict[str, str]) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for n in sorted(nodes, key=lambda x: names.get(x, x)):
if n in seen:
continue
q = deque([n])
seen.add(n)
c: list[str] = []
while q:
u = q.popleft()
c.append(u)
for v in adj.get(u, ()):
if v in nodes and v not in seen:
seen.add(v)
q.append(v)
out.append(c)
return out
def _attach_of(
comp: list[str], on: set[str], adj: dict[str, set[str]], fallback: str
) -> tuple[str, str]:
for n in comp:
for v in adj.get(n, ()):
if v in on:
return v, n
return fallback, comp[0]
def _order_chain(
comp: list[str], start: str, adj: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
s = set(comp)
if start not in s:
start = sorted(comp, key=lambda n: names.get(n, n))[0]
ordered = [start]
seen = {start}
prev = None
cur = start
while len(ordered) < len(comp):
nbs = [v for v in adj.get(cur, ()) if v in s and v not in seen]
if not nbs:
rest = [n for n in comp if n not in seen]
if not rest:
break
cur = sorted(rest, key=lambda n: names.get(n, n))[0]
ordered.append(cur)
seen.add(cur)
prev = None
continue
nbs.sort(
key=lambda v: (
len([x for x in adj.get(v, ()) if x in s]),
names.get(v, v),
)
)
if prev in nbs and len(nbs) > 1:
nbs = [v for v in nbs if v != prev] + [prev]
prev, cur = cur, nbs[0]
ordered.append(cur)
seen.add(cur)
return ordered
def layout_min_rings_positions(
state: LayoutState,
params: LayoutParams,
plan: dict[str, Any] | None = None,
) -> dict[str, tuple[float, float]] | None:
"""Compute positions; None if plan ineligible."""
plan = plan or min_rings_eligible(state)
if not plan:
return None
hub_a = str(plan["hub_a"])
hub_b = str(plan["hub_b"])
paths: list[list[str]] = list(plan["paths"]) # type: ignore[arg-type]
adj, names, layers = state.adj, state.names, state.layers
rx = max(params.an_gap * 2.5, params.pitch * 7.0, 1100.0)
ry_step = max(params.side * 1.6, params.lane * 0.9, 280.0)
pitch = max(params.pitch * 0.8, 160.0)
base_r = max(params.side * 1.3, 220.0)
# Prefer denser bands when graph is small.
n_access = sum(1 for ly in layers.values() if ly == "access")
if n_access <= 55:
ry_step = min(ry_step, 320.0)
pitch = min(pitch, 170.0)
pos: dict[str, tuple[float, float]] = {
hub_a: (-rx, 0.0),
hub_b: (rx, 0.0),
}
# Nodes that appear as portal-adjacent on ≥2 paths sit on the hub chord.
hop_count: dict[str, int] = defaultdict(int)
for p in paths:
mid = p[1:-1]
if not mid:
continue
hop_count[mid[0]] += 1
if len(mid) > 1:
hop_count[mid[-1]] += 1
shared_hops = {n for n, c in hop_count.items() if c >= 2}
used: set[str] = set()
for i, p in enumerate(paths):
side = 1 if i % 2 == 0 else -1
ry = ry_step * ((i // 2) + 1)
m = len(p)
for j, n in enumerate(p):
if n in (hub_a, hub_b):
continue
t = j / (m - 1) if m > 1 else 0.5
if n in shared_hops:
# Keep on axis so corridor arcs do not cross the hub chord.
if n not in pos:
pos[n] = (-rx + 2 * rx * t, 0.0)
used.add(n)
continue
ang = math.pi * (1 - t)
if side < 0:
ang = -ang
pos[n] = (rx * math.cos(ang), ry * math.sin(ang))
used.add(n)
on = used | {hub_a, hub_b}
# Orphan hubs (other agg/core) + leftover access comps → free sectors
leftovers = {n for n in names if n not in on}
orphan_hubs = [
n
for n in leftovers
if layers.get(n) in ("agg", "core")
]
left_access = leftovers - set(orphan_hubs)
lcomps = _comps(left_access, adj, names)
by_hub: dict[str, list[tuple[list[str], str]]] = defaultdict(list)
for c in lcomps:
att, seed = _attach_of(c, on, adj, hub_a)
by_hub[att].append((c, seed))
left_angs = [math.pi, 2.55, 3.55, -2.55]
right_angs = [0.0, 0.5, -0.5, 0.95]
for hub, groups in by_hub.items():
groups = sorted(groups, key=lambda g: -len(g[0]))
if hub == hub_a:
angs = left_angs
elif hub == hub_b:
angs = right_angs
else:
angs = None
hx, hy = pos.get(hub, (0.0, 0.0))
for gi, (comp, seed) in enumerate(groups):
if angs is not None:
ang = angs[gi % len(angs)]
else:
ang = math.atan2(hy, hx or 1e-9) + (gi - (len(groups) - 1) / 2) * 0.25
for i, n in enumerate(_order_chain(comp, seed, adj, names)):
r = base_r + i * pitch
pos[n] = (hx + r * math.cos(ang), hy + r * math.sin(ang))
# Park orphan hubs above/below clear of bands
ymin = min((y for _, y in pos.values()), default=0.0)
ymax = max((y for _, y in pos.values()), default=0.0)
for i, n in enumerate(sorted(orphan_hubs, key=lambda x: names.get(x, x))):
pos[n] = (80.0 * (i - (len(orphan_hubs) - 1) / 2), ymin - 280.0 - i * 40)
for n in names:
if n not in pos:
pos[n] = (0.0, ymax + 200.0)
# Normalize to positive margin
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
x0, y0 = min(xs), min(ys)
pad = params.margin
return {n: (x - x0 + pad, y - y0 + pad) for n, (x, y) in pos.items()}
def build_min_ring_skeleton(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult | None:
"""Return OpResult if dual-hub min-rings applies; else None (caller falls back)."""
params = params or LayoutParams()
plan = min_rings_eligible(state)
if not plan:
return None
pos = layout_min_rings_positions(state, params, plan)
if not pos:
return None
st = state.copy()
st.positions = pos
st.pinned = {plan["hub_a"], plan["hub_b"]}
# Also pin path portals' immediate corridor order lightly via spine
spine: set[str] = {plan["hub_a"], plan["hub_b"]}
for p in plan["paths"]:
spine.update(p)
st.spine = spine
st.last_moved = set(pos.keys())
cross = count_edge_crossings(pos, st.links)
st.meta["rings_mode"] = "min_rings"
st.meta["min_rings"] = {
"hub_a": plan["hub_a"],
"hub_b": plan["hub_b"],
"path_count": plan["path_count"],
"cover_frac": round(float(plan["cover_frac"]), 3),
"edge_crossings": cross,
}
st.meta["components"] = 1
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_min_ring_skeleton",
params={
"mode": "min_rings",
"path_count": plan["path_count"],
"cover_frac": plan["cover_frac"],
"hubs": [plan["hub_a"], plan["hub_b"]],
},
note=(
f"min-rings dual-hub paths={plan['path_count']} "
f"cover={plan['cover_frac']:.2f} cross={cross}"
),
)

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,523 @@
"""Soft partitioning for layout init — own algorithms first, igraph optional.
Design (partial igraph use):
1. Hard blocks = connected components (existing ``list_blocks(component)``).
2. Soft blocks inside a giant CC = **hub-seeded territory** (our BFS; default).
3. Optional igraph:
- ``leiden``: only refine leftover / oversized mesh pockets (hubs pinned).
- ``fr_pack``: place soft-block *centers* on a supergraph (not final metro coords).
4. Per-block geometry stays ours: beam / stub petals / chain spine / ume_petals.
Never use force-directed as the final access layout.
"""
from __future__ import annotations
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import Any, Iterable
from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
# Preferred soft-block step when packing centers (px).
_BLOCK_GAP = 420.0
def resolve_block_mode(state: LayoutState, mode: str | None = None) -> str:
"""Resolve ``auto`` → hub_territory when one giant CC has multiple hubs."""
m = (mode or "component").strip().lower()
if m != "auto":
return m
active = {
n
for n in state.positions
if state.layers.get(n) in ("core", "agg", "access")
}
comps = connected_components(active, state.adj) if active else []
hubs = pick_hub_seeds(state)
big = max((len(c) for c in comps), default=0)
if len(comps) <= 2 and big >= 40 and len(hubs) >= 2:
return "hub_territory"
return "component"
def igraph_available() -> bool:
try:
import igraph # noqa: F401
return True
except ImportError:
return False
@dataclass(frozen=True)
class SoftBlock:
block_id: int
hub_id: str | None
method: str
node_ids: tuple[str, ...]
def as_dict(self) -> dict[str, Any]:
return {
"block_id": self.block_id,
"hub_id": self.hub_id,
"method": self.method,
"size": len(self.node_ids),
"node_ids": list(self.node_ids)[:80],
"node_count": len(self.node_ids),
}
def pick_hub_seeds(
state: LayoutState,
*,
min_degree: int = 2,
max_hubs: int = 24,
) -> list[str]:
"""core/agg hubs by degree; fall back to high-degree nodes."""
ids = list(state.positions.keys())
layered = [
n
for n in ids
if state.layers.get(n) in ("core", "agg")
and len(state.adj.get(n, ())) >= min_degree
]
if layered:
layered.sort(
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n))
)
return layered[:max_hubs]
ranked = sorted(
ids,
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
return [n for n in ranked if len(state.adj.get(n, ())) >= max(3, min_degree)][
:max_hubs
]
def hub_territory_partition(
state: LayoutState,
*,
hubs: Iterable[str] | None = None,
) -> list[SoftBlock]:
"""Multi-source BFS from each hub's 1-hop stubs — one soft block per hub.
Hubs themselves are included in their block. Nodes claimed by the nearest
stub (first visit). Leftovers become CC soft-blocks (``method=leftover``).
"""
hub_list = [h for h in (hubs or pick_hub_seeds(state)) if h in state.positions]
if not hub_list:
comps = connected_components(set(state.positions), state.adj)
return [
SoftBlock(i, None, "component", tuple(sorted(c)))
for i, c in enumerate(comps)
if c
]
pinned = set(hub_list)
owner: dict[str, str] = {}
q: deque[str] = deque()
for h in hub_list:
for stub in sorted(state.adj.get(h, ()), key=lambda i: state.names.get(i, i)):
if stub in pinned or stub in owner:
continue
owner[stub] = h
q.append(stub)
while q:
u = q.popleft()
h = owner[u]
for v in state.adj.get(u, ()):
if v in pinned or v in owner:
continue
owner[v] = h
q.append(v)
by_hub: dict[str, list[str]] = {h: [h] for h in hub_list}
for nid, h in owner.items():
by_hub.setdefault(h, [h]).append(nid)
blocks: list[SoftBlock] = []
claimed = set(hub_list) | set(owner)
for h in hub_list:
nodes = tuple(sorted(set(by_hub.get(h, [h])), key=lambda i: state.names.get(i, i)))
blocks.append(SoftBlock(len(blocks), h, "hub_territory", nodes))
rest = set(state.positions) - claimed
if rest:
for comp in connected_components(rest, state.adj):
if not comp:
continue
blocks.append(
SoftBlock(
len(blocks),
None,
"leftover",
tuple(sorted(comp, key=lambda i: state.names.get(i, i))),
)
)
return blocks
def _leiden_membership(
node_ids: list[str],
adj: dict[str, set[str]],
*,
resolution: float = 1.0,
) -> list[int] | None:
if not igraph_available() or len(node_ids) < 6:
return None
import igraph as ig
idx = {n: i for i, n in enumerate(node_ids)}
edges: list[tuple[int, int]] = []
seen: set[tuple[int, int]] = set()
for a in node_ids:
for b in adj.get(a, ()):
if b not in idx or a >= b:
continue
ea, eb = idx[a], idx[b]
key = (ea, eb) if ea < eb else (eb, ea)
if key in seen:
continue
seen.add(key)
edges.append(key)
if not edges:
return None
g = ig.Graph(n=len(node_ids), edges=edges, directed=False)
try:
part = g.community_leiden(
objective_function="modularity",
resolution=resolution,
n_iterations=2,
)
except Exception:
try:
part = g.community_multilevel()
except Exception:
return None
return list(part.membership)
def leiden_refine_leftovers(
state: LayoutState,
blocks: list[SoftBlock],
*,
resolution: float = 0.8,
min_split_size: int = 12,
) -> list[SoftBlock]:
"""Optionally split leftover/mesh pockets with Leiden; hubs stay untouched.
If igraph is missing, returns ``blocks`` unchanged.
"""
if not igraph_available():
return blocks
out: list[SoftBlock] = []
for b in blocks:
if b.method != "leftover" or len(b.node_ids) < min_split_size:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
nodes = list(b.node_ids)
memb = _leiden_membership(nodes, state.adj, resolution=resolution)
if memb is None:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
groups: dict[int, list[str]] = defaultdict(list)
for n, m in zip(nodes, memb):
groups[int(m)].append(n)
if len(groups) <= 1:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
for gid in sorted(groups.keys()):
ids = tuple(sorted(groups[gid], key=lambda i: state.names.get(i, i)))
out.append(SoftBlock(len(out), None, "leiden", ids))
return out
def partition_soft_blocks(
state: LayoutState,
*,
mode: str = "hub_territory",
hubs: Iterable[str] | None = None,
leiden_resolution: float = 0.8,
) -> list[SoftBlock]:
"""Public entry: ``hub_territory`` (default) | ``leiden`` | ``soft``.
- hub_territory: our seeded BFS only
- leiden: hub territories + Leiden on leftovers (needs igraph)
- soft: alias of leiden if igraph else hub_territory
"""
m = (mode or "hub_territory").strip().lower()
base = hub_territory_partition(state, hubs=hubs)
if m in {"leiden", "soft"} and igraph_available():
return leiden_refine_leftovers(
state, base, resolution=leiden_resolution
)
return base
def soft_blocks_as_sets(blocks: list[SoftBlock]) -> list[set[str]]:
return [set(b.node_ids) for b in blocks if b.node_ids]
def _block_centroids(
state: LayoutState, blocks: list[SoftBlock]
) -> dict[int, tuple[float, float]]:
out: dict[int, tuple[float, float]] = {}
for b in blocks:
pts = [state.positions[n] for n in b.node_ids if n in state.positions]
if not pts:
continue
out[b.block_id] = (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
return out
def _centroid_spread(centroids: dict[int, tuple[float, float]]) -> float:
"""Max pairwise distance among centroids (0 if <2)."""
ids = list(centroids)
if len(ids) < 2:
return 0.0
best = 0.0
for i, a in enumerate(ids):
ax, ay = centroids[a]
for b in ids[i + 1 :]:
bx, by = centroids[b]
d = ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
if d > best:
best = d
return best
def _median_nn(centroids: dict[int, tuple[float, float]]) -> float:
ids = list(centroids)
if len(ids) < 2:
return 0.0
nns: list[float] = []
for a in ids:
ax, ay = centroids[a]
best = None
for b in ids:
if a == b:
continue
bx, by = centroids[b]
d = ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
if best is None or d < best:
best = d
if best is not None:
nns.append(best)
if not nns:
return 0.0
nns.sort()
return nns[len(nns) // 2]
def _pack_centers_preserve(
centroids: dict[int, tuple[float, float]],
*,
gap: float,
) -> dict[int, tuple[float, float]]:
"""Keep relative constellation; scale about COM so median NN ≈ gap."""
if len(centroids) < 2:
return dict(centroids)
cx = sum(p[0] for p in centroids.values()) / len(centroids)
cy = sum(p[1] for p in centroids.values()) / len(centroids)
med = _median_nn(centroids)
if med < 1e-6:
return dict(centroids)
# Only densify when already too sparse; never explode a tight pack here.
scale = min(1.0, gap / med)
if abs(scale - 1.0) < 1e-3:
return dict(centroids)
return {
bid: (cx + (x - cx) * scale, cy + (y - cy) * scale)
for bid, (x, y) in centroids.items()
}
def _pack_centers_fr_or_grid(
state: LayoutState,
blocks: list[SoftBlock],
*,
gap: float,
) -> dict[int, tuple[float, float]]:
"""Fresh placement when centroids are collapsed (init / stacked)."""
id_of = {}
for b in blocks:
for n in b.node_ids:
id_of[n] = b.block_id
cut: dict[tuple[int, int], int] = defaultdict(int)
for a, b in state.links:
ia, ib = id_of.get(a), id_of.get(b)
if ia is None or ib is None or ia == ib:
continue
key = (ia, ib) if ia < ib else (ib, ia)
cut[key] += 1
n = len(blocks)
if igraph_available() and n >= 2 and cut:
import igraph as ig
edges = list(cut.keys())
weights = [float(cut[e]) for e in edges]
g = ig.Graph(n=n, edges=edges, directed=False)
try:
layout = g.layout_fruchterman_reingold(weights=weights, niter=200)
except Exception:
layout = g.layout_fruchterman_reingold(niter=200)
xs = [float(p[0]) for p in layout]
ys = [float(p[1]) for p in layout]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
span = max(maxx - minx, maxy - miny, 1e-6)
target = gap * max(2.0, n**0.5)
scale = target / span
return {
blocks[i].block_id: ((xs[i] - minx) * scale, (ys[i] - miny) * scale)
for i in range(n)
}
cols = max(1, int(n**0.5 + 0.999))
out: dict[int, tuple[float, float]] = {}
for i, b in enumerate(blocks):
r, c = divmod(i, cols)
out[b.block_id] = (c * gap, r * gap)
return out
def pack_block_centers(
state: LayoutState,
blocks: list[SoftBlock],
*,
gap: float = _BLOCK_GAP,
strategy: str = "auto",
) -> dict[int, tuple[float, float]]:
"""Suggest soft-block centroids (init only; does not move nodes).
``strategy``:
- ``preserve``: keep current relative centroids; densify so median NN≈gap
- ``grid`` / ``fr``: ignore current world placement (FR if igraph else grid)
- ``auto``: if centroids already spread (≥0.75·gap), preserve; else FR/grid
"""
if not blocks:
return {}
centroids = _block_centroids(state, blocks)
if len(centroids) < 2:
return centroids
strat = (strategy or "auto").strip().lower()
spread = _centroid_spread(centroids)
if strat == "preserve" or (strat == "auto" and spread >= gap * 0.75):
return _pack_centers_preserve(centroids, gap=gap)
return _pack_centers_fr_or_grid(state, blocks, gap=gap)
def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any]:
"""Structure-facing summary for analyzeTopologyViewLayout."""
blocks = partition_soft_blocks(state, mode=mode)
centers = pack_block_centers(state, blocks)
return {
"mode": mode if mode != "soft" else ("leiden" if igraph_available() else "hub_territory"),
"igraph": igraph_available(),
"block_count": len(blocks),
"blocks": [
{
**b.as_dict(),
"center_hint": (
[round(centers[b.block_id][0], 1), round(centers[b.block_id][1], 1)]
if b.block_id in centers
else None
),
}
for b in blocks[:40]
],
"tip": (
"Soft blocks for init only: lay each hub territory / leftover with our "
"petal/spine algorithms; use center_hint to pack blocks. "
"igraph Leiden/FR are optional extras when available."
),
}
def pack_soft_blocks(
state: LayoutState,
params: LayoutParams | None = None,
*,
mode: str = "hub_territory",
gap: float | None = None,
strategy: str = "auto",
) -> OpResult:
"""Rigid-translate each soft block so centroids match ``pack_block_centers``.
Init packing only — does not reshape petals inside a block. No-op if <2 blocks.
``strategy=auto`` preserves an already-spread constellation (densify only);
collapsed stacks still get FR/grid separation.
"""
del params # reserved for future spacing knobs
st = state.copy()
raw = (mode or "hub_territory").strip().lower()
resolved = resolve_block_mode(st, raw) if raw == "auto" else raw
if resolved == "component":
resolved = "hub_territory"
blocks = partition_soft_blocks(st, mode=resolved)
if len(blocks) < 2:
return OpResult(
state=st,
moved=set(),
op="pack_soft_blocks",
params={"mode": resolved, "blocks_n": len(blocks)},
note="skip soft pack (<2 blocks)",
)
g = float(gap or _BLOCK_GAP)
strat = (strategy or "auto").strip().lower()
before = _block_centroids(st, blocks)
targets = pack_block_centers(st, blocks, gap=g, strategy=strat)
used = (
"preserve"
if strat == "preserve"
or (strat == "auto" and _centroid_spread(before) >= g * 0.75)
else ("fr" if igraph_available() else "grid")
)
moved: set[str] = set()
for b in blocks:
tid = b.block_id
if tid not in targets or not b.node_ids:
continue
pts = [st.positions[n] for n in b.node_ids if n in st.positions]
if not pts:
continue
cx = sum(p[0] for p in pts) / len(pts)
cy = sum(p[1] for p in pts) / len(pts)
tx, ty = targets[tid]
dx, dy = tx - cx, ty - cy
if abs(dx) < 1e-6 and abs(dy) < 1e-6:
continue
for n in b.node_ids:
if n not in st.positions:
continue
x, y = st.positions[n]
st.positions[n] = (x + dx, y + dy)
moved.add(n)
st.meta["soft_pack"] = {
"mode": resolved,
"strategy": used,
"blocks_n": len(blocks),
"moved_n": len(moved),
"igraph_centers": igraph_available(),
}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="pack_soft_blocks",
params={
"mode": resolved,
"strategy": used,
"blocks_n": len(blocks),
"moved_n": len(moved),
},
note=f"packed soft blocks={len(blocks)} strategy={used} moved={len(moved)}",
)

View file

@ -0,0 +1,252 @@
"""Stage-2: pin core horizontal beam + agg row via rigid soft-block translates."""
from __future__ import annotations
from collections import deque
from typing import Any
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.partition import partition_soft_blocks
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
def _hops_to_cores(
src: str, cores: list[str], adj: dict[str, set[str]]
) -> tuple[int, str]:
if not cores:
return 99, ""
core_set = set(cores)
if src in core_set:
return 0, src
seen = {src}
q: deque[tuple[str, int]] = deque([(src, 0)])
while q:
u, d = q.popleft()
for v in adj.get(u, ()):
if v in seen:
continue
if v in core_set:
return d + 1, v
seen.add(v)
q.append((v, d + 1))
return 99, cores[0]
def _core_order(state: LayoutState) -> list[str]:
cores = [
n
for n in state.positions
if state.layers.get(n) == "core"
]
if len(cores) < 2:
# fall back: high-degree hubs that look like CN
for n, nm in state.names.items():
if "-CN" in (nm or "") and n not in cores:
cores.append(n)
cores = sorted(
set(cores),
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
return cores
def _agg_hubs(state: LayoutState, *, min_degree: int = 4) -> list[str]:
aggs = [
n
for n in state.positions
if state.layers.get(n) == "agg"
and len(state.adj.get(n, ())) >= min_degree
]
return sorted(
aggs,
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
def _apply_targets(
state: LayoutState,
targets: dict[str, tuple[float, float]],
by_hub: dict[str, Any],
) -> LayoutState:
pos = dict(state.positions)
for hid, (tx, ty) in targets.items():
block = by_hub.get(hid)
if not block or hid not in pos:
if hid in pos:
pos[hid] = (tx, ty)
continue
hx, hy = pos[hid]
dx, dy = tx - hx, ty - hy
for n in block.node_ids:
if n in pos:
x, y = pos[n]
pos[n] = (x + dx, y + dy)
st = state.copy()
st.positions = pos
st = fix_overlaps_local(st, LayoutParams()).state
st = normalize_origin(st, LayoutParams()).state
return st
def pin_beam_rigid(
state: LayoutState,
params: LayoutParams | None = None,
*,
step: float | None = None,
gap_scales: tuple[float, ...] = (1.0, 1.3, 1.6, 0.85),
) -> OpResult:
"""Rigid-translate soft blocks so cores form a beam and aggs sit on a row.
Picks the trial with lowest (overlaps, crossings). Does not worsen crossings
vs the input when a better trial exists; otherwise returns input unchanged.
"""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch, 180.0))
cores = _core_order(state)
if len(cores) < 2:
return OpResult(
state=state,
moved=set(),
op="pin_beam",
params={"cores": cores},
note="need_ge_2_cores",
)
aggs = _agg_hubs(state)
aggs = sorted(
aggs,
key=lambda a: (
_hops_to_cores(a, cores, state.adj)[0],
state.names.get(a, a),
),
)
blocks = partition_soft_blocks(state, mode="hub_territory")
by_hub = {b.hub_id: b for b in blocks if b.hub_id}
links = list(state.links)
base_cross = count_edge_crossings(state.positions, links)
base_score = score_state(state)
base_ov = int(base_score.get("footprint_overlap_pairs") or 0) + int(
base_score.get("label_overlap_pairs") or 0
)
gap0 = step_px * 2.4
beam_y = 0.0
trials: list[tuple[str, LayoutState, int, int]] = []
for gs in gap_scales:
gap = gap0 * gs
for petal_mul in (4.0, 4.5, 5.5):
petal_dy = step_px * petal_mul
targets: dict[str, tuple[float, float]] = {}
for i, c in enumerate(cores):
targets[c] = (i * gap, beam_y)
if aggs:
lo = targets[cores[0]][0] - gap * 0.5
hi = targets[cores[-1]][0] + gap * 0.5
for i, a in enumerate(aggs):
_, pref = _hops_to_cores(a, cores, state.adj)
prefer_x = targets.get(pref, targets[cores[0]])[0]
if len(aggs) == 1:
x = prefer_x
else:
x = lo + (hi - lo) * i / (len(aggs) - 1)
x = 0.55 * x + 0.45 * prefer_x
targets[a] = (x, beam_y + petal_dy)
st2 = _apply_targets(state, targets, by_hub)
sc = score_state(st2)
ov = int(sc.get("footprint_overlap_pairs") or 0) + int(
sc.get("label_overlap_pairs") or 0
)
cross = int(sc.get("edge_crossings") or 0)
trials.append((f"g{gs}_dy{petal_dy:.0f}", st2, ov, cross))
# Y-align only (preserve x): often safer on already-readable canvases
cy = sum(state.positions[c][1] for c in cores if c in state.positions) / len(
cores
)
targets_y: dict[str, tuple[float, float]] = {}
for c in cores:
if c in state.positions:
targets_y[c] = (state.positions[c][0], cy)
ay = cy + step_px * 4.0
for a in aggs:
if a in state.positions:
targets_y[a] = (state.positions[a][0], ay)
if targets_y:
st_y = _apply_targets(state, targets_y, by_hub)
sc = score_state(st_y)
ov = int(sc.get("footprint_overlap_pairs") or 0) + int(
sc.get("label_overlap_pairs") or 0
)
cross = int(sc.get("edge_crossings") or 0)
trials.append(("y_align", st_y, ov, cross))
if not trials:
return OpResult(
state=state, moved=set(), op="pin_beam", note="no_trials"
)
best_name, best_st, best_ov, best_cross = min(
trials, key=lambda t: (t[2], t[3])
)
# Refuse internal accept if worse than input on both axes we care about
if best_ov > base_ov or best_cross > base_cross + max(20, int(base_cross * 0.05)):
return OpResult(
state=state,
moved=set(),
op="pin_beam",
params={
"cores": cores,
"aggs": aggs,
"best_trial": best_name,
"best_crossings": best_cross,
"base_crossings": base_cross,
"accepted": False,
},
note="no_improvement",
)
moved = {
n
for n, p in best_st.positions.items()
if n in state.positions and p != state.positions[n]
}
best_st.meta = dict(best_st.meta or {})
best_st.meta["pin_beam"] = {
"trial": best_name,
"cores": cores,
"aggs": aggs,
"crossings_before": base_cross,
"crossings_after": best_cross,
"overlaps_after": best_ov,
}
return OpResult(
state=best_st,
moved=moved,
op="pin_beam",
params={
"cores": cores,
"aggs": aggs,
"best_trial": best_name,
"accepted": True,
"crossings_before": base_cross,
"crossings_after": best_cross,
},
note=f"pin_beam:{best_name} {base_cross}->{best_cross}",
)
def pin_beam_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
return out

View 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

View file

@ -0,0 +1,354 @@
"""Pull far deg≤2 chains / isolates toward the eye portal anchor.
Unlike uniform ``compact_bbox``, only moves far corridors (and deg=0 orphans).
Hard gates: crossings not rise, overlaps stay 0, portals frozen.
Scale is toward the portal mid-point (not the chain hub) so outer hubs
still shrink the canvas bbox.
"""
from __future__ import annotations
import math
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
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
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 pts:
return (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
xs = [p[0] for p in pos.values()] or [0.0]
ys = [p[1] for p in pos.values()] or [0.0]
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, 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 _try_scale(
pos: dict[str, tuple[float, float]],
mobile: list[str],
*,
cx: float,
cy: float,
scale: float,
links,
names,
g0: int,
clr0: int,
max_clearance_slack: int,
) -> tuple[dict[str, tuple[float, float]], int, int, float] | None:
trial = dict(pos)
for n in mobile:
x, y = pos[n]
trial[n] = (cx + (x - cx) * scale, cy + (y - cy) * scale)
if _has_any_footprint_overlap(trial, names):
return None
g1 = count_edge_crossings(trial, links)
if g1 > g0:
return None
clr1 = _clr_hits(trial, links, names)
if clr1 > clr0 + max(0, int(max_clearance_slack)):
return None
return trial, g1, clr1, _area(trial)
def pull_far_chains(
state: LayoutState,
params: LayoutParams | None = None,
*,
frozen_ids: set[str] | None = None,
portal_ids: list[str] | None = None,
max_chains: int = 16,
min_tip_radius: float = 1800.0,
scales: tuple[float, ...] = (0.92, 0.88, 0.84, 0.80, 0.75),
max_clearance_slack: int = 40,
pull_isolates: bool = True,
) -> OpResult:
"""Shorten farthest corridors / orphans toward the portal mid-point."""
del params
st = state.copy()
pos = dict(st.positions)
names = st.names
links = list(st.links)
adj = st.adj
frozen: set[str] = set(frozen_ids or ())
for p in portal_ids or []:
if p:
frozen.add(str(p))
g0 = count_edge_crossings(pos, links)
if _has_any_footprint_overlap(pos, names):
return OpResult(
state=st,
moved=set(),
op="pull_far_chains",
note="pull_far_chains:overlaps_before",
params={"error": "overlaps_before"},
)
clr0 = _clr_hits(pos, links, names)
area0 = _area(pos)
cx, cy = _anchor(pos, frozen)
chains = extract_chain_paths(adj)
scored: list[tuple[float, list[str], str]] = []
for path in chains:
path = [n for n in path if n in pos]
if len(path) < 2:
continue
d0 = math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy)
d1 = math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy)
tip = path[0] if d0 >= d1 else path[-1]
hub = path[-1] if tip == path[0] else path[0]
if tip in frozen:
continue
tip_r = max(d0, d1)
if tip_r < float(min_tip_radius):
continue
scored.append((tip_r, path, hub))
scored.sort(key=lambda t: t[0], reverse=True)
moved: set[str] = set()
accepted: list[dict[str, Any]] = []
used: set[str] = set(frozen)
for tip_r, path, hub in scored[: max(1, int(max_chains))]:
# Scale corridor toward portal mid-point (shrinks bbox even if hub is outer).
mobile = [n for n in path if n not in used and n in pos and n not in frozen]
if len(mobile) < 1:
continue
best_local = None
best_key = None
for scale in scales:
got = _try_scale(
pos,
mobile,
cx=cx,
cy=cy,
scale=scale,
links=links,
names=names,
g0=g0,
clr0=clr0,
max_clearance_slack=max_clearance_slack,
)
if got is None:
continue
trial, g1, clr1, area1 = got
key = (g1, area1, clr1)
if best_key is None or key < best_key:
best_key = key
best_local = (trial, scale, g1, clr1, area1)
if best_local is None:
continue
trial, scale, g1, clr1, area1 = best_local
tip = (
path[0]
if math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy)
>= math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy)
else path[-1]
)
tip_r1 = math.hypot(trial[tip][0] - cx, trial[tip][1] - cy)
if tip_r1 >= tip_r - 1.0 and area1 >= area0 * 0.999:
continue
pos = trial
g0 = g1
clr0 = clr1
for n in mobile:
moved.add(n)
used.add(n)
accepted.append(
{
"hub": hub,
"tip": tip,
"member_n": len(mobile),
"scale": scale,
"tip_r0": round(tip_r, 1),
"tip_r1": round(tip_r1, 1),
}
)
isolates_n = 0
if pull_isolates:
orphans = [
n
for n, nbs in adj.items()
if n in pos
and n not in used
and n not in frozen
and len(nbs) == 0
and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius) * 0.6
]
orphans.sort(
key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy),
reverse=True,
)
for n in orphans[: max(4, int(max_chains) // 2)]:
best_local = None
best_key = None
for scale in scales:
got = _try_scale(
pos,
[n],
cx=cx,
cy=cy,
scale=scale,
links=links,
names=names,
g0=g0,
clr0=clr0,
max_clearance_slack=max_clearance_slack,
)
if got is None:
continue
trial, g1, clr1, area1 = got
key = (g1, area1, clr1)
if best_key is None or key < best_key:
best_key = key
best_local = (trial, scale, g1, clr1)
if best_local is None:
continue
trial, scale, g1, clr1 = best_local
pos = trial
g0 = g1
clr0 = clr1
moved.add(n)
used.add(n)
isolates_n += 1
accepted.append(
{
"hub": None,
"tip": n,
"member_n": 1,
"scale": scale,
"isolate": True,
}
)
leaves = [
n
for n, nbs in adj.items()
if n in pos
and n not in used
and n not in frozen
and len(nbs) == 1
and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius)
]
leaves.sort(
key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy),
reverse=True,
)
for n in leaves[: max(4, int(max_chains) // 2)]:
best_local = None
best_key = None
for scale in scales:
got = _try_scale(
pos,
[n],
cx=cx,
cy=cy,
scale=scale,
links=links,
names=names,
g0=g0,
clr0=clr0,
max_clearance_slack=max_clearance_slack,
)
if got is None:
continue
trial, g1, clr1, area1 = got
key = (g1, area1, clr1)
if best_key is None or key < best_key:
best_key = key
best_local = (trial, scale, g1, clr1)
if best_local is None:
continue
trial, scale, g1, clr1 = best_local
pos = trial
g0 = g1
clr0 = clr1
moved.add(n)
used.add(n)
accepted.append(
{
"hub": next(iter(adj.get(n) or ()), None),
"tip": n,
"member_n": 1,
"scale": scale,
"leaf": True,
}
)
st.positions = pos
st.last_moved = moved
g_end = count_edge_crossings(pos, links)
clr_end = _clr_hits(pos, links, names)
meta = {
"mode": "pull_far_chains",
"chains_tried": min(len(scored), max(1, int(max_chains))),
"chains_accepted": len(accepted),
"accepted_chains": accepted[:20],
"moved_n": len(moved),
"isolates_pulled": isolates_n,
"start_area": round(area0, 1),
"end_area": round(_area(pos), 1),
"start_crossings": count_edge_crossings(dict(state.positions), list(state.links)),
"end_crossings": g_end,
"start_clearance_hits": _clr_hits(dict(state.positions), list(state.links), names),
"end_clearance_hits": clr_end,
}
st.meta["pull_far_chains"] = meta
return OpResult(
state=st,
moved=moved,
op="pull_far_chains",
params=meta,
note=(
f"pull_far_chains n={len(accepted)} moved={len(moved)} "
f"area={meta['start_area']}→{meta['end_area']} "
f"x={meta['start_crossings']}→{meta['end_crossings']}"
),
)
def pull_far_chains_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]
scales = o.get("scales")
if isinstance(scales, (list, tuple)) and scales:
sc = tuple(float(x) for x in scales)
else:
sc = (0.92, 0.88, 0.84, 0.80, 0.75)
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()},
"max_chains": int(o.get("max_chains") or 16),
"min_tip_radius": float(o.get("min_tip_radius") or 1800.0),
"scales": sc,
"max_clearance_slack": int(o.get("max_clearance_slack") or 40),
"pull_isolates": bool(o.get("pull_isolates", True)),
}

View file

@ -0,0 +1,352 @@
"""Compose atomic ops into multipass recipes (supports per-block scope)."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any, Callable
from netx_api.mcp_remote.topology.layout_ops.score import score_op, score_state
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, map_blocks, select_scope
from netx_api.mcp_remote.topology.layout_ops.sides import place_side_branches
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
from netx_api.mcp_remote.topology.layout_ops.partition import pack_soft_blocks
from netx_api.mcp_remote.topology.layout_ops.transforms import (
enforce_min_gap,
explode_clusters,
normalize_origin,
pack_utilization,
resolve_overlaps,
scale_edge_axes,
scale_region,
select_pins,
soft_nn_scale,
)
OpFn = Callable[..., OpResult]
REGISTRY: dict[str, OpFn] = {
"build_skeleton": build_skeleton,
"build_ring_skeleton": build_ring_skeleton,
"place_side_branches": place_side_branches,
"select_pins": select_pins,
"select_scope": select_scope,
"map_blocks": map_blocks,
"scale_region": scale_region,
"scale_edge_axes": scale_edge_axes,
"pack_utilization": pack_utilization,
"pack_soft_blocks": pack_soft_blocks,
"resolve_overlaps": resolve_overlaps,
"explode_clusters": explode_clusters,
"enforce_min_gap": enforce_min_gap,
"soft_nn_scale": soft_nn_scale,
"normalize_origin": normalize_origin,
"fix_overlaps_local": fix_overlaps_local,
"relax_hotspots": relax_hotspots,
"score": score_op,
}
def smd_corridor_v1_passes() -> list[dict[str, Any]]:
"""Global structure, then per-component explode+resolve (scoped)."""
return [
{"op": "build_skeleton"},
{"op": "place_side_branches"},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "scale_edge_axes"},
{"op": "soft_nn_scale"},
{"op": "map_blocks", "kwargs": {"mode": "component"}},
# Expanded at runtime by foreach_blocks
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"passes": [
{
"op": "explode_clusters",
"kwargs": {"thr": 20.0, "gap": 80.0, "axis": "perp"},
},
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "normalize_origin"},
{"op": "score"},
]
def smd_corridor_unstick_v1_passes() -> list[dict[str, Any]]:
"""v1 + second per-block free resolve for stubborn overlaps."""
return [
*smd_corridor_v1_passes()[:-2],
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"passes": [
{"op": "resolve_overlaps", "kwargs": {"mode": "free"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "normalize_origin"},
{"op": "score"},
]
def agg_rings_v1_passes() -> list[dict[str, Any]]:
"""Rings recipe: dual-hub min-rings when eligible, else UME petals.
Light overlap fix only — avoid explode that destroys ring geometry.
"""
return [
{"op": "build_ring_skeleton"},
{"op": "select_pins", "kwargs": {"mode": "agg"}},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "fix_overlaps_local"},
{"op": "normalize_origin"},
{"op": "score"},
]
def smd_corridor_compact_v1_passes() -> list[dict[str, Any]]:
"""Corridor skeleton, then pack/unstick **per component** — no global crush.
Global isotropic pack destroys crossings; islands are compacted in-place.
Ends with surgical local overlap fix.
"""
return [
{"op": "build_skeleton"},
{"op": "place_side_branches"},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "scale_edge_axes"},
{"op": "soft_nn_scale"},
{"op": "map_blocks", "kwargs": {"mode": "component"}},
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"min_size": 3,
"passes": [
{"op": "pack_utilization"},
{
"op": "explode_clusters",
"kwargs": {"thr": 8.0, "gap": 35.0, "axis": "along"},
},
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "fix_overlaps_local"},
{"op": "normalize_origin"},
{"op": "score"},
]
RECIPES: dict[str, Callable[[], list[dict[str, Any]]]] = {
"smd_corridor_v1": smd_corridor_v1_passes,
"smd_corridor_unstick_v1": smd_corridor_unstick_v1_passes,
"smd_corridor_compact_v1": smd_corridor_compact_v1_passes,
"agg_rings_v1": agg_rings_v1_passes,
}
def _apply_op(
st: LayoutState,
name: str,
params: LayoutParams,
kwargs: dict[str, Any],
*,
ume_reference: bool,
) -> OpResult:
if name == "score":
return score_op(st, ume_reference=ume_reference)
if name == "select_pins":
return select_pins(st, **kwargs)
if name == "select_scope":
return select_scope(st, **kwargs)
if name == "map_blocks":
return map_blocks(st, **kwargs)
if name == "pack_soft_blocks":
return pack_soft_blocks(st, params, **kwargs)
if name == "scale_region":
return scale_region(st, params, **kwargs)
if name == "resolve_overlaps":
return resolve_overlaps(st, params, **kwargs)
if name == "explode_clusters":
return explode_clusters(st, params, **kwargs)
if name == "enforce_min_gap":
return enforce_min_gap(st, params, **kwargs)
if name == "fix_overlaps_local":
return fix_overlaps_local(st, params)
if name == "relax_hotspots":
return relax_hotspots(st, params)
if name == "build_ring_skeleton":
return build_ring_skeleton(st, params)
fn = REGISTRY.get(name)
if fn is None:
raise KeyError(f"unknown op: {name}")
return fn(st, params)
def _trace_entry(
i: int,
op: str,
note: str,
params: dict[str, Any],
moved_n: int,
pinned_n: int,
metrics: dict[str, Any] | None = None,
) -> dict[str, Any]:
m = metrics or {}
return {
"i": i,
"op": op,
"note": note,
"params": params,
"moved_n": moved_n,
"pinned_n": pinned_n,
"edge_crossings": m.get("edge_crossings"),
"nn_p50": m.get("nn_p50"),
"footprint_overlap_pairs": m.get("footprint_overlap_pairs"),
"label_overlap_pairs": m.get("label_overlap_pairs"),
"space_utilization": m.get("space_utilization"),
"bbox": m.get("bbox"),
"grade": (m.get("grade") or {}).get("overall") if m else None,
}
def _expand_foreach(
st: LayoutState,
kwargs: dict[str, Any],
params: LayoutParams,
*,
ume_reference: bool,
base_i: int,
) -> tuple[LayoutState, list[dict[str, Any]]]:
"""Run sub-passes once per block. Score only once after all blocks (fast)."""
mode = str(kwargs.get("mode") or "component")
sub_passes: list[dict[str, Any]] = list(kwargs.get("passes") or [])
min_size = int(kwargs.get("min_size") or 2)
blocks = list_blocks(st, mode=mode)
# Record resolved mode (auto → hub_territory|component) for agents/trace.
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
st = st.copy()
st.meta["block_mode"] = resolved_mode
st.meta["blocks"] = [sorted(b) for b in blocks]
trace: list[dict[str, Any]] = []
step_i = base_i
total_moved = 0
for bi, block in enumerate(blocks):
if len(block) < min_size:
continue
st = select_scope(st, mode="ids", node_ids=block).state
block_moved = 0
for step in sub_passes:
name = str(step.get("op") or "")
kw = dict(step.get("kwargs") or {})
result = _apply_op(st, name, params, kw, ume_reference=ume_reference)
st = result.state
block_moved += len(result.moved)
total_moved += len(result.moved)
trace.append(
_trace_entry(
step_i,
"foreach_block",
f"block[{bi}] n={len(block)} moved≈{block_moved}",
{"block_index": bi, "scope_n": len(block), "mode": mode},
block_moved,
len(st.pinned),
)
)
step_i += 1
st = select_scope(st, mode="all").state
metrics = score_state(st, ume_reference=ume_reference)
trace.append(
_trace_entry(
step_i,
"foreach_blocks",
f"done blocks={len(blocks)} moved≈{total_moved}",
{"mode": mode, "blocks_n": len(blocks), "moved_n": total_moved},
total_moved,
len(st.pinned),
metrics,
)
)
return st, trace
def run_recipe(
state: LayoutState,
recipe: str | list[dict[str, Any]] = "smd_corridor_v1",
params: LayoutParams | None = None,
*,
ume_reference: bool = False,
) -> tuple[LayoutState, list[dict[str, Any]], dict[str, Any]]:
"""Run passes; return final state, per-pass trace, final score."""
params = params or LayoutParams()
passes = RECIPES[recipe]() if isinstance(recipe, str) else recipe
st = state.copy()
trace: list[dict[str, Any]] = []
i = 0
for step in passes:
name = str(step.get("op") or "")
kwargs = dict(step.get("kwargs") or {})
if name == "foreach_blocks":
st, sub = _expand_foreach(
st, kwargs, params, ume_reference=ume_reference, base_i=i
)
trace.extend(sub)
i = (trace[-1]["i"] + 1) if trace else i + 1
continue
result = _apply_op(st, name, params, kwargs, ume_reference=ume_reference)
st = result.state
# Lightweight steps skip full score; score at structural checkpoints + end.
want_score = name in {
"build_skeleton",
"place_side_branches",
"soft_nn_scale",
"normalize_origin",
"score",
"pack_utilization",
}
metrics = score_state(st, ume_reference=ume_reference) if want_score else None
trace.append(
_trace_entry(
i,
result.op,
result.note,
result.params,
len(result.moved),
len(st.pinned),
metrics,
)
)
i += 1
final = score_state(st, ume_reference=ume_reference)
final["recipe"] = recipe if isinstance(recipe, str) else "custom"
final["params"] = asdict(params)
final["passes"] = len(trace)
return st, trace, final
def positions_for_api(state: LayoutState) -> list[dict[str, float | str]]:
return [
{
"fabric_node_id": n,
"x": round(state.positions[n][0], 1),
"y": round(state.positions[n][1], 1),
}
for n in state.positions
]

View file

@ -0,0 +1,566 @@
"""Rigid-body densify: drag a dual-unit by an external corridor (bridge orbit).
For each staging rigid group, pick an external bridge tip as pivot and sweep the
exclusive members on concentric circles (angle × radius_scale). Prefer inward
radii to raise util / shrink bbox while keeping overlaps=0 and crossings within
``x_slack``.
This reuses the polar spirit of ``compose_orbit`` attach, but runs *after*
compose as a densify/util pass (portals stay frozen when shared).
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import crossings_touching
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_compose_meta
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6
_DEFAULT_ANGLE_STEP = 30
# Bias inward: pull units toward external tips to densify.
_DEFAULT_RADII = (0.55, 0.7, 0.85, 1.0, 1.15, 1.35)
# Large canvases: coarser polar grid (full E² count is too expensive).
_LARGE_N = 600
_LARGE_ANGLE_STEP = 45
_LARGE_RADII = (0.55, 0.75, 0.9, 1.1)
_MIN_BRIDGE = 180.0
_FOOTPRINT_MIN = 90.0
def _apply_polar(
base: dict[str, tuple[float, float]],
members: list[str],
pivot: tuple[float, float],
*,
angle: float,
radius_scale: float,
) -> dict[str, tuple[float, float]]:
cos_a, sin_a = math.cos(angle), math.sin(angle)
cx, cy = pivot
out = dict(base)
for n in members:
if n not in out:
continue
x, y = out[n]
dx, dy = x - cx, y - cy
rx = (dx * cos_a - dy * sin_a) * radius_scale
ry = (dx * sin_a + dy * cos_a) * radius_scale
out[n] = (cx + rx, cy + ry)
return out
def _bbox_area(pos: dict[str, tuple[float, float]], ids: set[str] | None = None) -> float:
pts = [
pos[n]
for n in (ids or pos.keys())
if n in pos
and abs(pos[n][0]) <= _COORD_ABS_MAX
and abs(pos[n][1]) <= _COORD_ABS_MAX
]
if len(pts) < 2:
return 1.0
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
def _footprint_hits(
pos: dict[str, tuple[float, float]],
moved: set[str],
frozen: set[str],
*,
min_dist: float = _FOOTPRINT_MIN,
) -> int:
"""Cheap center collisions between moved nodes and the rest."""
hits = 0
md2 = min_dist * min_dist
others = [
(pos[n][0], pos[n][1])
for n in pos
if n not in moved and n not in frozen
and abs(pos[n][0]) <= _COORD_ABS_MAX
]
for n in moved:
if n not in pos:
continue
x, y = pos[n]
for ox, oy in others:
dx, dy = x - ox, y - oy
if dx * dx + dy * dy < md2:
hits += 1
break
return hits
def _parse_groups(
groups: list[dict[str, Any]],
valid: set[str],
) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
parsed_raw: list[tuple[str, list[str], list[str]]] = []
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
pivots = [str(p) for p in (g.get("pivots") or []) if str(p) in valid]
for n in members:
counts[n] = counts.get(n, 0) + 1
parsed_raw.append((key, members, pivots))
shared = {n for n, c in counts.items() if c > 1}
out: list[dict[str, Any]] = []
for key, members, pivots in parsed_raw:
piv = pivots or [n for n in members if n in shared]
exclusive = [n for n in members if n not in shared]
out.append(
{
"key": key,
"members": members,
"pivots": piv,
"exclusive": exclusive,
"shared": [n for n in members if n in shared],
}
)
return out
def _external_bridges(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
members: set[str],
exclusive: set[str],
) -> list[tuple[float, str, str]]:
"""Return (length, inner_id, outer_id) sorted longest-first."""
bridges: list[tuple[float, str, str]] = []
for a, b in links:
if a not in pos or b not in pos:
continue
a_in, b_in = a in members, b in members
if a_in == b_in:
continue
inner, outer = (a, b) if a_in else (b, a)
# Prefer tips that are exclusive (true corridor leaf), not shared portals.
if exclusive and inner not in exclusive and inner not in members:
continue
L = math.hypot(pos[inner][0] - pos[outer][0], pos[inner][1] - pos[outer][1])
if L < _MIN_BRIDGE:
continue
bridges.append((L, inner, outer))
bridges.sort(reverse=True)
return bridges
def _movers_for_group(g: dict[str, Any]) -> list[str]:
"""Nodes that move under bridge orbit (exclusive; whole group if free)."""
if g["exclusive"]:
return list(g["exclusive"])
# No shared membership → whole body can orbit.
return list(g["members"])
def rigid_orbit_candidates_for_group(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
group: dict[str, Any],
*,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] = _DEFAULT_RADII,
bridges_per_group: int = 4,
cand_cap: int = 120,
x0: int | None = None,
area0: float | None = None,
x_slack: int = 40,
) -> list[dict[str, Any]]:
"""Ranked candidates for one rigid group (best first).
Ranking uses *partial* crossings (edges touching movers) — O(focus×E) —
not full global E². Callers verify top picks with ``count_edge_crossings``.
"""
del x0 # reserved; global verify happens in round
members = set(group["members"])
exclusive = set(group["exclusive"])
movers = _movers_for_group(group)
if len(movers) < 2:
return []
frozen_portals = set(group["shared"] or group["pivots"])
bridges = _external_bridges(pos, links, members, exclusive)[
: max(1, bridges_per_group)
]
if not bridges:
return []
area_base = _bbox_area(pos) if area0 is None else float(area0)
step = max(15, min(60, int(angle_step)))
rads = tuple(float(r) for r in radii) or _DEFAULT_RADII
touch_slack = max(4, int(x_slack))
cands: list[dict[str, Any]] = []
for L, inner, outer in bridges:
if outer not in pos or inner not in pos:
continue
pivot = pos[outer]
focus0 = set(movers) | {inner, outer}
x_touch0 = crossings_touching(pos, links, focus0)
for deg in range(0, 360, step):
ang = math.radians(deg)
for rs in rads:
if deg == 0 and abs(rs - 1.0) < 1e-9:
continue
trial = _apply_polar(pos, movers, pivot, angle=ang, radius_scale=rs)
for p in frozen_portals:
if p in pos:
trial[p] = pos[p]
trial[outer] = pos[outer]
ov_cheap = _footprint_hits(trial, set(movers), frozen_portals)
if ov_cheap > 0:
continue
x_touch = crossings_touching(trial, links, focus0)
if x_touch > x_touch0 + touch_slack:
continue
area1 = _bbox_area(trial)
area_ratio = area_base / max(area1, 1e-6)
# Prefer denser bbox, then fewer partial crossings.
rank = (area_ratio, -(x_touch - x_touch0), -x_touch, -L)
cands.append(
{
"group_key": group["key"],
"inner": inner,
"outer": outer,
"bridge_len": round(L, 1),
"angle_deg": float(deg),
"radius_scale": float(rs),
"crossings_touch": x_touch,
"crossings_touch0": x_touch0,
"area_ratio": round(area_ratio, 4),
"delta_touch": x_touch - x_touch0,
"movers_n": len(movers),
"rank": rank,
"positions": {n: trial[n] for n in movers if n in trial},
}
)
if len(cands) >= cand_cap * 2:
break
if len(cands) >= cand_cap * 2:
break
if len(cands) >= cand_cap * 2:
break
cands.sort(key=lambda r: r["rank"], reverse=True)
useful = [
c
for c in cands
if c["area_ratio"] >= 1.004 or c["delta_touch"] < 0
]
out = useful[:cand_cap] if useful else cands[: min(3, len(cands))]
for i, c in enumerate(out, start=1):
c["rank_i"] = i
c.pop("rank", None)
return out
def rigid_orbit_round(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
top_n: int = 24,
bridges_per_group: int = 3,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] | list[float] | None = None,
cand_cap: int = 80,
x_slack: int | None = None,
max_accepts: int = 16,
) -> OpResult:
"""Greedy bridge-orbit densify over top rigid groups."""
from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled,
report_progress,
touch_heartbeat,
)
st = state.copy()
groups = groups if groups is not None else groups_from_compose_meta(
(st.meta or {}).get("compose_views")
)
valid = {
n
for n, (x, y) in st.positions.items()
if abs(x) <= _COORD_ABS_MAX
and abs(y) <= _COORD_ABS_MAX
and math.isfinite(x)
and math.isfinite(y)
}
parsed = _parse_groups(groups or [], valid)
if not parsed:
return OpResult(
state=st,
moved=set(),
op="rigid_orbit",
params={"groups": 0},
note="rigid_orbit:no_groups",
)
large = len(st.positions) >= _LARGE_N or len(st.links) >= _LARGE_N
before = score_state(st, fast=True)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = len(overlapping_nodes(st))
area0 = _bbox_area(st.positions)
slack = (
max(8, int(before_x * 0.08))
if x_slack is None
else max(0, int(x_slack))
)
# Auto-coarsen polar grid on large canvases unless caller overrode radii.
use_angle = int(angle_step)
if radii is None and large:
rads = _LARGE_RADII
use_angle = max(use_angle, _LARGE_ANGLE_STEP)
bridges_per_group = min(int(bridges_per_group), 2)
cand_cap = min(int(cand_cap), 48)
top_n = min(int(top_n), 20)
max_accepts = min(int(max_accepts), 10)
else:
rads = tuple(float(r) for r in (radii or _DEFAULT_RADII))
# Order groups by longest external bridge (sparsity / stretch drivers).
scored_g: list[tuple[float, dict[str, Any]]] = []
for g in parsed:
br = _external_bridges(
st.positions, st.links, set(g["members"]), set(g["exclusive"])
)
if not br:
continue
scored_g.append((br[0][0], g))
scored_g.sort(key=lambda t: t[0], reverse=True)
ordered = [g for _L, g in scored_g[: max(1, int(top_n))]]
pos = {n: (float(p[0]), float(p[1])) for n, p in st.positions.items()}
links = list(st.links)
cur_x = before_x
cur_area = area0
moved: set[str] = set()
trace: list[dict[str, Any]] = []
accepts = 0
verify_k = 3 if large else 5
report_progress(
"rigid_orbit",
pct=48.0,
message=f"scan {len(ordered)} groups large={large}",
groups=len(ordered),
)
for gi, g in enumerate(ordered):
if accepts >= max(1, int(max_accepts)):
break
raise_if_cancelled()
touch_heartbeat()
if gi == 0 or gi % 2 == 0 or accepts > 0:
pct = 48.0 + 25.0 * (gi / max(len(ordered), 1))
report_progress(
"rigid_orbit",
pct=min(72.0, pct),
message=f"group {gi + 1}/{len(ordered)} accepts={accepts}",
group_key=g["key"],
accepts=accepts,
)
cands = rigid_orbit_candidates_for_group(
pos,
links,
g,
angle_step=use_angle,
radii=rads,
bridges_per_group=bridges_per_group,
cand_cap=cand_cap,
x0=cur_x,
area0=cur_area,
x_slack=slack,
)
if not cands:
trace.append({"group_key": g["key"], "skipped": "no_candidates"})
continue
applied = False
for cand in cands[:verify_k]:
trial = dict(pos)
movers_ids = set(cand.get("positions") or {})
for n, xy in (cand.get("positions") or {}).items():
trial[n] = xy
frozen = set(g.get("shared") or g.get("pivots") or [])
if _footprint_hits(trial, movers_ids, frozen) > 0:
continue
raise_if_cancelled()
touch_heartbeat()
x1 = count_edge_crossings(trial, links)
if x1 > cur_x + slack:
continue
area1 = _bbox_area(trial)
area_ratio = cur_area / max(area1, 1e-6)
util_proxy_up = area_ratio >= 1.004
x_down = x1 < cur_x
if not util_proxy_up and not x_down:
continue
# Accept (footprint-ok). Residual AABB → ensure_zero_overlap in layout_tool.
pos = trial
cur_x = x1
cur_area = area1
moved.update(movers_ids)
accepts += 1
applied = True
trace.append(
{
"group_key": g["key"],
"applied": True,
"inner": cand.get("inner"),
"outer": cand.get("outer"),
"angle_deg": cand.get("angle_deg"),
"radius_scale": cand.get("radius_scale"),
"bridge_len": cand.get("bridge_len"),
"crossings": x1,
"area_ratio": round(area_ratio, 4),
"movers_n": len(movers_ids),
}
)
break
if not applied:
best = cands[0]
trace.append(
{
"group_key": g["key"],
"skipped": "no_accept",
"best_area_ratio": best.get("area_ratio"),
"best_delta_touch": best.get("delta_touch"),
}
)
st.positions = pos
fin = score_state(st, fast=True)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
end_ov = len(overlapping_nodes(st))
# Residual overlaps: keep accepted moves; layout_tool ensure_zero_overlap repairs.
# Only hard-revert when we never accepted and somehow got worse.
if end_ov > before_ov and accepts == 0:
return OpResult(
state=state.copy(),
moved=set(),
op="rigid_orbit",
params={
"reverted": True,
"reason": "overlaps",
"start_util": before_util,
"start_crossings": before_x,
"trace": trace,
},
note="rigid_orbit:reverted overlaps",
)
util_up = end_util > before_util + 1e-6
area_up = (area0 / max(_bbox_area(st.positions), 1e-6)) >= 1.004
x_ok = end_x <= before_x + slack
# Crossing-only gains count (util may stay flat when bbox is hull-dominated).
if not ((util_up or area_up or (accepts > 0 and end_x < before_x)) and x_ok) and accepts == 0:
meta = {
"reverted": True,
"reason": "no_gain",
"start_util": before_util,
"end_util": before_util,
"start_crossings": before_x,
"end_crossings": before_x,
"x_slack": slack,
"scanned_groups": len(ordered),
"trace": trace[:40],
}
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["rigid_orbit"] = meta
return OpResult(
state=st0,
moved=set(),
op="rigid_orbit",
params=meta,
note="rigid_orbit:reverted no_gain",
)
meta = {
"reverted": False,
"start_util": before_util,
"end_util": end_util,
"start_crossings": before_x,
"end_crossings": end_x,
"accepted": accepts,
"moved_n": len(moved),
"x_slack": slack,
"scanned_groups": len(ordered),
"bbox_area_ratio": round(area0 / max(_bbox_area(st.positions), 1e-6), 4),
"trace": trace[:60],
}
st.meta = dict(st.meta or {})
st.meta["rigid_orbit"] = meta
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="rigid_orbit",
params=meta,
note=(
f"rigid_orbit util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} accepted={accepts}/{len(ordered)}"
),
)
def rigid_orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("top_n") is not None:
try:
out["top_n"] = max(1, min(80, int(o["top_n"])))
except (TypeError, ValueError):
pass
if o.get("bridges_per_group") is not None:
try:
out["bridges_per_group"] = max(1, min(12, int(o["bridges_per_group"])))
except (TypeError, ValueError):
pass
if o.get("angle_step") is not None:
try:
out["angle_step"] = max(10, min(90, int(o["angle_step"])))
except (TypeError, ValueError):
pass
if o.get("cand_cap") is not None:
try:
out["cand_cap"] = max(20, min(400, int(o["cand_cap"])))
except (TypeError, ValueError):
pass
if o.get("max_accepts") is not None:
try:
out["max_accepts"] = max(1, min(64, int(o["max_accepts"])))
except (TypeError, ValueError):
pass
if o.get("x_slack") is not None:
try:
out["x_slack"] = max(0, int(o["x_slack"]))
except (TypeError, ValueError):
pass
if isinstance(o.get("radii"), (list, tuple)) and o["radii"]:
try:
out["radii"] = tuple(float(r) for r in o["radii"])
except (TypeError, ValueError):
pass
groups = o.get("rigid_groups") or o.get("_rigid_groups")
if isinstance(groups, list):
out["groups"] = groups
# round defaults true; allow explicit false for preview-only catalog
if "round" in o:
v = o.get("round")
out["round"] = v in (True, 1, "1", "true", "yes", "on") or v is True
else:
out["round"] = True
if o.get("group_key") is not None:
out["group_key"] = str(o.get("group_key") or "").strip()
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
"""Minimal ring faces: detect chordless cycles and piercing by foreign edges.
Stage-1/2 use this to keep ring interiors hollow (metro face integrity).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_api.mcp_remote.topology.layout_topology_quality import (
RING_MAX_LEN,
find_short_chordless_cycles,
)
@dataclass(frozen=True)
class RingFace:
node_ids: tuple[str, ...]
def as_dict(self) -> dict[str, Any]:
return {"n": len(self.node_ids), "node_ids": list(self.node_ids)}
def extract_ring_faces(
state: LayoutState,
*,
max_len: int = min(6, RING_MAX_LEN),
max_cycles: int = 80,
) -> list[RingFace]:
cycles = find_short_chordless_cycles(
state.adj, max_len=max_len, max_cycles=max_cycles
)
faces = [RingFace(tuple(c)) for c in cycles]
faces.sort(key=lambda f: (len(f.node_ids), f.node_ids[0] if f.node_ids else ""))
return faces
def _point_in_poly(
x: float, y: float, poly: list[tuple[float, float]]
) -> bool:
"""Ray casting; boundary counts as outside (conservative)."""
n = len(poly)
if n < 3:
return False
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if ((yi > y) != (yj > y)) and (
x < (xj - xi) * (y - yi) / (yj - yi + 1e-15) + xi
):
inside = not inside
j = i
return inside
def ring_polygon(
face: RingFace, pos: dict[str, tuple[float, float]]
) -> list[tuple[float, float]] | None:
pts = [pos[n] for n in face.node_ids if n in pos]
if len(pts) < 3:
return None
return pts
def count_ring_pierces(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
faces: list[RingFace],
) -> dict[str, Any]:
"""Foreign edges that properly cross a ring boundary edge."""
pierced = 0
pierce_x = 0
for face in faces:
rset = set(face.node_ids)
poly = ring_polygon(face, pos)
if not poly:
continue
n = len(face.node_ids)
ring_edges = []
for i in range(n):
a, b = face.node_ids[i], face.node_ids[(i + 1) % n]
if a in pos and b in pos:
ring_edges.append((a, b))
hit = False
for a, b in links:
if a not in pos or b not in pos:
continue
# foreign if at least one endpoint outside ring
if a in rset and b in rset:
continue
p1, p2 = pos[a], pos[b]
for u, v in ring_edges:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
pierce_x += 1
hit = True
if hit:
pierced += 1
return {
"ring_count": len(faces),
"rings_pierced": pierced,
"pierce_crossings": pierce_x,
}
def place_ring_rectangle(
face: RingFace,
*,
center: tuple[float, float],
width: float,
height: float,
start_angle: float = -math.pi / 2,
) -> dict[str, tuple[float, float]]:
"""Place ring nodes on an axis-aligned rectangle (hollow face)."""
nodes = list(face.node_ids)
n = len(nodes)
if n < 3:
return {}
cx, cy = center
hw, hh = width / 2, height / 2
# Perimeter parametrization 0..1 around rectangle
perim = 2 * (width + height)
out: dict[str, tuple[float, float]] = {}
for i, nid in enumerate(nodes):
t = (i / n + (start_angle + math.pi / 2) / (2 * math.pi)) % 1.0
d = t * perim
if d <= width:
x, y = cx - hw + d, cy - hh
elif d <= width + height:
x, y = cx + hw, cy - hh + (d - width)
elif d <= 2 * width + height:
x, y = cx + hw - (d - width - height), cy + hh
else:
x, y = cx - hw, cy + hh - (d - 2 * width - height)
out[nid] = (x, y)
return out
def eject_intruders(
pos: dict[str, tuple[float, float]],
faces: list[RingFace],
*,
push: float = 40.0,
protected: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Push non-ring nodes out of ring polygons."""
protected = protected or set()
out = dict(pos)
for face in faces:
rset = set(face.node_ids)
poly = ring_polygon(face, out)
if not poly:
continue
cx = sum(p[0] for p in poly) / len(poly)
cy = sum(p[1] for p in poly) / len(poly)
for nid, (x, y) in list(out.items()):
if nid in rset or nid in protected:
continue
if not _point_in_poly(x, y, poly):
continue
dx, dy = x - cx, y - cy
dist = math.hypot(dx, dy) or 1.0
out[nid] = (cx + dx / dist * (dist + push), cy + dy / dist * (dist + push))
return out
def ring_faces_report(state: LayoutState) -> dict[str, Any]:
faces = extract_ring_faces(state)
pierces = count_ring_pierces(state.positions, state.links, faces)
return {
**pierces,
"sample": [f.as_dict() for f in faces[:10]],
"tip": "最小环应中空;channel_metro / protect_rings 优先降 pierced。",
}

View file

@ -0,0 +1,35 @@
"""Ring/chain layout entry: dual-hub min-rings first, else UME petals.
- ``min_rings``: two agg hubs + ≥2 parallel corridors → nested ellipse bands
(A1-ULU / PLAU↔ATP style). Prefer when structure says ``agg_bar``.
- ``ume_petals``: rect-perimeter petals around AN anchors (legacy Sugiyama path).
"""
from __future__ import annotations
from netx_api.mcp_remote.topology.layout_ops.min_rings import build_min_ring_skeleton
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_ops.sugiyama import build_sugiyama_layout
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
params = params or LayoutParams()
dual = build_min_ring_skeleton(state, params)
if dual is not None:
return OpResult(
state=dual.state,
moved=dual.moved,
op="build_ring_skeleton",
params={**(dual.params or {}), "mode": "min_rings"},
note=dual.note,
)
out = build_sugiyama_layout(state, params)
st = out.state
st.meta["rings_mode"] = st.meta.get("rings_mode") or "ume_petals"
return OpResult(
state=st,
moved=out.moved,
op="build_ring_skeleton",
params={**(out.params or {}), "mode": st.meta.get("rings_mode", "ume_petals")},
note=out.note or "UME petal rect-perimeter skeleton",
)

View file

@ -0,0 +1,153 @@
"""Block / scope selection — ops act only on active nodes."""
from __future__ import annotations
from typing import Any, Iterable
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
def active_nodes(state: LayoutState) -> set[str]:
"""Nodes currently in scope (all if scope is None/empty meaning unrestricted)."""
if state.scope is None:
return set(state.positions.keys())
return {n for n in state.scope if n in state.positions}
def movable_nodes(state: LayoutState, *, respect_pins: bool = True) -> set[str]:
"""Nodes an op may move: in scope, not hard agg/core, optionally not soft-pinned."""
act = active_nodes(state)
hard = {n for n in act if state.layers.get(n) in ("agg", "core")}
out = act - hard
if respect_pins:
out -= set(state.pinned)
return out
def list_blocks(
state: LayoutState,
*,
mode: str = "component",
) -> list[set[str]]:
"""Partition graph into blocks for per-block recipes.
Modes:
- component: hard CC (default; existing recipes)
- layer / bbox_quad: coarse buckets
- hub_territory: soft blocks by hub-seeded BFS (our algo)
- leiden / soft: hub territories + optional igraph Leiden on leftovers
"""
ids = set(state.positions.keys())
m = (mode or "component").strip().lower()
if m == "auto":
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
m = resolve_block_mode(state, "auto")
if m == "component":
active = {
n
for n in ids
if state.layers.get(n) in ("core", "agg", "access")
}
comps = connected_components(active, state.adj)
blocks = [set(c) for c in comps if c]
# orphans / other
rest = ids - {n for b in blocks for n in b}
if rest:
blocks.append(rest)
return blocks
if m in {"hub_territory", "leiden", "soft"}:
from netx_api.mcp_remote.topology.layout_ops.partition import (
partition_soft_blocks,
soft_blocks_as_sets,
)
return soft_blocks_as_sets(partition_soft_blocks(state, mode=m))
if m == "layer":
by: dict[str, set[str]] = {}
for n in ids:
by.setdefault(state.layers.get(n, "other"), set()).add(n)
return [by[k] for k in sorted(by.keys()) if by[k]]
if m == "bbox_quad":
if not ids:
return []
x0, y0, x1, y1 = bbox(state.positions)
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
quads: list[set[str]] = [set() for _ in range(4)]
for n, (x, y) in state.positions.items():
qi = (0 if x < mx else 1) + (0 if y < my else 2)
quads[qi].add(n)
return [q for q in quads if q]
# single full block
return [ids] if ids else []
def select_scope(
state: LayoutState,
*,
mode: str = "all",
node_ids: Iterable[str] | None = None,
component_index: int | None = None,
layer: str | None = None,
bbox_region: tuple[float, float, float, float] | None = None,
) -> OpResult:
"""Set state.scope. mode=all clears scope (full graph)."""
st = state.copy()
pos = st.positions
if mode == "all" or (mode == "ids" and not node_ids):
st.scope = None
return OpResult(
state=st, moved=set(), op="select_scope", params={"mode": "all"}, note="scope=all"
)
if mode == "ids" and node_ids is not None:
scope = {n for n in node_ids if n in pos}
elif mode == "component":
blocks = list_blocks(st, mode="component")
idx = 0 if component_index is None else int(component_index)
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
elif mode == "layer":
lyr = layer or "access"
scope = {n for n in pos if st.layers.get(n) == lyr}
elif mode == "bbox" and bbox_region is not None:
x0, y0, x1, y1 = bbox_region
scope = {
n
for n, (x, y) in pos.items()
if x0 <= x <= x1 and y0 <= y <= y1
}
elif mode == "bbox_quad":
blocks = list_blocks(st, mode="bbox_quad")
idx = 0 if component_index is None else int(component_index)
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
else:
scope = set(pos.keys())
st.scope = scope
return OpResult(
state=st,
moved=set(),
op="select_scope",
params={"mode": mode, "scope_n": len(scope), "component_index": component_index},
note=f"scope={mode} n={len(scope)}",
)
def map_blocks(
state: LayoutState,
*,
mode: str = "component",
) -> OpResult:
"""Record block partition in meta (for agents / foreach). Does not change scope."""
st = state.copy()
blocks = list_blocks(st, mode=mode)
st.meta["blocks"] = [sorted(b) for b in blocks]
st.meta["block_mode"] = mode
return OpResult(
state=st,
moved=set(),
op="map_blocks",
params={"mode": mode, "blocks_n": len(blocks), "sizes": [len(b) for b in blocks]},
note=f"blocks={len(blocks)} mode={mode}",
)

View file

@ -0,0 +1,57 @@
"""Score a LayoutState with layout_metrics + layout_stats composite."""
from __future__ import annotations
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
def score_state(
state: LayoutState,
*,
ume_reference: bool = False,
fast: bool | None = None,
) -> dict[str, Any]:
nodes = [
{
"fabric_node_id": n,
"name": state.names.get(n, n),
"x": state.positions[n][0],
"y": state.positions[n][1],
}
for n in state.positions
]
edges = [{"a_node_id": a, "b_node_id": b} for a, b in state.links]
# Giant canvases: skip ring pierce (O(rings·E²)-ish) unless forced full.
if fast is None:
fast = len(nodes) >= 600 or len(state.links) >= 700
return analyze_layout_stats(
nodes, edges, ume_reference=ume_reference, fast=bool(fast)
)
def score_op(state: LayoutState, *, ume_reference: bool = False) -> OpResult:
st = state.copy()
m = score_state(st, ume_reference=ume_reference)
st.meta["last_score"] = m
report = m.get("report") or {}
verdict = report.get("verdict") or m.get("summary") or {}
return OpResult(
state=st,
moved=set(),
op="score",
params={
"total": verdict.get("total"),
"overall": verdict.get("overall"),
"headline": verdict.get("headline"),
"overlap": (report.get("overlap") or {}).get("status"),
"crossing": (report.get("crossing") or {}).get("status"),
"sparsity": (report.get("sparsity") or {}).get("status"),
"edge_crossings": m.get("edge_crossings"),
"nn_p50": m.get("nn_p50"),
"space_utilization": m.get("space_utilization"),
},
note=f"score={verdict.get('total')} {verdict.get('headline')}",
)

View file

@ -0,0 +1,87 @@
"""Atomic op: place side-branch NEs on both sides of spine."""
from __future__ import annotations
import math
from netx_api.mcp_remote.topology.layout_ops.graph_util import spine_backbone
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def place_side_branches(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""X-gain + lane push for non-pinned / off-spine access nodes."""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
if not pos:
return OpResult(state=st, moved=set(), op="place_side_branches", note="empty")
# Amplify X about global center (borrow left/right).
xs = [pos[n][0] for n in pos]
cx = sum(xs) / len(xs)
pos = {n: (cx + (x - cx) * params.x_gain, y) for n, (x, y) in pos.items()}
moved = set(pos.keys())
ens = [n for n in pos if st.layers.get(n) == "access"]
if ens:
if st.spine:
spine = spine_backbone([n for n in ens if n in st.spine], st.adj, st.names)
if len(spine) < 2:
spine = spine_backbone(ens, st.adj, st.names)
else:
spine = spine_backbone(ens, st.adj, st.names)
spine_set = set(spine)
st.spine = spine_set
tang: dict[str, tuple[float, float]] = {}
for i, n in enumerate(spine):
if i + 1 < len(spine):
x0, y0 = pos[n]
x1, y1 = pos[spine[i + 1]]
elif i > 0:
x1, y1 = pos[n]
x0, y0 = pos[spine[i - 1]]
else:
tang[n] = (1.0, 0.0)
continue
dx, dy = x1 - x0, y1 - y0
L = math.hypot(dx, dy) or 1.0
tang[n] = (dx / L, dy / L)
for n in ens:
if n in spine_set or n in st.pinned:
continue
sx, sy = pos[n]
nearest = min(
spine, key=lambda s: math.hypot(pos[s][0] - sx, pos[s][1] - sy)
)
tx, ty = tang.get(nearest, (1.0, 0.0))
nx, ny = -ty, tx
px, py = pos[nearest]
cross = tx * (sy - py) - ty * (sx - px)
sign = 1.0 if cross >= 0 else -1.0
cur = math.hypot(sx - px, sy - py)
target = max(params.lane, cur)
if cur < 1e-6:
pos[n] = (px + nx * sign * target, py + ny * sign * target)
elif cur < target:
sc = target / cur
pos[n] = (px + (sx - px) * sc, py + (sy - py) * sc)
# Pin skeleton after sides placed
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in st.spine
}
st.positions = pos
st.pinned = pin
st.last_moved = moved - pin
return OpResult(
state=st,
moved=st.last_moved,
op="place_side_branches",
params={"x_gain": params.x_gain, "lane": params.lane, "pinned_n": len(pin)},
note="side lanes from spine; pinned=agg+core+spine",
)

View file

@ -0,0 +1,497 @@
"""Select dual-unit batches to sink from a root canvas onto a child region.
Pure selection / park / per-unit layout helpers — HTTP moves live in http_tools.
"""
from __future__ import annotations
from typing import Any
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, layout_dual_unit
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState
def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
counts: dict[str, int] = {}
for u in units:
for p in (u.portal_a, u.portal_b):
counts[p] = counts.get(p, 0) + 1
return counts
def unit_detach_score(u: DualUnit, share: dict[str, int]) -> tuple[int, int, int]:
"""Tie-break only: less shared portals, then lower unit_id (stable)."""
shared = sum(1 for p in (u.portal_a, u.portal_b) if share.get(p, 0) > 1)
share_sum = share.get(u.portal_a, 0) + share.get(u.portal_b, 0)
return (shared, share_sum, int(u.unit_id))
def select_dual_unit_batch(
units: list[DualUnit],
*,
max_units: int = 3,
min_nodes: int = 8,
max_nodes: int = 300,
max_batch_nodes: int = 400,
exclude_ids: set[str] | None = None,
keep_ids: set[str] | None = None,
sink_ids: set[str] | None = None,
prefer_pure: bool = False,
prefer_core_eye: bool = True,
prefer_top_eye: bool | None = None,
layers: dict[str, str] | None = None,
) -> list[DualUnit]:
"""Greedy pick detachable dual-units within size / batch caps.
Eyes walk **top→down** (core → agg → access): prefer core/agg portal
pairs and maximize movable membership. ``prefer_core_eye`` is an alias
for ``prefer_top_eye`` (default true).
"""
exclude_ids = exclude_ids or set()
keep_ids = keep_ids or set()
layers = layers or {}
if prefer_top_eye is None:
prefer_top_eye = prefer_core_eye
share = _portal_share_counts(units)
candidates: list[DualUnit] = []
for u in units:
members = u.member_ids()
if not members:
continue
movable = members - keep_ids
if sink_ids is not None:
movable = movable & sink_ids
if not movable or movable.issubset(exclude_ids):
continue
n = len(members)
if n < int(min_nodes) or n > int(max_nodes):
continue
if members and members.issubset(exclude_ids | keep_ids):
continue
# Need enough *movable* mass for this level phase.
if len(movable) < max(2, min(4, int(min_nodes) // 2)):
continue
candidates.append(u)
def _movable(u: DualUnit) -> set[str]:
m = u.member_ids() - keep_ids
if sink_ids is not None:
m = m & sink_ids
return m
def _portal_tier(nid: str) -> int:
ly = (layers.get(nid) or "").strip().lower()
if ly == "core":
return 0
if ly == "agg":
return 1
if ly == "access":
return 2
return 3
def _eye_key(u: DualUnit) -> tuple[int, int]:
t = sorted((_portal_tier(u.portal_a), _portal_tier(u.portal_b)))
return (t[0], t[1])
def _score(u: DualUnit) -> tuple:
mov = _movable(u)
keep_portals = sum(1 for p in (u.portal_a, u.portal_b) if p in keep_ids)
pure_penalty = keep_portals if prefer_pure else 0
# Top-down eye tier, then max movable / membership.
eye = _eye_key(u) if prefer_top_eye else (0, 0)
return (
pure_penalty,
eye,
-len(mov),
-len(u.member_ids()),
*unit_detach_score(u, share),
)
candidates.sort(key=_score)
picked: list[DualUnit] = []
claimed: set[str] = set()
for u in candidates:
if len(picked) >= int(max_units):
break
movable = _movable(u)
interior = movable - {u.portal_a, u.portal_b}
if interior & claimed:
continue
next_ids = claimed | movable
if len(next_ids) > int(max_batch_nodes):
continue
picked.append(u)
claimed |= movable
return picked
def batch_node_ids(
units: list[DualUnit],
*,
keep_ids: set[str] | None = None,
sink_ids: set[str] | None = None,
) -> list[str]:
keep_ids = keep_ids or set()
out: list[str] = []
seen: set[str] = set()
for u in units:
for nid in sorted(u.member_ids()):
if not nid or nid in seen or nid in keep_ids:
continue
if sink_ids is not None and nid not in sink_ids:
continue
seen.add(nid)
out.append(nid)
return out
def leftover_batch_ids(
source_ids: list[str],
*,
max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None,
keep_ids: set[str] | None = None,
sink_ids: set[str] | None = None,
) -> list[str]:
"""When dual_units are exhausted, take a plain leftover chunk."""
exclude_ids = exclude_ids or set()
keep_ids = keep_ids or set()
out: list[str] = []
for nid in source_ids:
sid = str(nid or "").strip()
if not sid or sid.startswith("region:"):
continue
if sid in exclude_ids or sid in keep_ids:
continue
if sink_ids is not None and sid not in sink_ids:
continue
out.append(sid)
if len(out) >= int(max_batch_nodes):
break
return out
def park_positions(
src_pos: dict[str, tuple[float, float]],
node_ids: list[str],
*,
sink_pos: dict[str, tuple[float, float]] | None = None,
pad: float = 280.0,
links: list[tuple[str, str]] | None = None,
) -> list[dict[str, Any]]:
"""Park batch via compose_orbit-style block sweep (not fixed right)."""
pts = {nid: src_pos[nid] for nid in node_ids if nid in src_pos}
if not pts:
out: list[dict[str, Any]] = []
cols = max(4, int(len(node_ids) ** 0.5) or 1)
for i, nid in enumerate(node_ids):
out.append(
{
"fabric_node_id": nid,
"x": float((i % cols) * 160),
"y": float((i // cols) * 120),
}
)
return out
# Normalize local bbox to origin before orbit attach.
xs = [p[0] for p in pts.values()]
ys = [p[1] for p in pts.values()]
min_x, min_y = min(xs), min(ys)
local = {nid: (x - min_x, y - min_y) for nid, (x, y) in pts.items()}
world, _meta = orbit_attach_to_sink(
local,
sink_pos or {},
links=links or [],
pad=pad,
)
return positions_to_patch(world)
def units_as_batch_rows(
units: list[DualUnit],
names: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
names = names or {}
rows: list[dict[str, Any]] = []
for u in units:
d = u.as_dict(names)
rows.append(
{
"unit_id": d.get("unit_id"),
"node_count": d.get("node_count"),
"portals": [d.get("portal_a"), d.get("portal_b")],
"names": [d.get("portal_a_name"), d.get("portal_b_name")],
"node_ids": d.get("node_ids") or [],
}
)
return rows
def subgraph_state(state: LayoutState, members: set[str]) -> LayoutState:
"""Induced subgraph for one dual-unit (local layout_dual_unit)."""
ids = {m for m in members if m}
adj = {n: {v for v in state.adj.get(n, ()) if v in ids} for n in ids}
links = [(a, b) for a, b in state.links if a in ids and b in ids]
return LayoutState(
positions={n: state.positions[n] for n in ids if n in state.positions},
names={n: state.names.get(n, n) for n in ids},
layers={n: state.layers.get(n, "access") for n in ids},
links=links,
adj=adj,
meta={"ids": sorted(ids)},
)
def _bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
return min(xs), min(ys), max(xs), max(ys)
def _translate(
pos: dict[str, tuple[float, float]], dx: float, dy: float
) -> dict[str, tuple[float, float]]:
return {n: (x + dx, y + dy) for n, (x, y) in pos.items()}
def orbit_attach_to_sink(
local: dict[str, tuple[float, float]],
sink_pos: dict[str, tuple[float, float]],
*,
links: list[tuple[str, str]] | None = None,
pad: float = 280.0,
angle_step: int = 30,
radii: tuple[float, ...] = (0.75, 1.0, 1.25, 1.55, 1.95),
cand_cap: int = 96,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Block-sweep attach: reuse compose_orbit orphan/portal orbit pick.
Scores candidates by (partial crossings, footprint hits, bridge length)
against the existing sink world — same spirit as compose_orbit hang-block.
"""
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import (
_orbit_candidates_one_portal,
_orbit_candidates_orphan,
_orbit_candidates_two_portal,
_pick_best,
_thin_candidates,
)
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
_rank_shared_pivots,
_rigid_align_to_world,
)
link_list = list(links or [])
if not local:
return {}, {"via": "empty", "cands": 0}
if not sink_pos:
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
seeded = {
n: (x - min_x + 40.0, y - min_y + 40.0) for n, (x, y) in local.items()
}
return seeded, {"via": "seed", "cands": 1, "score": [0, 0, 0.0]}
world = dict(sink_pos)
shared_raw = [nid for nid in local if nid in world]
# membership hint: portals already on sink count as glue
membership = {n: 2 for n in shared_raw}
shared = _rank_shared_pivots(shared_raw, membership)
exclusive = [n for n in local if n not in set(shared)]
old_ids = set(world)
if shared:
prefer = None
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
if wxs and wys:
prefer = (0.5 * (min(wxs) + max(wxs)), 0.5 * (min(wys) + max(wys)))
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=link_list,
)
if len(shared) >= 2:
cands = _orbit_candidates_two_portal(
world,
aligned,
shared[:2],
exclusive,
local,
angle_step=angle_step,
)
via = "orbit_dual"
else:
cands = _orbit_candidates_one_portal(
world,
aligned,
shared[0],
exclusive,
angle_step=angle_step,
radii=radii,
)
via = "orbit_portal"
else:
cands = _orbit_candidates_orphan(
world,
local,
pad=pad,
angle_step=angle_step,
radii=radii,
links=link_list,
)
via = "orbit_orphan"
cands = _thin_candidates(cands, cand_cap)
new_ids = set(exclusive) if exclusive else set(local) - old_ids
# Fresh rescore every call — never sticky pick.
best, best_sc, best_i = _pick_best(cands, link_list, new_ids, old_ids)
out = {n: best[n] for n in local if n in best}
meta = {
"via": via,
"shared_n": len(shared),
"shared": shared[:4],
"cands": len(cands),
"best_i": best_i,
"score": [best_sc[0], best_sc[1], round(best_sc[2], 1)],
"exclusive_n": len(exclusive),
}
return out, meta
def layout_and_pack_batch(
state: LayoutState,
units: list[DualUnit],
*,
sink_pos: dict[str, tuple[float, float]] | None = None,
pad: float = 280.0,
unit_gap: float = 220.0,
params: LayoutParams | None = None,
links: list[tuple[str, str]] | None = None,
) -> tuple[dict[str, tuple[float, float]], list[dict[str, Any]], dict[str, Any]]:
"""layout_dual_unit each unit, strip-pack, then orbit-attach onto sink.
Attach uses compose_orbit block sweep (partial crossings / overlap /
bridge length) — not a fixed right-side park.
"""
params = params or LayoutParams()
reports: list[dict[str, Any]] = []
packed: dict[str, tuple[float, float]] = {}
cursor_x = 0.0
row_max_h = 0.0
for u in units:
members = u.member_ids()
if not members:
continue
sub = subgraph_state(state, members)
# Seed missing coords so layout_dual_unit has a canvas.
for n in members:
if n not in sub.positions:
sub.positions[n] = (0.0, 0.0)
op = layout_dual_unit(sub, params, unit=u)
local = {
n: op.state.positions[n]
for n in members
if n in op.state.positions
}
if not local:
reports.append(
{
"unit_id": u.unit_id,
"accepted": False,
"note": "no_positions",
"unit_crossings": None,
}
)
continue
# Align shared portals already placed by an earlier unit in this batch.
shared = [p for p in (u.portal_a, u.portal_b) if p in packed and p in local]
if shared:
# Translate so first shared portal matches world.
piv = shared[0]
dx = packed[piv][0] - local[piv][0]
dy = packed[piv][1] - local[piv][1]
local = _translate(local, dx, dy)
# Exclusive members only — keep prior portal coords.
for n, xy in local.items():
if n not in packed:
packed[n] = xy
min_x, min_y, max_x, max_y = _bbox(
{n: packed[n] for n in members if n in packed}
)
cursor_x = max(cursor_x, max_x + float(unit_gap))
row_max_h = max(row_max_h, max_y - min_y)
else:
min_x, min_y, max_x, max_y = _bbox(local)
local = _translate(local, cursor_x - min_x, -min_y)
for n, xy in local.items():
if n not in packed:
packed[n] = xy
w = max_x - min_x
h = max_y - min_y
cursor_x += w + float(unit_gap)
row_max_h = max(row_max_h, h)
reports.append(
{
"unit_id": u.unit_id,
"accepted": bool(op.params.get("accepted")),
"unit_crossings": op.params.get("unit_crossings"),
"note": op.note,
"node_count": len(members),
"portals": [u.portal_a, u.portal_b],
}
)
if not packed:
return {}, reports, {"via": "empty"}
# Prefer fabric links from caller (sink∪source); fall back to state links.
attach_links = list(links) if links is not None else list(state.links)
world, attach_meta = orbit_attach_to_sink(
packed,
sink_pos or {},
links=attach_links,
pad=pad,
)
return world, reports, attach_meta
def positions_to_patch(
pos: dict[str, tuple[float, float]],
) -> list[dict[str, Any]]:
return [
{"fabric_node_id": nid, "x": float(xy[0]), "y": float(xy[1])}
for nid, xy in sorted(pos.items())
]
def merge_view_links(
*payloads: dict[str, Any],
) -> list[tuple[str, str]]:
"""Undirected fabric pairs from one or more view GET payloads."""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for payload in payloads:
for e in payload.get("edges") or []:
if not isinstance(e, dict):
continue
a = str(e.get("a_node_id") or e.get("source") or "").strip()
b = str(e.get("b_node_id") or e.get("target") 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

View file

@ -0,0 +1,231 @@
"""Atomic op: build corridor skeleton (Tutte + island pack)."""
from __future__ import annotations
from collections import defaultdict, deque
from itertools import product
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
bbox,
connected_components,
order_ans,
spine_backbone,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _place_component_tutte(
comp: list[str],
state: LayoutState,
params: LayoutParams,
) -> tuple[dict[str, tuple[float, float]], list[str]]:
"""Tutte embedding for one component. Returns positions + spine order (EN)."""
g, layers, names = state.adj, state.layers, state.names
edge_pairs = state.links
ans = sorted([n for n in comp if layers[n] == "agg"], key=lambda n: names[n])
ens = [n for n in comp if layers[n] == "access"]
cores = [n for n in comp if layers[n] == "core"]
ens_set = set(ens)
an_order = order_ans(ans, ens_set, g, names)
pinned: dict[str, tuple[float, float]] = {}
for i, a in enumerate(an_order):
pinned[a] = (i * params.an_gap, params.an_y)
for i, c in enumerate(sorted(cores, key=lambda n: names[n])):
pinned[c] = (
(len(an_order) - 1) * params.an_gap / 2 + i * params.an_gap,
params.an_y - params.an_gap,
)
pos: dict[str, list[float]] = {
n: [float(pinned[n][0]), float(pinned[n][1])] for n in pinned
}
if ens:
dist: dict[str, int] = {}
nearest: dict[str, str] = {}
q: deque[str] = deque()
for a in an_order:
for nb in g.get(a, ()):
if nb in ens_set and nb not in dist:
dist[nb] = 1
nearest[nb] = a
q.append(nb)
while q:
u = q.popleft()
for v in g.get(u, ()):
if v in ens_set and v not in dist:
dist[v] = dist[u] + 1
nearest[v] = nearest[u]
q.append(v)
for n in ens:
if n not in dist:
dist[n] = max(dist.values(), default=1) + 1
nearest[n] = an_order[0] if an_order else n
buckets: dict[tuple[str, int], list[str]] = defaultdict(list)
for n in ens:
buckets[(nearest[n], dist[n])].append(n)
for (an, d), group in buckets.items():
group.sort(key=lambda n: names[n])
ax = pinned[an][0] if an in pinned else 0.0
for k, n in enumerate(group):
pos[n] = [ax + (k - (len(group) - 1) / 2) * 0.35, float(d)]
for _ in range(80):
for n in ens:
neigh = [v for v in g.get(n, ()) if v in pos]
if not neigh:
continue
pos[n][0] = sum(pos[v][0] for v in neigh) / len(neigh)
pos[n][1] = sum(pos[v][1] for v in neigh) / len(neigh)
for n, xy in pinned.items():
pos[n][0], pos[n][1] = float(xy[0]), float(xy[1])
for n in ens:
if pos[n][1] < params.an_y + 0.4:
pos[n][1] = params.an_y + 0.4
raw = {n: (pos[n][0], pos[n][1]) for n in pos}
x0, y0, x1, y1 = bbox(raw)
bw, bh = max(x1 - x0, 1e-6), max(y1 - y0, 1e-6)
unit = {n: ((x - x0) / bw, (y - y0) / bh) for n, (x, y) in raw.items()}
w = max(params.an_gap * max(len(an_order), 1), 1120.0) * params.width_mul
h = max(140.0 * (1 + len(ens) ** 0.5), 840.0) * params.height_mul
node_set = set(comp)
internal = [(a, b) for a, b in edge_pairs if a in node_set and b in node_set]
best, best_c = None, 10**9
for fx, fy in product([False, True], repeat=2):
p = {
n: (((1 - x) if fx else x) * w, ((1 - y) if fy else y) * h)
for n, (x, y) in unit.items()
}
if an_order and ens:
if sum(p[a][1] for a in an_order) / len(an_order) > sum(
p[e][1] for e in ens
) / len(ens):
continue
c = count_edge_crossings(p, internal)
if c < best_c:
best_c, best = c, p
assert best is not None
x0, y0, _, _ = bbox(best)
placed = {n: (x - x0, y - y0) for n, (x, y) in best.items()}
spine = spine_backbone(ens, g, names) if ens else []
return placed, spine
def _pack_islands(
islands: list[dict[str, tuple[float, float]]],
edge_pairs: list[tuple[str, str]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
islands = sorted(islands, key=lambda p: -len(p))
cols = 2 if len(islands) >= 3 else 1
n = len(islands)
def build(flips: list[bool]) -> dict[str, tuple[float, float]]:
cells: list[list[dict]] = [[] for _ in range(cols)]
for i, isl in enumerate(islands):
loc = dict(isl)
if flips[i]:
x0, _, x1, _ = bbox(loc)
mid = (x0 + x1) / 2
loc = {nid: (2 * mid - x, y) for nid, (x, y) in loc.items()}
cells[i % cols].append(loc)
col_w = []
for c in range(cols):
w = 0.0
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
w = max(w, x1 - x0 + params.island_pad_x)
col_w.append(max(w, 1.0))
out: dict[str, tuple[float, float]] = {}
for c in range(cols):
oy = 0.0
ox = sum(col_w[:c])
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
pad = (col_w[c] - params.island_pad_x - (x1 - x0)) / 2
for nid, (x, y) in loc.items():
out[nid] = (ox + pad + (x - x0), oy + (y - y0))
oy += (y1 - y0) + params.island_pad_y
return out
k = min(n, 5)
best, best_c = None, 10**9
for bits in product([False, True], repeat=k):
flips = list(bits) + [False] * (n - k)
p = build(flips)
c = count_edge_crossings(p, edge_pairs)
if c < best_c:
best_c, best = c, p
assert best is not None
return best
def build_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
"""Place Tutte corridor skeleton; pin agg+core+spine; leave sides for next op."""
params = params or LayoutParams()
st = state.copy()
ids = list(st.meta.get("ids") or st.names.keys())
active = {i for i in ids if st.layers.get(i) in ("core", "agg", "access")}
others = sorted(
[i for i in ids if st.layers.get(i) == "other"], key=lambda n: st.names[n]
)
comps = connected_components(active, st.adj)
islands: list[dict[str, tuple[float, float]]] = []
spine_all: set[str] = set()
for c in comps:
placed, spine = _place_component_tutte(c, st, params)
if placed:
islands.append(placed)
spine_all.update(spine)
pos = _pack_islands(islands, st.links, params) if islands else {}
if others:
if pos:
x0, y0, x1, y1 = bbox(pos)
fallback_x, fallback_y = x1 + params.island_pad_x * 0.4, y0
else:
fallback_x, fallback_y = 0.0, 0.0
for i, n in enumerate(others):
neigh = [v for v in st.adj.get(n, ()) if v in pos]
if neigh:
mx = sum(pos[v][0] for v in neigh) / len(neigh)
my = sum(pos[v][1] for v in neigh) / len(neigh)
pos[n] = (
mx + params.pitch * (1.0 + (i % 3) * 0.35),
my + params.side * ((i % 5) - 2) * 0.25,
)
else:
pos[n] = (
fallback_x + (i % 3) * params.pitch,
fallback_y + (i // 3) * params.side,
)
for n in ids:
if n not in pos:
pos[n] = (0.0, 0.0)
st.positions = pos
st.spine = spine_all
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in spine_all
}
st.pinned = pin
st.last_moved = set(pos.keys())
st.meta["components"] = len(comps)
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_skeleton",
params={
"width_mul": params.width_mul,
"height_mul": params.height_mul,
"an_gap": params.an_gap,
"spine_n": len(spine_all),
"pinned_n": len(pin),
},
note="Tutte islands packed; pinned=agg+core+spine",
)

View file

@ -0,0 +1,323 @@
"""Stage-2: per soft-block stub fan + spine; accept only if global crossings drop."""
from __future__ import annotations
import math
from collections import deque
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.channels import order_stubs_crossing_aware
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _longest_path(
comp: set[str], adj: dict[str, set[str]], prefer: str | None = None
) -> list[str]:
if not comp:
return []
if len(comp) == 1:
return [next(iter(comp))]
def far(src: str) -> tuple[str, list[str]]:
prev: dict[str, str | None] = {src: None}
q: deque[str] = deque([src])
last = src
while q:
u = q.popleft()
last = u
for v in adj.get(u, ()):
if v in comp and v not in prev:
prev[v] = u
q.append(v)
path = [last]
while prev[path[-1]] is not None:
path.append(prev[path[-1]]) # type: ignore[index]
path.reverse()
return last, path
start = prefer if prefer in comp else next(iter(comp))
a, _ = far(start)
_, path = far(a)
return path
def soft_petals_greedy(
state: LayoutState,
params: LayoutParams | None = None,
*,
hub_id: str | None = None,
step: float | None = None,
min_block_size: int = 3,
) -> OpResult:
"""Fan stubs around each soft-block hub; keep a block only if crossings fall."""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch * 0.92, 160.0))
pos = dict(state.positions)
links = list(state.links)
adj = state.adj
cross = count_edge_crossings(pos, links)
blocks = partition_soft_blocks(state, mode="hub_territory")
hubs = set(pick_hub_seeds(state))
for n, ly in state.layers.items():
if ly == "core":
hubs.add(n)
if hub_id:
hub_id = str(hub_id).strip()
blocks = [b for b in blocks if b.hub_id == hub_id]
if not blocks:
return OpResult(
state=state,
moved=set(),
op="soft_petals",
params={"hub_id": hub_id},
note="hub_not_found",
)
def _commit_trial(
trial: dict[str, tuple[float, float]],
*,
hub: str,
hx: float,
hy: float,
best_c: int,
) -> tuple[dict[str, tuple[float, float]] | None, int]:
c1 = count_edge_crossings(trial, links)
if c1 >= best_c:
return None, best_c
st_try = state.copy()
st_try.positions = trial
stf = fix_overlaps_local(st_try, params).state
for hid in hubs:
if hid != hub and hid in pos:
stf.positions[hid] = pos[hid]
stf.positions[hub] = (hx, hy)
trial2 = {k: (float(v[0]), float(v[1])) for k, v in stf.positions.items()}
c2 = count_edge_crossings(trial2, links)
if c2 < best_c:
return trial2, c2
return None, best_c
accepted: list[dict[str, Any]] = []
for b in sorted(blocks, key=lambda x: -len(x.node_ids)):
hub = b.hub_id
members = [n for n in b.node_ids if n in pos]
if not hub or hub not in pos or len(members) < min_block_size:
continue
pinned = set(hubs)
hx, hy = pos[hub]
member_set = set(members)
stubs = [
n
for n in adj.get(hub, ())
if n in member_set and n not in pinned
]
if len(stubs) < 1:
continue
owner: dict[str, str] = {}
q: deque[str] = deque()
for stub in stubs:
owner[stub] = stub
q.append(stub)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v not in member_set or v == hub or v in owner or v in pinned:
continue
owner[v] = owner[u]
q.append(v)
def ang(s: str) -> float:
x, y = pos[s]
return math.atan2(y - hy, x - hx)
best_trial = None
best_c = cross
kind = None
# 1) Rigid rotate existing petal around hub (keeps local structure).
movable = [n for n in members if n != hub and n not in pinned]
for deg in (-90, -60, -45, -30, -15, 15, 30, 45, 60, 90):
rad = math.radians(deg)
ca, sa = math.cos(rad), math.sin(rad)
trial = dict(pos)
for n in movable:
x, y = pos[n]
dx, dy = x - hx, y - hy
trial[n] = (hx + dx * ca - dy * sa, hy + dx * sa + dy * ca)
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = f"rotate:{deg}"
# 2) Swap adjacent stub territories by exchanging polar angles of nodes.
stubs_geo = sorted(stubs, key=ang)
if len(stubs_geo) >= 2:
for i in range(len(stubs_geo) - 1):
a, bstub = stubs_geo[i], stubs_geo[i + 1]
ta = {n for n, o in owner.items() if o == a} | {a}
tb = {n for n, o in owner.items() if o == bstub} | {bstub}
aa, ab = ang(a), ang(bstub)
delta = ab - aa
trial = dict(pos)
for n in ta:
if n in pinned:
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) + delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
for n in tb:
if n in pinned:
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) - delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = f"swap:{a[:6]}-{bstub[:6]}"
# 3) Full re-fan with geo / crossing-aware orders + cyclic rotations.
stubs_x = order_stubs_crossing_aware(hub, stubs, adj, member_set, owner)
orders: list[list[str]] = []
for base in (stubs_geo, stubs_x):
for k in range(len(base)):
orders.append(base[k:] + base[:k])
orders.append(list(reversed(base)))
seen_ord: set[tuple[str, ...]] = set()
for stubs_ord in orders:
key = tuple(stubs_ord)
if key in seen_ord:
continue
seen_ord.add(key)
angs = [ang(s) for s in stubs_ord]
a0, a1 = min(angs) - 0.1, max(angs) + 0.1
if a1 - a0 < 0.7:
mid = sum(angs) / len(angs)
a0, a1 = mid - 1.0, mid + 1.0
# Also try a wider fan spanning more of the circle when crowded.
spans = [(a0, a1)]
if len(stubs_ord) >= 3:
mid = 0.5 * (a0 + a1)
half = max(0.9, 0.55 * (a1 - a0) + 0.4)
spans.append((mid - half, mid + half))
for sa0, sa1 in spans:
trial = dict(pos)
for i, stub in enumerate(stubs_ord):
mid = (
0.5 * (sa0 + sa1)
if len(stubs_ord) == 1
else sa0 + (sa1 - sa0) * i / (len(stubs_ord) - 1)
)
ux, uy = math.cos(mid), math.sin(mid)
trial[stub] = (hx + ux * step_px, hy + uy * step_px)
terr = {n for n, o in owner.items() if o == stub}
spine = _longest_path(terr, adj, prefer=stub)
if stub in spine:
si = spine.index(stub)
fwd, bwd = spine[si:], list(reversed(spine[: si + 1]))
spine = fwd if len(fwd) >= len(bwd) else bwd
sx, sy = trial[stub]
for k, n in enumerate(spine):
if n in pinned or n == stub:
continue
trial[n] = (
sx + ux * step_px * max(k, 1),
sy + uy * step_px * max(k, 1),
)
for n in terr:
if n in pinned or n == stub or n in spine:
continue
portals = [v for v in adj.get(n, ()) if v in trial]
if not portals:
continue
px, py = trial[portals[0]]
trial[n] = (px - uy * step_px * 0.7, py + ux * step_px * 0.7)
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = "refan"
if best_trial is None:
continue
pos = best_trial
cross = best_c
accepted.append(
{
"hub_id": hub,
"hub_name": state.names.get(hub, hub),
"crossings": cross,
"n": len(members),
"kind": kind,
}
)
if not accepted:
return OpResult(
state=state,
moved=set(),
op="soft_petals",
params={"accepted_n": 0, "hub_id": hub_id},
note="no_block_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["soft_petals"] = {
"accepted": accepted,
"crossings_after": cross,
}
return OpResult(
state=out,
moved=moved,
op="soft_petals",
params={
"accepted_n": len(accepted),
"accepted": accepted,
"hub_id": hub_id,
"crossings_after": cross,
},
note=f"soft_petals:{len(accepted)} blocks",
)
def soft_petals_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("hub_id"):
out["hub_id"] = str(overrides["hub_id"]).strip()
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("min_block_size") is not None:
try:
out["min_block_size"] = int(overrides["min_block_size"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,78 @@
"""Layout state shared by atomic ops."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class LayoutParams:
"""Tunable knobs for recipe / ops (agent edits these, not ad-hoc scripts)."""
pitch: float = 200.0
side: float = 170.0
an_gap: float = 420.0
an_y: float = 0.0
island_pad_x: float = 220.0
island_pad_y: float = 200.0
width_mul: float = 3.5
height_mul: float = 2.0
x_gain: float = 1.8
lane: float = 260.0
target_nn: float = 150.0
scale_cap: float = 2.2
target_util: float = 0.08
min_util: float = 0.03
pack_min_scale: float = 0.55
pack_iters: int = 4
# Cap uniform pack so nn_p50 does not fall below this (keeps icons readable).
pack_nn_floor: float = 140.0
overlap_iters: int = 160
overlap_step: float = 3.0
margin: float = 160.0
# explode_clusters / enforce_min_gap
cluster_thr: float = 8.0
cluster_gap: float = 35.0
min_center_gap: float = 160.0
@dataclass
class LayoutState:
positions: dict[str, tuple[float, float]] = field(default_factory=dict)
pinned: set[str] = field(default_factory=set)
names: dict[str, str] = field(default_factory=dict)
layers: dict[str, str] = field(default_factory=dict)
# Optional fabric numeric level (1 / 1.1 / 2 …); used by freeze_levels.
levels: dict[str, float] = field(default_factory=dict)
links: list[tuple[str, str]] = field(default_factory=list)
adj: dict[str, set[str]] = field(default_factory=dict)
spine: set[str] = field(default_factory=set)
# None = whole graph active; else only these ids may be moved by scoped ops.
scope: set[str] | None = None
meta: dict[str, Any] = field(default_factory=dict)
last_moved: set[str] = field(default_factory=set)
def copy(self) -> LayoutState:
return LayoutState(
positions=dict(self.positions),
pinned=set(self.pinned),
names=dict(self.names),
layers=dict(self.layers),
levels=dict(self.levels),
links=list(self.links),
adj={k: set(v) for k, v in self.adj.items()},
spine=set(self.spine),
scope=None if self.scope is None else set(self.scope),
meta=dict(self.meta),
last_moved=set(self.last_moved),
)
@dataclass
class OpResult:
state: LayoutState
moved: set[str]
op: str
params: dict[str, Any] = field(default_factory=dict)
note: str = ""

View file

@ -0,0 +1,262 @@
"""Suggest next non-dual sink batches: rank hub territories for move_nodes(park).
After the one-shot dual_unit eye is fixed, remaining access should migrate by
hub territory (not another dual sink). This module turns structure hubs +
soft_blocks into ordered batches of fabric_node_ids.
"""
from __future__ import annotations
from typing import Any
_LAYER_RANK = {"agg": 0, "core": 1, "access": 2, "external": 3, "other": 4}
def _as_id_set(raw: Any) -> set[str]:
out: set[str] = set()
if raw is None:
return out
if isinstance(raw, (str, bytes)):
s = str(raw).strip()
if s:
out.add(s)
return out
if isinstance(raw, (list, tuple, set)):
for x in raw:
s = str(x or "").strip()
if s and not s.startswith("region:"):
out.add(s)
return out
def _portal_ids_from_dual(dual_units: dict[str, Any] | None) -> set[str]:
out: set[str] = set()
if not isinstance(dual_units, dict):
return out
for u in dual_units.get("units") or []:
if not isinstance(u, dict):
continue
for k in ("portal_a", "portal_b"):
pid = str(u.get(k) or "").strip()
if pid:
out.add(pid)
return out
def _hub_index(hubs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for h in hubs or []:
if not isinstance(h, dict):
continue
hid = str(h.get("fabric_node_id") or "").strip()
if hid:
out[hid] = h
return out
def _blocks_by_hub(soft_blocks: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
if not isinstance(soft_blocks, dict):
return out
for b in soft_blocks.get("blocks") or []:
if not isinstance(b, dict):
continue
hid = str(b.get("hub_id") or "").strip()
if not hid:
continue
# Prefer larger territory if duplicate hub rows
prev = out.get(hid)
n = len([x for x in (b.get("node_ids") or []) if x])
if prev is None or n > len([x for x in (prev.get("node_ids") or []) if x]):
out[hid] = b
return out
def suggest_sink_hub_batches(
*,
hubs: list[dict[str, Any]] | None,
soft_blocks: dict[str, Any] | None,
source_ids: set[str],
sink_ids: set[str] | None = None,
exclude_ids: set[str] | None = None,
dual_units: dict[str, Any] | None = None,
min_territory: int = 1,
min_move_n: int = 1,
top_n: int = 12,
include_hub: bool = True,
only_layers: list[str] | None = None,
) -> dict[str, Any]:
"""Rank hub territories still on ``source_ids`` and not already on sink.
Ranking (desc): remaining move count → remaining access stubs → prefer agg
over core → structure hub score. Eye portals / ``exclude_ids`` never lead a
batch (and are dropped from id lists).
"""
src = {str(x) for x in (source_ids or ()) if str(x) and not str(x).startswith("region:")}
on_sink = {str(x) for x in (sink_ids or ()) if str(x)}
excluded = set(exclude_ids or ())
excluded |= _portal_ids_from_dual(dual_units)
layer_allow: set[str] | None = None
if only_layers:
layer_allow = {str(x).strip().lower() for x in only_layers if str(x).strip()}
by_hub = _hub_index(list(hubs or []))
blocks = _blocks_by_hub(soft_blocks)
# Ensure every structure hub has a block (fallback: hub + stub_ids).
for hid, h in by_hub.items():
if hid in blocks:
continue
stubs = [str(x) for x in (h.get("stub_ids") or []) if str(x)]
blocks[hid] = {
"hub_id": hid,
"method": "hub_stubs",
"node_ids": [hid, *stubs],
"node_count": 1 + len(stubs),
}
batches: list[dict[str, Any]] = []
for hid, block in blocks.items():
hmeta = by_hub.get(hid) or {}
layer = str(hmeta.get("layer") or "other").lower()
if layer_allow is not None and layer not in layer_allow:
continue
raw_ids = [str(x) for x in (block.get("node_ids") or []) if str(x)]
# Still on source, not yet on sink, not excluded portals
move_ids = [
nid
for nid in raw_ids
if nid in src and nid not in on_sink and nid not in excluded
]
if not include_hub:
move_ids = [nid for nid in move_ids if nid != hid]
# Hub may already be on sink — still migrate remaining territory
if hid in on_sink and include_hub:
move_ids = [nid for nid in move_ids if nid != hid]
# Dedup preserve order
seen: set[str] = set()
ordered: list[str] = []
# Eye portals / exclude_ids never lead a batch, but leftover stubs under
# an already-sunk or portal hub may still migrate.
if include_hub and hid in src and hid not in on_sink and hid not in excluded:
ordered.append(hid)
seen.add(hid)
for nid in move_ids:
if nid in seen:
continue
seen.add(nid)
ordered.append(nid)
# Portal hub with nothing left to move (except itself) → skip
if hid in excluded and not ordered:
continue
if len(ordered) < max(1, int(min_move_n)):
continue
# Territory signal: prefer structure territory, else remaining stubs
struct_terr = int(hmeta.get("territory") or 0)
remaining_n = len(ordered)
# Count non-hub members as remaining territory proxy
remaining_terr = max(0, remaining_n - (1 if hid in ordered else 0))
if remaining_terr < max(0, int(min_territory)):
continue
access_n = int(hmeta.get("access_neighbors") or 0)
score = float(hmeta.get("score") or 0.0)
batches.append(
{
"hub_id": hid,
"hub_name": str(hmeta.get("name") or hid),
"layer": layer,
"degree": int(hmeta.get("degree") or 0),
"structure_territory": struct_terr,
"structure_access_neighbors": access_n,
"structure_score": score,
"remaining_n": remaining_n,
"remaining_territory": remaining_terr,
"block_method": str(block.get("method") or ""),
"fabric_node_ids": ordered,
"already_on_sink": hid in on_sink,
"excluded_from_batch": sorted(
nid for nid in raw_ids if nid in excluded or nid in on_sink
)[:40],
}
)
batches.sort(
key=lambda b: (
-int(b["remaining_territory"]),
-int(b["remaining_n"]),
_LAYER_RANK.get(str(b["layer"]), 9),
-float(b["structure_score"]),
str(b["hub_name"]),
)
)
# Orphan leftovers: source ids with no hub territory still need park batches.
covered: set[str] = set()
for b in batches:
covered |= set(b.get("fabric_node_ids") or [])
orphans = sorted(
nid
for nid in src
if nid not in on_sink and nid not in excluded and nid not in covered
)
orphan_batches: list[dict[str, Any]] = []
if orphans and len(batches) < max(1, min(40, int(top_n))):
# Chunk orphans so park stays stable (~8 per batch).
chunk = 8
for i in range(0, len(orphans), chunk):
ids = orphans[i : i + chunk]
orphan_batches.append(
{
"hub_id": ids[0],
"hub_name": f"orphan_batch_{i // chunk + 1}",
"layer": "other",
"degree": 0,
"structure_territory": 0,
"structure_access_neighbors": 0,
"structure_score": 0.0,
"remaining_n": len(ids),
"remaining_territory": len(ids),
"block_method": "orphan_leftovers",
"fabric_node_ids": ids,
"already_on_sink": False,
"excluded_from_batch": [],
"orphan": True,
}
)
batches.extend(orphan_batches)
top = batches[: max(1, min(40, int(top_n)))]
for i, b in enumerate(top, start=1):
b["rank"] = i
return {
"ok": True,
"batch_count": len(top),
"excluded_n": len(excluded),
"excluded_ids": sorted(excluded)[:40],
"source_n": len(src),
"sink_n": len(on_sink),
"orphan_n": len(orphans),
"batches": top,
"hint": (
"Pick batches[0] (or pick=N) → layoutTopologyView move_nodes "
"source→sink with park=true. Do NOT sinkTopologyDualUnits again. "
"Eye portals are excluded from leading a batch. "
"orphan_batch_* = disconnected leftovers (park in chunks)."
),
}
def pick_batch(report: dict[str, Any], pick: int = 1) -> dict[str, Any] | None:
batches = list(report.get("batches") or [])
if not batches:
return None
idx = max(1, min(len(batches), int(pick or 1))) - 1
return dict(batches[idx])

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,844 @@
"""Atomic transforms: pin select, regional scale, pack util, resolve overlaps."""
from __future__ import annotations
import math
from collections import defaultdict
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
ICON_SIZE,
REC_CENTER_DX,
REC_CENTER_DY,
node_footprint,
)
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
from netx_api.mcp_remote.topology.layout_ops.scope import active_nodes, movable_nodes
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def select_pins(
state: LayoutState,
*,
mode: str = "spine",
complement_of_last: bool = False,
) -> OpResult:
"""Set pinned set by rule. Does not move positions."""
st = state.copy()
pos = st.positions
if mode == "spine":
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in st.spine
}
elif mode == "agg":
pin = {n for n in pos if st.layers.get(n) in ("agg", "core")}
elif mode == "high_degree":
deg = sorted(pos.keys(), key=lambda n: (-len(st.adj.get(n, ())), st.names.get(n, n)))
k = max(3, len(pos) // 10)
pin = set(deg[:k])
elif mode == "bbox_quartile":
if not pos:
pin = set()
else:
x0, y0, x1, y1 = bbox(pos)
mid_x = (x0 + x1) / 2
# pin left half (already "stable" after lateral stretch)
pin = {n for n, (x, _) in pos.items() if x <= mid_x}
elif mode == "prev_moved":
pin = set(st.last_moved)
else:
pin = set(st.pinned)
if complement_of_last and st.last_moved:
# pin everyone except last_moved (stabilize what we just placed)
pin = set(pos.keys()) - set(st.last_moved)
st.pinned = pin
return OpResult(
state=st,
moved=set(),
op="select_pins",
params={"mode": mode, "complement_of_last": complement_of_last, "pinned_n": len(pin)},
note=f"pins={len(pin)} mode={mode}",
)
def scale_region(
state: LayoutState,
params: LayoutParams | None = None,
*,
sx: float | None = None,
sy: float | None = None,
factor: float | None = None,
node_ids: set[str] | None = None,
only_unpinned: bool = True,
anchor: str = "centroid",
) -> OpResult:
"""Scale a subset of nodes about an anchor. Pinned stay fixed when only_unpinned."""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
if factor is not None:
sx = sy = float(factor)
sx = 1.0 if sx is None else float(sx)
sy = 1.0 if sy is None else float(sy)
if abs(sx - 1.0) < 1e-12 and abs(sy - 1.0) < 1e-12:
return OpResult(state=st, moved=set(), op="scale_region", note="noop")
if node_ids is not None:
targets = {n for n in node_ids if n in pos}
else:
targets = active_nodes(st)
if only_unpinned:
targets = {n for n in targets if n not in st.pinned}
# hard anchors never move
targets = {n for n in targets if st.layers.get(n) not in ("agg", "core")}
if not targets:
return OpResult(state=st, moved=set(), op="scale_region", note="no targets")
if anchor == "origin":
ax, ay = 0.0, 0.0
else:
ax = sum(pos[n][0] for n in targets) / len(targets)
ay = sum(pos[n][1] for n in targets) / len(targets)
moved: set[str] = set()
for n in targets:
x, y = pos[n]
pos[n] = (ax + (x - ax) * sx, ay + (y - ay) * sy)
moved.add(n)
st.positions = pos
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="scale_region",
params={"sx": sx, "sy": sy, "moved_n": len(moved), "only_unpinned": only_unpinned},
note=f"scaled {len(moved)} nodes sx={sx} sy={sy}",
)
def scale_edge_axes(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Anisotropic stretch so median |dx|/|dy| of edges approach pitch/side."""
params = params or LayoutParams()
st = state.copy()
pos = st.positions
dxs = [
abs(pos[a][0] - pos[b][0])
for a, b in st.links
if a in pos and b in pos and abs(pos[a][0] - pos[b][0]) > 1e-6
]
dys = [
abs(pos[a][1] - pos[b][1])
for a, b in st.links
if a in pos and b in pos and abs(pos[a][1] - pos[b][1]) > 1e-6
]
def _med(vals: list[float]) -> float:
vals = sorted(vals)
return vals[len(vals) // 2] if vals else 0.0
med_dx, med_dy = _med(dxs), _med(dys)
sx = sy = 1.0
if 1e-6 < med_dx < params.pitch:
sx = min(params.pitch / med_dx, 1.8)
if 1e-6 < med_dy < params.side:
sy = min(params.side / med_dy, 1.8)
out = scale_region(
st,
params,
sx=sx,
sy=sy,
node_ids=active_nodes(st),
only_unpinned=False,
anchor="centroid",
)
out.op = "scale_edge_axes"
out.params = {**out.params, "med_dx": round(med_dx, 2), "med_dy": round(med_dy, 2)}
out.note = f"edge-axis stretch sx={sx:.3f} sy={sy:.3f}"
return out
def compress_long_edges(
state: LayoutState,
params: LayoutParams | None = None,
*,
edge_len_cap: float | None = None,
pull: float = 0.45,
iters: int = 4,
frozen: set[str] | None = None,
) -> OpResult:
"""Shorten edges longer than ``edge_len_cap`` by walking ends toward mid.
This is the densify that works when nn is already sweet but bbox is huge
from metro bridges (uniform pack cannot raise util without crushing nn).
Frozen nodes (e.g. a small portal set) stay put; the other end takes the
full pull.
"""
params = params or LayoutParams()
st = state.copy()
pos = {n: (float(p[0]), float(p[1])) for n, p in st.positions.items()}
freeze = set(frozen or ())
cap = float(edge_len_cap) if edge_len_cap is not None else max(
700.0, float(params.target_nn) * 4.5
)
pull = max(0.05, min(float(pull), 0.9))
iters = max(1, int(iters))
moved: set[str] = set()
shortened = 0
def _util() -> float:
ids = list(active_nodes(st))
if len(ids) < 2:
return 0.0
sub = {n: pos[n] for n in ids if n in pos}
x0, y0, x1, y1 = bbox(sub)
return len(ids) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
util_before = _util()
for _ in range(iters):
progress = 0
for a, b in list(st.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
L = math.hypot(dx, dy)
if L <= cap + 1e-6:
continue
mx, my = 0.5 * (ax + bx), 0.5 * (ay + by)
# Desired length after this step.
target = L - pull * (L - cap)
target = max(cap, target)
if target >= L - 1e-6:
continue
half = 0.5 * target
ux, uy = dx / L, dy / L
new_a = (mx - ux * half, my - uy * half)
new_b = (mx + ux * half, my + uy * half)
a_fr, b_fr = a in freeze, b in freeze
if a_fr and b_fr:
continue
trial = dict(pos)
if a_fr and not b_fr:
# Keep a; place b on the ray at distance target.
trial[b] = (ax + ux * target, ay + uy * target)
elif b_fr and not a_fr:
trial[a] = (bx - ux * target, by - uy * target)
else:
trial[a], trial[b] = new_a, new_b
movers = {n for n in (a, b) if trial[n] != pos[n]}
# Reject if a mover lands too close to another center (avoid ov).
min_gap = max(80.0, float(params.min_center_gap) * 0.55)
too_close = False
for n in movers:
nx, ny = trial[n]
for m, (mx2, my2) in trial.items():
if m == n:
continue
if math.hypot(nx - mx2, ny - my2) < min_gap:
too_close = True
break
if too_close:
break
if too_close:
continue
for n in movers:
pos[n] = trial[n]
moved.add(n)
shortened += 1
progress += 1
if progress == 0:
break
st.positions = pos
st.last_moved = moved
util_after = _util()
return OpResult(
state=st,
moved=moved,
op="compress_long_edges",
params={
"edge_len_cap": round(cap, 1),
"pull": round(pull, 3),
"iters": iters,
"shortened": shortened,
"moved_n": len(moved),
"util_before": round(util_before, 4),
"util_after": round(util_after, 4),
"frozen_n": len(freeze),
},
note=(
f"compress_long cap={cap:.0f} shortened={shortened} "
f"util {util_before:.4f}→{util_after:.4f}"
),
)
def pack_toward_portals(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | set[str] | None = None,
scale: float | None = None,
) -> OpResult:
"""Pull non-portal nodes toward nearest portal (shorten long metro spokes).
Uniform bbox pack cannot raise util when nn is already in the sweet band —
empty area lives in long corridors. This compresses leaves toward portals
while leaving portal coordinates fixed (semi-rigid dual-unit style).
"""
params = params or LayoutParams()
st = state.copy()
portals = {str(p) for p in (portal_ids or []) if str(p) and str(p) in st.positions}
if len(portals) < 1:
return OpResult(
state=st, moved=set(), op="pack_toward_portals", note="no portals"
)
# Mild default: 0.82 keeps local clusters readable; caller may tighten.
s = 0.82 if scale is None else float(scale)
s = max(0.35, min(s, 0.98))
targets = [n for n in active_nodes(st) if n not in portals]
if not targets:
return OpResult(
state=st, moved=set(), op="pack_toward_portals", note="no leaves"
)
pos = dict(st.positions)
moved: set[str] = set()
for n in targets:
x, y = pos[n]
best_p = None
best_d = None
for p in portals:
px, py = pos[p]
d = math.hypot(x - px, y - py)
if best_d is None or d < best_d:
best_d = d
best_p = p
if best_p is None or best_d is None or best_d < 1e-6:
continue
px, py = pos[best_p]
pos[n] = (px + (x - px) * s, py + (y - py) * s)
moved.add(n)
st.positions = pos
st.last_moved = moved
ids = list(active_nodes(st))
sub = {n: pos[n] for n in ids}
x0, y0, x1, y1 = bbox(sub)
util = len(ids) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
return OpResult(
state=st,
moved=moved,
op="pack_toward_portals",
params={
"scale": round(s, 4),
"portal_n": len(portals),
"moved_n": len(moved),
"util_after": round(util, 4),
"nn_p50_after": round(_nn_p50(pos, ids), 2),
},
note=f"portal-pack s={s:.2f} moved={len(moved)} util→{util:.4f}",
)
def _nn_p50(pos: dict[str, tuple[float, float]], ids: list[str]) -> float:
nns: list[float] = []
for a in ids:
ax, ay = pos[a]
best = min(
(math.hypot(ax - pos[b][0], ay - pos[b][1]) for b in ids if b != a),
default=None,
)
if best is not None:
nns.append(best)
if not nns:
return 0.0
nns.sort()
return float(nns[len(nns) // 2])
def pack_utilization(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Shrink active scope toward centroid until util≈target (multi-iter).
Each iter clamps scale to [pack_min_scale, 1] and also refuses to push
nn_p50 below ``pack_nn_floor`` (default 140) — otherwise a sparse-but-
well-spaced canvas gets crushed and fix_overlaps blows crossings.
Use with resolve_overlaps afterwards when pack still leaves footprint hits.
"""
params = params or LayoutParams()
st = state.copy()
targets = list(active_nodes(st))
if len(targets) < 2:
return OpResult(state=st, moved=set(), op="pack_utilization", note="too few")
pos = dict(st.positions)
moved: set[str] = set()
util_before = None
scales: list[float] = []
iters = max(1, int(params.pack_iters))
# Allow aggressive pack when caller sets pack_min_scale < 0.2 (MCP tune).
floor = max(0.05, min(float(params.pack_min_scale), 0.95))
nn_floor = max(40.0, float(getattr(params, "pack_nn_floor", 140.0) or 140.0))
nn_capped = False
for _ in range(iters):
sub = {n: pos[n] for n in targets}
x0, y0, x1, y1 = bbox(sub)
bw, bh = max(x1 - x0, 1e-6), max(y1 - y0, 1e-6)
util = len(targets) * REC_CENTER_DX * REC_CENTER_DY / (bw * bh)
if util_before is None:
util_before = util
if util >= params.target_util:
break
nn = _nn_p50(pos, targets)
if nn <= nn_floor + 1e-6:
# Already at/below floor — do not shrink further.
nn_capped = True
break
s = math.sqrt(util / max(params.target_util, 1e-6))
# Hard cap: never project nn_p50 below floor (similarity shrink).
s_nn = nn_floor / nn
if s < s_nn:
nn_capped = True
s = s_nn
s = max(floor, min(s, 1.0))
if s >= 0.999:
break
# Guard: refuse a step that would still undershoot the floor.
if nn * s < nn_floor - 1e-3:
nn_capped = True
break
cx = (x0 + x1) / 2
cy = (y0 + y1) / 2
snap = {n: pos[n] for n in targets}
for n in targets:
x, y = pos[n]
pos[n] = (cx + (x - cx) * s, cy + (y - cy) * s)
moved.add(n)
nn_step = _nn_p50(pos, targets)
if nn_step + 1e-6 < nn_floor:
# Revert step — nn metric can drop faster than uniform scale predicts.
for n, p in snap.items():
pos[n] = p
nn_capped = True
break
scales.append(round(s, 4))
if nn_capped:
break
st.positions = pos
st.last_moved = moved
sub = {n: pos[n] for n in targets}
x0, y0, x1, y1 = bbox(sub)
util_after = len(targets) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
nn_after = _nn_p50(pos, targets)
return OpResult(
state=st,
moved=moved,
op="pack_utilization",
params={
"scales": scales,
"util_before": round(float(util_before or 0.0), 4),
"util_after": round(util_after, 4),
"target_util": params.target_util,
"nn_p50_after": round(nn_after, 2),
"pack_nn_floor": nn_floor,
"nn_capped": nn_capped,
"scope_n": len(targets),
},
note=(
(
f"pack iters={len(scales)} util {util_before:.4f}→{util_after:.4f}"
+ (" (nn floor)" if nn_capped else "")
)
if scales
else (
f"nn floor blocks pack (nn≈{nn_after:.1f})"
if nn_capped
else f"util already ok ({util_before:.4f})"
)
),
)
def _footprints(
pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> dict[str, tuple[float, float, float, float]]:
out = {}
for n, (x, y) in pos.items():
fx0, fy0, fx1, fy1 = node_footprint(names.get(n, n))
out[n] = (x + fx0, y + fy0, x + fx1, y + fy1)
return out
def resolve_overlaps(
state: LayoutState,
params: LayoutParams | None = None,
*,
mode: str = "lateral",
) -> OpResult:
"""Push movable nodes to clear footprint overlaps.
mode=lateral (default): project pushes onto ±X (corridor sides) to preserve
Tutte chain crossings. mode=free: isotropic push (can raise crossings a lot).
Hard-pinned = agg/core.
"""
params = params or LayoutParams()
st = state.copy()
pos = {n: [float(p[0]), float(p[1])] for n, p in st.positions.items()}
ids = list(pos)
# Move only scoped non-agg/core; outsiders are frozen obstacles.
can_move = movable_nodes(st, respect_pins=False)
# Spine = accepted pure-chain units; keep them atomic during unstick.
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")} | set(st.spine)
pinned_soft = set(st.pinned) - hard
moved: set[str] = set()
for _ in range(params.overlap_iters):
fps = _footprints({n: (pos[n][0], pos[n][1]) for n in ids}, st.names)
cell = max(REC_CENTER_DX, 80.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n in ids:
ax0, ay0, ax1, ay1 = fps[n]
cx = int(((ax0 + ax1) / 2) // cell)
cy = int(((ay0 + ay1) / 2) // cell)
buckets[(cx, cy)].append(n)
hits = 0
for n in ids:
if n not in can_move:
continue
# Prefer moving non-spine; spine only if still overlapping
ax0, ay0, ax1, ay1 = fps[n]
cx = int(((ax0 + ax1) / 2) // cell)
cy = int(((ay0 + ay1) / 2) // cell)
fx = fy = 0.0
local_hits = 0
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m == n:
continue
bx0, by0, bx1, by1 = fps[m]
if not (ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0):
continue
local_hits += 1
mx = (ax0 + ax1) / 2 - (bx0 + bx1) / 2
my = (ay0 + ay1) / 2 - (by0 + by1) / 2
if abs(mx) + abs(my) < 1e-9:
mx = 1.0 if (hash(n) & 1) else -1.0
my = 0.0
ox = min(ax1, bx1) - max(ax0, bx0)
oy = min(ay1, by1) - max(ay0, by0)
push = 0.5 * max(ox, oy, ICON_SIZE)
if m in hard:
push *= 2.0
L = math.hypot(mx, my) or 1.0
ux, uy = mx / L, my / L
if mode == "lateral":
# keep chain Y; separate in X (+ small Y for label)
fx += math.copysign(push, ux if abs(ux) > 1e-9 else 1.0)
fy += 0.25 * push if uy >= 0 else -0.15 * push
else:
fx += ux * push
fy += uy * push
if local_hits == 0:
continue
if n in pinned_soft and mode == "lateral" and local_hits < 2:
# spine lightly held unless multi-overlap
continue
hits += local_hits
L = math.hypot(fx, fy)
if L > params.overlap_step:
fx *= params.overlap_step / L
fy *= params.overlap_step / L
if L > 1e-9:
pos[n][0] += fx
pos[n][1] += fy
moved.add(n)
if hits == 0:
break
st.positions = {n: (pos[n][0], pos[n][1]) for n in ids}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="resolve_overlaps",
params={"iters": params.overlap_iters, "moved_n": len(moved), "mode": mode},
note=f"unstick mode={mode} moved={len(moved)} scope={len(can_move)}",
)
def explode_clusters(
state: LayoutState,
params: LayoutParams | None = None,
*,
thr: float | None = None,
gap: float | None = None,
axis: str = "along",
) -> OpResult:
"""Spread near-coincident clusters (generic atom).
Union nodes with center dist ≤ thr; lay cluster on a short line.
axis=along: parallel to average neighbor edge (usually fewer new crossings).
axis=perp: side-lane / normal direction.
Only moves nodes in current scope (non agg/core).
"""
params = params or LayoutParams()
thr = params.cluster_thr if thr is None else float(thr)
gap = params.cluster_gap if gap is None else float(gap)
st = state.copy()
pos = dict(st.positions)
ids = list(pos)
if len(ids) < 2:
return OpResult(state=st, moved=set(), op="explode_clusters", note="too few")
can_move = movable_nodes(st, respect_pins=False)
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")}
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, 1.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n, (x, y) in pos.items():
buckets[(int(x // cell), int(y // cell))].append(n)
for n, (x, y) in pos.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
if math.hypot(x - pos[m][0], y - pos[m][1]) <= thr:
uni(n, m)
groups: dict[str, list[str]] = defaultdict(list)
for n in ids:
groups[find(n)].append(n)
moved: set[str] = set()
for grp in groups.values():
if len(grp) < 2:
continue
grp = sorted(grp, key=lambda n: st.names.get(n, n))
vx = vy = 0.0
for n in grp:
for m in st.adj.get(n, ()):
if m not in pos:
continue
dx = pos[m][0] - pos[n][0]
dy = pos[m][1] - pos[n][1]
if abs(dx) + abs(dy) < 1e-9:
continue
if dx < 0:
dx, dy = -dx, -dy
vx += dx
vy += dy
L = math.hypot(vx, vy)
if L < 1e-9:
ax, ay = 1.0, 0.0
else:
ax, ay = vx / L, vy / L
if axis == "perp":
tx, ty = -ay, ax
else:
tx, ty = ax, ay
cx = sum(pos[n][0] for n in grp) / len(grp)
cy = sum(pos[n][1] for n in grp) / len(grp)
# Only explode clusters that touch the active scope; only move can_move.
movable = [n for n in grp if n in can_move]
if not movable:
continue
for i, n in enumerate(movable):
off = (i - (len(movable) - 1) / 2) * gap
pos[n] = (cx + tx * off, cy + ty * off)
moved.add(n)
st.positions = pos
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="explode_clusters",
params={
"thr": thr,
"gap": gap,
"axis": axis,
"moved_n": len(moved),
"scope_n": len(can_move),
},
note=f"exploded axis={axis} moved={len(moved)} scope={len(can_move)}",
)
def enforce_min_gap(
state: LayoutState,
params: LayoutParams | None = None,
*,
min_gap: float | None = None,
iters: int | None = None,
) -> OpResult:
"""Push centers apart until euclidean nearest-neighbor ≥ min_gap (unpinned)."""
params = params or LayoutParams()
min_gap = params.min_center_gap if min_gap is None else float(min_gap)
iters = params.overlap_iters if iters is None else int(iters)
st = state.copy()
pos = {n: [float(p[0]), float(p[1])] for n, p in st.positions.items()}
ids = list(pos)
can_move = movable_nodes(st, respect_pins=False)
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")}
moved: set[str] = set()
cell = max(min_gap, 1.0)
step = params.overlap_step
for _ in range(iters):
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n in ids:
buckets[(int(pos[n][0] // cell), int(pos[n][1] // cell))].append(n)
hits = 0
for n in ids:
if n not in can_move:
continue
cx, cy = int(pos[n][0] // cell), int(pos[n][1] // cell)
fx = fy = 0.0
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m == n:
continue
vx = pos[n][0] - pos[m][0]
vy = pos[n][1] - pos[m][1]
d = math.hypot(vx, vy)
if d >= min_gap:
continue
hits += 1
if d < 1e-6:
ang = (hash(n) ^ hash(m)) % 360
rad = math.radians(float(ang))
vx, vy = math.cos(rad), math.sin(rad)
d = 1e-6
else:
vx /= d
vy /= d
push = (min_gap - d) * (1.0 if m in hard else 0.5)
fx += vx * push
fy += vy * push
L = math.hypot(fx, fy)
if L > step:
fx *= step / L
fy *= step / L
if L > 1e-9:
pos[n][0] += fx
pos[n][1] += fy
moved.add(n)
if hits == 0:
break
st.positions = {n: (pos[n][0], pos[n][1]) for n in ids}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="enforce_min_gap",
params={"min_gap": min_gap, "moved_n": len(moved)},
note=f"min_gap={min_gap} moved={len(moved)}",
)
def soft_nn_scale(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Capped isotropic scale by nn_p50 toward target_nn (active scope)."""
params = params or LayoutParams()
st = state.copy()
ids = list(active_nodes(st))
pos = st.positions
if len(ids) < 2:
return OpResult(state=st, moved=set(), op="soft_nn_scale", note="too few")
nns = []
for a in ids:
ax, ay = pos[a]
best = min(
(math.hypot(ax - pos[b][0], ay - pos[b][1]) for b in ids if b != a),
default=None,
)
if best is not None:
nns.append(best)
nns.sort()
nn = nns[len(nns) // 2] if nns else 0.0
if nn <= 1e-9 or nn >= params.target_nn:
return OpResult(
state=st,
moved=set(),
op="soft_nn_scale",
params={"nn_p50": round(nn, 2), "scope_n": len(ids)},
note="no scale",
)
s = min(params.target_nn / nn, params.scale_cap)
out = scale_region(
st, params, factor=s, node_ids=set(ids), only_unpinned=False, anchor="centroid"
)
out.op = "soft_nn_scale"
out.params = {
**out.params,
"nn_p50_before": round(nn, 2),
"scale": round(s, 3),
"scope_n": len(ids),
}
out.note = f"nn soft scale s={s:.3f} scope={len(ids)}"
return out
def normalize_origin(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Translate so min corner is at margin."""
params = params or LayoutParams()
st = state.copy()
if not st.positions:
return OpResult(state=st, moved=set(), op="normalize_origin", note="empty")
x0, y0, _, _ = bbox(st.positions)
m = params.margin
pos = {
n: (x - x0 + m, y - y0 + m) for n, (x, y) in st.positions.items()
}
st.positions = pos
st.last_moved = set(pos.keys())
return OpResult(
state=st,
moved=st.last_moved,
op="normalize_origin",
params={"margin": m},
note="origin+margin",
)
OPS: dict[str, Any] = {
"select_pins": select_pins,
"scale_region": scale_region,
"scale_edge_axes": scale_edge_axes,
"pack_utilization": pack_utilization,
"resolve_overlaps": resolve_overlaps,
"explode_clusters": explode_clusters,
"enforce_min_gap": enforce_min_gap,
"soft_nn_scale": soft_nn_scale,
"normalize_origin": normalize_origin,
}

View file

@ -0,0 +1,395 @@
"""Greedy local untangle: move low-degree nodes to cut crossings (keep ov=0)."""
from __future__ import annotations
import math
import random
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings,
crossing_participation,
crossings_involving_node,
node_footprint,
top_crossing_nodes,
)
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
x, y = pos[nid]
minx, miny, maxx, maxy = node_footprint(names.get(nid, ""))
return (x + minx, y + miny, x + maxx, y + maxy)
def _node_overlaps_any(
node: str, pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> bool:
ax0, ay0, ax1, ay1 = _box(node, pos, names)
for b, (x, y) in pos.items():
if b == node:
continue
if abs(x - pos[node][0]) > 80 and abs(y - pos[node][1]) > 60:
continue
bx0, by0, bx1, by1 = _box(b, pos, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _participation(
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
) -> dict[str, int]:
_n, hit = crossing_participation(pos, links)
return hit
# Max jump from current position — prevents radial *scale* candidates from
# flinging nodes across a giant star and exploding util/bbox.
# Default raised: BTM-scale stars need ~900px jumps to escape petal local minima.
_MAX_JUMP = 900.0
_MAX_FROM_NBS = 1100.0
# Sparse metro compose can have spokes of several thousand px; a hard 1200
# cap makes untangle a no-op on the worst bridges.
_MAX_JUMP_CAP = 4800.0
def _candidates(
pos: dict[str, tuple[float, float]],
node: str,
adj: dict[str, set[str]],
rng: random.Random,
*,
max_jump: float = _MAX_JUMP,
angle_step: int = 15,
random_n: int = 12,
) -> list[tuple[float, float]]:
x, y = pos[node]
jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP))
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
step = max(10, int(angle_step))
cands: list[tuple[float, float]] = []
radii = [80, 120, 180, 260, 360, 480]
if jump > 520:
radii = radii + [640, 800]
if jump > 1200:
radii = radii + [1200, 1600, 2200, min(jump, 3200)]
if step >= 25:
radii = [r for r in radii if r >= 120]
for ang in range(0, 360, step):
for r in radii:
if r > jump + 1:
continue
rad = math.radians(ang)
cands.append((x + r * math.cos(rad), y + 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 = x - cx, y - cy
L = math.hypot(dx, dy) or 1.0
# Unit steps along/away from neighbor centroid — NOT unbounded *s* of L.
ux, uy = dx / L, dy / L
for s in (-360.0, -220.0, -120.0, 120.0, 220.0, 360.0):
if abs(s) <= jump:
cands.append((x + ux * s, y + uy * s))
cands.append((cx + ux * abs(s), cy + uy * abs(s)))
px, py = -uy, ux
for s in (-240, -160, -80, 80, 160, 240):
if abs(s) <= jump:
cands.append((x + px * s, y + py * s))
span = min(350.0, jump * 0.7)
for _ in range(max(0, int(random_n))):
cands.append((x + rng.uniform(-span, span), y + rng.uniform(-span, span)))
# Drop absurd jumps (legacy radial scale could send nodes 10k+ px away).
out: list[tuple[float, float]] = []
for cx_, cy_ in cands:
if math.hypot(cx_ - x, cy_ - y) > jump:
continue
if nbs:
mx = sum(p[0] for p in nbs) / len(nbs)
my = sum(p[1] for p in nbs) / len(nbs)
if math.hypot(cx_ - mx, cy_ - my) > nbs_cap:
continue
out.append((cx_, cy_))
return out
def untangle_crossings(
state: LayoutState,
params: LayoutParams | None = None,
*,
max_rounds: int = 200,
max_degree: int = 7,
target_crossings: int = 60,
seed: int = 7,
protect_rings: bool = False,
protect_rigid: bool | str = "portals",
moves_per_round: int = 3,
max_jump: float | None = None,
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
rank_cap: int | None = None,
angle_step: int | None = None,
refresh_every: int | None = None,
) -> OpResult:
"""Move low-degree nodes greedily to reduce global edge crossings.
``protect_rigid`` (default ``portals``): freeze shared dual-unit pivots
only — corridors/tails may still move. Use ``all`` to freeze every
compose-group member, or ``false`` to freeze nothing.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
rng = random.Random(seed)
jump = float(max_jump if max_jump is not None else _MAX_JUMP)
jump = max(200.0, min(jump, _MAX_JUMP_CAP))
n_links = len(links)
# Auto-throttle on large E so MCP stdio stays interactive.
if rank_cap is None:
rank_cap = 36 if n_links >= 400 else (60 if n_links >= 200 else 140)
if angle_step is None:
angle_step = 30 if n_links >= 400 else (20 if n_links >= 200 else 15)
if refresh_every is None:
refresh_every = 3 if n_links >= 200 else 1
rank_cap = max(8, int(rank_cap))
angle_step = max(10, int(angle_step))
refresh_every = max(1, int(refresh_every))
random_n = 4 if n_links >= 400 else (8 if n_links >= 200 else 12)
cur_c = count_edge_crossings(pos, links)
start_c = cur_c
moved: set[str] = set()
frozen: set[str] = set(frozen_ids or ())
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid)
faces = None
pierce0 = 0
if protect_rings:
from netx_api.mcp_remote.topology.layout_ops.ring_faces import (
count_ring_pierces,
extract_ring_faces,
)
faces = extract_ring_faces(st)
pierce0 = int(count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0)
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
hit: dict[str, int] = {}
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
rounds_total = max(1, int(max_rounds))
for _round in range(rounds_total):
if _round % max(1, refresh_every) == 0:
raise_if_cancelled()
pct = 70.0 + 4.0 * (_round / rounds_total)
report_progress(
"untangle",
pct=min(74.0, pct),
message=f"round {_round + 1}/{rounds_total} x={cur_c}",
step=_round + 1,
total_steps=rounds_total,
crossings=cur_c,
)
if _round % refresh_every == 0 or not hit:
hit = _participation(pos, links)
if not hit:
break
# Prefer analyze.top_crossing_nodes / explicit focus_ids first.
if not focus:
focus = {
str(r["fabric_node_id"])
for r in top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
)
}
# High-degree hubs in top_nodes cannot move under max_degree — pull in
# their low-degree neighbors that still participate in crossings.
movable_focus: set[str] = set()
for nid in focus:
if len(adj.get(nid, ())) < max_degree and nid in hit:
movable_focus.add(nid)
for nb in adj.get(nid, ()):
if nb in hit and len(adj.get(nb, ())) < max_degree:
movable_focus.add(nb)
prefer = movable_focus or focus
ranked = sorted(
hit.keys(),
key=lambda n: (
0 if n in prefer else 1,
-hit[n] / max(len(adj.get(n, ())), 1),
len(adj.get(n, ())),
-hit[n],
),
)
improved = False
moves_left = max(1, int(moves_per_round))
for node in ranked[:rank_cap]:
if moves_left <= 0:
break
if node in frozen:
continue
deg = len(adj.get(node, ()))
if deg >= max_degree:
continue
before = crossings_involving_node(node, pos, links, adj)
if before <= 0:
continue
best: tuple[int, tuple[float, float]] | None = None
x0, y0 = pos[node]
for cand in _candidates(
pos,
node,
adj,
rng,
max_jump=jump,
angle_step=angle_step,
random_n=random_n,
):
if math.hypot(cand[0] - x0, cand[1] - y0) > jump:
continue
trial = dict(pos)
trial[node] = cand
if _node_overlaps_any(node, trial, names):
continue
after = crossings_involving_node(node, trial, links, adj)
if after >= before:
continue
g2 = cur_c - before + after
if g2 >= cur_c:
continue
if faces is not None:
from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
pierce1 = int(
count_ring_pierces(trial, links, faces).get("pierce_crossings")
or 0
)
if pierce1 > pierce0:
continue
if best is None or g2 < best[0]:
best = (g2, cand)
if best is None:
continue
after_local = best[0] - cur_c + before
pos[node] = best[1]
moved.add(node)
cur_c = best[0]
# Stale hit is ok between refreshes; keep local estimate for ranking.
if after_local > 0:
hit[node] = after_local
else:
hit.pop(node, None)
if faces is not None:
from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
pierce0 = int(
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
)
improved = True
moves_left -= 1
if not improved:
break
if cur_c <= target_crossings:
break
protect_mode = (
protect_rigid
if isinstance(protect_rigid, str)
else ("portals" if protect_rigid else "off")
)
st.positions = pos
# Reuse last hit for meta (avoid an extra O(E²) pass on large graphs).
if not hit:
hit = _participation(pos, links)
st.meta["untangle"] = {
"start_crossings": start_c,
"end_crossings": cur_c,
"moved_n": len(moved),
"max_degree": max_degree,
"target_crossings": target_crossings,
"max_jump": jump,
"protect_rings": bool(protect_rings),
"protect_rigid": protect_mode,
"frozen_n": len(frozen),
"moves_per_round": int(moves_per_round),
"focus_n": len(focus),
"rank_cap": rank_cap,
"angle_step": angle_step,
"refresh_every": refresh_every,
"top_crossing_nodes": top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
),
}
return OpResult(
state=st,
moved=moved,
op="untangle_crossings",
params={
"max_rounds": max_rounds,
"max_degree": max_degree,
"target_crossings": target_crossings,
"seed": seed,
"protect_rings": bool(protect_rings),
"protect_rigid": protect_mode,
"frozen_n": len(frozen),
"moves_per_round": int(moves_per_round),
"max_jump": jump,
"focus_n": len(focus),
},
note=f"untangle {start_c}->{cur_c} moved={len(moved)} frozen={len(frozen)}",
)
def untangle_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pull untangle knobs from layout params overrides (optional)."""
o = overrides or {}
out: dict[str, Any] = {}
for key, cast, default in (
("max_rounds", int, 200),
("max_degree", int, 7),
("target_crossings", int, 60),
("seed", int, 7),
("moves_per_round", int, 3),
):
if key not in o or o[key] is None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
out[key] = default
if "protect_rings" in o:
out["protect_rings"] = bool(o["protect_rings"])
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
# true → portals (semi-rigid); false → off
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
if o.get("max_jump") is not None:
try:
out["max_jump"] = float(o["max_jump"])
except (TypeError, ValueError):
pass
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
elif isinstance(focus, str) and focus.strip():
out["focus_ids"] = [s.strip() for s in focus.split(",") if s.strip()]
return out

View file

@ -0,0 +1,286 @@
"""Layout "sight" for agents: crossing hotspots + spatial blocks with coords.
Gives enough geometry to hand-drag like a human without loading the full canvas
into chat. Used by analyzeTopologyViewLayout(detail=hotspots|blocks).
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
collapse_links,
segments_properly_intersect,
top_crossing_edges,
top_crossing_nodes,
)
def _short_name(name: str) -> str:
parts = (name or "").split("-")
return parts[1] if len(parts) >= 2 else (name or "")[:16]
def _pos_map(nodes: list[dict[str, Any]]) -> dict[str, tuple[float, float, str]]:
out: dict[str, tuple[float, float, str]] = {}
for n in nodes:
if not isinstance(n, dict):
continue
nid = str(n.get("fabric_node_id") or "").strip()
if not nid:
continue
try:
x, y = float(n.get("x")), float(n.get("y"))
except (TypeError, ValueError):
continue
out[nid] = (x, y, str(n.get("name") or nid))
return out
def list_crossings(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
limit: int = 40,
) -> dict[str, Any]:
pos = _pos_map(nodes)
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
crosses: list[dict[str, Any]] = []
hit: dict[str, int] = {}
edge_hit: dict[tuple[str, str], int] = {}
total_cross = 0
for i, (a, b) in enumerate(links):
p1, p2 = (pos[a][0], pos[a][1]), (pos[b][0], pos[b][1])
e1 = (a, b) if a < b else (b, a)
for c, d in links[i + 1 :]:
if len({a, b, c, d}) < 4:
continue
p3, p4 = (pos[c][0], pos[c][1]), (pos[d][0], pos[d][1])
if not segments_properly_intersect(p1, p2, p3, p4):
continue
total_cross += 1
e2 = (c, d) if c < d else (d, c)
for nid in (a, b, c, d):
hit[nid] = hit.get(nid, 0) + 1
edge_hit[e1] = edge_hit.get(e1, 0) + 1
edge_hit[e2] = edge_hit.get(e2, 0) + 1
if len(crosses) < limit:
crosses.append(
{
"e1": {
"a": a,
"b": b,
"a_name": _short_name(pos[a][2]),
"b_name": _short_name(pos[b][2]),
},
"e2": {
"a": c,
"b": d,
"a_name": _short_name(pos[c][2]),
"b_name": _short_name(pos[d][2]),
},
}
)
# Rank drag candidates: high crossing participation / low degree.
adj: dict[str, set[str]] = {n: set() for n in pos}
for a, b in links:
adj[a].add(b)
adj[b].add(a)
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
names = {k: v[2] for k, v in pos.items()}
candidates = []
for nid, cnt in sorted(hit.items(), key=lambda kv: -kv[1]):
deg = len(adj.get(nid, ()))
if deg >= 8:
continue
x, y, name = pos[nid]
suggest = orbit_lite_suggest(
nid,
pos_xy,
links,
adj,
names,
max_jump=360.0,
angle_step=45,
top_k=3,
)
best_delta = suggest[0]["delta_crossings_est"] if suggest else 0
candidates.append(
{
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": cnt,
"score": round(cnt / max(deg, 1), 3),
"suggest_xy": suggest[:3],
"delta_crossings_est": best_delta,
}
)
if len(candidates) >= min(30, limit):
break
top5 = top_crossing_nodes(
pos_xy, links, names=names, adj=adj, top_n=5, participation=hit
)
top_e = top_crossing_edges(
pos_xy, links, names=names, top_n=5, edge_participation=edge_hit
)
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
if not candidates:
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
for eh in (ec.get("top_edge_hits") or [])[:5]:
nid = str(eh.get("fabric_node_id") or "")
if not nid or nid not in pos:
continue
x, y, name = pos[nid]
deg = len(adj.get(nid, ()))
candidates.append({
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": hit.get(nid, 0),
"score": 0.0,
"suggest_xy": [],
"delta_crossings_est": 0,
"reason": "edge_clearance_hit",
})
for tn in top5:
nid = str(tn.get("fabric_node_id") or "")
if not nid or nid not in pos:
continue
if any(c["fabric_node_id"] == nid for c in candidates):
continue
x, y, name = pos[nid]
deg = len(adj.get(nid, ()))
candidates.append({
"fabric_node_id": nid,
"name": name,
"short": _short_name(name),
"x": round(x, 1),
"y": round(y, 1),
"degree": deg,
"in_crossings": tn.get("crossing_hits", 0),
"score": round(tn.get("crossing_hits", 0) / max(deg, 1), 3),
"suggest_xy": [],
"delta_crossings_est": 0,
"reason": "top_crossing_high_degree",
})
return {
"edge_crossings": total_cross,
"crossings_listed": len(crosses),
"crossings": crosses,
"top_nodes": top5,
"top_edges": top_e,
"drag_candidates": candidates,
"tip": (
"先看 top_nodes / top_edges(交叉最重前5网元与前5边);"
"再动 drag_candidates(低度数可挪点,suggest_xy=orbit_lite);"
"完整扫角用 layoutTopologyView action=orbit_sweep(preview,node_id)→pick。"
"勿本地穷举坐标。"
),
}
def spatial_blocks(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
cell: float = 600.0,
top_k: int = 12,
) -> dict[str, Any]:
"""Bucket nodes into coarse grid cells; report densest / most-crossing blocks."""
pos = _pos_map(nodes)
if not pos or cell <= 1:
return {"blocks": [], "cell": cell}
links = [(a, b) for a, b in collapse_links(edges) if a in pos and b in pos]
def cell_key(x: float, y: float) -> tuple[int, int]:
return (int(math.floor(x / cell)), int(math.floor(y / cell)))
buckets: dict[tuple[int, int], list[str]] = {}
for nid, (x, y, _) in pos.items():
buckets.setdefault(cell_key(x, y), []).append(nid)
# Count crossings whose both midpoints fall in a cell (or either endpoint).
cell_cross: dict[tuple[int, int], int] = {k: 0 for k in buckets}
for i, (a, b) in enumerate(links):
p1 = (pos[a][0], pos[a][1])
p2 = (pos[b][0], pos[b][1])
for c, d in links[i + 1 :]:
if len({a, b, c, d}) < 4:
continue
p3 = (pos[c][0], pos[c][1])
p4 = (pos[d][0], pos[d][1])
if not segments_properly_intersect(p1, p2, p3, p4):
continue
mx = (p1[0] + p2[0] + p3[0] + p4[0]) / 4
my = (p1[1] + p2[1] + p3[1] + p4[1]) / 4
ck = cell_key(mx, my)
cell_cross[ck] = cell_cross.get(ck, 0) + 1
blocks = []
for (cx, cy), ids in buckets.items():
xs = [pos[i][0] for i in ids]
ys = [pos[i][1] for i in ids]
sample = sorted(ids, key=lambda i: pos[i][2])[:8]
blocks.append(
{
"cell": [cx, cy],
"bbox": [
round(min(xs), 1),
round(min(ys), 1),
round(max(xs), 1),
round(max(ys), 1),
],
"node_count": len(ids),
"crossings_near": cell_cross.get((cx, cy), 0),
"sample_nodes": [
{
"fabric_node_id": i,
"short": _short_name(pos[i][2]),
"x": round(pos[i][0], 1),
"y": round(pos[i][1], 1),
}
for i in sample
],
}
)
blocks.sort(key=lambda b: (-int(b["crossings_near"]), -int(b["node_count"])))
return {
"cell": cell,
"blocks": blocks[:top_k],
"tip": (
"分块手拖:选 crossings_near 高的 block,"
"getTopologyView / queryTopologyNeighborhood 看邻接,"
"再 updateTopologyViewPositions 挪该块内的点。"
),
}
def build_sight(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
mode: str = "hotspots",
limit: int = 40,
cell: float = 600.0,
) -> dict[str, Any]:
mode = (mode or "hotspots").strip().lower()
out: dict[str, Any] = {"mode": mode}
if mode in {"hotspots", "both", "all"}:
out["hotspots"] = list_crossings(nodes, edges, limit=limit)
if mode in {"blocks", "both", "all"}:
out["blocks"] = spatial_blocks(nodes, edges, cell=cell, top_k=min(16, max(6, limit // 3)))
return out

View file

@ -0,0 +1,819 @@
"""Topology layout statistics + composite score for parameter search.
Complements layout_metrics.analyze_positions with density / emptiness /
edge-stretch stats and a single comparable score in [0, 100].
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import (
REC_CENTER_DX,
REC_CENTER_DY,
analyze_positions,
grade_layout,
node_footprint,
)
# Sweet-spot nearest-neighbor band (px): readable but not empty.
NN_SWEET_LO = 140.0
NN_SWEET_HI = 220.0
# Ideal space_utilization band (n * rec_tile / bbox_area).
# Floor aligned with real mid-size IPRAN / hand golden (~0.08–0.10), not 0.12 fantasy.
UTIL_SWEET_LO = 0.08
UTIL_SWEET_HI = 0.45
# Ideal median undirected edge length / recommended pitch.
EDGE_SWEET_LO = 0.7
EDGE_SWEET_HI = 2.2
# Score profiles: weights must sum to 1.0.
# default = metro/flat canvases; eye = dual-portal arc-band sinks (diagonals expected).
_SCORE_WEIGHTS: dict[str, dict[str, float]] = {
"default": {
"overlap": 0.24,
"crossing": 0.17,
"utilization": 0.13,
"chain": 0.10,
"rings": 0.06,
"edge_clearance": 0.10,
"edge_axis": 0.06,
"grid": 0.05,
"nn": 0.04,
"hull": 0.03,
"stretch": 0.02,
},
"eye": {
"overlap": 0.24,
"crossing": 0.16,
"utilization": 0.14,
"chain": 0.10,
"rings": 0.05,
"edge_clearance": 0.12,
"edge_axis": 0.03, # arc-band eyes intentionally diagonal
"grid": 0.05,
"nn": 0.04,
"hull": 0.05,
"stretch": 0.02,
},
}
def resolve_score_profile(
requested: str | None,
*,
node_count: int = 0,
space_utilization: float | None = None,
axis_frac: float | None = None,
) -> str:
"""Map request → default|eye. ``auto`` uses size/sparsity/axis heuristic."""
raw = str(requested or "auto").strip().lower() or "auto"
if raw in {"default", "metro", "flat", "corridor"}:
return "default"
if raw in {"eye", "dual", "dual_unit", "access_eye"}:
return "eye"
util = float(space_utilization) if space_utilization is not None else 1.0
axis = float(axis_frac) if axis_frac is not None else 1.0
# Large, still-sparse, diagonal-heavy → treat as dual-eye sink for scoring.
if int(node_count) >= 80 and util < 0.10 and axis < 0.55:
return "eye"
return "default"
def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
"""Andrew monotone chain; returns hull CCW, or [] if < 3 unique points."""
pts = sorted(set(points))
if len(pts) <= 2:
return pts
def cross(o: tuple[float, float], a: tuple[float, float], b: tuple[float, float]) -> float:
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower: list[tuple[float, float]] = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0:
lower.pop()
lower.append(p)
upper: list[tuple[float, float]] = []
for p in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0:
upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]
def _polygon_area(poly: list[tuple[float, float]]) -> float:
if len(poly) < 3:
return 0.0
a = 0.0
for i in range(len(poly)):
x0, y0 = poly[i]
x1, y1 = poly[(i + 1) % len(poly)]
a += x0 * y1 - x1 * y0
return abs(a) * 0.5
def _pct(sorted_vals: list[float], p: float) -> float | None:
if not sorted_vals:
return None
idx = min(len(sorted_vals) - 1, max(0, int(round((len(sorted_vals) - 1) * p))))
return round(sorted_vals[idx], 2)
def _band_score(x: float, lo: float, hi: float, *, hard_lo: float = 0.0, hard_hi: float | None = None) -> float:
"""1.0 inside [lo,hi], linear falloff outside toward hard bounds → 0."""
if lo <= x <= hi:
return 1.0
if x < lo:
floor = hard_lo
if x <= floor:
return 0.0
return max(0.0, (x - floor) / max(lo - floor, 1e-9))
ceiling = hard_hi if hard_hi is not None else hi * 3.0
if x >= ceiling:
return 0.0
return max(0.0, (ceiling - x) / max(ceiling - hi, 1e-9))
def compute_density_stats(
pos: dict[str, tuple[float, float]],
names: dict[str, str],
links: list[tuple[str, str]],
) -> dict[str, Any]:
"""Emptiness / compactness / edge-stretch stats (no crossing count)."""
n = len(pos)
if n == 0:
return {
"bbox_area": 0.0,
"hull_area": 0.0,
"space_utilization": 0.0,
"hull_utilization": 0.0,
"footprint_fill": 0.0,
"grid_occupancy": 0.0,
"empty_grid_ratio": 1.0,
"aspect_ratio": 1.0,
"edge_len_p50": None,
"edge_len_p90": None,
"edge_stretch_p50": None,
"whitespace_index": 1.0,
}
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
bw = max(xs) - min(xs)
bh = max(ys) - min(ys)
bbox_area = max(bw * bh, 1.0)
tile = REC_CENTER_DX * REC_CENTER_DY
space_utilization = n * tile / bbox_area
hull = _convex_hull(list(pos.values()))
hull_area = max(_polygon_area(hull), 1.0) if len(hull) >= 3 else bbox_area
hull_utilization = n * tile / hull_area
# Sum of icon+label AABBs / bbox (can exceed 1 if overlaps)
fp_area = 0.0
for nid, (x, y) in pos.items():
fx0, fy0, fx1, fy1 = node_footprint(names.get(nid, nid))
fp_area += max(fx1 - fx0, 1.0) * max(fy1 - fy0, 1.0)
footprint_fill = fp_area / bbox_area
# Grid occupancy: cells of size pitch×side covering bbox
cell_w, cell_h = REC_CENTER_DX, REC_CENTER_DY
x0, y0 = min(xs), min(ys)
cols = max(1, int(math.ceil(bw / cell_w)) + 1)
rows = max(1, int(math.ceil(bh / cell_h)) + 1)
occupied: set[tuple[int, int]] = set()
for x, y in pos.values():
occupied.add((int((x - x0) // cell_w), int((y - y0) // cell_h)))
total_cells = cols * rows
grid_occupancy = len(occupied) / total_cells
empty_grid_ratio = 1.0 - grid_occupancy
aspect = (bw / bh) if bh > 1e-9 else 999.0
pitch = math.hypot(REC_CENTER_DX, REC_CENTER_DY) / math.sqrt(2.0) # ~185
edge_lens: list[float] = []
for a, b in links:
if a not in pos or b not in pos:
continue
edge_lens.append(math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1]))
edge_lens.sort()
el_p50 = _pct(edge_lens, 0.5)
el_p90 = _pct(edge_lens, 0.9)
stretch_p50 = round(el_p50 / pitch, 3) if el_p50 else None
# whitespace_index: 0 compact, 1 desolate (based on util + empty grid)
whitespace_index = round(
0.55 * (1.0 - min(space_utilization / UTIL_SWEET_LO, 1.0))
+ 0.45 * empty_grid_ratio,
4,
)
return {
"bbox_area": round(bbox_area, 1),
"hull_area": round(hull_area, 1),
"space_utilization": round(space_utilization, 4),
"hull_utilization": round(hull_utilization, 4),
"footprint_fill": round(footprint_fill, 4),
"grid_occupancy": round(grid_occupancy, 4),
"empty_grid_ratio": round(empty_grid_ratio, 4),
"aspect_ratio": round(aspect, 3),
"edge_len_p50": el_p50,
"edge_len_p90": el_p90,
"edge_stretch_p50": stretch_p50,
"whitespace_index": whitespace_index,
"grid_cells": [cols, rows],
"occupied_cells": len(occupied),
}
def score_layout_components(
metrics: dict[str, Any],
*,
score_profile: str | None = None,
) -> dict[str, Any]:
"""Weighted sub-scores in [0,1] + total in [0,100].
Hard gate: any footprint/label overlap → total capped near 0 (still report parts).
Profiles: ``default`` (metro) | ``eye`` (dual-portal arc sink) | ``auto``.
"""
n = int(metrics.get("node_count") or 0)
overlaps = int(metrics.get("footprint_overlap_pairs") or 0)
label_ov = int(metrics.get("label_overlap_pairs") or 0)
cpl = float(metrics.get("crossings_per_link") or 0.0)
nn = float(metrics.get("nn_p50") or 0.0)
util = float(metrics.get("space_utilization") or 0.0)
hull_u = float(metrics.get("hull_utilization") or util)
grid_occ = float(metrics.get("grid_occupancy") or 0.0)
stretch = metrics.get("edge_stretch_p50")
stretch_f = float(stretch) if stretch is not None else 1.0
white = float(metrics.get("whitespace_index") or 0.0)
chain_s = float(metrics.get("chain_score") if metrics.get("chain_score") is not None else 1.0)
rings_s = float(metrics.get("rings_score") if metrics.get("rings_score") is not None else 1.0)
edge_clr_s = float(
metrics.get("edge_clearance_score")
if metrics.get("edge_clearance_score") is not None
else 1.0
)
edge_axis_s = float(
metrics.get("edge_axis_score")
if metrics.get("edge_axis_score") is not None
else 1.0
)
axis_frac = metrics.get("axis_frac")
try:
axis_frac_f = float(axis_frac) if axis_frac is not None else None
except (TypeError, ValueError):
axis_frac_f = None
profile = resolve_score_profile(
score_profile if score_profile is not None else metrics.get("score_profile"),
node_count=n,
space_utilization=util,
axis_frac=axis_frac_f,
)
weights = dict(_SCORE_WEIGHTS.get(profile) or _SCORE_WEIGHTS["default"])
# Crossing: UME mid-size ~0.16; 0 → 1.0, 0.30 → 0
if n <= 50:
cpl_ok, cpl_bad = 0.05, 0.20
elif n <= 200:
cpl_ok, cpl_bad = 0.10, 0.25
else:
cpl_ok, cpl_bad = 0.16, 0.30
if cpl <= cpl_ok:
cross_s = 1.0
elif cpl >= cpl_bad:
cross_s = 0.0
else:
cross_s = 1.0 - (cpl - cpl_ok) / max(cpl_bad - cpl_ok, 1e-9)
overlap_s = 1.0 if overlaps == 0 and label_ov == 0 else 0.0
nn_s = _band_score(nn, NN_SWEET_LO, NN_SWEET_HI, hard_lo=40.0, hard_hi=500.0)
# Eye sinks: blend bbox util with hull util so long corridors don't auto-fail.
if profile == "eye":
util_blend = 0.55 * util + 0.45 * hull_u
util_s = _band_score(util_blend, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
else:
util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2)
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5)
grid_s = _band_score(grid_occ, 0.20, 0.75, hard_lo=0.02, hard_hi=1.0)
stretch_s = _band_score(stretch_f, EDGE_SWEET_LO, EDGE_SWEET_HI, hard_lo=0.2, hard_hi=8.0)
white_s = max(0.0, 1.0 - white) # less whitespace → better (diagnostic only)
chain_s = max(0.0, min(1.0, chain_s))
rings_s = max(0.0, min(1.0, rings_s))
edge_clr_s = max(0.0, min(1.0, edge_clr_s))
edge_axis_s = max(0.0, min(1.0, edge_axis_s))
parts = {
"overlap": round(overlap_s, 4),
"crossing": round(cross_s, 4),
"utilization": round(util_s, 4),
"chain": round(chain_s, 4),
"rings": round(rings_s, 4),
"edge_clearance": round(edge_clr_s, 4),
"edge_axis": round(edge_axis_s, 4),
"grid": round(grid_s, 4),
"nn": round(nn_s, 4),
"hull": round(hull_s, 4),
"stretch": round(stretch_s, 4),
# Diagnostic only — not in weights (avoid double-count with util/grid).
"compactness": round(white_s, 4),
}
total = sum(parts[k] * weights[k] for k in weights)
if overlap_s < 1.0:
total *= 0.15 # hard gate: overlaps wreck the score
total_100 = round(100.0 * total, 2)
return {
"total": total_100,
"parts": parts,
"weights": weights,
"score_profile": profile,
"targets": {
"nn_sweet": [NN_SWEET_LO, NN_SWEET_HI],
"util_sweet": [UTIL_SWEET_LO, UTIL_SWEET_HI],
"edge_stretch_sweet": [EDGE_SWEET_LO, EDGE_SWEET_HI],
"cpl_ok": cpl_ok,
"cpl_bad": cpl_bad,
"chain_ok": 0.75,
"chain_warn": 0.45,
"rings_ok": 0.75,
"rings_warn": 0.45,
"edge_clearance_ok": 0.85,
"edge_clearance_warn": 0.45,
"edge_axis_ok": 0.75,
"edge_axis_warn": 0.45,
},
"rank_key": [
0 if overlap_s >= 1.0 else 1,
-total_100,
int(metrics.get("edge_crossings") or 0),
-util,
-edge_clr_s,
-chain_s,
-rings_s,
-edge_axis_s,
],
"hint": (
f"total∈[0,100] profile={profile}. Overlaps hard-gate. "
"Clearance blends nodes_hit + hits/link. "
"Eye profile softens axis (arc bands) and rings; raises util/clearance. "
"compactness is diagnostic only (not weighted)."
),
}
def _status_from_score(part: float, *, fail_below: float = 0.01, warn_below: float = 0.55) -> str:
if part <= fail_below:
return "fail"
if part < warn_below:
return "warn"
return "ok"
def _sparsity_status(util: float, white: float, grid_occ: float) -> str:
# Aligned with UTIL_SWEET_LO=0.08: below sweet → warn; desolate → fail.
if util < 0.04 or white > 0.85 or grid_occ < 0.04:
return "fail"
if util < UTIL_SWEET_LO or white > 0.65 or grid_occ < 0.12:
return "warn"
return "ok"
def _headline(verdict_overall: str, dims: dict[str, Any]) -> str:
bad = [k for k, v in dims.items() if v.get("status") == "fail"]
warn = [k for k, v in dims.items() if v.get("status") == "warn"]
labels = {
"overlap": "重叠",
"crossing": "交叉",
"spacing": "间距",
"sparsity": "稀疏/空旷",
"edges": "边长",
"chains": "直链不成一体",
"rings": "最小环被穿",
"edge_clearance": "网元贴边",
"edge_axis": "斜边过多",
}
if bad:
return "问题:" + "、".join(labels.get(k, k) for k in bad)
if warn:
return "可改进:" + "、".join(labels.get(k, k) for k in warn)
if verdict_overall == "ok":
return "布图验收通过"
return f"overall={verdict_overall}"
def build_layout_report(metrics: dict[str, Any]) -> dict[str, Any]:
"""One structured report for the single analyze tool (agent-facing)."""
score = metrics.get("score") or score_layout_components(metrics)
grade = metrics.get("grade") or grade_layout(metrics)
parts = score.get("parts") or {}
targets = score.get("targets") or {}
fp = int(metrics.get("footprint_overlap_pairs") or 0)
lbl = int(metrics.get("label_overlap_pairs") or 0)
util = float(metrics.get("space_utilization") or 0.0)
white = float(metrics.get("whitespace_index") or 0.0)
grid_occ = float(metrics.get("grid_occupancy") or 0.0)
nn_p50 = metrics.get("nn_p50")
stretch = metrics.get("edge_stretch_p50")
overlap = {
"status": "ok" if fp == 0 and lbl == 0 else "fail",
"score": parts.get("overlap"),
"footprint_pairs": fp,
"label_pairs": lbl,
"hard_zero": True,
"tip": "图标+名称 AABB 不得互挡;有重叠则总分硬门控。",
}
top_x = metrics.get("top_crossing_nodes") or []
top_e = metrics.get("top_crossing_edges") or []
crossing = {
"status": grade.get("crossing_grade") or _status_from_score(float(parts.get("crossing") or 0)),
"score": parts.get("crossing"),
"edge_crossings": metrics.get("edge_crossings"),
"crossings_per_link": metrics.get("crossings_per_link"),
"crossings_per_node": metrics.get("crossings_per_node"),
"top_nodes": top_x,
"top_edges": top_e,
"budget": {
"cpl_ok": targets.get("cpl_ok"),
"cpl_bad": targets.get("cpl_bad"),
"cross_warn": (grade.get("budgets") or {}).get("cross_warn"),
"cross_fail": (grade.get("budgets") or {}).get("cross_fail"),
},
"tip": (
"无向 NE↔NE 真交叉(共端点不算);优先看 crossings_per_link。"
" top_nodes=交叉最重前5网元;top_edges=交叉点最多的前5条边;"
"阶段2优先处理它们的端点/邻边。"
),
}
spacing = {
"status": (
"fail"
if nn_p50 is not None and float(nn_p50) < 80
else "warn"
if nn_p50 is not None and float(nn_p50) < 150
else "ok"
if nn_p50 is not None
else "warn"
),
"score": parts.get("nn"),
"nn_min": metrics.get("nn_min"),
"nn_p10": metrics.get("nn_p10"),
"nn_p50": nn_p50,
"pairs_closer_than_min_dist": metrics.get("pairs_closer_than_min_dist"),
"sweet": targets.get("nn_sweet") or [NN_SWEET_LO, NN_SWEET_HI],
"tip": "中位最近邻宜在 140–220;过小挤、过大浪费。",
}
sparsity = {
"status": _sparsity_status(util, white, grid_occ),
"score": parts.get("utilization"),
"space_utilization": util,
"hull_utilization": metrics.get("hull_utilization"),
"grid_occupancy": grid_occ,
"empty_grid_ratio": metrics.get("empty_grid_ratio"),
"whitespace_index": white,
"footprint_fill": metrics.get("footprint_fill"),
"aspect_ratio": metrics.get("aspect_ratio"),
"sweet_util": targets.get("util_sweet") or [UTIL_SWEET_LO, UTIL_SWEET_HI],
"tip": (
"util=推荐瓦片×n/bbox;grid_occupancy=有点格子占比;"
"whitespace_index→1 表示太空旷。目标 util≈0.12–0.45。"
),
}
edges = {
"status": _status_from_score(float(parts.get("stretch") or 0), fail_below=0.15, warn_below=0.5),
"score": parts.get("stretch"),
"edge_len_p50": metrics.get("edge_len_p50"),
"edge_len_p90": metrics.get("edge_len_p90"),
"edge_stretch_p50": stretch,
"sweet": targets.get("edge_stretch_sweet") or [EDGE_SWEET_LO, EDGE_SWEET_HI],
"tip": "中位边长/推荐步长;过大=走廊被拉爆,过小=叠在一起。",
}
chain_ok = float(targets.get("chain_ok") or 0.75)
chain_warn = float(targets.get("chain_warn") or 0.45)
rings_ok = float(targets.get("rings_ok") or 0.75)
rings_warn = float(targets.get("rings_warn") or 0.45)
chain_part = float(parts.get("chain") if parts.get("chain") is not None else 1.0)
rings_part = float(parts.get("rings") if parts.get("rings") is not None else 1.0)
def _mid_status(part: float, ok: float, warn: float) -> str:
if part >= ok:
return "ok"
if part >= warn:
return "warn"
return "fail"
chains = {
"status": _mid_status(chain_part, chain_ok, chain_warn),
"score": parts.get("chain"),
"chain_count": metrics.get("chain_count"),
"chain_nodes": metrics.get("chain_nodes"),
"straightness_p50": metrics.get("chain_straightness_p50"),
"straightness_mean": metrics.get("chain_straightness_mean"),
"kink_count": metrics.get("chain_kink_count"),
"kink_frac": metrics.get("chain_kink_frac"),
"budget": {"ok": chain_ok, "warn": chain_warn},
"tip": metrics.get("chain_tip")
or "deg≤2 走廊应近似共线成一体;折角少、chord/path≈1。",
}
rings = {
"status": _mid_status(rings_part, rings_ok, rings_warn),
"score": parts.get("rings"),
"ring_count": metrics.get("ring_count"),
"rings_pierced": metrics.get("rings_pierced"),
"pierce_crossings": metrics.get("ring_pierce_crossings"),
"budget": {"ok": rings_ok, "warn": rings_warn},
"tip": metrics.get("rings_tip")
or "弦无关短环(3–8)边界不应被环外边穿越。",
}
clr_ok = float(targets.get("edge_clearance_ok") or 0.85)
clr_warn = float(targets.get("edge_clearance_warn") or 0.45)
clr_part = float(
parts.get("edge_clearance") if parts.get("edge_clearance") is not None else 1.0
)
edge_clearance = {
"status": _mid_status(clr_part, clr_ok, clr_warn),
"score": parts.get("edge_clearance"),
"hits": metrics.get("edge_clearance_hits"),
"nodes_hit": metrics.get("nodes_hit"),
"min_clearance_p50": metrics.get("min_clearance_p50"),
"thr": metrics.get("edge_clearance_thr"),
"top": metrics.get("top_edge_hits") or [],
"budget": {"ok": clr_ok, "warn": clr_warn},
"tip": metrics.get("edge_clearance_tip")
or "非关联边不得擦过网元;阶段2 clear_edge_hits。",
}
axis_ok = float(targets.get("edge_axis_ok") or 0.75)
axis_warn = float(targets.get("edge_axis_warn") or 0.45)
axis_part = float(
parts.get("edge_axis") if parts.get("edge_axis") is not None else 1.0
)
edge_axis = {
"status": _mid_status(axis_part, axis_ok, axis_warn),
"score": parts.get("edge_axis"),
"axis_frac": metrics.get("axis_frac"),
"horiz_frac": metrics.get("horiz_frac"),
"vert_frac": metrics.get("vert_frac"),
"diag_frac": metrics.get("diag_frac"),
"horiz_n": metrics.get("horiz_n"),
"vert_n": metrics.get("vert_n"),
"diag_n": metrics.get("diag_n"),
"tol_deg": metrics.get("edge_axis_tol_deg"),
"top_skew": metrics.get("top_skew_edges") or [],
"budget": {"ok": axis_ok, "warn": axis_warn},
"tip": metrics.get("edge_axis_tip")
or "边宜水平/垂直(水平优先);阶段2 straighten_channels / polish。",
}
dims = {
"overlap": overlap,
"crossing": crossing,
"spacing": spacing,
"sparsity": sparsity,
"edges": edges,
"chains": chains,
"rings": rings,
"edge_clearance": edge_clearance,
"edge_axis": edge_axis,
}
overall = grade.get("overall") or "fail"
# sparsity / mid-tier fails should surface in overall
order = {"ok": 0, "warn": 1, "fail": 2}
for d in dims.values():
st = str(d.get("status") or "ok")
if order.get(st, 0) > order.get(overall, 0):
overall = st
issues = list(grade.get("issues") or [])
if sparsity["status"] == "fail" and not any("space_utilization" in i for i in issues):
issues.append(f"sparsity: util={util} whitespace={white} grid={grid_occ}")
if sparsity["status"] == "warn" and not any("space_utilization" in i for i in issues):
issues.append(f"sparsity_warn: util={util} whitespace={white}")
if chains["status"] != "ok":
issues.append(
f"chain_{chains['status']}: straight_p50={chains.get('straightness_p50')} "
f"kinks={chains.get('kink_count')} score={chains.get('score')}"
)
if rings["status"] != "ok":
issues.append(
f"rings_{rings['status']}: pierced={rings.get('rings_pierced')}/"
f"{rings.get('ring_count')} pierce_x={rings.get('pierce_crossings')} "
f"score={rings.get('score')}"
)
if edge_clearance["status"] != "ok":
issues.append(
f"edge_clearance_{edge_clearance['status']}: "
f"hits={edge_clearance.get('hits')} nodes={edge_clearance.get('nodes_hit')} "
f"score={edge_clearance.get('score')}"
)
if edge_axis["status"] != "ok":
issues.append(
f"edge_axis_{edge_axis['status']}: "
f"H={edge_axis.get('horiz_n')} V={edge_axis.get('vert_n')} "
f"D={edge_axis.get('diag_n')} score={edge_axis.get('score')}"
)
return {
"verdict": {
"overall": overall,
"total": score.get("total"),
"headline": _headline(overall, dims),
"issues": issues,
},
"size": {
"nodes": metrics.get("node_count"),
"links": metrics.get("link_count"),
"bbox": metrics.get("bbox"),
"bbox_area": metrics.get("bbox_area"),
"hull_area": metrics.get("hull_area"),
},
"overlap": overlap,
"crossing": crossing,
"spacing": spacing,
"sparsity": sparsity,
"edges": edges,
"chains": chains,
"rings": rings,
"edge_clearance": edge_clearance,
"edge_axis": edge_axis,
"score": {
"total": score.get("total"),
"parts": parts,
"weights": score.get("weights"),
"rank_key": score.get("rank_key"),
"targets": targets,
"hint": score.get("hint"),
},
"guide": {
"spacing": metrics.get("spacing_guide"),
"crossing_definition": metrics.get("crossing_definition"),
"how_to_read": (
"只看本工具即可验收:verdict.total∈[0,100];"
"overlap/crossing/spacing/sparsity/edges/chains/rings/"
"edge_clearance/edge_axis 各有 status。"
f"score_profile={score.get('score_profile') or 'default'}:"
"eye 下调 axis/rings、上调 util/clearance;"
"贴边分=节点命中∪hits/link;compactness 仅诊断不加权。"
"扫参用 score.rank_key(先零重叠,再高 total)。"
),
},
}
def analyze_layout_stats(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
with_meta: bool = False,
ume_reference: bool = False,
fast: bool = False,
score_profile: str | None = "auto",
) -> dict[str, Any]:
"""Full stats: flat metrics + composite score + unified report.
``fast=True`` skips ring-pierce (expensive on giant metro canvases) and
still scores overlap/crossing/util/chains for apply gates + agent QA.
``score_profile``: auto|default|eye — eye softens axis/rings for dual sinks.
"""
base = analyze_positions(nodes, edges, with_meta=with_meta)
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)
from netx_api.mcp_remote.topology.layout_metrics import (
collapse_links,
compute_edge_axis,
compute_edge_clearance,
)
from netx_api.mcp_remote.topology.layout_topology_quality import (
compute_chain_cohesion,
compute_topology_quality,
)
links = collapse_links(edges)
density = compute_density_stats(pos, names, links)
if fast:
chain_q = compute_chain_cohesion(pos, links)
rings_q = {
"ring_count": None,
"rings_pierced": None,
"pierce_crossings": None,
"score": 1.0,
"tip": "fast score: ring pierce skipped (n/links large).",
}
else:
quality = compute_topology_quality(pos, links)
chain_q = quality.get("chains") or {}
rings_q = quality.get("rings") or {}
clr_q = compute_edge_clearance(pos, links, names=names)
axis_q = compute_edge_axis(pos, links, names=names)
merged = {**base, **density}
merged["space_utilization"] = density["space_utilization"]
merged["chain_score"] = chain_q.get("score", 1.0)
merged["chain_count"] = chain_q.get("chain_count")
merged["chain_nodes"] = chain_q.get("chain_nodes")
merged["chain_straightness_p50"] = chain_q.get("straightness_p50")
merged["chain_straightness_mean"] = chain_q.get("straightness_mean")
merged["chain_kink_count"] = chain_q.get("kink_count")
merged["chain_kink_frac"] = chain_q.get("kink_frac")
merged["chain_tip"] = chain_q.get("tip")
merged["rings_score"] = rings_q.get("score", 1.0)
merged["ring_count"] = rings_q.get("ring_count")
merged["rings_pierced"] = rings_q.get("rings_pierced")
merged["ring_pierce_crossings"] = rings_q.get("pierce_crossings")
merged["rings_tip"] = rings_q.get("tip")
merged["edge_clearance_score"] = clr_q.get("edge_clearance_score", 1.0)
merged["edge_clearance_hits"] = clr_q.get("edge_clearance_hits")
merged["nodes_hit"] = clr_q.get("nodes_hit")
merged["min_clearance_p50"] = clr_q.get("min_clearance_p50")
merged["top_edge_hits"] = clr_q.get("top_edge_hits") or []
merged["edge_clearance_tip"] = clr_q.get("edge_clearance_tip")
merged["edge_clearance_thr"] = clr_q.get("edge_clearance_thr")
merged["edge_clearance_skipped"] = clr_q.get("edge_clearance_skipped")
merged["hit_per_link"] = clr_q.get("hit_per_link")
merged["edge_axis_score"] = axis_q.get("edge_axis_score", 1.0)
merged["axis_frac"] = axis_q.get("axis_frac")
merged["horiz_frac"] = axis_q.get("horiz_frac")
merged["vert_frac"] = axis_q.get("vert_frac")
merged["diag_frac"] = axis_q.get("diag_frac")
merged["horiz_n"] = axis_q.get("horiz_n")
merged["vert_n"] = axis_q.get("vert_n")
merged["diag_n"] = axis_q.get("diag_n")
merged["top_skew_edges"] = axis_q.get("top_skew_edges") or []
merged["edge_axis_tip"] = axis_q.get("edge_axis_tip")
merged["edge_axis_tol_deg"] = axis_q.get("edge_axis_tol_deg")
merged["edge_axis_tol_px"] = axis_q.get("edge_axis_tol_px")
merged["score_profile"] = score_profile
score = score_layout_components(merged, score_profile=score_profile)
grade = grade_layout(merged, ume_reference=ume_reference)
packed = {
**merged,
"score": score,
"grade": grade,
"summary": {
"total": score["total"],
"overall": grade.get("overall"),
"score_profile": score.get("score_profile"),
"crossings": merged.get("edge_crossings"),
"cpl": merged.get("crossings_per_link"),
"overlaps": merged.get("footprint_overlap_pairs"),
"nn_p50": merged.get("nn_p50"),
"util": merged.get("space_utilization"),
"grid_occ": merged.get("grid_occupancy"),
"whitespace": merged.get("whitespace_index"),
"edge_stretch": merged.get("edge_stretch_p50"),
"chain": merged.get("chain_score"),
"rings": merged.get("rings_score"),
"rings_pierced": merged.get("rings_pierced"),
"edge_clearance": merged.get("edge_clearance_score"),
"edge_clearance_hits": merged.get("edge_clearance_hits"),
"edge_axis": merged.get("edge_axis_score"),
"axis_frac": merged.get("axis_frac"),
"horiz_frac": merged.get("horiz_frac"),
"diag_n": merged.get("diag_n"),
"top_crossing": [
{
"name": r.get("name"),
"hits": r.get("crossing_hits"),
"id": r.get("fabric_node_id"),
}
for r in (merged.get("top_crossing_nodes") or [])[:5]
],
"top_crossing_edges": [
{
"label": r.get("label"),
"hits": r.get("crossing_hits"),
"a": r.get("a_name"),
"b": r.get("b_name"),
"a_id": r.get("a_node_id"),
"b_id": r.get("b_node_id"),
}
for r in (merged.get("top_crossing_edges") or [])[:5]
],
},
}
packed["report"] = build_layout_report(packed)
# Align summary.overall with report (includes sparsity)
packed["summary"]["overall"] = packed["report"]["verdict"]["overall"]
packed["summary"]["headline"] = packed["report"]["verdict"]["headline"]
return packed

View file

@ -0,0 +1,625 @@
"""Graph structure stats for layout planning (shape / gravity / blocks).
Read-only: no coordinates required. Feeds analyzeTopologyViewLayout(detail=structure).
Order of judgment (important):
1) global / per-component **shape** (chains first — simplest & common)
2) hub gravity (core_bar / agg_bar / mixed) only when not chain-like
3) complex canvases = pack of blocks; lay each block, then compose
4) soft_blocks: hub territories inside giant CCs (+ optional igraph leftovers)
"""
from __future__ import annotations
from collections import defaultdict, deque
from statistics import mean, median
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
from netx_api.mcp_remote.topology.layout_ops.graph_util import infer_layer
def _pct(xs: list[int], p: float) -> float:
if not xs:
return 0.0
s = sorted(xs)
if len(s) == 1:
return float(s[0])
i = min(len(s) - 1, max(0, int(round((len(s) - 1) * p))))
return float(s[i])
def _connected_components(
ids: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for s in sorted(ids, key=lambda x: names.get(x, x)):
if s in seen:
continue
q: deque[str] = deque([s])
seen.add(s)
comp: list[str] = []
while q:
u = q.popleft()
comp.append(u)
for v in adj.get(u, ()):
if v not in seen:
seen.add(v)
q.append(v)
out.append(comp)
out.sort(key=lambda c: (-len(c), names.get(c[0], c[0])))
return out
def _farthest(
start: str, allowed: set[str], adj: dict[str, set[str]]
) -> tuple[tuple[str, int], dict[str, str | None]]:
q: deque[tuple[str, int]] = deque([(start, 0)])
seen = {start}
best = (start, 0)
parent: dict[str, str | None] = {start: None}
while q:
u, d = q.popleft()
if d > best[1]:
best = (u, d)
for v in adj.get(u, ()):
if v in allowed and v not in seen:
seen.add(v)
parent[v] = u
q.append((v, d + 1))
return best, parent
def _diameter_path(
comp: list[str], adj: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
s = set(comp)
if not s:
return []
leaves = [n for n in comp if len(adj.get(n, ())) <= 1] or list(comp)
start = sorted(leaves, key=lambda n: names.get(n, n))[0]
(e1, _), _ = _farthest(start, s, adj)
(e2, _), parent = _farthest(e1, s, adj)
path = [e2]
while parent[path[-1]] is not None:
path.append(parent[path[-1]] or "")
path.reverse()
return [n for n in path if n]
def _block_shape(
comp: list[str],
adj: dict[str, set[str]],
names: dict[str, str],
layers: dict[str, str],
) -> dict[str, Any]:
"""Classify one connected component: chain | star | mesh | tiny."""
n = len(comp)
if n <= 2:
return {
"shape": "tiny",
"node_count": n,
"link_count": sum(len(adj[u]) for u in comp) // 2,
"spine_len": n,
"chain_frac": 1.0,
"max_degree": max((len(adj[u]) for u in comp), default=0),
"mean_degree": round(mean(len(adj[u]) for u in comp), 2) if comp else 0.0,
}
degs = [len(adj[u]) for u in comp]
low = sum(1 for d in degs if d <= 2) / n
max_d = max(degs)
mean_d = mean(degs)
path = _diameter_path(comp, adj, names)
spine_len = len(path)
# nodes on diameter path / n — high ⇒ path-like
on_spine = spine_len / n
# edges ≈ n-1 ⇒ tree/path; denser ⇒ mesh
e = sum(degs) // 2
treeish = e <= n # forest/tree (path has e=n-1)
shape = "mesh"
# Path / corridor first (CN/AN on spine with deg≤5 still counts as chain)
nearly_path = mean_d <= 2.45 and low >= 0.62 and max_d <= 5 and e <= n + 2
if nearly_path or (treeish and low >= 0.65 and mean_d <= 2.5 and max_d <= 5):
shape = "chain"
elif treeish and low >= 0.72 and mean_d <= 2.35 and max_d <= 6:
shape = "chain"
elif on_spine >= 0.25 and low >= 0.68 and mean_d <= 2.4 and max_d <= 5:
shape = "chain"
elif max_d >= 7 and low >= 0.55 and on_spine < 0.4:
shape = "star"
elif max_d >= 8 or (mean_d >= 2.8 and e > n + max(2, n // 10)):
shape = "mesh"
else:
if on_spine >= 0.35 and low >= 0.6:
shape = "chain"
elif max_d >= 6 and low >= 0.5:
shape = "star"
hubs = sorted(
[u for u in comp if layers.get(u) in {"core", "agg"}],
key=lambda u: (-len(adj[u]), names.get(u, u)),
)[:4]
return {
"shape": shape,
"node_count": n,
"link_count": e,
"spine_len": spine_len,
"on_spine_frac": round(on_spine, 3),
"chain_frac": round(low, 3), # frac deg<=2
"max_degree": max_d,
"mean_degree": round(mean_d, 2),
"treeish": treeish,
"hub_ids": hubs,
"spine_ends": [path[0], path[-1]] if path else [],
"spine_end_names": [names.get(path[0], ""), names.get(path[-1], "")] if path else [],
}
def _bfs_territory(
hubs: list[str],
access: set[str],
adj: dict[str, set[str]],
) -> dict[str, str]:
"""Multi-source Voronoi on access: each access node → nearest hub (via stubs)."""
owner: dict[str, str] = {}
q: deque[str] = deque()
for h in sorted(hubs):
for nb in adj.get(h, ()):
if nb in access and nb not in owner:
owner[nb] = h
q.append(nb)
while q:
u = q.popleft()
h = owner[u]
for v in adj.get(u, ()):
if v in access and v not in owner:
owner[v] = h
q.append(v)
return owner
def analyze_graph_structure(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
hub_top_k: int = 12,
stub_top_k: int = 24,
) -> dict[str, Any]:
"""Return shape/blocks, hubs, gravity type, recipe preference."""
names: dict[str, str] = {}
layers: dict[str, str] = {}
ids: list[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
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
layers[fid] = infer_layer(nm, n.get("role"), n.get("level"))
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
for a, b in links:
if a in adj and b in adj:
adj[a].add(b)
adj[b].add(a)
deg = {i: len(adj[i]) for i in ids}
access = {i for i in ids if layers.get(i) == "access"}
layer_known = sum(
1 for i in ids if layers.get(i) in {"external", "core", "agg", "access"}
)
if layer_known < max(3, len(ids) // 5) and ids:
ranked = sorted(ids, key=lambda i: (-deg[i], names[i]))
hub_budget = max(2, min(8, len(ids) // 15 + 2))
for i in ranked[:hub_budget]:
if layers.get(i) == "other":
layers[i] = "agg"
for i in ids:
if layers.get(i) == "other" and deg[i] <= 2:
layers[i] = "access"
access = {i for i in ids if layers.get(i) == "access"}
# --- blocks / shape first ---
comps = _connected_components(ids, adj, names)
blocks: list[dict[str, Any]] = []
for idx, comp in enumerate(comps):
b = _block_shape(comp, adj, names, layers)
b["block_id"] = idx
b["sample_names"] = [names[n] for n in sorted(comp, key=lambda x: names[x])[:3]]
blocks.append(b)
n_nodes = max(len(ids), 1)
chain_nodes = sum(b["node_count"] for b in blocks if b["shape"] in {"chain", "tiny"})
star_nodes = sum(b["node_count"] for b in blocks if b["shape"] == "star")
mesh_nodes = sum(b["node_count"] for b in blocks if b["shape"] == "mesh")
chain_frac_nodes = chain_nodes / n_nodes
global_low = (sum(1 for i in ids if deg[i] <= 2) / n_nodes) if ids else 0.0
global_mean = mean(deg.values()) if deg else 0.0
global_max = max(deg.values()) if deg else 0
primary_shape = "mesh"
if chain_frac_nodes >= 0.55 or (global_low >= 0.72 and global_mean <= 2.35 and global_max <= 6):
primary_shape = "chains"
elif star_nodes / n_nodes >= 0.45:
primary_shape = "star"
elif mesh_nodes / n_nodes >= 0.45:
primary_shape = "mesh"
elif chain_frac_nodes >= 0.35:
primary_shape = "mixed_blocks" # chain corridors + some hub blocks
shape_block = {
"primary": primary_shape,
"component_count": len(comps),
"chain_node_frac": round(chain_frac_nodes, 3),
"star_node_frac": round(star_nodes / n_nodes, 3),
"mesh_node_frac": round(mesh_nodes / n_nodes, 3),
"deg_le2_frac": round(global_low, 3),
"mean_degree": round(global_mean, 2),
"max_degree": global_max,
"blocks": blocks[:40],
"strategy": (
"Lay each connected component as its own block: "
"chain→horizontal spine + short stubs; star→hub + petals; "
"mesh→corridor/compact. Pack blocks in rows. "
"Do NOT treat a chain canvas as hub-and-spoke rings."
),
}
by_layer: dict[str, list[str]] = defaultdict(list)
for i in ids:
by_layer[layers.get(i, "other")].append(i)
owner = _bfs_territory(
[i for i in ids if layers.get(i) in {"core", "agg"}],
access,
adj,
)
territory_count: dict[str, int] = defaultdict(int)
for h in owner.values():
territory_count[h] += 1
def layer_stats(layer: str) -> dict[str, Any]:
members = by_layer.get(layer, [])
if not members:
return {
"count": 0,
"max_degree": 0,
"mean_degree": 0.0,
"access_neighbor_sum": 0,
"access_cover": 0,
"access_cover_frac": 0.0,
"territory": 0,
"territory_frac": 0.0,
}
degs = [deg[i] for i in members]
covered: set[str] = set()
access_nb_sum = 0
terr = 0
for i in members:
nbs = [x for x in adj.get(i, ()) if x in access]
access_nb_sum += len(nbs)
covered.update(nbs)
terr += territory_count.get(i, 0)
n_access = max(len(access), 1)
return {
"count": len(members),
"max_degree": max(degs) if degs else 0,
"mean_degree": round(mean(degs), 2) if degs else 0.0,
"access_neighbor_sum": access_nb_sum,
"access_cover": len(covered),
"access_cover_frac": round(len(covered) / n_access, 3),
"territory": terr,
"territory_frac": round(terr / n_access, 3),
}
layer_block = {
"external": layer_stats("external"),
"core": layer_stats("core"),
"agg": layer_stats("agg"),
"access": {
"count": len(access),
"max_degree": max((deg[i] for i in access), default=0),
"mean_degree": round(mean([deg[i] for i in access]), 2) if access else 0.0,
},
"other": layer_stats("other"),
}
hub_scores: list[tuple[float, str]] = []
for i in ids:
if layers.get(i) not in {"core", "agg"}:
continue
an = sum(1 for x in adj.get(i, ()) if x in access)
score = deg[i] * 1.0 + an * 2.0 + territory_count.get(i, 0) * 0.5
hub_scores.append((score, i))
hub_scores.sort(key=lambda t: (-t[0], names[t[1]]))
hubs_out: list[dict[str, Any]] = []
for score, i in hub_scores[: max(1, min(40, hub_top_k))]:
an = sorted(
(x for x in adj.get(i, ()) if x in access),
key=lambda x: (-deg[x], names[x]),
)
hubs_out.append(
{
"fabric_node_id": i,
"name": names[i],
"layer": layers[i],
"degree": deg[i],
"access_neighbors": len(an),
"territory": territory_count.get(i, 0),
"score": round(score, 2),
"stub_ids": an[:8],
}
)
stub_rows: list[dict[str, Any]] = []
for h in hubs_out[:8]:
hid = h["fabric_node_id"]
for sid in h.get("stub_ids") or []:
stub_rows.append(
{
"hub_id": hid,
"hub_name": h["name"],
"stub_id": sid,
"stub_name": names.get(sid, sid),
"stub_degree": deg.get(sid, 0),
}
)
stub_rows.sort(key=lambda r: (-r["stub_degree"], r["hub_name"], r["stub_name"]))
stub_rows = stub_rows[: max(1, min(60, stub_top_k))]
core_s = layer_block["core"]
agg_s = layer_block["agg"]
rationale: list[str] = []
gravity_type = "unclear"
confidence = 0.35
anchor_layer = "access"
decorative: list[str] = []
geometry = "chain_rows"
recipe_pref = ["corridor", "compact", "rings"]
c_terr = float(core_s["territory_frac"])
a_terr = float(agg_s["territory_frac"])
c_deg = int(core_s["max_degree"])
a_deg = int(agg_s["max_degree"])
c_n = int(core_s["count"])
a_n = int(agg_s["count"])
# 1) Chain-first — never fall through to hub-bar on path graphs
if primary_shape == "chains":
gravity_type = "chains"
confidence = min(0.97, 0.55 + chain_frac_nodes * 0.4)
anchor_layer = "access"
geometry = "chain_rows"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append(
f"shape=chains; deg_le2_frac={global_low:.2f}; "
f"components={len(comps)}; lay spines+stubs per block"
)
elif primary_shape == "mixed_blocks":
gravity_type = "mixed_blocks"
confidence = 0.7
anchor_layer = "agg" if a_n else ("core" if c_n else "access")
geometry = "block_pack"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append("mixed chain blocks + hub blocks; process per component")
elif c_n == 0 and a_n > 0 and (a_terr >= 0.35 or a_deg >= 4):
gravity_type = "agg_bar"
confidence = min(0.95, 0.55 + a_terr * 0.4)
anchor_layer = "agg"
geometry = "agg_bar_mid"
recipe_pref = ["rings", "compact", "corridor"]
rationale.append("no_core_layer; agg holds access territory")
if a_n >= 2:
rationale.append(
"dual_agg: rings uses min-ring path nesting (parallel corridors) "
"before per-AN petals"
)
elif c_n > 0 and (
c_terr >= a_terr * 1.35 or (c_deg >= max(6, int(a_deg * 1.4)) and c_terr >= 0.25)
):
gravity_type = "core_bar"
confidence = min(0.95, 0.5 + c_terr * 0.45)
anchor_layer = "core"
if a_n > 0 and a_terr < 0.2 and a_deg <= max(4, c_deg // 2):
decorative.append("agg")
rationale.append("agg_low_attachment; treat as decorative bar")
core_hubs = [h for h in hubs_out if h["layer"] == "core"][:4]
if len(core_hubs) >= 2 and core_hubs[1]["degree"] >= max(
4, core_hubs[0]["degree"] * 0.5
):
geometry = "core_center"
rationale.append("dual_core_hubs; prefer horizontal beam at mid/upper-mid")
else:
geometry = "core_top"
rationale.append("core_holds_access; default core beam above access fans")
recipe_pref = ["compact", "corridor", "rings"]
rationale.append("core_territory_or_degree_dominates_agg")
rationale.append(
"prefer dual_units → sinkTopologyDualUnits; "
"small core: layout compact|corridor"
)
elif c_n > 0 and a_n > 0 and c_terr >= 0.15 and a_terr >= 0.15:
gravity_type = "mixed"
confidence = min(0.9, 0.45 + min(c_terr, a_terr) * 0.5)
anchor_layer = "core" if c_terr >= a_terr else "agg"
geometry = "core_center" if anchor_layer == "core" else "agg_bar_mid"
recipe_pref = ["compact", "rings", "corridor"]
rationale.append("both_core_and_agg_own_access_territory")
elif hubs_out and primary_shape == "star":
top = hubs_out[0]
gravity_type = "agg_bar" if top["layer"] == "agg" else "core_bar"
confidence = 0.55
anchor_layer = top["layer"]
geometry = "agg_bar_mid" if top["layer"] == "agg" else "core_center"
recipe_pref = (
["rings", "compact", "corridor"]
if top["layer"] == "agg"
else ["compact", "corridor", "rings"]
)
rationale.append("shape=star; hub-and-spoke")
elif hubs_out:
top = hubs_out[0]
gravity_type = "core_bar" if top["layer"] == "core" else "agg_bar"
confidence = 0.45
anchor_layer = top["layer"]
geometry = "core_center" if top["layer"] == "core" else "agg_bar_mid"
recipe_pref = (
["compact", "corridor", "rings"]
if top["layer"] == "core"
else ["rings", "compact", "corridor"]
)
rationale.append("fallback_top_hub_layer")
else:
gravity_type = "chains" if global_low >= 0.6 else "unclear"
geometry = "chain_rows" if gravity_type == "chains" else "compact"
recipe_pref = ["corridor", "compact", "rings"]
rationale.append("no_clear_hubs; corridor baseline")
degs = list(deg.values())
chain_mode = gravity_type in {"chains", "mixed_blocks"}
advice = {
"pin_anchors": (
[]
if gravity_type == "chains"
else [h["fabric_node_id"] for h in hubs_out[:4]]
),
"preview_recipes": list(recipe_pref[:2]),
"skip_rings_first": gravity_type in {"core_bar", "chains", "mixed_blocks"},
"geometry": geometry,
"layout_over_rank": True,
"decompose_by_component": True,
"decompose_soft_blocks": primary_shape in {"star", "mesh", "mixed_blocks"}
or (len(comps) <= 2 and star_nodes >= 40),
"block_plan": [
{
"block_id": b["block_id"],
"shape": b["shape"],
"n": b["node_count"],
"how": (
"spine+short stubs, row pack"
if b["shape"] in {"chain", "tiny"}
else (
"hub center + petals"
if b["shape"] == "star"
else "corridor/compact then local untangle"
)
),
}
for b in blocks[:20]
],
"next": (
"CHAIN canvas: for each structure.shape.blocks item with shape=chain, "
"lay diameter spine horizontally, hang deg-1/short branches as stubs, "
"stack components as rows; preview corridor/compact (skip rings); "
"untangle residual."
if chain_mode
else (
"Giant star/core_bar: prefer structure.dual_units "
"(two portals + ≥2 interior-disjoint corridors). "
"Batch sinkTopologyDualUnits (layout_batch) → orbit_sweep → "
"polish_crossings → clear_edge_hits. "
"Portals may appear in multiple units. "
"Small graphs: layout compact|corridor|rings. "
"agg_bar: preview rings first."
)
),
"prefer_dual_units": (
not chain_mode
and (
primary_shape in {"star", "mesh", "mixed_blocks"}
or gravity_type in {"core_bar", "mixed", "mixed_blocks"}
or star_nodes >= 40
)
),
}
# Soft blocks inside giant CCs (hub BFS; igraph optional for leftovers).
soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False}
dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
try:
from netx_api.mcp_remote.topology.layout_ops.dual_units import dual_units_report
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_api.mcp_remote.topology.layout_ops.partition import partition_report
st = build_state_from_nodes_edges(nodes, edges)
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"
soft_blocks = partition_report(st, mode=soft_mode)
dual_units = dual_units_report(st)
except Exception as exc: # pragma: no cover - defensive
soft_blocks = {
"block_count": 0,
"blocks": [],
"igraph": False,
"error": str(exc)[:120],
}
dual_units = {"unit_count": 0, "units": [], "error": str(exc)[:120]}
anchors = []
if gravity_type == "chains":
# expose spine ends of largest chain blocks as soft anchors
for b in blocks[:6]:
if b["shape"] not in {"chain", "tiny"}:
continue
for end, nm in zip(b.get("spine_ends") or [], b.get("spine_end_names") or []):
anchors.append(
{
"fabric_node_id": end,
"name": nm,
"layer": layers.get(end, "access"),
"degree": deg.get(end, 0),
"territory": 0,
"role": "spine_end",
}
)
else:
anchors = [
{
"fabric_node_id": h["fabric_node_id"],
"name": h["name"],
"layer": h["layer"],
"degree": h["degree"],
"territory": h["territory"],
}
for h in hubs_out[:6]
if h["layer"] == anchor_layer
or (gravity_type in {"mixed", "mixed_blocks"} and h["layer"] in {"core", "agg"})
]
return {
"node_count": len(ids),
"link_count": len(links),
"degree": {
"max": max(degs) if degs else 0,
"mean": round(mean(degs), 2) if degs else 0.0,
"median": float(median(degs)) if degs else 0.0,
"p90": _pct(degs, 0.9),
"le2_frac": round(global_low, 3),
},
"shape": shape_block,
"layers": layer_block,
"hubs": hubs_out,
"stubs": stub_rows,
"soft_blocks": soft_blocks,
"dual_units": dual_units,
"gravity": {
"type": gravity_type,
"confidence": round(confidence, 3),
"anchor_layer": anchor_layer,
"decorative_layers": decorative,
"geometry_hint": geometry,
"recipe_preference": recipe_pref,
"rationale": rationale,
"anchors": anchors,
},
"advice": advice,
"headline": (
f"shape={primary_shape} gravity={gravity_type} "
f"comps={len(comps)} geometry={geometry} try={'>'.join(recipe_pref[:2])}"
),
}

View file

@ -0,0 +1,990 @@
"""MCP-facing layout runner: full layout + local fix/relax (no temp scripts)."""
from __future__ import annotations
from dataclasses import replace
from typing import Any
from netx_api.mcp_remote.topology.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
positions_for_api,
run_recipe,
score_state,
)
from netx_api.mcp_remote.topology.layout_ops.hotspots import (
fix_overlaps_local,
hotspot_scopes,
overlapping_nodes,
)
from netx_api.mcp_remote.topology.layout_ops.recipe import RECIPES
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
from netx_api.mcp_remote.topology.layout_ops.channels import (
straighten_channels_greedy,
straighten_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
dual_unit_params_from_overrides,
layout_dual_unit,
)
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
from netx_api.mcp_remote.topology.layout_ops.untangle import (
untangle_crossings,
untangle_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.press_crossings import (
park_phantom_nodes,
polish_crossings,
press_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import (
clear_edge_hits,
clear_edge_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.compact_bbox import (
compact_bbox,
compact_bbox_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import (
pull_far_chains,
pull_far_chains_params_from_overrides,
)
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
apply_orbit_pick,
orbit_params_from_overrides,
orbit_sweep_node,
orbit_sweep_round,
orbit_sweep_until_limit,
)
from netx_api.mcp_remote.topology.layout_ops.level_util import (
apply_level_bands,
level_bands_params_from_overrides,
)
# Public recipe names → internal multipass ids
RECIPE_ALIASES: dict[str, str] = {
"rings": "agg_rings_v1",
"corridor": "smd_corridor_v1",
"compact": "smd_corridor_compact_v1",
"unstick": "smd_corridor_unstick_v1",
"agg_rings_v1": "agg_rings_v1",
"smd_corridor_v1": "smd_corridor_v1",
"smd_corridor_compact_v1": "smd_corridor_compact_v1",
"smd_corridor_unstick_v1": "smd_corridor_unstick_v1",
}
PRESETS: dict[str, dict[str, float]] = {
"loose": {
"target_nn": 170.0,
"scale_cap": 2.8,
"target_util": 0.12,
"pack_min_scale": 0.35,
"pack_iters": 6,
"island_pad_x": 200.0,
"island_pad_y": 180.0,
"lane": 300.0,
"x_gain": 1.8,
"pitch": 220.0,
"side": 200.0,
"an_gap": 560.0,
"width_mul": 2.4,
"height_mul": 1.5,
},
"balanced": {
"target_nn": 155.0,
"scale_cap": 2.2,
"target_util": 0.18,
"pack_min_scale": 0.28,
"pack_iters": 6,
"island_pad_x": 180.0,
"island_pad_y": 160.0,
"lane": 260.0,
"x_gain": 1.8,
"width_mul": 2.0,
"height_mul": 1.35,
},
"dense": {
"target_nn": 145.0,
"scale_cap": 2.0,
"target_util": 0.28,
"pack_min_scale": 0.22,
"pack_iters": 8,
"island_pad_x": 160.0,
"island_pad_y": 160.0,
"lane": 240.0,
"x_gain": 1.8,
"width_mul": 1.7,
"height_mul": 1.2,
},
}
_PARAM_KEYS = (
"target_nn",
"scale_cap",
"target_util",
"pack_min_scale",
"pack_iters",
"pack_nn_floor",
"island_pad_x",
"island_pad_y",
"cluster_gap",
"cluster_thr",
"x_gain",
"lane",
"overlap_iters",
"overlap_step",
"width_mul",
"height_mul",
"pitch",
"side",
"an_gap",
)
ACTIONS = (
"layout",
"fix_overlaps",
"untangle",
"straighten_channels",
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
"compact_bbox",
"pull_far_chains",
"align_reference",
"orbit_sweep",
"level_bands",
)
def resolve_recipe(name: str | None) -> str:
key = str(name or "rings").strip().lower() or "rings"
if key not in RECIPE_ALIASES:
raise ValueError(f"unknown_recipe:{key}")
internal = RECIPE_ALIASES[key]
if internal not in RECIPES:
raise ValueError(f"recipe_not_registered:{internal}")
return internal
def build_params(
*,
preset: str = "balanced",
overrides: dict[str, Any] | None = None,
) -> LayoutParams:
preset_key = str(preset or "balanced").strip().lower() or "balanced"
if preset_key not in PRESETS:
raise ValueError(f"unknown_preset:{preset_key}")
base = LayoutParams()
merged = {**PRESETS[preset_key]}
for k, v in (overrides or {}).items():
if k not in _PARAM_KEYS or v is None:
continue
try:
merged[k] = float(v) if k not in {"pack_iters", "overlap_iters"} else int(v)
except (TypeError, ValueError):
continue
return replace(base, **merged)
def _rank_key(fin: dict[str, Any]) -> tuple:
rk = (fin.get("score") or {}).get("rank_key")
if isinstance(rk, list) and rk:
return tuple(rk)
ov = int(fin.get("footprint_overlap_pairs") or 0) + int(
fin.get("label_overlap_pairs") or 0
)
total = float((fin.get("score") or {}).get("total") or 0.0)
cross = int(fin.get("edge_crossings") or 0)
return (ov, -total, cross)
def _tune_grid(base: LayoutParams) -> list[LayoutParams]:
"""Sweep knobs that actually move util/nn/crossings (incl. skeleton scale)."""
out: list[LayoutParams] = []
for tu, pms in (
(0.08, base.pack_min_scale),
(0.14, max(0.45, base.pack_min_scale - 0.05)),
(0.20, max(0.40, base.pack_min_scale - 0.10)),
(0.12, min(0.70, base.pack_min_scale + 0.05)),
):
for wm, hm in ((2.2, 1.4), (2.8, 1.7), (3.5, 2.0)):
for tnn in (base.target_nn, 160.0, 140.0):
out.append(
replace(
base,
target_util=tu,
pack_min_scale=pms,
target_nn=tnn,
width_mul=wm,
height_mul=hm,
)
)
seen: set[tuple] = set()
uniq: list[LayoutParams] = []
for p in out:
key = (
p.target_util,
p.pack_min_scale,
p.target_nn,
p.width_mul,
p.height_mul,
)
if key in seen:
continue
seen.add(key)
uniq.append(p)
return uniq[:12]
def _pack_result(
st: LayoutState,
fin: dict[str, Any],
*,
action: str,
recipe: str | None,
recipe_id: str | None,
preset: str,
params: LayoutParams,
tune: bool,
tried: list[dict[str, Any]] | None,
local: dict[str, Any] | None = None,
) -> dict[str, Any]:
report = dict(fin.get("report") or {})
meta = st.meta or {}
return {
"ok": True,
"action": action,
"recipe": recipe,
"recipe_id": recipe_id,
"preset": preset,
"tune": bool(tune),
"tried": tried,
"local": local,
"rings_mode": meta.get("rings_mode"),
"min_rings": meta.get("min_rings"),
"params_used": {
k: getattr(params, k)
for k in (
"target_nn",
"scale_cap",
"target_util",
"pack_min_scale",
"island_pad_x",
"island_pad_y",
"cluster_gap",
"x_gain",
"lane",
)
},
"node_count": len(st.positions),
"positions": positions_for_api(st),
"verdict": report.get("verdict"),
"size": report.get("size"),
"overlap": report.get("overlap"),
"crossing": report.get("crossing"),
"spacing": report.get("spacing"),
"sparsity": report.get("sparsity"),
"edges": report.get("edges"),
"chains": report.get("chains"),
"rings": report.get("rings"),
"score": report.get("score"),
"summary": fin.get("summary") or {},
"guide": report.get("guide"),
}
def _ensure_zero_overlap(st: LayoutState, params: LayoutParams) -> tuple[LayoutState, dict[str, Any]]:
"""If any overlap remains, surgical local pull-apart (not global scale)."""
before = len(overlapping_nodes(st))
if before == 0:
return st, {"ran": False, "overlaps_before": 0, "overlaps_after": 0}
op = fix_overlaps_local(st, params)
after = len(overlapping_nodes(op.state))
return op.state, {
"ran": True,
"overlaps_before": before,
"overlaps_after": after,
"moved_n": len(op.moved),
"note": op.note,
}
def run_layout_on_graph(
nodes: list[dict[str, Any]],
edges: list[dict[str, Any]],
*,
action: str = "layout",
recipe: str = "rings",
preset: str = "balanced",
params: dict[str, Any] | None = None,
tune: bool = False,
) -> dict[str, Any]:
"""Compute layout or local polish on existing positions. Does not PATCH."""
action_key = str(action or "layout").strip().lower() or "layout"
if action_key not in ACTIONS:
raise ValueError(
f"unknown_action:{action_key}; "
f"allowed={','.join(ACTIONS)}"
)
base_params = build_params(preset=preset, overrides=params)
st0 = build_state_from_nodes_edges(nodes, edges)
park_phantom_nodes(st0)
# Inject rigid/portal groups from staging membership when provided.
if params:
raw = params.get("_rigid_membership") or params.get("rigid_membership")
if isinstance(raw, list):
groups: list[dict[str, Any]] = []
pairs: list[tuple[str, list[str]]] = []
any_pivots = False
for row in raw:
if not isinstance(row, dict):
continue
key = str(row.get("key") or "").strip()
ids = [str(x) for x in (row.get("node_ids") or []) if str(x)]
pivots = [str(x) for x in (row.get("pivots") or []) if str(x)]
if not key or len(ids) < 2:
continue
if pivots:
any_pivots = True
groups.append({"key": key, "node_ids": ids, "pivots": pivots})
pairs.append((key, ids))
if groups:
if not any_pivots:
groups = groups_from_membership(pairs)
st0 = st0.copy()
st0.meta = dict(st0.meta or {})
st0.meta["compose_views"] = {
**(st0.meta.get("compose_views") or {}),
"rigid_groups": groups,
}
# --- local actions: keep current coordinates, do not rebuild skeleton ---
if action_key == "fix_overlaps":
op = fix_overlaps_local(st0, base_params)
st, fix_meta = _ensure_zero_overlap(op.state, base_params)
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={"op": op.params, "ensure": fix_meta, "hotspots": len(hotspot_scopes(st0))},
)
if action_key == "untangle":
knobs = untangle_params_from_overrides(params)
op = untangle_crossings(st0, base_params, **knobs)
# Origin shift only — soft_nn_scale can inflate an already-good human
# layout; local jumps are capped inside untangle_crossings.
st = normalize_origin(op.state, base_params).state
# Overlap crush must not erase untangle gains.
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x_before_fix = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params)
x_after_fix = _cx(st2.positions, st2.links)
if x_after_fix <= x_before_fix + 2:
st = st2
else:
fix_meta = {
**fix_meta,
"reverted": True,
"crossings_before": x_before_fix,
"crossings_after": x_after_fix,
"reason": "overlap_fix_raised_crossings",
}
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={"op": op.params, "note": op.note, "meta": st.meta.get("untangle"), "ensure": fix_meta},
)
if action_key == "straighten_channels":
knobs = straighten_params_from_overrides(params)
op = straighten_channels_greedy(st0, base_params, **knobs)
st, fix_meta = _ensure_zero_overlap(op.state, base_params)
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("straighten_channels"),
"ensure": fix_meta,
},
)
if action_key == "layout_dual_unit":
knobs = dual_unit_params_from_overrides(params)
op = layout_dual_unit(
st0,
base_params,
unit_id=knobs.get("unit_id"),
portal_a=knobs.get("portal_a"),
portal_b=knobs.get("portal_b"),
require_zero_cross=bool(knobs.get("require_zero_cross", False)),
)
accepted = bool(op.params.get("accepted", False))
# Always return best-effort dual geometry (even if accepted=False).
# Do NOT run global overlap crush — it reintroduces crossings.
st = op.state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"accepted": accepted,
"meta": st.meta.get("layout_dual_unit"),
"ensure": {
"ran": False,
"reason": "dual_unit_skips_global_overlap_crush",
},
},
)
if action_key == "orbit_sweep":
knobs = orbit_params_from_overrides(params)
do_round = bool(knobs.get("round"))
do_until = bool(knobs.get("until_limit"))
node_id = str(knobs.get("node_id") or "").strip()
frozen = knobs.get("frozen_ids")
protect = knobs.get("protect_rigid", "off")
if do_until:
# until_limit wins over round/node_id (single-point loop to stall).
op = orbit_sweep_until_limit(
st0,
params=base_params,
max_degree=int(knobs.get("max_degree") or 14),
max_jump=knobs.get("max_jump"),
angle_step=knobs.get("angle_step"),
nn_floor=float(knobs.get("nn_floor") or 36.0),
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
protect_rigid=protect,
frozen_ids=frozen,
freeze_layers=knobs.get("freeze_layers"),
freeze_levels=knobs.get("freeze_levels"),
y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"),
objective=str(knobs.get("objective") or "crossing"),
max_moves=int(knobs.get("max_moves") or 40),
stall_limit=int(knobs.get("stall_limit") or 12),
max_stretch=float(knobs.get("max_stretch") or 32.0),
min_delta=int(knobs.get("min_delta") or 1),
scan_cap=int(knobs.get("scan_cap") or 32),
top_k=int(knobs.get("top_k") or 8),
prefer_low_degree=bool(knobs.get("prefer_low_degree", True)),
cand_cap=int(knobs.get("cand_cap") or 360),
bundle=bool(knobs.get("bundle", True)),
bundle_max=int(knobs.get("bundle_max") or 10),
)
st = normalize_origin(op.state, base_params).state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("orbit_sweep"),
"until_limit": True,
},
)
if do_round:
op = orbit_sweep_round(
st0,
params=base_params,
top_n=int(knobs.get("top_n") or 12),
max_degree=int(knobs.get("max_degree") or 9),
max_jump=knobs.get("max_jump"),
angle_step=knobs.get("angle_step"),
nn_floor=float(knobs.get("nn_floor") or 36.0),
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
protect_rigid=protect,
frozen_ids=frozen,
freeze_layers=knobs.get("freeze_layers"),
freeze_levels=knobs.get("freeze_levels"),
focus_ids=knobs.get("focus_ids"),
y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"),
objective=str(knobs.get("objective") or "crossing"),
)
st = normalize_origin(op.state, base_params).state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("orbit_sweep"),
"round": True,
},
)
if not node_id:
raise ValueError(
"orbit_sweep_requires_node_id_or_round_or_until_limit:"
"params.node_id=… or params.round=true or params.until_limit=true"
)
sweep = orbit_sweep_node(
st0,
node_id,
params=base_params,
max_jump=knobs.get("max_jump"),
angle_step=knobs.get("angle_step"),
nn_floor=float(knobs.get("nn_floor") or 36.0),
min_angle_sep=float(knobs.get("min_angle_sep") or 35.0),
cand_cap=int(knobs.get("cand_cap") or 280),
protect_rigid=protect,
frozen_ids=frozen,
freeze_layers=knobs.get("freeze_layers"),
freeze_levels=knobs.get("freeze_levels"),
top_k=int(knobs.get("top_k") or 3),
y_min=knobs.get("y_min"),
y_max=knobs.get("y_max"),
objective=knobs.get("objective", "crossing"),
)
if not sweep.get("ok"):
fin = score_state(st0)
return _pack_result(
st0,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={"op": sweep, "note": f"orbit_sweep:{sweep.get('error')}"},
)
# Suggest-only keeps coords. Apply path sets params.pick (HTTP injects
# pick=1 on mode=apply) so the chosen candidate is in positions.
apply_pick = knobs.get("pick")
if apply_pick is not None:
op = apply_orbit_pick(st0, sweep, pick=int(apply_pick or 1))
st = normalize_origin(op.state, base_params).state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"sweep": sweep,
"pick": int(apply_pick or 1),
},
)
fin = score_state(st0)
return _pack_result(
st0,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": {
"node_id": sweep.get("node_id"),
"crossings_before": sweep.get("crossings_before"),
"candidates": sweep.get("candidates"),
"sampled": sweep.get("sampled"),
"improving_n": sweep.get("improving_n"),
},
"note": (
f"orbit_sweep top3 improving={sweep.get('improving_n')} "
f"sampled={sweep.get('sampled')}"
),
"sweep": sweep,
"hint": sweep.get("hint"),
},
)
if action_key == "clear_edge_hits":
knobs = clear_edge_params_from_overrides(params)
preserve = bool(knobs.get("preserve_axis"))
op = clear_edge_hits(
st0,
base_params,
top_n=int(knobs.get("top_n") or 12),
thr=float(knobs.get("thr") or 40.0),
margin=float(knobs.get("margin") or 20.0),
max_moves=int(knobs.get("max_moves") or 24),
preserve_axis=preserve,
pitch=knobs.get("pitch"),
side=knobs.get("side"),
rounds=int(knobs.get("rounds") or (6 if preserve else 1)),
frozen_ids=knobs.get("frozen_ids") or set(),
max_eject_degree=int(knobs.get("max_eject_degree") or 5),
)
st = normalize_origin(op.state, base_params).state
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x0 = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params)
x1 = _cx(st2.positions, st2.links)
if x1 <= x0 + 2:
st = st2
else:
fix_meta = {"ran": False, "reason": "overlap_fix_would_raise_crossings"}
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("clear_edge_hits"),
"ensure": fix_meta,
},
)
if action_key == "compact_bbox":
knobs = compact_bbox_params_from_overrides(params)
op = compact_bbox(
st0,
base_params,
frozen_ids=knobs.get("frozen_ids") or set(),
portal_ids=knobs.get("portal_ids") or [],
min_scale=float(knobs.get("min_scale") or 0.72),
step=float(knobs.get("step") or 0.03),
max_clearance_slack=int(knobs.get("max_clearance_slack") or 80),
outlier_only=bool(knobs.get("outlier_only", True)),
)
st = normalize_origin(op.state, base_params).state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={"op": op.params, "note": op.note, "meta": st.meta.get("compact_bbox")},
)
if action_key == "pull_far_chains":
knobs = pull_far_chains_params_from_overrides(params)
op = pull_far_chains(
st0,
base_params,
frozen_ids=knobs.get("frozen_ids") or set(),
portal_ids=knobs.get("portal_ids") or [],
max_chains=int(knobs.get("max_chains") or 16),
min_tip_radius=float(knobs.get("min_tip_radius") or 1800.0),
scales=knobs.get("scales") or (0.92, 0.88, 0.84, 0.80, 0.75),
max_clearance_slack=int(knobs.get("max_clearance_slack") or 40),
pull_isolates=bool(knobs.get("pull_isolates", True)),
)
st = normalize_origin(op.state, base_params).state
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("pull_far_chains"),
},
)
if action_key == "level_bands":
knobs = level_bands_params_from_overrides(params)
op = apply_level_bands(st0, base_params, **knobs)
# Soft unstick only — hard crush fights band geometry and inflates crossings.
st = op.state
ov = overlapping_nodes(st)
fix_meta: dict[str, Any] = {"ran": False, "overlaps_before": len(ov)}
if ov:
st2 = fix_overlaps_local(st, base_params).state
st = st2
fix_meta = {
"ran": True,
"overlaps_before": len(ov),
"overlaps_after": len(overlapping_nodes(st)),
"mode": "local_only",
}
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get("level_bands"),
"ensure": fix_meta,
},
)
if action_key == "polish_crossings":
knobs = press_params_from_overrides(params)
# Omit knobs so polish can auto-scale budgets on large E (MCP timeout).
# Internal polish still calls straighten / press_hot_edges /
# press_crossers / untangle.
op = polish_crossings(
st0,
base_params,
portal_ids=knobs.get("portal_ids"),
straighten=knobs.get("straighten"),
preserve_dual_eye=knobs.get("preserve_dual_eye"),
max_degree=int(knobs.get("max_degree") or 9),
untangle_rounds=knobs.get("untangle_rounds"),
top_n=knobs.get("top_n"),
max_moves=knobs.get("max_moves"),
max_sweeps=knobs.get("max_sweeps"),
)
st = normalize_origin(op.state, base_params).state
# Overlap crush must not erase crossing gains.
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x0 = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params)
x1 = _cx(st2.positions, st2.links)
if x1 <= x0 + 2:
st = st2
else:
fix_meta = {
**fix_meta,
"reverted": True,
"reason": "overlap_fix_raised_crossings",
}
fin = score_state(st)
return _pack_result(
st,
fin,
action=action_key,
recipe=None,
recipe_id=None,
preset=preset,
params=base_params,
tune=False,
tried=None,
local={
"op": op.params,
"note": op.note,
"meta": st.meta.get(action_key),
"ensure": fix_meta,
},
)
# --- full layout recipe ---
recipe_id = resolve_recipe(recipe)
tried: list[dict[str, Any]] = []
best_st = None
best_fin: dict[str, Any] | None = None
best_params = base_params
combos = _tune_grid(base_params) if tune else [base_params]
for i, p in enumerate(combos):
st, _trace, fin = run_recipe(st0, recipe_id, p)
st, fix_meta = _ensure_zero_overlap(st, p)
fin = score_state(st)
row = {
"i": i,
"total": (fin.get("score") or {}).get("total"),
"overlaps": fin.get("footprint_overlap_pairs"),
"crossings": fin.get("edge_crossings"),
"nn_p50": fin.get("nn_p50"),
"util": fin.get("space_utilization"),
"fix": fix_meta,
"headline": ((fin.get("report") or {}).get("verdict") or {}).get("headline"),
}
tried.append(row)
if best_fin is None or _rank_key(fin) < _rank_key(best_fin):
best_st, best_fin, best_params = st, fin, p
assert best_st is not None and best_fin is not None
return _pack_result(
best_st,
best_fin,
action=action_key,
recipe=recipe if recipe in RECIPE_ALIASES else recipe_id,
recipe_id=recipe_id,
preset=preset,
params=best_params,
tune=bool(tune),
tried=tried if tune else None,
local={"auto_fix_overlaps": True},
)
def list_layout_catalog() -> dict[str, Any]:
from netx_api.mcp_remote.topology import NETX_MCP_REV, __version__
return {
"version": __version__,
"rev": NETX_MCP_REV,
"actions": {
"layout": "全图配方(骨架);块内 pack,结束时局部解叠",
"fix_overlaps": "只拉开当前重叠点(+1 跳邻居),不重排全图",
"straighten_channels": (
"拉直 deg≤2 通道(弦/横/纵);仅全局交叉下降才接受该通道"
"(params: step/min_len)"
),
"layout_dual_unit": (
"双门户单元美化(多走廊→平行 H/V 道;链→拉直);"
"默认允许残留交叉;params.require_zero_cross=true 时要求为 0(params: unit_id)"
),
"untangle": (
"贪心挪低度数点降交叉;默认 protect_rigid=portals "
"只冻共享门户,走廊/触手可动(all=全冻,off=不冻);"
"可用 focus_ids / source_view_ids"
),
"polish_crossings": (
"一键压交叉:straighten→press_hot_edges→press_crossers→"
"untangle(portals);优先用 source_view_ids 冻门户 "
"(勿写临时 py)"
),
"clear_edge_hits": (
"把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);"
"门控:不增交叉、不增重叠(preserve_axis 亦不放宽交叉)。"
"眼 sink 须 portal_ids;params: top_n/thr/margin/max_moves/max_eject_degree"
),
"compact_bbox": (
"眼图安全收 bbox:相对门户中心;默认 farthest-K(outlier_only);"
"门控:交叉不升、overlaps=0、贴边不可大幅恶化。"
"params: portal_ids/min_scale/step"
),
"pull_far_chains": (
"眼图安全收远场:deg≤2 走廊/孤立点/远叶相对门户中点缩放;"
"门控:交叉不升、overlaps=0、贴边松弛有限。"
"params: portal_ids/max_chains/min_tip_radius/scales"
),
"align_reference": (
"【非主路径】仅同网同成员画布 A/B 调试:"
"把参考画布几何映射到当前画布(共享 fabric_node_id)。"
"日常无范本、跨网人工图 → 禁止当交付手段。"
"params: reference_view_id|source_view_id, portal_ids, mode=similarity|adopt"
),
"level_bands": (
"按 fabric level/layer 水平分层(external→core→agg→access);"
"params: y0/band_gap/preserve_x/pitch;分层场景先于 polish"
),
"orbit_sweep": (
"压交叉:以网元为圆心不定长扫角;"
"preview+node_id / apply+pick;"
"until_limit=true 单点循环到 stall(默认冻 portals、objective=crossing|total);"
"round=true 一批 top_n 自动 pick#1(眼 sink 禁用);"
"单点/round 默认 protect_rigid=off;bundle 默认开"
),
"job_status": (
"轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、"
"heartbeat_age_ms、stale(软警告,不改状态)"
),
"job_cancel": (
"协作式取消后台 job:params.job_id;下一检查点退出;"
"若已 PATCH 则保留写入并回报 applied"
),
"move_nodes": (
"成员迁移(双向):params.fabric_node_ids 从 source_view_id→view_id;"
"默认 remove_from_source;对调两 view 即回迁;"
"copy_positions|park;mode=preview|apply"
),
},
"recipes": {
"rings": "环+链花瓣;core_bar 时梁优先(多 CN 不塞进 AN 列)",
"corridor": "Tutte 走廊骨架,交叉常更少,易偏空——稳基线",
"compact": "走廊 + 分块 pack(禁止全局压扁)——稳基线",
"unstick": "corridor 后再强解重叠",
},
"presets": {
"loose": "偏疏、交叉友好",
"balanced": "默认折中",
"dense": "偏紧、抬 util",
},
"modes": {
"preview": "只算分+坐标,不写库",
"apply": "PATCH 到 view_id(有残留重叠则拒绝落笔)",
},
"workflow": (
"主路径:analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"suggestSinkHubs/move_nodes(park) → "
"orbit_sweep(until_limit crossing→total) → "
"clear_edge_hits → pull_far_chains → compact_bbox → 手拖。"
"默认无范本;align_reference 仅同网调试,禁止当跨网交付。"
"眼 sink 禁 polish/fix_overlaps/untangle/round。"
),
"eye_polish_plateau": (
"算法到头:overlaps=0 + until_limit stall + "
"pull/compact/clear moved≈0 → 手拖或改初布;勿指望金标对齐。"
),
}

View file

@ -0,0 +1,334 @@
"""Mid-tier topology geometry QA: chain cohesion + min-ring integrity.
- chain(直链成一体): deg≤2 corridors should lay nearly collinear as one unit.
- rings(最小环不被穿): short chordless cycles should not be crossed by foreign edges.
"""
from __future__ import annotations
import math
from collections import defaultdict, deque
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
# Turn from 180° beyond this → kink (degrees).
CHAIN_KINK_DEG = 40.0
# Min nodes on a corridor to score as a chain (hub + ≥2 hops, or ≥3 deg≤2).
CHAIN_MIN_NODES = 3
# Chordless cycles longer than this are ignored (metro min-rings are short).
RING_MAX_LEN = 8
def _adj(links: list[tuple[str, str]]) -> dict[str, set[str]]:
g: dict[str, set[str]] = defaultdict(set)
for a, b in links:
if a == b:
continue
g[a].add(b)
g[b].add(a)
return g
def _edge_key(a: str, b: str) -> tuple[str, str]:
return (a, b) if a < b else (b, a)
def _walk_path(start: str, nodes: set[str], adj: dict[str, set[str]]) -> list[str]:
ordered = [start]
seen = {start}
prev: str | None = None
cur = start
while True:
nbs = [v for v in adj.get(cur, ()) if v in nodes and v not in seen]
if not nbs:
break
# Prefer continuing along the unique unused neighbor.
nxt = nbs[0] if len(nbs) == 1 else sorted(nbs)[0]
if prev in nbs and len(nbs) > 1:
nbs = [v for v in nbs if v != prev]
nxt = nbs[0]
prev, cur = cur, nxt
ordered.append(cur)
seen.add(cur)
return ordered
def extract_chain_paths(adj: dict[str, set[str]]) -> list[list[str]]:
"""Maximal deg≤2 corridors, optionally extended by one hub endpoint each side."""
if not adj:
return []
deg = {u: len(vs) for u, vs in adj.items()}
low = {u for u, d in deg.items() if 0 < d <= 2}
if not low:
return []
seen: set[str] = set()
chains: list[list[str]] = []
for seed in sorted(low):
if seed in seen:
continue
q = deque([seed])
seen.add(seed)
comp: set[str] = set()
while q:
u = q.popleft()
comp.add(u)
for v in adj.get(u, ()):
if v in low and v not in seen:
seen.add(v)
q.append(v)
# Cycle of only low-deg nodes → not a chain corridor.
sub_deg = {u: sum(1 for v in adj.get(u, ()) if v in comp) for u in comp}
ends = [u for u in comp if sub_deg[u] <= 1]
if len(ends) == 0 and len(comp) >= 3:
continue
start = sorted(ends)[0] if ends else sorted(comp)[0]
ordered = _walk_path(start, comp, adj)
if len(ordered) < len(comp):
# branched low-deg blob: take longest path approximation via ends
best = ordered
for e in ends:
p = _walk_path(e, comp, adj)
if len(p) > len(best):
best = p
ordered = best
# Extend with unique high-deg portals (hub ends).
def _portal(end: str) -> str | None:
outs = [v for v in adj.get(end, ()) if v not in comp]
return outs[0] if len(outs) == 1 else None
if ordered:
left = _portal(ordered[0])
right = _portal(ordered[-1])
if left:
ordered = [left] + ordered
if right and right != left:
ordered = ordered + [right]
if len(ordered) >= CHAIN_MIN_NODES:
chains.append(ordered)
return chains
def _chain_straightness(path: list[str], pos: dict[str, tuple[float, float]]) -> float:
pts = [pos[n] for n in path if n in pos]
if len(pts) < 2:
return 1.0
chord = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1])
plen = 0.0
for i in range(len(pts) - 1):
plen += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1])
if plen < 1e-9:
return 1.0
return max(0.0, min(1.0, chord / plen))
def _chain_kinks(path: list[str], pos: dict[str, tuple[float, float]]) -> int:
pts = [pos[n] for n in path if n in pos]
kinks = 0
for i in range(1, len(pts) - 1):
ax, ay = pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]
bx, by = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]
la, lb = math.hypot(ax, ay), math.hypot(bx, by)
if la < 1e-9 or lb < 1e-9:
continue
cos = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
turn = abs(math.degrees(math.acos(cos)) - 0.0) # 0 = straight continuation
# acos of dot gives angle between directions; 0° = collinear same way.
# kink if direction changes more than CHAIN_KINK_DEG from straight (0°).
if turn > CHAIN_KINK_DEG:
kinks += 1
return kinks
def compute_chain_cohesion(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
) -> dict[str, Any]:
"""直链成一体:corridor straightness / kink rate → score∈[0,1]."""
adj = _adj(links)
chains = extract_chain_paths(adj)
if not chains:
return {
"chain_count": 0,
"chain_nodes": 0,
"straightness_p50": None,
"kink_count": 0,
"kink_frac": 0.0,
"score": 1.0,
"tip": "无 deg≤2 走廊可评;不扣分。",
}
straight: list[float] = []
weights: list[float] = []
kink_total = 0
kink_slots = 0
node_union: set[str] = set()
for path in chains:
node_union.update(path)
s = _chain_straightness(path, pos)
straight.append(s)
weights.append(max(1, len(path) - 1))
k = _chain_kinks(path, pos)
kink_total += k
kink_slots += max(0, len([n for n in path if n in pos]) - 2)
# weighted mean straightness
wsum = sum(weights) or 1.0
mean_s = sum(s * w for s, w in zip(straight, weights)) / wsum
sorted_s = sorted(straight)
p50 = sorted_s[len(sorted_s) // 2]
kink_frac = (kink_total / kink_slots) if kink_slots else 0.0
# Mid-tier score: mostly straightness, penalize kinks.
score = max(0.0, min(1.0, 0.75 * mean_s + 0.25 * (1.0 - kink_frac)))
return {
"chain_count": len(chains),
"chain_nodes": len(node_union),
"straightness_p50": round(p50, 4),
"straightness_mean": round(mean_s, 4),
"kink_count": kink_total,
"kink_frac": round(kink_frac, 4),
"score": round(score, 4),
"tip": (
"deg≤2 走廊应近似共线成一体(chord/path≈1、少折角);"
f"折角阈值 {CHAIN_KINK_DEG:.0f}°。"
),
}
def _is_chordless(cycle: list[str], adj: dict[str, set[str]]) -> bool:
n = len(cycle)
idx = {u: i for i, u in enumerate(cycle)}
for i, u in enumerate(cycle):
for v in adj.get(u, ()):
j = idx.get(v)
if j is None:
continue
dist = min((j - i) % n, (i - j) % n)
if dist > 1:
return False
return True
def find_short_chordless_cycles(
adj: dict[str, set[str]],
*,
max_len: int = RING_MAX_LEN,
max_cycles: int = 120,
) -> list[list[str]]:
"""Enumerate chordless cycles of length 3..max_len (canonicalized).
Caps at ``max_cycles`` so giant star canvases stay interactive.
"""
if not adj:
return []
cycles: list[list[str]] = []
seen: set[frozenset[str]] = set()
nodes = sorted(adj.keys())
for start in nodes:
if len(cycles) >= max_cycles:
break
# path grows; first neighbor ordered to cut duplicates
for first in sorted(adj.get(start, ())):
if first <= start:
continue
if len(cycles) >= max_cycles:
break
stack: list[tuple[str, list[str]]] = [(first, [start, first])]
while stack:
if len(cycles) >= max_cycles:
break
u, path = stack.pop()
if len(path) > max_len:
continue
for v in adj.get(u, ()):
if v == start and len(path) >= 3:
key = frozenset(path)
if key in seen:
continue
if _is_chordless(path, adj):
seen.add(key)
cycles.append(list(path))
if len(cycles) >= max_cycles:
break
continue
if v in path or v < start:
continue
if len(path) + 1 > max_len:
continue
stack.append((v, path + [v]))
return cycles
def compute_ring_integrity(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
) -> dict[str, Any]:
"""最小环不被穿:foreign edges crossing a short cycle's boundary."""
adj = _adj(links)
cycles = find_short_chordless_cycles(adj, max_len=RING_MAX_LEN)
if not cycles:
return {
"ring_count": 0,
"rings_pierced": 0,
"pierce_crossings": 0,
"score": 1.0,
"tip": "无长度≤8 的弦无关短环;不扣分。",
}
pierced = 0
pierce_crossings = 0
for cyc in cycles:
cyc_edges = {
_edge_key(cyc[i], cyc[(i + 1) % len(cyc)]) for i in range(len(cyc))
}
hit = False
for a, b in links:
ek = _edge_key(a, b)
if ek in cyc_edges:
continue
if a not in pos or b not in pos:
continue
pa, pb = pos[a], pos[b]
for u, v in cyc_edges:
if u not in pos or v not in pos:
continue
if segments_properly_intersect(pa, pb, pos[u], pos[v]):
pierce_crossings += 1
hit = True
break
# continue scanning to count pierce_crossings
if hit:
pierced += 1
n = len(cycles)
# Soften: many pierce events on one ring still one pierced ring;
# also decay by pierce density.
pierce_frac = pierced / n
dens = pierce_crossings / max(1, n)
score = max(0.0, min(1.0, 1.0 - 0.7 * pierce_frac - 0.3 * min(1.0, dens / 3.0)))
return {
"ring_count": n,
"rings_pierced": pierced,
"pierce_crossings": pierce_crossings,
"score": round(score, 4),
"tip": (
f"弦无关短环(3–{RING_MAX_LEN})边界不应被环外边穿越;"
"rings_pierced / pierce_crossings 越低越好。"
),
}
def compute_topology_quality(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
) -> dict[str, Any]:
chain = compute_chain_cohesion(pos, links)
rings = compute_ring_integrity(pos, links)
return {"chains": chain, "rings": rings}