Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.

Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-09 23:18:53 +08:00
parent 9bcb8ee0e3
commit 79f929de0c
86 changed files with 31578 additions and 240 deletions

View file

@ -1,3 +1,5 @@
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
__version__ = "0.1.0"
__version__ = "0.1.20"
# Bump when public catalog/actions change so agents know to restart stdio.
NETX_MCP_REV = "2026-08-09-fabric-merge"

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
import httpx
@ -51,20 +52,28 @@ def lang_query_params() -> dict[str, str]:
return {}
def _default_timeout() -> float:
try:
return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0)
except (TypeError, ValueError):
return 120.0
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float = 60.0,
timeout: float | None = None,
) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
to = float(timeout) if timeout is not None else _default_timeout()
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
with httpx.Client(timeout=to, trust_env=False) as client:
resp = client.request(
method,
url,
@ -81,6 +90,55 @@ def http_json(
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def http_json_many(
requests: list[dict[str, Any]],
*,
max_workers: int = 12,
timeout: float | None = None,
) -> list[dict[str, Any]]:
"""Run many HTTP calls in parallel; preserve input order in results.
Each request: ``{method, path, params?, body?, key?}``.
"""
if not requests:
return []
if len(requests) == 1:
r0 = requests[0]
out = http_json(
str(r0.get("method") or "GET"),
str(r0.get("path") or ""),
params=r0.get("params") if isinstance(r0.get("params"), dict) else None,
body=r0.get("body") if isinstance(r0.get("body"), dict) else None,
timeout=timeout,
)
if "key" in r0:
out = {**out, "key": r0.get("key")}
return [out]
to = float(timeout) if timeout is not None else _default_timeout()
workers = max(1, min(int(max_workers), len(requests)))
results: list[dict[str, Any] | None] = [None] * len(requests)
def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]:
out = http_json(
str(req.get("method") or "GET"),
str(req.get("path") or ""),
params=req.get("params") if isinstance(req.get("params"), dict) else None,
body=req.get("body") if isinstance(req.get("body"), dict) else None,
timeout=to,
)
if "key" in req:
out = {**out, "key": req.get("key")}
return idx, out
with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)]
for fut in as_completed(futs):
idx, out = fut.result()
results[idx] = out
return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results]
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if is_error:

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_topology_mcp.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_topology_mcp.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_topology_mcp.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,483 @@
"""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:
# packages/netx-topology-mcp/src/netx_topology_mcp → 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
for path in files[: 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
)
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
with _LOCK:
job = _read_job_disk(job_id) or job
job["pid"] = proc.pid
job["progress"] = {
**(job.get("progress") or {}),
"phase": "running",
"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,811 @@
"""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)
# Score by unique nodes hit / n (plan: ~0.05 warn, 0.2 → 0)
hit_frac = len(nodes_with_hit) / max(n_nodes, 1)
if hit_frac <= 0.0:
score = 1.0
elif hit_frac >= 0.2:
score = 0.0
else:
score = 1.0 - hit_frac / 0.2
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),
"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.03
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_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.partition import (
igraph_available,
pack_block_centers,
pack_soft_blocks,
partition_report,
partition_soft_blocks,
resolve_block_mode,
)
from netx_topology_mcp.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_topology_mcp.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.compose_views import (
compose_into_state,
strip_pack_blocks,
)
from netx_topology_mcp.layout_ops.dual_units import (
find_dual_portal_units,
layout_dual_unit,
)
from netx_topology_mcp.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,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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import extract_channels
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.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_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.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_topology_mcp.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,637 @@
"""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_topology_mcp.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_topology_mcp.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"])
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,
) -> 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``.
"""
del params
st = state.copy()
pos = dict(st.positions)
names = st.names
links = list(st.links)
adj = st.adj
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)
# Clearance matters, but keep metro readable — modest crossing slack only.
x_slack = 4 if preserve_axis else 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
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
# Very high-degree hub on a chord: break the chord instead of ejecting hub.
if deg.get(nid, 0) >= 5:
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,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_topology_mcp.layout_metrics import (
count_edge_crossings,
segments_properly_intersect,
)
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.layout_metrics import (
count_edge_crossings,
crossings_involving_node,
)
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.orbit_sweep import (
_diversify_top,
_eval_candidate,
_incident_stretch,
)
from netx_topology_mcp.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
shrink_long_corridors,
)
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.compose_orbit import orbit_pack_blocks
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
from netx_topology_mcp.layout_ops.dual_units import (
DualUnit,
beautify_dual_unit_positions,
classify_dual_unit,
find_dual_portal_units,
)
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round
from netx_topology_mcp.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

View file

@ -0,0 +1,627 @@
"""Dual-portal basic units: parallel lanes + straight/回 chains + tails.
A unit = two portals + ≥2 interior-disjoint corridors (+ optional deg≤2
tails), or a long chain between portals. Units may share portals.
Beautify targets zero edge crossings: multi-corridor → parallel H/V lanes;
chains (any length) → straight; tails as straight spurs. No 回字 fold.
"""
from __future__ import annotations
import math
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.sugiyama import _find_two_portal_ring_groups
@dataclass
class DualUnit:
portal_a: str
portal_b: str
paths: list[list[str]]
tails: list[list[str]] = field(default_factory=list)
unit_id: int = 0
@property
def portals(self) -> tuple[str, str]:
return self.portal_a, self.portal_b
def member_ids(self) -> set[str]:
out = {self.portal_a, self.portal_b}
for p in self.paths:
out.update(p)
for t in self.tails:
out.update(t)
return out
def as_dict(self, names: dict[str, str] | None = None) -> dict[str, Any]:
names = names or {}
nest = len(self.paths)
return {
"unit_id": self.unit_id,
"portal_a": self.portal_a,
"portal_b": self.portal_b,
"portal_a_name": names.get(self.portal_a, self.portal_a),
"portal_b_name": names.get(self.portal_b, self.portal_b),
"path_count": nest,
"nest_depth": nest,
"tail_count": len(self.tails),
"node_count": len(self.member_ids()),
"node_ids": sorted(self.member_ids()),
"paths_len": [len(p) for p in self.paths],
"tail_lens": [len(t) for t in self.tails],
}
def _collect_tails(
state: LayoutState,
core: set[str],
*,
max_tail: int = 24,
) -> list[list[str]]:
"""deg≤2 chains hanging off unit core; stop at foreign high-degree nodes."""
adj, names = state.adj, state.names
claimed = set(core)
tails: list[list[str]] = []
seeds = []
for n in core:
for v in adj.get(n, ()):
if v in claimed:
continue
deg = len(adj.get(v, ()))
if deg <= 2 and state.layers.get(v) not in ("core", "agg"):
seeds.append((n, v))
for attach, stub in sorted(seeds, key=lambda x: names.get(x[1], x[1])):
if stub in claimed:
continue
chain = [stub]
claimed.add(stub)
prev, cur = attach, stub
while len(chain) < max_tail:
nbs = [v for v in adj.get(cur, ()) if v != prev and v not in claimed]
# Prefer continuing along deg≤2 corridor
forward = [
v
for v in nbs
if len(adj.get(v, ())) <= 2 and state.layers.get(v) not in ("core", "agg")
]
if len(forward) != 1:
break
prev, cur = cur, forward[0]
chain.append(cur)
claimed.add(cur)
tails.append(chain)
return tails
def find_dual_portal_units(
state: LayoutState,
*,
max_units: int = 120,
) -> list[DualUnit]:
"""Detect dual-portal eye units; interiors exclusive, portals may overlap."""
adj, names, layers = state.adj, state.names, state.layers
ens = [n for n, ly in layers.items() if ly == "access" and n in adj]
an_set = {n for n, ly in layers.items() if ly == "agg"}
core_set = {n for n, ly in layers.items() if ly == "core"}
units: list[DualUnit] = []
used_interior: set[str] = set()
# 1) Access/AN two-portal ring groups (sugiyama metro).
if ens:
groups = _find_two_portal_ring_groups(ens, adj, names, an_set)
for g in groups:
a, b = g["portals"] # type: ignore[misc]
paths: list[list[str]] = list(g["paths"]) # type: ignore[arg-type]
interior: set[str] = set()
for p in paths:
interior |= set(p[1:-1])
if interior & used_interior:
continue
used_interior |= interior
core = {a, b} | interior
for p in paths:
core.update(p)
tails = _collect_tails(state, core)
units.append(
DualUnit(portal_a=a, portal_b=b, paths=paths, tails=tails)
)
if len(units) >= max_units:
break
# 2) Agg/core hub pairs with ≥2 corridor covers (fills CN—AN / CN—CN).
hubs = sorted(
[n for n in (an_set | core_set) if n in adj],
key=lambda n: (-len(adj.get(n, ())), names.get(n, n)),
)
for i, a in enumerate(hubs):
if len(units) >= max_units:
break
for b in hubs[i + 1 :]:
if len(units) >= max_units:
break
# Only forbid other cores — when almost all NEs are layer=agg,
# banning every agg hub makes cover_hub_paths return 0 corridors.
forbid = core_set - {a, b}
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
if len(paths) < 2:
continue
interior = set()
for p in paths:
interior |= set(p[1:-1])
if not interior or interior & used_interior:
continue
# Skip if this pair already covered as a unit
if any(
{u.portal_a, u.portal_b} == {a, b} for u in units
):
continue
used_interior |= interior
core = {a, b} | interior
for p in paths:
core.update(p)
tails = _collect_tails(state, core)
# Stable left/right by name
pa, pb = a, b
if names.get(pa, pa) > names.get(pb, pb):
pa, pb = pb, pa
paths = [list(reversed(p)) for p in paths]
units.append(
DualUnit(portal_a=pa, portal_b=pb, paths=paths, tails=tails)
)
for i, u in enumerate(units):
u.unit_id = i
return units
def _normalize_paths(
unit: DualUnit,
) -> list[list[str]]:
a, b = unit.portal_a, unit.portal_b
paths: list[list[str]] = []
for p in unit.paths:
pp = list(p)
if pp and pp[0] == b and pp[-1] == a:
pp = list(reversed(pp))
if len(pp) >= 2 and pp[0] == a and pp[-1] == b:
paths.append(pp)
paths.sort(key=len)
return paths
def classify_dual_unit(unit: DualUnit) -> str:
"""petal = multi-corridor (parallel lanes); else straight (no 回字)."""
paths = _normalize_paths(unit)
corridors = [p for p in paths if len(p) >= 3]
if len(corridors) >= 2:
return "petal"
return "straight"
def _place_chain_straight(
nodes: list[str],
*,
origin: tuple[float, float],
direction: tuple[float, float],
pitch: float,
pos: dict[str, tuple[float, float]],
) -> None:
dx, dy = direction
nrm = math.hypot(dx, dy) or 1.0
ux, uy = dx / nrm, dy / nrm
ox, oy = origin
for i, nid in enumerate(nodes):
if nid in pos:
continue
pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1))
def beautify_dual_unit_positions(
state: LayoutState,
unit: DualUnit,
params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]:
"""Beautify one unit: multi-corridor→H/V lanes; chains→straight (no 回字).
Local coords; portals on x-axis. dual_mass aligns onto world portals.
Multi-corridor (kind=petal): parallel horizontal lanes with vertical
stubs at portal x — no ellipse arcs.
"""
params = params or LayoutParams()
pitch = max(float(params.pitch), 170.0)
ry = max(float(params.lane), float(params.side), 220.0)
a, b = unit.portal_a, unit.portal_b
paths = _normalize_paths(unit)
kind = classify_dual_unit(unit)
max_mid = max((len(p) - 2 for p in paths), default=0)
half = max(pitch * (max_mid + 1) * 0.5, pitch * 4.0, 700.0)
pos: dict[str, tuple[float, float]] = {a: (-half, 0.0), b: (half, 0.0)}
if kind == "petal":
# Parallel H/V lanes: first/last mid share portal x → V stub + H spine.
band_i = 0
for p in paths:
mid = p[1:-1]
if not mid:
continue
side = 1 if band_i % 2 == 0 else -1
amp = ry * (0.85 + 0.35 * (band_i // 2))
band_i += 1
n_mid = len(mid)
for k, n in enumerate(mid):
if n in pos:
continue
if n_mid == 1:
pos[n] = (0.0, side * amp)
else:
t = k / (n_mid - 1)
x = -half + 2.0 * half * t
pos[n] = (x, side * amp)
else:
# Single corridor / chain body — always straight between portals.
body: list[str] = []
if paths:
body = list(paths[0][1:-1])
if not body and unit.tails:
longest = max(unit.tails, key=len)
body = list(longest)
if body:
_place_chain_straight(
body,
origin=(-half, 0.0),
direction=(1.0, 0.0),
pitch=max(pitch, (2.0 * half) / (len(body) + 1)),
pos=pos,
)
# Tails: always straight H/V spurs (stack parallel if many).
used = set(pos)
for ti, chain in enumerate(unit.tails):
if not chain:
continue
# Skip if already placed as body.
fresh = [n for n in chain if n not in used]
if not fresh:
continue
attach = None
for n in chain:
for v in state.adj.get(n, ()):
if v in pos and v not in chain:
attach = v
break
if attach is not None:
break
if attach is None:
attach = a if ti % 2 == 0 else b
ax, ay = pos[attach]
y_off = (ti % 3 - 1) * pitch * 0.45
if attach == a:
origin = (ax, ay + y_off)
direc = (-1.0, 0.0)
elif attach == b:
origin = (ax, ay + y_off)
direc = (1.0, 0.0)
else:
origin = (ax, ay)
direc = (0.0, 1.0 if ay >= 0 else -1.0)
_place_chain_straight(
fresh, origin=origin, direction=direc, pitch=pitch * 0.85, pos=pos
)
used |= set(pos)
leftovers = [n for n in unit.member_ids() if n not in pos]
top = max((xy[1] for xy in pos.values()), default=0.0) + ry
for i, n in enumerate(sorted(leftovers, key=lambda x: state.names.get(x, x))):
pos[n] = (-half + i * pitch, top)
return pos
def layout_dual_unit_positions(
state: LayoutState,
unit: DualUnit,
params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]:
"""Unit local layout — lanes / 回 / straight beautify (zero-cross target)."""
return beautify_dual_unit_positions(state, unit, params)
def _uncross_unit(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
pinned: set[str],
*,
max_rounds: int = 80,
) -> dict[str, tuple[float, float]]:
"""Greedy: move lower-degree free endpoint vertically to kill crossings."""
from netx_topology_mcp.layout_metrics import segments_properly_intersect
out = dict(pos)
deg: dict[str, int] = {}
for u, v in links:
deg[u] = deg.get(u, 0) + 1
deg[v] = deg.get(v, 0) + 1
def crossings() -> list[tuple[int, int]]:
segs = []
for u, v in links:
if u in out and v in out:
segs.append((u, v, out[u], out[v]))
bad: list[tuple[int, int]] = []
for i in range(len(segs)):
for j in range(i + 1, len(segs)):
a, b, pa, pb = segs[i]
c, d, pc, pd = segs[j]
if len({a, b, c, d}) < 4:
continue
if segments_properly_intersect(pa, pb, pc, pd):
bad.append((i, j))
return bad
for _ in range(max_rounds):
bad = crossings()
if not bad:
break
segs = [(u, v) for u, v in links if u in out and v in out]
progressed = False
for i, j in bad[:12]:
if i >= len(segs) or j >= len(segs):
continue
ends = list(segs[i]) + list(segs[j])
free = [n for n in ends if n not in pinned]
if not free:
continue
free.sort(key=lambda n: (deg.get(n, 0), n))
n = free[0]
x, y = out[n]
best = None
for dy in (
80.0,
-80.0,
160.0,
-160.0,
240.0,
-240.0,
320.0,
-320.0,
480.0,
-480.0,
):
for dx in (0.0, 40.0, -40.0, 80.0, -80.0):
trial = dict(out)
trial[n] = (x + dx, y + dy)
c0 = count_edge_crossings(out, links)
c1 = count_edge_crossings(trial, links)
if c1 < c0 and (best is None or c1 < best[0]):
best = (c1, x + dx, y + dy)
if best is not None:
out[n] = (best[1], best[2])
progressed = True
break
if not progressed:
break
return out
def layout_dual_unit(
state: LayoutState,
params: LayoutParams | None = None,
*,
unit: DualUnit | None = None,
unit_id: int | None = None,
portal_a: str | None = None,
portal_b: str | None = None,
) -> OpResult:
"""Layout the (single) dual-portal unit on this canvas; require crossings=0."""
params = params or LayoutParams()
units = find_dual_portal_units(state) if unit is None else [unit]
if unit_id is not None:
units = [u for u in units if u.unit_id == unit_id]
if portal_a and portal_b:
matched = [
u
for u in units
if {u.portal_a, u.portal_b} == {portal_a, portal_b}
]
if matched:
units = matched
else:
# Rebuild unit for the requested portals from this subgraph.
# Explicit portals: only forbid *other cores* (do NOT ban all agg —
# LPG-style canvases label almost every NE as agg, which yielded 0 paths).
forbid = {
n
for n, ly in state.layers.items()
if ly == "core" and n not in (portal_a, portal_b)
}
paths = cover_hub_paths(
portal_a, portal_b, state.adj, state.names, forbid=forbid
)
if len(paths) < 2:
paths = cover_hub_paths(
portal_a, portal_b, state.adj, state.names, forbid=set()
)
if len(paths) >= 2:
pa, pb = portal_a, portal_b
if state.names.get(pa, pa) > state.names.get(pb, pb):
pa, pb = pb, pa
paths = [list(reversed(p)) for p in paths]
core = {pa, pb}
for p in paths:
core.update(p)
units = [
DualUnit(
portal_a=pa,
portal_b=pb,
paths=paths,
tails=_collect_tails(state, core),
unit_id=0,
)
]
if not units:
# Whole canvas as one unit attempt: pick best hub pair cover
hubs = [
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n in state.adj
]
hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)))
if len(hubs) >= 2:
a, b = hubs[0], hubs[1]
forbid = {
n
for n, ly in state.layers.items()
if ly == "core" and n not in (a, b)
}
paths = cover_hub_paths(a, b, state.adj, state.names, forbid=forbid)
if len(paths) < 2:
paths = cover_hub_paths(
a, b, state.adj, state.names, forbid=set()
)
if len(paths) >= 2:
if state.names.get(a, a) > state.names.get(b, b):
a, b = b, a
paths = [list(reversed(p)) for p in paths]
core = {a, b}
for p in paths:
core.update(p)
units = [
DualUnit(
portal_a=a,
portal_b=b,
paths=paths,
tails=_collect_tails(state, core),
unit_id=0,
)
]
if not units:
return OpResult(
state=state,
moved=set(),
op="layout_dual_unit",
params={},
note="no_dual_unit",
)
# If multiple units detected on one canvas, layout the largest by node count
u = max(units, key=lambda x: len(x.member_ids()))
pos = layout_dual_unit_positions(state, u, params)
out = state.copy()
for n, xy in pos.items():
if n in out.positions:
out.positions[n] = xy
parked = 0
extras = [n for n in out.positions if n not in pos]
if extras:
base_y = max((xy[1] for xy in pos.values()), default=0.0) + max(
params.lane, 300.0
)
for i, n in enumerate(sorted(extras, key=lambda x: state.names.get(x, x))):
out.positions[n] = (
-len(extras) * 0.5 * params.pitch + i * params.pitch,
base_y,
)
parked += 1
members = u.member_ids()
unit_links = [e for e in out.links if e[0] in members and e[1] in members]
# Uncross before overlap fix (overlap fix often reintroduces crossings).
pinned = {u.portal_a, u.portal_b}
out.positions = _uncross_unit(out.positions, unit_links, pinned)
x_unit = count_edge_crossings(out.positions, unit_links)
# Gentle overlap resolve only if still zero-cross; else skip.
stf = out
if x_unit == 0:
cand = fix_overlaps_local(out, params).state
cand.positions[u.portal_a] = pos[u.portal_a]
cand.positions[u.portal_b] = pos[u.portal_b]
x_after = count_edge_crossings(cand.positions, unit_links)
if x_after == 0:
stf = cand
# else keep pre-overlap geometry
x_unit = count_edge_crossings(stf.positions, unit_links)
x = count_edge_crossings(stf.positions, stf.links)
moved = {
n
for n, p in stf.positions.items()
if n in state.positions and p != state.positions[n]
}
stf.meta = dict(stf.meta or {})
stf.meta["layout_dual_unit"] = {
"unit": u.as_dict(state.names),
"unit_crossings": x_unit,
"parked": parked,
}
accepted = x_unit == 0
return OpResult(
state=stf,
moved=moved,
op="layout_dual_unit",
params={
"unit": u.as_dict(state.names),
"unit_crossings": x_unit,
"global_crossings": x,
"accepted": accepted,
"parked": parked,
},
note=(
f"layout_dual_unit:paths={len(u.paths)} x=0"
if accepted
else f"dual_unit_crossings={x_unit}"
),
)
def dual_units_report(
state: LayoutState,
*,
max_units: int = 120,
) -> dict[str, Any]:
units = find_dual_portal_units(state, max_units=max_units)
covered: set[str] = set()
for u in units:
covered |= u.member_ids()
graph_n = len(state.positions) or len(state.names) or len(state.adj)
return {
"unit_count": len(units),
"max_units": max_units,
"covered_nodes": len(covered),
"graph_nodes": graph_n,
"uncovered_nodes": max(0, graph_n - len(covered)),
"units": [u.as_dict(state.names) for u in units],
"tip": (
"Dual-portal eye units: ≥2 interior-disjoint corridors between "
"portals; layout with action=layout_dual_unit (require crossings=0). "
"Portals may be shared across units; compose merges same node ids. "
"Leftovers (uncovered_nodes) go to misc unit canvases."
),
}
def dual_unit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("unit_id") is not None:
try:
out["unit_id"] = int(overrides["unit_id"])
except (TypeError, ValueError):
pass
for key in ("portal_a", "portal_b"):
v = overrides.get(key)
if v is not None and str(v).strip():
out[key] = str(v).strip()
return out

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_topology_mcp.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_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
)
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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,209 @@
"""Graph helpers for layout ops."""
from __future__ import annotations
import re
from collections import deque
_ROLE_TO_LAYER = {
"core": "core",
"cn": "core",
"aggregation": "agg",
"aggregate": "agg",
"agg": "agg",
"an": "agg",
"access": "access",
"en": "access",
"edge": "access",
}
def infer_layer(name: str, role: str | None = None) -> str:
"""Map inventory role / name token (CN|AN|EN) → core|agg|access|other."""
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 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_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links
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"))
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,
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_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import pick_hub_seeds
from netx_topology_mcp.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_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
from netx_topology_mcp.layout_ops.scope import select_scope
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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,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_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.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}"
),
)

View file

@ -0,0 +1,812 @@
"""Polar orbit sweep: suggest top-3 single-node drags by crossing score.
Agent workflow: preview → pick rank 1..3 → apply; or round=true to auto-apply
#1 for each hot node when global crossings drop.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossing_participation,
crossings_involving_node,
node_footprint,
top_crossing_nodes,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_JUMP = 900.0
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for
# max_jump≈8k–12k orbit rounds (still clamped per-call via params).
_MAX_JUMP_CAP = 12000.0
_MAX_FROM_NBS = 1100.0
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:
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen
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 _nn_ok(
node: str,
pos: dict[str, tuple[float, float]],
nn_floor: float,
) -> bool:
if nn_floor <= 0:
return True
x0, y0 = pos[node]
floor2 = nn_floor * nn_floor
for b, (x, y) in pos.items():
if b == node:
continue
dx, dy = x - x0, y - y0
if dx * dx + dy * dy < floor2:
return False
return True
def _incident_stretch(
node: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
target_nn: float,
) -> float:
nbs = [v for v in adj.get(node, ()) if v in pos]
if not nbs:
return 1.0
tn = max(40.0, float(target_nn))
x0, y0 = pos[node]
lengths = [math.hypot(pos[v][0] - x0, pos[v][1] - y0) for v in nbs]
mean_l = sum(lengths) / len(lengths)
return mean_l / tn
def _angle_diff_deg(a: float, b: float) -> float:
d = abs(a - b) % 360.0
return d if d <= 180.0 else 360.0 - d
def _radii_for_jump(jump: float) -> list[float]:
radii = [80.0, 120.0, 180.0, 260.0, 360.0, 480.0]
if jump > 520:
radii = radii + [640.0, 800.0]
if jump > 1200:
radii = radii + [1200.0, 1600.0, 2200.0, min(jump, 3200.0)]
return [r for r in radii if r <= jump + 1]
def _polar_grid(
x0: float,
y0: float,
*,
jump: float,
angle_step: int,
radii: list[float] | None = None,
) -> list[tuple[float, float, float, float]]:
"""Return (x, y, r, angle_deg) samples on polar rings about (x0,y0)."""
step = max(10, int(angle_step))
rs = radii if radii is not None else _radii_for_jump(jump)
out: list[tuple[float, float, float, float]] = []
for ang in range(0, 360, step):
rad = math.radians(ang)
c, s = math.cos(rad), math.sin(rad)
for r in rs:
if r > jump + 1:
continue
out.append((x0 + r * c, y0 + r * s, float(r), float(ang)))
return out
def _neighbor_guides(
pos: dict[str, tuple[float, float]],
node: str,
adj: dict[str, set[str]],
jump: float,
) -> list[tuple[float, float, float, float]]:
x, y = pos[node]
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if not nbs:
return []
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
ux, uy = dx / L, dy / L
px, py = -uy, ux
out: list[tuple[float, float, float, float]] = []
for s in (-360.0, -220.0, -120.0, 120.0, 220.0, 360.0):
if abs(s) > jump:
continue
for nx, ny in (
(x + ux * s, y + uy * s),
(cx + ux * abs(s), cy + uy * abs(s)),
):
r = math.hypot(nx - x, ny - y)
if r > jump or r < 1.0:
continue
ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0
out.append((nx, ny, r, ang))
for s in (-240.0, -160.0, -80.0, 80.0, 160.0, 240.0):
if abs(s) > jump:
continue
nx, ny = x + px * s, y + py * s
r = abs(s)
ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0
out.append((nx, ny, r, ang))
return out
def _score_key(c: dict[str, Any]) -> tuple:
return (
int(c["crossings"]["global"]),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
float(c.get("r") or 0.0),
)
def _diversify_top(
ranked: list[dict[str, Any]],
*,
k: int = 3,
min_angle_sep: float = 35.0,
) -> list[dict[str, Any]]:
picked: list[dict[str, Any]] = []
for c in ranked:
ok = True
for p in picked:
if _angle_diff_deg(float(c["angle_deg"]), float(p["angle_deg"])) < min_angle_sep:
r0 = max(float(p.get("r") or 1.0), 1.0)
r1 = max(float(c.get("r") or 1.0), 1.0)
ratio = max(r0, r1) / min(r0, r1)
if ratio < 1.3:
ok = False
break
if ok:
picked.append(c)
if len(picked) >= k:
break
# Fill if diversity filtered too hard.
if len(picked) < k:
ids = {id(p) for p in picked}
for c in ranked:
if id(c) in ids:
continue
picked.append(c)
if len(picked) >= k:
break
for i, c in enumerate(picked):
c["rank"] = i + 1
return picked
def _eval_candidate(
node: str,
cand_xy: tuple[float, float],
r: float,
angle_deg: float,
*,
pos: dict[str, tuple[float, float]],
names: dict[str, str],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
global0: int,
local0: int,
target_nn: float,
nn_floor: float,
nbs_cap: float,
) -> dict[str, Any] | None:
x0, y0 = pos[node]
nx, ny = cand_xy
if math.hypot(nx - x0, ny - y0) > r + 1e-6 and r > 0:
# keep r as reported displacement
pass
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
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(nx - mx, ny - my) > nbs_cap:
return None
trial = dict(pos)
trial[node] = (nx, ny)
if _node_overlaps_any(node, trial, names):
return None
nn_ok = _nn_ok(node, trial, nn_floor)
if not nn_ok:
return None
local1 = crossings_involving_node(node, trial, links, adj)
g1 = int(global0) - int(local0) + int(local1)
stretch = _incident_stretch(node, trial, adj, target_nn)
disp = math.hypot(nx - x0, ny - y0)
return {
"x": round(nx, 1),
"y": round(ny, 1),
"r": round(disp, 1),
"angle_deg": round(angle_deg % 360.0, 1),
"crossings": {"global": g1, "incident": int(local1)},
"delta": {
"global": g1 - int(global0),
"incident": int(local1) - int(local0),
},
"ov": False,
"nn_ok": True,
"stretch": round(stretch, 3),
}
def orbit_sweep_node(
state: LayoutState,
node_id: str,
*,
params: LayoutParams | None = None,
max_jump: float | None = None,
angle_step: int | None = None,
nn_floor: float = 36.0,
min_angle_sep: float = 35.0,
cand_cap: int = 280,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
top_k: int = 3,
) -> dict[str, Any]:
"""Sweep polar candidates for one node; return diversified top-k.
Default ``protect_rigid=off`` so multi-round orbit may move portals to cut
crossings; other layout actions keep portal freeze. Opt in with portals/all.
"""
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",
}
n_links = len(links)
jump = float(max_jump if max_jump is not None else _MAX_JUMP)
jump = max(200.0, min(jump, _MAX_JUMP_CAP))
if angle_step is None:
angle_step = 24 if n_links >= 400 else (18 if n_links >= 200 else 15)
angle_step = max(10, int(angle_step))
# Local untangle-style jumps stay near neighbor centroid; metro bridges
# (max_jump≫1k) must be allowed to leave the unit blob.
if jump > 1200:
nbs_cap = jump * 2.5
else:
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
# Coarse grid.
coarse_step = max(angle_step, 24 if n_links >= 200 else angle_step)
samples = _polar_grid(x0, y0, jump=jump, angle_step=coarse_step)
samples.extend(_neighbor_guides(pos, nid, adj, jump))
# Explicit samples toward each neighbor (incl. long bridges).
for nb in adj.get(nid, ()):
if nb not in pos:
continue
bx, by = pos[nb]
dx, dy = bx - x0, by - y0
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
ang = math.degrees(math.atan2(uy, ux)) % 360.0
for frac in (0.15, 0.35, 0.55, 0.75):
r = min(jump, L * frac)
if r < 40:
continue
samples.append((x0 + ux * r, y0 + uy * r, r, ang))
# Perpendicular escapes at mid-chord fractions.
px, py = -uy, ux
for r in (180.0, 360.0, 640.0, 1200.0):
if r > jump:
continue
samples.append((x0 + px * r, y0 + py * r, r, (ang + 90) % 360))
samples.append((x0 - px * r, y0 - py * r, r, (ang + 270) % 360))
# Dedup by rounded xy.
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 not None:
scored.append(c)
scored.sort(key=_score_key)
# Refine around top-8 coarse winners.
refine_budget = max(0, cand_cap - len(uniq))
fine: list[tuple[float, float, float, float]] = []
half = max(5.0, coarse_step / 2.0)
radii = _radii_for_jump(jump)
for base in scored[:8]:
ang0 = float(base["angle_deg"])
r0 = float(base["r"])
# nearest radius indices
near_r = sorted(radii, key=lambda rr: abs(rr - r0))[:3]
for dang in (-half, 0.0, half):
ang = (ang0 + dang) % 360.0
rad = math.radians(ang)
c_, s_ = math.cos(rad), math.sin(rad)
for rr in near_r:
if rr > jump + 1:
continue
fine.append((x0 + rr * c_, y0 + rr * s_, rr, ang))
if len(fine) >= refine_budget:
break
if len(fine) >= refine_budget:
break
if len(fine) >= refine_budget:
break
for sx, sy, r, ang in fine:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
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 not None:
scored.append(c)
scored.sort(key=_score_key)
# Prefer improving moves; still return best even if none improve.
improving = [c for c in scored if c["delta"]["global"] < 0]
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, ())),
"crossings_before": {"global": int(global0), "incident": int(local0)},
"candidates": top,
"sampled": len(seen),
"improving_n": len(improving),
"max_jump": jump,
"angle_step": angle_step,
"hint": (
"prefer rank1 unless util/label concern; then pick 2/3. "
"apply with params.pick=1|2|3 or updateTopologyViewPositions."
),
}
def apply_orbit_pick(
state: LayoutState,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
"""Move node to chosen candidate (1-based rank)."""
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="orbit_sweep",
note="orbit_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["orbit_sweep"] = {
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
}
return OpResult(
state=st,
moved={nid},
op="orbit_sweep",
params={
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
},
note=(
f"orbit_sweep pick={idx + 1} "
f"g{sweep.get('crossings_before', {}).get('global')}->"
f"{chosen['crossings']['global']}"
),
)
def orbit_sweep_round(
state: LayoutState,
*,
params: LayoutParams | None = None,
top_n: int = 12,
max_degree: int = 9,
max_jump: float | None = None,
angle_step: int | None = None,
nn_floor: float = 36.0,
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
) -> OpResult:
"""Scan hot nodes; auto-apply each node's rank-1 if global crossings drop.
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}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
global0 = count_edge_crossings(pos, links)
hit = crossing_participation(pos, links)[1]
focus = {str(x) for x in (focus_ids or []) if str(x)}
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
)
}
# Expand hub focus to low-deg neighbors.
movable: list[str] = []
seen_m: set[str] = set()
prefer: list[str] = []
for nid in focus:
if nid in hit and len(adj.get(nid, ())) < max_degree and nid not in frozen:
prefer.append(nid)
for nb in adj.get(nid, ()):
if nb in hit and len(adj.get(nb, ())) < max_degree and nb not in frozen:
prefer.append(nb)
ranked = sorted(
hit.keys(),
key=lambda n: (
0 if n in prefer or n in focus else 1,
-hit[n] / max(len(adj.get(n, ())), 1),
len(adj.get(n, ())),
-hit[n],
),
)
for nid in ranked:
if nid in frozen or nid in seen_m:
continue
if len(adj.get(nid, ())) >= max_degree:
continue
if hit.get(nid, 0) <= 0:
continue
movable.append(nid)
seen_m.add(nid)
if len(movable) >= max(1, int(top_n)):
break
cur_g = global0
moved: set[str] = set()
trace: list[dict[str, Any]] = []
for nid in movable:
# Refresh state positions into a temp LayoutState for sweep.
st.positions = pos
sweep = orbit_sweep_node(
st,
nid,
params=params,
max_jump=max_jump,
angle_step=angle_step,
nn_floor=nn_floor,
min_angle_sep=min_angle_sep,
protect_rigid="off", # already applied frozen set
frozen_ids=frozen,
)
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]
if int(best["delta"]["global"]) >= 0:
trace.append(
{
"node_id": nid,
"skipped": "no_global_gain",
"best_delta": best["delta"],
}
)
continue
# Apply #1
pos[nid] = (float(best["x"]), float(best["y"]))
moved.add(nid)
cur_g = int(best["crossings"]["global"])
# Refresh hit lightly for ranking continuity.
local = crossings_involving_node(nid, pos, links, adj)
if local > 0:
hit[nid] = local
else:
hit.pop(nid, None)
trace.append(
{
"node_id": nid,
"name": names.get(nid, nid),
"applied": True,
"pick": 1,
"xy": [best["x"], best["y"]],
"delta": best["delta"],
"crossings": best["crossings"],
}
)
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,
}
st.meta["orbit_sweep"] = meta
return OpResult(
state=st,
moved=moved,
op="orbit_sweep_round",
params=meta,
note=f"orbit_sweep_round {global0}->{end_g} moved={len(moved)}/{len(movable)}",
)
def orbit_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
if o.get("round") is not None:
out["round"] = str(o.get("round")).lower() not in {
"0",
"false",
"no",
"off",
"",
} or o.get("round") is True
for key, cast, default in (
("top_n", int, 12),
("max_degree", int, 9),
("angle_step", int, None),
("cand_cap", int, 280),
("top_k", int, 3),
):
if key not in o or o[key] is None:
if default is not None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
if default is not None:
out[key] = default
if o.get("max_jump") is not None:
try:
out["max_jump"] = float(o["max_jump"])
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"] = 36.0
else:
out["nn_floor"] = 36.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:
# orbit 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()]
# portal freeze from polish path
raw_p = o.get("portal_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
return out
def orbit_lite_suggest(
node: str,
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
names: dict[str, str],
*,
max_jump: float = 360.0,
angle_step: int = 45,
top_k: int = 3,
target_nn: float = 155.0,
) -> list[dict[str, Any]]:
"""Lightweight polar suggest for analyze sight (fewer samples)."""
if node not in pos:
return []
# Build a tiny state-like eval without LayoutState.
x0, y0 = pos[node]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(node, pos, links, adj)
jump = max(120.0, min(float(max_jump), 800.0))
samples = _polar_grid(
x0,
y0,
jump=jump,
angle_step=angle_step,
radii=[120.0, 200.0, 320.0],
)
samples.extend(_neighbor_guides(pos, node, adj, jump)[:12])
scored: list[dict[str, Any]] = []
seen: set[tuple[int, int]] = set()
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
c = _eval_candidate(
node,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=36.0,
nbs_cap=jump * 2.5 if jump > 1200 else max(_MAX_FROM_NBS, jump * 1.25),
)
if c is not None:
scored.append(c)
scored.sort(key=_score_key)
improving = [c for c in scored if c["delta"]["global"] < 0]
pool = improving if improving else scored
top = _diversify_top(pool, k=top_k, min_angle_sep=35.0)
out: list[dict[str, Any]] = []
for c in top:
out.append(
{
"kind": f"orbit_r{int(c['r'])}_a{int(c['angle_deg'])}",
"x": c["x"],
"y": c["y"],
"r": c["r"],
"angle_deg": c["angle_deg"],
"delta_crossings_est": c["delta"]["global"],
"delta_incident": c["delta"]["incident"],
"global_after": c["crossings"]["global"],
}
)
return out

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_topology_mcp.layout_ops.graph_util import connected_components
from netx_topology_mcp.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 — install netx-topology-mcp[layout]."
),
}
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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.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,760 @@
"""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_topology_mcp.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_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.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_topology_mcp.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,
"straighten": True,
"skip_dual_full_x": False,
"untangle_rounds": 120,
"untangle_moves": 5,
"untangle_rank_cap": 140,
"untangle_angle_step": 15,
}
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_topology_mcp.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]) -> None:
nonlocal best
if math.hypot(cand[0] - leaf_xy[0], cand[1] - leaf_xy[1]) > max_disp:
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).
if r0 > r_use + 50.0:
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))
# 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,
) -> OpResult:
"""Pipeline: park phantoms → straighten → hot_edges → crossers → untangle."""
from netx_topology_mcp.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))
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
if len(st.links) >= 800:
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_topology_mcp.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,
"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 "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,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_topology_mcp.layout_ops.score import score_op, score_state
from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope
from netx_topology_mcp.layout_ops.sides import place_side_branches
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.partition import pack_soft_blocks
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.compose_orbit import crossings_touching
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.layout_metrics import segments_properly_intersect
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.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_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.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_topology_mcp.layout_ops.graph_util import bbox, connected_components
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.layout_ops.state import LayoutState, OpResult
from netx_topology_mcp.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_topology_mcp.layout_ops.graph_util import spine_backbone
from netx_topology_mcp.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,427 @@
"""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_topology_mcp.layout_ops.dual_units import DualUnit, layout_dual_unit
from netx_topology_mcp.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]:
"""Lower is better: less shared portals, smaller unit, lower unit_id."""
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, len(u.member_ids()), int(u.unit_id))
def select_dual_unit_batch(
units: list[DualUnit],
*,
max_units: int = 3,
min_nodes: int = 8,
max_nodes: int = 80,
max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None,
) -> list[DualUnit]:
"""Greedy pick detachable dual-units within size / batch caps."""
exclude_ids = exclude_ids or set()
share = _portal_share_counts(units)
candidates: list[DualUnit] = []
for u in units:
members = u.member_ids()
if not members:
continue
n = len(members)
if n < int(min_nodes) or n > int(max_nodes):
continue
# Skip units already fully present on sink (nothing new to move).
if members and members.issubset(exclude_ids):
continue
candidates.append(u)
candidates.sort(key=lambda u: unit_detach_score(u, share))
picked: list[DualUnit] = []
claimed: set[str] = set()
for u in candidates:
if len(picked) >= int(max_units):
break
members = u.member_ids()
# Prefer units whose interiors are not already claimed this batch.
interior = members - {u.portal_a, u.portal_b}
if interior & claimed:
continue
next_ids = claimed | members
if len(next_ids) > int(max_batch_nodes):
continue
picked.append(u)
claimed |= members
return picked
def batch_node_ids(units: list[DualUnit]) -> list[str]:
out: list[str] = []
seen: set[str] = set()
for u in units:
for nid in sorted(u.member_ids()):
if nid and nid not in seen:
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,
) -> list[str]:
"""When dual_units are exhausted, take a plain leftover chunk."""
exclude_ids = exclude_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:
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_topology_mcp.layout_ops.compose_orbit import (
_orbit_candidates_one_portal,
_orbit_candidates_orphan,
_orbit_candidates_two_portal,
_pick_best,
_thin_candidates,
)
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import (
bbox,
connected_components,
order_ans,
spine_backbone,
)
from netx_topology_mcp.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_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
from netx_topology_mcp.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,75 @@
"""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)
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),
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 = ""

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_topology_mcp.layout_metrics import (
ICON_SIZE,
REC_CENTER_DX,
REC_CENTER_DY,
node_footprint,
)
from netx_topology_mcp.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.scope import active_nodes, movable_nodes
from netx_topology_mcp.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_topology_mcp.layout_metrics import (
count_edge_crossings,
crossing_participation,
crossings_involving_node,
node_footprint,
top_crossing_nodes,
)
from netx_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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_topology_mcp.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,240 @@
"""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_topology_mcp.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_topology_mcp.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
)
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,750 @@
"""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_topology_mcp.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).
UTIL_SWEET_LO = 0.12
UTIL_SWEET_HI = 0.45
# Ideal median undirected edge length / recommended pitch.
EDGE_SWEET_LO = 0.7
EDGE_SWEET_HI = 2.2
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]) -> 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).
Mid-tier: chain(直链成一体)+ rings(最小环不被穿), each weight 0.10.
"""
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
)
# 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)
util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.01, hard_hi=1.2)
hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.02, hard_hi=1.5)
grid_s = _band_score(grid_occ, 0.25, 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
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))
weights = {
"overlap": 0.24,
"crossing": 0.18,
"utilization": 0.12,
"chain": 0.10,
"rings": 0.10,
"edge_clearance": 0.08,
"edge_axis": 0.06,
"grid": 0.04,
"nn": 0.04,
"hull": 0.02,
"stretch": 0.02,
}
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),
"compactness": round(white_s, 4),
}
# compactness folded into util/grid already; keep as diagnostic
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,
"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,
-chain_s,
-rings_s,
-edge_clr_s,
-edge_axis_s,
],
"hint": (
"total∈[0,100]. Overlaps hard-gate the score. "
"Mid-tier: chain=直链成一体、rings=最小环不被穿(各权 0.10);"
"edge_clearance=网元勿贴非关联边(权 0.08);"
"edge_axis=边宜水平/垂直且水平优先(权 0.06). "
"Raise utilization/grid_occupancy without overlaps; "
"keep crossings_per_link near ~0.16 (mid-size reference) and nn_p50 in 140–220."
),
}
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:
if util < 0.03 or white > 0.85 or grid_occ < 0.05:
return "fail"
if util < 0.08 or white > 0.65 or grid_occ < 0.15:
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。"
"中档:chains/rings 各权 0.10;edge_clearance=网元贴边(0.08);"
"edge_axis=水平/垂直边且水平优先(0.06)。"
"扫参用 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,
) -> 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.
"""
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_topology_mcp.layout_metrics import (
collapse_links,
compute_edge_axis,
compute_edge_clearance,
)
from netx_topology_mcp.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["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")
score = score_layout_components(merged)
grade = grade_layout(merged, ume_reference=ume_reference)
packed = {
**merged,
"score": score,
"grade": grade,
"summary": {
"total": score["total"],
"overall": grade.get("overall"),
"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,622 @@
"""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_topology_mcp.layout_metrics import collapse_links
from netx_topology_mcp.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"))
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 {"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 = {
"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_topology_mcp.layout_ops.dual_units import dual_units_report
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.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,791 @@
"""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_topology_mcp.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
positions_for_api,
run_recipe,
score_state,
)
from netx_topology_mcp.layout_ops.hotspots import (
fix_overlaps_local,
hotspot_scopes,
overlapping_nodes,
)
from netx_topology_mcp.layout_ops.recipe import RECIPES
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_ops.transforms import normalize_origin
from netx_topology_mcp.layout_ops.channels import (
straighten_channels_greedy,
straighten_params_from_overrides,
)
from netx_topology_mcp.layout_ops.dual_units import (
dual_unit_params_from_overrides,
layout_dual_unit,
)
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
from netx_topology_mcp.layout_ops.untangle import (
untangle_crossings,
untangle_params_from_overrides,
)
from netx_topology_mcp.layout_ops.press_crossings import (
park_phantom_nodes,
polish_crossings,
press_params_from_overrides,
)
from netx_topology_mcp.layout_ops.clear_edge_hits import (
clear_edge_hits,
clear_edge_params_from_overrides,
)
from netx_topology_mcp.layout_ops.orbit_sweep import (
apply_orbit_pick,
orbit_params_from_overrides,
orbit_sweep_node,
orbit_sweep_round,
)
# 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",
"resolve_overlaps", # alias of fix_overlaps
"untangle",
"straighten_channels",
"layout_dual_unit",
"polish_crossings",
"clear_edge_hits",
"orbit_sweep",
)
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)}"
)
if action_key == "resolve_overlaps":
action_key = "fix_overlaps"
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_topology_mcp.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"),
)
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_preserves_zero_cross"},
},
)
if action_key == "orbit_sweep":
knobs = orbit_params_from_overrides(params)
do_round = bool(knobs.get("round"))
node_id = str(knobs.get("node_id") or "").strip()
frozen = knobs.get("frozen_ids")
protect = knobs.get("protect_rigid", "off")
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,
focus_ids=knobs.get("focus_ids"),
)
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:"
"params.node_id=… or params.round=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,
top_k=int(knobs.get("top_k") or 3),
)
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)),
)
st = normalize_origin(op.state, base_params).state
from netx_topology_mcp.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 == "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"),
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_topology_mcp.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_topology_mcp import NETX_MCP_REV, __version__
return {
"version": __version__,
"rev": NETX_MCP_REV,
"actions": {
"layout": "全图配方(骨架);块内 pack,结束时局部解叠",
"fix_overlaps": "只拉开当前重叠点(+1 跳邻居),不重排全图",
"resolve_overlaps": "fix_overlaps 别名",
"straighten_channels": (
"拉直 deg≤2 通道(弦/横/纵);仅全局交叉下降才接受该通道"
"(params: step/min_len)"
),
"layout_dual_unit": (
"双门户单元美化(多走廊→平行 H/V 道;链→拉直);"
"单元内交叉必须为 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);"
"门控:不增交叉、不增重叠。"
"params: top_n/thr/margin/max_moves"
),
"orbit_sweep": (
"压交叉(默认可动门户):以网元为圆心不定长扫角 top-3;"
"preview+node_id / apply+pick / round=true;"
"protect_rigid 默认 off(portals/all 可恢复刚体冻)"
),
"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"
),
"sink_nodes": "move_nodes 别名",
},
"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) → layout_dual_unit(或小图 layout "
"compact|corridor|rings)→ sinkTopologyDualUnits / move_nodes(park) → "
"orbit_sweep(round) → polish_crossings → clear_edge_hits → "
"手拖微调。禁止临时 py 算坐标。"
),
}

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_topology_mcp.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}

View file

@ -10,11 +10,16 @@ from __future__ import annotations
import json
import sys
import time
from typing import Any
from netx_topology_mcp.http_client import http_json
from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
# Re-fetch /v1/auth/me scopes so API Key permission edits become visible without
# restarting the whole MCP host for too long. tools/list always refreshes.
_SCOPE_TTL_SEC = 45.0
def _ensure_utf8_stdio() -> None:
for stream in (sys.stdin, sys.stdout, sys.stderr):
@ -63,11 +68,15 @@ def _fetch_scopes() -> list[str] | None:
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
cached_at = 0.0
def scopes() -> list[str] | None:
nonlocal cached_scopes
if cached_scopes is _UNSET:
def scopes(*, force: bool = False) -> list[str] | None:
nonlocal cached_scopes, cached_at
now = time.monotonic()
stale = cached_scopes is _UNSET or (now - cached_at) >= _SCOPE_TTL_SEC
if force or stale:
cached_scopes = _fetch_scopes()
cached_at = now
return cached_scopes # type: ignore[return-value]
for line in sys.stdin:
@ -88,15 +97,15 @@ def run_stdio_loop() -> None:
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-topology-mcp", "version": "0.1.0", "mode": "http"},
"capabilities": {"tools": {"listChanged": True}},
"serverInfo": {"name": "netx-topology-mcp", "version": "0.1.2", "mode": "http"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes())})
_ok(rid, {"tools": tools_for_scopes(scopes(force=True))})
continue
if method == "tools/call":
name = str(params.get("name") or "")