mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 10:23:19 +08:00
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:
parent
9bcb8ee0e3
commit
79f929de0c
86 changed files with 31578 additions and 240 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
483
packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py
Normal file
483
packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py
Normal 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)
|
||||
|
|
@ -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."
|
||||
),
|
||||
}
|
||||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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}",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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},
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)}",
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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}"
|
||||
),
|
||||
)
|
||||
|
|
@ -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
|
|
@ -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)}",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
]
|
||||
|
|
@ -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
|
|
@ -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。",
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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}",
|
||||
)
|
||||
|
|
@ -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')}",
|
||||
)
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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
|
||||
240
packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py
Normal file
240
packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py
Normal 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
|
||||
750
packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py
Normal file
750
packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py
Normal 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
|
||||
|
||||
|
||||
|
|
@ -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])}"
|
||||
),
|
||||
}
|
||||
791
packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py
Normal file
791
packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py
Normal 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 算坐标。"
|
||||
),
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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 "")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue