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

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

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

View file

@ -1,58 +1,148 @@
---
name: netx-topology
description: >-
用 netx-topology MCP 查链路、画拓扑(不污染 Fabric)。触发:画拓扑、LLDP、Fabric、netx-topology。先读再调。
user-invocable: true
disable-model-invocation: false
用 netx-topology MCP 查邻接、dual_unit 分批沉入、扫角压交叉(不污染 Fabric)。
触发:画拓扑、布图、拖图、LLDP、Fabric、netx-topology。先读本 skill 再调工具。
---
# netx 拓扑 MCP
# netx 拓扑(通用)
通过 **`netx-topology`** MCP(包 `netx-topology-mcp`)操作 netx 拓扑。与告警/CLI 的 **`netx`** MCP **分开**;画图只用本包工具。
只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
安装与 scopes 真源:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
**原则**:复杂图先拆 **dual_units** 再拼;交叉少、边短、近轴优先。
**禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**。不造 Fabric 边。
**勿**把客户网元名、区域名、具体交叉数写进本 skill。
## 硬规则
---
1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前可 `getTopologyView`(大图慎拉整图)。
2. **筛选交给 API**:加/挪/删优先传 `keyword` / `role` / `vendor` / `link_status`(加节点再用 `limit`/`offset`)。**不要**先 list 全量再回传成千上万 id。
3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。
4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。
5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。
6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`,再 `createTopologyView` 建画布。
7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。
## 推荐流水线(从零画一张图)
## 主路径(必循)
```
1 getTopologyTree → 看有无区域;没有则 createTopologyFolder → folder_id
2 createTopologyView → view_id
3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap
4 projectTopologyNeighbors → 可选
5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆
6 (少量微调才用 positions[])
analyze(structure) → 认 dual_units / shape
→ 核心层:手拖或小图 layout(compact|corridor|rings)
→ sinkTopologyDualUnits(一批,layout_batch=true)
或 move_nodes(park=true) 指定 ids
→ orbit_sweep(round) → polish_crossings → clear_edge_hits
→ 手拖微调 updateTopologyViewPositions
→ 下一批 sink…(禁止 until_empty 日常连抽)
```
查链路不画图:`queryTopologyEdges` / `queryTopologyNeighborhood`。
停手:`overlaps=0` 且 `verdict.total≈70`、cpl 中档内即可交付。
---
## 阶段 0 — 画布与成员
1. `getTopologyTree` → **`view_id`**(文件夹 physical 画布)。
2. 建根/子区域只用 `createTopologyFolder`。
3. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape`。
4. `addTopologyViewNodes` / `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。
5. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。
---
## 形状与 dual_units
```
analyzeTopologyViewLayout({ view_id, detail: "structure" })
```
| 字段 | 用途 |
|------|------|
| `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` |
| `dual_units` | 两端门户 + ≥2 条内部不交走廊;成员可重叠 |
| `advice.block_plan` | 每块怎么拖 |
| `gravity.type` | 链图勿当 hub 花瓣 |
- **链图**:脊柱水平 + stub;小图 preview `corridor`/`compact`(跳过 rings)。
- **巨图 / 多门户**:勿全图一把揉;走 **sink 分批 dual_unit**。
- **禁止**再用互斥 soft_block 把通路切开。
---
## 根图 → 子区域排水
```
sinkTopologyDualUnits({
source_view_id: <根图>,
sink_view_id: <子区域>,
max_units: 3,
max_batch_nodes: 120,
layout_batch: true, // 每单元 layout_dual_unit → 块扫挂 sink
})
→ orbit_sweep / polish_crossings / clear_edge_hits
→ 再调下一批(source_remaining>0)
```
- **一次只沉一批**;勿日常 `until_empty`。
- 落点:**块扫**(交叉/重叠/桥长择优),禁止固定往右排。
- 指定迁移:
```
layoutTopologyView({
action: "move_nodes",
source_view_id: <FROM>, view_id: <TO>,
mode: "apply",
params: { fabric_node_ids: [...], park: true, remove_from_source: true },
})
```
`park=true` = 扫角停靠;回迁对调两 view_id。
---
## layoutTopologyView(精简)
| action | 用途 |
|--------|------|
| `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` |
| `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 |
| `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 |
| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800) |
| `polish_crossings` | 一键:straighten→press→untangle |
| `clear_edge_hits` | 网元贴非关联边时正交弹开 |
| `fix_overlaps` / `resolve_overlaps` | 只拉开重叠 |
| `untangle` | 贪心降交叉;默认可冻门户 |
| `straighten_channels` | 拉直 deg≤2 走廊 |
| `job_status` / `job_cancel` | 后台 job |
阶段2顺序:先 `orbit_sweep` 压交叉 → `polish_crossings` → 看 `edge_clearance` 再 `clear_edge_hits`。
`orbit_round` 只在全局交叉严格下降时落笔;卡顿加大 `max_jump` / 单点 preview→pick。
---
## 验收
| 块 | 看什么 |
|----|--------|
| `overlap` | 硬零 |
| `crossing` | crossings/cpl;`top_nodes` / `top_edges` |
| `edge_clearance` | 贴边 → clear_edge_hits |
| `verdict.total` | ≈70 可交付(ov=0) |
图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。
---
## 工具速查
| 目的 | 工具 |
| 工具 | 作用 |
|------|------|
| 树 / 区域 / 画布 | `getTopologyTree`, `createTopologyFolder`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 筛选批量加 | `addTopologyViewNodes`(filters + limit/offset) |
| 筛选批量挪 | `updateTopologyViewPositions`(layout + filters) |
| 筛选批量删 | `removeTopologyViewNodes`(filters) |
| 投影邻居 | `projectTopologyNeighbors` |
| Fabric 读 | `search…` / `list…` / `queryTopologyEdges` / `…Neighborhood` / summary |
| `getTopologyTree` / `getTopologyView` | 树与画布 |
| `createTopologyFolder` | 新建根/区域(返回 view_id) |
| `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 |
| `sinkTopologyDualUnits` | dual_units 分批沉入 |
| `copyTopologyViewNodes` | 克隆沙箱 |
| `projectTopologyNeighbors` | 投影邻居 |
| `queryTopologyFabricNodes` | 库存(summary\|list\|search) |
| neighborhood / edges | 邻接 |
| `layoutTopologyView` | 上表 action |
| `analyzeTopologyViewLayout` | structure + 验收 |
## 对人说清楚
---
- 网页观看:拓扑页开 **「实时同步」**(默认关)。
- 回报:`folder_id` / `view_id`、画布名、`added`/`updated`/`removed`、是否 `truncated`/`next_offset`。
## 代码热更
## 不要做
1. 本仓 MCP 用 `PYTHONPATH=…/src`,改源码后不必为加载而 pip install。
2. **必须重启** stdio 进程;`catalog` 含 `rev`(当前 `NETX_MCP_REV`)。
3. `layoutTopologyView(catalog=true)` 核对 action/recipe 清单。
- 不要用 `netx` 告警 MCP 冒充拓扑写。
- 不要为画图去造 Fabric / 改 managed-NE。
- 不要在未确认 folder/view 时连环盲写。
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。

View file

@ -44,6 +44,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "nxt_your_key_with_ne_write",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
@ -53,41 +54,56 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
}
```
画图必须设 `NETX_API_TOKEN`(网页新建 Key,默认含 `ne:write`)。未设置时回退读 `data/auth/mcp_token`(只读+CLI,**无写工具**)。改 Key 权限后 Sync Tools / 约 45s 内 scopes 会刷新。
样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具)→ 专家绑定勾选 `server_id=netx-topology`。
oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。
配套 Agent Skill(画图流水线 / 安全约束):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。
配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
---
## 3. 工具一览
### 目录模型
- 顶级为导航「根」;其下唯一「根图」/ `Root map` 为画布(physical view)。
- 子区域即画布(创建时自动 physical view)。
- 文件夹 `ne_count`:子树去重 Fabric 网元数(来源无关)。
### 读
| 工具 | 作用 |
|------|------|
| `getTopologyTree` | 站点/区域文件夹树 + 下属画布 |
| `listTopologyViews` / `getTopologyView` | 画布列表 / 单图(节点+边+坐标) |
| `getTopologyFabricSummary` | Fabric 汇总 |
| `listTopologyFabricNodes` / `searchTopologyFabricNodes` | 网元搜索 |
| `queryTopologyNeighborhood` | 指定节点邻接 |
| `queryTopologyEdges` | LLDP/手工链路(含 `peer_count`) |
| `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) |
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) |
| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 |
| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score(含中档 `chains` 直链成一体、`rings` 最小环不被穿,各权 0.10);`detail=structure` 给重心/枢纽/配方建议;`hotspots\|blocks\|both` 给手拖 sight(`drag_candidates[].suggest_xy` / `delta_crossings_est`) |
### 写(只动画布,不污染 Fabric)
| 工具 | 作用 |
|------|------|
| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id`;**不**自动建画布,需再调 `createTopologyView` |
| `createTopologyView` | 在 folder 下新建画布 |
| `createTopologyFolder` | **唯一建图入口**:顶级→「根」+「根图」;根图下→**子区域**;返回 `view_id` |
| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` |
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 |
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 |
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|polish_crossings\|clear_edge_hits\|fix_overlaps\|untangle\|straighten_channels\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
**单画布硬顶 2000**;库存更大时多画布 + `offset` 翻页加满。前端对可见节点做 `onlyRenderVisibleElements`。
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
**硬顶 2000:目标 ≤2000 画一张图即可**;physical 根图/区域默认 `max_nodes=2000`,custom 仍按角色软顶。Agent 默认 `links[]` 邻接;布图按邻接与 Skill 中的经验摆点。
**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。

View file

@ -10,11 +10,19 @@ MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx AP
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
## 目录模型
- 顶级「根」为导航;其下「根图」/ `Root map` 为画布。
- **只**用 `createTopologyFolder`:顶级建根+根图;根图下建**子区域**(各带 physical `view_id`)。
- 不提供 `createTopologyView`(禁止同目录另挂 custom 交付图)。
## 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
# optional: igraph for soft-block Leiden / block-center FR (layout still ours)
pip install -e "./packages/netx-topology-mcp[layout]"
python -c "import netx_topology_mcp; print('ok')"
```
@ -28,12 +36,16 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。
画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。
## 工具(14)
| 类别 | 工具 |
|------|------|
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 画图 | `addTopologyViewNodes` / `remove…` / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
| Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` |
| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` |
| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) |
| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。

View file

@ -5,9 +5,11 @@
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "nxt_replace_with_api_key_ne_write",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
"PYTHONUTF8": "1",
"PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-topology-mcp/src"
}
}
}

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "netx-topology-mcp"
version = "0.1.0"
version = "0.1.20"
description = "stdio MCP server for netx topology canvas (views / fabric / draw)"
readme = "README.md"
requires-python = ">=3.11"
@ -13,6 +13,15 @@ dependencies = [
"httpx>=0.27.0",
]
[project.optional-dependencies]
layout = [
"igraph>=0.11.0",
]
dev = [
"pytest>=8.0",
"igraph>=0.11.0",
]
[project.scripts]
netx-topology-mcp = "netx_topology_mcp.server:main"

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,404 @@
"""Path-based channels: maximal deg≤2 corridors as atomic layout units.
Inspired by Path-Based Framework (PBF): treat corridors as first-class
geometry (straight spines), not independent force-directed points.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
def order_stubs_crossing_aware(
hub: str,
stubs: list[str],
adj: dict[str, set[str]],
member_set: set[str],
owner: dict[str, str],
) -> list[str]:
"""Stable stub order by foreign-neighbor hash angle (crossing-aware proxy)."""
if len(stubs) <= 2:
return sorted(stubs)
def score(s: str) -> tuple[float, str]:
terr = {n for n, o in owner.items() if o == s} | {s}
foreign: list[str] = []
for n in terr:
for v in adj.get(n, ()):
if v == hub or v in terr or v in member_set:
continue
foreign.append(v)
ang = (hash(s) % 1000) / 1000.0 * 2 * math.pi
if foreign:
ang = (hash(min(foreign)) % 1000) / 1000.0 * 2 * math.pi
return (ang, s)
return [s for _, s in sorted((score(s) for s in stubs), key=lambda t: t[0])]
@dataclass(frozen=True)
class Channel:
"""Ordered path hub…access… (portals may be high-degree)."""
node_ids: tuple[str, ...]
@property
def length(self) -> int:
return len(self.node_ids)
def as_dict(self) -> dict[str, Any]:
return {
"n": self.length,
"ends": [self.node_ids[0], self.node_ids[-1]] if self.node_ids else [],
"node_ids": list(self.node_ids)[:40],
}
def extract_channels(state: LayoutState) -> list[Channel]:
"""All maximal deg≤2 corridors (with portal hubs when unique)."""
paths = extract_chain_paths(state.adj)
out = [Channel(tuple(p)) for p in paths if len(p) >= 3]
out.sort(key=lambda c: (-c.length, c.node_ids[0] if c.node_ids else ""))
return out
def channels_touching(
channels: list[Channel], node_set: set[str]
) -> list[Channel]:
"""Channels with ≥2 nodes inside ``node_set`` (block-local)."""
return [c for c in channels if sum(1 for n in c.node_ids if n in node_set) >= 2]
def place_channel_ray(
path: list[str],
*,
origin: tuple[float, float],
ux: float,
uy: float,
step: float,
pinned: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Lay path along a unit ray from origin; skip pinned nodes (keep them).
Distance index: if path[0] is pinned (hub at origin), node at path[i]
sits at i*step; otherwise path[0] is at 1*step.
"""
pinned = pinned or set()
ox, oy = origin
out: dict[str, tuple[float, float]] = {}
hub_first = bool(path and path[0] in pinned)
for i, n in enumerate(path):
if n in pinned:
continue
k = i if hub_first else i + 1
out[n] = (ox + ux * step * k, oy + uy * step * k)
return out
def channels_report(state: LayoutState) -> dict[str, Any]:
ch = extract_channels(state)
nodes = {n for c in ch for n in c.node_ids}
return {
"channel_count": len(ch),
"channel_nodes": len(nodes),
"longest": ch[0].length if ch else 0,
"sample": [c.as_dict() for c in ch[:12]],
"tip": "通道是 deg≤2 走廊原子;channel_metro 按射线拉直,勿逐点力导。",
}
def _straighten_path_positions(
path: list[str],
pos: dict[str, tuple[float, float]],
*,
mode: str = "chord",
step: float | None = None,
bend_frac: float = 0.5,
) -> dict[str, tuple[float, float]] | None:
"""Reposition interior (and optionally ends) of path. Ends stay fixed for chord/L."""
pts = [pos[n] for n in path if n in pos]
if len(pts) < 3 or len(pts) != len(path):
return None
x0, y0 = pts[0]
x1, y1 = pts[-1]
n = len(path) - 1
out: dict[str, tuple[float, float]] = {}
if mode == "chord" or mode.startswith("chord_shift"):
dx, dy = x1 - x0, y1 - y0
plen = math.hypot(dx, dy) or 1.0
# lateral offset for chord_shift± (keeps ends fixed via taper)
off = 0.0
if mode.startswith("chord_shift"):
try:
off = float(mode.split(":", 1)[1])
except (IndexError, ValueError):
off = 0.0
px, py = (-dy / plen) * off, (dx / plen) * off
for i, nid in enumerate(path):
if i == 0 or i == len(path) - 1:
continue
t = i / n
# taper offset to 0 at ends (already skipping ends)
taper = math.sin(math.pi * t)
out[nid] = (
x0 + dx * t + px * taper,
y0 + dy * t + py * taper,
)
return out
if mode in {"L_hv", "L_vh"}:
# Orthogonal elbow; both ends fixed. bend_frac ∈ (0,1) picks knee along path.
bf = min(0.85, max(0.15, float(bend_frac)))
bend_i = max(1, min(n - 1, int(round(n * bf))))
for i, nid in enumerate(path):
if i == 0 or i == len(path) - 1:
continue
if mode == "L_hv":
# (x0,y0) → (x1,y0) → (x1,y1)
if i <= bend_i:
t = i / bend_i
out[nid] = (x0 + (x1 - x0) * t, y0)
else:
t = (i - bend_i) / (n - bend_i)
out[nid] = (x1, y0 + (y1 - y0) * t)
else:
# (x0,y0) → (x0,y1) → (x1,y1)
if i <= bend_i:
t = i / bend_i
out[nid] = (x0, y0 + (y1 - y0) * t)
else:
t = (i - bend_i) / (n - bend_i)
out[nid] = (x0 + (x1 - x0) * t, y1)
return out
if mode in {"horizontal", "vertical"}:
# Keep first endpoint; lay along axis with equal step
st = step or (
math.hypot(x1 - x0, y1 - y0) / max(n, 1)
if math.hypot(x1 - x0, y1 - y0) > 1
else 180.0
)
st = max(120.0, min(st, 280.0))
for i, nid in enumerate(path):
if i == 0:
continue
if mode == "horizontal":
out[nid] = (x0 + st * i, y0)
else:
out[nid] = (x0, y0 + st * i)
return out
return None
def _path_axis_credit(
path: list[str],
pos: dict[str, tuple[float, float]],
*,
tol_deg: float = 8.0,
tol_px: float = 4.0,
) -> float:
"""Mean H/V credit along path edges (H=1, V=0.75, diagonal=0)."""
import math
if len(path) < 2:
return 1.0
tol_rad = math.radians(max(0.1, float(tol_deg)))
tol_p = max(0.0, float(tol_px))
credits: list[float] = []
for i in range(len(path) - 1):
a, b = path[i], path[i + 1]
if a not in pos or b not in pos:
continue
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
length = math.hypot(dx, dy)
if length < 1e-9:
credits.append(1.0)
continue
adx, ady = abs(dx), abs(dy)
ang_h = math.atan2(ady, adx)
ang_v = abs(math.pi / 2 - ang_h)
if ady <= tol_p or ang_h <= tol_rad:
credits.append(1.0)
elif adx <= tol_p or ang_v <= tol_rad:
credits.append(0.75)
else:
credits.append(0.0)
return sum(credits) / max(len(credits), 1)
def straighten_channels_greedy(
state: LayoutState,
params: Any | None = None,
*,
step: float | None = None,
min_len: int = 3,
max_passes: int = 3,
):
"""Straighten deg≤2 channels.
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
accept H/V / L modes that raise path axis credit (metro look).
"""
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult
params = params or LayoutParams()
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
pos = dict(state.positions)
links = list(state.links)
cross = count_edge_crossings(pos, links)
accepted: list[dict[str, Any]] = []
base_modes = (
"chord",
"horizontal",
"vertical",
"L_hv",
"L_vh",
)
bend_fracs = (0.35, 0.5, 0.65)
shift_offs = sorted(
{
-2.0 * step_px,
-1.5 * step_px,
-step_px,
-0.5 * step_px,
0.5 * step_px,
step_px,
1.5 * step_px,
2.0 * step_px,
-400.0,
-280.0,
280.0,
400.0,
}
)
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
for _pass in range(max(1, int(max_passes))):
raise_if_cancelled()
channels = extract_channels(state)
# Prefer longer kinked channels first
accepted_pass = 0
n_ch = max(1, len(channels))
for ci, ch in enumerate(channels):
if ci % 8 == 0:
raise_if_cancelled()
report_progress(
"straighten_channels",
pct=48.0 + 6.0 * ((_pass + ci / n_ch) / max(1, int(max_passes))),
message=f"pass {_pass + 1} ch {ci + 1}/{n_ch} x={cross}",
step=ci + 1,
total_steps=n_ch,
crossings=cross,
)
path = list(ch.node_ids)
if len(path) < min_len:
continue
if any(n not in pos for n in path):
continue
axis0 = _path_axis_credit(path, pos)
# Lexicographic key: fewer crossings, then higher axis credit.
best_key = (cross, -axis0)
best_pos = None
best_mode = None
modes = list(base_modes) + [f"chord_shift:{o:.1f}" for o in shift_offs]
for mode in modes:
fracs = bend_fracs if mode.startswith("L_") else (0.5,)
for bf in fracs:
delta = _straighten_path_positions(
path, pos, mode=mode, step=step_px, bend_frac=bf
)
if not delta:
continue
trial = dict(pos)
trial.update(delta)
c1 = count_edge_crossings(trial, links)
if c1 > cross:
continue
a1 = _path_axis_credit(path, trial)
key = (c1, -a1)
if key < best_key:
best_key = key
best_pos = trial
best_mode = f"{mode}@{bf:.2f}" if mode.startswith("L_") else mode
if best_pos is None:
continue
# light overlap fix; keep if still improved
st_try = state.copy()
st_try.positions = best_pos
stf = fix_overlaps_local(st_try, params).state
# restore pinned hubs (core/agg endpoints)
for end in (path[0], path[-1]):
if state.layers.get(end) in ("core", "agg") and end in pos:
stf.positions[end] = pos[end]
trial2 = {k: (float(v[0]), float(v[1])) for k, v in stf.positions.items()}
c2 = count_edge_crossings(trial2, links)
a2 = _path_axis_credit(path, trial2)
if (c2, -a2) >= (cross, -axis0):
continue
pos = trial2
cross = c2
accepted_pass += 1
accepted.append(
{
"ends": [path[0], path[-1]],
"n": len(path),
"mode": best_mode,
"crossings": cross,
"axis": round(a2, 3),
}
)
if accepted_pass == 0:
break
if not accepted:
return OpResult(
state=state,
moved=set(),
op="straighten_channels",
params={"accepted_n": 0},
note="no_channel_improved",
)
out = state.copy()
out.positions = pos
moved = {
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
}
out.meta = dict(out.meta or {})
out.meta["straighten_channels"] = {"accepted": accepted, "crossings": cross}
return OpResult(
state=out,
moved=moved,
op="straighten_channels",
params={"accepted_n": len(accepted), "accepted": accepted[:20], "crossings": cross},
note=f"straighten_channels:{len(accepted)} x->{cross}",
)
def straighten_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("min_len") is not None:
try:
out["min_len"] = int(overrides["min_len"])
except (TypeError, ValueError):
pass
if overrides.get("max_passes") is not None:
try:
out["max_passes"] = int(overrides["max_passes"])
except (TypeError, ValueError):
pass
return out

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,209 @@
"""Graph helpers for layout ops."""
from __future__ import annotations
import re
from collections import deque
_ROLE_TO_LAYER = {
"core": "core",
"cn": "core",
"aggregation": "agg",
"aggregate": "agg",
"agg": "agg",
"an": "agg",
"access": "access",
"en": "access",
"edge": "access",
}
def infer_layer(name: str, role: str | None = None) -> str:
"""Map inventory role / name token (CN|AN|EN) → core|agg|access|other."""
r = str(role or "").strip().lower()
if r in _ROLE_TO_LAYER:
return _ROLE_TO_LAYER[r]
m = re.search(r"-(CN|AN|EN)(\d*)-", name or "", re.I)
if not m:
return "other"
return {"CN": "core", "AN": "agg", "EN": "access"}[m.group(1).upper()]
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
return min(xs), min(ys), max(xs), max(ys)
def connected_components(nodes: set[str], g: dict[str, set[str]]) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for s in sorted(nodes):
if s in seen:
continue
q = deque([s])
seen.add(s)
comp: list[str] = []
while q:
u = q.popleft()
comp.append(u)
for v in g.get(u, ()):
if v in nodes and v not in seen:
seen.add(v)
q.append(v)
out.append(comp)
out.sort(key=lambda c: -len(c))
return out
def order_ans(
ans: list[str], ens: set[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
if len(ans) <= 1:
return list(ans)
def dist(a: str, b: str) -> int:
q = deque([(a, 0)])
seen = {a}
while q:
u, d = q.popleft()
for v in g.get(u, ()):
if v == b:
return d + 1
if v in seen:
continue
if v in ens or v in ans:
seen.add(v)
q.append((v, d + 1))
return 10**6
start = max(ans, key=lambda a: (len([x for x in g.get(a, ()) if x in ens]), names[a]))
seq = [start]
rest = set(ans) - {start}
while rest:
last = seq[-1]
nxt = min(rest, key=lambda a: (dist(last, a), names[a]))
seq.append(nxt)
rest.remove(nxt)
return seq
def chain_order(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Covering walk (may include all nodes). Prefer spine_backbone for true spine."""
s = set(nodes)
if not s:
return []
sub = {n: [x for x in g.get(n, ()) if x in s] for n in nodes}
ends = [n for n in nodes if len(sub[n]) <= 1] or list(nodes)
start = sorted(ends, key=lambda n: (len(sub[n]), names.get(n, n)))[0]
ordered = [start]
prev = None
cur = start
seen = {start}
while len(ordered) < len(nodes):
nxts = [x for x in sub[cur] if x != prev and x not in seen]
if not nxts:
cand = [v for u in ordered for v in sub[u] if v not in seen]
if not cand:
rest = [n for n in nodes if n not in seen]
if not rest:
break
nxts = [sorted(rest, key=lambda n: names.get(n, n))[0]]
else:
nxts = [sorted(cand, key=lambda n: names.get(n, n))[0]]
prev = None
nxts.sort(key=lambda n: names.get(n, n))
prev, cur = cur, nxts[0]
ordered.append(cur)
seen.add(cur)
return ordered
def spine_backbone(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Longest shortest-path among `nodes` (approx diameter). Side branches stay off-spine."""
s = set(nodes)
if not s:
return []
if len(s) == 1:
return list(s)
def bfs(src: str) -> tuple[dict[str, int], dict[str, str | None]]:
dist = {src: 0}
parent: dict[str, str | None] = {src: None}
q = deque([src])
while q:
u = q.popleft()
for v in g.get(u, ()):
if v not in s or v in dist:
continue
dist[v] = dist[u] + 1
parent[v] = u
q.append(v)
return dist, parent
# eccentricity from an arbitrary end, then from farthest
seed = sorted(nodes, key=lambda n: (len([x for x in g.get(n, ()) if x in s]), names.get(n, n)))[0]
d1, _ = bfs(seed)
a = max(d1, key=lambda n: (d1[n], names.get(n, n)))
d2, parent = bfs(a)
b = max(d2, key=lambda n: (d2[n], names.get(n, n)))
path = [b]
while parent.get(path[-1]) is not None:
path.append(parent[path[-1]] or "")
path.reverse()
return [n for n in path if n]
def build_state_from_nodes_edges(
nodes: list[dict], edges: list[dict]
) -> "LayoutState": # noqa: F821
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links
names: dict[str, str] = {}
layers: dict[str, str] = {}
ids: list[str] = []
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
nm = str(n.get("name") or n.get("label") or fid)
ids.append(fid)
names[fid] = nm
layers[fid] = infer_layer(nm, n.get("role"))
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
for a, b in links:
if a in adj and b in adj:
adj[a].add(b)
adj[b].add(a)
positions: dict[str, tuple[float, float]] = {}
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
if n.get("x") is not None and n.get("y") is not None:
try:
positions[fid] = (float(n["x"]), float(n["y"]))
except (TypeError, ValueError):
pass
return LayoutState(
positions=positions,
names=names,
layers=layers,
links=[(a, b) for a, b in links if a in adj and b in adj],
adj=adj,
meta={"ids": ids},
)

View file

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

View file

@ -0,0 +1,380 @@
"""Local hotspots: overlaps / dense cells — scope without touching whole canvas."""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import replace
from typing import Any
from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
from netx_topology_mcp.layout_ops.scope import select_scope
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import (
explode_clusters,
pack_utilization,
resolve_overlaps,
)
def _aabb_overlap(
a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> bool:
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def overlapping_nodes(state: LayoutState) -> set[str]:
"""Nodes whose icon+label footprints collide."""
ids = list(state.positions)
fps: dict[str, tuple[float, float, float, float]] = {}
for n in ids:
x, y = state.positions[n]
fx0, fy0, fx1, fy1 = node_footprint(state.names.get(n, n))
fps[n] = (x + fx0, y + fy0, x + fx1, y + fy1)
hit: set[str] = set()
for i, a in enumerate(ids):
for b in ids[i + 1 :]:
if _aabb_overlap(fps[a], fps[b]):
hit.add(a)
hit.add(b)
return hit
def expand_hops(
seeds: set[str], adj: dict[str, set[str]], *, hops: int = 1
) -> set[str]:
out = set(seeds)
frontier = set(seeds)
for _ in range(max(0, hops)):
nxt: set[str] = set()
for u in frontier:
nxt |= set(adj.get(u, ()))
nxt -= out
out |= nxt
frontier = nxt
if not frontier:
break
return out
def dense_blocks(
state: LayoutState,
*,
cell_w: float = REC_CENTER_DX,
cell_h: float = REC_CENTER_DY,
min_count: int = 5,
) -> list[set[str]]:
"""Grid cells with too many centers — local overcrowding."""
if len(state.positions) < min_count:
return []
buckets: dict[tuple[int, int], set[str]] = defaultdict(set)
for n, (x, y) in state.positions.items():
buckets[(int(math.floor(x / cell_w)), int(math.floor(y / cell_h)))].add(n)
return [nodes for nodes in buckets.values() if len(nodes) >= min_count]
def close_clusters(
state: LayoutState, *, thr: float = 100.0
) -> list[set[str]]:
"""Union-find clusters of centers closer than thr (each cluster = one local scope)."""
ids = list(state.positions)
parent = {n: n for n in ids}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def uni(a: str, b: str) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
cell = max(thr, 40.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n, (x, y) in state.positions.items():
buckets[(int(x // cell), int(y // cell))].append(n)
for n, (x, y) in state.positions.items():
cx, cy = int(x // cell), int(y // cell)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m <= n:
continue
mx, my = state.positions[m]
if math.hypot(x - mx, y - my) < thr:
uni(n, m)
groups: dict[str, set[str]] = defaultdict(set)
for n in ids:
groups[find(n)].add(n)
return [g for g in groups.values() if len(g) >= 2]
def sprawled_component_scopes(
state: LayoutState,
*,
target_util: float,
min_size: int = 8,
max_size: int = 80,
) -> list[set[str]]:
"""Graph components with low util — skip huge ones (those need layout, not local pack)."""
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components
comps = connected_components(set(state.positions), state.adj)
out: list[set[str]] = []
tile = REC_CENTER_DX * REC_CENTER_DY
for c in comps:
if len(c) < min_size or len(c) > max_size:
continue
sub = {n: state.positions[n] for n in c}
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
util = len(c) * tile / area
if util < target_util:
out.append(set(c))
return out
def _merge_scopes(scopes: list[set[str]], *, max_size: int = 64) -> list[set[str]]:
"""Merge intersecting scopes but never grow past max_size."""
merged: list[set[str]] = []
for s in scopes:
if not s or len(s) < 2:
continue
if len(s) > max_size:
continue
hit = None
for m in merged:
if s & m and len(m | s) <= max_size:
hit = m
break
if hit is None:
merged.append(set(s))
else:
hit |= s
return [m for m in merged if len(m) >= 2]
def hotspot_scopes(
state: LayoutState,
*,
hops: int = 1,
dense_min: int = 5,
close_thr: float = 100.0,
target_util: float | None = None,
max_scope: int = 64,
) -> list[set[str]]:
"""Local scopes only: overlap / close clusters / dense cells / small sprawled comps."""
seeds_list: list[set[str]] = []
ov = overlapping_nodes(state)
if ov and len(ov) <= max_scope:
seeds_list.append(ov)
seeds_list.extend(close_clusters(state, thr=close_thr))
seeds_list.extend(dense_blocks(state, min_count=dense_min))
if target_util is not None:
seeds_list.extend(
sprawled_component_scopes(
state, target_util=target_util, max_size=max_scope
)
)
if not seeds_list:
return []
expanded: list[set[str]] = []
for s in seeds_list:
if len(s) < 2 or len(s) > max_scope:
continue
e = expand_hops(s, state.adj, hops=hops) & set(state.positions)
if len(e) > max_scope:
e = set(s) # drop hop expansion if it balloons
expanded.append(e)
return _merge_scopes(expanded, max_size=max_scope)
def fix_overlaps_local(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
from netx_topology_mcp.layout_metrics import count_edge_crossings
params = params or LayoutParams()
st = state.copy()
seeds = overlapping_nodes(st)
if not seeds:
return OpResult(
state=st,
moved=set(),
op="fix_overlaps_local",
params={"seeds": 0},
note="no overlaps",
)
c0 = count_edge_crossings(st.positions, st.links)
baseline = st.copy()
scope = expand_hops(seeds, st.adj, hops=1) & set(st.positions)
# Prefer moving smaller set; keep rest as obstacles
st = select_scope(st, mode="ids", node_ids=scope).state
p = replace(
params,
overlap_iters=max(220, int(params.overlap_iters)),
overlap_step=max(4.0, float(params.overlap_step)),
)
moved: set[str] = set()
r1 = resolve_overlaps(st, p, mode="free")
st, moved = r1.state, moved | r1.moved
r2 = resolve_overlaps(st, p, mode="lateral")
st, moved = r2.state, moved | r2.moved
# Still stuck? explode tiny coincidences inside scope, then resolve again
still = overlapping_nodes(st) & scope
if still:
st = select_scope(st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)).state
r3 = explode_clusters(st, p, thr=12.0, gap=max(40.0, p.cluster_gap), axis="along")
st, moved = r3.state, moved | r3.moved
r4 = resolve_overlaps(st, p, mode="free")
st, moved = r4.state, moved | r4.moved
r5 = resolve_overlaps(st, p, mode="lateral")
st, moved = r5.state, moved | r5.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
# Free/explode can shred metro geometry — fall back to lateral-only unstick.
if c1 > max(c0 * 1.35, c0 + 60):
st = baseline
st = select_scope(st, mode="ids", node_ids=scope).state
moved = set()
p2 = replace(p, overlap_iters=max(320, int(p.overlap_iters)))
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, r.moved
still = overlapping_nodes(st) & scope
if still:
st = select_scope(
st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)
).state
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, moved | r.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
left = overlapping_nodes(st)
return OpResult(
state=st,
moved=moved,
op="fix_overlaps_local",
params={
"seeds": len(seeds),
"scope_n": len(scope),
"moved_n": len(moved),
"overlaps_left": len(left),
"crossings_before": c0,
"crossings_after": c1,
},
note=f"local unstick seeds={len(seeds)} left={len(left)} cross={c0}->{c1}",
)
def _block_util(state: LayoutState, block: set[str]) -> float:
from netx_topology_mcp.layout_ops.graph_util import bbox
if len(block) < 2:
return 1.0
sub = {n: state.positions[n] for n in block if n in state.positions}
if len(sub) < 2:
return 1.0
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
return len(sub) * REC_CENTER_DX * REC_CENTER_DY / area
def _block_has_close(state: LayoutState, block: set[str], thr: float) -> bool:
ids = [n for n in block if n in state.positions]
for i, a in enumerate(ids):
ax, ay = state.positions[a]
for b in ids[i + 1 :]:
bx, by = state.positions[b]
if math.hypot(ax - bx, ay - by) < thr:
return True
return False
def relax_hotspots(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Per-hotspot local polish — never global compress.
- Sprawled block (low util): pack + resolve only
- Crowded block (close pairs / overlaps): mild explode + resolve (no pack)
"""
params = params or LayoutParams()
st = state.copy()
scopes = hotspot_scopes(
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
)
if not scopes:
from netx_topology_mcp.layout_ops.scope import list_blocks
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]
scopes = sorted(scopes, key=len, reverse=True)[:24]
total_moved: set[str] = set()
block_notes: list[dict[str, Any]] = []
close_thr = 55.0 # only true local crowding, not soft nn band
for i, block in enumerate(scopes):
if len(block) < 2:
continue
st = select_scope(st, mode="ids", node_ids=block).state
before_ov = len(overlapping_nodes(st) & block)
util = _block_util(st, block)
crowded = before_ov > 0 or _block_has_close(st, block, close_thr)
sprawled = util < params.target_util
mode = "pack" if sprawled and not crowded else ("unstick" if crowded else "skip")
moved_here: set[str] = set()
if mode == "unstick":
r1 = explode_clusters(
st, params, thr=close_thr, gap=max(40.0, params.cluster_gap), axis="along"
)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
r3 = resolve_overlaps(st, params, mode="free")
st = r3.state
moved_here |= r3.moved
elif mode == "pack":
r1 = pack_utilization(st, params)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
total_moved |= moved_here
after_ov = len(overlapping_nodes(st) & block)
block_notes.append(
{
"i": i,
"n": len(block),
"mode": mode,
"util": round(util, 4),
"ov_before": before_ov,
"ov_after": after_ov,
"moved": len(moved_here),
}
)
st = select_scope(st, mode="all").state
fin = fix_overlaps_local(st, params)
st = fin.state
total_moved |= fin.moved
return OpResult(
state=st,
moved=total_moved,
op="relax_hotspots",
params={"blocks": block_notes, "blocks_n": len(scopes), "moved_n": len(total_moved)},
note=f"relax blocks={len(scopes)} moved={len(total_moved)}",
)

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,137 @@
"""Tests for channel_metro (channels + ring faces + recipe)."""
from __future__ import annotations
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
from netx_topology_mcp.layout_ops.channel_metro import build_channel_metro_skeleton
from netx_topology_mcp.layout_ops.channels import extract_channels, place_channel_ray
from netx_topology_mcp.layout_ops.ring_faces import place_ring_rectangle
from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph
def _core_bar_channels():
"""Two cores, two ANs, each with a deg-2 corridor + one triangle ring."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 0, "y": 0},
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
# corridor a0-e0-e1
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
# corridor a1-e2-e3
{"a_node_id": "a1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "e3"},
# triangle ring on a0 side
{"a_node_id": "a0", "b_node_id": "e4"},
{"a_node_id": "e4", "b_node_id": "e5"},
{"a_node_id": "e5", "b_node_id": "a0"},
]
return nodes, edges
def test_extract_channels() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
ch = extract_channels(st)
assert len(ch) >= 1
assert any(c.length >= 3 for c in ch)
def test_place_channel_ray() -> None:
placed = place_channel_ray(
["h", "a", "b"],
origin=(0.0, 0.0),
ux=1.0,
uy=0.0,
step=100.0,
pinned={"h"},
)
assert "h" not in placed
assert placed["a"] == (100.0, 0.0)
assert placed["b"] == (200.0, 0.0)
def test_ring_rectangle() -> None:
from netx_topology_mcp.layout_ops.ring_faces import RingFace
face = RingFace(("a", "b", "c", "d"))
pos = place_ring_rectangle(face, center=(0, 0), width=200, height=100)
assert set(pos) == {"a", "b", "c", "d"}
def test_build_channel_metro_places_all() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
op = build_channel_metro_skeleton(st, LayoutParams())
assert op.op == "build_channel_metro_skeleton"
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
assert op.state.meta.get("rings_mode") == "channel_metro"
# Cores present; beam pin may or may not accept depending on crossings
assert "c0" in op.state.positions and "c1" in op.state.positions
def test_recipe_alias_and_run_unpublished() -> None:
assert "channel_metro" not in RECIPE_ALIASES
nodes, edges = _core_bar_channels()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="channel_metro")
raise AssertionError("channel_metro should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_protect_rings_action_unpublished() -> None:
nodes, edges = _core_bar_channels()
for i, n in enumerate(nodes):
n["x"] = float(i * 40)
n["y"] = float((i % 3) * 30)
try:
run_layout_on_graph(nodes, edges, action="protect_rings")
raise AssertionError("protect_rings should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_recipe_channel_metro_v1_unpublished() -> None:
nodes, edges = _core_bar_channels()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "channel_metro_v1", LayoutParams())
raise AssertionError("channel_metro_v1 should be unpublished")
except Exception as e:
assert "channel" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
def test_straighten_channels_action() -> None:
nodes, edges = _core_bar_channels()
# bent corridor a0-e0-e1
coords = {
"c0": (0, 0),
"c1": (400, 0),
"a0": (100, 200),
"a1": (500, 200),
"e0": (50, 400),
"e1": (300, 450),
"e2": (500, 400),
"e3": (700, 420),
"e4": (150, 300),
"e5": (200, 250),
}
for n in nodes:
x, y = coords[n["fabric_node_id"]]
n["x"], n["y"] = x, y
out = run_layout_on_graph(nodes, edges, action="straighten_channels")
assert out["ok"] is True
assert out["action"] == "straighten_channels"

View file

@ -0,0 +1,145 @@
"""edge_clearance metric + clear_edge_hits action."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import (
EDGE_CLEARANCE_THR,
compute_edge_clearance,
count_edge_crossings,
)
from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState
from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components
from netx_topology_mcp.layout_tool import run_layout_on_graph
def _mksr_style() -> tuple[list[dict], list[dict]]:
"""M1MJ—MEPL horizontal trunk with MKSR sitting on the segment; DALH stub."""
nodes = [
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
{"fabric_node_id": "mksr", "name": "MKSR", "x": 200.0, "y": 6.0}, # on trunk
{"fabric_node_id": "dalh", "name": "DALH", "x": 200.0, "y": 200.0},
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
]
edges = [
{"a_node_id": "m1mj", "b_node_id": "mepl"},
{"a_node_id": "mepl", "b_node_id": "bbsn"},
{"a_node_id": "bbsn", "b_node_id": "mksr"},
{"a_node_id": "mksr", "b_node_id": "dalh"},
]
return nodes, edges
def test_collinear_midpoint_scores_edge_clearance_hit() -> None:
nodes, edges = _mksr_style()
m = analyze_layout_stats(nodes, edges)
assert int(m["edge_clearance_hits"] or 0) >= 1
assert float(m["edge_clearance_score"]) < 1.0
assert "edge_clearance" in m["score"]["parts"]
assert abs(m["score"]["weights"]["edge_clearance"] - 0.08) < 1e-9
assert m["report"]["edge_clearance"]["status"] in {"warn", "fail"}
top = m.get("top_edge_hits") or []
assert any(r.get("fabric_node_id") == "mksr" for r in top)
def test_cleared_layout_has_zero_hits() -> None:
nodes = [
{"fabric_node_id": "m1mj", "name": "M1MJ", "x": 0.0, "y": 0.0},
{"fabric_node_id": "mepl", "name": "MEPL", "x": 400.0, "y": 0.0},
{"fabric_node_id": "mksr", "name": "MKSR", "x": 600.0, "y": 200.0},
{"fabric_node_id": "dalh", "name": "DALH", "x": 600.0, "y": 400.0},
{"fabric_node_id": "bbsn", "name": "BBSN", "x": 600.0, "y": 0.0},
]
edges = [
{"a_node_id": "m1mj", "b_node_id": "mepl"},
{"a_node_id": "mepl", "b_node_id": "bbsn"},
{"a_node_id": "bbsn", "b_node_id": "mksr"},
{"a_node_id": "mksr", "b_node_id": "dalh"},
]
m = analyze_layout_stats(nodes, edges)
assert int(m["edge_clearance_hits"] or 0) == 0
assert float(m["edge_clearance_score"]) == 1.0
assert m["report"]["edge_clearance"]["status"] == "ok"
def test_clear_edge_hits_moves_mksr_off_trunk() -> None:
nodes, edges = _mksr_style()
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
names = {n["fabric_node_id"]: n["name"] for n in nodes}
links = [("m1mj", "mepl"), ("mepl", "bbsn"), ("bbsn", "mksr"), ("mksr", "dalh")]
st = LayoutState(positions=pos, names=names, links=links)
x0 = count_edge_crossings(st.positions, st.links)
clr0 = compute_edge_clearance(st.positions, st.links, thr=EDGE_CLEARANCE_THR)
assert int(clr0["edge_clearance_hits"] or 0) >= 1
op = clear_edge_hits(st, LayoutParams(), thr=EDGE_CLEARANCE_THR, margin=20.0)
assert "mksr" in op.moved or int(op.params.get("hits_after") or 0) < int(
op.params.get("hits_before") or 0
)
x1 = count_edge_crossings(op.state.positions, op.state.links)
assert x1 <= x0
clr1 = compute_edge_clearance(op.state.positions, op.state.links, thr=EDGE_CLEARANCE_THR)
assert int(clr1["edge_clearance_hits"] or 0) < int(clr0["edge_clearance_hits"] or 0)
def test_run_layout_clear_edge_hits_apply_path() -> None:
nodes, edges = _mksr_style()
out = run_layout_on_graph(
nodes,
edges,
action="clear_edge_hits",
params={"thr": 40.0, "margin": 20.0, "top_n": 8},
)
assert out.get("ok") is not False
assert out.get("action") == "clear_edge_hits"
local = out.get("local") or {}
note = str(local.get("note") or "")
assert "clear_edge_hits" in note
def test_score_weights_include_edge_clearance() -> None:
s = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 0.2,
"edge_axis_score": 0.2,
}
)
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
assert abs(s["weights"]["nn"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
good = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 1.0,
"edge_axis_score": 1.0,
}
)
assert good["total"] > s["total"]

View file

@ -0,0 +1,129 @@
"""Tests for incremental Prim+orbit compose_orbit."""
from __future__ import annotations
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.compose_orbit import (
compose_orbit_into_state,
compose_orbit_params_from_overrides,
crossings_touching,
orbit_pack_blocks,
)
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_compose_orbit() -> None:
assert "compose_orbit" not in ACTIONS
def test_orbit_params_parse_knobs() -> None:
knobs = compose_orbit_params_from_overrides(
{
"source_view_ids": ["a", "b"],
"pad": 400,
"angle_step": 45,
"radii": [1.0, 1.5],
"cand_cap": 40,
}
)
assert knobs["pad"] == 400
assert knobs["angle_step"] == 45
assert knobs["radii"] == (1.0, 1.5)
assert knobs["cand_cap"] == 40
assert knobs["source_view_ids"] == ["a", "b"]
def test_orbit_pack_glues_shared_portal() -> None:
unit_a = ComposeBlock(
key="ua",
positions={
"p": (0.0, 0.0),
"a1": (120.0, 0.0),
"a2": (120.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p": (0.0, 0.0),
"b1": (120.0, 0.0),
"b2": (120.0, -80.0),
},
)
links = [
("p", "a1"),
("a1", "a2"),
("p", "b1"),
("b1", "b2"),
("a2", "b2"),
]
merged, meta = orbit_pack_blocks(
[unit_a, unit_b],
pad=200.0,
links=links,
fabric_bridges=True,
angle_step=30,
cand_cap=60,
)
assert meta["mode"] == "compose_orbit"
assert meta["pack_mode"] == "orbit_attach"
assert meta["slots"] == 2
assert set(merged) == {"p", "a1", "a2", "b1", "b2"}
# Shared portal is a single point (first-owner / align).
assert "p" in merged
via = meta.get("merged_via") or {}
assert "orbit" in " ".join(str(v) for v in via.values()) or any(
"shared" in str(v) or "orbit" in str(v) for v in via.values()
)
def test_crossings_touching_partial() -> None:
# Two segments crossing; focus only on one endpoint set.
pos = {
"a": (0.0, 0.0),
"b": (10.0, 10.0),
"c": (0.0, 10.0),
"d": (10.0, 0.0),
}
links = [("a", "b"), ("c", "d")]
assert crossings_touching(pos, links, {"a", "b"}) == 1
assert crossings_touching(pos, links, {"z"}) == 0
def test_compose_orbit_into_state_and_run_layout() -> None:
nodes = [
{"id": "p", "x": 0, "y": 0},
{"id": "a1", "x": 100, "y": 0},
{"id": "b1", "x": 0, "y": 100},
]
edges = [{"source": "p", "target": "a1"}, {"source": "p", "target": "b1"}]
st = build_state_from_nodes_edges(nodes, edges)
blocks = [
ComposeBlock(key="ua", positions={"p": (0.0, 0.0), "a1": (100.0, 0.0)}),
ComposeBlock(key="ub", positions={"p": (0.0, 0.0), "b1": (100.0, 0.0)}),
]
op = compose_orbit_into_state(st, blocks, LayoutParams(), pad=150.0)
assert op.op == "compose_orbit"
assert "compose_views" in (op.state.meta or {})
assert (op.state.meta.get("compose_views") or {}).get("mode") == "compose_orbit"
try:
run_layout_on_graph(
nodes,
edges,
action="compose_orbit",
params={
"_compose_blocks": [
{"key": "ua", "positions": {"p": [0, 0], "a1": [100, 0]}},
{"key": "ub", "positions": {"p": [0, 0], "b1": [100, 0]}},
],
"rigid_polish": False,
"soft_polish": False,
"pad": 150,
"angle_step": 45,
},
)
raise AssertionError("compose_orbit should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,325 @@
"""Tests for world-map strip-pack compose_views."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.compose_views import (
ComposeBlock,
compose_into_state,
compose_params_from_overrides,
strip_pack_blocks,
)
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_compose_views() -> None:
assert "compose_views" not in ACTIONS
def test_strip_pack_preserves_relative_geometry() -> None:
a = ComposeBlock(
key="va",
positions={
"a1": (10.0, 20.0),
"a2": (110.0, 20.0),
"a3": (10.0, 120.0),
},
)
b = ComposeBlock(
key="vb",
positions={
"b1": (0.0, 0.0),
"b2": (50.0, 0.0),
},
)
merged, meta = strip_pack_blocks([a, b], pad=100.0)
assert meta["slots"] == 2
assert set(merged) == {"a1", "a2", "a3", "b1", "b2"}
# Relative offsets inside block A preserved.
dx = merged["a2"][0] - merged["a1"][0]
dy = merged["a2"][1] - merged["a1"][1]
assert abs(dx - 100.0) < 1e-6
assert abs(dy) < 1e-6
# Blocks occupy distinct slots (centroids far apart vs pad).
ax = sum(merged[n][0] for n in ("a1", "a2", "a3")) / 3
bx = sum(merged[n][0] for n in ("b1", "b2")) / 2
ay = sum(merged[n][1] for n in ("a1", "a2", "a3")) / 3
by = sum(merged[n][1] for n in ("b1", "b2")) / 2
assert abs(ax - bx) > 50 or abs(ay - by) > 50
def test_strip_pack_first_owner_wins_duplicates() -> None:
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
# Portal-grow still glues the later shared block rigidly.
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
assert "shared" in merged
assert "a" in merged and "b" in merged
via = meta.get("merged_via") or {}
assert "rigid_shared" in via.values()
assert meta.get("order_mode") == "portal_grow"
def test_portal_grow_attaches_shared_before_orphan_misc() -> None:
"""Connected dual units grow together; huge misc without shared packs last."""
unit_ab = ComposeBlock(
key="ab",
positions={
"p_a": (0.0, 0.0),
"p_b": (200.0, 0.0),
"mid": (100.0, 40.0),
},
)
unit_bc = ComposeBlock(
key="bc",
positions={
"p_b": (0.0, 0.0),
"p_c": (200.0, 0.0),
"x1": (50.0, 30.0),
"x2": (100.0, 30.0),
"x3": (150.0, 30.0),
"x4": (80.0, -30.0),
},
)
misc = ComposeBlock(
key="misc",
positions={f"m{i}": (float(i) * 40.0, float(i % 3) * 40.0) for i in range(24)},
)
merged, meta = strip_pack_blocks(
[misc, unit_ab, unit_bc], pad=80.0, merge_shared=True
)
order = meta.get("order") or []
assert meta.get("order_mode") == "portal_grow"
# Misc is largest area but has no shared glue → last.
assert order[-1] == "misc"
# ab and bc share p_b → one of them merges rigidly onto the other.
via = meta.get("merged_via") or {}
assert via.get("ab") == "rigid_shared" or via.get("bc") == "rigid_shared"
# Shared portal has a single world coord.
assert "p_b" in merged
assert abs(merged["p_b"][0] - merged["p_b"][0]) < 1e-9
def test_portal_centroid_keeps_shared_units_near() -> None:
"""Units that share portals get closer origins than unrelated orphans."""
# Chain A—B—C via portals; orphan D has no shared nodes.
unit_a = ComposeBlock(
key="ua",
positions={
"p_ab": (0.0, 0.0),
"a1": (100.0, 0.0),
"a2": (100.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p_ab": (0.0, 0.0),
"p_bc": (200.0, 0.0),
"b1": (100.0, 40.0),
},
)
unit_c = ComposeBlock(
key="uc",
positions={
"p_bc": (0.0, 0.0),
"c1": (100.0, 0.0),
"c2": (100.0, 60.0),
},
)
orphan = ComposeBlock(
key="orphan",
positions={f"o{i}": (float(i) * 30.0, 0.0) for i in range(8)},
)
merged, meta = strip_pack_blocks(
[orphan, unit_c, unit_a, unit_b], pad=80.0, merge_shared=True
)
assert meta.get("pack_mode") == "portal_centroid"
origins = meta["origins"]
def _slot_c(key: str) -> tuple[float, float]:
sm = meta["slot_meta"][key]
ox, oy = origins[key]
return ox + 0.5 * sm["w"], oy + 0.5 * sm["h"]
ca, cb, cc, co = _slot_c("ua"), _slot_c("ub"), _slot_c("uc"), _slot_c("orphan")
# Portal-graph neighbors closer than the A–C skip.
d_ab = math.hypot(ca[0] - cb[0], ca[1] - cb[1])
d_bc = math.hypot(cb[0] - cc[0], cb[1] - cc[1])
d_ac = math.hypot(ca[0] - cc[0], ca[1] - cc[1])
assert d_ab < d_ac
assert d_bc < d_ac
# Orphans sit below the glued cluster.
glued_ymax = max(
origins[k][1] + meta["slot_meta"][k]["h"] for k in ("ua", "ub", "uc")
)
assert origins["orphan"][1] >= glued_ymax - 1e-6
# Rigid merge still glues the chain.
via = meta.get("merged_via") or {}
assert "rigid_shared" in via.values()
assert "p_ab" in merged and "p_bc" in merged
def test_fabric_bridges_pull_disjoint_units_closer() -> None:
"""No shared portal: fabric edge + higher bridge_boost / lower ideal_scale."""
unit_a = ComposeBlock(
key="ua",
positions={
"a0": (0.0, 0.0),
"a1": (120.0, 0.0),
"a2": (60.0, 80.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"b0": (0.0, 0.0),
"b1": (120.0, 0.0),
"b2": (60.0, 80.0),
},
)
unit_c = ComposeBlock(
key="uc",
positions={
"c0": (0.0, 0.0),
"c1": (120.0, 0.0),
"c2": (60.0, 80.0),
},
)
# Only A↔B has a fabric spoke; C is an unrelated peer (orphan strip below).
links = [("a1", "b0")]
blocks = [unit_a, unit_b, unit_c]
def _origin_gap(meta: dict, ka: str, kb: str) -> float:
oa, ob = meta["origins"][ka], meta["origins"][kb]
return math.hypot(oa[0] - ob[0], oa[1] - ob[1])
loose, meta_loose = strip_pack_blocks(
blocks,
pad=200.0,
merge_shared=True,
links=links,
fabric_bridges=True,
bridge_boost=4.0,
ideal_scale=0.85,
)
tight, meta_tight = strip_pack_blocks(
blocks,
pad=200.0,
merge_shared=True,
links=links,
fabric_bridges=True,
bridge_boost=40.0,
ideal_scale=0.3,
)
off, meta_off = strip_pack_blocks(
blocks, pad=200.0, merge_shared=True, links=links, fabric_bridges=False
)
assert meta_off.get("fabric_bridges") is False
assert meta_tight.get("fabric_bridges") is True
assert meta_tight.get("bridge_boost") == 40.0
assert meta_tight.get("ideal_scale") == 0.3
# Tunable spring: denser knobs shrink A–B slot gap.
assert _origin_gap(meta_tight, "ua", "ub") < _origin_gap(meta_loose, "ua", "ub") * 0.75
# Fabric glue puts C below the A–B component (not interleaved in strip).
glued_ymax = max(
meta_tight["origins"][k][1] + meta_tight["slot_meta"][k]["h"]
for k in ("ua", "ub")
)
assert meta_tight["origins"]["uc"][1] >= glued_ymax - 1e-6
del loose, tight, off
def test_compose_params_from_overrides_knobs() -> None:
knobs = compose_params_from_overrides(
{
"pad": 400,
"fabric_bridges": "on",
"bridge_boost": 12,
"ideal_scale": 0.4,
"spring_iters": 100,
"merge_shared": False,
}
)
assert knobs["pad"] == 400.0
assert knobs["fabric_bridges"] is True
assert knobs["bridge_boost"] == 12.0
assert knobs["ideal_scale"] == 0.4
assert knobs["spring_iters"] == 100
assert knobs["merge_shared"] is False
def test_dual_portal_flip_shortens_external_bridge() -> None:
"""When two portals fix the chord, pick the bank that shortens fabric spokes."""
unit_a = ComposeBlock(
key="ua",
positions={
"p0": (0.0, 0.0),
"p1": (200.0, 0.0),
"t": (100.0, -160.0),
},
)
unit_b = ComposeBlock(
key="ub",
positions={
"p0": (0.0, 0.0),
"p1": (200.0, 0.0),
"b": (100.0, 120.0),
},
)
links = [("b", "t")]
merged, _meta = strip_pack_blocks(
[unit_a, unit_b], pad=40.0, merge_shared=True, links=links
)
db = math.hypot(merged["b"][0] - merged["t"][0], merged["b"][1] - merged["t"][1])
# Unflipped bank would put b roughly opposite t (~280); flipped ~40.
assert db < 120.0
def test_strip_pack_merge_shared_false_keeps_first_only() -> None:
a = ComposeBlock(key="va", positions={"shared": (0.0, 0.0), "a": (10.0, 0.0)})
b = ComposeBlock(key="vb", positions={"shared": (999.0, 999.0), "b": (0.0, 0.0)})
merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=False)
assert meta.get("merge_shared") is False
assert "shared" in merged and "b" in merged
def test_compose_into_state_and_run_layout() -> None:
nodes = [
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2", "x": 10, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 10, "y": 0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
blocks = [
ComposeBlock("va", {"a1": (0.0, 0.0), "a2": (100.0, 0.0)}),
ComposeBlock("vb", {"b1": (0.0, 0.0), "b2": (80.0, 0.0)}),
]
op = compose_into_state(st, blocks, LayoutParams(), pad=200.0)
assert op.op == "compose_views"
assert op.params.get("slots") == 2
assert abs(op.state.positions["a2"][0] - op.state.positions["a1"][0] - 100.0) < 1e-6
try:
run_layout_on_graph(
nodes,
edges,
action="compose_views",
params={
"_compose_blocks": [
{"key": "va", "positions": {"a1": [0, 0], "a2": [100, 0]}},
{"key": "vb", "positions": {"b1": [0, 0], "b2": [80, 0]}},
],
"pad": 200,
},
)
raise AssertionError("compose_views should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,76 @@
"""core_bar beam-first placement in sugiyama._place_component."""
from __future__ import annotations
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.sugiyama import _place_component
def _dual_core_multi_an():
"""Two cores on a bar, two ANs each with a small access petal."""
# c0 -- a0 -- e0
# | |
# c1 -- a1 -- e1
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a0", "b_node_id": "e2"},
{"a_node_id": "a1", "b_node_id": "e1"},
{"a_node_id": "a1", "b_node_id": "e3"},
]
return nodes, edges
def test_place_component_core_beam_collinear_cores() -> None:
nodes, edges = _dual_core_multi_an()
st = build_state_from_nodes_edges(nodes, edges)
assert st.layers["c0"] == "core" and st.layers["c1"] == "core"
assert st.layers["a0"] == "agg" and st.layers["a1"] == "agg"
ids = [n["fabric_node_id"] for n in nodes]
out = _place_component(ids, st, LayoutParams())
assert set(out) == set(ids)
# Cores approximately collinear on a horizontal beam
y0, y1 = out["c0"][1], out["c1"][1]
assert abs(y0 - y1) < 1.0
assert abs(out["c0"][0] - out["c1"][0]) > 100.0
# ANs hang off the beam (not packed into a core column): mean AN y away from beam
beam_y = (y0 + y1) / 2
an_y = (out["a0"][1] + out["a1"][1]) / 2
assert abs(an_y - beam_y) > 80.0
# Beam-first: both cores share one y; ANs are not co-located with cores as a column pack
assert abs(out["a0"][1] - beam_y) > 40.0
assert abs(out["a1"][1] - beam_y) > 40.0
def test_place_component_single_core_still_packs() -> None:
"""One core keeps the legacy AN-column pack path (no beam branch)."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c0", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a1", "b_node_id": "e1"},
]
st = build_state_from_nodes_edges(nodes, edges)
ids = [n["fabric_node_id"] for n in nodes]
out = _place_component(ids, st, LayoutParams())
assert set(out) == set(ids)
assert "c0" in out

View file

@ -0,0 +1,219 @@
"""Tests for inward densify_sweep top-3 / round / corridor scan."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.densify_sweep import (
apply_densify_pick,
densify_corridor_scan,
densify_sweep_node,
densify_sweep_round,
)
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.orbit_sweep import _incident_stretch
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
def _sparse_spoke():
"""Hub + three near stubs + one far spoke (stretchy)."""
nodes = [
{"fabric_node_id": "h", "name": "HHHHHH-EN-H", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a", "name": "AAAAAA-EN-A", "x": 180.0, "y": 0.0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-B", "x": 0.0, "y": 180.0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-C", "x": -180.0, "y": 0.0},
{"fabric_node_id": "far", "name": "FFFFFF-EN-F", "x": 2400.0, "y": 0.0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "h", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "far"},
]
return nodes, edges
def _two_island_bridge():
"""Two compact islands linked by a long bridge — corridor densify target."""
nodes = [
{"fabric_node_id": "a1", "name": "A1A1A1-EN-1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a2", "name": "A2A2A2-EN-2", "x": 160.0, "y": 0.0},
{"fabric_node_id": "a3", "name": "A3A3A3-EN-3", "x": 80.0, "y": 140.0},
{"fabric_node_id": "b1", "name": "B1B1B1-EN-1", "x": 4000.0, "y": 0.0},
{"fabric_node_id": "b2", "name": "B2B2B2-EN-2", "x": 4160.0, "y": 0.0},
{"fabric_node_id": "b3", "name": "B3B3B3-EN-3", "x": 4080.0, "y": 140.0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "a3"},
{"a_node_id": "a3", "b_node_id": "a1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "b3"},
{"a_node_id": "b3", "b_node_id": "b1"},
{"a_node_id": "a2", "b_node_id": "b1"},
]
return nodes, edges
def test_densify_sweep_node_pulls_inward() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
out = densify_sweep_node(
st,
"far",
protect_rigid="off",
max_pull=2000,
nn_floor=40.0,
)
assert out["ok"] is True
cands = out["candidates"]
assert 1 <= len(cands) <= 3
best = cands[0]
# Moved toward hub (x decreases).
assert best["x"] < 2400.0 - 50.0
assert best["crossings"]["global"] <= g0
assert best["delta"]["global"] <= 0
# Closer to hub than start.
d0 = 2400.0
d1 = abs(best["x"] - 0.0) # hub at 0
assert d1 < d0
def test_densify_nn_floor_rejects_crush() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# Huge nn_floor should leave few/no candidates (can't get near hub).
out = densify_sweep_node(
st,
"far",
protect_rigid="off",
max_pull=2000,
nn_floor=500.0,
)
assert out["ok"] is True
for c in out["candidates"]:
# Any accepted candidate must keep nn to all others ≥ floor.
trial = dict(st.positions)
trial["far"] = (c["x"], c["y"])
for oid, (ox, oy) in trial.items():
if oid == "far":
continue
d = ((c["x"] - ox) ** 2 + (c["y"] - oy) ** 2) ** 0.5
assert d >= 500.0 - 1e-3
def test_apply_densify_pick_moves() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
sweep = densify_sweep_node(st, "far", protect_rigid="off", max_pull=1800, nn_floor=40)
assert sweep["ok"] and sweep["candidates"]
op = apply_densify_pick(st, sweep, pick=1)
assert "far" in op.moved
chosen = sweep["candidates"][0]
assert abs(op.state.positions["far"][0] - chosen["x"]) < 0.2
def test_densify_round_lowers_stretch_not_x() -> None:
nodes, edges = _sparse_spoke()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
params = LayoutParams(target_nn=155.0)
stretch0 = _incident_stretch("far", st.positions, st.adj, 155.0)
op = densify_sweep_round(
st,
params=params,
top_n=4,
max_degree=9,
protect_rigid="off",
max_pull=2000,
nn_floor=40.0,
focus_ids=["far"],
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
stretch1 = _incident_stretch("far", op.state.positions, op.state.adj, 155.0)
if op.moved:
assert stretch1 < stretch0
def test_corridor_scan_raises_util_or_reverts_clean() -> None:
nodes, edges = _two_island_bridge()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "ua", "node_ids": ["a1", "a2", "a3"], "pivots": []},
{"key": "ub", "node_ids": ["b1", "b2", "b3"], "pivots": []},
]
st.meta["compose_views"] = {"rigid_groups": groups}
g0 = count_edge_crossings(st.positions, st.links)
op = densify_corridor_scan(
st,
groups=groups,
corridor_caps=[800.0, 1600.0, 3200.0],
pulls=[0.5, 0.65],
iters=4,
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
meta = op.params or {}
assert g1 <= g0 + int(meta.get("x_slack") or 5)
if not meta.get("reverted"):
assert float(meta.get("end_util") or 0) >= float(meta.get("start_util") or 0)
assert meta.get("chosen")
def test_run_layout_densify_unpublished() -> None:
nodes, edges = _sparse_spoke()
try:
run_layout_on_graph(
nodes,
edges,
action="densify_sweep",
params={"node_id": "far", "protect_rigid": "off", "max_pull": 1800, "nn_floor": 40},
)
raise AssertionError("densify_sweep should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_catalog_omits_densify_sweep() -> None:
cat = list_layout_catalog()
assert "densify_sweep" not in cat["actions"]
def test_densify_default_protect_off_may_move_shared_portal() -> None:
from netx_topology_mcp.layout_ops.densify_sweep import densify_params_from_overrides
knobs = densify_params_from_overrides({"node_id": "hub"})
assert knobs["protect_rigid"] == "off"
nodes = [
{"fabric_node_id": "hub", "name": "HUBHUB-EN-1", "x": 0.0, "y": 0.0},
{"fabric_node_id": "a1", "name": "AAAAAA-EN-1", "x": 200.0, "y": 0.0},
{"fabric_node_id": "b1", "name": "BBBBBB-EN-1", "x": 2000.0, "y": 0.0},
{"fabric_node_id": "b2", "name": "BBBBBB-EN-2", "x": 2200.0, "y": 0.0},
]
edges = [
{"a_node_id": "hub", "b_node_id": "a1"},
{"a_node_id": "hub", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "ua", "node_ids": ["hub", "a1"], "pivots": ["hub"]},
{"key": "ub", "node_ids": ["hub", "b1", "b2"], "pivots": ["hub"]},
]
st.meta["compose_views"] = {"rigid_groups": groups}
# Default off: shared portal may densify (not shared_portal error).
ok = densify_sweep_node(st, "hub", groups=groups, max_pull=1800, nn_floor=40)
assert ok["ok"] is True
# Opt-in freeze still works.
blocked = densify_sweep_node(
st, "hub", protect_rigid="portals", groups=groups, frozen_ids={"hub"}
)
assert blocked["ok"] is False
assert blocked.get("error") in {"frozen", "shared_portal"}

View file

@ -0,0 +1,169 @@
"""Same-canvas dual_mass: petal/straight beautify in place."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges, run_recipe
from netx_topology_mcp.layout_ops.dual_mass import layout_dual_mass
from netx_topology_mcp.layout_ops.dual_units import (
DualUnit,
beautify_dual_unit_positions,
classify_dual_unit,
)
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_tool import ACTIONS, RECIPE_ALIASES, run_layout_on_graph
def _two_eye_graph():
"""Two dual-portal petals sharing portal p2 + one leftover."""
nodes = [
{"fabric_node_id": "p1", "name": "AN-P1", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "AN-P2", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "p3", "name": "AN-P3", "role": "an", "x": 200, "y": 0},
{"fabric_node_id": "a1", "name": "EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "EN-C2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d1", "name": "EN-D1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "d2", "name": "EN-D2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "z1", "name": "EN-Z1", "role": "en", "x": 999, "y": 999},
]
edges = [
# eye p1–p2
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
# eye p2–p3
{"a_node_id": "p2", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p3"},
{"a_node_id": "p2", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "d2", "b_node_id": "p3"},
# leftover hanging off p3
{"a_node_id": "p3", "b_node_id": "z1"},
]
return nodes, edges
def test_recipe_alias_dual_mass() -> None:
# dual_mass kept as module; unpublished from public recipe/action surface.
assert "dual_mass" not in RECIPE_ALIASES
assert "dual_mass" not in ACTIONS
def test_classify_petal_and_straight() -> None:
petal = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
)
assert classify_dual_unit(petal) == "petal"
chain = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1"] + [f"n{i}" for i in range(10)] + ["p2"]],
)
assert classify_dual_unit(chain) == "straight"
short = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"]],
)
assert classify_dual_unit(short) == "straight"
def test_beautify_parallel_lanes() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
unit = DualUnit(
portal_a="p1",
portal_b="p2",
paths=[["p1", "a1", "a2", "p2"], ["p1", "b1", "b2", "p2"]],
)
pos = beautify_dual_unit_positions(st, unit, LayoutParams())
# Parallel H/V lanes: same y in a corridor; first/last share portal x.
assert abs(pos["a1"][1] - pos["a2"][1]) < 1e-6
assert abs(pos["b1"][1] - pos["b2"][1]) < 1e-6
assert abs(pos["a1"][1]) > 1.0 and abs(pos["b1"][1]) > 1.0
assert pos["a1"][1] * pos["b1"][1] < 0
assert abs(pos["a1"][0] - pos["p1"][0]) < 1e-6
assert abs(pos["a2"][0] - pos["p2"][0]) < 1e-6
assert abs(pos["b1"][0] - pos["p1"][0]) < 1e-6
assert abs(pos["b2"][0] - pos["p2"][0]) < 1e-6
def test_dual_mass_refine_reports_drift() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
# Seed a spread layout first (optional repark).
op1 = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
op2 = layout_dual_mass(
op1.state, LayoutParams(), mode="refine", rounds=2, stable_drift=1e9
)
assert op2.params.get("mode_first") == "refine"
assert int(op2.params.get("rounds_ran") or 0) >= 1
assert isinstance(op2.params.get("centroid_drift"), list)
kinds = op2.params.get("kinds") or {}
assert int(kinds.get("petal") or 0) >= 1
def test_layout_dual_mass_beautify_and_meta() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_mass(st, LayoutParams(), mode="full")
assert op.op == "layout_dual_mass"
assert int(op.params.get("units_n") or 0) >= 1
assert op.state.meta.get("dual_mass")
cv = op.state.meta.get("compose_views") or {}
assert cv.get("mass_groups")
assert op.state.meta.get("mass_field")
assert op.params.get("role") == "beautify"
p1 = op.state.positions["p1"]
p2 = op.state.positions["p2"]
dist = math.hypot(p1[0] - p2[0], p1[1] - p2[1])
assert dist >= 80.0
assert int(op.params.get("unit_ok") or 0) >= 1
def test_run_recipe_dual_mass_v1_unpublished() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "dual_mass_v1", LayoutParams())
raise AssertionError("dual_mass_v1 should be unpublished from RECIPES")
except Exception as e:
assert "dual_mass" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
def test_run_layout_on_graph_dual_mass_unpublished() -> None:
nodes, edges = _two_eye_graph()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="dual_mass")
raise AssertionError("dual_mass recipe should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_layout_dual_mass_module_still_works() -> None:
nodes, edges = _two_eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_mass(st, LayoutParams(), mode="full", mass_merge=False)
assert op.op == "layout_dual_mass"
pos = op.state.positions
assert "p1" in pos and "p2" in pos
members = {"p1", "p2", "a1", "a2", "b1", "b2"}
links = [
(e["a_node_id"], e["b_node_id"])
for e in edges
if e["a_node_id"] in members and e["b_node_id"] in members
]
assert count_edge_crossings({k: pos[k] for k in members}, links) == 0

View file

@ -0,0 +1,121 @@
"""Dual-portal eye units: detect, zero-cross layout, shared-portal compose."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, 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
from netx_topology_mcp.layout_structure import analyze_graph_structure
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def _eye_graph():
"""Two AN portals + three interior-disjoint access corridors."""
nodes = [
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "t1", "name": "BTM-EN-T1", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p2"},
{"a_node_id": "a1", "b_node_id": "t1"}, # tail off corridor
]
return nodes, edges
def test_actions_include_layout_dual_unit() -> None:
assert "layout_dual_unit" in ACTIONS
def test_find_dual_portal_units_eye() -> None:
nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
units = find_dual_portal_units(st)
assert len(units) >= 1
u = units[0]
assert {u.portal_a, u.portal_b} == {"p1", "p2"}
assert len(u.paths) >= 2
interiors = set()
for p in u.paths:
interiors |= set(p[1:-1])
assert "a1" in interiors or "b1" in interiors or "c1" in interiors
def test_layout_dual_unit_zero_crossings() -> None:
nodes, edges = _eye_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = layout_dual_unit(st, LayoutParams())
assert op.params.get("accepted") is True
members = set()
unit = op.params.get("unit") or {}
members.update(unit.get("node_ids") or [])
unit_links = [e for e in op.state.links if e[0] in members and e[1] in members]
x = count_edge_crossings(op.state.positions, unit_links)
assert x == 0
out = run_layout_on_graph(nodes, edges, action="layout_dual_unit")
assert out["ok"] is True
assert out["action"] == "layout_dual_unit"
loc = out.get("local") or {}
assert loc.get("accepted") is True
assert int((loc.get("op") or {}).get("unit_crossings") or 0) == 0
def test_structure_reports_dual_units() -> None:
nodes, edges = _eye_graph()
report = analyze_graph_structure(nodes, edges)
du = report.get("dual_units") or {}
assert int(du.get("unit_count") or 0) >= 1
assert report.get("advice", {}).get("prefer_dual_units") is True or du.get(
"unit_count", 0
) >= 1
def test_compose_merge_shared_portal_unique_coord() -> None:
"""Two units sharing portal p2 → one world coord for p2; B rigidly glued."""
a = ComposeBlock(
key="unit-p1-p2",
positions={
"p1": (0.0, 0.0),
"p2": (200.0, 0.0),
"a1": (100.0, 80.0),
},
)
b = ComposeBlock(
key="unit-p2-p3",
positions={
"p2": (0.0, 0.0),
"p3": (200.0, 0.0),
"b1": (100.0, -60.0),
},
)
merged, meta = strip_pack_blocks([a, b], pad=100.0, merge_shared=True)
assert meta.get("merge_shared") is True
assert "p2" in merged and "p1" in merged and "p3" in merged
# Relative offset p2→p3 preserved after rigid glue (200 on x in local B).
dx = merged["p3"][0] - merged["p2"][0]
dy = merged["p3"][1] - merged["p2"][1]
assert abs(dx - 200.0) < 1e-6
assert abs(dy) < 1e-6
# b1 relative to p2 preserved.
assert abs(merged["b1"][0] - merged["p2"][0] - 100.0) < 1e-6
assert abs(merged["b1"][1] - merged["p2"][1] + 60.0) < 1e-6

View file

@ -0,0 +1,52 @@
"""edge_axis: prefer H/V edges, horizontal over vertical."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import compute_edge_axis
from netx_topology_mcp.layout_stats import analyze_layout_stats
def test_all_horizontal_scores_one() -> None:
pos = {"a": (0.0, 0.0), "b": (200.0, 0.0), "c": (400.0, 2.0)} # 2px ≈ H
links = [("a", "b"), ("b", "c")]
m = compute_edge_axis(pos, links)
assert m["horiz_n"] == 2
assert m["diag_n"] == 0
assert float(m["edge_axis_score"]) == 1.0
def test_diagonal_scores_zero() -> None:
pos = {"a": (0.0, 0.0), "b": (100.0, 100.0)}
m = compute_edge_axis(pos, [("a", "b")])
assert m["diag_n"] == 1
assert float(m["edge_axis_score"]) == 0.0
assert m["top_skew_edges"]
def test_horizontal_beats_vertical() -> None:
h = compute_edge_axis(
{"a": (0.0, 0.0), "b": (200.0, 0.0)},
[("a", "b")],
)
v = compute_edge_axis(
{"a": (0.0, 0.0), "b": (0.0, 200.0)},
[("a", "b")],
)
assert float(h["edge_axis_score"]) > float(v["edge_axis_score"])
assert float(v["edge_axis_score"]) == 0.75
def test_analyze_report_includes_edge_axis() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 0.0},
{"fabric_node_id": "b", "name": "B", "x": 100.0, "y": 100.0},
{"fabric_node_id": "c", "name": "C", "x": 200.0, "y": 0.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
m = analyze_layout_stats(nodes, edges)
assert "edge_axis" in m["score"]["parts"]
assert m["report"]["edge_axis"]["status"] in {"warn", "fail"}
assert int(m["diag_n"] or 0) >= 1

View file

@ -0,0 +1,89 @@
"""Tests for fold_chain sector sweep + tentacle fold."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops.fold_chain import fold_chain_into_sector
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
def _star_with_tentacle() -> tuple[list[dict], list[dict]]:
"""Hub H with ring of leaves + long deg2 tentacle S0..S4."""
nodes = [
{"fabric_node_id": "H", "name": "PLG-HUB-AN1-Z", "x": 0, "y": 0},
{"fabric_node_id": "A", "name": "PLG-AAA-EN1-Z", "x": 200, "y": 0},
{"fabric_node_id": "B", "name": "PLG-BBB-EN1-Z", "x": 0, "y": 200},
{"fabric_node_id": "C", "name": "PLG-CCC-EN1-Z", "x": -200, "y": 0},
{"fabric_node_id": "D", "name": "PLG-DDD-EN1-Z", "x": 0, "y": -200},
# tentacle stretched far east (pierces through ring)
{"fabric_node_id": "S0", "name": "PLG-S0-EN1-Z", "x": 80, "y": 0},
{"fabric_node_id": "S1", "name": "PLG-S1-EN1-Z", "x": 260, "y": 0},
{"fabric_node_id": "S2", "name": "PLG-S2-EN1-Z", "x": 440, "y": 0},
{"fabric_node_id": "S3", "name": "PLG-S3-EN1-Z", "x": 620, "y": 0},
{"fabric_node_id": "S4", "name": "PLG-S4-EN1-Z", "x": 800, "y": 0},
# filler leaf in south gap candidate
{"fabric_node_id": "E", "name": "PLG-EEE-EN1-Z", "x": 140, "y": 140},
{"fabric_node_id": "F", "name": "PLG-FFF-EN1-Z", "x": -140, "y": 140},
]
edges = [
{"a_node_id": "H", "b_node_id": "A"},
{"a_node_id": "H", "b_node_id": "B"},
{"a_node_id": "H", "b_node_id": "C"},
{"a_node_id": "H", "b_node_id": "D"},
{"a_node_id": "H", "b_node_id": "E"},
{"a_node_id": "H", "b_node_id": "F"},
{"a_node_id": "H", "b_node_id": "S0"},
{"a_node_id": "S0", "b_node_id": "S1"},
{"a_node_id": "S1", "b_node_id": "S2"},
{"a_node_id": "S2", "b_node_id": "S3"},
{"a_node_id": "S3", "b_node_id": "S4"},
]
return nodes, edges
def test_fold_chain_places_whole_chain_on_arc() -> None:
nodes, edges = _star_with_tentacle()
st = build_state_from_nodes_edges(nodes, edges)
op = fold_chain_into_sector(
st,
LayoutParams(target_nn=155.0),
hub_id="H",
stub_id="S0",
prefer_mid_deg=90.0,
r_arc=300.0,
chord=180.0,
)
meta = op.state.meta["fold_chain"]
assert meta["hub_id"] == "H"
assert meta["chain"] == ["S0", "S1", "S2", "S3", "S4"]
assert len(meta["folded"]) == 5
hx, hy = op.state.positions["H"]
radii = [
math.hypot(op.state.positions[n][0] - hx, op.state.positions[n][1] - hy)
for n in meta["chain"]
]
assert all(abs(r - 300.0) < 1.0 for r in radii)
# Chain should not remain as a straight east ray
assert op.state.positions["S4"][0] < 700
def test_fold_chain_action_unpublished() -> None:
nodes, edges = _star_with_tentacle()
try:
run_layout_on_graph(
nodes,
edges,
action="fold_chain",
params={"hub_id": "H", "stub_id": "S0", "prefer_mid_deg": 120},
)
raise AssertionError("fold_chain should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_catalog_omits_fold_chain() -> None:
cat = list_layout_catalog()
assert "fold_chain" not in cat["actions"]

View file

@ -0,0 +1,141 @@
"""Force densify: edge attract + local repulse + semi-rigid bodies."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops.force_densify import force_densify_round
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_tool import ACTIONS
def test_actions_include_force_densify() -> None:
assert "force_densify" not in ACTIONS
def _sparse_two_units():
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 4200, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 4400, "y": 80},
{"fabric_node_id": "b3", "name": "B3", "x": 4600, "y": -60},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2", "b3"]),
]
)
st.meta = {"compose_views": {"rigid_groups": groups}}
return st, groups
def test_force_densify_shrinks_bridge() -> None:
st, groups = _sparse_two_units()
L0 = math.hypot(
st.positions["b1"][0] - st.positions["a2"][0],
st.positions["b1"][1] - st.positions["a2"][1],
)
before = score_state(st, fast=True)
util0 = float((before.get("summary") or {}).get("util") or 0.0)
op = force_densify_round(
st,
groups=groups,
iters=16,
step=0.5,
max_step=280.0,
ideal_len=280.0,
nn_floor=90.0,
attract_k=1.4,
repulse_k=0.6,
gravity_k=0.9,
rigid_strength=0.95,
deform=0.05,
protect_rigid="off",
x_slack=500,
)
assert op.op == "force_densify"
assert not (op.params or {}).get("reverted"), op.note
L1 = math.hypot(
op.state.positions["b1"][0] - op.state.positions["a2"][0],
op.state.positions["b1"][1] - op.state.positions["a2"][1],
)
assert L1 < L0 * 0.92, f"bridge not shortened: {L0:.0f}->{L1:.0f}"
after = score_state(op.state, fast=True)
util1 = float((after.get("summary") or {}).get("util") or 0.0)
assert util1 >= util0 - 1e-6
def test_edge_spring_pushes_crushed_edge() -> None:
"""Too-short edge should lengthen (push), not only pull long ones."""
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 40, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 2000, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
L0 = math.hypot(
st.positions["b"][0] - st.positions["a"][0],
st.positions["b"][1] - st.positions["a"][1],
)
op = force_densify_round(
st,
groups=[],
iters=12,
step=0.45,
max_step=80.0,
ideal_len=160.0,
nn_floor=80.0,
attract_k=1.5,
repulse_k=0.4,
gravity_k=0.0,
rigid_strength=0.0,
deform=0.0,
protect_rigid="off",
x_slack=200,
)
L1 = math.hypot(
op.state.positions["b"][0] - op.state.positions["a"][0],
op.state.positions["b"][1] - op.state.positions["a"][1],
)
assert L1 > L0 + 15.0, f"crushed edge not pushed: {L0:.1f}->{L1:.1f}"
def test_semi_rigid_preserves_internal_ratios() -> None:
st, groups = _sparse_two_units()
def _pair(pos):
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
return d12 / max(d13, 1e-6)
r0 = _pair(st.positions)
op = force_densify_round(
st,
groups=groups,
iters=10,
rigid_strength=1.0,
deform=0.0,
ideal_len=180.0,
attract_k=1.2,
x_slack=500,
)
r1 = _pair(op.state.positions)
assert abs(r1 - r0) < 0.05, f"rigid shape drifted: {r0:.3f}->{r1:.3f}"

View file

@ -0,0 +1,123 @@
"""Tests for stage-2 hierarchy_sectors (contract → order → expand)."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.hierarchy import (
_candidate_orders,
_contracted_crossings,
_contracted_links,
_stub_territories,
hierarchy_sectors_greedy,
)
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def _crossed_star():
"""Hub with 4 stubs whose geo order crosses two inter-stub chords."""
# Hub at origin; stubs placed so angular order A,C,B,D crosses A-B with C-D.
nodes = [
{"fabric_node_id": "h", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "X-AN0-Y", "x": 200, "y": -40},
{"fabric_node_id": "b", "name": "X-AN1-Y", "x": 40, "y": 200},
{"fabric_node_id": "c", "name": "X-AN2-Y", "x": -200, "y": 40},
{"fabric_node_id": "d", "name": "X-AN3-Y", "x": -40, "y": -200},
{"fabric_node_id": "a1", "name": "X-EN0-Y", "x": 360, "y": -60},
{"fabric_node_id": "b1", "name": "X-EN1-Y", "x": 60, "y": 360},
{"fabric_node_id": "c1", "name": "X-EN2-Y", "x": -360, "y": 60},
{"fabric_node_id": "d1", "name": "X-EN3-Y", "x": -60, "y": -360},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "h", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "d"},
{"a_node_id": "a", "b_node_id": "a1"},
{"a_node_id": "b", "b_node_id": "b1"},
{"a_node_id": "c", "b_node_id": "c1"},
{"a_node_id": "d", "b_node_id": "d1"},
# Cross chords between territories (contracted as a↔b and c↔d or similar)
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
return nodes, edges
def test_actions_include_hierarchy_sectors() -> None:
assert "hierarchy_sectors" not in ACTIONS
def test_contracted_links_ignore_internal() -> None:
nodes, edges = _crossed_star()
st = build_state_from_nodes_edges(nodes, edges)
members = set(st.positions)
stubs, owner = _stub_territories("h", members, st.adj, pinned={"h"})
assert set(stubs) == {"a", "b", "c", "d"}
assert owner["a1"] == "a"
links = _contracted_links("h", stubs, owner, st.adj, pinned={"h"})
# Internal a—a1 must not appear; hub—stub and inter-stub do.
assert ("a", "a1") not in links
assert ("a", "h") in links
assert ("a", "b") in links
def test_candidate_orders_cover_rotations() -> None:
stubs = ["a", "b", "c", "d"]
pos = {
"h": (0.0, 0.0),
"a": (1.0, 0.0),
"b": (0.0, 1.0),
"c": (-1.0, 0.0),
"d": (0.0, -1.0),
}
orders = _candidate_orders(stubs, pos, "h")
assert len(orders) >= 4
assert any(o[0] == "a" for o in orders)
def test_contracted_crossings_sensitive_to_order() -> None:
links = [("a", "h"), ("b", "h"), ("c", "h"), ("d", "h"), ("a", "b"), ("c", "d")]
good = _contracted_crossings(
"h",
["a", "b", "c", "d"],
links,
radius=100.0,
hub_xy=(0.0, 0.0),
a0=-3.0,
a1=3.0,
)
# Force a bad interleaved order on the same arc.
bad = _contracted_crossings(
"h",
["a", "c", "b", "d"],
links,
radius=100.0,
hub_xy=(0.0, 0.0),
a0=-3.0,
a1=3.0,
)
assert bad >= good
def test_hierarchy_sectors_accepts_on_contracted_gain() -> None:
nodes, edges = _crossed_star()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = hierarchy_sectors_greedy(st, LayoutParams())
assert op.op == "hierarchy_sectors"
assert "accepted_n" in op.params
# Gate is contracted, not global: if a level was accepted, contracted fell.
for lvl in op.params.get("levels") or []:
assert lvl["contracted_after"] < lvl["contracted_before"]
# Smoke: crossings countable after expand.
assert count_edge_crossings(op.state.positions, op.state.links) >= 0
def test_run_layout_on_graph_hierarchy_sectors_unpublished() -> None:
nodes, edges = _crossed_star()
try:
run_layout_on_graph(nodes, edges, action="hierarchy_sectors")
raise AssertionError("hierarchy_sectors should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,314 @@
"""Local hotspot / overlap fix tests."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
fix_overlaps_local,
relax_hotspots,
)
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_tool import run_layout_on_graph
def test_fix_overlaps_local_pulls_apart() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 3, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 2000, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
assert len(overlapping_nodes(st)) >= 2
far_before = st.positions["c"]
out = fix_overlaps_local(st, LayoutParams(overlap_iters=200, overlap_step=5.0))
assert len(overlapping_nodes(out.state)) == 0
# distant node should barely move (outside 1-hop of a-b... c is 1-hop from b)
# c may move if in expand; at least a/b separated
assert abs(out.state.positions["a"][0] - out.state.positions["b"][0]) > 10
def test_run_layout_action_pack_utilization_unpublished() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="pack_utilization",
preset="balanced",
params={
"target_util": 0.15,
"pack_min_scale": 0.2,
"pack_iters": 6,
"shrink_corridors": True,
"corridor_cap": 2000,
"pull": 0.6,
},
)
raise AssertionError("pack_utilization should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_pack_utilization_transform_still_works() -> None:
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_ops.transforms import pack_utilization
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 8000, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 16000, "y": 0},
{"fabric_node_id": "d", "name": "DDDDDD-AN-1", "x": 8000, "y": 8000},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
st = build_state_from_nodes_edges(nodes, edges)
op = pack_utilization(
st,
LayoutParams(target_util=0.15, pack_min_scale=0.2, pack_iters=6),
)
assert op.op == "pack_utilization"
xs = [p[0] for p in op.state.positions.values()]
ys = [p[1] for p in op.state.positions.values()]
assert max(xs) - min(xs) < 16000
assert max(ys) - min(ys) < 8000
local = {"mode": (op.params or {}).get("mode"), "op": op.params or {}}
op = local.get("op") or {}
# Corridor shrink reports bbox_area_ratio; uniform reports util_after.
if op.get("bbox_area_ratio") is not None:
assert float(op["bbox_area_ratio"]) >= 1.0
else:
assert float(op.get("util_after") or 0) > float(op.get("util_before") or 0)
def test_shrink_long_corridors_pulls_islands() -> None:
from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors
# Two dense islands connected by one long bridge.
nodes = [
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 8000, "y": 8000},
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 8200, "y": 8000},
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 8000, "y": 8200},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a1", "b_node_id": "a3"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
{"a_node_id": "a1", "b_node_id": "b1"}, # long corridor
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
d0 = math.hypot(
st.positions["a1"][0] - st.positions["b1"][0],
st.positions["a1"][1] - st.positions["b1"][1],
)
out = shrink_long_corridors(
st,
edge_len_cap=1200,
pull=0.6,
iters=3,
min_island=3,
max_bridges=4,
accept_crossings=False,
)
d1 = math.hypot(
out.state.positions["a1"][0] - out.state.positions["b1"][0],
out.state.positions["a1"][1] - out.state.positions["b1"][1],
)
assert d1 < d0 * 0.55
# Intra-island spacing preserved.
assert abs(
math.hypot(
out.state.positions["a1"][0] - out.state.positions["a2"][0],
out.state.positions["a1"][1] - out.state.positions["a2"][1],
)
- 200.0
) < 1.0
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.5
def test_shrink_long_corridors_unit_exclusive() -> None:
from netx_topology_mcp.layout_ops.rigid_units import shrink_long_corridors
# Shared portal + two exclusive clusters far apart (metro dual-unit shape).
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "a3", "name": "A3-EN-3", "x": 0, "y": 200},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 10000, "y": 10000},
{"fabric_node_id": "b2", "name": "B2-EN-2", "x": 10200, "y": 10000},
{"fabric_node_id": "b3", "name": "B3-EN-3", "x": 10000, "y": 10200},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a1", "b_node_id": "a3"},
{"a_node_id": "p", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "u0", "node_ids": ["p", "a1", "a2", "a3"], "pivots": ["p"]},
{"key": "u1", "node_ids": ["p", "b1", "b2", "b3"], "pivots": ["p"]},
]
d0 = math.hypot(
st.positions["a1"][0] - st.positions["b1"][0],
st.positions["a1"][1] - st.positions["b1"][1],
)
out = shrink_long_corridors(
st,
edge_len_cap=1500,
pull=0.55,
iters=4,
min_island=3,
max_bridges=4,
groups=groups,
accept_crossings=True,
)
d1 = math.hypot(
out.state.positions["a1"][0] - out.state.positions["b1"][0],
out.state.positions["a1"][1] - out.state.positions["b1"][1],
)
assert (out.params or {}).get("island_mode") == "unit_exclusive"
assert d1 < d0 * 0.75
assert float((out.params or {}).get("bbox_area_ratio") or 0) > 1.2
# Portal may move less / stay; exclusives keep relative spacing.
assert abs(
math.hypot(
out.state.positions["a1"][0] - out.state.positions["a2"][0],
out.state.positions["a1"][1] - out.state.positions["a2"][1],
)
- 200.0
) < 1.0
def test_densify_rigid_groups_raises_util() -> None:
from netx_topology_mcp.layout_ops.rigid_units import densify_rigid_groups
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 5000, "y": 5000},
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 10000, "y": 10000},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 10200, "y": 10000},
]
edges = [
{"a_node_id": "p", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "p", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
groups = [
{"key": "u0", "node_ids": ["p", "a", "b"], "pivots": ["p"]},
{"key": "u1", "node_ids": ["p", "c", "d"], "pivots": ["p"]},
]
xs0 = [st.positions[n][0] for n in st.positions]
ys0 = [st.positions[n][1] for n in st.positions]
area0 = (max(xs0) - min(xs0)) * (max(ys0) - min(ys0))
out = densify_rigid_groups(st, groups, scale=0.5)
xs1 = [out.state.positions[n][0] for n in out.state.positions]
ys1 = [out.state.positions[n][1] for n in out.state.positions]
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
assert area1 < area0 * 0.85
# Intra-unit gap preserved (a-b still ~200).
assert abs(
math.hypot(
out.state.positions["a"][0] - out.state.positions["b"][0],
out.state.positions["a"][1] - out.state.positions["b"][1],
)
- 200.0
) < 1.0
def test_run_layout_pack_compress_long_edges_unpublished() -> None:
nodes = [
{"fabric_node_id": "p", "name": "PORTAL-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 5000, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 0, "y": 5000},
]
edges = [
{"a_node_id": "p", "b_node_id": "a"},
{"a_node_id": "p", "b_node_id": "b"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="pack_utilization",
preset="balanced",
params={
"portal_ids": ["p"],
"shrink_corridors": True,
"corridor_cap": 900,
"pull": 0.6,
"compress_iters": 4,
},
)
raise AssertionError("pack_utilization should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_layout_action_fix_overlaps() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 2, "y": 1},
{"fabric_node_id": "c", "name": "CCCCCC-AN-1", "x": 400, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
out = run_layout_on_graph(nodes, edges, action="fix_overlaps", preset="balanced")
assert out["ok"] is True
assert out["action"] == "fix_overlaps"
assert out["overlap"]["footprint_pairs"] == 0
# Should not rebuild corridor skeleton to origin-scale extremes
xs = [p["x"] for p in out["positions"]]
assert max(xs) - min(xs) < 5000
def test_relax_hotspots_moves_only_dense() -> None:
# 6 coinciding + 1 disconnected far island
nodes = [
{"fabric_node_id": f"n{i}", "name": f"N{i}-EN-x", "x": float(i % 2), "y": 0.0}
for i in range(6)
]
nodes.append({"fabric_node_id": "far", "name": "FAR-EN-z", "x": 8000.0, "y": 0.0})
nodes.append({"fabric_node_id": "far2", "name": "FAR2-EN-z", "x": 8200.0, "y": 0.0})
edges = [{"a_node_id": f"n{i}", "b_node_id": f"n{i+1}"} for i in range(5)]
edges.append({"a_node_id": "far", "b_node_id": "far2"})
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
far0 = st.positions["far"]
out = relax_hotspots(st, LayoutParams(cluster_gap=40.0, target_util=0.1))
# disconnected far island should stay put (not in dense hotspot)
assert abs(out.state.positions["far"][0] - far0[0]) < 1.0

View file

@ -0,0 +1,180 @@
"""Background layout jobs + slim payload helpers."""
from __future__ import annotations
import time
from unittest.mock import patch
from netx_topology_mcp.http_tools import _layout_topology_view, _slim_layout_payload
from netx_topology_mcp.layout_jobs import (
cancel_job,
get_job,
is_cancelled,
job_public,
report_progress,
start_job,
)
def test_start_job_and_poll() -> None:
def runner() -> dict:
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
time.sleep(0.05)
return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3}
jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1})
assert jid
saw_progress = False
for _ in range(40):
pub = job_public(jid)
assert pub is not None
assert "elapsed_ms" in pub
assert "progress" in pub
assert "heartbeat_age_ms" in pub
if (pub.get("progress") or {}).get("phase") == "mid":
saw_progress = True
if pub["status"] != "running":
break
time.sleep(0.05)
assert saw_progress
job = get_job(jid)
assert job is not None
assert job["status"] == "done"
assert job["result"]["applied"] is True
st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert st.get("ok") is True
assert st.get("status") == "done"
assert st.get("result", {}).get("applied") is True
assert isinstance(st.get("progress"), dict)
def test_cancel_job_cooperative() -> None:
started = time.time()
def runner() -> dict:
report_progress("work", pct=10.0, message="before sleep")
for _ in range(40):
if is_cancelled():
from netx_topology_mcp.layout_jobs import raise_if_cancelled
raise_if_cancelled()
time.sleep(0.05)
return {"ok": True, "applied": True}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
time.sleep(0.08)
out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}})
assert out.get("ok") is True
assert out.get("cancel_requested") is True
for _ in range(40):
pub = job_public(jid)
assert pub is not None
if pub["status"] == "cancelled":
break
time.sleep(0.05)
pub = job_public(jid)
assert pub is not None
assert pub["status"] == "cancelled"
assert time.time() - started < 3.0
def test_slim_layout_payload_drops_guide() -> None:
fat = {
"ok": True,
"action": "compose_views",
"node_count": 1200,
"guide": {"how_to_read": "x" * 200},
"tried": [1, 2, 3],
"params_used": {"lane": 1},
"crossing": {
"status": "fail",
"score": 0,
"edge_crossings": 9,
"crossings_per_link": 0.1,
"top_nodes": [{"id": "a"}] * 20,
"top_edges": [{"id": "e"}] * 20,
"tip": "long",
},
"summary": {"total": 40},
}
slim = _slim_layout_payload(fat)
assert slim.get("slim") is True
assert "guide" not in slim
assert "tried" not in slim
assert len(slim["crossing"]["top_nodes"]) <= 5
def test_compose_background_returns_job_id() -> None:
src = [f"s{i:02d}" for i in range(12)]
def fake_start(**kwargs):
assert kwargs["action"] == "compose_views"
assert kwargs["view_id"] == "full"
assert isinstance(kwargs.get("tool_args"), dict)
assert kwargs["tool_args"].get("action") == "compose_views"
return "abc123job"
with patch("netx_topology_mcp.http_tools.start_job", side_effect=fake_start):
out = _layout_topology_view(
{
"view_id": "full",
"action": "compose_views",
"mode": "apply",
"params": {"source_view_ids": src},
}
)
assert out.get("ok") is True
assert out.get("status") == "running"
assert out.get("job_id") == "abc123job"
assert out.get("applied") is False
def test_job_status_missing() -> None:
out = _layout_topology_view({"action": "job_status", "params": {}})
assert out.get("ok") is False
assert out.get("error") == "job_id_required"
def test_job_status_unknown() -> None:
out = _layout_topology_view(
{"action": "job_status", "params": {"job_id": "does-not-exist"}}
)
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_cancel_missing() -> None:
out = cancel_job("missing-job")
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_soft_stale_keeps_running_and_cancelable() -> None:
"""stale is a poll warning only — must not freeze status or block cancel."""
from netx_topology_mcp import layout_jobs as lj
def runner() -> dict:
time.sleep(0.3)
return {"ok": True, "applied": False}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
with lj._LOCK: # noqa: SLF001 — test injects silent heartbeat on disk
job = lj._read_job_disk(jid) or lj._JOBS[jid]
job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5)
lj._JOBS[jid] = job
lj._write_job_disk(job)
pub = job_public(jid)
assert pub is not None
assert pub["stale"] is True
assert pub["status"] == "running"
cancelled = cancel_job(jid)
assert cancelled.get("ok") is True
assert cancelled.get("status") == "cancelling"
for _ in range(40):
pub2 = job_public(jid)
assert pub2 is not None
if pub2["status"] in {"cancelled", "done", "error"}:
break
time.sleep(0.05)
assert job_public(jid)["status"] == "cancelled"

View file

@ -0,0 +1,525 @@
"""Unit tests for composable layout_ops atoms."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_metrics import grade_layout
from netx_topology_mcp.layout_ops import (
LayoutParams,
build_state_from_nodes_edges,
run_recipe,
score_state,
)
from netx_topology_mcp.layout_ops.transforms import (
resolve_overlaps,
scale_region,
select_pins,
)
def _line_graph(n: int = 6, gap: float = 40.0):
nodes = []
edges = []
for i in range(n):
nodes.append(
{
"fabric_node_id": f"n{i}",
"name": f"X-EN{i}-Y",
"x": i * gap,
"y": 0.0,
}
)
if i:
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
return nodes, edges
def test_scale_region_only_unpinned() -> None:
nodes, edges = _line_graph(4, gap=100.0)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.pinned = {"n0", "n1"}
before = dict(st.positions)
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=True)
assert out.state.positions["n0"] == before["n0"]
assert out.state.positions["n1"] == before["n1"]
assert out.state.positions["n2"] != before["n2"]
assert "n2" in out.moved
def test_resolve_overlaps_clears_stack() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "CCCCCC", "x": 200, "y": 200},
]
edges = [{"a_node_id": "a", "b_node_id": "c"}]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.pinned = {"c"}
m0 = score_state(st)
assert m0["footprint_overlap_pairs"] >= 1
out = resolve_overlaps(st, LayoutParams(overlap_iters=120, overlap_step=12.0))
m1 = score_state(out.state)
assert m1["footprint_overlap_pairs"] == 0
assert out.state.positions["c"] == st.positions["c"]
def test_select_pins_modes() -> None:
nodes, edges = _line_graph(8)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st.layers = {n["fabric_node_id"]: "access" for n in nodes}
st.layers["n0"] = "agg"
st.spine = {"n1", "n2", "n3"}
r = select_pins(st, mode="spine")
assert "n0" in r.state.pinned
assert "n1" in r.state.pinned
def test_grade_hard_zero_overlap() -> None:
m = {
"node_count": 10,
"link_count": 9,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 2,
"label_overlap_pairs": 0,
"nn_p50": 160,
"space_utilization": 0.2,
}
g = grade_layout(m)
assert g["spacing_grade"] == "fail"
g2 = grade_layout(m, ume_reference=True)
assert g2["spacing_grade"] in {"ok", "warn", "fail"}
def test_select_scope_limits_moves() -> None:
from netx_topology_mcp.layout_ops.scope import select_scope
from netx_topology_mcp.layout_ops.transforms import scale_region
nodes, edges = _line_graph(6, gap=100.0)
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
st = select_scope(st, mode="ids", node_ids={"n0", "n1", "n2"}).state
before = dict(st.positions)
out = scale_region(st, LayoutParams(), sx=2.0, sy=1.0, only_unpinned=False)
assert out.state.positions["n5"] == before["n5"]
assert out.state.positions["n0"] != before["n0"]
def test_explode_clusters_breaks_stack() -> None:
nodes = [
{"fabric_node_id": "a", "name": "X-EN0-A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "X-EN1-B", "x": 1, "y": 0},
{"fabric_node_id": "c", "name": "X-EN2-C", "x": 400, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "c"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
from netx_topology_mcp.layout_ops.transforms import explode_clusters
out = explode_clusters(st, LayoutParams(), thr=5.0, gap=50.0)
d = math.hypot(
out.state.positions["a"][0] - out.state.positions["b"][0],
out.state.positions["a"][1] - out.state.positions["b"][1],
)
assert d >= 40.0
def test_place_on_rect_edge_axis_aligned() -> None:
from netx_topology_mcp.layout_ops.sugiyama import _place_on_rect_edge
order = [f"e{i}" for i in range(8)]
pos = _place_on_rect_edge(order, 0.0, 0.0, 400.0, 200.0)
assert len(pos) == 8
for x, y in pos.values():
on_h = (abs(y - 0.0) < 1e-6 or abs(y - 200.0) < 1e-6) and -1e-6 <= x <= 400.0 + 1e-6
on_v = (abs(x - 0.0) < 1e-6 or abs(x - 400.0) < 1e-6) and -1e-6 <= y <= 200.0 + 1e-6
assert on_h or on_v
def test_extract_dangling_feeder_leaf_to_attach() -> None:
from netx_topology_mcp.layout_ops.sugiyama import _extract_dangling_feeders
# Leaf e4 → … → attach e0 (on ring/hub). Not a ring corridor.
ens = [f"e{i}" for i in range(5)]
adj = {
"e0": {"e1", "r1", "r2"},
"e1": {"e0", "e2"},
"e2": {"e1", "e3"},
"e3": {"e2", "e4"},
"e4": {"e3"},
"r1": {"e0", "r2"},
"r2": {"e0", "r1"},
}
names = {n: n for n in adj}
chains = _extract_dangling_feeders(
ens + ["r1", "r2"], adj, names, attach_set={"e0", "r1", "r2"}, min_len=2
)
assert chains
long = max(chains, key=len)
assert long[0] == "e4"
assert long[-1] == "e0"
assert long == ["e4", "e3", "e2", "e1", "e0"]
def test_eject_portal_neighbor_outside_ring() -> None:
"""PNBR-like stub on a portal must leave the ring interior."""
from netx_topology_mcp.layout_ops.sugiyama import _eject_intruders_from_rings
from netx_topology_mcp.layout_ops.state import LayoutParams
# Trapezoid ring a—b with bottom corridor; stub s attached to a sits inside.
groups = [
{
"portals": ("a", "b"),
"paths": [["a", "b"], ["a", "p1", "p2", "b"]],
}
]
pos = {
"a": (0.0, 0.0),
"b": (400.0, 0.0),
"p1": (100.0, 150.0),
"p2": (300.0, 150.0),
"s": (120.0, 40.0), # inside polygon
}
adj = {"a": {"b", "p1", "s"}, "b": {"a", "p2"}, "p1": {"a", "p2"}, "p2": {"p1", "b"}, "s": {"a"}}
edges = [("a", "b"), ("a", "p1"), ("p1", "p2"), ("p2", "b"), ("a", "s")]
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
# Outside: left of portal a (outer side away from b).
assert out["s"][0] < out["a"][0]
def test_triangle_ring_stub_must_not_pierce_chord() -> None:
"""Minimal triangle ring: portal stub edge must not cross the third side."""
from netx_topology_mcp.layout_ops.sugiyama import (
_eject_intruders_from_rings,
_stub_crosses_ring,
)
from netx_topology_mcp.layout_ops.state import LayoutParams
# Triangle a-v-b with direct a—b; stub s on b pierces a—v.
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
pos = {
"a": (0.0, 0.0),
"b": (300.0, 0.0),
"v": (150.0, 120.0),
"s": (50.0, 80.0), # s—b crosses a—v
"out": (-200.0, 0.0),
}
adj = {
"a": {"b", "v"},
"b": {"a", "v", "s"},
"v": {"a", "b"},
"s": {"b", "out"},
"out": {"s"},
}
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("b", "s"), ("s", "out")]
assert _stub_crosses_ring("s", "b", pos, ["a", "b", "v"])
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
assert not _stub_crosses_ring("s", "b", out, ["a", "b", "v"])
def test_triangle_apex_flips_off_foreign_chord() -> None:
"""VOTI-like apex must sit on the side that foreign portal edges do not hit."""
from netx_topology_mcp.layout_ops.sugiyama import _orient_ring_sides
from netx_topology_mcp.layout_metrics import segments_properly_intersect
groups = [{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]}]
# Foreign f—b runs under the chord; apex v below is pierced / crossed.
pos = {
"a": (0.0, 0.0),
"b": (300.0, 0.0),
"v": (150.0, 120.0),
"f": (-100.0, 60.0),
}
edges = [("a", "b"), ("a", "v"), ("v", "b"), ("f", "b")]
assert segments_properly_intersect(pos["a"], pos["v"], pos["f"], pos["b"])
out = _orient_ring_sides(
pos, groups, edges, pinned={"a", "b", "v"}, max_interiors=3, push=40.0
)
assert out["v"][1] < 0.0 # flipped above the chord
assert not segments_properly_intersect(out["a"], out["v"], out["f"], out["b"])
def test_eject_one_ring_must_not_pierce_another() -> None:
"""Ejecting from a large ring must not park the stub through a triangle."""
from netx_topology_mcp.layout_ops.sugiyama import (
_eject_intruders_from_rings,
_stub_crosses_ring,
)
from netx_topology_mcp.layout_ops.state import LayoutParams
# Big ring a—b via p1-p2; triangle a-v-b; stub s inside big ring.
groups = [
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "p1", "p2", "b"]]},
{"portals": ("a", "b"), "paths": [["a", "b"], ["a", "v", "b"]]},
]
pos = {
"a": (0.0, 0.0),
"b": (400.0, 0.0),
"p1": (80.0, 200.0),
"p2": (320.0, 200.0),
"v": (200.0, -120.0),
"s": (120.0, 40.0), # inside big ring; naive eject can cross a—v
}
adj = {
"a": {"b", "p1", "v", "s"},
"b": {"a", "p2", "v"},
"p1": {"a", "p2"},
"p2": {"p1", "b"},
"v": {"a", "b"},
"s": {"a"},
}
edges = [
("a", "b"),
("a", "p1"),
("p1", "p2"),
("p2", "b"),
("a", "v"),
("v", "b"),
("a", "s"),
]
out = _eject_intruders_from_rings(pos, groups, adj, LayoutParams(), edges)
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "v"])
assert not _stub_crosses_ring("s", "a", out, ["a", "b", "p2", "p1"])
def test_an_side_ring_not_mistaken_for_feeder() -> None:
"""AN—EN ring leg must be a ring unit, not a dangling feeder (no X with chain)."""
from netx_topology_mcp.layout_ops.sugiyama import (
_extract_dangling_feeders,
_find_two_portal_ring_groups,
_ring_nodes_from_groups,
)
# SPB(AN)-TNM direct + SPB-SRIN-ADAK-JROS-TNM; chain TNM-TNMS-SMDA
nodes = {
"spb": "X-AN-SPB",
"tnm": "X-EN-TNM",
"tnms": "X-EN-TNMS",
"smda": "X-EN-SMDA",
"jros": "X-EN-JROS",
"adak": "X-EN-ADAK",
"srin": "X-EN-SRIN",
}
adj = {
"spb": {"tnm", "srin", "other"},
"tnm": {"spb", "tnms", "jros"},
"tnms": {"tnm", "smda"},
"smda": {"tnms"},
"jros": {"tnm", "adak"},
"adak": {"jros", "srin"},
"srin": {"adak", "spb"},
"other": {"spb"},
}
ens = ["tnm", "tnms", "smda", "jros", "adak", "srin"]
an_set = {"spb"}
groups = _find_two_portal_ring_groups(ens, adj, nodes, an_set)
assert groups
ring = _ring_nodes_from_groups(groups)
assert {"tnm", "jros", "adak", "srin", "spb"} <= ring
attach = ring | an_set
feeders = _extract_dangling_feeders(
ens, adj, nodes, attach, min_len=2, an_set=an_set
)
bodies = {n for f in feeders for n in f[:-1]}
# Ring leg must not be consumed as a feeder body.
assert not ({"jros", "adak", "srin"} & bodies)
# True dangling chain off TNM remains.
assert any(f[0] == "smda" or "smda" in f for f in feeders)
def test_two_portal_rings_nest_smallest_inner() -> None:
"""Shared portals: shortest corridor innermost, longer outward."""
from netx_topology_mcp.layout_ops.sugiyama import (
_find_two_portal_ring_groups,
_place_two_portal_ring_groups,
)
from netx_topology_mcp.layout_ops.state import LayoutParams
# Portals p1,p2 with three disjoint corridors (sizes 1 / 2 / 3 mids).
ens = ["p1", "p2", "a1", "b1", "b2", "c1", "c2", "c3"]
adj = {
"p1": {"a1", "b1", "c1", "an"},
"p2": {"a1", "b2", "c3", "x"},
"a1": {"p1", "p2"},
"b1": {"p1", "b2"},
"b2": {"b1", "p2"},
"c1": {"p1", "c2"},
"c2": {"c1", "c3"},
"c3": {"c2", "p2"},
"an": {"p1"},
"x": {"p2"},
}
names = {n: n for n in list(adj)}
groups = _find_two_portal_ring_groups(ens, adj, names, {"an"})
assert groups
g0 = next(g for g in groups if set(g["portals"]) == {"p1", "p2"})
paths = g0["paths"]
assert len(paths) >= 2
# Shortest path first.
assert all(len(paths[i]) <= len(paths[i + 1]) for i in range(len(paths) - 1))
pos = {
"p1": (0.0, 0.0),
"p2": (600.0, 0.0),
"a1": (300.0, 10.0),
"b1": (200.0, 20.0),
"b2": (400.0, 20.0),
"c1": (150.0, 30.0),
"c2": (300.0, 30.0),
"c3": (450.0, 30.0),
}
edges = [("p1", "a1"), ("a1", "p2"), ("p1", "b1"), ("b1", "b2"), ("b2", "p2"),
("p1", "c1"), ("c1", "c2"), ("c2", "c3"), ("c3", "p2")]
out, pinned = _place_two_portal_ring_groups(
pos, ens, adj, names, {"an"}, LayoutParams(), edges
)
assert pinned
# Trapezoid nest: shortest path closer to midline; outer band wider.
my = (out["p1"][1] + out["p2"][1]) / 2
assert abs(out["a1"][1] - my) <= abs(out["c2"][1] - my) + 1e-6
outer_span = abs(out["c3"][0] - out["c1"][0])
mid_span = abs(out["b2"][0] - out["b1"][0])
assert outer_span + 1e-6 >= mid_span
def test_chain_first_keeps_feeder_sequential() -> None:
"""Pure feeder chain stays a coherent polyline after rings recipe."""
nodes = [{"fabric_node_id": "an", "name": "X-AN1-Y"}]
edges = []
for i in range(5):
nodes.append({"fabric_node_id": f"e{i}", "name": f"X-EN{i}-Y"})
edges.append({"a_node_id": "an", "b_node_id": "e0"})
for i in range(4):
edges.append({"a_node_id": f"e{i}", "b_node_id": f"e{i+1}"})
st = build_state_from_nodes_edges(nodes, edges)
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
assert len(st2.positions) == 6
# Consecutive chain edges should stay roughly axis-aligned (not hop-scrambled).
axis = 0
for i in range(4):
a, b = st2.positions[f"e{i}"], st2.positions[f"e{i+1}"]
dx, dy = abs(a[0] - b[0]), abs(a[1] - b[1])
if dx < 1e-6 or dy < 1e-6:
axis += 1
assert axis >= 3
assert int(final.get("edge_crossings") or 0) == 0
def test_agg_rings_recipe_places_dual_agg_ring() -> None:
# Two ANs + 4-EN ring + one chain stub → dual-hub min-rings
nodes = [
{"fabric_node_id": "an1", "name": "X-AN1-Y"},
{"fabric_node_id": "an2", "name": "X-AN2-Y"},
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
{"fabric_node_id": "e3", "name": "X-EN3-Y"},
{"fabric_node_id": "e4", "name": "X-EN4-Y"},
]
edges = [
{"a_node_id": "an1", "b_node_id": "an2"},
{"a_node_id": "an1", "b_node_id": "e0"},
{"a_node_id": "an2", "b_node_id": "e2"},
{"a_node_id": "e0", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
{"a_node_id": "e2", "b_node_id": "e3"},
{"a_node_id": "e3", "b_node_id": "e0"},
{"a_node_id": "e1", "b_node_id": "e4"},
]
st = build_state_from_nodes_edges(nodes, edges)
params = LayoutParams()
st2, trace, final = run_recipe(st, "agg_rings_v1", params)
assert any(t.get("op") == "build_ring_skeleton" for t in trace)
assert st2.meta.get("rings_mode") == "min_rings"
assert len(st2.positions) == 7
# Dual hubs on a horizontal bar; corridors nest above/below
assert abs(st2.positions["an1"][1] - st2.positions["an2"][1]) < 1e-6
assert st2.positions["an1"][0] < st2.positions["an2"][0]
assert "edge_crossings" in final
assert int(final.get("edge_crossings") or 0) == 0
assert int(final.get("footprint_overlap_pairs") or 0) == 0
def test_min_rings_dual_hub_parallel_paths_zero_cross() -> None:
"""Classic dual-AN parallel corridors + side stubs → 0 crossings via rings."""
# anL / anR with 3 disjoint corridors + left stub chain off anL
nodes = [
{"fabric_node_id": "anL", "name": "Z-PLAU-AN1-X"},
{"fabric_node_id": "anR", "name": "Z-ATP-AN1-X"},
{"fabric_node_id": "orphan", "name": "Z-BSR-AN1-X"},
]
edges = []
# short upper: anL-a1-a2-anR
for i, nid in enumerate(["a1", "a2"]):
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "anR"},
]
# mid lower: anL-b1-b2-b3-anR
for nid in ["b1", "b2", "b3"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "b3"},
{"a_node_id": "b3", "b_node_id": "anR"},
]
# long upper-outer: anL-c1..c4-anR
for nid in ["c1", "c2", "c3", "c4"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "c3"},
{"a_node_id": "c3", "b_node_id": "c4"},
{"a_node_id": "c4", "b_node_id": "anR"},
]
# side stub off anL
for nid in ["s1", "s2"]:
nodes.append({"fabric_node_id": nid, "name": f"Z-{nid.upper()}-EN1-X"})
edges += [
{"a_node_id": "anL", "b_node_id": "s1"},
{"a_node_id": "s1", "b_node_id": "s2"},
]
st = build_state_from_nodes_edges(nodes, edges)
st2, _, final = run_recipe(st, "agg_rings_v1", LayoutParams())
assert st2.meta.get("rings_mode") == "min_rings"
assert int(final.get("edge_crossings") or 0) == 0
assert int(final.get("footprint_overlap_pairs") or 0) == 0
# orphan AN parked, not on the bar mid
assert "orphan" in st2.positions
assert abs(st2.positions["anL"][1] - st2.positions["anR"][1]) < 1e-6
def test_tiny_recipe_runs() -> None:
# Mini AN+EN star for skeleton path
nodes = [
{"fabric_node_id": "an", "name": "X-AN1-Y"},
{"fabric_node_id": "e0", "name": "X-EN0-Y"},
{"fabric_node_id": "e1", "name": "X-EN1-Y"},
{"fabric_node_id": "e2", "name": "X-EN2-Y"},
]
edges = [
{"a_node_id": "an", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
{"a_node_id": "e1", "b_node_id": "e2"},
]
st = build_state_from_nodes_edges(nodes, edges)
params = LayoutParams(width_mul=2.0, height_mul=1.5, lane=120.0, target_util=0.05)
st2, trace, final = run_recipe(st, "smd_corridor_v1", params)
assert len(trace) >= 5
assert len(st2.positions) == 4
assert "edge_crossings" in final
assert "space_utilization" in final
# Zero-overlap is a tuning target of resolve_overlaps; recipe must complete.
assert all("op" in t for t in trace)

View file

@ -0,0 +1,34 @@
"""Tests for agent layout sight (crossing hotspots / blocks)."""
from __future__ import annotations
from netx_topology_mcp.layout_sight import build_sight, list_crossings
def test_list_crossings_and_drag_candidates() -> None:
# Two segments that properly cross: a—b horizontal, c—d vertical.
nodes = [
{"fabric_node_id": "a", "name": "X-A-Y", "x": 0.0, "y": 50.0},
{"fabric_node_id": "b", "name": "X-B-Y", "x": 100.0, "y": 50.0},
{"fabric_node_id": "c", "name": "X-C-Y", "x": 50.0, "y": 0.0},
{"fabric_node_id": "d", "name": "X-D-Y", "x": 50.0, "y": 100.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
out = list_crossings(nodes, edges, limit=10)
assert out["crossings_listed"] == 1
assert out["crossings"][0]["e1"]["a_name"] == "A"
assert len(out.get("top_nodes") or []) == 4
assert out["top_nodes"][0]["crossing_hits"] >= 1
assert len(out.get("top_edges") or []) == 2
assert out["top_edges"][0]["crossing_hits"] >= 1
assert out["drag_candidates"]
cand = out["drag_candidates"][0]
assert "suggest_xy" in cand and len(cand["suggest_xy"]) >= 1
assert "delta_crossings_est" in cand
assert "x" in cand["suggest_xy"][0] and "delta_crossings_est" in cand["suggest_xy"][0]
sight = build_sight(nodes, edges, mode="both", limit=10, cell=200.0)
assert "hotspots" in sight and "blocks" in sight
assert sight["blocks"]["blocks"]

View file

@ -0,0 +1,132 @@
"""Tests for topology layout statistics / composite score."""
from __future__ import annotations
from netx_topology_mcp.layout_stats import (
analyze_layout_stats,
build_layout_report,
score_layout_components,
)
def test_dense_vs_sparse_score() -> None:
# Compact 2x2 grid at recommended pitch
dense_nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
]
dense = analyze_layout_stats(dense_nodes, edges)
sparse_nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 20000, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 17000},
{"fabric_node_id": "d", "name": "D", "x": 20000, "y": 17000},
]
sparse = analyze_layout_stats(sparse_nodes, edges)
assert dense["space_utilization"] > sparse["space_utilization"]
assert dense["whitespace_index"] < sparse["whitespace_index"]
assert dense["score"]["total"] > sparse["score"]["total"]
assert dense["grid_occupancy"] > sparse["grid_occupancy"]
def test_overlap_hard_gates_score() -> None:
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "BBBBBB", "x": 5, "y": 0},
]
edges = [{"a_node_id": "a", "b_node_id": "b"}]
m = analyze_layout_stats(nodes, edges)
assert m["footprint_overlap_pairs"] >= 1
assert m["score"]["parts"]["overlap"] == 0.0
assert m["score"]["total"] < 20.0
def test_score_components_rank_key() -> None:
good = {
"node_count": 100,
"edge_crossings": 10,
"crossings_per_link": 0.05,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
}
s = score_layout_components(good)
assert s["total"] > 50
assert s["rank_key"][0] == 0
def test_top_crossing_nodes_in_summary() -> None:
# Classic X: AC and BD cross at center → all four endpoints participate.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
m = analyze_layout_stats(nodes, edges)
assert m["edge_crossings"] == 1
top = m.get("top_crossing_nodes") or []
assert len(top) == 4
assert all(int(r["crossing_hits"]) >= 1 for r in top)
assert (m.get("summary") or {}).get("top_crossing")
assert (m.get("report") or {}).get("crossing", {}).get("top_nodes")
top_e = m.get("top_crossing_edges") or []
assert len(top_e) == 2
assert all(int(r["crossing_hits"]) == 1 for r in top_e)
assert (m.get("summary") or {}).get("top_crossing_edges")
assert (m.get("report") or {}).get("crossing", {}).get("top_edges")
def test_unified_report_facets() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 170},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 170},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
]
full = analyze_layout_stats(nodes, edges)
r = full["report"]
assert set(r) >= {
"verdict",
"size",
"overlap",
"crossing",
"spacing",
"sparsity",
"edges",
"chains",
"rings",
"score",
"guide",
}
assert r["overlap"]["status"] == "ok"
assert r["sparsity"]["status"] in {"ok", "warn", "fail"}
assert "chain" in r["score"]["parts"]
assert "rings" in r["score"]["parts"]
built = build_layout_report(full)
assert built["verdict"]["total"] == r["verdict"]["total"]

View file

@ -0,0 +1,123 @@
"""Structure / gravity detection for layout planning."""
from __future__ import annotations
from netx_topology_mcp.layout_structure import analyze_graph_structure
def _nodes_edges_core_bar():
"""Two cores own access rings; ANs are low-degree decorative."""
nodes = [
{"fabric_node_id": "c1", "name": "X-CN1-a", "role": "core", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "X-CN2-a", "role": "core", "x": 200, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-a", "role": "aggregation", "x": 100, "y": 50},
{"fabric_node_id": "a2", "name": "X-AN2-a", "role": "aggregation", "x": 300, "y": 50},
]
edges = [
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "c2", "b_node_id": "a2"},
]
# Access petals hanging on cores
for i in range(12):
eid = f"e{i}"
nodes.append(
{
"fabric_node_id": eid,
"name": f"X-EN{i}-a",
"role": "access",
"x": i * 40,
"y": 200,
}
)
hub = "c1" if i < 6 else "c2"
edges.append({"a_node_id": hub, "b_node_id": eid})
if i % 2 == 1:
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
return nodes, edges
def _nodes_edges_agg_bar():
"""No cores; ANs own access rings."""
nodes = [
{"fabric_node_id": "a1", "name": "Y-AN1-a", "role": "aggregation", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "Y-AN2-a", "role": "aggregation", "x": 400, "y": 0},
]
edges = [{"a_node_id": "a1", "b_node_id": "a2"}]
for i in range(16):
eid = f"e{i}"
nodes.append(
{
"fabric_node_id": eid,
"name": f"Y-EN{i}-a",
"role": "access",
"x": i * 30,
"y": 200,
}
)
hub = "a1" if i < 8 else "a2"
edges.append({"a_node_id": hub, "b_node_id": eid})
if i % 2 == 1:
edges.append({"a_node_id": f"e{i - 1}", "b_node_id": eid})
return nodes, edges
def test_core_bar_gravity() -> None:
nodes, edges = _nodes_edges_core_bar()
s = analyze_graph_structure(nodes, edges)
assert s["gravity"]["type"] == "core_bar"
assert s["gravity"]["anchor_layer"] == "core"
assert "agg" in s["gravity"]["decorative_layers"] or s["layers"]["agg"]["territory_frac"] < 0.2
assert s["gravity"]["recipe_preference"][0] in {"compact", "corridor"}
assert s["advice"]["skip_rings_first"] is True
assert s["gravity"]["geometry_hint"] in {"core_center", "core_top"}
def test_agg_bar_gravity() -> None:
nodes, edges = _nodes_edges_agg_bar()
s = analyze_graph_structure(nodes, edges)
assert s["gravity"]["type"] == "agg_bar"
assert s["gravity"]["anchor_layer"] == "agg"
assert s["gravity"]["recipe_preference"][0] == "rings"
assert s["advice"]["skip_rings_first"] is False
assert len(s["hubs"]) >= 2
def test_hubs_include_stubs() -> None:
nodes, edges = _nodes_edges_agg_bar()
s = analyze_graph_structure(nodes, edges, hub_top_k=4, stub_top_k=10)
assert s["hubs"][0]["access_neighbors"] >= 1
assert s["stubs"]
assert "headline" in s
def _nodes_edges_chains():
"""Several path components (SID-TOB-like): mostly deg≤2, not hub-spoke."""
nodes: list[dict] = []
edges: list[dict] = []
# three chains of lengths 12 / 8 / 5; one has a CN/AN on the path (still chain)
chains = [
[("c0", "Z-CN1-a", "core")]
+ [(f"a{i}", f"Z-EN{i}-a", "access") for i in range(1, 12)],
[(f"b{i}", f"Y-EN{i}-a", "access") for i in range(8)],
[(f"d{i}", f"X-EN{i}-a", "access") for i in range(5)],
]
for ch in chains:
for fid, name, role in ch:
nodes.append({"fabric_node_id": fid, "name": name, "role": role, "x": 0, "y": 0})
for i in range(len(ch) - 1):
edges.append({"a_node_id": ch[i][0], "b_node_id": ch[i + 1][0]})
return nodes, edges
def test_chains_gravity_before_hub_fallback() -> None:
nodes, edges = _nodes_edges_chains()
s = analyze_graph_structure(nodes, edges)
assert s["shape"]["primary"] == "chains"
assert s["gravity"]["type"] == "chains"
assert s["gravity"]["geometry_hint"] == "chain_rows"
assert s["gravity"]["recipe_preference"][0] == "corridor"
assert s["advice"]["skip_rings_first"] is True
assert s["advice"]["decompose_by_component"] is True
assert s["shape"]["component_count"] == 3
assert all(b["shape"] in {"chain", "tiny"} for b in s["shape"]["blocks"])

View file

@ -0,0 +1,185 @@
"""Tests for layoutTopologyView MCP wrapper."""
from __future__ import annotations
import json
from unittest.mock import patch
from netx_topology_mcp.http_tools import call_http_tool
from netx_topology_mcp.layout_tool import build_params, list_layout_catalog, resolve_recipe, run_layout_on_graph
def test_catalog_and_aliases() -> None:
cat = list_layout_catalog()
assert "rings" in cat["recipes"]
assert "compact" in cat["recipes"]
assert "corridor" in cat["recipes"]
assert "unstick" in cat["recipes"]
assert "compact_soft" not in cat["recipes"]
assert "ortho_metro" not in cat["recipes"]
assert "dual_mass" not in cat["recipes"]
assert "balanced" in cat["presets"]
assert "fix_overlaps" in cat["actions"]
assert "orbit_sweep" in cat["actions"]
assert "polish_crossings" in cat["actions"]
assert "clear_edge_hits" in cat["actions"]
assert "mass_merge" not in cat["actions"]
assert "compose_orbit" not in cat["actions"]
assert resolve_recipe("rings") == "agg_rings_v1"
assert resolve_recipe("compact") == "smd_corridor_compact_v1"
assert resolve_recipe("corridor") == "smd_corridor_v1"
try:
resolve_recipe("compact_soft")
raise AssertionError("compact_soft should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
p = build_params(preset="dense")
assert p.target_util >= 0.12
def test_run_layout_on_small_graph() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 10, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 20, "y": 0},
{"fabric_node_id": "d", "name": "D-EN-3", "x": 30, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
]
out = run_layout_on_graph(nodes, edges, recipe="corridor", preset="balanced")
assert out["ok"] is True
assert len(out["positions"]) == 4
assert "verdict" in out
assert out["overlap"]["status"] in {"ok", "warn", "fail"}
def test_layout_topology_view_catalog_tool() -> None:
out = call_http_tool("layoutTopologyView", {"catalog": True})
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert "recipes" in payload
assert "rings" in payload["recipes"]
assert "compact" in payload["recipes"]
assert "actions" in payload
assert "fix_overlaps" in payload["actions"]
def test_layout_topology_view_preview() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
],
}
with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "recipe": "corridor", "preset": "loose", "mode": "preview"},
)
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["applied"] is False
assert payload["mode"] == "preview"
assert payload["verdict"]["total"] is not None
def test_local_polish_refuses_crossing_regression() -> None:
"""untangle apply must not silently worsen crossings."""
nodes = [
{"fabric_node_id": "h", "name": "H-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A-EN-1", "x": -200, "y": -40},
{"fabric_node_id": "b", "name": "B-EN-2", "x": 200, "y": -40},
{"fabric_node_id": "c", "name": "C-EN-3", "x": -200, "y": 200},
{"fabric_node_id": "d", "name": "D-EN-4", "x": 200, "y": 200},
{"fabric_node_id": "e", "name": "E-EN-5", "x": 0, "y": 100},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "h", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "d"},
{"a_node_id": "a", "b_node_id": "d"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "e", "b_node_id": "a"},
{"a_node_id": "e", "b_node_id": "b"},
]
graph = {"view": {"id": "v1", "name": "demo", "folder_id": "f1"}, "nodes": nodes, "edges": edges}
bad = run_layout_on_graph(nodes, edges, action="untangle")
worse = dict(bad)
worse["crossing"] = {**(bad.get("crossing") or {}), "edge_crossings": 9999}
worse["positions"] = bad["positions"]
def fake_http(method, path, body=None, **_kw):
if method == "GET":
return {"ok": True, "data": graph}
raise AssertionError("PATCH must not run on crossing regression")
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http):
with patch(
"netx_topology_mcp.http_tools.run_layout_on_graph",
return_value=worse,
):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "action": "untangle", "mode": "apply"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("ok") is False
assert payload.get("error") == "crossing_regression"
assert payload.get("applied") is False
def test_unpublished_action_rejected() -> None:
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "action": "mass_merge", "mode": "preview"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("ok") is False
assert "unknown_action" in str(payload.get("error") or "")
assert "mass_merge" not in (payload.get("actions") or {})
def test_layout_topology_view_apply_patches() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "A-EN-1", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B-AN-1", "x": 5, "y": 0},
{"fabric_node_id": "c", "name": "C-EN-2", "x": 5, "y": 5},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
],
}
calls: list[tuple] = []
def fake_http(method, path, body=None, **_kw):
calls.append((method, path, body))
if method == "GET":
return {"ok": True, "data": graph}
return {"ok": True, "data": {"updated": len((body or {}).get("positions") or [])}}
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http):
out = call_http_tool(
"layoutTopologyView",
{"view_id": "v1", "recipe": "compact", "preset": "balanced", "mode": "apply"},
)
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["applied"] is True
assert payload["updated"] >= 3
assert any(m == "PATCH" for m, _p, _b in calls)

View file

@ -0,0 +1,331 @@
"""Mass field roles + soft merge (attract/repulse instead of rigid islands)."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops.dual_units import DualUnit
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.mass_field import (
annotate_dual_unit,
build_mass_field,
capture_pass,
evolve_chains_to_rings,
)
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round
from netx_topology_mcp.layout_tool import ACTIONS
def test_actions_include_mass_merge() -> None:
assert "mass_merge" not in ACTIONS
def test_annotate_dual_unit_roles() -> None:
unit = DualUnit(
portal_a="pa",
portal_b="pb",
paths=[
["pa", "r1", "r2", "pb"],
["pa", "r3", "r4", "pb"],
["pa", "r5", "r6", "r7", "pb"],
],
tails=[["t1", "t2"]],
unit_id=0,
)
ann = annotate_dual_unit(unit)
assert ann["nest_depth"] == 3
assert ann["nodes"]["pa"]["role"] == "core"
assert ann["nodes"]["r1"]["role"] == "ring"
assert ann["nodes"]["t1"]["role"] == "chain"
assert ann["nodes"]["r1"]["attract"] > ann["nodes"]["t1"]["attract"]
assert ann["nodes"]["pa"]["attract"] > ann["nodes"]["r1"]["attract"]
def _two_block_state():
# Strong left dual eye + weak right chain attached by bridge.
nodes = [
{"fabric_node_id": "pa", "name": "PA", "x": 0, "y": 0, "layer": "agg"},
{"fabric_node_id": "pb", "name": "PB", "x": 400, "y": 0, "layer": "agg"},
{"fabric_node_id": "r1", "name": "R1", "x": 120, "y": 80, "layer": "access"},
{"fabric_node_id": "r2", "name": "R2", "x": 280, "y": 80, "layer": "access"},
{"fabric_node_id": "r3", "name": "R3", "x": 120, "y": -80, "layer": "access"},
{"fabric_node_id": "r4", "name": "R4", "x": 280, "y": -80, "layer": "access"},
{"fabric_node_id": "t1", "name": "T1", "x": 500, "y": 0, "layer": "access"},
{"fabric_node_id": "t2", "name": "T2", "x": 700, "y": 0, "layer": "access"},
{"fabric_node_id": "w1", "name": "W1", "x": 2200, "y": 40, "layer": "access"},
{"fabric_node_id": "w2", "name": "W2", "x": 2400, "y": -40, "layer": "access"},
{"fabric_node_id": "wc", "name": "WC", "x": 2000, "y": 0, "layer": "agg"},
]
edges = [
{"a_node_id": "pa", "b_node_id": "r1"},
{"a_node_id": "r1", "b_node_id": "r2"},
{"a_node_id": "r2", "b_node_id": "pb"},
{"a_node_id": "pa", "b_node_id": "r3"},
{"a_node_id": "r3", "b_node_id": "r4"},
{"a_node_id": "r4", "b_node_id": "pb"},
{"a_node_id": "pb", "b_node_id": "t1"},
{"a_node_id": "t1", "b_node_id": "t2"},
{"a_node_id": "t2", "b_node_id": "wc"},
{"a_node_id": "wc", "b_node_id": "w1"},
{"a_node_id": "wc", "b_node_id": "w2"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = [
{
"key": "strong",
"node_ids": ["pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"],
"pivots": ["pa", "pb"],
"cores": ["pa", "pb"],
"soft": True,
},
{
"key": "weak",
"node_ids": ["wc", "w1", "w2"],
"pivots": ["wc"],
"cores": ["wc"],
"soft": True,
},
]
unit = DualUnit(
portal_a="pa",
portal_b="pb",
paths=[
["pa", "r1", "r2", "pb"],
["pa", "r3", "r4", "pb"],
],
tails=[["t1", "t2"]],
unit_id=0,
)
mass = build_mass_field(st, units=[unit], groups=groups)
st.meta = {
"compose_views": {
"rigid_groups": groups,
"mass_groups": groups,
"soft": True,
},
"mass_field": mass,
}
return st, groups, mass
def test_build_mass_field_tags_chain_and_bridge() -> None:
st, groups, mass = _two_block_state()
assert mass["nodes"]["t1"]["role"] == "chain"
assert mass["nodes"]["r1"]["role"] == "ring"
bridge = mass["edges"].get("t2|wc") or mass["edges"].get("wc|t2")
assert bridge is not None
assert bridge["role"] == "bridge"
def test_capture_can_steal_chain_toward_strong_core() -> None:
st, groups, mass = _two_block_state()
# Pull weak leaves next to strong core so capture fires.
st.positions["w1"] = (450.0, 20.0)
st.positions["w2"] = (460.0, -20.0)
st.positions["wc"] = (480.0, 0.0)
mass2, report = capture_pass(
st,
mass,
kappa_node=1.0,
kappa_block=1.5,
rho_ideal=8.0,
ideal_len=200.0,
)
home = {}
for g in mass2["groups"]:
for n in g["node_ids"]:
if n not in ("pa", "pb", "wc"):
home.setdefault(n, g["key"])
# At least some movement of membership or block absorb.
assert report["stolen_nodes"] + report["stolen_blocks"] >= 1 or any(
home.get(n) == "strong" for n in ("w1", "w2")
)
def test_evolve_chain_to_ring_when_second_corridor() -> None:
# Single corridor + dangling path that completes a second corridor.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0, "layer": "agg"},
{"fabric_node_id": "b", "name": "B", "x": 300, "y": 0, "layer": "agg"},
{"fabric_node_id": "u1", "name": "U1", "x": 100, "y": 60, "layer": "access"},
{"fabric_node_id": "u2", "name": "U2", "x": 200, "y": 60, "layer": "access"},
{"fabric_node_id": "d1", "name": "D1", "x": 100, "y": -60, "layer": "access"},
{"fabric_node_id": "d2", "name": "D2", "x": 200, "y": -60, "layer": "access"},
]
edges = [
{"a_node_id": "a", "b_node_id": "u1"},
{"a_node_id": "u1", "b_node_id": "u2"},
{"a_node_id": "u2", "b_node_id": "b"},
{"a_node_id": "a", "b_node_id": "d1"},
{"a_node_id": "d1", "b_node_id": "d2"},
{"a_node_id": "d2", "b_node_id": "b"},
]
st = build_state_from_nodes_edges(nodes, edges)
# Pretend lower path was tagged chain initially.
mass = {
"nodes": {
"a": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
"b": {"role": "core", "attract": 4.0, "repulse": 1.8, "mass": 3.0},
"u1": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
"u2": {"role": "ring", "attract": 2.4, "repulse": 1.2, "mass": 1.8},
"d1": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
"d2": {"role": "chain", "attract": 0.7, "repulse": 0.6, "mass": 0.5},
},
"edges": {},
"groups": [],
}
out = evolve_chains_to_rings(st, mass)
# If dual-unit finder sees both corridors, d1/d2 should promote.
if (out.get("evolve") or {}).get("promoted_nodes", 0) > 0:
assert out["nodes"]["d1"]["role"] == "ring"
assert out["nodes"]["d1"]["attract"] > 0.7
def test_mass_merge_shortens_bridge() -> None:
st, groups, _mass = _two_block_state()
L0 = math.hypot(
st.positions["wc"][0] - st.positions["t2"][0],
st.positions["wc"][1] - st.positions["t2"][1],
)
op = mass_merge_round(
st,
groups=groups,
iters=14,
step=0.45,
max_step=220.0,
ideal_len=280.0,
nn_floor=90.0,
attract_k=1.2,
repulse_k=0.7,
gravity_k=0.6,
group_sep_k=0.4,
global_gravity_k=0.0,
core_pull_k=0.8,
protect_rigid="off",
evolve_every=4,
kappa_node=1.1,
kappa_block=1.8,
x_slack=800,
capture=False,
cluster_seed=False,
use_dual_units=True,
)
assert op.op == "mass_merge"
assert not (op.params or {}).get("reverted"), op.note
L1 = math.hypot(
op.state.positions["wc"][0] - op.state.positions["t2"][0],
op.state.positions["wc"][1] - op.state.positions["t2"][1],
)
assert L1 < L0 * 0.95 or (op.params or {}).get("end_geo", 0) >= (
op.params or {}
).get("start_geo", 0)
def test_mass_merge_cluster_centroids_round_pack() -> None:
"""Dual-unit clusters reseated on a round pack (not a strip lattice)."""
st, groups, _mass = _two_block_state()
# Force a strip of group centroids.
for n in ("pa", "pb", "r1", "r2", "r3", "r4", "t1", "t2"):
st.positions[n] = (st.positions[n][0], 0.0)
for n in ("wc", "w1", "w2"):
st.positions[n] = (st.positions[n][0] + 5000.0, 5.0)
op = mass_merge_round(
st,
groups=groups,
iters=8,
fa2=True,
use_dual_units=True,
cluster_seed=True,
sep_ideal=800.0,
group_sep_k=1.2,
global_gravity_k=0.4,
capture=False,
x_slack=2000,
)
assert (op.params or {}).get("use_dual_units") is True
assert not (op.params or {}).get("reverted"), op.note
# Two cluster centroids should not sit on a long horizontal line only.
c0 = (
0.5 * (op.state.positions["pa"][0] + op.state.positions["pb"][0]),
0.5 * (op.state.positions["pa"][1] + op.state.positions["pb"][1]),
)
c1 = (
(op.state.positions["wc"][0] + op.state.positions["w1"][0] + op.state.positions["w2"][0])
/ 3.0,
(op.state.positions["wc"][1] + op.state.positions["w1"][1] + op.state.positions["w2"][1])
/ 3.0,
)
assert math.hypot(c0[0] - c1[0], c0[1] - c1[1]) > 200.0
def test_mass_merge_fa2_spreads_stacks() -> None:
"""FA2 mode: coincident nodes get unpacked; lin-log + long-range repulse run."""
st, groups, _mass = _two_block_state()
for n in st.positions:
st.positions[n] = (10.0, 10.0)
op = mass_merge_round(
st,
groups=groups,
iters=10,
fa2=True,
scaling=10.0,
linlog=True,
use_dual_units=True,
gravity_k=0.6,
group_sep_k=1.2,
group_pack_k=1.3,
sep_ideal=800.0,
global_gravity_k=0.0,
core_pull_k=0.1,
cluster_seed=True,
capture=False,
x_slack=2000,
)
assert op.op == "mass_merge"
assert not (op.params or {}).get("reverted"), op.note
assert (op.params or {}).get("fa2") is True
# Not a single hairball.
xs = [op.state.positions[n][0] for n in op.state.positions]
ys = [op.state.positions[n][1] for n in op.state.positions]
assert max(xs) - min(xs) > 200.0 or max(ys) - min(ys) > 200.0
assert float(op.params.get("end_sep_nn") or 0) > 80.0
def test_mass_merge_separates_collapsed_groups() -> None:
"""Two dual-unit groups stacked on top of each other → seed+sep spreads them."""
st, groups, _mass = _two_block_state()
# Collapse weak block onto strong block (hairball seed).
for n in ("wc", "w1", "w2"):
st.positions[n] = (
st.positions["pa"][0] + 40.0,
st.positions["pa"][1] + 20.0,
)
c0 = (
0.5 * (st.positions["pa"][0] + st.positions["pb"][0]),
0.5 * (st.positions["pa"][1] + st.positions["pb"][1]),
)
c1 = (
(st.positions["wc"][0] + st.positions["w1"][0] + st.positions["w2"][0]) / 3.0,
(st.positions["wc"][1] + st.positions["w1"][1] + st.positions["w2"][1]) / 3.0,
)
d0 = math.hypot(c0[0] - c1[0], c0[1] - c1[1])
op = mass_merge_round(
st,
groups=groups,
iters=12,
use_dual_units=True,
gravity_k=0.9,
group_sep_k=1.2,
group_pack_k=0.8,
sep_ideal=900.0,
global_gravity_k=0.0,
core_pull_k=0.05,
protect_rigid="off",
capture=False,
cluster_seed=True,
x_slack=2000,
)
assert not (op.params or {}).get("reverted"), op.note
assert float(op.params.get("end_sep_nn") or 0) > d0 + 80.0
# Not blown to absurd spacing.
assert float(op.params.get("end_sep_nn") or 0) < 4000.0

View file

@ -7,21 +7,38 @@ import subprocess
import sys
from unittest.mock import patch
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
from netx_topology_mcp.http_tools import (
HTTP_MCP_TOOLS,
call_http_tool,
resolve_draw_target,
tools_for_scopes,
)
from netx_topology_mcp.server import _fetch_scopes
def test_tool_list_has_draw_and_query_tools() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert len(names) == 14
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
assert "createTopologyFolder" in names
assert "createTopologyView" in names
assert "createTopologyView" not in names
assert "listTopologyViews" not in names
assert "queryTopologyFabricNodes" in names
assert "listTopologyFabricNodes" not in names
assert "searchTopologyFabricNodes" not in names
assert "getTopologyFabricSummary" not in names
assert "addTopologyViewNodes" in names
assert "updateTopologyViewPositions" in names
assert "queryTopologyEdges" in names
assert "analyzeTopologyViewLayout" in names
assert "layoutTopologyView" in names
assert "getTopologyTree" in names
assert "createTopologyManualEdge" not in names
assert "populateTopologyView" not in names
folder_tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "createTopologyFolder")
assert "Does not create a canvas" not in str(folder_tool.get("description") or "")
assert "view_id" in str(folder_tool.get("description") or "")
def test_create_folder_requires_name() -> None:
@ -31,20 +48,95 @@ def test_create_folder_requires_name() -> None:
assert payload["error"] == "name_required"
def test_create_folder_posts_region() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}}
out = call_http_tool("createTopologyFolder", {"name": "华北", "sort_order": 1})
mock_http.assert_called_once()
assert mock_http.call_args[0][0] == "POST"
assert mock_http.call_args[0][1] == "/v1/topology/folders"
body = mock_http.call_args[1]["body"]
def test_create_folder_posts_region_and_resolves_view() -> None:
tree = {
"root": {
"id": "sys-root",
"kind": "root",
"children": [
{
"id": "f1",
"name": "华北",
"kind": "region",
"views": [],
"ne_count": 0,
"children": [
{
"id": "rm1",
"name": "根图",
"kind": "region",
"is_system": True,
"ne_count": 0,
"views": [{"id": "v-root-map", "name": "根图", "kind": "physical"}],
"children": [],
}
],
}
],
}
}
def fake_http(method: str, path: str, body: dict | None = None, **_kwargs):
if method == "POST" and path == "/v1/topology/folders":
return {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}}
if method == "GET" and path == "/v1/topology/tree":
return {"ok": True, "data": tree}
return {"ok": False, "error": f"unexpected {method} {path}"}
with patch("netx_topology_mcp.http_tools.http_json", side_effect=fake_http) as mock_http:
out = call_http_tool(
"createTopologyFolder",
{"name": "华北", "sort_order": 1, "locale": "zh"},
)
assert mock_http.call_count == 2
post = mock_http.call_args_list[0]
assert post[0][0] == "POST"
assert post[0][1] == "/v1/topology/folders"
body = post[1]["body"]
assert body["name"] == "华北"
assert body["kind"] == "region"
assert body["sort_order"] == 1
assert body["locale"] == "zh"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["id"] == "f1"
assert payload["view_id"] == "v-root-map"
assert payload["canvas_folder_id"] == "rm1"
assert "view_id" in str(payload.get("hint") or "")
def test_resolve_draw_target_nested_region() -> None:
tree = {
"root": {
"id": "sys",
"children": [
{
"id": "nav",
"views": [],
"children": [
{
"id": "rm",
"name": "Root map",
"is_system": True,
"views": [{"id": "v0", "kind": "physical"}],
"children": [
{
"id": "zone",
"ne_count": 3,
"views": [{"id": "vz", "kind": "physical"}],
"children": [],
}
],
}
],
}
],
}
}
tip = resolve_draw_target(tree, "zone")
assert tip["view_id"] == "vz"
assert tip["canvas_folder_id"] == "zone"
assert tip["ne_count"] == 3
def test_add_nodes_rejects_managed_ume_ids() -> None:
@ -107,36 +199,22 @@ def test_update_positions_layout_filter() -> None:
assert payload["updated"] == 5
def test_create_view_requires_folder() -> None:
out = call_http_tool("createTopologyView", {"name": "map1"})
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "folder_id_required"
def test_create_view_tool_removed() -> None:
try:
call_http_tool("createTopologyView", {"name": "map1", "folder_id": "f1"})
raise AssertionError("createTopologyView should be unregistered")
except ValueError as e:
assert "unknown tool" in str(e)
def test_create_view_posts_body() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"id": "v1", "name": "map1"}}
out = call_http_tool(
"createTopologyView",
{"name": "map1", "folder_id": "f1", "kind": "custom"},
)
mock_http.assert_called_once()
assert mock_http.call_args[0][0] == "POST"
assert mock_http.call_args[0][1] == "/v1/topology/views"
body = mock_http.call_args[1]["body"]
assert body["name"] == "map1"
assert body["folder_id"] == "f1"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_query_edges_enriches_peers() -> None:
def test_query_edges_defaults_to_adjacency() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"total": 1,
"total": 2,
"page": 1,
"page_size": 100,
"items": [
{
"a_node_id": "A",
@ -145,28 +223,253 @@ def test_query_edges_enriches_peers() -> None:
"b_name": "ne-b",
"a_ip": "1.1.1.1",
"b_ip": "2.2.2.2",
}
"a_port": "p1",
"b_port": "p2",
},
{
"a_node_id": "B",
"b_node_id": "A",
"a_name": "ne-b",
"b_name": "ne-a",
"a_port": "p3",
"b_port": "p4",
},
],
},
}
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["detail"] == "adjacency"
assert payload["link_count"] == 1
assert payload["links"][0]["link_count"] == 2
assert "items" not in payload
assert payload["peer_count"] == 1
assert payload["peers"][0]["node_id"] == "B"
assert payload["peers"][0]["link_count"] == 2
assert payload["peers_complete"] is True
out_ports = call_http_tool(
"queryTopologyEdges", {"node_id": "A", "detail": "ports", "page_size": 100}
)
ports = json.loads(out_ports["content"][0]["text"])
assert ports["detail"] == "ports"
assert len(ports["items"]) == 2
def test_tools_for_scopes_filters_write() -> None:
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
assert "queryTopologyEdges" in read_only
assert "createTopologyView" not in read_only
assert "queryTopologyFabricNodes" in read_only
assert "createTopologyFolder" not in read_only
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" in write
assert "createTopologyView" not in write
assert "createTopologyFolder" in write
def test_query_fabric_nodes_modes() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"node_count": 3, "edge_count": 2}}
out = call_http_tool("queryTopologyFabricNodes", {"mode": "summary"})
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "summary"
assert mock_http.call_args[0][1] == "/v1/topology/fabric/summary"
mock_http.return_value = {
"ok": True,
"data": {"items": [{"id": "n1", "name": "A"}], "total": 1, "page": 1, "page_size": 50},
}
out = call_http_tool("queryTopologyFabricNodes", {"q": "A"})
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "search"
assert "/search" in mock_http.call_args[0][1]
out = call_http_tool(
"queryTopologyFabricNodes",
{"mode": "list", "keyword": "core", "role": "core"},
)
payload = json.loads(out["content"][0]["text"])
assert payload.get("mode") == "list"
assert mock_http.call_args[0][1] == "/v1/topology/fabric/nodes"
assert mock_http.call_args[1]["params"]["keyword"] == "core"
def test_get_topology_tree_compacts_by_default() -> None:
tree = {
"root": {
"id": "r",
"name": "Network",
"kind": "root",
"ne_count": 2,
"external_ref": "drop-me",
"views": [],
"children": [
{
"id": "a",
"name": "RootA",
"kind": "region",
"ne_count": 2,
"views": [],
"children": [
{
"id": "rm",
"name": "根图",
"kind": "region",
"is_system": True,
"ne_count": 2,
"views": [{"id": "v1", "name": "根图", "kind": "physical", "node_count": 2}],
"children": [
{
"id": "deep",
"name": "Deep",
"kind": "region",
"ne_count": 0,
"views": [],
"children": [],
}
],
}
],
}
],
}
}
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": tree}
out = call_http_tool("getTopologyTree", {"max_depth": 1})
payload = json.loads(out["content"][0]["text"])
assert payload["compact"] is True
assert "external_ref" not in payload["root"]
child = payload["root"]["children"][0]
assert child["name"] == "RootA"
# depth 1: grandchildren collapsed
assert child["children_truncated"] == 1
assert child["children"] == []
def test_get_topology_view_defaults_to_summary() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "folder_id": "f1", "kind": "physical", "node_count": 2},
"nodes": [
{"fabric_node_id": "n1", "name": "A", "ip": "1.1.1.1", "x": 1, "y": 2},
{"fabric_node_id": "n2", "name": "B", "ip": "2.2.2.2", "x": 3, "y": 4},
],
"edges": [{"id": "e1", "a_node_id": "n1", "b_node_id": "n2", "display_label": "huge" * 50}],
"outside_peers": [{"fabric_node_id": "n3"}],
"truncated": False,
}
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool("getTopologyView", {"view_id": "v1"})
payload = json.loads(out["content"][0]["text"])
assert payload["detail"] == "summary"
assert payload["node_count"] == 2
assert payload["edge_count"] == 1
assert payload["link_count"] == 1
assert payload["links"][0]["a_node_id"] == "n1"
assert payload["links"][0]["b_node_id"] == "n2"
assert "edges" not in payload
assert len(payload["sample_nodes"]) == 2
out_full = call_http_tool("getTopologyView", {"view_id": "v1", "detail": "full"})
full = json.loads(out_full["content"][0]["text"])
assert full["detail"] == "full"
assert len(full["edges"]) == 1
def test_project_neighbors_defaults_to_summary() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "node_count": 3},
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
"edges": [],
"outside_peers": [],
"truncated": True,
"truncate_reason": "membership_cap",
}
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool("projectTopologyNeighbors", {"view_id": "v1"})
payload = json.loads(out["content"][0]["text"])
assert payload["detail"] == "summary"
assert payload["projected"] is True
assert payload["truncated"] is True
assert "edges" not in payload
def test_project_neighbors_passes_region_folder_and_reports_skips() -> None:
graph = {
"view": {"id": "v1", "name": "根图", "node_count": 2},
"nodes": [{"fabric_node_id": "n1", "name": "A", "ip": "", "x": 0, "y": 0}],
"edges": [],
"outside_peers": [],
"out_of_region_skipped": 3,
"out_of_region_sample": [
{"fabric_node_id": "x1", "name": "X", "region_folder_id": "other"}
],
}
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": graph}
out = call_http_tool(
"projectTopologyNeighbors",
{"view_id": "v1", "region_folder_id": "reg-A"},
)
payload = json.loads(out["content"][0]["text"])
assert payload["projected"] is True
assert payload["region_folder_id"] == "reg-A"
assert payload["out_of_region_skipped"] == 3
assert payload["out_of_region_sample"][0]["fabric_node_id"] == "x1"
assert mock_http.call_args.kwargs["body"]["region_folder_id"] == "reg-A"
def test_neighborhood_returns_links_not_ports() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"center_node_id": "A",
"depth": 1,
"nodes": [
{
"id": "A",
"name": "ne-a",
"ip": "1.1.1.1",
"attrs": {"sources": ["ume"], "huge": "x" * 200},
"world_x": 1,
"world_y": 2,
}
],
"edges": [
{
"id": "e1",
"a_node_id": "A",
"b_node_id": "B",
"source": "ume",
"status": "active",
"a_port": "p1",
"attrs": {"display_label": "LONG" * 40},
},
{
"id": "e2",
"a_node_id": "A",
"b_node_id": "B",
"source": "ume",
"status": "active",
"a_port": "p2",
},
],
},
}
out = call_http_tool("queryTopologyNeighborhood", {"node_id": "A"})
payload = json.loads(out["content"][0]["text"])
assert payload["node_count"] == 1
assert payload["edge_count"] == 2
assert payload["link_count"] == 1
assert payload["links"][0]["link_count"] == 2
assert "edges" not in payload
assert "attrs" not in payload["nodes"][0]
assert "world_x" not in payload["nodes"][0]
def test_fetch_scopes_unwraps_envelope() -> None:
with patch("netx_topology_mcp.server.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}}
@ -202,6 +505,69 @@ def test_stdio_initialize_and_tools_list() -> None:
list_resp = json.loads(proc.stdout.readline())
tools = list_resp["result"]["tools"]
assert len(tools) == 14
names = {t["name"] for t in tools}
assert "createTopologyView" not in names
assert "listTopologyViews" not in names
assert "queryTopologyFabricNodes" in names
assert "createTopologyFolder" in names
assert "analyzeTopologyViewLayout" in names
assert "layoutTopologyView" in names
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
finally:
proc.terminate()
proc.wait(timeout=5)
def test_layout_metrics_crossing_and_spacing() -> None:
from netx_topology_mcp.layout_metrics import analyze_positions, grade_layout, segments_properly_intersect
assert segments_properly_intersect((0, 0), (2, 2), (0, 2), (2, 0))
assert not segments_properly_intersect((0, 0), (1, 0), (1, 0), (2, 0)) # shared endpoint
# X crossing
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 200},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
]
m = analyze_positions(nodes, edges)
assert m["edge_crossings"] == 1
assert m["link_count"] == 2
g = grade_layout(m)
assert g["overall"] in {"ok", "warn", "fail"}
def test_analyze_topology_view_layout_single() -> None:
graph = {
"view": {"id": "v1", "name": "demo", "folder_id": "f1"},
"nodes": [
{"fabric_node_id": "a", "name": "N-A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "N-B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "N-C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "N-D", "x": 200, "y": 200},
],
"edges": [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "a"},
],
}
with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}):
out = call_http_tool("analyzeTopologyViewLayout", {"view_id": "v1"})
assert out.get("isError") is not True
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["size"]["links"] == 4
assert payload["crossing"]["edge_crossings"] == 0
assert payload["overlap"]["status"] == "ok"
assert "sparsity" in payload
assert "verdict" in payload
assert "total" in payload["verdict"]

View file

@ -0,0 +1,36 @@
"""layoutTopologyView action=move_nodes registration + validation."""
from __future__ import annotations
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, _move_topology_view_nodes
from netx_topology_mcp.layout_tool import list_layout_catalog
def test_move_nodes_in_layout_schema_and_catalog() -> None:
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
enum = tool["inputSchema"]["properties"]["action"]["enum"]
assert "move_nodes" in enum
assert "sink_nodes" in enum
cat = list_layout_catalog()
assert "move_nodes" in cat["actions"]
assert "sink_nodes" in cat["actions"]
def test_move_nodes_requires_ids_and_views() -> None:
assert _move_topology_view_nodes({}).get("error") == "view_id_and_source_view_id_required"
assert (
_move_topology_view_nodes(
{
"view_id": "a",
"source_view_id": "a",
"params": {"fabric_node_ids": ["x"]},
}
).get("error")
== "source_and_dest_must_differ"
)
assert (
_move_topology_view_nodes(
{"view_id": "a", "source_view_id": "b", "params": {}}
).get("error")
== "fabric_node_ids_required"
)

View file

@ -0,0 +1,176 @@
"""Tests for polar orbit_sweep top-3 suggest / round apply."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.orbit_sweep import (
apply_orbit_pick,
orbit_sweep_node,
orbit_sweep_round,
)
from netx_topology_mcp.layout_tool import run_layout_on_graph
def _crossed_pair():
# a—b horizontal crosses c—d vertical at center; free node e tethered to c.
nodes = [
{"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0},
{"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0},
{"fabric_node_id": "c", "name": "CCCCCC-EN-3", "x": 200.0, "y": 0.0},
{"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0},
{"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "c", "b_node_id": "e"},
]
return nodes, edges
def test_orbit_sweep_node_returns_top3_with_gain() -> None:
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
assert g0 >= 1
# Move endpoint d — polar sweep should find a non-crossing slot.
out = orbit_sweep_node(
st,
"d",
protect_rigid="off",
max_jump=500,
angle_step=15,
nn_floor=20.0,
)
assert out["ok"] is True
cands = out["candidates"]
assert 1 <= len(cands) <= 3
assert cands[0]["rank"] == 1
assert "x" in cands[0] and "y" in cands[0]
assert cands[0]["crossings"]["global"] <= g0
# Prefer at least one improving candidate on this toy cross.
assert out["improving_n"] >= 1 or cands[0]["delta"]["global"] <= 0
def test_orbit_top3_angular_diversity() -> None:
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
out = orbit_sweep_node(
st, "d", protect_rigid="off", max_jump=600, min_angle_sep=35.0
)
cands = out["candidates"]
if len(cands) >= 2:
for i in range(len(cands)):
for j in range(i + 1, len(cands)):
a = float(cands[i]["angle_deg"])
b = float(cands[j]["angle_deg"])
d = abs(a - b) % 360.0
d = d if d <= 180 else 360 - d
r0 = max(float(cands[i]["r"]), 1.0)
r1 = max(float(cands[j]["r"]), 1.0)
ratio = max(r0, r1) / min(r0, r1)
# Either angularly separated or clearly different radius.
assert d >= 34.0 or ratio >= 1.29
def test_apply_orbit_pick_moves_to_rank2() -> None:
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
sweep = orbit_sweep_node(st, "d", protect_rigid="off", max_jump=600)
assert sweep["ok"]
cands = sweep["candidates"]
assert len(cands) >= 1
pick = 2 if len(cands) >= 2 else 1
op = apply_orbit_pick(st, sweep, pick=pick)
assert "d" in op.moved
chosen = cands[pick - 1]
assert abs(op.state.positions["d"][0] - chosen["x"]) < 0.2
assert abs(op.state.positions["d"][1] - chosen["y"]) < 0.2
def test_orbit_sweep_round_does_not_raise_crossings() -> None:
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
g0 = count_edge_crossings(st.positions, st.links)
op = orbit_sweep_round(
st,
top_n=4,
max_degree=9,
protect_rigid="off",
max_jump=600,
)
g1 = count_edge_crossings(op.state.positions, op.state.links)
assert g1 <= g0
assert (op.params or {}).get("end_crossings") == g1
def test_run_layout_orbit_sweep_preview_keeps_coords() -> None:
nodes, edges = _crossed_pair()
out = run_layout_on_graph(
nodes,
edges,
action="orbit_sweep",
params={"node_id": "d", "protect_rigid": "off", "max_jump": 600},
)
assert out["ok"] is True
assert out["action"] == "orbit_sweep"
sweep = (out.get("local") or {}).get("sweep") or {}
assert sweep.get("candidates")
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
assert abs(by_id["d"]["x"] - 200.0) < 0.2
assert abs(by_id["d"]["y"] - 400.0) < 0.2
def test_run_layout_orbit_sweep_pick_applies() -> None:
nodes, edges = _crossed_pair()
out = run_layout_on_graph(
nodes,
edges,
action="orbit_sweep",
params={
"node_id": "d",
"pick": 1,
"protect_rigid": "off",
"max_jump": 600,
},
)
assert out["ok"] is True
pick = (out.get("local") or {}).get("pick")
assert pick == 1
sweep = (out.get("local") or {}).get("sweep") or {}
chosen = (sweep.get("candidates") or [{}])[0]
by_id = {p["fabric_node_id"]: p for p in out["positions"]}
# normalize_origin shifts all coords; check relative to fixed peer a.
assert abs((by_id["d"]["x"] - by_id["a"]["x"]) - (chosen["x"] - 0.0)) < 0.2
assert abs((by_id["d"]["y"] - by_id["a"]["y"]) - (chosen["y"] - 200.0)) < 0.2
def test_catalog_lists_orbit_sweep() -> None:
from netx_topology_mcp.layout_tool import list_layout_catalog
cat = list_layout_catalog()
assert "orbit_sweep" in cat["actions"]
def test_orbit_default_protect_off_ignores_portal_freeze() -> None:
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides
knobs = orbit_params_from_overrides({"node_id": "d", "portal_ids": ["d"]})
assert knobs["protect_rigid"] == "off"
nodes, edges = _crossed_pair()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
# frozen_ids present but protect default off → still sweeps.
out = orbit_sweep_node(st, "d", frozen_ids={"d"}, max_jump=500, nn_floor=20.0)
assert out["ok"] is True
assert out.get("candidates")
frozen = orbit_sweep_node(
st, "d", protect_rigid="portals", frozen_ids={"d"}, max_jump=500
)
assert frozen["ok"] is False
assert frozen.get("error") == "frozen"

View file

@ -0,0 +1,113 @@
"""Tests for ortho_metro (multi-layer H/V metro recipe)."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges, run_recipe
from netx_topology_mcp.layout_ops.ortho_metro import (
_axis_ok,
build_ortho_metro_skeleton,
)
from netx_topology_mcp.layout_tool import RECIPE_ALIASES, run_layout_on_graph
def _core_bar_graph():
"""Two CN cores + AN stubs + one deg-2 corridor + one triangle."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 0, "y": 0},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 0, "y": 0},
{"fabric_node_id": "t0", "name": "X-AN2-Y", "x": 0, "y": 0},
{"fabric_node_id": "t1", "name": "X-AN3-Y", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "e0", "b_node_id": "e1"},
# triangle on a0
{"a_node_id": "a0", "b_node_id": "t0"},
{"a_node_id": "t0", "b_node_id": "t1"},
{"a_node_id": "t1", "b_node_id": "a0"},
]
return nodes, edges
def test_recipe_alias() -> None:
# ortho_metro module kept; unpublished from public recipe aliases.
assert "ortho_metro" not in RECIPE_ALIASES
def test_build_ortho_metro_places_all() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams())
assert op.op == "build_ortho_metro_skeleton"
assert set(op.state.positions) == {n["fabric_node_id"] for n in nodes}
assert op.state.meta.get("rings_mode") == "ortho_metro"
meta = op.state.meta.get("ortho_metro") or {}
assert int(meta.get("axis_edges") or 0) + int(meta.get("diag_edges") or 0) == len(
op.state.links
)
def test_ortho_metro_mostly_axis_aligned() -> None:
from netx_topology_mcp.layout_metrics import compute_edge_clearance
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=True)
pos = op.state.positions
axis = 0
diag = 0
for a, b in op.state.links:
if _axis_ok(pos[a], pos[b]):
axis += 1
else:
diag += 1
# Ortho first; clearance may break one chord to kill edge-through-node.
assert axis >= len(op.state.links) - 2
assert diag <= 2
clr = compute_edge_clearance(pos, op.state.links, names=op.state.names, thr=40.0)
assert int(clr.get("edge_clearance_hits") or 0) == 0
def test_ortho_metro_no_footprint_stack() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(pitch=200, side=170))
ids = list(op.state.positions)
for i, a in enumerate(ids):
for b in ids[i + 1 :]:
x1, y1 = op.state.positions[a]
x2, y2 = op.state.positions[b]
assert math.hypot(x2 - x1, y2 - y1) >= 140.0
def test_run_recipe_and_layout_tool_unpublished() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
try:
run_recipe(st, "ortho_metro_v1", LayoutParams())
raise AssertionError("ortho_metro_v1 should be unpublished")
except Exception as e:
assert "ortho" in str(e).lower() or "unknown" in str(e).lower() or "recipe" in str(e).lower()
try:
run_layout_on_graph(nodes, edges, action="layout", recipe="ortho_metro")
raise AssertionError("ortho_metro recipe should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)
def test_skip_triangles_false_tries_all() -> None:
nodes, edges = _core_bar_graph()
st = build_state_from_nodes_edges(nodes, edges)
op = build_ortho_metro_skeleton(st, LayoutParams(), skip_triangles=False)
# Still places everyone; may leave 0 diags if coplanar H on triangle.
assert len(op.state.positions) == len(nodes)

View file

@ -0,0 +1,166 @@
"""Soft partition: hub territory (always) + optional igraph."""
from __future__ import annotations
from netx_topology_mcp.layout_ops import (
build_state_from_nodes_edges,
igraph_available,
list_blocks,
pack_block_centers,
partition_soft_blocks,
)
from netx_topology_mcp.layout_structure import analyze_graph_structure
def _star_graph():
# Two hubs + private stubs + shared leftover chain
nodes = [
{"fabric_node_id": "h1", "name": "H1-AN-1", "x": 0, "y": 0},
{"fabric_node_id": "h2", "name": "H2-AN-1", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "A1-EN-1", "x": 0, "y": 20},
{"fabric_node_id": "a2", "name": "A2-EN-1", "x": 0, "y": 40},
{"fabric_node_id": "b1", "name": "B1-EN-1", "x": 100, "y": 20},
{"fabric_node_id": "b2", "name": "B2-EN-1", "x": 100, "y": 40},
{"fabric_node_id": "z1", "name": "Z1-EN-1", "x": 50, "y": 80},
{"fabric_node_id": "z2", "name": "Z2-EN-1", "x": 50, "y": 100},
]
edges = [
{"a_node_id": "h1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "h2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "h1", "b_node_id": "h2"},
{"a_node_id": "a2", "b_node_id": "z1"},
{"a_node_id": "z1", "b_node_id": "z2"},
]
return nodes, edges
def test_hub_territory_splits_by_hub() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="hub_territory")
methods = {b.method for b in blocks}
assert "hub_territory" in methods
by_hub = {b.hub_id: set(b.node_ids) for b in blocks if b.hub_id}
assert "h1" in by_hub and "h2" in by_hub
assert "a1" in by_hub["h1"] and "a2" in by_hub["h1"]
assert "b1" in by_hub["h2"] and "b2" in by_hub["h2"]
# z* claimed via a2 → h1 territory (first BFS from stubs)
assert "z1" in by_hub["h1"] or any(
"z1" in b.node_ids for b in blocks if b.method == "leftover"
)
def test_list_blocks_hub_territory_mode() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
sets = list_blocks(st, mode="hub_territory")
assert len(sets) >= 2
assert all(isinstance(s, set) and s for s in sets)
def test_pack_block_centers_grid_fallback() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="hub_territory")
centers = pack_block_centers(st, blocks)
assert len(centers) == len(blocks)
assert all(isinstance(xy, tuple) and len(xy) == 2 for xy in centers.values())
def test_structure_includes_soft_blocks() -> None:
nodes, edges = _star_graph()
struct = analyze_graph_structure(nodes, edges)
sb = struct.get("soft_blocks") or {}
assert sb.get("block_count", 0) >= 2
assert "blocks" in sb
assert "igraph" in sb
assert struct["advice"].get("decompose_soft_blocks") in {True, False}
def test_leiden_mode_falls_back_without_crash() -> None:
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
blocks = partition_soft_blocks(st, mode="leiden")
assert blocks
# With or without igraph, we still get a partition.
assert sum(len(b.node_ids) for b in blocks) >= len(st.positions)
_ = igraph_available() # smoke
def test_resolve_auto_small_graph_stays_component() -> None:
from netx_topology_mcp.layout_ops import resolve_block_mode
nodes, edges = _star_graph()
st = build_state_from_nodes_edges(nodes, edges)
# 8 nodes < 40 → auto keeps component
assert resolve_block_mode(st, "auto") == "component"
def test_pack_soft_blocks_moves_territories_apart() -> None:
from netx_topology_mcp.layout_ops import pack_soft_blocks
nodes, edges = _star_graph()
# Stack both hubs on top of each other so pack must separate
for n in nodes:
if n["fabric_node_id"] in {"h1", "a1", "a2"}:
n["x"], n["y"] = 0.0, 0.0
else:
n["x"], n["y"] = 5.0, 5.0
st = build_state_from_nodes_edges(nodes, edges)
before = dict(st.positions)
out = pack_soft_blocks(st, mode="hub_territory")
assert out.op == "pack_soft_blocks"
assert len(out.moved) >= 2
# centroids of the two hub blocks should diverge
blocks = partition_soft_blocks(out.state, mode="hub_territory")
hubs = [b for b in blocks if b.hub_id]
assert len(hubs) >= 2
def centroid(ids):
pts = [out.state.positions[i] for i in ids if i in out.state.positions]
return (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
c0, c1 = centroid(hubs[0].node_ids), centroid(hubs[1].node_ids)
dist = ((c0[0] - c1[0]) ** 2 + (c0[1] - c1[1]) ** 2) ** 0.5
assert dist > 50.0
# something moved vs stacked start
assert any(out.state.positions[n] != before[n] for n in out.moved)
def test_pack_soft_blocks_preserves_spread_constellation() -> None:
from netx_topology_mcp.layout_ops import pack_soft_blocks
nodes, edges = _star_graph()
# Already-spread hubs: preserve strategy densifies but keeps left/right order
for n in nodes:
fid = n["fabric_node_id"]
if fid in {"h1", "a1", "a2"}:
n["x"], n["y"] = 0.0, 0.0
elif fid in {"h2", "b1", "b2"}:
n["x"], n["y"] = 2000.0, 0.0
else:
n["x"], n["y"] = 1000.0, 400.0
st = build_state_from_nodes_edges(nodes, edges)
out = pack_soft_blocks(st, mode="hub_territory", gap=420.0, strategy="auto")
assert out.params.get("strategy") == "preserve"
h1 = out.state.positions["h1"]
h2 = out.state.positions["h2"]
assert h1[0] < h2[0]
# densified toward gap, not remapped onto a fresh grid origin
assert abs(h2[0] - h1[0]) < 2000.0
assert abs(h2[0] - h1[0]) > 200.0
def test_compact_soft_recipe_unpublished() -> None:
from netx_topology_mcp.layout_tool import resolve_recipe
try:
resolve_recipe("compact_soft")
raise AssertionError("compact_soft should be unpublished")
except ValueError as e:
assert "unknown_recipe" in str(e)

View file

@ -0,0 +1,84 @@
"""Tests for stage-2 pin_beam + soft_petals actions."""
from __future__ import annotations
from netx_topology_mcp.layout_ops import LayoutParams, build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def _starish():
"""Two cores + two AN hubs each with a few stubs (positions intentionally messy)."""
nodes = [
{"fabric_node_id": "c0", "name": "X-CN0-Y", "x": 100, "y": 500},
{"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 900, "y": 100},
{"fabric_node_id": "a0", "name": "X-AN0-Y", "x": 200, "y": 800},
{"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 800, "y": 50},
{"fabric_node_id": "e0", "name": "X-EN0-Y", "x": 50, "y": 900},
{"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 350, "y": 950},
{"fabric_node_id": "e2", "name": "X-EN2-Y", "x": 750, "y": 20},
{"fabric_node_id": "e3", "name": "X-EN3-Y", "x": 950, "y": 40},
{"fabric_node_id": "e4", "name": "X-EN4-Y", "x": 250, "y": 700},
{"fabric_node_id": "e5", "name": "X-EN5-Y", "x": 850, "y": 200},
]
edges = [
{"a_node_id": "c0", "b_node_id": "c1"},
{"a_node_id": "c0", "b_node_id": "a0"},
{"a_node_id": "c1", "b_node_id": "a1"},
{"a_node_id": "a0", "b_node_id": "e0"},
{"a_node_id": "a0", "b_node_id": "e1"},
{"a_node_id": "a0", "b_node_id": "e4"},
{"a_node_id": "a1", "b_node_id": "e2"},
{"a_node_id": "a1", "b_node_id": "e3"},
{"a_node_id": "a1", "b_node_id": "e5"},
{"a_node_id": "e0", "b_node_id": "e4"},
{"a_node_id": "e2", "b_node_id": "e5"},
]
return nodes, edges
def test_actions_include_pin_and_petals() -> None:
assert "pin_beam" not in ACTIONS
assert "soft_petals" not in ACTIONS
def test_pin_beam_aligns_cores() -> None:
nodes, edges = _starish()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = pin_beam_rigid(st, LayoutParams())
assert op.op == "pin_beam"
ys = [op.state.positions["c0"][1], op.state.positions["c1"][1]]
# Either accepted with near-collinear cores, or refused (no_improvement) keeping input
if op.params.get("accepted"):
assert abs(ys[0] - ys[1]) < 5.0
else:
assert op.note in {"no_improvement", "need_ge_2_cores"}
def test_soft_petals_runs() -> None:
nodes, edges = _starish()
st = build_state_from_nodes_edges(nodes, edges)
st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
op = soft_petals_greedy(st, LayoutParams())
assert op.op == "soft_petals"
assert "accepted_n" in op.params
def test_run_layout_on_graph_pin_beam_unpublished() -> None:
nodes, edges = _starish()
try:
run_layout_on_graph(nodes, edges, action="pin_beam")
raise AssertionError("pin_beam should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)
def test_run_layout_on_graph_soft_petals_unpublished() -> None:
nodes, edges = _starish()
try:
run_layout_on_graph(nodes, edges, action="soft_petals")
raise AssertionError("soft_petals should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,162 @@
"""press_hot_edges / press_crossers / polish_crossings MCP actions."""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossings_after_node_move,
)
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.press_crossings import (
_large_graph_budget,
park_phantom_nodes,
press_hot_edges,
)
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_press_polish() -> None:
assert "press_hot_edges" not in ACTIONS
assert "press_crossers" not in ACTIONS
assert "polish_crossings" in ACTIONS
def test_park_phantom_region_nodes() -> None:
st = build_state_from_nodes_edges(
[
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{
"fabric_node_id": "region:x",
"name": "Units",
"x": 1e12,
"y": 100,
},
],
[],
)
moved = park_phantom_nodes(st)
assert "region:x" in moved
assert abs(st.positions["region:x"][0]) < 1000
def test_crossings_after_node_move_matches_full_recount() -> None:
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 400, "y": 100},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "b"},
{"a_node_id": "a", "b_node_id": "d"},
]
st = build_state_from_nodes_edges(nodes, edges)
cur = count_edge_crossings(st.positions, st.links)
for xy in ((200.0, -40.0), (50.0, 50.0), (300.0, 300.0)):
inc = crossings_after_node_move(
st.positions, st.links, st.adj, "a", xy, current_total=cur
)
trial = dict(st.positions)
trial["a"] = xy
assert inc == count_edge_crossings(trial, st.links)
def test_large_graph_budget_scales_down() -> None:
small = _large_graph_budget(50)
huge = _large_graph_budget(500)
assert huge["hot_max_sweeps"] < small["hot_max_sweeps"]
assert huge["untangle_rounds"] < small["untangle_rounds"]
assert huge["untangle_rank_cap"] < small["untangle_rank_cap"]
assert huge["cross_cand_cap"] < small["cross_cand_cap"]
def test_press_hot_edges_reduces_x_crossing() -> None:
# Classic X: rotate one endpoint about the other can uncross.
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 200},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 0, "y": 200},
]
# h-a and b-c cross; freeze hub h as portal
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "h", "b_node_id": "b"},
]
st = build_state_from_nodes_edges(nodes, edges)
st.meta = {
"compose_views": {
"rigid_groups": [
{"key": "u1", "node_ids": ["h", "a", "b"], "pivots": ["h"]},
]
}
}
before = 1 # at least the X
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=8, max_sweeps=3)
assert op.state is not None
assert int(op.params.get("end_crossings") or 0) <= before + 2
def test_press_hot_edges_reels_in_long_spoke() -> None:
"""max_disp must not block inward pulls that shorten metro bridges."""
import math
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "leaf", "name": "LEAF", "x": 8000, "y": 0},
# Barrier segment the long spoke crosses.
{"fabric_node_id": "p", "name": "P", "x": 4000, "y": -800},
{"fabric_node_id": "q", "name": "Q", "x": 4000, "y": 800},
{"fabric_node_id": "anchor", "name": "A", "x": -200, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "leaf"},
{"a_node_id": "p", "b_node_id": "q"},
{"a_node_id": "h", "b_node_id": "anchor"},
]
st = build_state_from_nodes_edges(nodes, edges)
# Pad with dummy edges so large-graph budget (E>=400) applies.
for i in range(420):
a, b = f"d{i}", f"d{i+1}"
st.positions[a] = (float(i), 5000.0)
st.positions[b] = (float(i) + 1.0, 5000.0)
st.names[a] = a
st.names[b] = b
st.links.append((a, b))
st.adj.setdefault(a, []).append(b)
st.adj.setdefault(b, []).append(a)
before = count_edge_crossings(st.positions, st.links)
assert before >= 1
op = press_hot_edges(st, portal_ids=["h"], top_n=5, max_moves=6, max_sweeps=2)
after_len = math.hypot(
op.state.positions["leaf"][0] - op.state.positions["h"][0],
op.state.positions["leaf"][1] - op.state.positions["h"][1],
)
assert after_len < 4000.0 or int(op.params.get("end_crossings") or before) < before
def test_run_layout_polish_crossings() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
{"a_node_id": "a", "b_node_id": "b"},
]
out = run_layout_on_graph(
nodes,
edges,
action="polish_crossings",
params={"straighten": True, "max_degree": 7, "untangle_rounds": 40},
)
assert out["ok"] is True
assert out["action"] == "polish_crossings"
assert (out.get("overlap") or {}).get("footprint_pairs", 0) == 0

View file

@ -0,0 +1,145 @@
"""Bridge-orbit densify: drag rigid exclusive body about external tip."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.rigid_orbit import (
rigid_orbit_candidates_for_group,
rigid_orbit_round,
)
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_rigid_orbit() -> None:
assert "rigid_orbit" not in ACTIONS
def _sparse_two_units() -> tuple:
# Unit A near origin; unit B far right; long bridge a2—b1.
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 120, "y": 40},
{"fabric_node_id": "a2", "name": "A2", "x": 240, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 3200, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 3400, "y": 60},
{"fabric_node_id": "b3", "name": "B3", "x": 3600, "y": -40},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b1", "b_node_id": "b3"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2", "b3"]),
]
)
st.meta = {"compose_views": {"rigid_groups": groups}}
return st, groups
def test_candidates_prefer_inward_pull() -> None:
st, groups = _sparse_two_units()
g = next(g for g in groups if g["key"] == "vb")
parsed = {
"key": g["key"],
"members": g["node_ids"],
"pivots": g["pivots"],
"exclusive": [n for n in g["node_ids"] if n not in g["pivots"]],
"shared": list(g["pivots"]),
}
# vb has no shared pivots with va (p not in vb) — exclusive = all members
cands = rigid_orbit_candidates_for_group(
st.positions,
st.links,
parsed,
angle_step=45,
radii=(0.5, 0.7, 1.0),
bridges_per_group=2,
cand_cap=40,
x_slack=200,
)
assert cands, "expected bridge-orbit candidates for far unit"
# Best should densify (area_ratio > 1) when pulling toward a2 tip
assert cands[0]["area_ratio"] >= 1.0 or cands[0]["radius_scale"] < 1.0
def test_rigid_orbit_round_shrinks_bbox() -> None:
st, groups = _sparse_two_units()
before = score_state(st, fast=True)
util0 = float((before.get("summary") or {}).get("util") or 0.0)
xs = [p[0] for p in st.positions.values()]
ys = [p[1] for p in st.positions.values()]
area0 = (max(xs) - min(xs)) * (max(ys) - min(ys))
op = rigid_orbit_round(
st,
groups=groups,
top_n=4,
bridges_per_group=2,
angle_step=30,
radii=(0.45, 0.6, 0.75, 0.9),
x_slack=500,
max_accepts=4,
)
assert op.op == "rigid_orbit"
# Similarity: pairwise distance ratios inside vb stay equal (polar scale).
def _pair_lens(pos: dict) -> tuple[float, float]:
d12 = math.hypot(pos["b2"][0] - pos["b1"][0], pos["b2"][1] - pos["b1"][1])
d13 = math.hypot(pos["b3"][0] - pos["b1"][0], pos["b3"][1] - pos["b1"][1])
return d12, d13
L12_0, L13_0 = _pair_lens(st.positions)
L12_1, L13_1 = _pair_lens(op.state.positions)
assert L12_0 > 1 and L13_0 > 1
r12 = L12_1 / L12_0
r13 = L13_1 / L13_0
assert abs(r12 - r13) < 1e-5
if not (op.params or {}).get("reverted"):
xs1 = [p[0] for p in op.state.positions.values()]
ys1 = [p[1] for p in op.state.positions.values()]
area1 = (max(xs1) - min(xs1)) * (max(ys1) - min(ys1))
after = score_state(op.state, fast=True)
util1 = float((after.get("summary") or {}).get("util") or 0.0)
assert area1 < area0 * 0.98 or util1 > util0 + 1e-6
def test_run_layout_rigid_orbit_action() -> None:
st, groups = _sparse_two_units()
nodes = [
{
"fabric_node_id": nid,
"name": st.names.get(nid, nid),
"x": st.positions[nid][0],
"y": st.positions[nid][1],
}
for nid in st.positions
]
edges = [{"a_node_id": a, "b_node_id": b} for a, b in st.links]
try:
run_layout_on_graph(
nodes,
edges,
action="rigid_orbit",
params={
"_rigid_membership": [
{"key": g["key"], "node_ids": g["node_ids"], "pivots": g["pivots"]}
for g in groups
],
"top_n": 4,
"x_slack": 500,
"radii": [0.5, 0.7, 0.9],
},
)
raise AssertionError("rigid_orbit should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,151 @@
"""Sub-regions stay rigid after compose / rigid_untangle."""
from __future__ import annotations
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, strip_pack_blocks
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.rigid_units import (
groups_from_membership,
rigid_untangle_groups,
)
from netx_topology_mcp.layout_ops.state import LayoutParams
from netx_topology_mcp.layout_tool import ACTIONS, run_layout_on_graph
def test_actions_include_rigid_untangle() -> None:
assert "rigid_untangle" not in ACTIONS
def test_protect_portals_freezes_shared_only() -> None:
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
from netx_topology_mcp.layout_ops.state import LayoutState
st = LayoutState(
positions={"p": (0, 0), "a": (1, 0), "b": (2, 0)},
names={},
adj={},
links=[],
layers={},
meta={
"compose_views": {
"rigid_groups": [
{"key": "va", "node_ids": ["p", "a"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b"], "pivots": ["p"]},
]
}
},
)
assert frozen_ids_for_protect(st, "portals") == {"p"}
assert frozen_ids_for_protect(st, "all") == {"p", "a", "b"}
assert frozen_ids_for_protect(st, "off") == set()
# Shared corridor `c` is multi-membership but not a pivot — must stay movable.
st.meta["compose_views"]["rigid_groups"] = [
{"key": "va", "node_ids": ["p", "a", "c"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b", "c"], "pivots": ["p"]},
]
assert frozen_ids_for_protect(st, "portals") == {"p"}
assert "c" not in frozen_ids_for_protect(st, "portals")
def test_compose_emits_rigid_groups() -> None:
a = ComposeBlock(
"va",
{"p": (0.0, 0.0), "a1": (100.0, 50.0), "a2": (200.0, 0.0)},
)
b = ComposeBlock(
"vb",
{"p": (0.0, 0.0), "b1": (100.0, -40.0), "b2": (200.0, 0.0)},
)
_merged, meta = strip_pack_blocks([a, b], pad=50.0, merge_shared=True)
groups = meta.get("rigid_groups") or []
assert len(groups) == 2
by_key = {g["key"]: g for g in groups}
assert "p" in by_key["vb"]["pivots"]
assert set(by_key["va"]["node_ids"]) >= {"p", "a1", "a2"}
def test_rigid_untangle_preserves_relative_geometry() -> None:
nodes = [
{"fabric_node_id": "p", "name": "P", "x": 0, "y": 0},
{"fabric_node_id": "a1", "name": "A1", "x": 100, "y": 50},
{"fabric_node_id": "a2", "name": "A2", "x": 200, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 500, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "p", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
st = build_state_from_nodes_edges(nodes, edges)
groups = groups_from_membership(
[
("va", ["p", "a1", "a2"]),
("vb", ["b1", "b2"]),
]
)
# Capture relative offset inside va
dx0 = st.positions["a2"][0] - st.positions["a1"][0]
dy0 = st.positions["a2"][1] - st.positions["a1"][1]
op = rigid_untangle_groups(st, LayoutParams(), groups=groups, max_rounds=8, step=200.0)
dx1 = op.state.positions["a2"][0] - op.state.positions["a1"][0]
dy1 = op.state.positions["a2"][1] - op.state.positions["a1"][1]
# Distance preserved (rigid); angle may flip 90° so check length
import math
assert abs(math.hypot(dx1, dy1) - math.hypot(dx0, dy0)) < 1e-6
def test_rigid_fan_out_reduces_bbox_stack() -> None:
"""Two eyes glued on one portal should prefer a non-stacked angle."""
from netx_topology_mcp.layout_ops.rigid_units import rigid_fan_out_portals
# Same portal p; unit A along +x, unit B also along +x (stacked).
pos = {
"p": (0.0, 0.0),
"a1": (100.0, 20.0),
"a2": (200.0, 0.0),
"b1": (100.0, 20.0),
"b2": (200.0, 0.0),
}
groups = [
{"key": "va", "node_ids": ["p", "a1", "a2"], "pivots": ["p"]},
{"key": "vb", "node_ids": ["p", "b1", "b2"], "pivots": ["p"]},
]
links = [("p", "a1"), ("a1", "a2"), ("p", "b1"), ("b1", "b2")]
out, n = rigid_fan_out_portals(pos, groups, links)
assert n >= 1
# After fan-out, B should not sit on A's exclusive stack.
assert abs(out["b1"][0] - out["a1"][0]) > 1.0 or abs(
out["b1"][1] - out["a1"][1]
) > 1.0
def test_run_layout_rigid_untangle() -> None:
nodes = [
{"fabric_node_id": "a1", "name": "A1", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "A2", "x": 100, "y": 0},
{"fabric_node_id": "b1", "name": "B1", "x": 400, "y": 0},
{"fabric_node_id": "b2", "name": "B2", "x": 500, "y": 0},
]
edges = [
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
]
try:
run_layout_on_graph(
nodes,
edges,
action="rigid_untangle",
params={
"_rigid_membership": [
{"key": "va", "node_ids": ["a1", "a2"]},
{"key": "vb", "node_ids": ["b1", "b2"]},
]
},
)
raise AssertionError("rigid_untangle should be unpublished")
except ValueError as e:
assert "unknown_action" in str(e)

View file

@ -0,0 +1,107 @@
"""Tests for sinkTopologyDualUnits selection helpers + tool registration."""
from __future__ import annotations
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_topology_mcp.layout_ops.sink_dual_units import (
batch_node_ids,
layout_and_pack_batch,
leftover_batch_ids,
park_positions,
select_dual_unit_batch,
)
def _u(uid: int, a: str, b: str, *mids: str) -> DualUnit:
path = [a, *mids, b] if mids else [a, b]
return DualUnit(portal_a=a, portal_b=b, paths=[path], tails=[], unit_id=uid)
def test_select_prefers_less_shared_portals() -> None:
units = [
_u(1, "X", "A", "m1", "m2", "m3", "m4", "m5", "m6"),
_u(2, "X", "B", "n1", "n2", "n3", "n4", "n5", "n6"),
_u(3, "C", "D", "p1", "p2", "p3", "p4", "p5", "p6"),
]
picked = select_dual_unit_batch(units, max_units=1, min_nodes=8, max_nodes=80)
assert len(picked) == 1
assert picked[0].unit_id == 3
def test_select_respects_batch_cap() -> None:
units = [
_u(1, "A", "B", "m1", "m2", "m3", "m4", "m5", "m6"),
_u(2, "C", "D", "n1", "n2", "n3", "n4", "n5", "n6"),
_u(3, "E", "F", "p1", "p2", "p3", "p4", "p5", "p6"),
]
picked = select_dual_unit_batch(
units, max_units=3, min_nodes=8, max_nodes=80, max_batch_nodes=20
)
ids = batch_node_ids(picked)
assert len(ids) <= 20
assert len(picked) >= 1
def test_leftover_and_park() -> None:
left = leftover_batch_ids(
["a", "region:x", "b", "c"], max_batch_nodes=2, exclude_ids={"b"}
)
assert left == ["a", "c"]
pos = park_positions(
{"a": (10.0, 10.0), "c": (30.0, 10.0)},
["a", "c"],
sink_pos={"z": (100.0, 50.0)},
pad=40.0,
)
assert len(pos) == 2
# Orbit block-sweep: not fixed right — just leave sink tip with clearance.
assert all("x" in p and "y" in p for p in pos)
assert max(abs(p["x"] - 100.0) for p in pos) >= 40.0 or max(
abs(p["y"] - 50.0) for p in pos
) >= 40.0
def test_layout_and_pack_batch_zero_cross_units() -> None:
nodes = [
{"fabric_node_id": "p1", "name": "BTM-AN1-P", "role": "an", "x": 0, "y": 0},
{"fabric_node_id": "p2", "name": "BTM-AN2-P", "role": "an", "x": 100, "y": 0},
{"fabric_node_id": "a1", "name": "BTM-EN-A1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "a2", "name": "BTM-EN-A2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b1", "name": "BTM-EN-B1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "b2", "name": "BTM-EN-B2", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c1", "name": "BTM-EN-C1", "role": "en", "x": 0, "y": 0},
{"fabric_node_id": "c2", "name": "BTM-EN-C2", "role": "en", "x": 0, "y": 0},
]
edges = [
{"a_node_id": "p1", "b_node_id": "a1"},
{"a_node_id": "a1", "b_node_id": "a2"},
{"a_node_id": "a2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "b1"},
{"a_node_id": "b1", "b_node_id": "b2"},
{"a_node_id": "b2", "b_node_id": "p2"},
{"a_node_id": "p1", "b_node_id": "c1"},
{"a_node_id": "c1", "b_node_id": "c2"},
{"a_node_id": "c2", "b_node_id": "p2"},
]
st = build_state_from_nodes_edges(nodes, edges)
units = find_dual_portal_units(st)
assert units
world, reports, attach = layout_and_pack_batch(
st, units[:1], sink_pos={"z": (500.0, 0.0)}, pad=50.0, links=list(st.links)
)
assert world
assert attach.get("via") in {"orbit_orphan", "orbit_portal", "orbit_dual", "seed"}
assert reports and reports[0].get("accepted") is True
members = units[0].member_ids()
unit_links = [e for e in st.links if e[0] in members and e[1] in members]
assert count_edge_crossings(world, unit_links) == 0
def test_tools_registered() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
assert len(names) == 14

View file

@ -0,0 +1,179 @@
"""Tests for chain cohesion + min-ring integrity mid-tier metrics."""
from __future__ import annotations
from netx_topology_mcp.layout_stats import analyze_layout_stats, score_layout_components
from netx_topology_mcp.layout_topology_quality import (
compute_chain_cohesion,
compute_ring_integrity,
extract_chain_paths,
)
def _pos_links_from_nodes(nodes, edges):
pos = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes}
links = [(e["a_node_id"], e["b_node_id"]) for e in edges]
return pos, links
def test_straight_chain_scores_high() -> None:
# hub + straight corridor
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
from collections import defaultdict
adj: dict[str, set[str]] = defaultdict(set)
for a, b in links:
adj[a].add(b)
adj[b].add(a)
assert extract_chain_paths(adj)
q = compute_chain_cohesion(pos, links)
assert q["score"] >= 0.9
assert q["kink_count"] == 0
def test_kinked_chain_scores_lower() -> None:
nodes = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 200},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
edges = [
{"a_node_id": "h", "b_node_id": "a"},
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
bent = compute_chain_cohesion(pos, links)
# straighten
nodes2 = [
{"fabric_node_id": "h", "name": "H", "x": 0, "y": 0},
{"fabric_node_id": "a", "name": "A", "x": 200, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 400, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 600, "y": 0},
]
pos2, _ = _pos_links_from_nodes(nodes2, edges)
straight = compute_chain_cohesion(pos2, links)
assert straight["score"] > bent["score"]
assert bent["kink_count"] >= 1
def test_ring_pierce_detected() -> None:
# Square cycle + a chord that crosses through (diagonal would share verts;
# use an external edge that crosses one side).
# Cycle: a-b-c-d-a. External: e--f crosses a-b vertically.
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 200, "y": 200},
{"fabric_node_id": "d", "name": "D", "x": 0, "y": 200},
{"fabric_node_id": "e", "name": "E", "x": 100, "y": -50},
{"fabric_node_id": "f", "name": "F", "x": 100, "y": 50},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "c", "b_node_id": "d"},
{"a_node_id": "d", "b_node_id": "a"},
{"a_node_id": "e", "b_node_id": "f"},
]
pos, links = _pos_links_from_nodes(nodes, edges)
pierced = compute_ring_integrity(pos, links)
assert pierced["ring_count"] >= 1
assert pierced["rings_pierced"] >= 1
assert pierced["score"] < 1.0
# move e-f away → no pierce
nodes2 = [
*nodes[:4],
{"fabric_node_id": "e", "name": "E", "x": 300, "y": -50},
{"fabric_node_id": "f", "name": "F", "x": 300, "y": 50},
]
pos2, _ = _pos_links_from_nodes(nodes2, edges)
clean = compute_ring_integrity(pos2, links)
assert clean["rings_pierced"] == 0
assert clean["score"] == 1.0
def test_score_includes_mid_tier_weights() -> None:
s = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 0.2,
"rings_score": 0.2,
"edge_clearance_score": 0.2,
"edge_axis_score": 0.2,
}
)
assert "chain" in s["parts"]
assert "rings" in s["parts"]
assert "edge_clearance" in s["parts"]
assert "edge_axis" in s["parts"]
assert abs(s["weights"]["chain"] - 0.10) < 1e-9
assert abs(s["weights"]["rings"] - 0.10) < 1e-9
assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9
assert abs(s["weights"]["edge_axis"] - 0.06) < 1e-9
assert abs(s["weights"]["grid"] - 0.04) < 1e-9
assert abs(sum(s["weights"].values()) - 1.0) < 1e-9
good = score_layout_components(
{
"node_count": 40,
"edge_crossings": 0,
"crossings_per_link": 0.0,
"footprint_overlap_pairs": 0,
"label_overlap_pairs": 0,
"nn_p50": 170,
"space_utilization": 0.2,
"hull_utilization": 0.25,
"grid_occupancy": 0.4,
"edge_stretch_p50": 1.2,
"whitespace_index": 0.2,
"chain_score": 1.0,
"rings_score": 1.0,
"edge_clearance_score": 1.0,
"edge_axis_score": 1.0,
}
)
assert good["total"] > s["total"]
def test_report_exposes_chains_and_rings() -> None:
nodes = [
{"fabric_node_id": "a", "name": "A", "x": 0, "y": 0},
{"fabric_node_id": "b", "name": "B", "x": 200, "y": 0},
{"fabric_node_id": "c", "name": "C", "x": 400, "y": 0},
{"fabric_node_id": "d", "name": "D", "x": 200, "y": 200},
]
edges = [
{"a_node_id": "a", "b_node_id": "b"},
{"a_node_id": "b", "b_node_id": "c"},
{"a_node_id": "b", "b_node_id": "d"},
]
r = analyze_layout_stats(nodes, edges)["report"]
assert "chains" in r
assert "rings" in r
assert "chain" in r["score"]["parts"]
assert "rings" in r["score"]["parts"]
assert r["chains"]["status"] in {"ok", "warn", "fail"}
assert r["rings"]["status"] in {"ok", "warn", "fail"}

View file

@ -0,0 +1,54 @@
"""Untangle must not fling nodes across the canvas."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.untangle import _MAX_JUMP, _candidates, untangle_crossings
def test_candidates_stay_near_node() -> None:
pos = {
"a": (0.0, 0.0),
"b": (5000.0, 0.0), # far neighbor — old code used dx*3.5 → 17k jump
"c": (2500.0, 800.0),
}
adj = {"c": {"a", "b"}, "a": {"c"}, "b": {"c"}}
import random
cands = _candidates(pos, "c", adj, random.Random(1))
assert cands
x0, y0 = pos["c"]
for x, y in cands:
assert math.hypot(x - x0, y - y0) <= _MAX_JUMP + 1e-6
def test_untangle_preserves_bbox_order() -> None:
# Long chain with one intentional crossing-ish layout; moves stay local.
nodes = []
edges = []
for i in range(8):
nodes.append(
{
"fabric_node_id": f"n{i}",
"name": f"N{i}-EN-1",
"x": float(i * 200),
"y": 0.0 if i % 2 == 0 else 40.0,
}
)
if i:
edges.append({"a_node_id": f"n{i-1}", "b_node_id": f"n{i}"})
# add a long chord that crosses nothing badly but gives degree
edges.append({"a_node_id": "n0", "b_node_id": "n7"})
st = build_state_from_nodes_edges(nodes, edges)
before = dict(st.positions)
xs0 = [p[0] for p in before.values()]
ys0 = [p[1] for p in before.values()]
span0 = max(max(xs0) - min(xs0), max(ys0) - min(ys0))
out = untangle_crossings(st, max_rounds=40, max_degree=7, target_crossings=0)
xs1 = [p[0] for p in out.state.positions.values()]
ys1 = [p[1] for p in out.state.positions.values()]
span1 = max(max(xs1) - min(xs1), max(ys1) - min(ys1))
# Must not explode bbox by >2x
assert span1 <= span0 * 2.0 + 800.0