From 79f929de0ccd55d0e4834716711e72ca281060b6 Mon Sep 17 00:00:00 2001 From: oliver Date: Sun, 9 Aug 2026 23:18:53 +0800 Subject: [PATCH] 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 --- .cursor/skills/netx-topology/SKILL.md | 164 +- docs/MCP_TOPOLOGY.md | 40 +- packages/netx-topology-mcp/README.md | 18 +- packages/netx-topology-mcp/mcp.json | 4 +- packages/netx-topology-mcp/pyproject.toml | 11 +- .../src/netx_topology_mcp/__init__.py | 4 +- .../src/netx_topology_mcp/http_client.py | 62 +- .../src/netx_topology_mcp/http_tools.py | 3008 ++++++++++++++++- .../netx_topology_mcp/layout_job_worker.py | 92 + .../src/netx_topology_mcp/layout_jobs.py | 483 +++ .../src/netx_topology_mcp/layout_metrics.py | 811 +++++ .../netx_topology_mcp/layout_ops/__init__.py | 77 + .../layout_ops/channel_metro.py | 72 + .../netx_topology_mcp/layout_ops/channels.py | 404 +++ .../layout_ops/clear_edge_hits.py | 637 ++++ .../layout_ops/compose_orbit.py | 628 ++++ .../layout_ops/compose_views.py | 796 +++++ .../layout_ops/densify_sweep.py | 781 +++++ .../netx_topology_mcp/layout_ops/dual_mass.py | 451 +++ .../layout_ops/dual_units.py | 627 ++++ .../layout_ops/fold_chain.py | 295 ++ .../layout_ops/force_densify.py | 599 ++++ .../layout_ops/graph_util.py | 209 ++ .../netx_topology_mcp/layout_ops/hierarchy.py | 591 ++++ .../netx_topology_mcp/layout_ops/hotspots.py | 380 +++ .../layout_ops/mass_field.py | 591 ++++ .../layout_ops/mass_merge.py | 1049 ++++++ .../netx_topology_mcp/layout_ops/min_rings.py | 372 ++ .../layout_ops/orbit_sweep.py | 812 +++++ .../layout_ops/ortho_metro.py | 1158 +++++++ .../netx_topology_mcp/layout_ops/partition.py | 523 +++ .../netx_topology_mcp/layout_ops/pin_beam.py | 252 ++ .../layout_ops/press_crossings.py | 760 +++++ .../netx_topology_mcp/layout_ops/recipe.py | 352 ++ .../layout_ops/rigid_orbit.py | 566 ++++ .../layout_ops/rigid_units.py | 1053 ++++++ .../layout_ops/ring_faces.py | 181 + .../src/netx_topology_mcp/layout_ops/rings.py | 35 + .../src/netx_topology_mcp/layout_ops/scope.py | 153 + .../src/netx_topology_mcp/layout_ops/score.py | 57 + .../src/netx_topology_mcp/layout_ops/sides.py | 87 + .../layout_ops/sink_dual_units.py | 427 +++ .../netx_topology_mcp/layout_ops/skeleton.py | 231 ++ .../layout_ops/soft_petals.py | 323 ++ .../src/netx_topology_mcp/layout_ops/state.py | 75 + .../netx_topology_mcp/layout_ops/sugiyama.py | 2157 ++++++++++++ .../layout_ops/transforms.py | 844 +++++ .../netx_topology_mcp/layout_ops/untangle.py | 395 +++ .../src/netx_topology_mcp/layout_sight.py | 240 ++ .../src/netx_topology_mcp/layout_stats.py | 750 ++++ .../src/netx_topology_mcp/layout_structure.py | 622 ++++ .../src/netx_topology_mcp/layout_tool.py | 791 +++++ .../layout_topology_quality.py | 334 ++ .../src/netx_topology_mcp/server.py | 21 +- .../tests/test_channel_metro.py | 137 + .../tests/test_clear_edge_hits.py | 145 + .../tests/test_compose_orbit.py | 129 + .../tests/test_compose_views.py | 325 ++ .../netx-topology-mcp/tests/test_core_beam.py | 76 + .../tests/test_densify_sweep.py | 219 ++ .../netx-topology-mcp/tests/test_dual_mass.py | 169 + .../tests/test_dual_units.py | 121 + .../netx-topology-mcp/tests/test_edge_axis.py | 52 + .../tests/test_fold_chain.py | 89 + .../tests/test_force_densify.py | 141 + .../netx-topology-mcp/tests/test_hierarchy.py | 123 + .../netx-topology-mcp/tests/test_hotspots.py | 314 ++ .../tests/test_layout_jobs.py | 180 + .../tests/test_layout_ops.py | 525 +++ .../tests/test_layout_sight.py | 34 + .../tests/test_layout_stats.py | 132 + .../tests/test_layout_structure.py | 123 + .../tests/test_layout_tool.py | 185 + .../tests/test_mass_merge.py | 331 ++ .../tests/test_mcp_topology.py | 440 ++- .../tests/test_move_nodes.py | 36 + .../tests/test_orbit_sweep.py | 176 + .../tests/test_ortho_metro.py | 113 + .../netx-topology-mcp/tests/test_partition.py | 166 + .../tests/test_pin_beam_soft_petals.py | 84 + .../tests/test_press_crossings.py | 162 + .../tests/test_rigid_orbit.py | 145 + .../tests/test_rigid_units.py | 151 + .../tests/test_sink_dual_units.py | 107 + .../tests/test_topology_quality.py | 179 + .../netx-topology-mcp/tests/test_untangle.py | 54 + 86 files changed, 31578 insertions(+), 240 deletions(-) create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py create mode 100644 packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py create mode 100644 packages/netx-topology-mcp/tests/test_channel_metro.py create mode 100644 packages/netx-topology-mcp/tests/test_clear_edge_hits.py create mode 100644 packages/netx-topology-mcp/tests/test_compose_orbit.py create mode 100644 packages/netx-topology-mcp/tests/test_compose_views.py create mode 100644 packages/netx-topology-mcp/tests/test_core_beam.py create mode 100644 packages/netx-topology-mcp/tests/test_densify_sweep.py create mode 100644 packages/netx-topology-mcp/tests/test_dual_mass.py create mode 100644 packages/netx-topology-mcp/tests/test_dual_units.py create mode 100644 packages/netx-topology-mcp/tests/test_edge_axis.py create mode 100644 packages/netx-topology-mcp/tests/test_fold_chain.py create mode 100644 packages/netx-topology-mcp/tests/test_force_densify.py create mode 100644 packages/netx-topology-mcp/tests/test_hierarchy.py create mode 100644 packages/netx-topology-mcp/tests/test_hotspots.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_jobs.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_ops.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_sight.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_stats.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_structure.py create mode 100644 packages/netx-topology-mcp/tests/test_layout_tool.py create mode 100644 packages/netx-topology-mcp/tests/test_mass_merge.py create mode 100644 packages/netx-topology-mcp/tests/test_move_nodes.py create mode 100644 packages/netx-topology-mcp/tests/test_orbit_sweep.py create mode 100644 packages/netx-topology-mcp/tests/test_ortho_metro.py create mode 100644 packages/netx-topology-mcp/tests/test_partition.py create mode 100644 packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py create mode 100644 packages/netx-topology-mcp/tests/test_press_crossings.py create mode 100644 packages/netx-topology-mcp/tests/test_rigid_orbit.py create mode 100644 packages/netx-topology-mcp/tests/test_rigid_units.py create mode 100644 packages/netx-topology-mcp/tests/test_sink_dual_units.py create mode 100644 packages/netx-topology-mcp/tests/test_topology_quality.py create mode 100644 packages/netx-topology-mcp/tests/test_untangle.py diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index 68ec148..d29873c 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -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: , view_id: , + 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 写拓扑。 diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index bafadd6..94a5040 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -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 / 删整图。 diff --git a/packages/netx-topology-mcp/README.md b/packages/netx-topology-mcp/README.md index d1c745c..b5b6405 100644 --- a/packages/netx-topology-mcp/README.md +++ b/packages/netx-topology-mcp/README.md @@ -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 节点。 diff --git a/packages/netx-topology-mcp/mcp.json b/packages/netx-topology-mcp/mcp.json index 8b64ad7..96399d6 100644 --- a/packages/netx-topology-mcp/mcp.json +++ b/packages/netx-topology-mcp/mcp.json @@ -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" } } } diff --git a/packages/netx-topology-mcp/pyproject.toml b/packages/netx-topology-mcp/pyproject.toml index 1047996..39b499c 100644 --- a/packages/netx-topology-mcp/pyproject.toml +++ b/packages/netx-topology-mcp/pyproject.toml @@ -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" diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py index 4011e88..543839c 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py @@ -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" diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py index d24e955..2fcd805 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_client.py @@ -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: diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py index db8d0b8..1123f39 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -2,9 +2,21 @@ from __future__ import annotations +import sys from typing import Any, Callable -from netx_topology_mcp.http_client import http_json, mcp_from_handler_result +from netx_topology_mcp.http_client import http_json, http_json_many, mcp_from_handler_result +from netx_topology_mcp.layout_jobs import ( + cancel_job, + job_public, + raise_if_cancelled, + report_progress, + start_job, +) +from netx_topology_mcp.layout_sight import build_sight +from netx_topology_mcp.layout_stats import analyze_layout_stats +from netx_topology_mcp.layout_structure import analyze_graph_structure +from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph def _data(out: dict[str, Any]) -> dict[str, Any]: @@ -21,44 +33,1385 @@ def _data(out: dict[str, Any]) -> dict[str, Any]: return {"ok": True, "data": data} -def _get_topology_tree(_args: dict[str, Any]) -> dict[str, Any]: - return _data(http_json("GET", "/v1/topology/tree")) +_FABRIC_KEEP = ( + "id", + "name", + "ip", + "vendor", + "device_type", + "role", + "link_status", + "region_folder_id", + "managed_ne_id", + "ume_ne_id", +) -def _list_topology_views(_args: dict[str, Any]) -> dict[str, Any]: - return _data(http_json("GET", "/v1/topology/views")) +def _compact_fabric_item(item: dict[str, Any]) -> dict[str, Any]: + """Drop bulky attrs / coordinate noise for agent context.""" + out = {k: item.get(k) for k in _FABRIC_KEEP if k in item} + views = item.get("views") + if isinstance(views, list): + slim: list[dict[str, Any]] = [] + for v in views[:12]: + if not isinstance(v, dict): + continue + slim.append( + { + "view_id": v.get("view_id") or v.get("id") or "", + "view_name": v.get("view_name") or v.get("name") or "", + "folder_name": v.get("folder_name") or "", + "kind": v.get("kind") or "", + } + ) + if slim: + out["views"] = slim + if len(views) > 12: + out["views_truncated"] = True + return out + + +def _compact_fabric_page(payload: dict[str, Any]) -> dict[str, Any]: + if not payload.get("ok"): + return payload + items = payload.get("items") + if not isinstance(items, list): + return payload + out = dict(payload) + out["items"] = [_compact_fabric_item(x) if isinstance(x, dict) else x for x in items] + return out + + +def _compact_folder_node(node: dict[str, Any], *, depth: int, max_depth: int | None) -> dict[str, Any]: + views_in = [v for v in (node.get("views") or []) if isinstance(v, dict)] + views = [ + { + "id": v.get("id") or "", + "name": v.get("name") or "", + "kind": v.get("kind") or "", + "node_count": v.get("node_count"), + } + for v in views_in + ] + out: dict[str, Any] = { + "id": node.get("id") or "", + "parent_id": node.get("parent_id") or "", + "kind": node.get("kind") or "", + "name": node.get("name") or "", + "ne_count": int(node.get("ne_count") or 0), + "is_system": bool(node.get("is_system")), + "views": views, + } + children_in = [c for c in (node.get("children") or []) if isinstance(c, dict)] + if max_depth is not None and depth >= max_depth: + if children_in: + out["children_truncated"] = len(children_in) + out["children"] = [] + else: + out["children"] = [] + return out + out["children"] = [ + _compact_folder_node(c, depth=depth + 1, max_depth=max_depth) for c in children_in + ] + return out + + +def _edge_endpoints(edge: dict[str, Any]) -> tuple[str, str]: + a = str(edge.get("a_node_id") or edge.get("a") or "").strip() + b = str(edge.get("b_node_id") or edge.get("b") or "").strip() + return a, b + + +def _collapse_edges_to_links( + edges: list[dict[str, Any]], + *, + include_names: bool = False, +) -> list[dict[str, Any]]: + """Collapse parallel port-level edges to one undirected NE↔NE link + link_count. + + Canvas drawing only needs existence (and optionally multiplicity), not ports/labels. + """ + buckets: dict[tuple[str, str], dict[str, Any]] = {} + for e in edges: + if not isinstance(e, dict): + continue + a, b = _edge_endpoints(e) + if not a or not b or a == b: + continue + key = (a, b) if a < b else (b, a) + row = buckets.get(key) + if row is None: + lo, hi = key + row = {"a_node_id": lo, "b_node_id": hi, "link_count": 0} + if include_names: + # Prefer names matching the canonical endpoint order. + if a == lo: + row["a_name"] = str(e.get("a_name") or "") + row["b_name"] = str(e.get("b_name") or "") + else: + row["a_name"] = str(e.get("b_name") or "") + row["b_name"] = str(e.get("a_name") or "") + buckets[key] = row + row["link_count"] = int(row["link_count"]) + 1 + return sorted(buckets.values(), key=lambda r: (r["a_node_id"], r["b_node_id"])) + + +def _summarize_view_graph(graph: dict[str, Any], *, sample: int = 20) -> dict[str, Any]: + """Shrink getTopologyView / projectNeighbors payloads for agents.""" + view = graph.get("view") if isinstance(graph.get("view"), dict) else {} + nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)] + edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)] + peers = graph.get("outside_peers") if isinstance(graph.get("outside_peers"), list) else [] + sample_n = max(0, min(200, int(sample))) + links = _collapse_edges_to_links(edges) + return { + "ok": True, + "detail": "summary", + "view": { + "id": view.get("id") or graph.get("view_id") or "", + "name": view.get("name") or "", + "folder_id": view.get("folder_id") or "", + "kind": view.get("kind") or "", + "node_count": int(view.get("node_count") or len(nodes)), + }, + "node_count": len(nodes), + "edge_count": len(edges), + "link_count": len(links), + "links": links, + "truncated": bool(graph.get("truncated")), + "truncate_reason": str(graph.get("truncate_reason") or ""), + "outside_peer_count": len(peers), + "sample_nodes": [ + { + "fabric_node_id": n.get("fabric_node_id") or n.get("id") or "", + "name": n.get("name") or n.get("label") or "", + "ip": n.get("ip") or "", + "x": n.get("x"), + "y": n.get("y"), + } + for n in nodes[:sample_n] + ], + "hint": ( + "links[] = undirected NE pairs (one canvas edge each); edge_count is raw port-level. " + "Use sample>=node_count for full id/name coverage when laying out. " + "Place from links[] + names. detail=full only for every membership field." + ), + } + + +def _get_topology_tree(args: dict[str, Any]) -> dict[str, Any]: + out = _data(http_json("GET", "/v1/topology/tree")) + if not out.get("ok"): + return out + compact = str(args.get("compact") if args.get("compact") is not None else "true").strip().lower() + if compact in {"0", "false", "no", "full"}: + return out + max_depth_raw = args.get("max_depth") + max_depth: int | None = None + if max_depth_raw is not None and str(max_depth_raw).strip() != "": + try: + max_depth = max(0, int(max_depth_raw)) + except (TypeError, ValueError): + max_depth = None + root = out.get("root") if isinstance(out.get("root"), dict) else None + if root is None: + return out + return { + "ok": True, + "compact": True, + "max_depth": max_depth, + "root": _compact_folder_node(root, depth=0, max_depth=max_depth), + } def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]: view_id = str(args.get("view_id") or "").strip() if not view_id: return {"ok": False, "error": "view_id_required"} - return _data(http_json("GET", f"/v1/topology/views/{view_id}")) + out = _data(http_json("GET", f"/v1/topology/views/{view_id}")) + if not out.get("ok"): + return out + detail = str(args.get("detail") or "summary").strip().lower() + if detail in {"full", "raw", "graph"}: + out["detail"] = "full" + return out + sample = int(args.get("sample") or 20) + return _summarize_view_graph(out, sample=sample) -def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]: - name = str(args.get("name") or "").strip() - folder_id = str(args.get("folder_id") or "").strip() - if not name: - return {"ok": False, "error": "name_required"} - if not folder_id: - return {"ok": False, "error": "folder_id_required"} - body: dict[str, Any] = { - "name": name, - "folder_id": folder_id, - "remark": str(args.get("remark") or ""), - "kind": str(args.get("kind") or "custom").strip() or "custom", - "role": str(args.get("role") or "core").strip() or "core", - "sort_order": int(args.get("sort_order") or 0), +def _collect_physical_views( + node: dict[str, Any] | None, + *, + out: list[dict[str, Any]], + max_views: int, +) -> None: + if not isinstance(node, dict) or len(out) >= max_views: + return + for v in node.get("views") or []: + if not isinstance(v, dict): + continue + if str(v.get("kind") or "").strip().lower() not in {"", "physical"}: + continue + vid = str(v.get("id") or "").strip() + if not vid: + continue + out.append( + { + "view_id": vid, + "view_name": str(v.get("name") or ""), + "folder_id": str(node.get("id") or ""), + "folder_name": str(node.get("name") or ""), + "ne_count": int(node.get("ne_count") or v.get("node_count") or 0), + } + ) + if len(out) >= max_views: + return + for child in node.get("children") or []: + if isinstance(child, dict): + _collect_physical_views(child, out=out, max_views=max_views) + + +def _analyze_one_view( + view_id: str, + *, + with_meta: bool = False, + detail: str = "summary", + sight_limit: int = 40, + sight_cell: float = 600.0, +) -> dict[str, Any]: + graph = _data(http_json("GET", f"/v1/topology/views/{view_id}")) + if not graph.get("ok"): + return {"ok": False, "view_id": view_id, "error": graph.get("error") or "view_fetch_failed"} + nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)] + edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)] + view = graph.get("view") if isinstance(graph.get("view"), dict) else {} + stats = analyze_layout_stats(nodes, edges, with_meta=with_meta) + report = dict(stats.get("report") or {}) + out: dict[str, Any] = { + "ok": True, + "view_id": view_id, + "view_name": str(view.get("name") or ""), + "folder_id": str(view.get("folder_id") or ""), + **report, + # Compact one-liner for agents / folder samples + "summary": stats.get("summary") or {}, } - filt = args.get("filter") - if isinstance(filt, dict): - body["filter"] = filt - return _data(http_json("POST", "/v1/topology/views", body=body)) + # detail=structure → graph gravity / layer attachment (phase 0.5, no coords needed) + # detail=hotspots|blocks|both → agent "sight" for hand-drag (phase 2) + d = (detail or "summary").strip().lower() + if d in {"structure", "gravity", "plan", "both", "all"}: + try: + hub_top_k = max(4, min(40, int(sight_limit or 12))) + except (TypeError, ValueError): + hub_top_k = 12 + out["structure"] = analyze_graph_structure( + nodes, edges, hub_top_k=hub_top_k, stub_top_k=max(12, hub_top_k * 2) + ) + if d in {"hotspots", "blocks", "both", "all", "sight"}: + mode = "both" if d in {"both", "all", "sight"} else d + out["sight"] = build_sight( + nodes, edges, mode=mode, limit=max(5, min(80, int(sight_limit))), cell=float(sight_cell) + ) + # Prefer exact crossing count from score metrics when present. + metrics = (stats.get("metrics") or {}) if isinstance(stats, dict) else {} + if isinstance(out.get("sight"), dict) and isinstance(out["sight"].get("hotspots"), dict): + if metrics.get("edge_crossings") is not None: + out["sight"]["hotspots"]["edge_crossings"] = metrics.get("edge_crossings") + elif isinstance(report.get("crossing"), dict): + out["sight"]["hotspots"]["edge_crossings"] = report["crossing"].get( + "edge_crossings" + ) + return out + + +def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> dict[str, Any]: + updated = 0 + chunks = 0 + # Larger chunks cut round-trips on 1k+ node canvases (Cursor MCP ~60s budget). + step = 250 + total = len(positions) + for i in range(0, total, step): + raise_if_cancelled() + chunk = positions[i : i + step] + report_progress( + "apply", + pct=90.0 + 9.0 * (i / max(total, 1)), + message=f"PATCH {i + len(chunk)}/{total}", + step=i + len(chunk), + total_steps=total, + ) + out = _data( + http_json( + "PATCH", + f"/v1/topology/views/{view_id}/positions", + body={"positions": chunk, "return_graph": False}, + timeout=180.0, + ) + ) + if not out.get("ok"): + return { + "ok": False, + "error": out.get("error") or "positions_patch_failed", + "updated": updated, + "detail": out, + } + updated += int(out.get("updated") or len(chunk)) + chunks += 1 + return {"ok": True, "updated": updated, "chunks": chunks} + + +def _slim_layout_payload(out: dict[str, Any]) -> dict[str, Any]: + """Trim giant layout responses so MCP JSON stays under host timeouts.""" + n = int(out.get("node_count") or 0) + action = str(out.get("action") or "") + heavy = n >= 400 or action in { + "polish_crossings", + "orbit_sweep", + "clear_edge_hits", + "layout_dual_unit", + } + if not heavy: + return out + slim = dict(out) + slim.pop("guide", None) + slim.pop("tried", None) + slim.pop("params_used", None) + # Keep summary + compact crossing tops; drop bulky sub-reports. + for k in ("spacing", "sparsity", "edges", "chains", "rings", "size"): + if k in slim and isinstance(slim[k], dict): + slim[k] = { + kk: slim[k].get(kk) + for kk in ("status", "score", "edge_crossings", "space_utilization", "nn_p50") + if kk in slim[k] + } or slim[k] + crossing = slim.get("crossing") + if isinstance(crossing, dict): + slim["crossing"] = { + "status": crossing.get("status"), + "score": crossing.get("score"), + "edge_crossings": crossing.get("edge_crossings"), + "crossings_per_link": crossing.get("crossings_per_link"), + "top_nodes": (crossing.get("top_nodes") or [])[:5], + "top_edges": (crossing.get("top_edges") or [])[:5], + } + local = slim.get("local") + if isinstance(local, dict): + meta = local.get("meta") + if isinstance(meta, dict): + # Drop bulky slot_meta / origins from compose echo. + keep_meta = { + kk: meta.get(kk) + for kk in ( + "slots", + "nodes", + "pad", + "merge_shared", + "order_mode", + "pack_mode", + "tip", + ) + if kk in meta + } + # Keep rigid_groups keys only (ids stay in compose_session). + rg = meta.get("rigid_groups") + if isinstance(rg, list): + keep_meta["rigid_groups_n"] = len(rg) + local = {**local, "meta": keep_meta} + slim["local"] = local + slim["slim"] = True + return slim + + +def _truthy(v: Any) -> bool: + if isinstance(v, bool): + return v + return str(v or "").strip().lower() in {"1", "true", "yes", "on"} + + +def _fetch_views_parallel(view_ids: list[str]) -> dict[str, dict[str, Any]]: + """GET many views in parallel; return map view_id → payload (_data shape).""" + uniq: list[str] = [] + seen: set[str] = set() + for vid in view_ids: + s = str(vid or "").strip() + if s and s not in seen: + seen.add(s) + uniq.append(s) + if not uniq: + return {} + reqs = [ + {"method": "GET", "path": f"/v1/topology/views/{sid}", "key": sid} + for sid in uniq + ] + print(f"[netx-topology] parallel GET views n={len(reqs)}", file=sys.stderr, flush=True) + raw = http_json_many(reqs, max_workers=min(16, len(reqs)), timeout=120.0) + out: dict[str, dict[str, Any]] = {} + for sid, envelope in zip(uniq, raw): + out[sid] = _data(envelope) + return out + + +def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: + """Generic layout / local polish: preview or apply onto a canvas.""" + if str(args.get("catalog") or "").strip().lower() in {"1", "true", "yes"} or ( + args.get("catalog") is True + ): + return {"ok": True, **list_layout_catalog()} + + action = str(args.get("action") or "layout").strip().lower() or "layout" + overrides0 = args.get("params") if isinstance(args.get("params"), dict) else {} + + # Public surface only (legacy modules may remain in-tree but are not exposed). + _public = { + "layout", + "fix_overlaps", + "resolve_overlaps", + "untangle", + "straighten_channels", + "layout_dual_unit", + "polish_crossings", + "clear_edge_hits", + "orbit_sweep", + "move_nodes", + "sink_nodes", + "job_status", + "job_cancel", + } + if action not in _public: + return { + "ok": False, + "error": f"unknown_action:{action}", + "hint": ( + "Public actions: layout|layout_dual_unit|move_nodes|orbit_sweep|" + "polish_crossings|clear_edge_hits|fix_overlaps|untangle|" + "straighten_channels|job_status|job_cancel. " + "Main path: sinkTopologyDualUnits → orbit_sweep → polish → clear." + ), + **list_layout_catalog(), + } + if action == "resolve_overlaps": + action = "fix_overlaps" + args = {**args, "action": "fix_overlaps"} + + # Bidirectional membership move: view_id=TO, source_view_id=FROM. + if action in {"move_nodes", "sink_nodes"}: + return _move_topology_view_nodes(args) + + # Background job poll / cancel (no view_id required). + if action in {"job_status", "job_cancel"}: + job_id = str( + overrides0.get("job_id") or args.get("job_id") or "" + ).strip() + if not job_id: + return { + "ok": False, + "error": "job_id_required", + "hint": "params.job_id from polish/orbit/layout_dual_unit background start.", + } + if action == "job_cancel": + cancelled = cancel_job(job_id) + # Return public snapshot after arming cancel. + snap = job_public(job_id) or {} + return { + **cancelled, + "action": "job_cancel", + "progress": snap.get("progress"), + "elapsed_ms": snap.get("elapsed_ms"), + "heartbeat_age_ms": snap.get("heartbeat_age_ms"), + } + job = job_public(job_id) + if not job: + return { + "ok": False, + "error": "job_not_found", + "job_id": job_id, + "hint": ( + "Unknown job_id. Jobs are in-process on this MCP worker; " + "restart or another instance cannot see them." + ), + } + status = str(job.get("status") or "unknown") + out: dict[str, Any] = { + "ok": True, + "action": "job_status", + "job_id": job_id, + "status": status, + "view_id": job.get("view_id"), + "job_action": job.get("action"), + "started_at": job.get("started_at"), + "finished_at": job.get("finished_at"), + "elapsed_ms": job.get("elapsed_ms"), + "heartbeat_at": job.get("heartbeat_at"), + "heartbeat_age_ms": job.get("heartbeat_age_ms"), + "stale": bool(job.get("stale")), + "cancel_requested": bool(job.get("cancel_requested")), + "progress": job.get("progress") or {}, + "meta": job.get("meta"), + } + if status in {"running", "cancelling"}: + prog = out["progress"] if isinstance(out["progress"], dict) else {} + phase = prog.get("phase") or status + pct = prog.get("pct") + pct_s = f" {pct:.0f}%" if isinstance(pct, (int, float)) else "" + if bool(out.get("stale")): + out["hint"] = ( + f"WARNING stale=true (no heartbeat ≥90s). " + f"Last phase={phase}{pct_s}; elapsed_ms={out['elapsed_ms']}. " + "job_cancel to arm cooperative stop; restart MCP if it never ends." + ) + else: + out["hint"] = ( + f"Running phase={phase}{pct_s}; elapsed_ms={out['elapsed_ms']}; " + f"heartbeat_age_ms={out['heartbeat_age_ms']}. " + "Re-poll job_status; job_cancel to request cooperative stop." + ) + return out + result = job.get("result") + if isinstance(result, dict): + out["result"] = _slim_layout_payload(result) + out["ok"] = bool(result.get("ok")) and status == "done" + if not out["ok"]: + out["error"] = result.get("error") or job.get("error") + elif status == "error": + out["ok"] = False + out["error"] = job.get("error") or "job_failed" + elif status == "cancelled": + out["ok"] = False + out["error"] = "cancelled" + return out + + view_id = str(args.get("view_id") or "").strip() + if not view_id: + return { + "ok": False, + "error": "view_id_required", + "hint": "Pass view_id, or catalog=true for recipe/preset list.", + **list_layout_catalog(), + } + # Local polish always reads current target coords (ignore foreign source) + source_id = str(args.get("source_view_id") or "").strip() or view_id + if action in { + "fix_overlaps", + "untangle", + "straighten_channels", + "layout_dual_unit", + "polish_crossings", + "clear_edge_hits", + "orbit_sweep", + }: + source_id = view_id + recipe = str(args.get("recipe") or "rings").strip().lower() or "rings" + preset = str(args.get("preset") or "balanced").strip().lower() or "balanced" + mode = str(args.get("mode") or "preview").strip().lower() or "preview" + if mode not in {"preview", "apply"}: + return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"} + tune = str(args.get("tune") or "").strip().lower() in {"1", "true", "yes"} or ( + args.get("tune") is True + ) + overrides = args.get("params") if isinstance(args.get("params"), dict) else None + if overrides is not None: + overrides = dict(overrides) + + # compose_views / compose_orbit: load staging canvases, inject blocks. + if action in {"compose_views", "compose_orbit"}: + from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides + + knobs = compose_params_from_overrides(overrides) + src_ids = list(knobs.get("source_view_ids") or []) + if not src_ids: + return { + "ok": False, + "error": "source_view_ids_required", + "hint": "params.source_view_ids=[...] staging view ids to compose.", + } + # Giant compose often exceeds Cursor MCP ~60s tool timeout — run async + # unless caller forces sync (background job / params.sync=true). + force_sync = _truthy((overrides or {}).get("sync")) or _truthy( + (overrides or {}).get("_force_sync") + ) + want_bg = _truthy((overrides or {}).get("background")) or ( + mode == "apply" and len(src_ids) >= 12 and not force_sync + ) + if want_bg and mode == "apply": + sync_args = dict(args) + sync_params = dict(overrides or {}) + sync_params["_force_sync"] = True + sync_params.pop("background", None) + sync_args["params"] = sync_params + sync_args["mode"] = "apply" + sync_args["action"] = action + job_id = start_job( + action=action, + view_id=view_id, + tool_args=sync_args, + meta={"source_n": len(src_ids)}, + ) + print( + f"[netx-topology] {action} background job_id={job_id} " + f"sources={len(src_ids)}", + file=sys.stderr, + flush=True, + ) + return { + "ok": True, + "action": action, + "view_id": view_id, + "mode": "apply", + "applied": False, + "status": "running", + "job_id": job_id, + "source_count": len(src_ids), + "hint": ( + f"{action} durable worker job_id={job_id}. " + "Poll job_status (progress/heartbeat); survives MCP restart." + ), + } + + report_progress( + "fetch_sources", + pct=5.0, + message=f"loading {len(src_ids)} staging views", + step=0, + total_steps=len(src_ids), + ) + raise_if_cancelled() + fetched = _fetch_views_parallel(src_ids) + report_progress( + "fetch_sources", + pct=20.0, + message=f"loaded {len(fetched)} staging views", + step=len(src_ids), + total_steps=len(src_ids), + ) + compose_blocks: list[dict[str, Any]] = [] + for sid in src_ids: + sg = fetched.get(sid) or {} + if not sg.get("ok"): + return { + "ok": False, + "error": "compose_source_fetch_failed", + "source_view_id": sid, + "detail": sg.get("error"), + } + pos_map: dict[str, list[float]] = {} + for n in sg.get("nodes") or []: + if not isinstance(n, dict): + continue + nid = str(n.get("fabric_node_id") or "").strip() + if not nid: + continue + try: + pos_map[nid] = [float(n.get("x") or 0), float(n.get("y") or 0)] + except (TypeError, ValueError): + continue + if pos_map: + compose_blocks.append({"key": sid, "positions": pos_map}) + if len(compose_blocks) < 1: + return {"ok": False, "error": "compose_sources_empty"} + overrides = overrides or {} + overrides["source_view_ids"] = src_ids + overrides["_compose_blocks"] = compose_blocks + for k in ( + "pad", + "merge_shared", + "fabric_bridges", + "bridge_boost", + "ideal_scale", + "spring_iters", + ): + if k in knobs: + overrides[k] = knobs[k] + # Staging may have emptied the target — restore members before scoring. + if mode == "apply": + all_ids: list[str] = [] + seen_ids: set[str] = set() + for blk in compose_blocks: + for nid in (blk.get("positions") or {}): + sid = str(nid) + if sid and sid not in seen_ids: + seen_ids.add(sid) + all_ids.append(sid) + dst0 = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0)) + have = { + str(n.get("fabric_node_id") or "") + for n in (dst0.get("nodes") or []) + if isinstance(n, dict) + } + missing0 = [i for i in all_ids if i not in have] + if missing0: + add_back0 = _add_topology_view_nodes( + { + "view_id": view_id, + "fabric_node_ids": missing0, + "layout": "keep", + } + ) + overrides["_restored_early"] = int( + add_back0.get("added") or len(missing0) + ) + + # Staging membership → portal freeze for polish / untangle. + # orbit_sweep intentionally omits auto portal freeze (default protect_rigid=off). + if action in { + "untangle", + "polish_crossings", + }: + from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides + + knobs = compose_params_from_overrides(overrides) + src_ids = list(knobs.get("source_view_ids") or []) + overrides = overrides or {} + # Prefer session echo from compose (skip 77× GET). + sess = overrides.get("compose_session") + if isinstance(sess, dict): + if isinstance(sess.get("portal_ids"), list) and "portal_ids" not in overrides: + overrides["portal_ids"] = [str(x) for x in sess["portal_ids"] if str(x)] + if isinstance(sess.get("mass_groups"), list) and "mass_groups" not in overrides: + overrides["mass_groups"] = sess["mass_groups"] + if isinstance(sess.get("rigid_groups"), list) and "_rigid_membership" not in overrides: + membership = [] + for g in sess["rigid_groups"]: + if not isinstance(g, dict): + continue + key = str(g.get("key") or "").strip() + ids = [str(x) for x in (g.get("node_ids") or []) if str(x)] + pivots = [str(x) for x in (g.get("pivots") or []) if str(x)] + if key and len(ids) >= 2: + membership.append( + {"key": key, "node_ids": ids, "pivots": pivots} + ) + if membership: + overrides["_rigid_membership"] = membership + src_ids = [] # skip re-fetch + if src_ids and "_rigid_membership" not in overrides: + # polish with only portal_ids: skip membership fetch + if ( + action == "polish_crossings" + and isinstance(overrides.get("portal_ids"), list) + and overrides.get("portal_ids") + ): + overrides["source_view_ids"] = list( + knobs.get("source_view_ids") or src_ids + ) + else: + fetched = _fetch_views_parallel(src_ids) + membership = [] + counts: dict[str, int] = {} + for sid in src_ids: + sg = fetched.get(sid) or {} + if not sg.get("ok"): + return { + "ok": False, + "error": "rigid_source_fetch_failed", + "source_view_id": sid, + "detail": sg.get("error"), + } + ids = [ + str(n.get("fabric_node_id") or "") + for n in (sg.get("nodes") or []) + if isinstance(n, dict) + and n.get("fabric_node_id") + and not str(n.get("fabric_node_id")).startswith("region:") + ] + for nid in ids: + counts[nid] = counts.get(nid, 0) + 1 + if ids: + membership.append({"key": sid, "node_ids": ids, "pivots": []}) + shared = {nid for nid, c in counts.items() if c > 1} + for row in membership: + row["pivots"] = sorted(n for n in row["node_ids"] if n in shared) + overrides["source_view_ids"] = src_ids + overrides["_rigid_membership"] = membership + if shared and "portal_ids" not in overrides: + overrides["portal_ids"] = sorted(shared) + + report_progress("load_canvas", pct=25.0, message=f"GET view {source_id[:12]}…") + raise_if_cancelled() + graph = _data(http_json("GET", f"/v1/topology/views/{source_id}", timeout=120.0)) + if not graph.get("ok"): + return { + "ok": False, + "error": graph.get("error") or "source_view_fetch_failed", + "source_view_id": source_id, + } + nodes = [n for n in (graph.get("nodes") or []) if isinstance(n, dict)] + edges = [e for e in (graph.get("edges") or []) if isinstance(e, dict)] + if len(nodes) < 2: + return {"ok": False, "error": "too_few_nodes", "node_count": len(nodes)} + report_progress( + "load_canvas", + pct=30.0, + message=f"canvas n={len(nodes)} e={len(edges)}", + nodes=len(nodes), + edges=len(edges), + ) + + # Giant-canvas polish/untangle often exceeds host ~60s — background apply. + force_sync = _truthy((overrides or {}).get("sync")) or _truthy( + (overrides or {}).get("_force_sync") + ) + heavy_bg = action in { + "polish_crossings", + "orbit_sweep", + "clear_edge_hits", + "layout_dual_unit", + } + if ( + mode == "apply" + and heavy_bg + and not force_sync + and (len(nodes) >= 600 or _truthy((overrides or {}).get("background"))) + ): + sync_args = dict(args) + sync_params = dict(overrides or {}) + sync_params["_force_sync"] = True + # Keep already-resolved membership so the worker skips re-fetch. + if overrides and overrides.get("_rigid_membership"): + sync_params["_rigid_membership"] = overrides["_rigid_membership"] + if overrides and overrides.get("portal_ids"): + sync_params["portal_ids"] = overrides["portal_ids"] + sync_params.pop("background", None) + sync_args["params"] = sync_params + sync_args["mode"] = "apply" + sync_args["action"] = action + sync_args["view_id"] = view_id + job_id = start_job( + action=action, + view_id=view_id, + tool_args=sync_args, + meta={"node_count": len(nodes)}, + ) + print( + f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}", + file=sys.stderr, + flush=True, + ) + return { + "ok": True, + "action": action, + "view_id": view_id, + "mode": "apply", + "applied": False, + "status": "running", + "job_id": job_id, + "node_count": len(nodes), + "hint": ( + f"{action} durable worker job_id={job_id}. " + "Poll job_status (progress/heartbeat); survives MCP restart." + ), + } + + # orbit_sweep: preview suggests only; apply defaults pick=1 (unless round). + if action == "orbit_sweep" and mode == "apply": + overrides = dict(overrides or {}) + if ( + not overrides.get("round") + and overrides.get("pick") is None + ): + if overrides.get("node_id") or overrides.get("fabric_node_id"): + overrides["pick"] = 1 + + try: + report_progress( + "compute", + pct=40.0, + message=f"action={action}", + action=action, + ) + raise_if_cancelled() + result = run_layout_on_graph( + nodes, + edges, + action=action, + recipe=recipe, + preset=preset, + params=overrides, + tune=tune, + ) + report_progress("compute", pct=75.0, message=f"{action} finished") + except ValueError as e: + return {"ok": False, "error": str(e), **list_layout_catalog()} + + positions = result.pop("positions", []) + ov = int((result.get("overlap") or {}).get("footprint_pairs") or 0) + lbl = int((result.get("overlap") or {}).get("label_pairs") or 0) + report_progress( + "score", + pct=80.0, + message=f"ov={ov} lbl={lbl} positions={len(positions)}", + overlaps=ov, + label_overlaps=lbl, + positions=len(positions), + ) + out = { + **result, + "view_id": view_id, + "source_view_id": source_id, + "mode": mode, + "applied": False, + } + if mode == "preview": + out["hint"] = ( + "Preview only. Re-call with mode=apply to PATCH. " + "If overlaps remain: action=fix_overlaps. " + "Main path: orbit_sweep → polish_crossings → clear_edge_hits." + ) + return _slim_layout_payload(out) + + # Dual-unit: gate on unit crossings only. Staging eyes often have label + # footprint touches that fix_overlaps would re-cross; allow apply when + # accepted (unit-internal crossings=0). + if action == "layout_dual_unit": + loc = result.get("local") or {} + if not loc.get("accepted", False): + return { + **out, + "ok": False, + "error": "dual_unit_crossings", + "hint": ( + "layout_dual_unit requires unit-internal crossings=0. " + "Check membership (portals+corridors+tails) or re-detect dual_units." + ), + "local": loc, + } + elif ov > 0 or lbl > 0: + return _slim_layout_payload( + { + **out, + "ok": False, + "error": "overlaps_remain", + "hint": ( + "Refusing apply while footprints/labels overlap. " + "Call layoutTopologyView with action=fix_overlaps, mode=apply." + ), + } + ) + + # Local polish must not silently worsen the canvas. + if action in { + "untangle", + "straighten_channels", + }: + before_x = int( + (analyze_layout_stats(nodes, edges).get("summary") or {}).get("crossings") or 0 + ) + after_x = int((result.get("crossing") or {}).get("edge_crossings") or 0) + slack = 0 if action == "straighten_channels" else max(40, int(before_x * 0.15)) + if after_x > before_x + slack: + return { + **out, + "ok": False, + "error": "crossing_regression", + "before_crossings": before_x, + "after_crossings": after_x, + "slack": slack, + "hint": ( + "Refusing apply: local polish raised crossings. " + "Keep current coords; preview first, or try orbit_sweep → " + "polish_crossings → clear_edge_hits, then surgical " + "updateTopologyViewPositions." + ), + } + + # compose_*: staging may have emptied the target — restore members first. + restored = 0 + if action in {"compose_views", "compose_orbit"}: + dst = _data(http_json("GET", f"/v1/topology/views/{view_id}")) + if not dst.get("ok"): + return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed", "view_id": view_id} + dst_ids = { + str(n.get("fabric_node_id") or "") + for n in (dst.get("nodes") or []) + if isinstance(n, dict) + } + missing = [ + str(p.get("fabric_node_id") or "") + for p in positions + if str(p.get("fabric_node_id") or "") and str(p.get("fabric_node_id")) not in dst_ids + ] + if missing: + add_back = _add_topology_view_nodes( + { + "view_id": view_id, + "fabric_node_ids": missing, + "layout": "keep", + } + ) + if not add_back.get("ok") and add_back.get("error"): + # Some APIs return ok via summary fields only — continue if added. + if int(add_back.get("added") or 0) <= 0 and not add_back.get("ok"): + return { + **out, + "ok": False, + "error": "compose_restore_members_failed", + "missing_count": len(missing), + "detail": add_back, + } + restored = int(add_back.get("added") or len(missing)) + out["restored_members"] = restored + + if source_id != view_id: + dst = _data(http_json("GET", f"/v1/topology/views/{view_id}")) + if not dst.get("ok"): + return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed", "view_id": view_id} + dst_ids = { + str(n.get("fabric_node_id") or "") + for n in (dst.get("nodes") or []) + if isinstance(n, dict) + } + missing = [p["fabric_node_id"] for p in positions if str(p.get("fabric_node_id")) not in dst_ids] + if missing: + return { + "ok": False, + "error": "target_missing_nodes", + "missing_count": len(missing), + "hint": "addTopologyViewNodes onto view_id first (or set source_view_id=view_id).", + "sample_missing": missing[:10], + } + + raise_if_cancelled() + report_progress( + "apply", + pct=90.0, + message=f"PATCH {len(positions)} positions", + positions=len(positions), + ) + patch = _patch_positions_chunked(view_id, positions) + if not patch.get("ok"): + return _slim_layout_payload( + {**out, "ok": False, "error": patch.get("error"), "patch": patch} + ) + out["applied"] = True + out["updated"] = patch.get("updated") + report_progress("done", pct=100.0, message="positions applied") + out["hint"] = ( + "Positions applied. Main path: analyze(structure) → " + "sinkTopologyDualUnits (or move_nodes park) → orbit_sweep(round) → " + "polish_crossings → clear_edge_hits → updateTopologyViewPositions. " + "Small graphs: layout(compact|corridor|rings). " + "Large jobs: poll job_status; job_cancel for cooperative stop." + ) + return _slim_layout_payload(out) + + +def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]: + """Unified layout QA: overlap / crossing / spacing / sparsity / edges + score.""" + view_id = str(args.get("view_id") or "").strip() + folder_id = str(args.get("folder_id") or "").strip() + with_meta = str(args.get("with_meta") or "").strip().lower() in {"1", "true", "yes"} + detail = str(args.get("detail") or "summary").strip().lower() or "summary" + try: + sight_limit = max(5, min(80, int(args.get("sight_limit") or 40))) + except (TypeError, ValueError): + sight_limit = 40 + try: + sight_cell = float(args.get("sight_cell") or 600.0) + except (TypeError, ValueError): + sight_cell = 600.0 + try: + max_views = max(1, min(80, int(args.get("max_views") or 25))) + except (TypeError, ValueError): + max_views = 25 + try: + min_nodes = max(0, int(args.get("min_nodes") or 5)) + except (TypeError, ValueError): + min_nodes = 5 + try: + max_nodes = max(1, min(2000, int(args.get("max_nodes") or 800))) + except (TypeError, ValueError): + max_nodes = 800 + + if view_id: + return _analyze_one_view( + view_id, + with_meta=with_meta, + detail=detail, + sight_limit=sight_limit, + sight_cell=sight_cell, + ) + + if not folder_id: + return { + "ok": False, + "error": "view_id_or_folder_id_required", + "hint": "Pass view_id for one canvas, or folder_id to sample physical views under it.", + } + + tree = _data(http_json("GET", "/v1/topology/tree")) + if not tree.get("ok"): + return tree + folder = _find_folder_in_tree(tree, folder_id) + if folder is None: + return {"ok": False, "error": "folder_not_found", "folder_id": folder_id} + + candidates: list[dict[str, Any]] = [] + _collect_physical_views(folder, out=candidates, max_views=max_views * 4) + # Prefer mid-size engineer canvases; skip empties / huge worlds + filtered = [ + c + for c in candidates + if min_nodes <= int(c.get("ne_count") or 0) <= max_nodes + ] + filtered.sort(key=lambda c: int(c.get("ne_count") or 0)) + # stride sample across size range + if len(filtered) > max_views: + step = len(filtered) / max_views + picked = [filtered[min(len(filtered) - 1, int(i * step))] for i in range(max_views)] + else: + picked = filtered + + rows: list[dict[str, Any]] = [] + for c in picked: + one = _analyze_one_view(str(c["view_id"]), with_meta=with_meta) + if not one.get("ok"): + rows.append( + { + "view_id": c.get("view_id"), + "folder_name": c.get("folder_name"), + "error": one.get("error"), + } + ) + continue + size = one.get("size") or {} + crossing = one.get("crossing") or {} + spacing = one.get("spacing") or {} + sparsity = one.get("sparsity") or {} + overlap = one.get("overlap") or {} + verdict = one.get("verdict") or {} + rows.append( + { + "view_id": one.get("view_id"), + "view_name": one.get("view_name"), + "folder_name": c.get("folder_name"), + "verdict": { + "overall": verdict.get("overall"), + "total": verdict.get("total"), + "headline": verdict.get("headline"), + }, + "size": size, + "overlap": { + "status": overlap.get("status"), + "footprint_pairs": overlap.get("footprint_pairs"), + "label_pairs": overlap.get("label_pairs"), + }, + "crossing": { + "status": crossing.get("status"), + "edge_crossings": crossing.get("edge_crossings"), + "crossings_per_link": crossing.get("crossings_per_link"), + "top_nodes": [ + { + "name": r.get("name"), + "hits": r.get("crossing_hits"), + "id": r.get("fabric_node_id"), + } + for r in (crossing.get("top_nodes") or [])[:5] + ], + "top_edges": [ + { + "label": r.get("label"), + "hits": r.get("crossing_hits"), + "a": r.get("a_name"), + "b": r.get("b_name"), + } + for r in (crossing.get("top_edges") or [])[:5] + ], + }, + "spacing": { + "status": spacing.get("status"), + "nn_p50": spacing.get("nn_p50"), + }, + "sparsity": { + "status": sparsity.get("status"), + "space_utilization": sparsity.get("space_utilization"), + "grid_occupancy": sparsity.get("grid_occupancy"), + "whitespace_index": sparsity.get("whitespace_index"), + }, + } + ) + + ok_rows = [r for r in rows if (r.get("crossing") or {}).get("edge_crossings") is not None] + + def _pct(vals: list[float], p: float) -> float | None: + if not vals: + return None + s = sorted(vals) + return round(s[min(len(s) - 1, max(0, int(round((len(s) - 1) * p))))], 4) + + cpl = [ + float(r["crossing"]["crossings_per_link"]) + for r in ok_rows + if (r.get("crossing") or {}).get("crossings_per_link") is not None + ] + cross = [ + float(r["crossing"]["edge_crossings"]) + for r in ok_rows + if (r.get("crossing") or {}).get("edge_crossings") is not None + ] + nn = [ + float(r["spacing"]["nn_p50"]) + for r in ok_rows + if (r.get("spacing") or {}).get("nn_p50") is not None + ] + util = [ + float(r["sparsity"]["space_utilization"]) + for r in ok_rows + if (r.get("sparsity") or {}).get("space_utilization") is not None + ] + totals = [ + float(r["verdict"]["total"]) + for r in ok_rows + if (r.get("verdict") or {}).get("total") is not None + ] + by_bucket: dict[str, list[dict[str, Any]]] = {"1-50": [], "51-200": [], "201-500": [], "501+": []} + for r in ok_rows: + n = int((r.get("size") or {}).get("nodes") or 0) + if n <= 50: + by_bucket["1-50"].append(r) + elif n <= 200: + by_bucket["51-200"].append(r) + elif n <= 500: + by_bucket["201-500"].append(r) + else: + by_bucket["501+"].append(r) + bucket_summary = {} + for key, items in by_bucket.items(): + if not items: + bucket_summary[key] = {"views": 0} + continue + bucket_summary[key] = { + "views": len(items), + "score_total_p50": _pct( + [float(i["verdict"]["total"]) for i in items if (i.get("verdict") or {}).get("total") is not None], + 0.5, + ), + "crossings_per_link_p50": _pct( + [float(i["crossing"]["crossings_per_link"]) for i in items], 0.5 + ), + "crossings_per_link_p90": _pct( + [float(i["crossing"]["crossings_per_link"]) for i in items], 0.9 + ), + "nn_p50_median": _pct( + [float(i["spacing"]["nn_p50"]) for i in items if (i.get("spacing") or {}).get("nn_p50") is not None], + 0.5, + ), + "util_p50": _pct( + [ + float(i["sparsity"]["space_utilization"]) + for i in items + if (i.get("sparsity") or {}).get("space_utilization") is not None + ], + 0.5, + ), + "edge_crossings_p50": _pct( + [float(i["crossing"]["edge_crossings"]) for i in items], 0.5 + ), + } + + return { + "ok": True, + "mode": "folder_sample", + "folder_id": folder_id, + "folder_name": str(folder.get("name") or ""), + "sampled_views": len(ok_rows), + "candidate_views": len(candidates), + "distribution": { + "score_total_p50": _pct(totals, 0.5), + "score_total_p90": _pct(totals, 0.9), + "crossings_per_link_p50": _pct(cpl, 0.5), + "crossings_per_link_p90": _pct(cpl, 0.9), + "edge_crossings_p50": _pct(cross, 0.5), + "edge_crossings_p90": _pct(cross, 0.9), + "nn_p50_median": _pct(nn, 0.5), + "space_utilization_p50": _pct(util, 0.5), + "by_node_bucket": bucket_summary, + }, + "guide": { + "how_to_read": ( + "同一工具:每张图看 verdict + overlap/crossing/spacing/sparsity/chains/rings。" + "对照 distribution:同规模 bucket 的 cpl ≤ 参考 p50 为优、勿差于 p90;" + "overlap 硬零;util 勿远低于参考 p50。" + ), + "acceptance_hint": ( + "Agent layouts: ≤ reference p50 crossings_per_link for size bucket, never worse than p90; " + "footprint/label overlap = 0; raise util without stacking." + ), + }, + "views": rows, + } + + +_ROOT_MAP_NAMES = frozenset({"根图", "Root map"}) + + +def _walk_folders(node: dict[str, Any] | None) -> list[dict[str, Any]]: + if not isinstance(node, dict): + return [] + out = [node] + for child in node.get("children") or []: + if isinstance(child, dict): + out.extend(_walk_folders(child)) + return out + + +def _find_folder_in_tree(tree: dict[str, Any], folder_id: str) -> dict[str, Any] | None: + fid = str(folder_id or "").strip() + if not fid: + return None + root = tree.get("root") if isinstance(tree.get("root"), dict) else tree + if not isinstance(root, dict): + return None + for folder in _walk_folders(root): + if str(folder.get("id") or "") == fid: + return folder + return None + + +def _pick_view(folder: dict[str, Any]) -> dict[str, Any] | None: + views = [v for v in (folder.get("views") or []) if isinstance(v, dict)] + if not views: + return None + for v in views: + if str(v.get("kind") or "").strip().lower() == "physical": + return v + return views[0] + + +def _is_root_map_folder(folder: dict[str, Any]) -> bool: + name = str(folder.get("name") or "").strip() + if name in _ROOT_MAP_NAMES: + return True + return bool(folder.get("is_system")) and not str(folder.get("external_ref") or "").strip() + + +def resolve_draw_target(tree: dict[str, Any], folder_id: str) -> dict[str, Any]: + """Resolve canvas folder + view_id for drawing under the new 根/根图 model.""" + folder = _find_folder_in_tree(tree, folder_id) + if folder is None: + return {} + view = _pick_view(folder) + if view is not None: + return { + "canvas_folder_id": str(folder.get("id") or ""), + "view_id": str(view.get("id") or ""), + "ne_count": int(folder.get("ne_count") or 0), + } + # Nav-only「根」: views empty — draw on auto「根图」/ Root map child. + for child in folder.get("children") or []: + if not isinstance(child, dict) or not _is_root_map_folder(child): + continue + child_view = _pick_view(child) + if child_view is None: + continue + return { + "canvas_folder_id": str(child.get("id") or ""), + "view_id": str(child_view.get("id") or ""), + "ne_count": int(child.get("ne_count") or 0), + } + return {} def _create_topology_folder(args: dict[str, Any]) -> dict[str, Any]: - """Create a region folder under the topology root (API kind=region only).""" + """Create a region; API auto-spawns 根图 / region canvas — return draw view_id.""" name = str(args.get("name") or "").strip() if not name: return {"ok": False, "error": "name_required"} @@ -70,7 +1423,36 @@ def _create_topology_folder(args: dict[str, Any]) -> dict[str, Any]: parent_id = str(args.get("parent_id") or "").strip() if parent_id: body["parent_id"] = parent_id - return _data(http_json("POST", "/v1/topology/folders", body=body)) + locale = str(args.get("locale") or "").strip() + if locale: + body["locale"] = locale + created = _data(http_json("POST", "/v1/topology/folders", body=body)) + if not created.get("ok"): + return created + folder_id = str(created.get("id") or "").strip() + if not folder_id: + return created + tree = _data(http_json("GET", "/v1/topology/tree")) + if not tree.get("ok"): + created["hint"] = ( + "Folder created but tree refresh failed; call getTopologyTree and use the " + "根图 / region canvas view_id." + ) + return created + tip = resolve_draw_target(tree, folder_id) + if tip.get("view_id"): + created["canvas_folder_id"] = tip["canvas_folder_id"] + created["view_id"] = tip["view_id"] + created["ne_count"] = tip.get("ne_count", 0) + created["hint"] = ( + "Use view_id with addTopologyViewNodes on this physical canvas. " + "Under 根图 use createTopologyFolder for sub-regions." + ) + else: + created["hint"] = ( + "Folder created; call getTopologyTree to locate the canvas view_id before drawing." + ) + return created _CHUNK = 500 @@ -113,6 +1495,31 @@ def _merge_mutation_summaries(parts: list[dict[str, Any]]) -> dict[str, Any]: return base +def _ensure_view_max_nodes(view_id: str, max_nodes: int) -> dict[str, Any]: + """Raise membership.max_nodes on a view (≤2000) without wiping other filter fields.""" + cap = max(1, min(2000, int(max_nodes))) + got = _data(http_json("GET", f"/v1/topology/views/{view_id}")) + view = got.get("view") if isinstance(got.get("view"), dict) else None + if not isinstance(view, dict): + return {"ok": False, "error": "view_get_failed", "detail": got} + filt = dict(view.get("filter") or {}) + mem = dict(filt.get("membership") or {}) + try: + cur = int(mem.get("max_nodes") or 0) + except (TypeError, ValueError): + cur = 0 + if cur >= cap: + return {"ok": True, "max_nodes": cur, "changed": False} + mem["max_nodes"] = cap + filt["membership"] = mem + patched = _data( + http_json("PATCH", f"/v1/topology/views/{view_id}", body={"filter": filt}) + ) + if isinstance(patched, dict) and patched.get("ok") is False: + return patched + return {"ok": True, "max_nodes": cap, "changed": True, "previous": cur} + + def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: """Place existing fabric nodes on a view — prefer server-side filters over id lists.""" view_id = str(args.get("view_id") or "").strip() @@ -124,6 +1531,10 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: "error": "fabric_nodes_only", "detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.", } + if args.get("max_nodes") is not None: + bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000)) + if isinstance(bump, dict) and bump.get("ok") is False: + return bump filters = _filter_fields(args) fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] if not filters and not fabric_ids: @@ -263,36 +1674,115 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]: mids = args.get("managed_ne_ids") or [] if isinstance(mids, list) and mids: body["managed_ne_ids"] = [str(x).strip() for x in mids if str(x).strip()] - return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body=body)) + region_folder_id = str(args.get("region_folder_id") or "").strip() + if region_folder_id: + body["region_folder_id"] = region_folder_id + # API still returns the full graph; large canvases easily exceed the 60s default. + out = _data( + http_json( + "POST", + f"/v1/topology/views/{view_id}/project-neighbors", + body=body, + timeout=180.0, + ) + ) + if not out.get("ok"): + return out + detail = str(args.get("detail") or "summary").strip().lower() + skipped = int(out.get("out_of_region_skipped") or 0) + skipped_sample = out.get("out_of_region_sample") or [] + if not isinstance(skipped_sample, list): + skipped_sample = [] + if detail in {"full", "raw", "graph"}: + out["detail"] = "full" + out["region_folder_id"] = region_folder_id or None + out["out_of_region_skipped"] = skipped + out["out_of_region_sample"] = skipped_sample[:20] + if region_folder_id and skipped: + out["hint"] = ( + f"Projected with region_folder_id={region_folder_id}; " + f"skipped {skipped} out-of-region neighbors (not added)." + ) + return out + summary = _summarize_view_graph(out, sample=int(args.get("sample") or 20)) + summary["view_id"] = view_id + summary["projected"] = True + summary["region_folder_id"] = region_folder_id or None + summary["out_of_region_skipped"] = skipped + summary["out_of_region_sample"] = [ + x if isinstance(x, dict) else {"fabric_node_id": str(x)} + for x in skipped_sample[:20] + ] + if region_folder_id: + summary["hint"] = ( + (summary.get("hint") or "") + + f" region_folder_id={region_folder_id} filters peers; " + f"out_of_region_skipped={skipped}." + ).strip() + return summary -def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]: - return _data(http_json("GET", "/v1/topology/fabric/summary")) - - -def _list_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: - page = max(1, int(args.get("page") or 1)) - page_size = min(500, max(1, int(args.get("page_size") or 50))) - params: dict[str, Any] = {"page": page, "page_size": page_size} - if str(args.get("keyword") or "").strip(): - params["keyword"] = str(args.get("keyword")).strip() +def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: + """Unified fabric inventory: mode=summary|list|search (replaces 3 tools).""" + mode = str(args.get("mode") or "").strip().lower() + q = str(args.get("q") or "").strip() + keyword = str(args.get("keyword") or "").strip() + if not mode: + if _truthy(args.get("summary")): + mode = "summary" + elif q: + mode = "search" + else: + mode = "list" + if mode in {"summary", "stats", "count"}: + out = _data(http_json("GET", "/v1/topology/fabric/summary")) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["mode"] = "summary" + return out + if mode in {"search", "find"}: + needle = q or keyword + if not needle: + return { + "ok": False, + "error": "q_required", + "hint": "mode=search needs q (or keyword).", + } + params: dict[str, Any] = { + "q": needle, + "page": max(1, int(args.get("page") or 1)), + "page_size": min( + 200, max(1, int(args.get("page_size") or args.get("limit") or 50)) + ), + } + page = _compact_fabric_page( + _data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params)) + ) + if isinstance(page, dict): + page = dict(page) + page["mode"] = "search" + return page + # mode=list (default): paged filter browse + page_n = max(1, int(args.get("page") or 1)) + page_size = min(500, max(1, int(args.get("page_size") or args.get("limit") or 50))) + params = {"page": page_n, "page_size": page_size} + # list path historically used keyword=; accept q as alias + filt = keyword or q + if filt: + params["keyword"] = filt if str(args.get("role") or "").strip(): params["role"] = str(args.get("role")).strip() if str(args.get("link_status") or "").strip(): params["link_status"] = str(args.get("link_status")).strip() - return _data(http_json("GET", "/v1/topology/fabric/nodes", params=params)) - - -def _search_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: - q = str(args.get("q") or args.get("keyword") or "").strip() - if not q: - return {"ok": False, "error": "q_required"} - params: dict[str, Any] = { - "q": q, - "page": max(1, int(args.get("page") or 1)), - "page_size": min(200, max(1, int(args.get("page_size") or args.get("limit") or 50))), - } - return _data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params)) + if str(args.get("region_folder_id") or "").strip(): + params["region_folder_id"] = str(args.get("region_folder_id")).strip() + page = _compact_fabric_page( + _data(http_json("GET", "/v1/topology/fabric/nodes", params=params)) + ) + if isinstance(page, dict): + page = dict(page) + page["mode"] = "list" + return page def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]: @@ -304,14 +1794,32 @@ def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]: "depth": min(3, max(1, int(args.get("depth") or 1))), "layer": str(args.get("layer") or "physical").strip() or "physical", } - return _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params)) + out = _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params)) + if not out.get("ok"): + return out + nodes_in = [n for n in (out.get("nodes") or []) if isinstance(n, dict)] + edges_in = [e for e in (out.get("edges") or []) if isinstance(e, dict)] + links = _collapse_edges_to_links(edges_in) + return { + "ok": True, + "center_node_id": out.get("center_node_id") or node_id, + "depth": out.get("depth") or params["depth"], + "node_count": len(nodes_in), + "edge_count": len(edges_in), + "link_count": len(links), + "nodes": [_compact_fabric_item(n) for n in nodes_in], + "links": links, + "hint": "links[] = undirected NE pairs for drawing; ignore ports. edge_count is raw.", + } def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: - """List fabric edges; with node_id, also summarize unique peer NEs.""" + """Adjacency for agents: NE↔NE links (+ link_count). Ports only when detail=ports.""" page = max(1, int(args.get("page") or 1)) page_size = min(500, max(1, int(args.get("page_size") or 100))) node_id = str(args.get("node_id") or "").strip() + detail = str(args.get("detail") or "adjacency").strip().lower() + want_ports = detail in {"ports", "raw", "full"} params: dict[str, Any] = { "page": page, "page_size": page_size, @@ -333,15 +1841,14 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: if not isinstance(out, dict) or not out.get("ok"): return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"} data = out.get("data") if isinstance(out.get("data"), dict) else {} - items = data.get("items") if isinstance(data.get("items"), list) else [] - result: dict[str, Any] = {"ok": True, **data} - if node_id and items: - peers: set[str] = set() - peer_labels: list[dict[str, str]] = [] - seen_label: set[str] = set() + items = [e for e in (data.get("items") or []) if isinstance(e, dict)] + edge_total = int(data.get("total") or len(items)) + links = _collapse_edges_to_links(items, include_names=True) + + peers: list[dict[str, Any]] = [] + if node_id: + peer_map: dict[str, dict[str, Any]] = {} for e in items: - if not isinstance(e, dict): - continue a_id = str(e.get("a_node_id") or "") b_id = str(e.get("b_node_id") or "") if a_id == node_id: @@ -350,36 +1857,893 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "") else: continue - if not peer_id or peer_id in peers: + if not peer_id: continue - peers.add(peer_id) - if peer_id not in seen_label: - seen_label.add(peer_id) - peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip}) + row = peer_map.get(peer_id) + if row is None: + peer_map[peer_id] = {"node_id": peer_id, "name": pname, "ip": pip, "link_count": 1} + else: + row["link_count"] = int(row["link_count"]) + 1 + peers = sorted(peer_map.values(), key=lambda p: str(p.get("name") or p.get("node_id") or "")) + + result: dict[str, Any] = { + "ok": True, + "detail": "ports" if want_ports else "adjacency", + "total": edge_total, + "page": data.get("page") or page, + "page_size": data.get("page_size") or page_size, + "link_count": len(links), + "links": links, + "hint": ( + "Default adjacency: one undirected NE↔NE link (+ link_count). " + "Canvas draws one edge per link. Pass detail=ports only for port-level rows." + ), + } + if node_id: result["peer_count"] = len(peers) - result["peers"] = peer_labels - result["edge_total"] = int(data.get("total") or len(items)) - result["peers_complete"] = int(data.get("total") or 0) <= len(items) + result["peers"] = peers + result["edge_total"] = edge_total + result["peers_complete"] = edge_total <= len(items) + if want_ports: + slim_items: list[dict[str, Any]] = [] + for e in items: + row = { + k: e.get(k) + for k in ( + "id", + "layer", + "a_node_id", + "b_node_id", + "a_port", + "b_port", + "a_name", + "b_name", + "source", + "status", + ) + if k in e + } + slim_items.append(row) + result["items"] = slim_items return result +def _view_node_id_pos(payload: dict[str, Any]) -> tuple[list[str], dict[str, tuple[float, float]]]: + ids: list[str] = [] + pos: dict[str, tuple[float, float]] = {} + for n in payload.get("nodes") or []: + if not isinstance(n, dict): + continue + fid = str(n.get("fabric_node_id") or "").strip() + if not fid or fid.startswith("region:"): + continue + ids.append(fid) + if n.get("x") is not None and n.get("y") is not None: + try: + pos[fid] = (float(n["x"]), float(n["y"])) + except (TypeError, ValueError): + pass + return ids, pos + + +def _fabric_bridges_into( + probe_ids: list[str], + peer_ids: set[str], + *, + probe_cap: int = 16, +) -> list[tuple[str, str]]: + """Fabric NE↔NE pairs from probes into peer_ids (cross-canvas bridges). + + View GET edges miss these when one endpoint already left the source canvas. + """ + seen: set[tuple[str, str]] = set() + out: list[tuple[str, str]] = [] + peers = {p for p in peer_ids if p} + for nid in probe_ids[: max(1, int(probe_cap))]: + sid = str(nid or "").strip() + if not sid: + continue + nb = _query_topology_neighborhood({"node_id": sid, "depth": 1}) + if not nb.get("ok"): + continue + for link in nb.get("links") or []: + if not isinstance(link, dict): + continue + a = str(link.get("a_node_id") or "").strip() + b = str(link.get("b_node_id") or "").strip() + if not a or not b or a == b: + continue + other = b if a == sid else a if b == sid else "" + if not other or other not in peers: + continue + key = (a, b) if a < b else (b, a) + if key in seen: + continue + seen.add(key) + out.append(key) + return out + + +def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: + """Move a dual_unit batch from source (root) canvas onto sink (sub-region). + + One call = one batch by default. Set until_empty=true to loop until the + source has no transferable nodes (or max_batches). Default layout_batch + runs layout_dual_unit per unit before parking (old staging flow). Global + polish still via layoutTopologyView on the sink. + """ + from netx_topology_mcp.layout_ops.dual_units import find_dual_portal_units + from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges + from netx_topology_mcp.layout_ops.sink_dual_units import ( + batch_node_ids, + layout_and_pack_batch, + leftover_batch_ids, + merge_view_links, + park_positions, + positions_to_patch, + select_dual_unit_batch, + units_as_batch_rows, + ) + + source_view_id = str( + args.get("source_view_id") or args.get("root_view_id") or "" + ).strip() + sink_view_id = str( + args.get("sink_view_id") or args.get("target_view_id") or "" + ).strip() + if not source_view_id or not sink_view_id: + return { + "ok": False, + "error": "source_view_id_and_sink_view_id_required", + "hint": "Pass root physical view_id + child-region physical view_id.", + } + if source_view_id == sink_view_id: + return {"ok": False, "error": "source_and_sink_must_differ"} + + max_units = max(1, min(20, int(args.get("max_units") or 3))) + min_nodes = max(2, min(200, int(args.get("min_nodes") or 8))) + max_nodes = max(min_nodes, min(400, int(args.get("max_nodes") or 80))) + max_batch_nodes = max(max_nodes, min(800, int(args.get("max_batch_nodes") or 120))) + until_empty = bool(args.get("until_empty")) + include_leftovers = bool( + args.get("include_leftovers") + if args.get("include_leftovers") is not None + else True + ) + dry_run = bool(args.get("dry_run")) + max_batches = max(1, min(200, int(args.get("max_batches") or (1 if not until_empty else 50)))) + pad = float(args.get("pad") or 280.0) + detect_max = max(20, min(300, int(args.get("detect_max_units") or 120))) + # Default on: per-unit layout_dual_unit before park (staging-style). + layout_batch = bool( + True if args.get("layout_batch") is None else args.get("layout_batch") + ) + unit_gap = float(args.get("unit_gap") or 220.0) + + batches: list[dict[str, Any]] = [] + source_remaining = -1 + sink_count = -1 + dry_removed: set[str] = set() + dry_sink_extra: set[str] = set() + + for bi in range(max_batches): + report_progress( + "sink_batch", + pct=min(95.0, 5.0 + 90.0 * bi / max(1, max_batches)), + message=f"batch {bi + 1}/{max_batches}", + batch=bi + 1, + ) + raise_if_cancelled() + src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) + if not src.get("ok"): + return { + "ok": False, + "error": src.get("error") or "source_view_fetch_failed", + "batches": batches, + } + snk = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0)) + if not snk.get("ok"): + return { + "ok": False, + "error": snk.get("error") or "sink_view_fetch_failed", + "batches": batches, + } + + src_ids, src_pos = _view_node_id_pos(src) + snk_ids, snk_pos = _view_node_id_pos(snk) + if dry_run and dry_removed: + src_ids = [i for i in src_ids if i not in dry_removed] + snk_ids = list(dict.fromkeys(list(snk_ids) + sorted(dry_sink_extra))) + source_remaining = len(src_ids) + sink_count = len(snk_ids) + if source_remaining <= 0: + break + + nodes = [ + n + for n in (src.get("nodes") or []) + if isinstance(n, dict) + and str(n.get("fabric_node_id") or "") not in dry_removed + ] + edges = [ + e + for e in (src.get("edges") or []) + if isinstance(e, dict) + and str(e.get("a_node_id") or e.get("source") or "") not in dry_removed + and str(e.get("b_node_id") or e.get("target") or "") not in dry_removed + ] + st = build_state_from_nodes_edges(nodes, edges) + units = find_dual_portal_units(st, max_units=detect_max) + picked = select_dual_unit_batch( + units, + max_units=max_units, + min_nodes=min_nodes, + max_nodes=max_nodes, + max_batch_nodes=max_batch_nodes, + exclude_ids=set(snk_ids), + ) + move_ids = batch_node_ids(picked) + mode = "dual_units" + if not move_ids: + # Relax size band once before leftovers. + picked = select_dual_unit_batch( + units, + max_units=max_units, + min_nodes=2, + max_nodes=max(max_nodes, 200), + max_batch_nodes=max_batch_nodes, + exclude_ids=set(snk_ids), + ) + move_ids = batch_node_ids(picked) + if not move_ids and include_leftovers: + mode = "leftovers" + move_ids = leftover_batch_ids( + src_ids, + max_batch_nodes=max_batch_nodes, + exclude_ids=set(snk_ids), + ) + # leftovers may already be on sink; still remove from source + if not move_ids: + move_ids = leftover_batch_ids( + src_ids, max_batch_nodes=max_batch_nodes, exclude_ids=set() + ) + + if not move_ids: + break + + need_cap = sink_count + len([i for i in move_ids if i not in set(snk_ids)]) + if need_cap > 2000: + return { + "ok": False, + "error": "sink_capacity_exceeded", + "sink_nodes": sink_count, + "batch_nodes": len(move_ids), + "hint": "Create another child region sink or lower max_batch_nodes.", + "batches": batches, + "source_remaining": source_remaining, + } + + batch_row: dict[str, Any] = { + "batch_index": bi + 1, + "mode": mode, + "unit_count": len(picked), + "units": units_as_batch_rows(picked, st.names), + "node_ids": move_ids, + "node_count": len(move_ids), + "source_before": source_remaining, + "sink_before": sink_count, + } + + if dry_run: + batches.append(batch_row) + dry_removed.update(move_ids) + dry_sink_extra.update(move_ids) + source_remaining = max(0, source_remaining - len(move_ids)) + sink_count = len(set(snk_ids) | dry_sink_extra) + if not until_empty: + break + continue + + bump = _ensure_view_max_nodes(sink_view_id, min(2000, max(need_cap + 20, 200))) + if isinstance(bump, dict) and bump.get("ok") is False: + return {**bump, "batches": batches} + + add_ids = [i for i in move_ids if i not in set(snk_ids)] + add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(move_ids)} + if add_ids: + add_out = _add_topology_view_nodes( + { + "view_id": sink_view_id, + "fabric_node_ids": add_ids, + "layout": "keep", + "max_nodes": min(2000, max(need_cap + 20, 200)), + } + ) + if add_out.get("ok") is False: + return { + "ok": False, + "error": add_out.get("error") or "sink_add_failed", + "detail": add_out, + "batches": batches, + "batch": batch_row, + } + + layout_reports: list[dict[str, Any]] = [] + attach_meta: dict[str, Any] = {} + pos_patch: list[dict[str, Any]] = [] + # View edges + fabric bridges into already-sunk peers (cross-canvas tips). + attach_links = merge_view_links(src, snk) + probe_ids: list[str] = [] + if mode == "dual_units" and picked: + for u in picked: + for p in (u.portal_a, u.portal_b): + if p and p not in probe_ids: + probe_ids.append(p) + for mid in move_ids: + if mid not in probe_ids: + probe_ids.append(mid) + fabric_bridges = _fabric_bridges_into( + probe_ids, set(snk_ids), probe_cap=16 + ) + if fabric_bridges: + have = set(attach_links) + for br in fabric_bridges: + if br not in have: + attach_links.append(br) + have.add(br) + if layout_batch and mode == "dual_units" and picked: + report_progress( + "sink_layout", + pct=min(95.0, 20.0 + 70.0 * bi / max(1, max_batches)), + message=f"layout_dual_unit + orbit_attach x{len(picked)}", + batch=bi + 1, + ) + world, layout_reports, attach_meta = layout_and_pack_batch( + st, + picked, + sink_pos=snk_pos, + pad=pad, + unit_gap=unit_gap, + links=attach_links, + ) + pos_patch = positions_to_patch(world) if world else [] + # Fill any members missing from unit layout (shared skip / fail). + have = {str(p.get("fabric_node_id") or "") for p in pos_patch} + missing = [i for i in move_ids if i not in have] + if missing: + pos_patch.extend( + park_positions( + src_pos, + missing, + sink_pos=snk_pos, + pad=pad, + links=attach_links, + ) + ) + else: + pos_patch = park_positions( + src_pos, + move_ids, + sink_pos=snk_pos, + pad=pad, + links=attach_links, + ) + + patch_out: dict[str, Any] = {"ok": True, "updated": 0} + if pos_patch: + patch_out = _patch_positions_chunked(sink_view_id, pos_patch) + + rm_out = _remove_topology_view_nodes( + {"view_id": source_view_id, "fabric_node_ids": move_ids} + ) + if rm_out.get("ok") is False: + return { + "ok": False, + "error": rm_out.get("error") or "source_remove_failed", + "detail": rm_out, + "hint": "Nodes may already be on sink; fix source membership manually.", + "batches": batches, + "batch": batch_row, + } + + # Refresh counts + src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) + snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0)) + src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {}) + snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {}) + source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids)) + sink_count = len(snk_ids2) if snk2.get("ok") else sink_count + len(add_ids) + + batch_row.update( + { + "added": int(add_out.get("added") or len(add_ids)), + "positions_updated": int(patch_out.get("updated") or len(pos_patch)), + "removed_from_source": int(rm_out.get("removed") or len(move_ids)), + "source_after": source_remaining, + "sink_after": sink_count, + "max_nodes_bump": bump, + "layout_batch": layout_batch and mode == "dual_units", + "unit_layouts": layout_reports, + "units_accepted": sum( + 1 for r in layout_reports if r.get("accepted") + ), + "orbit_attach": { + **(attach_meta or {}), + "fabric_bridge_n": len(fabric_bridges), + }, + } + ) + batches.append(batch_row) + + if not until_empty or source_remaining <= 0: + break + + done = source_remaining <= 0 + return { + "ok": True, + "action": "sinkTopologyDualUnits", + "source_view_id": source_view_id, + "sink_view_id": sink_view_id, + "dry_run": dry_run, + "until_empty": until_empty, + "batches_run": len(batches), + "batches": batches, + "source_remaining": source_remaining, + "sink_nodes": sink_count, + "done": done, + "layout_batch": layout_batch, + "hint": ( + "Source empty — sink membership complete. Batches already had " + "layout_dual_unit when layout_batch=true; finish with polish_crossings / " + "straighten / clear_edge_hits (avoid chord straighten after clear)." + if done + else ( + "Call again for the NEXT batch only after polish/clear on sink. " + "Do NOT set until_empty — one batch → tune → next batch." + ) + ), + } + + +def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: + """Move (or copy) explicit fabric ids from source_view_id onto view_id. + + Bidirectional: swap view_id / source_view_id to send nodes back. + Caller supplies fabric_node_ids — no auto CN/dual-unit selection. + """ + dest_view_id = str( + args.get("view_id") + or args.get("sink_view_id") + or args.get("target_view_id") + or args.get("to_view_id") + or "" + ).strip() + source_view_id = str( + args.get("source_view_id") + or args.get("from_view_id") + or args.get("root_view_id") + or "" + ).strip() + if not dest_view_id or not source_view_id: + return { + "ok": False, + "error": "view_id_and_source_view_id_required", + "hint": ( + "action=move_nodes: source_view_id=FROM, view_id=TO; " + "params.fabric_node_ids=[...]. Swap ids to reverse." + ), + } + if source_view_id == dest_view_id: + return {"ok": False, "error": "source_and_dest_must_differ"} + + overrides = args.get("params") if isinstance(args.get("params"), dict) else {} + raw_ids = overrides.get("fabric_node_ids") + if raw_ids is None: + raw_ids = args.get("fabric_node_ids") + if not isinstance(raw_ids, list) or not raw_ids: + return { + "ok": False, + "error": "fabric_node_ids_required", + "hint": "Pass params.fabric_node_ids with the exact NE ids to move.", + } + want: list[str] = [] + seen: set[str] = set() + for x in raw_ids: + sid = str(x or "").strip() + if not sid or sid.startswith("region:") or sid in seen: + continue + seen.add(sid) + want.append(sid) + if not want: + return {"ok": False, "error": "fabric_node_ids_empty"} + + mode = str(args.get("mode") or "preview").strip().lower() or "preview" + dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run")) + if mode != "apply": + dry_run = True + copy_positions = bool( + True + if overrides.get("copy_positions") is None and args.get("copy_positions") is None + else ( + overrides.get("copy_positions") + if "copy_positions" in overrides + else args.get("copy_positions") + ) + ) + park = bool( + overrides.get("park") if "park" in overrides else args.get("park") + ) + remove_from_source = bool( + True + if overrides.get("remove_from_source") is None + and args.get("remove_from_source") is None + else ( + overrides.get("remove_from_source") + if "remove_from_source" in overrides + else args.get("remove_from_source") + ) + ) + pad = float(overrides.get("pad") or args.get("pad") or 280.0) + offset_x = float(overrides.get("offset_x") or args.get("offset_x") or 0.0) + offset_y = float(overrides.get("offset_y") or args.get("offset_y") or 0.0) + + src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) + if not src.get("ok"): + return {"ok": False, "error": src.get("error") or "source_view_fetch_failed"} + dst = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0)) + if not dst.get("ok"): + return {"ok": False, "error": dst.get("error") or "dest_view_fetch_failed"} + + src_ids, src_pos = _view_node_id_pos(src) + dst_ids, dst_pos = _view_node_id_pos(dst) + src_set = set(src_ids) + move_ids = [i for i in want if i in src_set] + missing = [i for i in want if i not in src_set] + already = [i for i in move_ids if i in set(dst_ids)] + add_ids = [i for i in move_ids if i not in set(dst_ids)] + need_cap = len(dst_ids) + len(add_ids) + if need_cap > 2000: + return { + "ok": False, + "error": "dest_capacity_exceeded", + "dest_nodes": len(dst_ids), + "would_add": len(add_ids), + "hint": "Dest soft max is 2000; move a smaller batch.", + } + + names: dict[str, str] = {} + for n in src.get("nodes") or []: + if not isinstance(n, dict): + continue + fid = str(n.get("fabric_node_id") or "").strip() + if fid in seen: + names[fid] = str(n.get("name") or "") + + base: dict[str, Any] = { + "ok": True, + "action": "move_nodes", + "source_view_id": source_view_id, + "view_id": dest_view_id, + "requested": len(want), + "move_ids": move_ids, + "move_count": len(move_ids), + "names": [{"fabric_node_id": i, "name": names.get(i) or ""} for i in move_ids], + "missing_on_source": missing, + "already_on_dest": already, + "would_add": len(add_ids), + "copy_positions": copy_positions, + "park": park, + "remove_from_source": remove_from_source, + "source_before": len(src_ids), + "dest_before": len(dst_ids), + "mode": "preview" if dry_run else "apply", + } + if not move_ids: + return { + **base, + "ok": False, + "error": "none_of_ids_on_source", + "hint": "Those fabric_node_ids are not on source_view_id.", + } + if dry_run: + return { + **base, + "dry_run": True, + "hint": ( + "Preview only. Re-call with mode=apply to add onto view_id " + + ("and remove from source." if remove_from_source else "(copy; source kept).") + ), + } + + bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200))) + if isinstance(bump, dict) and bump.get("ok") is False: + return {**bump, **base} + + add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)} + if add_ids: + add_out = _add_topology_view_nodes( + { + "view_id": dest_view_id, + "fabric_node_ids": add_ids, + "layout": "keep", + "max_nodes": min(2000, max(need_cap + 20, 200)), + } + ) + if add_out.get("ok") is False: + return { + "ok": False, + "error": add_out.get("error") or "dest_add_failed", + "detail": add_out, + **{k: base[k] for k in ("source_view_id", "view_id", "move_ids")}, + } + + from netx_topology_mcp.layout_ops.sink_dual_units import ( + merge_view_links, + park_positions, + ) + + pos_patch: list[dict[str, Any]] = [] + if park: + attach_links = merge_view_links(src, dst) + pos_patch = park_positions( + src_pos, + move_ids, + sink_pos=dst_pos, + pad=pad, + links=attach_links, + ) + elif copy_positions: + pos_patch = [ + { + "fabric_node_id": nid, + "x": float(src_pos[nid][0] + offset_x), + "y": float(src_pos[nid][1] + offset_y), + } + for nid in move_ids + if nid in src_pos + ] + + patch_out: dict[str, Any] = {"ok": True, "updated": 0} + if pos_patch: + patch_out = _patch_positions_chunked(dest_view_id, pos_patch) + + rm_out: dict[str, Any] = {"ok": True, "removed": 0} + if remove_from_source: + rm_out = _remove_topology_view_nodes( + {"view_id": source_view_id, "fabric_node_ids": move_ids} + ) + if rm_out.get("ok") is False: + return { + "ok": False, + "error": rm_out.get("error") or "source_remove_failed", + "detail": rm_out, + "hint": "Nodes may already be on dest; fix source membership manually.", + "added": int(add_out.get("added") or len(add_ids)), + **{k: base[k] for k in ("source_view_id", "view_id", "move_ids")}, + } + + src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) + dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0)) + src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {}) + dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {}) + return { + **base, + "dry_run": False, + "added": int(add_out.get("added") or len(add_ids)), + "positions_updated": int(patch_out.get("updated") or len(pos_patch)), + "removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)), + "source_after": len(src_after) if src2.get("ok") else None, + "dest_after": len(dst_after) if dst2.get("ok") else None, + "max_nodes_bump": bump, + "hint": ( + "Moved onto view_id" + + ("; removed from source_view_id." if remove_from_source else "; source kept (copy).") + + " Swap view_id/source_view_id to send them back." + ), + } + + +def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]: + """Copy fabric placements (+ coords) from one canvas onto another. + + Source membership is unchanged. For test sandboxes: clone a known-good + canvas onto a fresh child region without re-adding ids by hand. + """ + source_view_id = str( + args.get("source_view_id") or args.get("from_view_id") or "" + ).strip() + target_view_id = str( + args.get("target_view_id") or args.get("to_view_id") or "" + ).strip() + if not source_view_id or not target_view_id: + return { + "ok": False, + "error": "source_view_id_and_target_view_id_required", + } + if source_view_id == target_view_id: + return {"ok": False, "error": "source_and_target_must_differ"} + + copy_positions = bool( + True if args.get("copy_positions") is None else args.get("copy_positions") + ) + clear_target = bool(args.get("clear_target")) + dry_run = bool(args.get("dry_run")) + offset_x = float(args.get("offset_x") or 0.0) + offset_y = float(args.get("offset_y") or 0.0) + limit = args.get("limit") + try: + limit_n = int(limit) if limit is not None else 0 + except (TypeError, ValueError): + limit_n = 0 + + src = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) + if not src.get("ok"): + return {"ok": False, "error": src.get("error") or "source_view_fetch_failed"} + dst = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0)) + if not dst.get("ok"): + return {"ok": False, "error": dst.get("error") or "target_view_fetch_failed"} + + src_ids, src_pos = _view_node_id_pos(src) + dst_ids, _ = _view_node_id_pos(dst) + if limit_n > 0: + src_ids = src_ids[:limit_n] + if not src_ids: + return { + "ok": True, + "action": "copyTopologyViewNodes", + "source_view_id": source_view_id, + "target_view_id": target_view_id, + "copied": 0, + "hint": "Source canvas has no fabric nodes.", + } + + need = len(set(dst_ids) | set(src_ids)) if not clear_target else len(src_ids) + if need > 2000: + return { + "ok": False, + "error": "target_capacity_exceeded", + "source_nodes": len(src_ids), + "target_nodes": len(dst_ids), + "hint": "Target soft max is 2000; clear_target or copy a subset (limit).", + } + + if dry_run: + return { + "ok": True, + "action": "copyTopologyViewNodes", + "dry_run": True, + "source_view_id": source_view_id, + "target_view_id": target_view_id, + "source_nodes": len(src_ids), + "target_nodes_before": len(dst_ids), + "would_add": len([i for i in src_ids if i not in set(dst_ids)]), + "would_clear_target": clear_target, + "copy_positions": copy_positions, + } + + cleared = 0 + if clear_target and dst_ids: + rm = _remove_topology_view_nodes( + {"view_id": target_view_id, "fabric_node_ids": list(dst_ids)} + ) + if rm.get("ok") is False: + return { + "ok": False, + "error": rm.get("error") or "clear_target_failed", + "detail": rm, + } + cleared = int(rm.get("removed") or len(dst_ids)) + dst_ids = [] + + bump = _ensure_view_max_nodes(target_view_id, min(2000, max(need + 20, 200))) + if isinstance(bump, dict) and bump.get("ok") is False: + return bump + + add_ids = [i for i in src_ids if i not in set(dst_ids)] + add_out: dict[str, Any] = {"ok": True, "added": 0} + if add_ids: + add_out = _add_topology_view_nodes( + { + "view_id": target_view_id, + "fabric_node_ids": add_ids, + "layout": "keep", + "max_nodes": min(2000, max(need + 20, 200)), + } + ) + if add_out.get("ok") is False: + return { + "ok": False, + "error": add_out.get("error") or "target_add_failed", + "detail": add_out, + } + + patch_out: dict[str, Any] = {"ok": True, "updated": 0} + if copy_positions: + positions = [ + { + "fabric_node_id": nid, + "x": float(src_pos[nid][0] + offset_x), + "y": float(src_pos[nid][1] + offset_y), + } + for nid in src_ids + if nid in src_pos + ] + if positions: + patch_out = _patch_positions_chunked(target_view_id, positions) + + dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0)) + dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {}) + return { + "ok": True, + "action": "copyTopologyViewNodes", + "source_view_id": source_view_id, + "target_view_id": target_view_id, + "source_nodes": len(src_ids), + "cleared": cleared, + "added": int(add_out.get("added") or len(add_ids)), + "positions_updated": int(patch_out.get("updated") or 0), + "copy_positions": copy_positions, + "target_nodes": len(dst_after) if dst2.get("ok") else need, + "max_nodes_bump": bump, + "hint": ( + "Clone done — source unchanged. Run layoutTopologyView / analyze on target." + ), + } + + HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "getTopologyTree", - "description": "Get topology folder tree (sites/regions) with nested views — start here; create region with createTopologyFolder if missing.", - "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, - }, - { - "name": "listTopologyViews", - "description": "List topology canvas views (maps).", - "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, + "description": ( + "Get topology folder tree. Top-level folders are nav「根」; each has a unique " + "「根图」/Root map canvas (physical view). Nested regions are canvases themselves. " + "Each folder has ne_count (distinct fabric NEs in subtree). Default compact=true " + "(slim fields for agents). Optional max_depth prunes deep children. Start here; " + "createTopologyFolder if you need a new root/region." + ), + "inputSchema": { + "type": "object", + "properties": { + "compact": { + "type": "boolean", + "default": True, + "description": "Default true — slim folder/view fields. Pass false for raw API tree.", + }, + "max_depth": { + "type": "integer", + "minimum": 0, + "description": "Optional depth limit from root (0 = root only). Omitted = full depth.", + }, + }, + "required": [], + "additionalProperties": False, + }, }, { "name": "getTopologyView", - "description": "Get a topology view graph (nodes + edges + positions) by view_id.", + "description": ( + "Get a topology view by view_id. Default detail=summary: counts, sample_nodes (x/y), " + "and links[] (undirected NE pairs + link_count for one canvas edge each). " + "For UME layout study on small regions, set sample >= node_count. " + "Pass detail=full only when you need every membership field." + ), "inputSchema": { "type": "object", - "properties": {"view_id": {"type": "string"}}, + "properties": { + "view_id": {"type": "string"}, + "detail": { + "type": "string", + "enum": ["summary", "full"], + "default": "summary", + }, + "sample": { + "type": "integer", + "minimum": 0, + "maximum": 200, + "default": 20, + "description": "Sample node count when detail=summary (use >=node_count for layout study).", + }, + }, "required": ["view_id"], "additionalProperties": False, }, @@ -387,16 +2751,25 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "createTopologyFolder", "description": ( - "Create a region folder under the topology root. Returns folder id for createTopologyView. " - "Does not create a canvas — call createTopologyView next. Requires ne:write." + "ONLY way to create canvases. Top-level: nav「根」+ auto「根图」physical + view_id. " + "Under 根图/region: creates a sub-region canvas (another physical view_id). " + "Returns folder id + view_id / canvas_folder_id for addTopologyViewNodes. " + "Optional locale zh|en for Root map naming. Requires ne:write." ), "inputSchema": { "type": "object", "properties": { - "name": {"type": "string", "description": "Region display name"}, + "name": {"type": "string", "description": "Root or sub-region display name"}, "parent_id": { "type": "string", - "description": "Optional; must be topology root id (default: root)", + "description": ( + "Omit for new top-level「根」+根图. Pass 根图/region folder id to " + "create a sub-region under it (not a sibling custom view)." + ), + }, + "locale": { + "type": "string", + "description": "Optional zh|en — labels auto「根图」vs Root map", }, "sort_order": {"type": "integer", "default": 0}, }, @@ -404,35 +2777,29 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, - { - "name": "createTopologyView", - "description": "Create a topology canvas under a folder (folder_id from getTopologyTree or createTopologyFolder).", - "inputSchema": { - "type": "object", - "properties": { - "name": {"type": "string"}, - "folder_id": {"type": "string"}, - "remark": {"type": "string"}, - "kind": {"type": "string", "enum": ["physical", "custom"], "default": "custom"}, - "role": {"type": "string", "default": "core"}, - "sort_order": {"type": "integer", "default": 0}, - "filter": {"type": "object"}, - }, - "required": ["name", "folder_id"], - "additionalProperties": False, - }, - }, { "name": "addTopologyViewNodes", "description": ( "Bulk-place existing fabric nodes on a view. Prefer server filters " "(keyword/role/vendor/link_status + limit/offset); API selects ids — do not pull then re-send huge id lists. " - "Returns a summary (added/truncated/next_offset). Canvas hard cap 2000. Never pass managed/UME ids." + "Returns a summary (added/truncated/next_offset). Soft max_nodes is per-view " + "(physical default 2000). Pass max_nodes to raise/clamp the view's " + "membership limit on the SAME physical canvas. " + "Never pass managed/UME ids." ), "inputSchema": { "type": "object", "properties": { "view_id": {"type": "string"}, + "max_nodes": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": ( + "Optional: PATCH this view's membership.max_nodes before add " + "(use on physical 根图/region when soft cap blocks members)." + ), + }, "keyword": {"type": "string"}, "role": {"type": "string"}, "vendor": {"type": "string"}, @@ -472,6 +2839,153 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "sinkTopologyDualUnits", + "description": ( + "Drain a root physical canvas into a child-region sink: detect dual_units on " + "source_view_id, layout_dual_unit each unit (layout_batch default true), " + "then compose_orbit-style block sweep to park onto sink (best partial " + "crossings / overlap / bridge — not fixed right), " + "then remove those fabric ids from the root. Default one batch/call; " + "until_empty=true loops until source empty (or leftovers). Global polish " + "still via layoutTopologyView. dry_run previews selection. Requires ne:write." + ), + "inputSchema": { + "type": "object", + "properties": { + "source_view_id": { + "type": "string", + "description": "Root / source physical view_id to drain.", + }, + "sink_view_id": { + "type": "string", + "description": "Child-region physical view_id receiving batches.", + }, + "max_units": { + "type": "integer", + "minimum": 1, + "maximum": 20, + "default": 3, + "description": "Max dual_units per batch (default 3).", + }, + "min_nodes": { + "type": "integer", + "minimum": 2, + "maximum": 200, + "default": 8, + }, + "max_nodes": { + "type": "integer", + "minimum": 2, + "maximum": 400, + "default": 80, + "description": "Max nodes per dual_unit candidate.", + }, + "max_batch_nodes": { + "type": "integer", + "minimum": 8, + "maximum": 800, + "default": 120, + }, + "layout_batch": { + "type": "boolean", + "default": True, + "description": ( + "Default true: run layout_dual_unit per unit before parking " + "(old staging/block layout). false = copy source coords only." + ), + }, + "unit_gap": { + "type": "number", + "default": 220, + "description": "Gap between laid-out units in a batch strip.", + }, + "until_empty": { + "type": "boolean", + "default": False, + "description": "Loop batches until source empty (watch MCP timeout).", + }, + "max_batches": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "description": "Safety cap when until_empty (default 50).", + }, + "include_leftovers": { + "type": "boolean", + "default": True, + "description": "When dual_units exhausted, move leftover NE chunks.", + }, + "dry_run": { + "type": "boolean", + "default": False, + }, + "pad": { + "type": "number", + "default": 280, + "description": "Gap between sink hull and parked batch.", + }, + "detect_max_units": { + "type": "integer", + "minimum": 20, + "maximum": 300, + "default": 120, + }, + }, + "required": ["source_view_id", "sink_view_id"], + "additionalProperties": False, + }, + }, + { + "name": "copyTopologyViewNodes", + "description": ( + "One-shot clone: copy all fabric placements from source_view_id onto " + "target_view_id (optional clear_target first), preserving x/y when " + "copy_positions=true. Source canvas is unchanged — use for test sandboxes " + "instead of re-adding ids. Soft max 2000. Requires ne:write." + ), + "inputSchema": { + "type": "object", + "properties": { + "source_view_id": { + "type": "string", + "description": "Canvas to copy FROM.", + }, + "target_view_id": { + "type": "string", + "description": "Canvas to copy TO.", + }, + "copy_positions": { + "type": "boolean", + "default": True, + "description": "Copy x/y from source (default true).", + }, + "clear_target": { + "type": "boolean", + "default": False, + "description": "Remove existing target members before copy.", + }, + "offset_x": { + "type": "number", + "default": 0, + "description": "Shift copied x by this amount.", + }, + "offset_y": { + "type": "number", + "default": 0, + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 2000, + "description": "Optional cap on how many source nodes to copy.", + }, + "dry_run": {"type": "boolean", "default": False}, + }, + "required": ["source_view_id", "target_view_id"], + "additionalProperties": False, + }, + }, { "name": "updateTopologyViewPositions", "description": ( @@ -521,10 +3035,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "projectTopologyNeighbors", "description": ( - "Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. " - "Only places nodes that already exist in fabric. " + "Project existing fabric neighbors (LLDP/UME) of nodes already on the view onto the canvas. " + "Only places nodes that already exist in fabric. Default detail=summary (not full graph). " "Optional seed_fabric_node_ids / managed_ne_ids limit expansion to those seeds; " - "omit to expand from every node on the view." + "omit to expand from every node on the view. " + "Pass region_folder_id to keep only peers whose fabric.region_folder_id matches " + "(reports out_of_region_skipped); use this on regional verify canvases." ), "inputSchema": { "type": "object", @@ -540,46 +3056,67 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "items": {"type": "string"}, "description": "Optional managed NE ids to resolve to on-view fabric seeds", }, + "region_folder_id": { + "type": "string", + "description": ( + "If set, only add neighbors with matching fabric.region_folder_id; " + "skipped peers are counted in out_of_region_skipped." + ), + }, + "detail": { + "type": "string", + "enum": ["summary", "full"], + "default": "summary", + }, + "sample": {"type": "integer", "minimum": 0, "maximum": 100, "default": 20}, }, "required": ["view_id"], "additionalProperties": False, }, }, { - "name": "getTopologyFabricSummary", - "description": "Fabric inventory summary (node/edge counts).", - "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, - }, - { - "name": "listTopologyFabricNodes", - "description": "Paged fabric nodes (keyword/role/link_status filters).", + "name": "queryTopologyFabricNodes", + "description": ( + "Fabric inventory (read-only). mode=summary|list|search. " + "Default: q→search, else list. " + "summary=node/edge counts; list=paged filters " + "(keyword/role/link_status/region_folder_id); " + "search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / " + "listTopologyFabricNodes / searchTopologyFabricNodes." + ), "inputSchema": { "type": "object", "properties": { - "keyword": {"type": "string"}, + "mode": { + "type": "string", + "enum": ["summary", "list", "search"], + "description": "Omit to auto-pick: q→search, else list.", + }, + "q": { + "type": "string", + "description": "Search needle (mode=search); also aliases keyword for list.", + }, + "keyword": { + "type": "string", + "description": "List filter keyword; alias of q for search.", + }, "role": {"type": "string"}, + "region_folder_id": { + "type": "string", + "description": "Filter by topo folder id (UME region / canvas folder).", + }, "link_status": { "type": "string", "enum": ["linked", "orphaned", "managed", "ume", "both"], }, "page": {"type": "integer", "minimum": 1, "default": 1}, "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50}, - }, - "required": [], - "additionalProperties": False, - }, - }, - { - "name": "searchTopologyFabricNodes", - "description": "Quick search fabric nodes by name/IP/id.", - "inputSchema": { - "type": "object", - "properties": { - "q": {"type": "string"}, - "keyword": {"type": "string", "description": "Alias of q"}, - "page": {"type": "integer", "minimum": 1, "default": 1}, - "page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}, "limit": {"type": "integer", "description": "Alias of page_size"}, + "summary": { + "type": "boolean", + "default": False, + "description": "Shortcut for mode=summary", + }, }, "required": [], "additionalProperties": False, @@ -587,7 +3124,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ }, { "name": "queryTopologyNeighborhood", - "description": "Neighborhood around a fabric node (depth 1–3).", + "description": ( + "Neighborhood around a fabric node (depth 1–3). Returns compact nodes + links[] " + "(undirected NE pairs + link_count). No port/label spam." + ), "inputSchema": { "type": "object", "properties": { @@ -602,8 +3142,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "queryTopologyEdges", "description": ( - "Query fabric LLDP/manual links. Pass node_id for edges of NE A plus peer_count. " - "Raise page_size if peers_complete is false." + "Fabric adjacency for drawing: default detail=adjacency returns links[] " + "(a_node_id, b_node_id, link_count [, names]) — one canvas edge per pair. " + "With node_id also returns peers[]. Raise page_size if peers_complete is false. " + "Pass detail=ports only when you need port-level rows." ), "inputSchema": { "type": "object", @@ -612,7 +3154,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "keyword": {"type": "string"}, "layer": {"type": "string", "default": "physical"}, "status": {"type": "string", "enum": ["active", "missing", "stale"]}, - "source": {"type": "string", "enum": ["lldp", "manual"]}, + "source": {"type": "string", "enum": ["lldp", "ume", "manual"]}, + "detail": { + "type": "string", + "enum": ["adjacency", "ports"], + "default": "adjacency", + "description": "adjacency (default) = NE pairs; ports = raw port rows in items[].", + }, "page": {"type": "integer", "minimum": 1, "default": 1}, "page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100}, }, @@ -620,40 +3168,220 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "analyzeTopologyViewLayout", + "description": ( + "Layout QA + structure planning (read-only). Returns verdict + overlap/crossing/" + "spacing/sparsity/edges + mid-tier chains(直链成一体)/rings(最小环不被穿) " + "+ score.total∈[0,100] (chain/rings each weight 0.10). Pass view_id, or folder_id to sample. " + "detail=structure: graph stats for gravity (core_bar|agg_bar|mixed), hubs, stubs, " + "dual_units (two-portal eye units), soft_blocks, " + "geometry_hint, recipe_preference (compact|corridor|rings) — call BEFORE layout. " + "detail=hotspots|blocks|both: sight{} for hand-drag (crossings, drag_candidates, blocks); " + "both also includes structure{}. " + "For writing positions use layoutTopologyView / updateTopologyViewPositions. Needs ne:read." + ), + "inputSchema": { + "type": "object", + "properties": { + "view_id": { + "type": "string", + "description": "Analyze a single topology view", + }, + "folder_id": { + "type": "string", + "description": "Sample physical views under this folder (tree walk)", + }, + "detail": { + "type": "string", + "enum": ["summary", "structure", "hotspots", "blocks", "both"], + "default": "summary", + "description": ( + "summary=score only; structure=gravity/hubs/recipe hint (phase 0.5); " + "hotspots|blocks=sight; both=structure+sight (hand-drag)" + ), + }, + "sight_limit": { + "type": "integer", + "minimum": 5, + "maximum": 80, + "default": 40, + "description": "Max crossings / drag candidates when detail≠summary", + }, + "sight_cell": { + "type": "number", + "default": 600, + "description": "Grid cell size (px) for detail=blocks", + }, + "max_views": { + "type": "integer", + "minimum": 1, + "maximum": 80, + "default": 25, + }, + "min_nodes": {"type": "integer", "minimum": 0, "default": 5}, + "max_nodes": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 800}, + "with_meta": { + "type": "boolean", + "default": False, + "description": "Include IP/vendor caption line in overlap boxes", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, + { + "name": "layoutTopologyView", + "description": ( + "Layout / local polish for a canvas. Prefer local actions over global crush. " + "action=layout: recipe=rings|corridor|compact|unstick (small graphs). " + "action=layout_dual_unit: eye-shaped dual-portal unit (require crossings=0). " + "action=move_nodes (alias sink_nodes): move fabric_node_ids from " + "source_view_id→view_id; park=true for orbit attach; swap views to reverse. " + "Prefer sinkTopologyDualUnits for dual_units batches. " + "action=orbit_sweep: crossing orbit; preview+node_id / apply+pick / round=true. " + "action=polish_crossings: one-shot straighten→press→untangle (no temp scripts). " + "action=clear_edge_hits: eject nodes on non-incident edges (H/V). " + "action=fix_overlaps|resolve_overlaps: pull apart overlaps. " + "action=untangle / straighten_channels: surgical polish. " + "action=job_status|job_cancel: poll/cancel background jobs. " + "preset: loose|balanced|dense. mode: preview|apply. " + "Workflow: analyze(structure) → sinkTopologyDualUnits → orbit_sweep → " + "polish_crossings → clear_edge_hits → hand drag. Needs ne:write for apply." + ), + "inputSchema": { + "type": "object", + "properties": { + "view_id": { + "type": "string", + "description": "Target canvas to layout (and write when mode=apply)", + }, + "action": { + "type": "string", + "enum": [ + "layout", + "fix_overlaps", + "resolve_overlaps", + "layout_dual_unit", + "straighten_channels", + "untangle", + "polish_crossings", + "clear_edge_hits", + "orbit_sweep", + "move_nodes", + "sink_nodes", + "job_status", + "job_cancel", + ], + "default": "layout", + "description": ( + "layout=full recipe; layout_dual_unit=dual-portal eye; " + "move_nodes|sink_nodes=migrate fabric_node_ids; " + "orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; " + "clear_edge_hits=eject edge hits; fix_overlaps|resolve_overlaps; " + "untangle/straighten_channels; job_status|job_cancel." + ), + }, + "source_view_id": { + "type": "string", + "description": ( + "For action=layout: optional load graph (default=view_id). " + "For action=move_nodes: required FROM canvas (view_id is TO)." + ), + }, + "recipe": { + "type": "string", + "enum": [ + "rings", + "corridor", + "compact", + "unstick", + ], + "default": "rings", + "description": ( + "Only for action=layout. rings=petals/min-rings; " + "corridor/compact/unstick=Tutte corridor variants." + ), + }, + "preset": { + "type": "string", + "enum": ["loose", "balanced", "dense"], + "default": "balanced", + }, + "mode": { + "type": "string", + "enum": ["preview", "apply"], + "default": "preview", + "description": "preview=no write; apply=PATCH positions", + }, + "tune": { + "type": "boolean", + "default": False, + "description": "Small param sweep for action=layout; zero-overlap first", + }, + "params": { + "type": "object", + "description": ( + "Overrides. layout: target_nn/target_util/…. " + "job_status|job_cancel: job_id (required). " + "layout_dual_unit: unit_id (optional). " + "untangle: max_rounds/max_degree/protect_rigid/focus_ids[]. " + "polish_crossings: portal_ids[]/source_view_ids[], " + "top_n/max_moves/max_sweeps/straighten/untangle_rounds. " + "clear_edge_hits: top_n/thr/margin/max_moves. " + "orbit_sweep: node_id/pick/round/top_n/max_jump/angle_step/" + "nn_floor/min_angle_sep; protect_rigid default off. " + "move_nodes|sink_nodes: fabric_node_ids[] (required), " + "copy_positions (default true), park, remove_from_source " + "(default true), pad/offset_x/offset_y." + ), + "additionalProperties": True, + }, + "catalog": { + "type": "boolean", + "default": False, + "description": "If true, only return action/recipe/preset catalog", + }, + }, + "required": [], + "additionalProperties": False, + }, + }, ] _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "getTopologyTree": _get_topology_tree, - "listTopologyViews": _list_topology_views, "getTopologyView": _get_topology_view, "createTopologyFolder": _create_topology_folder, - "createTopologyView": _create_topology_view, "addTopologyViewNodes": _add_topology_view_nodes, "removeTopologyViewNodes": _remove_topology_view_nodes, + "sinkTopologyDualUnits": _sink_topology_dual_units, + "copyTopologyViewNodes": _copy_topology_view_nodes, "updateTopologyViewPositions": _update_topology_view_positions, "projectTopologyNeighbors": _project_topology_neighbors, - "getTopologyFabricSummary": _get_topology_fabric_summary, - "listTopologyFabricNodes": _list_topology_fabric_nodes, - "searchTopologyFabricNodes": _search_topology_fabric_nodes, + "queryTopologyFabricNodes": _query_topology_fabric_nodes, "queryTopologyNeighborhood": _query_topology_neighborhood, "queryTopologyEdges": _query_topology_edges, + "analyzeTopologyViewLayout": _analyze_topology_view_layout, + "layoutTopologyView": _layout_topology_view, } TOOL_REQUIRED_SCOPE: dict[str, str] = { "getTopologyTree": "ne:read", - "listTopologyViews": "ne:read", "getTopologyView": "ne:read", "createTopologyFolder": "ne:write", - "createTopologyView": "ne:write", "addTopologyViewNodes": "ne:write", "removeTopologyViewNodes": "ne:write", + "sinkTopologyDualUnits": "ne:write", + "copyTopologyViewNodes": "ne:write", "updateTopologyViewPositions": "ne:write", "projectTopologyNeighbors": "ne:write", - "getTopologyFabricSummary": "ne:read", - "listTopologyFabricNodes": "ne:read", - "searchTopologyFabricNodes": "ne:read", + "queryTopologyFabricNodes": "ne:read", "queryTopologyNeighborhood": "ne:read", "queryTopologyEdges": "ne:read", + "analyzeTopologyViewLayout": "ne:read", + "layoutTopologyView": "ne:write", } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py new file mode 100644 index 0000000..8ea03a2 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_job_worker.py @@ -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()) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py new file mode 100644 index 0000000..39ae76d --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_jobs.py @@ -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 + ``/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) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py new file mode 100644 index 0000000..1100717 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py @@ -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." + ), + } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py new file mode 100644 index 0000000..2bd17cb --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/__init__.py @@ -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", +] diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py new file mode 100644 index 0000000..8a08c97 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channel_metro.py @@ -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}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py new file mode 100644 index 0000000..0bf41ec --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/channels.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py new file mode 100644 index 0000000..8a83932 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/clear_edge_hits.py @@ -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, + }, + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py new file mode 100644 index 0000000..4e9d46e --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_orbit.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py new file mode 100644 index 0000000..7a5800d --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compose_views.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py new file mode 100644 index 0000000..009fa74 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/densify_sweep.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py new file mode 100644 index 0000000..79a49d8 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_mass.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py new file mode 100644 index 0000000..bb212ba --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/dual_units.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py new file mode 100644 index 0000000..d46a241 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/fold_chain.py @@ -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, + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py new file mode 100644 index 0000000..22b2cc6 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/force_densify.py @@ -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 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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py new file mode 100644 index 0000000..4e44ee8 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/graph_util.py @@ -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}, + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py new file mode 100644 index 0000000..85b17cd --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hierarchy.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py new file mode 100644 index 0000000..48113f3 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/hotspots.py @@ -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)}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py new file mode 100644 index 0000000..ffa0d21 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_field.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py new file mode 100644 index 0000000..4c5e073 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/mass_merge.py @@ -0,0 +1,1049 @@ +"""Mass-field merge: ForceAtlas2-style forces + dual-unit islands. + +Gravity clustering (stage-1), Gephi-ForceAtlas2 inspired: + - Long-range node repulsion ∝ (m_i·m_j)/d (FA2 scaling). + - Lin-log edge attraction (short edges push apart, long edges log-pull). + - Gravity toward **home dual-unit core** (not canvas centroid). + - Inter-group centroid sep/pack so units stay separate islands. + - Global canvas gravity off by default (was collapsing the fabric). + +Optional cross-block core_pull / capture remain for later merge 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.force_densify import ( + _MAX_EDGE_PULL, + _bbox_area, + _spatial_bins, +) +from netx_topology_mcp.layout_ops.mass_field import ( + build_mass_field, + capture_pass, + core_centroid, + evolve_chains_to_rings, + geo_score, + group_effective_mass, + groups_from_mass_or_rigid, +) +from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect +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 +_STEP_CAP = { + "core": 0.35, + "ring": 0.65, + "chain": 1.4, + "free": 1.0, +} +_FOLLOW = { + "chain": 1.4, + "ring": 0.5, + "core": 0.15, + "free": 1.0, +} +_L0_MUL = { + "ring": 1.0, + "chain": 0.85, + "bridge": 1.35, # bridges prefer longer — keep islands apart + "plain": 1.0, +} + + +def _edge_key(a: str, b: str) -> str: + return f"{a}|{b}" if a <= b else f"{b}|{a}" + + +def _group_records( + groups: list[dict[str, Any]], + pos: dict[str, tuple[float, float]], + nodes: dict[str, Any], +) -> list[tuple[str, float, tuple[float, float], set[str]]]: + """(key, mass, centroid, members) for groups that have a usable centroid.""" + out: list[tuple[str, float, tuple[float, float], set[str]]] = [] + for g in groups: + if not isinstance(g, dict): + continue + key = str(g.get("key") or "") + members = {str(x) for x in (g.get("node_ids") or []) if str(x) in pos} + if len(members) < 1: + continue + c = core_centroid(g, pos, nodes) + if c is None: + xs = [pos[n][0] for n in members] + ys = [pos[n][1] for n in members] + c = (sum(xs) / len(xs), sum(ys) / len(ys)) + M = group_effective_mass(g, nodes) + out.append((key, M, c, members)) + return out + + +def _home_map( + gmeta: list[tuple[str, float, tuple[float, float], set[str]]], +) -> dict[str, str]: + home: dict[str, str] = {} + for key, _M, _c, members in gmeta: + for n in members: + if n not in home: + home[n] = key + return home + + +def _centroid_sep_stats( + gmeta: list[tuple[str, float, tuple[float, float], set[str]]], +) -> dict[str, float]: + """Nearest-neighbor distances among group centroids.""" + if len(gmeta) < 2: + return {"mean_nn": 0.0, "min_nn": 0.0, "n": float(len(gmeta))} + nns: list[float] = [] + for i, (_k, _M, ci, _m) in enumerate(gmeta): + best = None + for j, (_k2, _M2, cj, _m2) in enumerate(gmeta): + if i == j: + continue + d = math.hypot(ci[0] - cj[0], ci[1] - cj[1]) + if best is None or d < best: + best = d + if best is not None: + nns.append(best) + return { + "mean_nn": round(sum(nns) / len(nns), 2) if nns else 0.0, + "min_nn": round(min(nns), 2) if nns else 0.0, + "n": float(len(gmeta)), + } + + +def _seed_nodes_disk( + pos: dict[str, tuple[float, float]], + ids: list[str], + *, + pitch: float, + frozen: set[str], +) -> dict[str, tuple[float, float]]: + """Pack free nodes into a round disk (golden-angle spiral) — FA2 circular seed.""" + free = sorted(n for n in ids if n not in frozen and n in pos) + if len(free) < 3: + return pos + xs = [pos[n][0] for n in free] + ys = [pos[n][1] for n in free] + cx = sum(xs) / len(xs) + cy = sum(ys) / len(ys) + out = dict(pos) + p = max(float(pitch), 80.0) + for i, nid in enumerate(free): + ang = i * 2.399963229728653 + rad = p * 0.55 * math.sqrt(i + 1.0) + out[nid] = (cx + math.cos(ang) * rad, cy + math.sin(ang) * rad) + return out + + +def _seed_group_circle( + pos: dict[str, tuple[float, float]], + gmeta: list[tuple[str, float, tuple[float, float], set[str]]], + *, + sep_ideal: float, + frozen: set[str], +) -> dict[str, tuple[float, float]]: + """Place **cluster centroids** on a round golden-angle pack (keep islands). + + Overall footprint is circular; each dual-unit stays a rigid translate of + its members (home map once — no stacked deltas). + """ + n = len(gmeta) + pitch = max(float(sep_ideal), 400.0) + ordered = sorted(gmeta, key=lambda t: (-t[1], t[0])) + home = _home_map(ordered) + targets: dict[str, tuple[float, float]] = {} + for i, (key, _M, _c, _m) in enumerate(ordered): + if i == 0: + targets[key] = (0.0, 0.0) + continue + ang = (i - 1) * 2.399963229728653 + rad = pitch * 0.62 * math.sqrt(float(i)) + targets[key] = (math.cos(ang) * rad, math.sin(ang) * rad) + gcx = sum(c[0] for _k, _M, c, _m in gmeta) / n + gcy = sum(c[1] for _k, _M, c, _m in gmeta) / n + tcx = sum(t[0] for t in targets.values()) / n + tcy = sum(t[1] for t in targets.values()) / n + delta: dict[str, tuple[float, float]] = {} + for key, _M, (cx, cy), _mem in ordered: + tx, ty = targets[key] + delta[key] = ((tx - tcx + gcx) - cx, (ty - tcy + gcy) - cy) + out = dict(pos) + for nid, hk in home.items(): + if nid in frozen or hk not in delta: + continue + dx, dy = delta[hk] + x, y = out[nid] + out[nid] = (x + dx, y + dy) + # Unpack only crushed islands (local spiral); do not flatten the whole canvas. + by_home: dict[str, list[str]] = {} + for nid, hk in home.items(): + if nid in frozen: + continue + by_home.setdefault(hk, []).append(nid) + local_pitch = max(float(sep_ideal) * 0.1, 130.0) + for _hk, members in by_home.items(): + if len(members) < 2: + continue + members = sorted(members) + xs = [out[n][0] for n in members] + ys = [out[n][1] for n in members] + span = max(max(xs) - min(xs), max(ys) - min(ys)) + nns: list[float] = [] + for i, a in enumerate(members[:80]): + ax, ay = out[a] + best = None + for j, b in enumerate(members[:80]): + if i == j: + continue + d = math.hypot(ax - out[b][0], ay - out[b][1]) + if best is None or d < best: + best = d + if best is not None: + nns.append(best) + med_nn = sorted(nns)[len(nns) // 2] if nns else span + if span >= local_pitch * 3.5 and med_nn >= local_pitch * 0.45: + continue + cx = sum(xs) / len(xs) + cy = sum(ys) / len(ys) + for i, nid in enumerate(members): + ang = i * 2.399963229728653 + rad = local_pitch * 0.65 * math.sqrt(i + 1.0) + out[nid] = (cx + math.cos(ang) * rad, cy + math.sin(ang) * rad) + return out + + +def _seed_group_spread( + pos: dict[str, tuple[float, float]], + gmeta: list[tuple[str, float, tuple[float, float], set[str]]], + *, + sep_ideal: float, + frozen: set[str], +) -> tuple[dict[str, tuple[float, float]], bool, str]: + """Repack cluster centroids into a round pack when stacked/sparse/collapsed.""" + if len(gmeta) < 3: + return pos, False, "skip" + stats = _centroid_sep_stats(gmeta) + mean_nn = stats["mean_nn"] + min_nn = stats["min_nn"] + # Also reseat when the cloud of islands is a long strip. + cens = [c for _k, _M, c, _m in gmeta] + cw = max(c[0] for c in cens) - min(c[0] for c in cens) + ch = max(c[1] for c in cens) - min(c[1] for c in cens) + aspect = max(cw, ch) / max(min(cw, ch), 1.0) + if min_nn < sep_ideal * 0.35: + reason = "min_stacked" + elif mean_nn < sep_ideal * 0.45: + reason = "collapsed" + elif mean_nn > sep_ideal * 2.0: + reason = "too_sparse" + elif aspect > 1.45: + reason = "strip" + else: + return pos, False, "ok" + return _seed_group_circle(pos, gmeta, sep_ideal=sep_ideal, frozen=frozen), True, reason + + +def _accumulate_mass_forces( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + ids: list[str], + *, + mass: dict[str, Any], + ideal_len: float, + nn_floor: float, + attract_k: float, + repulse_k: float, + gravity_k: float, + group_sep_k: float, + group_pack_k: float, + sep_ideal: float, + global_gravity_k: float, + core_pull_k: float, + lambda_core: float, + frozen: set[str], + fa2: bool = True, + scaling: float = 8.0, + linlog: bool = True, +) -> dict[str, tuple[float, float]]: + nodes = mass.get("nodes") or {} + edges = mass.get("edges") or {} + groups = mass.get("groups") or [] + fx = {n: 0.0 for n in ids} + fy = {n: 0.0 for n in ids} + id_set = set(ids) + + free = [n for n in ids if n not in frozen and n in pos] + gmeta = _group_records(groups, pos, nodes) + home = _home_map(gmeta) + by_key = {k: (M, c, mem) for k, M, c, mem in gmeta} + + # Degree proxy for FA2 mass: use annotated mass (cores heavier). + def _m(n: str) -> float: + return max(float((nodes.get(n) or {}).get("mass") or 1.0), 0.2) + + # FA2 global gravity → canvas barycenter (keeps overall shape round, not a strip). + if global_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 * 0.5: + continue + # Stronger than before: ∝ dist (FA2), scaled by node mass. + mag = ( + global_gravity_k + * _m(n) + * min(dist * (0.045 if fa2 else 0.015), _MAX_EDGE_PULL * 1.1) + ) + fx[n] += (dx / dist) * mag + fy[n] += (dy / dist) * mag + + # Home-group gravity (FA2 gravity toward region center = dual-unit core). + if gravity_k > 1e-9 and by_key: + for n in free: + hk = home.get(n) + if not hk or hk not in by_key: + continue + _M, (cx, cy), _mem = by_key[hk] + dx, dy = cx - pos[n][0], cy - pos[n][1] + dist = math.hypot(dx, dy) + if dist < nn_floor * 0.35: + continue + role = str((nodes.get(n) or {}).get("role") or "free") + role_w = {"core": 0.15, "ring": 1.0, "chain": 1.2, "free": 0.95}.get( + role, 1.0 + ) + # FA2: gravity ∝ mass; stronger glue for leaves. + mag = ( + gravity_k + * role_w + * _m(n) + * min(dist * (0.055 if fa2 else 0.04), _MAX_EDGE_PULL * 0.9) + ) + fx[n] += (dx / dist) * mag + fy[n] += (dy / dist) * mag + + # Inter-group: repulse if too close, pack if too far (keep islands compact). + if (group_sep_k > 1e-9 or group_pack_k > 1e-9) and len(gmeta) >= 2: + target = max(float(sep_ideal), nn_floor * 6.0) + pack_lo = target * 1.55 + for i, (ki, Mi, ci, memi) in enumerate(gmeta): + for j, (kj, Mj, cj, memj) in enumerate(gmeta): + if j <= i: + continue + dx, dy = ci[0] - cj[0], ci[1] - cj[1] + dist = math.hypot(dx, dy) + if dist < 1e-6: + ang = hash((ki, kj)) % 360 + rad = math.radians(float(ang)) + dx, dy = math.cos(rad), math.sin(rad) + dist = 1.0 + ux, uy = dx / dist, dy / dist + w_sum = max(Mi, 0.2) + max(Mj, 0.2) + if dist < target and group_sep_k > 1e-9: + gap = target - dist + push = group_sep_k * min(gap * 0.08, _MAX_EDGE_PULL * 1.4) + pi = push * (max(Mj, 0.2) / w_sum) + pj = push * (max(Mi, 0.2) / w_sum) + for n in memi: + if n in frozen or n not in fx or home.get(n) != ki: + continue + fx[n] += ux * pi + fy[n] += uy * pi + for n in memj: + if n in frozen or n not in fx or home.get(n) != kj: + continue + fx[n] -= ux * pj + fy[n] -= uy * pj + elif dist > pack_lo and group_pack_k > 1e-9: + gap = dist - target + pull = group_pack_k * min(gap * 0.035, _MAX_EDGE_PULL * 1.1) + pi = pull * (max(Mj, 0.2) / w_sum) + pj = pull * (max(Mi, 0.2) / w_sum) + for n in memi: + if n in frozen or n not in fx or home.get(n) != ki: + continue + fx[n] -= ux * pi + fy[n] -= uy * pi + for n in memj: + if n in frozen or n not in fx or home.get(n) != kj: + continue + fx[n] += ux * pj + fy[n] += uy * pj + + # Edges: FA2 lin-log attraction, or legacy spring. + 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 + erow = edges.get(_edge_key(a, b)) or {} + role = str(erow.get("role") or "plain") + ae = float(erow.get("attract") or 1.0) + L0 = ideal_len * _L0_MUL.get(role, 1.0) + ax, ay = pos[a] + bx, by = pos[b] + dx, dy = bx - ax, by - ay + L = math.hypot(dx, dy) + if L < 1e-6: + dx, dy, L = 1.0, 0.0, 1.0 + ux, uy = dx / L, dy / L + ma, mb = _m(a), _m(b) + inv = 1.0 / ma + 1.0 / mb + na = float((nodes.get(a) or {}).get("attract") or 1.0) + nb = float((nodes.get(b) or {}).get("attract") or 1.0) + w = ae * math.sqrt(max(na * nb, 1e-6)) + + if fa2: + # Lin-log (Gephi default for clustered graphs): pull ∝ log(1+d). + # Below L0: strong push so corridors don't collapse onto portals. + if L < L0: + gap = L0 - L + mag = attract_k * w * min(gap * 0.09, _MAX_EDGE_PULL * 1.2) + sign = -1.0 # push apart along edge + else: + if linlog: + mag = attract_k * w * math.log1p(L / max(L0, 1.0)) * 18.0 + else: + mag = attract_k * w * min((L - L0) * 0.05, _MAX_EDGE_PULL) + mag = min(mag, _MAX_EDGE_PULL * 1.3) + sign = 1.0 # pull together + if role == "bridge" and sign > 0: + mag *= 0.4 + else: + delta = L - L0 + if abs(delta) <= dead: + continue + capped = max(-_MAX_EDGE_PULL * 8.0, min(delta, _MAX_EDGE_PULL * 12.0)) + mag = attract_k * w * min(abs(capped) * 0.045, _MAX_EDGE_PULL) + if capped < 0: + mag *= 0.75 + if role == "bridge" and capped > 0: + mag *= 0.55 + sign = 1.0 if capped > 0 else -1.0 + + fa = mag * ((1.0 / ma) / inv) + fb = mag * ((1.0 / mb) / inv) + 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 + + # Repulsion: FA2 long-range ∝ m_i·m_j / d ; legacy short nn bump otherwise. + if fa2: + # scaling ≈ Gephi scalingRatio; range grows with ideal island pitch. + r_max = max(float(sep_ideal) * 0.85, nn_floor * 10.0, ideal_len * 4.0) + cell = max(r_max * 0.45, nn_floor * 2.0) + bins = _spatial_bins(pos, ids, cell) + # 5×5 neighborhood ≈ long-range without full O(n²). + offsets = tuple((dx, dy) for dx in range(-2, 3) for dy in range(-2, 3)) + scale = max(float(scaling), 0.2) * float(repulse_k) + for (cx, cy), bucket in bins.items(): + neighbors: list[str] = [] + for dx, dy in offsets: + 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] + ma = _m(a) + ra = float((nodes.get(a) or {}).get("repulse") or 1.0) + for b in neighbors: + if b == a: + 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 >= r_max: + continue + if d < 1e-6: + ang = hash(pair) % 360 + rad = math.radians(float(ang)) + dx, dy, d = math.cos(rad), math.sin(rad), 1.0 + mb = _m(b) + rb = float((nodes.get(b) or {}).get("repulse") or 1.0) + # FA2: F = k * (m_i m_j) / d ; boost near-field to lift nn_p50. + mag = scale * ma * mb * math.sqrt(max(ra * rb, 1e-6)) / d + if d < nn_floor * 2.0: + mag *= 1.0 + (nn_floor * 2.0 - d) / max(nn_floor, 1.0) + mag = min(mag, _MAX_EDGE_PULL * 2.8) + ux, uy = dx / d, dy / d + 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 + else: + 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 = [] + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + neighbors.extend(bins.get((cx + dx, cy + dy), ())) + seen = set() + for a in bucket: + if a in frozen: + continue + ax, ay = pos[a] + ra = float((nodes.get(a) or {}).get("repulse") or 1.0) + 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 + rb = float((nodes.get(b) or {}).get("repulse") or 1.0) + ux, uy = dx / d, dy / d + mag = ( + repulse_k + * math.sqrt(max(ra * rb, 1e-6)) + * min(r0 - d, r0) + * 0.4 + ) + mag = min(mag, _MAX_EDGE_PULL * 0.9) + fx[a] += ux * mag + fy[a] += uy * mag + if b not in frozen: + fx[b] -= ux * mag + fy[b] -= uy * mag + + # Weak foreign-core attract (optional steal); keep mild so islands survive. + if core_pull_k > 1e-9 and gmeta: + for n in free: + role = str((nodes.get(n) or {}).get("role") or "free") + follow = _FOLLOW.get(role, 1.0) + if follow < 1e-6: + continue + nx, ny = pos[n] + hk = home.get(n) + for key, M, (cx, cy), _members in gmeta: + if key == hk: + continue + dx, dy = cx - nx, cy - ny + d = math.hypot(dx, dy) + if d < nn_floor * 0.5 or d > lambda_core * 2.5: + continue + mag = ( + core_pull_k + * M + * follow + * math.exp(-d / max(lambda_core, 1.0)) + * 0.015 + ) + mag = min(mag, _MAX_EDGE_PULL * 0.7) + fx[n] += (dx / d) * mag + fy[n] += (dy / d) * mag + + return {n: (fx[n], fy[n]) for n in ids} + + +def mass_merge_round( + state: LayoutState, + *, + groups: list[dict[str, Any]] | None = None, + iters: int = 16, + step: float = 0.32, + max_step: float = 160.0, + ideal_len: float | None = None, + nn_floor: float = 90.0, + attract_k: float = 1.0, + repulse_k: float = 1.15, + gravity_k: float = 0.85, + group_sep_k: float = 1.0, + group_pack_k: float = 1.1, + sep_ideal: float | None = None, + global_gravity_k: float = 0.0, + core_pull_k: float = 0.2, + lambda_core: float | None = None, + protect_rigid: bool | str = "off", + evolve_every: int = 8, + kappa_node: float = 1.25, + kappa_block: float = 2.0, + rho_ideal: float = 6.0, + x_slack: int | None = None, + damping: float = 0.85, + capture: bool = False, + cluster_seed: bool = True, + fa2: bool = True, + scaling: float = 8.0, + linlog: bool = True, + use_dual_units: bool = True, +) -> OpResult: + """Run FA2-style mass merge with dual-unit clusters. + + Islands = dual-unit groups; soft global gravity keeps the **cloud of + islands** roughly round (repulsion + edge traction), not a strip. + """ + from netx_topology_mcp.layout_jobs import ( + raise_if_cancelled, + report_progress, + touch_heartbeat, + ) + + st = state.copy() + cv = (st.meta or {}).get("compose_views") or {} + if use_dual_units: + grp = groups if groups is not None else groups_from_mass_or_rigid(cv) + else: + # Temporarily ignore dual-unit / compose mass groups — pure FA2. + grp = [] + mass = (st.meta or {}).get("mass_field") if use_dual_units else None + if not isinstance(mass, dict) or not mass.get("nodes"): + # units=[] skips dual-unit detection inside build_mass_field. + mass = build_mass_field( + st, units=[] if not use_dual_units else None, groups=grp + ) + else: + mass = dict(mass) + if grp and not mass.get("groups"): + mass["groups"] = grp + if not use_dual_units: + mass["groups"] = [] + mass["units"] = [] + + 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="mass_merge", note="mass_merge:too_few" + ) + + frozen = ( + frozen_ids_for_protect(st, protect_rigid) + if protect_rigid not in (False, "false", "off", "none", "0") + else set() + ) + for n in list(ids): + if str(n).startswith("region:"): + frozen.add(n) + + before = score_state(st, fast=True) + before_x = int((before.get("summary") or {}).get("crossings") or 0) + before_geo = geo_score(st, mass) + slack = ( + max(20, int(before_x * 0.12)) + if x_slack is None + else max(0, int(x_slack)) + ) + ideal = ( + float(ideal_len) + if ideal_len is not None + else max(float(nn_floor) * 6.0, 520.0) + ) + lam = float(lambda_core) if lambda_core is not None else 2.5 * ideal + g0 = _group_records(mass.get("groups") or [], st.positions, mass.get("nodes") or {}) + # Keep island pitch moderate — do NOT scale with sqrt(n) (blew 91 units apart). + sep_tgt = ( + float(sep_ideal) + if sep_ideal is not None + else max(ideal * 2.0, 700.0) + ) + before_sep = _centroid_sep_stats(g0) + # Soft barycenter gravity → round envelope of the whole graph. + # With clusters: mild (islands stay apart via group_sep). Without: stronger. + if global_gravity_k <= 1e-12: + global_gravity_k = (0.45 if use_dual_units else 1.05) if fa2 else 0.4 + if not use_dual_units: + gravity_k = 0.0 + group_sep_k = 0.0 + group_pack_k = 0.0 + core_pull_k = 0.0 + + pos = {n: (float(st.positions[n][0]), float(st.positions[n][1])) for n in ids} + seeded = False + seed_reason = "skip" + if cluster_seed and g0: + pos, seeded, seed_reason = _seed_group_spread( + pos, g0, sep_ideal=sep_tgt, frozen=frozen + ) + if seeded: + st.positions = {**st.positions, **pos} + g0 = _group_records( + mass.get("groups") or [], pos, mass.get("nodes") or {} + ) + before_sep = _centroid_sep_stats(g0) + elif cluster_seed and not g0: + # Fallback only when there are no clusters: disk if strip/collapsed. + xs = [pos[n][0] for n in ids if n not in frozen] + ys = [pos[n][1] for n in ids if n not in frozen] + if len(xs) >= 8: + w = max(xs) - min(xs) + h = max(ys) - min(ys) + aspect = max(w, h) / max(min(w, h), 1.0) + sample = ids[: min(80, len(ids))] + nns: list[float] = [] + for a in sample: + if a in frozen: + continue + ax, ay = pos[a] + best = None + for b in sample: + if a == b or b in frozen: + continue + d = math.hypot(ax - pos[b][0], ay - pos[b][1]) + if best is None or d < best: + best = d + if best is not None: + nns.append(best) + med = sorted(nns)[len(nns) // 2] if nns else 0.0 + if aspect > 1.55 or med < nn_floor * 0.7: + disk_pitch = max(float(nn_floor) * 1.15, ideal * 0.22, 100.0) + pos = _seed_nodes_disk(pos, ids, pitch=disk_pitch, frozen=frozen) + st.positions = {**st.positions, **pos} + seeded = True + seed_reason = "disk_aspect" if aspect > 1.55 else "disk_collapsed" + + links = list(st.links) + n_iters = max(1, min(48, 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))) + every = max(2, int(evolve_every)) + + report_progress( + "mass_merge", + pct=48.0, + message=( + f"iters={n_iters} groups={len(mass.get('groups') or [])} " + f"sep_ideal={sep_tgt:.0f} seed={seed_reason}" + ), + nodes=len(ids), + ) + + vel = {n: (0.0, 0.0) for n in ids} + moved: set[str] = set() + area0 = _bbox_area(pos) + best_pos = dict(pos) + best_mass = mass + # Prefer separation + geo; do NOT reward bbox crush. + best_key = ( + before_sep["mean_nn"], + before_geo, + -before_x, + ) + best_iter = 0 + capture_reports: list[dict[str, Any]] = [] + evolve_reports: list[dict[str, Any]] = [] + + nodes_m = mass.get("nodes") or {} + + for it in range(n_iters): + raise_if_cancelled() + touch_heartbeat() + if it % 2 == 0: + report_progress( + "mass_merge", + pct=48.0 + 24.0 * (it / max(n_iters, 1)), + message=f"iter {it + 1}/{n_iters}", + iter=it + 1, + ) + forces = _accumulate_mass_forces( + pos, + links, + ids, + mass=mass, + ideal_len=ideal, + nn_floor=float(nn_floor), + attract_k=float(attract_k), + repulse_k=float(repulse_k), + gravity_k=float(gravity_k), + group_sep_k=float(group_sep_k), + group_pack_k=float(group_pack_k), + sep_ideal=float(sep_tgt), + global_gravity_k=float(global_gravity_k), + core_pull_k=float(core_pull_k), + lambda_core=lam, + frozen=frozen, + fa2=bool(fa2), + scaling=float(scaling), + linlog=bool(linlog), + ) + for n in ids: + if n in frozen: + continue + fx, fy = forces[n] + row = nodes_m.get(n) or {} + m = max(float(row.get("mass") or 1.0), 0.2) + role = str(row.get("role") or "free") + role_cap = cap * _STEP_CAP.get(role, 1.0) + 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 > role_cap: + s = role_cap / spd + vx, vy = vx * s, vy * s + vel[n] = (vx, vy) + if abs(vx) + abs(vy) < 1e-4: + continue + x, y = pos[n] + pos[n] = (x + vx, y + vy) + moved.add(n) + + if (it + 1) % every == 0 or it == n_iters - 1: + st.positions = {**st.positions, **pos} + if use_dual_units: + mass = evolve_chains_to_rings(st, mass) + ev = mass.get("evolve") or {} + evolve_reports.append(dict(ev)) + if capture and use_dual_units: + mass, crep = capture_pass( + st, + mass, + kappa_node=kappa_node, + kappa_block=kappa_block, + rho_ideal=rho_ideal, + ideal_len=ideal, + ) + capture_reports.append(crep) + nodes_m = mass.get("nodes") or {} + + if it % 2 == 1 or it == n_iters - 1: + st.positions = {**st.positions, **pos} + g = geo_score(st, mass) + x_now = count_edge_crossings(pos, links) + soft_cap = before_x + max(slack * 4, int(before_x * 0.7) + 80) + # FA2 stage-1: crossings will spike while islands form — don't discard. + if (not fa2) and x_now > soft_cap: + continue + gnow = _group_records( + mass.get("groups") or [], pos, mass.get("nodes") or {} + ) + sep = _centroid_sep_stats(gnow) + if fa2: + key = (sep["mean_nn"], g, -abs(sep["mean_nn"] - sep_tgt)) + else: + key = (sep["mean_nn"], g, -x_now) + if key > best_key: + best_key = key + best_pos = dict(pos) + best_mass = mass + best_iter = it + 1 + + if best_iter == 0: + st.positions = {**st.positions, **pos} + last_geo = geo_score(st, mass) + last_x = count_edge_crossings(pos, links) + soft_cap = before_x + max(slack * 4, int(before_x * 0.7) + 80) + gnow = _group_records(mass.get("groups") or [], pos, mass.get("nodes") or {}) + sep = _centroid_sep_stats(gnow) + if last_x <= soft_cap and ( + sep["mean_nn"] >= before_sep["mean_nn"] * 0.95 + or last_geo >= before_geo - 1.0 + ): + best_pos = dict(pos) + best_mass = mass + best_iter = n_iters + + pos = best_pos + mass = best_mass + st.positions = {**st.positions, **pos} + fin = score_state(st, fast=True) + end_x = int((fin.get("summary") or {}).get("crossings") or 0) + end_geo = geo_score(st, mass) + end_gmeta = _group_records( + mass.get("groups") or [], pos, mass.get("nodes") or {} + ) + end_sep = _centroid_sep_stats(end_gmeta) + area1 = _bbox_area(pos) + area_ratio = area0 / max(area1, 1e-6) + + meta = { + "reverted": False, + "start_crossings": before_x, + "end_crossings": end_x, + "start_geo": round(before_geo, 3), + "end_geo": round(end_geo, 3), + "start_sep_nn": before_sep["mean_nn"], + "end_sep_nn": end_sep["mean_nn"], + "min_sep_nn": end_sep["min_nn"], + "sep_ideal": round(sep_tgt, 1), + "cluster_seeded": seeded, + "seed_reason": seed_reason, + "moved_n": len(moved), + "iters": n_iters, + "best_iter": best_iter, + "ideal_len": round(ideal, 1), + "gravity_k": float(gravity_k), + "group_sep_k": float(group_sep_k), + "group_pack_k": float(group_pack_k), + "global_gravity_k": float(global_gravity_k), + "core_pull_k": float(core_pull_k), + "capture": bool(capture), + "fa2": bool(fa2), + "scaling": float(scaling), + "linlog": bool(linlog), + "use_dual_units": bool(use_dual_units), + "kappa_node": float(kappa_node), + "kappa_block": float(kappa_block), + "evolve_every": every, + "capture_log": capture_reports[-3:] if capture_reports else [], + "evolve": evolve_reports[-3:] if evolve_reports else [], + "groups_n": len(mass.get("groups") or []), + "bbox_area_ratio": round(area_ratio, 4), + "x_slack": slack, + } + + sep_up = end_sep["mean_nn"] > before_sep["mean_nn"] * 1.05 + 20.0 + geo_up = end_geo > before_geo + 0.02 + x_ok = end_x <= before_x + max(slack * 4, int(before_x * 0.7) + 80) + # Lattice/repack seed is itself the stage-1 gain — keep it even if x rises. + gain = sep_up or geo_up or seeded or ( + len(moved) >= 2 and end_geo >= before_geo - 1.0 + ) + # FA2 clustering tolerates crossing spikes; only revert if nothing improved. + if (not fa2) and (not x_ok) and (not sep_up) and (not geo_up) and (not seeded): + meta["reverted"] = True + meta["reason"] = "crossing_rise" + st0 = state.copy() + st0.meta = dict(st0.meta or {}) + st0.meta["mass_merge"] = meta + return OpResult( + state=st0, + moved=set(), + op="mass_merge", + params=meta, + note="mass_merge:reverted crossing_rise", + ) + if best_iter == 0 and not gain: + meta["reverted"] = True + meta["reason"] = "no_sep_gain" + st0 = state.copy() + st0.meta = dict(st0.meta or {}) + st0.meta["mass_merge"] = meta + return OpResult( + state=st0, + moved=set(), + op="mass_merge", + params=meta, + note="mass_merge:reverted no_sep_gain", + ) + # Seeded but no checkpoint accepted: still keep last iterated / seeded pos. + if best_iter == 0 and seeded: + best_pos = dict(pos) + best_mass = mass + best_iter = max(1, n_iters) + pos = best_pos + mass = best_mass + st.positions = {**st.positions, **pos} + end_x = count_edge_crossings(pos, links) + end_geo = geo_score(st, mass) + end_gmeta = _group_records( + mass.get("groups") or [], pos, mass.get("nodes") or {} + ) + end_sep = _centroid_sep_stats(end_gmeta) + meta["end_crossings"] = end_x + meta["end_geo"] = round(end_geo, 3) + meta["end_sep_nn"] = end_sep["mean_nn"] + meta["min_sep_nn"] = end_sep["min_nn"] + meta["best_iter"] = best_iter + meta["kept_seed"] = True + + st.meta = dict(st.meta or {}) + st.meta["mass_field"] = mass + st.meta["mass_merge"] = meta + cv2 = dict(st.meta.get("compose_views") or cv or {}) + cv2["mass_groups"] = mass.get("groups") or [] + cv2["soft"] = True + st.meta["compose_views"] = cv2 + st.last_moved = moved + return OpResult( + state=st, + moved=moved, + op="mass_merge", + params=meta, + note=( + f"mass_merge sep {before_sep['mean_nn']:.0f}->{end_sep['mean_nn']:.0f} " + f"geo {before_geo:.1f}->{end_geo:.1f} x {before_x}->{end_x} " + f"moved={len(moved)}" + ), + ) + + +def mass_merge_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, 48, 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, 4.0, float), + ("group_sep_k", 0.0, 4.0, float), + ("group_pack_k", 0.0, 4.0, float), + ("sep_ideal", 200.0, 20000.0, float), + ("global_gravity_k", 0.0, 3.0, float), + ("core_pull_k", 0.0, 4.0, float), + ("lambda_core", 100.0, 8000.0, float), + ("evolve_every", 2, 24, int), + ("kappa_node", 0.5, 4.0, float), + ("kappa_block", 1.1, 6.0, float), + ("rho_ideal", 2.0, 20.0, float), + ("damping", 0.2, 0.98, float), + ("x_slack", 0, 5000, int), + ("scaling", 0.2, 40.0, float), + ): + if o.get(key) is None: + continue + try: + v = cast(o[key]) + out[key] = max(lo, min(hi, v)) + except (TypeError, ValueError): + pass + for flag in ("capture", "cluster_seed", "fa2", "linlog", "use_dual_units"): + if flag in o: + v = o.get(flag) + if isinstance(v, bool): + out[flag] = v + else: + out[flag] = str(v).strip().lower() in {"1", "true", "yes", "on"} + 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 "off").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" + return out diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py new file mode 100644 index 0000000..87a774a --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/min_rings.py @@ -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}" + ), + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py new file mode 100644 index 0000000..9844fb7 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py new file mode 100644 index 0000000..bd1a2f8 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ortho_metro.py @@ -0,0 +1,1158 @@ +"""Multi-layer orthogonal metro layout (H/V edges, parallel tracks). + +Design rules (canvas has no Steiner bends): +1. Core beam stays on track 0 (horizontal). +2. Deg≤2 corridors grow **horizontally** on their own parallel track. +3. Vertical edges are short stubs (beam→track or track↔track at one column). +4. Non-triangle edges are forced to share x or y; triangle hyps may stay diagonal. +""" + +from __future__ import annotations + +from collections import deque +from typing import Any + +from netx_topology_mcp.layout_metrics import ( + REC_CENTER_DX, + REC_CENTER_DY, + count_edge_crossings, + node_footprint, +) +from netx_topology_mcp.layout_ops.channels import extract_channels +from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_topology_mcp.layout_ops.transforms import normalize_origin + +_TOL_PX = 8.0 + + +def _axis_ok(a: tuple[float, float], b: tuple[float, float], tol: float = _TOL_PX) -> bool: + return abs(a[0] - b[0]) <= tol or abs(a[1] - b[1]) <= tol + + +def _segment_clear( + pos: dict[str, tuple[float, float]], + a: str, + b: str, + *, + thr: float = 40.0, + skip: set[str] | None = None, +) -> bool: + """True if no other node sits on the open segment a—b (edge occlusion).""" + from netx_topology_mcp.layout_metrics import point_segment_dist + + if a not in pos or b not in pos: + return True + pa, pb = pos[a], pos[b] + skip = skip or set() + for n, p in pos.items(): + if n == a or n == b or n in skip: + continue + d, t = point_segment_dist(p, pa, pb) + if d < thr and 0.05 < t < 0.95: + return False + return True + + +def _pick_beam(state: LayoutState) -> list[str]: + """Core layer hubs, else top-degree pair that are neighbors, else max hub.""" + cores = [n for n, ly in state.layers.items() if ly == "core" and n in state.positions] + if len(cores) >= 2: + best: tuple[int, str, str] | None = None + for i, a in enumerate(cores): + for b in cores[i + 1 :]: + if b in (state.adj.get(a) or set()): + score = len(state.adj.get(a) or ()) + len(state.adj.get(b) or ()) + if best is None or score > best[0]: + best = (score, a, b) + if best: + return [best[1], best[2]] + cores.sort(key=lambda n: -len(state.adj.get(n) or ())) + return cores[:2] + if len(cores) == 1: + return [cores[0]] + ranked = sorted( + state.positions.keys(), + key=lambda n: (-len(state.adj.get(n) or ()), state.names.get(n) or n), + ) + if not ranked: + return [] + hub = ranked[0] + nbrs = sorted( + state.adj.get(hub) or (), + key=lambda n: (-len(state.adj.get(n) or ()), state.names.get(n) or n), + ) + if nbrs and len(state.adj.get(hub) or ()) >= 2: + return [hub, nbrs[0]] + return [hub] + + +def _edge_key(a: str, b: str) -> tuple[str, str]: + return (a, b) if a <= b else (b, a) + + +def _dist2(a: tuple[float, float], b: tuple[float, float]) -> float: + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + + +def _triangles(adj: dict[str, set[str]]) -> list[tuple[str, str, str]]: + nodes = sorted(adj) + idx = {n: i for i, n in enumerate(nodes)} + seen: set[tuple[str, str, str]] = set() + out: list[tuple[str, str, str]] = [] + for u in nodes: + nbrs = sorted(adj.get(u) or (), key=lambda n: idx.get(n, 0)) + for i, v in enumerate(nbrs): + if idx[v] <= idx[u]: + continue + for w in nbrs[i + 1 :]: + if w in (adj.get(v) or ()): + t = tuple(sorted((u, v, w))) + if t not in seen: + seen.add(t) # type: ignore[arg-type] + out.append(t) # type: ignore[arg-type] + return out + + +def _triangle_hyps( + pos: dict[str, tuple[float, float]], + adj: dict[str, set[str]], +) -> set[tuple[str, str]]: + """One skippable diagonal per K3: prefer edge between two highest-degree nodes.""" + hyps: set[tuple[str, str]] = set() + for a, b, c in _triangles(adj): + deg = {n: len(adj.get(n) or ()) for n in (a, b, c)} + edges = [ + (_edge_key(a, b), deg[a] + deg[b], _dist2(pos[a], pos[b])), + (_edge_key(a, c), deg[a] + deg[c], _dist2(pos[a], pos[c])), + (_edge_key(b, c), deg[b] + deg[c], _dist2(pos[b], pos[c])), + ] + # highest combined degree, then longest + edges.sort(key=lambda t: (-t[1], -t[2])) + hyps.add(edges[0][0]) + return hyps + + +def _components(state: LayoutState) -> list[list[str]]: + seen: set[str] = set() + out: list[list[str]] = [] + for n in sorted(state.positions): + if n in seen: + continue + block: list[str] = [] + q = deque([n]) + seen.add(n) + while q: + u = q.popleft() + block.append(u) + for v in state.adj.get(u) or (): + if v in state.positions and v not in seen: + seen.add(v) + q.append(v) + out.append(block) + out.sort(key=lambda b: (-len(b), min(b))) + return out + + +def _cell_free( + pos: dict[str, tuple[float, float]], + xy: tuple[float, float], + *, + skip: str | None, + min_dx: float, + min_dy: float, +) -> bool: + x, y = xy + for nid, (px, py) in pos.items(): + if nid == skip: + continue + if abs(px - x) < min_dx * 0.92 and abs(py - y) < min_dy * 0.92: + return False + return True + + +def _snap_grid( + x: float, y: float, *, ox: float, oy: float, pitch: float, side: float +) -> tuple[float, float]: + col = round((x - ox) / pitch) + row = round((y - oy) / side) + return (ox + col * pitch, oy + row * side) + + +def _axis_score( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + *, + skip: set[tuple[str, str]], +) -> int: + s = 0 + for a, b in links: + if a not in pos or b not in pos: + continue + if _edge_key(a, b) in skip: + continue + if _axis_ok(pos[a], pos[b]): + s += 1 + return s + + +def _find_free( + pos: dict[str, tuple[float, float]], + base: tuple[float, float], + *, + skip: str | None, + pitch: float, + side: float, + ox: float, + oy: float, + prefer_h: bool = True, +) -> tuple[float, float]: + """Nearest free grid cell near base, preferring H then V offsets.""" + bx, by = _snap_grid(base[0], base[1], ox=ox, oy=oy, pitch=pitch, side=side) + if _cell_free(pos, (bx, by), skip=skip, min_dx=pitch, min_dy=side): + return (bx, by) + order = ( + [(1, 0), (-1, 0), (0, 1), (0, -1)] + if prefer_h + else [(0, 1), (0, -1), (1, 0), (-1, 0)] + ) + for s in range(1, 28): + for dx, dy in order: + cand = _snap_grid( + bx + dx * s * pitch, by + dy * s * side, ox=ox, oy=oy, pitch=pitch, side=side + ) + if _cell_free(pos, cand, skip=skip, min_dx=pitch, min_dy=side): + return cand + # diagonals last (only for parking, not for edge endpoints ideally) + for dx, dy in ((1, 1), (1, -1), (-1, 1), (-1, -1)): + cand = _snap_grid( + bx + dx * s * pitch, by + dy * s * side, ox=ox, oy=oy, pitch=pitch, side=side + ) + if _cell_free(pos, cand, skip=skip, min_dx=pitch, min_dy=side): + return cand + return (bx, by) + + +def _fp_overlap( + pos: dict[str, tuple[float, float]], + names: dict[str, str], + a: str, + b: str, +) -> bool: + ax, ay = pos[a] + bx, by = pos[b] + af = node_footprint(names.get(a, a)) + bf = node_footprint(names.get(b, b)) + aa = (ax + af[0], ay + af[1], ax + af[2], ay + af[3]) + bb = (bx + bf[0], by + bf[1], bx + bf[2], by + bf[3]) + return aa[0] < bb[2] and aa[2] > bb[0] and aa[1] < bb[3] and aa[3] > bb[1] + + +def separate_overlaps_ortho( + state: LayoutState, + params: LayoutParams | None = None, +) -> OpResult: + """Slide nodes on H/V only until footprint overlaps are gone (axis-preserving).""" + params = params or LayoutParams() + st = state.copy() + pitch = max(float(params.pitch), REC_CENTER_DX) + side = max(float(params.side), REC_CENTER_DY) + pos = dict(st.positions) + names = st.names + deg = {n: len(st.adj.get(n) or ()) for n in pos} + cores = {n for n, ly in st.layers.items() if ly == "core" and n in pos} + links = list(st.links) + skip: set[tuple[str, str]] = set() + moved: set[str] = set() + + for _ in range(24): + hits = overlapping_nodes( + LayoutState( + positions=pos, + names=names, + layers=st.layers, + links=links, + adj=st.adj, + ) + ) + if not hits: + break + # pair-wise: move lower-degree non-core + ids = sorted(hits) + progressed = False + for i, a in enumerate(ids): + for b in ids[i + 1 :]: + if a not in pos or b not in pos: + continue + if not _fp_overlap(pos, names, a, b): + continue + if a in cores and b not in cores: + mov = b + elif b in cores and a not in cores: + mov = a + elif deg.get(a, 0) <= deg.get(b, 0): + mov = a + else: + mov = b + mx, my = pos[mov] + base = _axis_score(pos, links, skip=skip) + placed = False + for s in range(1, 20): + for cand in ( + (mx + s * pitch, my), + (mx - s * pitch, my), + (mx, my + s * side), + (mx, my - s * side), + ): + trial = dict(pos) + trial[mov] = cand + if _axis_score(trial, links, skip=skip) < base - 1: + continue + # no footprint hit with anyone + ok = True + for other in trial: + if other == mov: + continue + if _fp_overlap(trial, names, mov, other): + ok = False + break + if ok: + pos[mov] = cand + moved.add(mov) + placed = True + progressed = True + break + if placed: + break + if not placed: + # accept axis-preserving slide even if still tight — next round + for s in range(1, 8): + cand = (mx + s * pitch, my) + trial = dict(pos) + trial[mov] = cand + if _axis_score(trial, links, skip=skip) >= base - 1: + pos[mov] = cand + moved.add(mov) + progressed = True + break + if not progressed: + break + + st.positions = pos + return OpResult( + state=st, + moved=moved, + op="separate_overlaps_ortho", + params={"moved_n": len(moved)}, + note=f"ortho_sep moved={len(moved)} ov_left={len(overlapping_nodes(st))}", + ) + + +def _place_component( + state: LayoutState, + nodes: list[str], + params: LayoutParams, + *, + skip_triangles: bool = True, + origin: tuple[float, float] = (0.0, 0.0), +) -> dict[str, tuple[float, float]]: + pitch = max(float(params.pitch), REC_CENTER_DX) + side = max(float(params.side), REC_CENTER_DY) + sub_ids = set(nodes) + adj: dict[str, set[str]] = { + n: {m for m in (state.adj.get(n) or ()) if m in sub_ids} for n in sub_ids + } + deg = {n: len(adj[n]) for n in sub_ids} + links = [(a, b) for a, b in state.links if a in sub_ids and b in sub_ids] + + mini = LayoutState( + positions={n: state.positions[n] for n in sub_ids}, + names={n: state.names.get(n, n) for n in sub_ids}, + layers={n: state.layers.get(n, "other") for n in sub_ids}, + links=links, + adj=adj, + ) + beam = [n for n in _pick_beam(mini) if n in sub_ids] + if not beam: + beam = [min(sub_ids)] + beam_set = set(beam) + ox, oy = origin + + pos: dict[str, tuple[float, float]] = {} + + # --- 1) beam on track 0 --- + if len(beam) >= 2: + gap = max(2.0, 1.0 + 0.2 * max(deg[beam[0]], deg[beam[1]])) + pos[beam[0]] = (ox, oy) + pos[beam[1]] = (ox + pitch * gap, oy) + for i, n in enumerate(beam[2:]): + pos[n] = (pos[beam[1]][0] + pitch * (i + 1), oy) + else: + pos[beam[0]] = (ox, oy) + + # --- 2) fan beam neighbors onto parallel tracks (short V stubs) --- + fan_i = 0 + track_of: dict[str, int] = {b: 0 for b in beam} + attach: dict[str, str] = {} # node -> beam attach + + def _next_track() -> int: + nonlocal fan_i + i = fan_i + fan_i += 1 + k = (i // 2) + 1 + return k if i % 2 == 0 else -k + + beam_nbrs: list[tuple[str, str]] = [] + for b in beam: + for n in sorted(adj[b], key=lambda x: (-deg[x], state.names.get(x) or x)): + if n in beam_set or n in pos: + continue + beam_nbrs.append((b, n)) + + for b, n in beam_nbrs: + if n in pos: + continue + tr = _next_track() + bx, _by = pos[b] + cand = (bx, oy + tr * side) + cand = _find_free(pos, cand, skip=None, pitch=pitch, side=side, ox=ox, oy=oy, prefer_h=True) + # Prefer same x as beam (true stub); if shifted, keep track y + if abs(cand[1] - (oy + tr * side)) > _TOL_PX: + cand = _find_free( + pos, + (bx + pitch, oy + tr * side), + skip=None, + pitch=pitch, + side=side, + ox=ox, + oy=oy, + ) + # Force onto track y + cand = (cand[0], oy + tr * side) + if not _cell_free(pos, cand, skip=None, min_dx=pitch, min_dy=side): + cand = _find_free( + pos, (bx, oy + tr * side), skip=None, pitch=pitch, side=side, ox=ox, oy=oy + ) + cand = (cand[0], oy + tr * side) + if not _cell_free(pos, cand, skip=None, min_dx=pitch, min_dy=side): + cand = _find_free(pos, cand, skip=None, pitch=pitch, side=side, ox=ox, oy=oy) + pos[n] = cand + track_of[n] = tr + attach[n] = b + + # --- 3) grow deg≤2 corridors horizontally on their track --- + # BFS from placed nodes; children of corridor nodes continue east/west on same y. + q = deque(sorted(pos.keys(), key=lambda n: (abs(track_of.get(n, 0)), pos[n][0]))) + parent: dict[str, str | None] = {b: None for b in beam} + for n, b in attach.items(): + parent[n] = b + + while q: + u = q.popleft() + ux, uy = pos[u] + tr = track_of.get(u, 0) + kids = sorted( + (v for v in adj[u] if v not in pos), + key=lambda n: (-deg[n], state.names.get(n) or n), + ) + for ki, v in enumerate(kids): + # Corridor continuation: deg(u)<=2 or u is non-beam leaf-ish → stay on track, H grow + corridor = (deg[u] <= 2 and u not in beam_set) or ( + deg[v] <= 2 and deg[u] <= 3 and u not in beam_set + ) + if corridor or ki == 0 and deg[v] <= 2: + p = parent.get(u) + if p and p in pos: + direction = 1.0 if ux >= pos[p][0] - 1e-9 else -1.0 + else: + direction = 1.0 if ux >= (pos[beam[0]][0] + pos[beam[-1]][0]) / 2 else -1.0 + cand = (ux + direction * pitch, uy) # same track + if not _cell_free(pos, cand, skip=None, min_dx=pitch, min_dy=side): + # try opposite then further + for s in range(1, 16): + for d in (direction, -direction): + c2 = (ux + d * s * pitch, uy) + if _cell_free(pos, c2, skip=None, min_dx=pitch, min_dy=side): + cand = c2 + break + else: + continue + break + pos[v] = cand + track_of[v] = tr + else: + # branch: new parallel track, short stub from u (prefer same x) + sign = 1 if ki % 2 == 0 else -1 + # pick unused track near u + new_tr = tr + sign + while any( + abs(track_of.get(n, 999) - new_tr) < 1 and n in pos for n in pos + ) and abs(new_tr) < 20: + # allow reuse if cells free + break + # find free track index + tried = 0 + while tried < 12: + cy = oy + new_tr * side + cand = (ux, cy) + if _cell_free(pos, cand, skip=None, min_dx=pitch, min_dy=side): + pos[v] = cand + track_of[v] = new_tr + break + cand = (ux + pitch, cy) + if _cell_free(pos, cand, skip=None, min_dx=pitch, min_dy=side): + pos[v] = cand + track_of[v] = new_tr + break + new_tr += sign + tried += 1 + else: + pos[v] = _find_free( + pos, (ux + pitch, uy), skip=None, pitch=pitch, side=side, ox=ox, oy=oy + ) + track_of[v] = int(round((pos[v][1] - oy) / side)) + parent[v] = u + q.append(v) + + # Also lay extracted long channels that may have been fragmented + channels = extract_channels(mini) + for ch in channels: + path = [n for n in ch.node_ids if n in sub_ids] + if len(path) < 3: + continue + # If most already placed on same track, snap missing; else place whole path H + placed_p = [n for n in path if n in pos] + if len(placed_p) >= 2: + # snap all to the median track of placed + ys = sorted(pos[n][1] for n in placed_p) + ty = ys[len(ys) // 2] + tr = int(round((ty - oy) / side)) + # order by current x or path order + xs = [pos[n][0] for n in placed_p] + x0 = min(xs) + # lay path left-to-right + # find leftmost path index among placed + for i, n in enumerate(path): + if n in pos: + x0 = pos[n][0] - i * pitch + break + for i, n in enumerate(path): + cand = (x0 + i * pitch, oy + tr * side) + if n in beam_set: + continue + if n in pos and abs(pos[n][1] - cand[1]) <= _TOL_PX: + continue + if n not in pos or deg[n] <= 2: + if _cell_free(pos, cand, skip=n, min_dx=pitch, min_dy=side) or n not in pos: + if n in pos and not _cell_free(pos, cand, skip=n, min_dx=pitch, min_dy=side): + continue + pos[n] = cand + track_of[n] = tr + + for n in sub_ids: + if n not in pos: + pos[n] = _find_free( + pos, + (ox + pitch * 3, oy), + skip=None, + pitch=pitch, + side=side, + ox=ox, + oy=oy, + ) + track_of[n] = int(round((pos[n][1] - oy) / side)) + + for n in list(pos): + pos[n] = _snap_grid(pos[n][0], pos[n][1], ox=ox, oy=oy, pitch=pitch, side=side) + + # --- 4) triangle right-angles + hyp skip --- + hyps = _triangle_hyps(pos, adj) if skip_triangles else set() + skip_edges = set(hyps) if skip_triangles else set() + + if skip_triangles: + for a, b, c in _triangles(adj): + edges = [ + (_edge_key(a, b), a, b, _dist2(pos[a], pos[b])), + (_edge_key(a, c), a, c, _dist2(pos[a], pos[c])), + (_edge_key(b, c), b, c, _dist2(pos[b], pos[c])), + ] + edges.sort(key=lambda t: -t[3]) + hyp_key = edges[0][0] + leg1, leg2 = edges[1], edges[2] + s1, s2 = {leg1[1], leg1[2]}, {leg2[1], leg2[2]} + corner_set = s1 & s2 + if len(corner_set) != 1: + continue + corner = next(iter(corner_set)) + if corner in beam_set: + continue + e1 = next(iter(s1 - {corner})) + e2 = next(iter(s2 - {corner})) + for cand in ((pos[e1][0], pos[e2][1]), (pos[e2][0], pos[e1][1])): + cand = _snap_grid(cand[0], cand[1], ox=ox, oy=oy, pitch=pitch, side=side) + if not (_axis_ok(cand, pos[e1]) and _axis_ok(cand, pos[e2])): + continue + if _cell_free(pos, cand, skip=corner, min_dx=pitch * 0.5, min_dy=side * 0.5): + pos[corner] = cand + skip_edges.add(hyp_key) + break + + # --- 5) force non-hyp edges via subtree rigid slide (preserves tree H/V) --- + kids: dict[str, list[str]] = {n: [] for n in sub_ids} + for c, p in parent.items(): + if p is not None and c in sub_ids and p in sub_ids: + kids[p].append(c) + + def _subtree(root: str) -> list[str]: + out: list[str] = [] + stack = [root] + seen_s = {root} + while stack: + u = stack.pop() + out.append(u) + for v in kids.get(u) or (): + if v not in seen_s: + seen_s.add(v) + stack.append(v) + return out + + def _free_axis_with_parent(n: str) -> str | None: + """Return 'x' if can slide in x (parent edge is H), 'y' if can slide in y (V).""" + p = parent.get(n) + if p is None or p not in pos: + return "xy" # roots / beam kids treated carefully below + if abs(pos[n][1] - pos[p][1]) <= _TOL_PX: + return "x" # horizontal parent edge → free in x + if abs(pos[n][0] - pos[p][0]) <= _TOL_PX: + return "y" # vertical parent edge → free in y + return None + + def _translate_subtree(root: str, dx: float, dy: float) -> dict[str, tuple[float, float]]: + trial = dict(pos) + for n in _subtree(root): + if n in beam_set: + continue + x, y = trial[n] + trial[n] = (x + dx, y + dy) + return trial + + def _collides(trial: dict[str, tuple[float, float]], moved: set[str]) -> bool: + for n in moved: + if n not in trial: + continue + if not _cell_free(trial, trial[n], skip=n, min_dx=pitch, min_dy=side): + return True + return False + + def _try_subtree_align(mov: str, fix: str) -> bool: + if mov in beam_set or mov not in pos or fix not in pos: + return False + if _axis_ok(pos[mov], pos[fix]): + return True + base = _axis_score(pos, links, skip=skip_edges) + free = _free_axis_with_parent(mov) + if free is None: + return False + mx, my = pos[mov] + fx, fy = pos[fix] + attempts: list[tuple[float, float]] = [] + # Align column (V edge) by sliding in x + if free in ("x", "xy") and abs(mx - fx) > _TOL_PX: + attempts.append((fx - mx, 0.0)) + # Align row (H edge) by sliding in y + if free in ("y", "xy") and abs(my - fy) > _TOL_PX: + attempts.append((0.0, fy - my)) + # Beam direct neighbors: parent is beam (H free if stub was V from beam) + if parent.get(mov) in beam_set: + attempts = [(fx - mx, 0.0), (0.0, fy - my)] + + for dx, dy in attempts: + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + continue + # snap delta to grid + dx = round(dx / pitch) * pitch + dy = round(dy / side) * side + trial = _translate_subtree(mov, dx, dy) + moved = set(_subtree(mov)) - beam_set + if _collides(trial, moved): + # nudge further along same axis to clear + cleared = False + for s in range(1, 10): + for sign in (1, -1): + ndx = dx + (sign * s * pitch if abs(dx) >= abs(dy) else 0.0) + ndy = dy + (sign * s * side if abs(dy) > abs(dx) else 0.0) + if abs(dx) < 1e-9: + ndx = 0.0 + ndy = dy + sign * s * side + if abs(dy) < 1e-9: + ndy = 0.0 + ndx = dx + sign * s * pitch + t2 = _translate_subtree(mov, ndx, ndy) + if not _collides(t2, moved) and _axis_ok(t2[mov], t2[fix]): + trial = t2 + cleared = True + break + if cleared: + break + if not cleared: + continue + if not _axis_ok(trial[mov], trial[fix]): + continue + if not _segment_clear(trial, mov, fix, thr=40.0): + continue + sc = _axis_score(trial, links, skip=skip_edges) + if sc >= base: + pos.clear() + pos.update(trial) + return True + return False + + for _ in range(24): + dirty = False + diags = [ + (a, b) + for a, b in links + if not _axis_ok(pos[a], pos[b]) and _edge_key(a, b) not in skip_edges + ] + if not diags: + break + diags.sort(key=lambda e: (min(deg[e[0]], deg[e[1]]), -max(deg[e[0]], deg[e[1]]))) + for a, b in diags: + if _axis_ok(pos[a], pos[b]): + continue + # Prefer moving the deeper / lower-degree endpoint's subtree + def _depth(n: str) -> int: + d = 0 + cur: str | None = n + seen_d = set() + while cur and cur in parent and cur not in seen_d: + seen_d.add(cur) + cur = parent.get(cur) + d += 1 + if d > 64: + break + return d + + if a in beam_set: + order = [(b, a)] + elif b in beam_set: + order = [(a, b)] + else: + order = [(a, b), (b, a)] + order.sort(key=lambda t: (-_depth(t[0]), deg[t[0]])) + for mov, fix in order: + if _try_subtree_align(mov, fix): + dirty = True + break + if skip_triangles: + skip_edges |= _triangle_hyps(pos, adj) + if not dirty: + break + + # --- 5b) hard force non-hyp diags onto H/V (beam edges first; allow sc-1) --- + def _force_one(mov: str, fix: str, *, min_sc_delta: int = -1) -> bool: + if mov in beam_set or mov not in pos or fix not in pos: + return False + mx, my = pos[mov] + fx, fy = pos[fix] + base = _axis_score(pos, links, skip=skip_edges) + # Prefer V stub to beam (share x), else H + ordered = ( + [(fx, my), (mx, fy)] + if fix in beam_set + else [(mx, fy), (fx, my)] # prefer stay on track + ) + for cand in ordered: + cand = _snap_grid(cand[0], cand[1], ox=ox, oy=oy, pitch=pitch, side=side) + if not _axis_ok(cand, (fx, fy)): + continue + options = [cand] + for s in range(1, 18): + for sign in (1, -1): + if abs(cand[0] - fx) <= _TOL_PX: + options.append((cand[0], cand[1] + sign * s * side)) + else: + options.append((cand[0] + sign * s * pitch, cand[1])) + for c2 in options: + c2 = _snap_grid(c2[0], c2[1], ox=ox, oy=oy, pitch=pitch, side=side) + if not _axis_ok(c2, (fx, fy)): + continue + trial = dict(pos) + trial[mov] = c2 + # Never create an H/V edge that passes through another node. + if not _segment_clear(trial, mov, fix, thr=40.0): + continue + sc = _axis_score(trial, links, skip=skip_edges) + if sc < base + min_sc_delta: + continue + free = _cell_free(trial, c2, skip=mov, min_dx=pitch, min_dy=side) + if free or sc > base: + pos[mov] = c2 + return True + return False + + for _ in range(40): + dirty = False + diags = [ + (a, b) + for a, b in links + if not _axis_ok(pos[a], pos[b]) and _edge_key(a, b) not in skip_edges + ] + if not diags: + break + # Beam-touching edges first (metro stubs), then low-degree + def _prio(e: tuple[str, str]) -> tuple: + a, b = e + beam_touch = 0 if (a in beam_set or b in beam_set) else 1 + return (beam_touch, min(deg[a], deg[b]), -max(deg[a], deg[b])) + + diags.sort(key=_prio) + for a, b in diags: + if _axis_ok(pos[a], pos[b]): + continue + ends: list[tuple[str, str]] = [] + if a in beam_set and b not in beam_set: + ends = [(b, a)] + elif b in beam_set and a not in beam_set: + ends = [(a, b)] + else: + ends = [(a, b), (b, a)] + ends.sort(key=lambda t: deg[t[0]]) + for mov, fix in ends: + if _force_one(mov, fix, min_sc_delta=-1): + dirty = True + break + if dirty: + break + if skip_triangles: + skip_edges |= _triangle_hyps(pos, adj) + if not dirty: + # final desperation: allow larger score drop to kill residual diags + for a, b in diags: + if _axis_ok(pos[a], pos[b]) or _edge_key(a, b) in skip_edges: + continue + mov, fix = (a, b) if deg[a] <= deg[b] else (b, a) + if mov in beam_set: + mov, fix = fix, mov + if _force_one(mov, fix, min_sc_delta=-3): + dirty = True + break + if not dirty: + break + + # --- 6) reduce crossings by sliding on free axis --- + for _ in range(12): + x0 = count_edge_crossings(pos, links) + if x0 == 0: + break + improved = False + # candidate movers: endpoints of crossing-heavy edges — try all low-deg + movers = sorted(sub_ids, key=lambda n: (deg[n], n)) + for mov in movers: + if mov in beam_set: + continue + mx, my = pos[mov] + base_x = count_edge_crossings(pos, links) + base_ax = _axis_score(pos, links, skip=skip_edges) + best = None + best_x = base_x + for s in range(1, 10): + for cand in ( + (mx + s * pitch, my), + (mx - s * pitch, my), + (mx, my + s * side), + (mx, my - s * side), + ): + cand = _snap_grid(cand[0], cand[1], ox=ox, oy=oy, pitch=pitch, side=side) + if not _cell_free(pos, cand, skip=mov, min_dx=pitch, min_dy=side): + continue + trial = dict(pos) + trial[mov] = cand + # must not drop axis score + if _axis_score(trial, links, skip=skip_edges) < base_ax: + continue + xc = count_edge_crossings(trial, links) + if xc < best_x: + best_x = xc + best = cand + if best is not None and best_x < base_x: + pos[mov] = best + improved = True + if not improved: + break + + # --- 7) ortho separation --- + for _ in range(8): + moved_any = False + ids = sorted(pos, key=lambda n: (pos[n][1], pos[n][0])) + for i, a in enumerate(ids): + ax, ay = pos[a] + for b in ids[i + 1 :]: + bx, by = pos[b] + if abs(ax - bx) >= pitch * 0.92 or abs(ay - by) >= side * 0.92: + continue + mov = b if a in beam_set else a if b in beam_set else (a if deg[a] <= deg[b] else b) + if mov in beam_set: + continue + mx, my = pos[mov] + base = _axis_score(pos, links, skip=skip_edges) + for s in range(1, 14): + done = False + for cand in ( + (mx + s * pitch, my), + (mx - s * pitch, my), + (mx, my + s * side), + (mx, my - s * side), + ): + cand = _snap_grid(cand[0], cand[1], ox=ox, oy=oy, pitch=pitch, side=side) + if not _cell_free(pos, cand, skip=mov, min_dx=pitch, min_dy=side): + continue + trial = dict(pos) + trial[mov] = cand + if _axis_score(trial, links, skip=skip_edges) >= base: + pos[mov] = cand + moved_any = True + done = True + break + if done: + break + ax, ay = pos[a] + if not moved_any: + break + + # --- 8) occluding H/V: nudge low-deg obstacle 1 track off; else leave chord --- + # Prefer readable metro over forcing a long chord through other nodes. + for _ in range(8): + dirty = False + for a, b in links: + if a not in pos or b not in pos or not _axis_ok(pos[a], pos[b]): + continue + if _segment_clear(pos, a, b, thr=40.0): + continue + # Collect obstacles on open segment + from netx_topology_mcp.layout_metrics import point_segment_dist + + pa, pb = pos[a], pos[b] + obstacles = [] + for n, p in pos.items(): + if n in (a, b): + continue + d, t = point_segment_dist(p, pa, pb) + if d < 40.0 and 0.05 < t < 0.95: + obstacles.append(n) + # Nudge lowest-degree obstacle one grid step off the trunk + obstacles.sort(key=lambda n: (deg.get(n, 0), n)) + for nid in obstacles: + if nid in beam_set or deg.get(nid, 0) >= 4: + continue + nx, ny = pos[nid] + horiz = abs(pa[1] - pb[1]) <= _TOL_PX + for dx, dy in ( + [(0.0, side), (0.0, -side)] + if horiz + else [(pitch, 0.0), (-pitch, 0.0)] + ): + trial = dict(pos) + trial[nid] = _snap_grid( + nx + dx, ny + dy, ox=ox, oy=oy, pitch=pitch, side=side + ) + if not _cell_free( + trial, trial[nid], skip=nid, min_dx=pitch, min_dy=side + ): + continue + d2, t2 = point_segment_dist(trial[nid], pa, pb) + if 0.05 < t2 < 0.95 and d2 < 40.0: + continue + # Keep incident axis count if possible + ok_axis = True + for nb in adj.get(nid) or (): + if nb not in trial: + continue + if _axis_ok(pos[nid], pos[nb]) and not _axis_ok( + trial[nid], trial[nb] + ): + ok_axis = False + break + if not ok_axis: + continue + pos[nid] = trial[nid] + dirty = True + break + if dirty: + break + if dirty: + break + # Cannot clear without wrecking hubs: break chord by 1 step on low-deg end + ends = sorted((a, b), key=lambda n: (deg.get(n, 0), n)) + for end in ends: + if end in beam_set or deg.get(end, 0) >= 4: + continue + ex, ey = pos[end] + horiz = abs(pa[1] - pb[1]) <= _TOL_PX + for dx, dy in ( + [(0.0, side), (0.0, -side)] + if horiz + else [(pitch, 0.0), (-pitch, 0.0)] + ): + trial = dict(pos) + trial[end] = _snap_grid( + ex + dx, ey + dy, ox=ox, oy=oy, pitch=pitch, side=side + ) + if not _cell_free( + trial, trial[end], skip=end, min_dx=pitch, min_dy=side + ): + continue + if not _segment_clear(trial, a, b, thr=40.0): + continue + pos[end] = trial[end] + dirty = True + break + if dirty: + break + if dirty: + break + if not dirty: + break + + return pos + + +def build_ortho_metro_skeleton( + state: LayoutState, + params: LayoutParams | None = None, + *, + skip_triangles: bool = True, +) -> OpResult: + """Multi-layer H/V metro layout for the whole canvas (per-component).""" + params = params or LayoutParams() + st = state.copy() + if not st.positions: + return OpResult(state=st, moved=set(), op="build_ortho_metro_skeleton", note="empty") + + comps = _components(st) + pitch = max(float(params.pitch), REC_CENTER_DX) + pad_x = max(float(params.island_pad_x), pitch) + placed: dict[str, tuple[float, float]] = {} + cursor_x = 0.0 + comp_meta: list[dict[str, Any]] = [] + + for bi, block in enumerate(comps): + local = _place_component( + st, + block, + params, + skip_triangles=skip_triangles, + origin=(0.0, 0.0), + ) + if not local: + continue + min_x = min(x for x, _y in local.values()) + min_y = min(y for _x, y in local.values()) + max_x = max(x for x, _y in local.values()) + for nid, (x, y) in local.items(): + placed[nid] = (x - min_x + cursor_x, y - min_y) + width = max_x - min_x + cursor_x += width + pad_x + links = [(a, b) for a, b in st.links if a in local and b in local] + adj = {n: {m for m in (st.adj.get(n) or ()) if m in local} for n in local} + hyps = _triangle_hyps(local, adj) if skip_triangles else set() + diag = sum(1 for a, b in links if not _axis_ok(local[a], local[b])) + diag_skip = sum( + 1 + for a, b in links + if not _axis_ok(local[a], local[b]) and _edge_key(a, b) in hyps + ) + comp_meta.append( + { + "block_id": bi, + "n": len(block), + "diag": diag, + "diag_triangle_skip": diag_skip, + "beam": _pick_beam( + LayoutState( + positions=local, + names=st.names, + layers=st.layers, + links=links, + adj=adj, + ) + ), + } + ) + + st.positions = placed + st = separate_overlaps_ortho(st, params).state + st = normalize_origin(st, params).state + st = separate_overlaps_ortho(st, params).state + + # Nodes must not sit on non-incident H/V trunks (edge occlusion). + from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits + + side = max(float(params.side), REC_CENTER_DY) + clr = clear_edge_hits( + st, + params, + top_n=60, + thr=40.0, + margin=20.0, + max_moves=60, + preserve_axis=True, + pitch=pitch, + side=side, + rounds=4, + ) + st = clr.state + st = separate_overlaps_ortho(st, params).state + + axis_n = 0 + diag_n = 0 + for a, b in st.links: + if a not in st.positions or b not in st.positions: + continue + if _axis_ok(st.positions[a], st.positions[b]): + axis_n += 1 + else: + diag_n += 1 + cross = count_edge_crossings(st.positions, st.links) + from netx_topology_mcp.layout_metrics import compute_edge_clearance + + clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0) + st.meta = dict(st.meta or {}) + st.meta["rings_mode"] = "ortho_metro" + st.meta["ortho_metro"] = { + "components": len(comps), + "axis_edges": axis_n, + "diag_edges": diag_n, + "skip_triangles": bool(skip_triangles), + "crossings": cross, + "blocks": comp_meta, + "pitch": pitch, + "side": side, + "overlaps": len(overlapping_nodes(st)), + "edge_clearance_hits": int(clr_m.get("edge_clearance_hits") or 0), + "clear_edge": clr.params, + } + return OpResult( + state=st, + moved=set(st.positions.keys()), + op="build_ortho_metro_skeleton", + params={ + "axis_edges": axis_n, + "diag_edges": diag_n, + "components": len(comps), + "skip_triangles": bool(skip_triangles), + "crossings": cross, + }, + note=( + f"ortho_metro comps={len(comps)} axis={axis_n} diag={diag_n} " + f"skip_tri={int(bool(skip_triangles))} x={cross}" + ), + ) + + +def ortho_metro_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + o = overrides or {} + out: dict[str, Any] = {} + if "skip_triangles" in o: + out["skip_triangles"] = bool(o.get("skip_triangles")) + return out diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py new file mode 100644 index 0000000..57a73de --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/partition.py @@ -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)}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py new file mode 100644 index 0000000..11ecec1 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pin_beam.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py new file mode 100644 index 0000000..2484547 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/press_crossings.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py new file mode 100644 index 0000000..0ed3848 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/recipe.py @@ -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 + ] diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py new file mode 100644 index 0000000..adca3ea --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_orbit.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py new file mode 100644 index 0000000..876abf3 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rigid_units.py @@ -0,0 +1,1053 @@ +"""Treat dual-unit / staging sub-regions as rigid bodies after compose. + +Internal relative geometry of a unit must not be broken by per-node untangle. +Polish only: translate whole groups, or rotate about shared portal pivots. +""" + +from __future__ import annotations + +import math +from collections import defaultdict +from typing import Any + +from netx_topology_mcp.layout_metrics import count_edge_crossings +from netx_topology_mcp.layout_ops.graph_util import connected_components +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult + +# Reject / ignore coords that would blow util (phantom region markers, bugs). +_COORD_ABS_MAX = 1.0e6 + + +def _exclusive_unit_islands( + groups: list[dict[str, Any]], + valid: set[str], + *, + min_island: int, +) -> tuple[list[list[str]], dict[str, int], set[str]]: + """Build rigid islands from dual-unit exclusive members (not shared portals).""" + counts: dict[str, int] = defaultdict(int) + parsed: list[list[str]] = [] + for g in groups: + members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid] + if len(members) < 2: + continue + for n in members: + counts[n] += 1 + parsed.append(members) + shared = {n for n, c in counts.items() if c > 1} + comps: list[list[str]] = [] + node_comp: dict[str, int] = {} + for members in parsed: + exclusive = [n for n in members if n not in shared] + if len(exclusive) < min_island: + continue + cid = len(comps) + comps.append(exclusive) + for n in exclusive: + if n not in node_comp: + node_comp[n] = cid + return comps, node_comp, shared + + +def shrink_long_corridors( + state: LayoutState, + *, + edge_len_cap: float = 1600.0, + pull: float = 0.55, + iters: int = 4, + max_bridges: int = 12, + min_island: int = 6, + groups: list[dict[str, Any]] | None = None, + accept_crossings: bool = True, +) -> OpResult: + """Partially-rigid densify: pull islands together along long bridges. + + Island definition: + - with ``groups`` (dual-unit membership): exclusive members per unit; + - otherwise: short-edge connected components (``edge_len_cap``). + + Only the longest bridge **per island-pair** is used. When + ``accept_crossings`` is set, each island translation is kept only if global + crossings do not rise by more than a small slack. + """ + st = state.copy() + pos = {n: (float(p[0]), float(p[1])) for n, p in st.positions.items()} + valid = { + n + for n, (x, y) in pos.items() + if abs(x) <= _COORD_ABS_MAX + and abs(y) <= _COORD_ABS_MAX + and math.isfinite(x) + and math.isfinite(y) + } + if len(valid) < 2 or not st.links: + return OpResult( + state=st, moved=set(), op="shrink_long_corridors", note="noop" + ) + + cap = max(400.0, float(edge_len_cap)) + pull = max(0.1, min(float(pull), 0.9)) + iters = max(1, int(iters)) + max_bridges = max(2, min(int(max_bridges), 24)) + min_island = max(2, int(min_island)) + use_units = bool(groups) + + xs0 = [pos[n][0] for n in valid] + ys0 = [pos[n][1] for n in valid] + area0 = max(max(xs0) - min(xs0), 1e-6) * max(max(ys0) - min(ys0), 1e-6) + + moved: set[str] = set() + bridges_used = 0 + bridges_rejected = 0 + island_n = 0 + mode = "unit_exclusive" if use_units else "short_edge_cc" + + x0 = count_edge_crossings(pos, st.links) if accept_crossings else 0 + + for _ in range(iters): + comps: list[list[str]] + node_comp: dict[str, int] + if use_units: + comps, node_comp, _shared = _exclusive_unit_islands( + groups or [], valid, min_island=min_island + ) + island_n = len(comps) + if island_n < 2: + break + sizes = [len(c) for c in comps] + bridges: list[tuple[float, str, str]] = [] + for a, b in st.links: + if a not in valid or b not in valid: + continue + ca, cb = node_comp.get(a), node_comp.get(b) + # Portal/shared ends: map to nearest exclusive island via membership. + if ca is None or cb is None or ca == cb: + continue + ax, ay = pos[a] + bx, by = pos[b] + L = math.hypot(bx - ax, by - ay) + if L > cap: + bridges.append((L, a, b)) + # Also pull by exclusive-centroid separation when no direct exclusive edge. + for i in range(len(comps)): + if not comps[i]: + continue + cxi = sum(pos[n][0] for n in comps[i]) / len(comps[i]) + cyi = sum(pos[n][1] for n in comps[i]) / len(comps[i]) + for j in range(i + 1, len(comps)): + if not comps[j]: + continue + cxj = sum(pos[n][0] for n in comps[j]) / len(comps[j]) + cyj = sum(pos[n][1] for n in comps[j]) / len(comps[j]) + L = math.hypot(cxj - cxi, cyj - cyi) + if L <= cap * 1.25: + continue + # Synthetic bridge endpoints = closest pair of exclusives. + best = None + best_d = -1.0 + # Cap pair scan for large islands. + ai = comps[i][:48] + bj = comps[j][:48] + for a in ai: + ax, ay = pos[a] + for b in bj: + d = math.hypot(pos[b][0] - ax, pos[b][1] - ay) + if d > best_d: + best_d = d + best = (d, a, b) + if best is not None and best[0] > cap: + bridges.append(best) + else: + short_adj: dict[str, set[str]] = defaultdict(set) + bridges = [] + for a, b in st.links: + if a not in valid or b not in valid: + continue + ax, ay = pos[a] + bx, by = pos[b] + L = math.hypot(bx - ax, by - ay) + if L <= cap: + short_adj[a].add(b) + short_adj[b].add(a) + else: + bridges.append((L, a, b)) + if not bridges: + break + comps = connected_components(valid, short_adj) + island_n = len(comps) + sizes = [len(c) for c in comps] + node_comp = {} + for i, comp in enumerate(comps): + for n in comp: + node_comp[n] = i + + if not bridges: + break + # Longest bridge per unordered island pair; skip tiny islands. + best_pair: dict[tuple[int, int], tuple[float, str, str, int, int]] = {} + for L, a, b in bridges: + ca, cb = node_comp.get(a), node_comp.get(b) + if ca is None or cb is None or ca == cb: + continue + if sizes[ca] < min_island or sizes[cb] < min_island: + continue + key = (ca, cb) if ca < cb else (cb, ca) + prev = best_pair.get(key) + if prev is None or L > prev[0]: + best_pair[key] = (L, a, b, ca, cb) + cross = sorted(best_pair.values(), reverse=True)[:max_bridges] + if not cross: + break + + # Apply one pair at a time when accepting crossings; else batch. + if not accept_crossings: + # batch path uses all pairs once + disp: dict[int, list[float]] = defaultdict(lambda: [0.0, 0.0, 0.0]) + for L, a, b, ca, cb in cross: + ax, ay = pos[a] + bx, by = pos[b] + ux, uy = (bx - ax) / L, (by - ay) / L + move = 0.5 * pull * (L - cap) + if move < 1.0: + continue + disp[ca][0] += ux * move + disp[ca][1] += uy * move + disp[ca][2] += 1.0 + disp[cb][0] -= ux * move + disp[cb][1] -= uy * move + disp[cb][2] += 1.0 + bridges_used += 1 + if not disp: + break + progress = False + for cid, (sx, sy, c) in disp.items(): + if c <= 0: + continue + dx, dy = sx / c, sy / c + if abs(dx) < 0.5 and abs(dy) < 0.5: + continue + progress = True + for n in comps[cid]: + x, y = pos[n] + pos[n] = (x + dx, y + dy) + moved.add(n) + if not progress: + break + continue + + progress = False + for L, a, b, ca, cb in cross: + ax, ay = pos[a] + bx, by = pos[b] + ux, uy = (bx - ax) / L, (by - ay) / L + move = 0.5 * pull * (L - cap) + if move < 1.0: + continue + dx_a, dy_a = ux * move, uy * move + dx_b, dy_b = -ux * move, -uy * move + snap_a = {n: pos[n] for n in comps[ca]} + snap_b = {n: pos[n] for n in comps[cb]} + for n in comps[ca]: + x, y = pos[n] + pos[n] = (x + dx_a, y + dy_a) + for n in comps[cb]: + x, y = pos[n] + pos[n] = (x + dx_b, y + dy_b) + if accept_crossings: + x1 = count_edge_crossings(pos, st.links) + # Metro dual-units often need a bit of slack; polish afterwards. + slack = max(20, int(x0 * 0.12)) + if x1 > x0 + slack: + for n, p in snap_a.items(): + pos[n] = p + for n, p in snap_b.items(): + pos[n] = p + bridges_rejected += 1 + continue + x0 = x1 + for n in comps[ca]: + moved.add(n) + for n in comps[cb]: + moved.add(n) + bridges_used += 1 + progress = True + if not progress: + break + + st.positions = pos + st.last_moved = moved + xs1 = [pos[n][0] for n in valid] + ys1 = [pos[n][1] for n in valid] + area1 = max(max(xs1) - min(xs1), 1e-6) * max(max(ys1) - min(ys1), 1e-6) + area_ratio = area0 / area1 + return OpResult( + state=st, + moved=moved, + op="shrink_long_corridors", + params={ + "edge_len_cap": round(cap, 1), + "pull": round(pull, 3), + "iters": iters, + "bridges_applied": bridges_used, + "bridges_rejected": bridges_rejected, + "islands": island_n, + "min_island": min_island, + "island_mode": mode, + "moved_n": len(moved), + "bbox_area_ratio": round(area_ratio, 3), + }, + note=( + f"shrink_corridors[{mode}] cap={cap:.0f} islands={island_n} " + f"bridges={bridges_used} rej={bridges_rejected} area×{area_ratio:.2f}" + ), + ) + + +def groups_from_compose_meta(meta: dict[str, Any] | None) -> list[dict[str, Any]]: + """Read mass_groups (preferred) or rigid_groups written by compose.""" + 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", bool(meta.get("soft")))), + } + ) + return out + + +def _coord_ok(pos: dict[str, tuple[float, float]]) -> bool: + for x, y in pos.values(): + if abs(x) > _COORD_ABS_MAX or abs(y) > _COORD_ABS_MAX: + return False + if not math.isfinite(x) or not math.isfinite(y): + return False + return True + + +def _apply_rigid( + pos: dict[str, tuple[float, float]], + members: list[str], + *, + pivot: tuple[float, float] | None, + dx: float, + dy: float, + angle: float, +) -> dict[str, tuple[float, float]]: + """Rotate members about pivot (or centroid), then translate.""" + pts = [pos[n] for n in members if n in pos] + if not pts: + return pos + if pivot is None: + cx = sum(p[0] for p in pts) / len(pts) + cy = sum(p[1] for p in pts) / len(pts) + else: + cx, cy = pivot + cos_a, sin_a = math.cos(angle), math.sin(angle) + out = dict(pos) + for n in members: + if n not in out: + continue + x, y = out[n] + rx = (x - cx) * cos_a - (y - cy) * sin_a + ry = (x - cx) * sin_a + (y - cy) * cos_a + out[n] = (cx + rx + dx, cy + ry + dy) + return out + + +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]]: + """Reflect members across the line through p0→p1 (2-portal flip).""" + 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] + # projection of (x-a) onto axis + 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 _bbox(pos: dict[str, tuple[float, float]], members: list[str]) -> tuple[float, float, float, float] | None: + pts = [pos[n] for n in members if n in pos] + if not pts: + return None + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + return min(xs), min(ys), max(xs), max(ys) + + +def _bboxes_overlap( + a: tuple[float, float, float, float], + b: tuple[float, float, float, float], + pad: float = 80.0, +) -> bool: + return not ( + a[2] + pad < b[0] + or b[2] + pad < a[0] + or a[3] + pad < b[1] + or b[3] + pad < a[1] + ) + + +def _exclusive_members(g: dict[str, Any]) -> list[str]: + piv = set(g.get("pivots") or []) + return [n for n in (g.get("node_ids") or []) if n not in piv] + + +def _group_bbox_overlap_count( + pos: dict[str, tuple[float, float]], + groups: list[dict[str, Any]], + *, + pad: float = 40.0, +) -> int: + """How many exclusive-bbox pairs from different groups collide.""" + boxes: list[tuple[float, float, float, float]] = [] + for g in groups: + excl = [n for n in _exclusive_members(g) if n in pos] + bb = _bbox(pos, excl) + if bb: + boxes.append(bb) + n = 0 + for i in range(len(boxes)): + for j in range(i + 1, len(boxes)): + if _bboxes_overlap(boxes[i], boxes[j], pad=pad): + n += 1 + return n + + +def _cost( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + groups: list[dict[str, Any]], +) -> tuple[int, int]: + """Lexicographic: crossings first, then exclusive bbox overlaps.""" + if not _coord_ok(pos): + return (10**9, 10**9) + return ( + count_edge_crossings(pos, links), + _group_bbox_overlap_count(pos, groups), + ) + + +def rigid_fan_out_portals( + pos: dict[str, tuple[float, float]], + groups: list[dict[str, Any]], + links: list[tuple[str, str]], + *, + crossing_budget: int = 40, +) -> tuple[dict[str, tuple[float, float]], int]: + """Space single-pivot units around each shared portal by discrete angles. + + When several eyes glue on the same portal they stack; assign angular + sectors so exclusive hulls fan out. Never moves the portal itself. + + Optimizes exclusive-bbox overlaps first; may spend up to ``crossing_budget`` + extra crossings vs the pre-fan baseline (otherwise stacked eyes never move). + """ + out = dict(pos) + accepted = 0 + baseline_x = count_edge_crossings(out, links) if _coord_ok(out) else 10**9 + by_pivot: dict[str, list[dict[str, Any]]] = defaultdict(list) + for g in groups: + pivots = [p for p in (g.get("pivots") or []) if p in out] + if len(pivots) == 1: + by_pivot[pivots[0]].append(g) + + # Absolute target headings for the unit centroid about the portal. + target_angles = [i * (math.pi / 6) for i in range(12)] + + for pivot_id, gs in by_pivot.items(): + if len(gs) < 2: + continue + # Largest first stays; rotate smaller ones into free sectors. + ordered = sorted(gs, key=lambda g: -len(g.get("node_ids") or [])) + reserved: list[float] = [] + # Record current heading of the keeper. + keep = ordered[0] + keep_excl = [n for n in _exclusive_members(keep) if n in out] + if keep_excl and pivot_id in out: + cx = sum(out[n][0] for n in keep_excl) / len(keep_excl) + cy = sum(out[n][1] for n in keep_excl) / len(keep_excl) + px, py = out[pivot_id] + reserved.append(math.atan2(cy - py, cx - px)) + + for g in ordered[1:]: + members = [n for n in g["node_ids"] if n in out] + excl = [n for n in _exclusive_members(g) if n in out] + if len(members) < 2 or not excl or pivot_id not in out: + continue + pivot_xy = out[pivot_id] + cx = sum(out[n][0] for n in excl) / len(excl) + cy = sum(out[n][1] for n in excl) / len(excl) + cur_heading = math.atan2(cy - pivot_xy[1], cx - pivot_xy[0]) + cur_ov = _group_bbox_overlap_count(out, groups) + cur_x = count_edge_crossings(out, links) + best: tuple[tuple[int, int, float], dict[str, tuple[float, float]], float] | None = ( + None + ) + for tgt in target_angles: + # Prefer sectors away from already-reserved headings. + if any(_angle_delta(tgt, r) < (math.pi / 12) for r in reserved): + continue + delta = tgt - cur_heading + trial = _apply_rigid( + out, members, pivot=pivot_xy, dx=0.0, dy=0.0, angle=delta + ) + trial[pivot_id] = out[pivot_id] + if not _coord_ok(trial): + continue + x1 = count_edge_crossings(trial, links) + if x1 > baseline_x + crossing_budget and x1 > cur_x: + continue + ov1 = _group_bbox_overlap_count(trial, groups) + # Prefer lower exclusive-bbox overlaps; then crossings; then + # separation from reserved headings. + sep = min((_angle_delta(tgt, r) for r in reserved), default=math.pi) + key = (ov1, x1, -sep) + if ov1 < cur_ov or (ov1 == cur_ov and x1 < cur_x): + if best is None or key < best[0]: + best = (key, trial, tgt) + if best is None: + continue + out = best[1] + reserved.append(best[2]) + accepted += 1 + return out, accepted + + +def _angle_delta(a: float, b: float) -> float: + d = abs(a - b) % (2 * math.pi) + return min(d, 2 * math.pi - d) + + +def rigid_separate_groups( + pos: dict[str, tuple[float, float]], + groups: list[dict[str, Any]], + *, + pad: float = 120.0, + max_iters: int = 12, +) -> dict[str, tuple[float, float]]: + """Push free rigid groups (no shared pivots) apart by whole-group translate. + + Groups anchored on shared portals are left to rotate-about-pivot in + ``rigid_untangle_groups`` — never translate exclusive nodes without the portal. + """ + out = dict(pos) + free = [g for g in groups if not (g.get("pivots") or [])] + for _ in range(max_iters): + moved = False + boxes: list[tuple[dict[str, Any], tuple[float, float, float, float]]] = [] + for g in free: + members = [n for n in g.get("node_ids") or [] if n in out] + bb = _bbox(out, members) + if bb: + boxes.append((g, bb)) + # Separate free groups from each other only (not the full anchored hull — + # that hull often swallows free slots and shove-walks them forever). + for i in range(len(boxes)): + for j in range(i + 1, len(boxes)): + gi, bi = boxes[i] + gj, bj = boxes[j] + if not _bboxes_overlap(bi, bj, pad=pad): + continue + area_i = (bi[2] - bi[0]) * (bi[3] - bi[1]) + area_j = (bj[2] - bj[0]) * (bj[3] - bj[1]) + g_move, b_move, b_other = (gi, bi, bj) if area_i <= area_j else (gj, bj, bi) + cix = (b_move[0] + b_move[2]) / 2 + ciy = (b_move[1] + b_move[3]) / 2 + cjx = (b_other[0] + b_other[2]) / 2 + cjy = (b_other[1] + b_other[3]) / 2 + dx, dy = cix - cjx, ciy - cjy + if abs(dx) + abs(dy) < 1e-6: + dx = 1.0 + overlap_x = min(b_move[2], b_other[2]) - max(b_move[0], b_other[0]) + pad + overlap_y = min(b_move[3], b_other[3]) - max(b_move[1], b_other[1]) + pad + if overlap_x <= 0 and overlap_y <= 0: + continue + if overlap_x < overlap_y: + push = ((overlap_x if dx >= 0 else -overlap_x), 0.0) + else: + push = (0.0, (overlap_y if dy >= 0 else -overlap_y)) + cap = 600.0 + push = (max(-cap, min(cap, push[0])), max(-cap, min(cap, push[1]))) + trial = dict(out) + for n in g_move.get("node_ids") or []: + if n in trial: + x, y = trial[n] + trial[n] = (x + push[0], y + push[1]) + if not _coord_ok(trial): + continue + out = trial + moved = True + if not moved: + break + return out + + +def rigid_untangle_groups( + state: LayoutState, + params: LayoutParams | None = None, + *, + groups: list[dict[str, Any]] | None = None, + max_rounds: int = 24, + step: float = 280.0, +) -> OpResult: + """Reduce crossings by rigid moves of whole sub-region groups only.""" + del params + st = state.copy() + meta = dict(st.meta or {}) + if groups is None: + groups = groups_from_compose_meta(meta.get("compose_views")) + if not groups: + return OpResult( + state=state, + moved=set(), + op="rigid_untangle", + params={"groups": 0}, + note="no_rigid_groups", + ) + + # Drop absurd coords (phantom region nodes) from the working set. + pos = { + nid: xy + for nid, xy in st.positions.items() + if abs(xy[0]) <= _COORD_ABS_MAX + and abs(xy[1]) <= _COORD_ABS_MAX + and math.isfinite(xy[0]) + and math.isfinite(xy[1]) + } + # Keep phantoms parked but do not let them drive cost. + for nid, xy in st.positions.items(): + if nid not in pos: + pos[nid] = (0.0, 0.0) + + links = list(st.links) + cur_cost = _cost(pos, links, groups) + start = cur_cost[0] + moved_nodes: set[str] = set() + accepted_moves = 0 + + # 1) Fan-out stacked eyes about shared portals (overlap-aware). + pos, fan_n = rigid_fan_out_portals(pos, groups, links) + accepted_moves += fan_n + if fan_n: + moved_nodes.update( + n + for g in groups + for n in (g.get("node_ids") or []) + if n in pos + ) + cur_cost = _cost(pos, links, groups) + + # Prefer moving freer groups first (fewer pivots / smaller). + ordered = sorted( + groups, + key=lambda g: (len(g.get("pivots") or []), len(g.get("node_ids") or [])), + ) + + angles = ( + 0.0, + math.pi / 6, + -math.pi / 6, + math.pi / 4, + -math.pi / 4, + math.pi / 3, + -math.pi / 3, + math.pi / 2, + -math.pi / 2, + 2 * math.pi / 3, + -2 * math.pi / 3, + 3 * math.pi / 4, + -3 * math.pi / 4, + math.pi, + ) + translations = [ + (0.0, 0.0), + (step, 0.0), + (-step, 0.0), + (0.0, step), + (0.0, -step), + (step, step), + (-step, step), + (step, -step), + (-step, -step), + (step * 1.6, 0.0), + (-step * 1.6, 0.0), + (0.0, step * 1.6), + (0.0, -step * 1.6), + ] + + for _ in range(max(1, int(max_rounds))): + improved = False + for g in ordered: + members = [n for n in g["node_ids"] if n in pos] + if len(members) < 2: + continue + pivots = [p for p in (g.get("pivots") or []) if p in pos] + if len(pivots) >= 2: + # Fully locked except reflection about portal axis. + cand_moves: list[tuple[float, float, float] | str] = ["flip"] + elif len(pivots) == 1: + pivot_xy = pos[pivots[0]] + cand_moves = [ + (0.0, 0.0, ang) for ang in angles if abs(ang) > 1e-12 + ] + else: + pivot_xy = None + cand_moves = [ + (dx, dy, ang) + for dx, dy in translations + for ang in ((0.0,) if (dx, dy) != (0.0, 0.0) else angles) + ] + + best: tuple[tuple[int, int], dict[str, tuple[float, float]]] | None = None + for move in cand_moves: + if move == "flip": + p0, p1 = pos[pivots[0]], pos[pivots[1]] + trial = _reflect_about_axis(pos, members, p0, p1) + for p in pivots: + trial[p] = pos[p] + else: + dx, dy, ang = move + if dx == 0 and dy == 0 and abs(ang) < 1e-12: + continue + trial = _apply_rigid( + pos, + members, + pivot=pivot_xy if len(pivots) == 1 else None, + dx=dx, + dy=dy, + angle=ang, + ) + if pivots: + for p in pivots: + trial[p] = pos[p] + c1 = _cost(trial, links, groups) + if c1 < cur_cost and (best is None or c1 < best[0]): + best = (c1, trial) + if best is None: + continue + pos = best[1] + cur_cost = best[0] + moved_nodes.update(members) + accepted_moves += 1 + improved = True + if not improved: + break + + # Whole-group bbox separation (still rigid; no internal reshaping). + before_sep = cur_cost + sep = rigid_separate_groups(pos, groups, pad=140.0) + sep_cost = _cost(sep, links, groups) + if sep_cost <= before_sep: + pos = sep + cur_cost = sep_cost + + st.positions = pos + st.meta = meta + st.meta["rigid_untangle"] = { + "start_crossings": start, + "end_crossings": cur_cost[0], + "end_group_bbox_overlaps": cur_cost[1], + "groups": len(groups), + "accepted_moves": accepted_moves, + "fan_out_moves": fan_n, + "moved_n": len(moved_nodes), + } + return OpResult( + state=st, + moved=moved_nodes, + op="rigid_untangle", + params={ + "groups": len(groups), + "max_rounds": max_rounds, + "step": step, + "start_crossings": start, + "end_crossings": cur_cost[0], + "end_group_bbox_overlaps": cur_cost[1], + "accepted_moves": accepted_moves, + "fan_out_moves": fan_n, + }, + note=( + f"rigid_untangle {start}->{cur_cost[0]} " + f"bbox_ov={cur_cost[1]} moves={accepted_moves}" + ), + ) + + +def densify_rigid_groups( + state: LayoutState, + groups: list[dict[str, Any]], + *, + scale: float = 0.55, + accept_crossings: bool = True, + x_slack: int | None = None, +) -> OpResult: + """Translate each staging unit toward the global centroid (rigid). + + Dual-unit membership heavily overlaps on portals — averaging a translation + over *all* members cancels densify / spikes crossings. Instead: + - move each group's **exclusive** nodes (membership==1) toward centroid; + - **freeze shared portals** (glue stays put; spokes shorten from islands); + - apply **one group at a time**; when ``accept_crossings``, reject a move + that raises global crossings beyond a small slack. + """ + st = state.copy() + pos0 = dict(st.positions) + valid = { + n + for n, (x, y) in pos0.items() + if abs(x) <= _COORD_ABS_MAX + and abs(y) <= _COORD_ABS_MAX + and math.isfinite(x) + and math.isfinite(y) + } + if len(valid) < 2 or not groups: + return OpResult( + state=st, moved=set(), op="densify_rigid_groups", note="noop" + ) + s = max(0.2, min(float(scale), 0.95)) + gcx = sum(pos0[n][0] for n in valid) / len(valid) + gcy = sum(pos0[n][1] for n in valid) / len(valid) + + xs = [pos0[n][0] for n in valid] + ys = [pos0[n][1] for n in valid] + bw0 = max(max(xs) - min(xs), 1e-6) + bh0 = max(max(ys) - min(ys), 1e-6) + + counts: dict[str, int] = defaultdict(int) + parsed: list[tuple[str, list[str], set[str]]] = [] + for g in groups: + 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] += 1 + parsed.append((str(g.get("key") or ""), members, pivots)) + + shared = {n for n, c in counts.items() if c > 1} + pos = dict(pos0) + moved: set[str] = set() + used_groups = 0 + accepted = 0 + rejected = 0 + exclusive_moved = 0 + x0 = ( + count_edge_crossings(pos0, st.links) if accept_crossings else 0 + ) + slack = ( + max(20, int(x0 * 0.12)) + if x_slack is None + else max(0, int(x_slack)) + ) + + # Farther islands first — they dominate bbox. + candidates: list[tuple[float, str, list[str], float, float]] = [] + for key, members, pivots in parsed: + exclusive = [n for n in members if n not in shared] + if len(exclusive) < 2: + exclusive = [n for n in members if n not in pivots and n not in shared] + if len(exclusive) < 2: + continue + cx = sum(pos0[n][0] for n in exclusive) / len(exclusive) + cy = sum(pos0[n][1] for n in exclusive) / len(exclusive) + dist = math.hypot(cx - gcx, cy - gcy) + candidates.append((dist, key, exclusive, cx, cy)) + candidates.sort(reverse=True) + + for _dist, _key, exclusive, cx, cy in candidates: + ncx = gcx + (cx - gcx) * s + ncy = gcy + (cy - gcy) * s + dx, dy = ncx - cx, ncy - cy + if abs(dx) < 1e-9 and abs(dy) < 1e-9: + continue + used_groups += 1 + trial = dict(pos) + for n in exclusive: + x0n, y0n = pos[n] + trial[n] = (x0n + dx, y0n + dy) + if accept_crossings: + x1 = count_edge_crossings(trial, st.links) + if x1 > x0 + slack: + rejected += 1 + continue + x0 = x1 + for n in exclusive: + pos[n] = trial[n] + moved.add(n) + exclusive_moved += 1 + accepted += 1 + + st.positions = pos + st.last_moved = moved + xs1 = [pos[n][0] for n in valid] + ys1 = [pos[n][1] for n in valid] + bw1 = max(max(xs1) - min(xs1), 1e-6) + bh1 = max(max(ys1) - min(ys1), 1e-6) + area_ratio = (bw0 * bh0) / (bw1 * bh1) + return OpResult( + state=st, + moved=moved, + op="densify_rigid_groups", + params={ + "scale": round(s, 4), + "groups": used_groups, + "accepted": accepted, + "rejected": rejected, + "moved_n": len(moved), + "exclusive_moved": exclusive_moved, + "shared_n": len(shared), + "portals_frozen": True, + "bbox_area_ratio": round(area_ratio, 3), + "x_slack": slack if accept_crossings else None, + }, + note=( + f"densify_rigid s={s:.2f} groups={used_groups} " + f"ok={accepted}/rej={rejected} excl={exclusive_moved} " + f"area×{area_ratio:.2f}" + ), + ) + + +def rigid_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + o = overrides or {} + out: dict[str, Any] = {} + if o.get("max_rounds") is not None: + try: + out["max_rounds"] = int(o["max_rounds"]) + except (TypeError, ValueError): + pass + if o.get("step") is not None: + try: + out["step"] = float(o["step"]) + except (TypeError, ValueError): + pass + groups = o.get("rigid_groups") or o.get("_rigid_groups") + if isinstance(groups, list): + out["groups"] = groups + return out + + +def exclusive_members_frozen(state: LayoutState) -> set[str]: + """All nodes in compose rigid groups (legacy full freeze).""" + return frozen_ids_for_protect(state, "all") + + +def _hub_portals(state: LayoutState, candidates: set[str], *, cap: int = 28) -> set[str]: + """Shrink an inflated pivot set to true dual-unit hubs (high degree). + + Overlapping dual-units mark whole shared corridors as pivots; freezing + those stalls soft polish. Keep high-degree hubs first. + """ + if len(candidates) <= cap: + return set(candidates) + scored = sorted( + ((len(state.adj.get(n, ())), n) for n in candidates), + reverse=True, + ) + hubs = {n for deg, n in scored if deg >= 5} + if 2 <= len(hubs) <= cap * 2: + return hubs + return {n for _deg, n in scored[:cap]} + + +def frozen_ids_for_protect( + state: LayoutState, + mode: str | bool | None = "portals", +) -> set[str]: + """Which compose-unit nodes to freeze during per-node untangle. + + Modes: + - ``False`` / ``off``: freeze nothing + - ``portals`` / ``skeleton`` / ``True`` (default): freeze dual-unit portals + (high-degree shared hubs). Corridors/tails may move. + - ``all``: freeze every rigid-group member (pure rigid; crossings stall) + """ + if mode is False: + return set() + if isinstance(mode, str): + key = mode.strip().lower() + elif mode is True: + key = "portals" + else: + key = "portals" + if key in {"0", "false", "no", "off", "none"}: + return set() + + groups = groups_from_compose_meta((state.meta or {}).get("compose_views")) + if not groups: + return set() + + if key in {"all", "full", "rigid"}: + frozen: set[str] = set() + for g in groups: + frozen.update(g.get("node_ids") or []) + return frozen + + # portals / skeleton / true: start from compose pivots, then shrink if the + # dual-unit overlap inflated the set with corridor nodes. + frozen: set[str] = set() + for g in groups: + frozen.update(str(x) for x in (g.get("pivots") or []) if str(x)) + if not frozen: + counts: dict[str, int] = {} + for g in groups: + for nid in g.get("node_ids") or []: + counts[str(nid)] = counts.get(str(nid), 0) + 1 + frozen = {n for n, c in counts.items() if c > 1} + return _hub_portals(state, frozen) + + +def groups_from_membership( + membership: list[tuple[str, list[str]]], +) -> list[dict[str, Any]]: + """Build rigid groups from (key, node_ids) membership lists. + + Pivots = nodes that appear in more than one group (shared portals). + """ + counts: dict[str, int] = {} + for _key, ids in membership: + for nid in ids: + counts[nid] = counts.get(nid, 0) + 1 + out: list[dict[str, Any]] = [] + for key, ids in membership: + uniq = sorted({str(x) for x in ids if str(x)}) + if len(uniq) < 2: + continue + pivots = [n for n in uniq if counts.get(n, 0) > 1] + out.append({"key": str(key), "node_ids": uniq, "pivots": pivots}) + return out diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py new file mode 100644 index 0000000..f068c9d --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/ring_faces.py @@ -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。", + } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py new file mode 100644 index 0000000..ce2db96 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/rings.py @@ -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", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py new file mode 100644 index 0000000..6677e1b --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/scope.py @@ -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}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py new file mode 100644 index 0000000..9792146 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/score.py @@ -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')}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py new file mode 100644 index 0000000..9c56f7a --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sides.py @@ -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", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py new file mode 100644 index 0000000..df45421 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sink_dual_units.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py new file mode 100644 index 0000000..8e7fe99 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/skeleton.py @@ -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", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py new file mode 100644 index 0000000..1ed273a --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/soft_petals.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py new file mode 100644 index 0000000..08dd19f --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/state.py @@ -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 = "" diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py new file mode 100644 index 0000000..fe039d8 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/sugiyama.py @@ -0,0 +1,2157 @@ +"""UME-inspired metro layout: 2D AN anchors + rectangular petals. + +Learned from human SMD canvas: +- ANs sit in a 2D constellation (not one top rail) +- Access grows around each AN (above+below), on axis-aligned rectangle edges +- Dangling feeders (非环纯链路): leaf → … → first ring/junction attach; no through-cross +- Two-portal minimal rings are atomic hollow units; nested rings use trapezoid bands + (shortest/narrowest inner → longer/wider outer) +- Islands stay roughly square (aspect ~1.2–1.7) +""" + +from __future__ import annotations + +import math +from collections import deque + +from netx_topology_mcp.layout_metrics import count_edge_crossings +from netx_topology_mcp.layout_ops.graph_util import ( + bbox, + chain_order, + connected_components, + order_ans, +) +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult + +def _nearest_an( + n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str] +) -> str: + if not ans: + return "" + an_set = set(ans) + q: deque[tuple[str, int]] = deque([(n, 0)]) + seen = {n} + best_d: int | None = None + cands: list[str] = [] + while q: + u, d = q.popleft() + if best_d is not None and d > best_d: + break + if u in an_set: + best_d = d + cands.append(u) + continue + if d >= 24: + continue + for v in adj.get(u, ()): + if v not in seen: + seen.add(v) + q.append((v, d + 1)) + if not cands: + return min(ans, key=lambda a: names.get(a, a)) + return min(cands, key=lambda a: (-len(adj.get(a, ())), names.get(a, a))) + + +def _hop_from_roots( + nodes: list[str], roots: list[str], adj: dict[str, set[str]] +) -> dict[str, int]: + node_set = set(nodes) + dist: dict[str, int] = {} + q: deque[str] = deque() + for r in roots: + for nb in adj.get(r, ()): + if nb in node_set and nb not in dist: + dist[nb] = 1 + q.append(nb) + while q: + u = q.popleft() + for v in adj.get(u, ()): + if v in node_set and v not in dist: + dist[v] = dist[u] + 1 + q.append(v) + for n in nodes: + if n not in dist: + dist[n] = (max(dist.values()) if dist else 0) + 1 + return dist + + +def _point_on_rect(t: float, x0: float, y0: float, w: float, h: float) -> tuple[float, float]: + perim = 2.0 * (w + h) + if perim <= 1e-9: + return x0, y0 + t = t % perim + if t <= w: + return x0 + t, y0 + t -= w + if t <= h: + return x0 + w, y0 + t + t -= h + if t <= w: + return x0 + w - t, y0 + h + t -= w + return x0, y0 + h - t + + +def _squarish_rect_dims(n: int, params: LayoutParams) -> tuple[float, float]: + """Rectangle sized for n edge nodes; keep aspect near UME (~1–1.6).""" + pitch, side = params.pitch, params.side + n = max(n, 4) + perim = n * pitch + # Solve 2(w+h)=perim with w/h ≈ 1.35 + ratio = 1.35 + h = perim / (2.0 * (ratio + 1.0)) + w = ratio * h + h = max(side * 1.2, h) + w = max(pitch * 2.5, w) + return w, h + + +def _place_on_rect_edge( + order: list[str], x0: float, y0: float, w: float, h: float +) -> dict[str, tuple[float, float]]: + n = len(order) + if n == 0: + return {} + perim = 2.0 * (w + h) + offset = w / 2.0 + out: dict[str, tuple[float, float]] = {} + for i, nid in enumerate(order): + t = offset + (i / n) * perim + out[nid] = _point_on_rect(t, x0, y0, w, h) + return out + + +def _cycle_order( + ring_nodes: list[str], adj: dict[str, set[str]], names: dict[str, str] +) -> list[str]: + s = set(ring_nodes) + if len(s) < 3: + return chain_order(ring_nodes, adj, names) + sub = {n: [v for v in adj.get(n, ()) if v in s] for n in s} + deg2 = [n for n in ring_nodes if len(sub.get(n, ())) == 2] + start = min(deg2 or ring_nodes, key=lambda n: names.get(n, n)) + order = [start] + prev = None + cur = start + for _ in range(len(s) - 1): + nxts = [v for v in sub.get(cur, ()) if v != prev] + fresh = [v for v in nxts if v not in order] + if not fresh: + break + pick = min(fresh, key=lambda n: names.get(n, n)) + order.append(pick) + prev, cur = cur, pick + if len(order) < len(s): + return chain_order(ring_nodes, adj, names) + return order + + +def _find_access_rings( + access: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + *, + min_len: int = 4, + max_len: int = 24, +) -> list[list[str]]: + """Prefer small/medium rings (UME rings ~10 nodes), not mega cycles.""" + s = {n for n in access if sum(1 for v in adj.get(n, ()) if v in access) >= 2} + if len(s) < min_len: + return [] + sub = { + n: sorted([v for v in adj.get(n, ()) if v in s], key=lambda x: names.get(x, x)) + for n in s + } + seen_sets: set[frozenset[str]] = set() + rings: list[list[str]] = [] + budget = [8000] + + def dfs(start: str, cur: str, path: list[str], parent: str | None) -> None: + if budget[0] <= 0 or len(path) > max_len: + return + for v in sub.get(cur, ()): + budget[0] -= 1 + if budget[0] <= 0: + return + if v == parent: + continue + if v == start and len(path) >= min_len: + key = frozenset(path) + if key not in seen_sets: + seen_sets.add(key) + rings.append(list(path)) + continue + if v in path or names.get(v, v) < names.get(start, start): + continue + path.append(v) + dfs(start, v, path, cur) + path.pop() + + for start in sorted(s, key=lambda n: names.get(n, n)): + if budget[0] <= 0: + break + dfs(start, start, [start], None) + # Prefer UME-like sizes (6–16) first, then longer + rings.sort( + key=lambda r: ( + 0 if 6 <= len(r) <= 16 else 1, + -len(r), + names.get(r[0], r[0]), + ) + ) + kept: list[list[str]] = [] + used: set[str] = set() + for r in rings: + if any(n in used for n in r): + continue + kept.append(r) + used.update(r) + if len(kept) >= 12: + break + return kept + + +def _assign_layers_column( + nodes: list[str], + roots: list[str], + layers: dict[str, str], + adj: dict[str, set[str]], + names: dict[str, str], +) -> dict[str, int]: + cores = sorted( + [n for n in nodes if layers.get(n) == "core"], key=lambda n: names[n] + ) + ans = [n for n in roots if n in nodes] + ens = [n for n in nodes if layers.get(n) == "access"] + others = sorted( + [n for n in nodes if layers.get(n) == "other"], key=lambda n: names[n] + ) + layer_of: dict[str, int] = {} + base = 0 + for c in cores: + layer_of[c] = 0 + if cores: + base = 1 + for a in ans: + layer_of[a] = base + access_base = base + (1 if ans else 0) + hops = _hop_from_roots(ens, ans, adj) if ens and ans else {e: 1 for e in ens} + hop_cap = min(max(hops.values(), default=1), 6) + for e in ens: + h = min(max(hops.get(e, 1), 1), hop_cap) + layer_of[e] = access_base + (h - 1) + other_layer = (max(layer_of.values()) if layer_of else 0) + 1 + for o in others: + layer_of[o] = other_layer + for n in nodes: + if n not in layer_of: + layer_of[n] = other_layer + return layer_of + + +def _count_layer_crossings( + order_a: list[str], order_b: list[str], adj: dict[str, set[str]] +) -> int: + pos_a = {n: i for i, n in enumerate(order_a)} + pos_b = {n: i for i, n in enumerate(order_b)} + edges: list[tuple[int, int]] = [] + for u in order_a: + for v in adj.get(u, ()): + if v in pos_b: + edges.append((pos_a[u], pos_b[v])) + edges.sort() + cross = 0 + for i in range(len(edges)): + ai, bi = edges[i] + for j in range(i + 1, len(edges)): + aj, bj = edges[j] + if aj == ai: + continue + if (ai - aj) * (bi - bj) < 0: + cross += 1 + return cross + + +def _minimize_crossings( + layer_lists: list[list[str]], + adj: dict[str, set[str]], + names: dict[str, str], + *, + sweeps: int = 10, +) -> list[list[str]]: + def total(layers: list[list[str]]) -> int: + t = 0 + for i in range(len(layers) - 1): + if layers[i] and layers[i + 1]: + t += _count_layer_crossings(layers[i], layers[i + 1], adj) + return t + + best = [list(L) for L in layer_lists] + best_c = total(best) + cur = best + for s in range(sweeps): + layers = [list(L) for L in cur] + downward = s % 2 == 0 + indices = range(1, len(layers)) if downward else range(len(layers) - 2, -1, -1) + ref_delta = -1 if downward else 1 + for i in indices: + ref = layers[i + ref_delta] + if not ref or not layers[i]: + continue + ref_pos = {n: k for k, n in enumerate(ref)} + scored = [] + for idx, n in enumerate(layers[i]): + neigh = [ref_pos[v] for v in adj.get(n, ()) if v in ref_pos] + bc = sum(neigh) / len(neigh) if neigh else float(idx) + scored.append((bc, idx, names.get(n, n), n)) + scored.sort() + layers[i] = [t[3] for t in scored] + c = total(layers) + if c < best_c: + best_c = c + best = layers + cur = layers + return best + + +def _sugiyama_column( + nodes: list[str], + roots: list[str], + state: LayoutState, + params: LayoutParams, + *, + adj: dict[str, set[str]] | None = None, +) -> dict[str, tuple[float, float]]: + """Low-cross layered skeleton for one AN territory.""" + g = adj if adj is not None else state.adj + layers, names = state.layers, state.names + layer_of = _assign_layers_column(nodes, roots, layers, g, names) + max_l = max(layer_of.values()) if layer_of else 0 + layer_lists: list[list[str]] = [[] for _ in range(max_l + 1)] + for n in sorted(nodes, key=lambda x: names.get(x, x)): + layer_lists[layer_of[n]].append(n) + while len(layer_lists) > 1 and not layer_lists[-1]: + layer_lists.pop() + layer_lists = _minimize_crossings(layer_lists, g, names, sweeps=10) + # Adaptive pitch: wide fans may tighten, but never below label-safe spacing. + n_access = sum(1 for n in nodes if layers.get(n) == "access") + pitch = params.pitch + if n_access > 40: + pitch = max(165.0, params.pitch * min(1.0, 32.0 / math.sqrt(n_access))) + side = params.side + pos: dict[str, tuple[float, float]] = {} + for li, L in enumerate(layer_lists): + for i, n in enumerate(L): + pos[n] = (i * pitch, float(li * side)) + for _ in range(8): + for li, L in enumerate(layer_lists): + if not L: + continue + prev = set(layer_lists[li - 1]) if li else set() + nxt = set(layer_lists[li + 1]) if li + 1 < len(layer_lists) else set() + new_x = {} + for n in L: + xs = [ + pos[v][0] + for v in g.get(n, ()) + if v in pos and (v in prev or v in nxt) + ] + new_x[n] = sum(xs) / len(xs) if xs else pos[n][0] + order_idx = {n: i for i, n in enumerate(L)} + ordered = sorted(L, key=lambda n: (new_x[n], order_idx[n])) + xs = [new_x[n] for n in ordered] + for i in range(1, len(xs)): + if xs[i] < xs[i - 1] + pitch: + xs[i] = xs[i - 1] + pitch + for n, x in zip(ordered, xs): + pos[n] = (x, float(li * side)) + layer_lists[li] = ordered + return pos + + +def _an_subtrees( + an: str, ens: list[str], adj: dict[str, set[str]], names: dict[str, str] +) -> list[list[str]]: + """Partition access into subtrees hanging off each AN neighbor.""" + ens_set = set(ens) + children = sorted( + [v for v in adj.get(an, ()) if v in ens_set], + key=lambda n: names.get(n, n), + ) + claimed: set[str] = set() + trees: list[list[str]] = [] + for c in children: + if c in claimed: + continue + tree: list[str] = [] + q: deque[str] = deque([c]) + seen = {c, an} + while q: + u = q.popleft() + tree.append(u) + claimed.add(u) + for v in adj.get(u, ()): + if v in ens_set and v not in seen: + seen.add(v) + q.append(v) + if tree: + trees.append(tree) + # Orphans not reached + rest = [e for e in ens if e not in claimed] + if rest: + trees.append(rest) + trees.sort(key=lambda t: -len(t)) + return trees + + +def _access_degree(n: str, ens_set: set[str], adj: dict[str, set[str]]) -> int: + return sum(1 for v in adj.get(n, ()) if v in ens_set) + + +def _extract_dangling_feeders( + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + attach_set: set[str], + *, + min_len: int = 2, + an_set: set[str] | None = None, +) -> list[list[str]]: + """Non-ring pure chains: leaf → corridor → first ring/junction attach. + + Junction↔junction corridors and cycle sides are NOT feeders — those belong + to rings. A feeder is a dead-end spur that should stay an uncrossed unit. + + Nodes that touch an AN are never leaves (they are ring/backbone portals); + otherwise AN-side ring legs get misclassified as feeders and cross chains. + """ + ens_set = set(ens) + ans = set(an_set or ()) + if len(ens_set) < min_len: + return [] + deg = {n: _access_degree(n, ens_set, adj) for n in ens_set} + attach = set(attach_set) & (ens_set | ans) + used: set[str] = set() + chains: list[list[str]] = [] + + def is_leaf(n: str) -> bool: + if n in attach or n in ans: + return False + if adj.get(n, ()) & ans: + return False # hangs on AN → ring/backbone, not a dangling tip + return deg.get(n, 0) <= 1 + + leaves = sorted( + [n for n in ens_set if is_leaf(n)], + key=lambda n: names.get(n, n), + ) + for leaf in leaves: + if leaf in used: + continue + path = [leaf] + prev: str | None = None + cur = leaf + while cur not in attach: + nbs = [ + v + for v in adj.get(cur, ()) + if v != prev and (v in ens_set or v in attach) + ] + if not nbs: + break + # Corridor: unique forward; stop at branch (non-attach junction). + if len(nbs) != 1: + hit = [v for v in nbs if v in attach] + if len(hit) == 1: + path.append(hit[0]) + cur = hit[0] + break + break + nxt = nbs[0] + path.append(nxt) + prev, cur = cur, nxt + if len(path) > 48: + break + if cur not in attach and deg.get(cur, 0) >= 3: + break + if len(path) < min_len: + continue + if path[-1] not in attach and deg.get(path[-1], 0) < 3: + continue + # Mark only the dangling body (keep attach node free for rings). + body = path[:-1] if path[-1] in attach or deg.get(path[-1], 0) >= 3 else path + if len(body) < 1: + continue + used.update(body) + chains.append(path) + + chains.sort(key=lambda c: (-len(c), names.get(c[0], c[0]))) + return chains + + +def _extract_pure_chains( + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + *, + min_len: int = 2, + attach_set: set[str] | None = None, +) -> list[list[str]]: + """Backward-compatible alias → dangling feeders (non-ring only).""" + ens_set = set(ens) + if attach_set is None: + # Fallback attach: junctions + leaves' far hubs (deg≥3). + attach_set = { + n + for n in ens_set + if _access_degree(n, ens_set, adj) >= 3 + } + return _extract_dangling_feeders( + ens, adj, names, attach_set, min_len=min_len + ) + + +def _orient_feeder_outward(chain: list[str], attach_set: set[str]) -> list[str]: + """Orient feeder as leaf → … → attach.""" + if not chain: + return [] + if chain[-1] in attach_set: + return list(chain) + if chain[0] in attach_set: + return list(reversed(chain)) + return list(chain) + + +def _chain_axis_frac(pts: list[tuple[float, float]]) -> float: + if len(pts) < 2: + return 1.0 + ok = 0 + for i in range(len(pts) - 1): + dx = abs(pts[i + 1][0] - pts[i][0]) + dy = abs(pts[i + 1][1] - pts[i][1]) + if dx < 1.0 or dy < 1.0: + ok += 1 + return ok / (len(pts) - 1) + + +def _place_chain_unit_at( + order: list[str], + pts: list[tuple[float, float]], + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + """Refit one pure chain to an axis-aligned polyline, preserving step lengths. + + Fixed-pitch packing caused mass overlaps → fix_overlaps blew crossings up. + """ + if not order or len(order) != len(pts): + return {} + min_step = max(params.pitch * 0.85, 1.0) + out: dict[str, tuple[float, float]] = {order[0]: pts[0]} + x, y = pts[0] + for i in range(1, len(order)): + tx, ty = pts[i] + dx, dy = tx - x, ty - y + dist = math.hypot(dx, dy) + step = max(dist, min_step) + if abs(dx) >= abs(dy): + x = x + (step if dx >= 0 else -step) + else: + y = y + (step if dy >= 0 else -step) + out[order[i]] = (x, y) + return out + + +def _snap_chains_as_wholes( + pos: dict[str, tuple[float, float]], + chains: list[list[str]], + params: LayoutParams, + edges: list[tuple[str, str]], +) -> tuple[dict[str, tuple[float, float]], set[str]]: + """Priority pass: accept each pure chain as one polyline only if crossings hold.""" + if not chains: + return pos, set() + cur = dict(pos) + pinned: set[str] = set() + c_cur = count_edge_crossings(cur, edges) if edges else 0 + # Longer feeders first — they benefit most from whole-unit treatment. + ordered = sorted(chains, key=lambda c: (-len(c), c[0] if c else "")) + for ch in ordered: + order = [n for n in ch if n in cur] + if len(order) < 3: + continue + pts = [cur[n] for n in order] + # Already a coherent axis unit — leave as-is, still pin. + if _chain_axis_frac(pts) >= 0.8: + pinned.update(order) + continue + unit = _place_chain_unit_at(order, pts, params) + if len(unit) != len(order): + continue + trial = dict(cur) + trial.update(unit) + c1 = count_edge_crossings(trial, edges) if edges else 0 + # Strict: no crossing regression for a cosmetic straighten. + if c1 <= c_cur: + cur = trial + c_cur = c1 + pinned.update(order) + return cur, pinned + + +def _surround_by_an_subtrees( + pos: dict[str, tuple[float, float]], + an: str, + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + *, + pinned: set[str] | None = None, +) -> dict[str, tuple[float, float]]: + """Balance AN subtrees above/below by node weight (UME surround).""" + if an not in pos or not ens: + return pos + pin = pinned or set() + out = dict(pos) + ax, ay = out[an] + out = {n: (x - ax, y - ay) for n, (x, y) in out.items()} + trees = _an_subtrees(an, ens, adj, names) + # Skip trees already claimed by chain-first placement. + movable = [] + for t in trees: + free = [n for n in t if n not in pin] + if free: + movable.append(free) + if len(movable) <= 1: + return out + north_w = south_w = 0 + for tree in movable: + # Greedy: put next tree on the lighter side. + to_north = north_w < south_w + if to_north: + north_w += len(tree) + for n in tree: + if n in out: + x, y = out[n] + out[n] = (x, -abs(y) if abs(y) > 1e-6 else -1.0) + else: + south_w += len(tree) + for n in tree: + if n in out: + x, y = out[n] + out[n] = (x, abs(y) if abs(y) > 1e-6 else 1.0) + return out + + +def _access_junctions( + ens: list[str], + adj: dict[str, set[str]], + an_set: set[str], +) -> list[str]: + """Portal candidates: access deg≥3, or deg≥2 and touching an AN.""" + ens_set = set(ens) + out: list[str] = [] + for n in ens: + adeg = _access_degree(n, ens_set, adj) + if adeg >= 3 or (adeg >= 2 and (adj.get(n, ()) & an_set)): + out.append(n) + return out + + +def _corridor_paths_between( + p1: str, + p2: str, + ens_set: set[str], + adj: dict[str, set[str]], + names: dict[str, str], + *, + max_len: int = 24, + an_set: set[str] | None = None, +) -> list[list[str]]: + """All p1→p2 paths whose interior nodes are pure corridor (access-deg ≤2). + + Portals may be ANs (outside ens_set); interiors stay on access. + """ + ans = set(an_set or ()) + + def adeg(n: str) -> int: + # Count AN touch as a stub so AN-hanging corridor nodes stay walkable. + return _access_degree(n, ens_set, adj) + ( + 1 if (adj.get(n, ()) & ans) and n not in (p1, p2) else 0 + ) + + def corridor_ok(n: str) -> bool: + """Interior: at most 2 access neighbors (AN link ignored for cap).""" + return _access_degree(n, ens_set, adj) <= 2 + + paths: list[list[str]] = [] + # Direct edge counts as an empty-interior side. + if p2 in adj.get(p1, ()): + paths.append([p1, p2]) + + for nxt in sorted(adj.get(p1, ()), key=lambda n: names.get(n, n)): + if nxt == p2: + continue + if nxt not in ens_set: + continue + if not corridor_ok(nxt): + continue + path = [p1, nxt] + prv, cur = p1, nxt + seen = {p1, nxt} + ok = True + while cur != p2: + nbs = [ + v + for v in adj.get(cur, ()) + if v != prv and (v == p2 or v in ens_set) + ] + forward = [ + v + for v in nbs + if v == p2 or (v in ens_set and corridor_ok(v) and v not in seen) + ] + if p2 in nbs and p2 not in forward: + forward.append(p2) + if len(forward) != 1: + ok = False + break + prv, cur = cur, forward[0] + if cur in seen and cur != p2: + ok = False + break + path.append(cur) + seen.add(cur) + if len(path) > max_len: + ok = False + break + if ok and path[-1] == p2 and len(path) >= 2: + paths.append(path) + + # Unique by interior node set (keep shortest name-stable representative). + best: dict[frozenset[str], list[str]] = {} + for p in paths: + key = frozenset(p[1:-1]) + prev = best.get(key) + if prev is None or len(p) < len(prev): + best[key] = p + return sorted( + best.values(), + key=lambda p: (len(p), names.get(p[1], p[1]) if len(p) > 1 else ""), + ) + + +def _is_two_portal_cycle( + path_a: list[str], + path_b: list[str], + adj: dict[str, set[str]], + ens_set: set[str], + an_set: set[str], +) -> bool: + """True if cycle(path_a ∪ path_b) touches the outside only at the two portals.""" + if len(path_a) < 2 or len(path_b) < 2: + return False + if path_a[0] != path_b[0] or path_a[-1] != path_b[-1]: + return False + a, b = path_a[0], path_a[-1] + if set(path_a[1:-1]) & set(path_b[1:-1]): + return False + node_set = set(path_a) | set(path_b) + if len(node_set) < 3: + return False + portals = [] + for n in node_set: + # Any neighbor outside the cycle counts (AN uplink, peer AN, side chain). + ext = [v for v in adj.get(n, ()) if v not in node_set] + if ext: + portals.append(n) + return set(portals) == {a, b} + + +def _find_two_portal_ring_groups( + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + an_set: set[str], +) -> list[dict[str, object]]: + """Portal pairs with ≥2 corridor paths; paths sorted shortest→longest (inner→outer). + + ANs may be portals — metro rings often hang off an AN with a direct edge + plus a longer access corridor (the TNM/JROS/ADAK/SRIN pattern). + """ + ens_set = set(ens) + junc = _access_junctions(ens, adj, an_set) + an_portals = sorted( + [a for a in an_set if any(v in ens_set for v in adj.get(a, ()))], + key=lambda n: names.get(n, n), + ) + portals = sorted(set(junc) | set(an_portals), key=lambda n: names.get(n, n)) + groups: list[dict[str, object]] = [] + + for i, a in enumerate(portals): + for b in portals[i + 1 :]: + paths = _corridor_paths_between( + a, b, ens_set, adj, names, an_set=an_set + ) + if len(paths) < 2: + continue + # Disjoint interiors, shortest first (= innermost). + paths = sorted( + paths, + key=lambda p: (len(p), names.get(p[1], p[1]) if len(p) > 2 else ""), + ) + chosen: list[list[str]] = [] + used_mid: set[str] = set() + for p in paths: + mid = set(p[1:-1]) + if mid & used_mid: + continue + if chosen and not any( + _is_two_portal_cycle(p, q, adj, ens_set, an_set) for q in chosen + ): + continue + chosen.append(p) + used_mid |= mid + if len(chosen) < 2: + continue + if not any( + _is_two_portal_cycle(chosen[0], q, adj, ens_set, an_set) + for q in chosen[1:] + ): + continue + groups.append({"portals": (a, b), "paths": chosen}) + + groups.sort( + key=lambda g: ( + -len(g["paths"]), # type: ignore[arg-type] + names.get(g["portals"][0], g["portals"][0]), # type: ignore[index] + ) + ) + kept: list[dict[str, object]] = [] + used_interior: set[str] = set() + for g in groups: + a, b = g["portals"] # type: ignore[misc] + paths = g["paths"] # type: ignore[assignment] + interiors = set() + for p in paths: # type: ignore[union-attr] + interiors |= set(p[1:-1]) + if interiors & used_interior: + continue + used_interior |= interiors + kept.append(g) + return kept + + +def _place_path_on_span( + path: list[str], + x_left: float, + x_right: float, + y: float, +) -> dict[str, tuple[float, float]]: + """Place corridor interiors evenly on a horizontal span (portals excluded).""" + mid = path[1:-1] + if not mid: + return {} + out: dict[str, tuple[float, float]] = {} + for i, n in enumerate(mid): + t = (i + 1) / (len(mid) + 1) + out[n] = (x_left + t * (x_right - x_left), y) + return out + + +def _ring_nodes_from_groups(groups: list[dict[str, object]]) -> set[str]: + out: set[str] = set() + for g in groups: + a, b = g["portals"] # type: ignore[misc] + out.add(a) + out.add(b) + for p in g["paths"]: # type: ignore[union-attr] + out.update(p) + return out + + +def _reflect_point_across_segment( + p: tuple[float, float], + a: tuple[float, float], + b: tuple[float, float], +) -> tuple[float, float]: + """Reflect point p across the infinite line through a—b.""" + ax, ay = a + bx, by = b + px, py = p + dx, dy = bx - ax, by - ay + L2 = dx * dx + dy * dy + if L2 < 1e-12: + return p + t = ((px - ax) * dx + (py - ay) * dy) / L2 + qx, qy = ax + t * dx, ay + t * dy + return (2.0 * qx - px, 2.0 * qy - py) + + +def _point_in_poly(x: float, y: float, poly: list[tuple[float, float]]) -> bool: + """Ray-casting inclusion; boundary counts as inside.""" + 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 abs((yj - yi) * (x - xi) - (xj - xi) * (y - yi)) < 1e-6: + # on edge segment? + if min(xi, xj) - 1e-6 <= x <= max(xi, xj) + 1e-6 and min( + yi, yj + ) - 1e-6 <= y <= max(yi, yj) + 1e-6: + return True + intersect = ((yi > y) != (yj > y)) and ( + x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-12) + xi + ) + if intersect: + inside = not inside + j = i + return inside + + +def _ring_polygon( + group: dict[str, object], pos: dict[str, tuple[float, float]] +) -> tuple[set[str], list[tuple[float, float]], list[str]]: + """Cycle polygon for a two-portal ring group (portals + corridor bands).""" + a, b = group["portals"] # type: ignore[misc] + paths: list[list[str]] = group["paths"] # type: ignore[assignment] + unit: set[str] = {a, b} + for p in paths: + unit.update(p) + # Prefer longest path as one arc; close via remaining arcs / chord. + arcs = [] + for p in paths: + if p[0] == b and p[-1] == a: + p = list(reversed(p)) + if p[0] == a and p[-1] == b: + arcs.append(p) + if not arcs: + order = [n for n in unit if n in pos] + pts = [pos[n] for n in order] + return unit, pts, order + arcs.sort(key=len) + # Walk a → short … → b → reverse(long) → a + short, longp = arcs[0], arcs[-1] + order = list(short) + list(reversed(longp[1:-1])) + poly = [pos[n] for n in order if n in pos] + return unit, poly, order + + +def _stub_crosses_ring( + stub: str, + portal: str, + pos: dict[str, tuple[float, float]], + order: list[str], +) -> bool: + """True if stub—portal properly intersects a ring edge (not at portal).""" + from netx_topology_mcp.layout_metrics import segments_properly_intersect + + if stub not in pos or portal not in pos or len(order) < 2: + return False + p0, p1 = pos[stub], pos[portal] + m = len(order) + for i in range(m): + u, v = order[i], order[(i + 1) % m] + if portal in (u, v): + continue + if u not in pos or v not in pos: + continue + if segments_properly_intersect(p0, p1, pos[u], pos[v]): + return True + return False + + +def _ring_descriptors( + groups: list[dict[str, object]], + pos: dict[str, tuple[float, float]], +) -> list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]]: + """(portal_a, portal_b, unit, poly, order) for placeable rings; small first.""" + descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]] = [] + for g in groups: + a, b = g["portals"] # type: ignore[misc] + unit, poly, order = _ring_polygon(g, pos) + if len(poly) < 3 or a not in pos or b not in pos: + continue + descs.append((a, b, unit, poly, order)) + descs.sort(key=lambda t: len(t[2])) + return descs + + +def _stub_clean_for_rings( + stub: str, + portal: str, + pos: dict[str, tuple[float, float]], + descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]], +) -> bool: + """True if stub is outside every portal-ring and its portal edge pierces none.""" + if stub not in pos: + return False + sx, sy = pos[stub] + for a, b, unit, poly, order in descs: + if stub in unit or portal not in (a, b): + continue + if _point_in_poly(sx, sy, poly): + return False + if _stub_crosses_ring(stub, portal, pos, order): + return False + return True + + +def _total_ring_thru( + pos: dict[str, tuple[float, float]], + descs: list[tuple[str, str, set[str], list[tuple[float, float]], list[str]]], + edges: list[tuple[str, str]], +) -> int: + return sum(_foreign_edge_crossings(pos, unit, edges) for _, _, unit, _, _ in descs) + + +def _eject_intruders_from_rings( + pos: dict[str, tuple[float, float]], + groups: list[dict[str, object]], + adj: dict[str, set[str]], + params: LayoutParams, + edges: list[tuple[str, str]], + *, + protected: set[str] | None = None, +) -> dict[str, tuple[float, float]]: + """Keep minimal rings hollow: no portal-stub node/edge through any ring. + + Covers triangles (SPB–VOTI–MRBD) as well as larger trapezoid rings. A stub + may sit *outside* the polygon yet still pierce a chord (PNBR–SPB × MRBD–VOTI). + Candidates must stay clean for **all** portal-rings sharing the stub (so + ejecting from SPB–TNM cannot park PNBR through the MRBD triangle). + """ + if not groups: + return pos + out = dict(pos) + prot = set(protected or ()) + pad = max(params.pitch * 0.75, 120.0) + + for _round in range(4): + descs = _ring_descriptors(groups, out) + if not descs: + break + moved_any = False + # Portal stubs across all rings (dedupe). + jobs: list[tuple[str, str]] = [] + seen: set[tuple[str, str]] = set() + for a, b, unit, _poly, _order in descs: + for p in (a, b): + for n in adj.get(p, ()): + if n in unit or n in prot or n not in out: + continue + key = (n, p) + if key in seen: + continue + seen.add(key) + jobs.append((n, p)) + # Prefer stubs that currently violate something; smaller rings first via descs. + jobs.sort( + key=lambda t: ( + 0 if not _stub_clean_for_rings(t[0], t[1], out, descs) else 1, + t[0], + t[1], + ) + ) + + for n, p in jobs: + if _stub_clean_for_rings(n, p, out, descs): + continue + px, py = out[p] + # Aim toward outside neighbors not in any ring that uses this portal. + ring_units = [unit for a, b, unit, _, _ in descs if p in (a, b)] + blocked = {p} | set().union(*ring_units) if ring_units else {p} + outs = [v for v in adj.get(n, ()) if v not in blocked and v in out] + if outs: + tx = sum(out[v][0] for v in outs) / len(outs) + ty = sum(out[v][1] for v in outs) / len(outs) + else: + # Push away from the other portals of rings sharing p. + others = [ + o + for a, b, _, _, _ in descs + if p in (a, b) + for o in ((a if b == p else b),) + if o in out + ] + if others: + ox = sum(out[o][0] for o in others) / len(others) + oy = sum(out[o][1] for o in others) / len(others) + tx, ty = px + (px - ox), py + (py - oy) + else: + tx, ty = px - pad, py + dx, dy = tx - px, ty - py + L = math.hypot(dx, dy) or 1.0 + ux, uy = dx / L, dy / L + candidates: list[tuple[float, float]] = [] + for scale in (1.0, 1.4, 1.9, 2.5, 3.2): + candidates.append((px + ux * pad * scale, py + uy * pad * scale)) + for rad in (1.0, 1.3, 1.8, 2.4): + for k in range(16): + ang = (2.0 * math.pi * k) / 16.0 + candidates.append( + (px + pad * rad * math.cos(ang), py + pad * rad * math.sin(ang)) + ) + + thru0 = _total_ring_thru(out, descs, edges) + best = None + best_key: tuple[float, float, float] | None = None + for cand in candidates: + trial = dict(out) + trial[n] = cand + # Refresh polys for point-in-poly against moved stub only — ring + # nodes are fixed, so existing descs polys stay valid. + if not _stub_clean_for_rings(n, p, trial, descs): + continue + thru1 = _total_ring_thru(trial, descs, edges) + if thru1 > thru0: + continue + aim_d = (cand[0] - tx) ** 2 + (cand[1] - ty) ** 2 + key = (float(thru1), aim_d, abs(cand[0] - px) + abs(cand[1] - py)) + if best_key is None or key < best_key: + best_key = key + best = cand + if best is not None and best != out[n]: + out[n] = best + moved_any = True + if not moved_any: + break + return out + + +def _foreign_edge_crossings( + pos: dict[str, tuple[float, float]], + unit_nodes: set[str], + edges: list[tuple[str, str]], +) -> int: + """Crossings of unit-internal edges by foreign edges (through the unit).""" + if len(unit_nodes) < 2 or not edges: + return 0 + from netx_topology_mcp.layout_metrics import segments_properly_intersect + + internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes] + foreign = [ + (a, b) for a, b in edges if a not in unit_nodes or b not in unit_nodes + ] + if not internal or not foreign: + return 0 + n = 0 + for a, b in internal: + if a not in pos or b not in pos: + continue + p1, p2 = pos[a], pos[b] + for c, d in foreign: + if c not in pos or d not in pos: + continue + if c in (a, b) or d in (a, b): + continue + if segments_properly_intersect(p1, p2, pos[c], pos[d]): + n += 1 + return n + + +def _point_segment_dist( + p: tuple[float, float], a: tuple[float, float], b: tuple[float, float] +) -> float: + 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) + t = max(0.0, min(1.0, (wx * vx + wy * vy) / L2)) + return math.hypot(px - (ax + t * vx), py - (ay + t * vy)) + + +def _foreign_edges_through_nodes( + pos: dict[str, tuple[float, float]], + unit_nodes: set[str], + edges: list[tuple[str, str]], + *, + thr: float = 28.0, +) -> int: + """Count foreign edges that pass too close to a ring interior node (icon hit).""" + if not unit_nodes or not edges: + return 0 + interiors = [ + n + for n in unit_nodes + if n in pos + and sum(1 for u, v in edges if n in (u, v) and (u in unit_nodes and v in unit_nodes)) + >= 2 + ] + # Prefer explicit interiors: nodes in unit that are not only portals of a 2-set. + # Fallback: any unit node that is an endpoint of ≥2 internal edges. + if not interiors: + interiors = [n for n in unit_nodes if n in pos] + foreign = [ + (a, b) + for a, b in edges + if (a not in unit_nodes or b not in unit_nodes) + and a in pos + and b in pos + ] + hits = 0 + for n in interiors: + # Skip if n is an endpoint of the foreign edge. + for a, b in foreign: + if n in (a, b): + continue + if _point_segment_dist(pos[n], pos[a], pos[b]) < thr: + hits += 1 + break + return hits + + +def _ring_side_cost( + pos: dict[str, tuple[float, float]], + unit_nodes: set[str], + edges: list[tuple[str, str]], +) -> tuple[float, float]: + """Lower is better: (through-crossings, icon-hits by foreign edges).""" + return ( + float(_foreign_edge_crossings(pos, unit_nodes, edges)), + float(_foreign_edges_through_nodes(pos, unit_nodes, edges)), + ) + + +def _place_two_portal_ring_groups( + pos: dict[str, tuple[float, float]], + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + an_set: set[str], + params: LayoutParams, + edges: list[tuple[str, str]], + *, + blocked: set[str] | None = None, + groups: list[dict[str, object]] | None = None, +) -> tuple[dict[str, tuple[float, float]], set[str]]: + """Place two-portal rings as trapezoid bands (short/narrow inner → wide outer).""" + groups = groups if groups is not None else _find_two_portal_ring_groups( + ens, adj, names, an_set + ) + if not groups: + return pos, set() + base = dict(pos) + c0 = count_edge_crossings(base, edges) if edges else 0 + trial = dict(base) + pinned: set[str] = set() + blocked = set(blocked or ()) + gap = max(params.side * 0.9, 150.0) + flare = max(params.pitch * 0.45, 70.0) + + for g in groups: + a, b = g["portals"] # type: ignore[misc] + paths: list[list[str]] = g["paths"] # type: ignore[assignment] + if a in blocked or b in blocked: + continue + if a not in trial or b not in trial: + continue + interiors = {n for p in paths for n in p[1:-1]} + if interiors & blocked: + continue + + ax, ay = trial[a] + bx, by = trial[b] + mx, my = (ax + bx) / 2, (ay + by) / 2 + base_span = max( + math.hypot(bx - ax, by - ay), + params.pitch * max(3, max(len(p) for p in paths)), + ) + x0, x1 = mx - base_span / 2, mx + base_span / 2 + trial[a] = (x0, my) + trial[b] = (x1, my) + + unit_nodes = {a, b} | interiors + # Trapezoid nest: path i at ±dist, span widens with i (outer wider). + # Orient every path a→…→b so portal-adjacent nodes sit on the correct side + # (reversed corridors are what produced the TNM/JROS × chain X-crossing). + for i, path in enumerate(paths): + if path[0] == b and path[-1] == a: + path = list(reversed(path)) + elif path[0] != a or path[-1] != b: + continue + sign = -1.0 if i % 2 == 0 else 1.0 + dist = (i // 2 + 1) * gap + half = base_span / 2 + i * flare + xl, xr = mx - half, mx + half + y = my + sign * dist + trial.update(_place_path_on_span(path, xl, xr, y)) + trial[a] = (x0, my) + trial[b] = (x1, my) + pinned.update(path) + + # Prefer geometry that does not let foreign links pierce the ring unit. + thru0 = _foreign_edge_crossings(base, unit_nodes, edges) + thru1 = _foreign_edge_crossings(trial, unit_nodes, edges) + if thru1 > thru0 + 2: + for n in unit_nodes: + if n in base: + trial[n] = base[n] + pinned -= unit_nodes + + if not pinned: + return base, set() + c1 = count_edge_crossings(trial, edges) if edges else 0 + if c1 <= max(c0 * 1.35, c0 + 50): + return trial, pinned + return base, set() + + +def _orient_ring_sides( + pos: dict[str, tuple[float, float]], + groups: list[dict[str, object]], + edges: list[tuple[str, str]], + *, + pinned: set[str] | None = None, + max_interiors: int = 3, + push: float = 0.0, +) -> dict[str, tuple[float, float]]: + """Flip small-ring corridors across the portal chord if that clears pierces. + + Cross-petal chords (BNT–SPB through MRBD–VOTI) are only visible after AN + petals are packed — call this on the full component, not inside one petal. + Restrict to small rings (triangles / short arcs) so large trapezoids stay put. + """ + if not groups or not edges: + return pos + out = dict(pos) + pin = set(pinned or ()) + # Smallest first so nested outer bands see the settled inner apex. + ordered = sorted( + groups, + key=lambda g: len({n for p in g["paths"] for n in p[1:-1]}), # type: ignore[index] + ) + for g in ordered: + a, b = g["portals"] # type: ignore[misc] + paths: list[list[str]] = g["paths"] # type: ignore[assignment] + if a not in out or b not in out: + continue + interiors = {n for p in paths for n in p[1:-1] if n in out} + if not interiors or len(interiors) > max_interiors: + continue + if pin and (interiors - pin): + continue + unit_nodes = {a, b} | interiors + pa, pb = out[a], out[b] + flipped = dict(out) + for n in interiors: + flipped[n] = _reflect_point_across_segment(out[n], pa, pb) + k0 = _ring_side_cost(out, unit_nodes, edges) + k1 = _ring_side_cost(flipped, unit_nodes, edges) + c_cur = count_edge_crossings(out, edges) + c_flip = count_edge_crossings(flipped, edges) + take = False + if k1 < k0 and c_flip <= c_cur + 2: + take = True + elif k1 == k0 and c_flip < c_cur: + take = True + if take: + out = flipped + # If foreign edges still graze the apex, push interiors further off-chord. + if push > 0 and _ring_side_cost(out, unit_nodes, edges)[1] > 0: + pa, pb = out[a], out[b] + dx, dy = pb[0] - pa[0], pb[1] - pa[1] + L = math.hypot(dx, dy) or 1.0 + # Normal pointing toward current interior centroid. + cx = sum(out[n][0] for n in interiors) / len(interiors) + cy = sum(out[n][1] for n in interiors) / len(interiors) + mx, my = (pa[0] + pb[0]) / 2, (pa[1] + pb[1]) / 2 + nx, ny = -dy / L, dx / L + if (cx - mx) * nx + (cy - my) * ny < 0: + nx, ny = -nx, -ny + pushed = dict(out) + for n in interiors: + x, y = out[n] + pushed[n] = (x + nx * push, y + ny * push) + pk = _ring_side_cost(pushed, unit_nodes, edges) + pc = count_edge_crossings(pushed, edges) + if pk < _ring_side_cost(out, unit_nodes, edges) and pc <= c_cur + 2: + out = pushed + elif pk[0] <= k0[0] and pk[1] < k0[1] and pc <= c_cur + 2: + out = pushed + return out + + +def _snap_small_rings( + pos: dict[str, tuple[float, float]], + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + params: LayoutParams, + edges: list[tuple[str, str]], + *, + blocked: set[str] | None = None, +) -> dict[str, tuple[float, float]]: + """Snap UME-sized rings (6–16) onto local rectangle edges if crossings allow.""" + rings = _find_access_rings(ens, adj, names, min_len=6, max_len=16) + if not rings: + return pos + base = dict(pos) + c0 = count_edge_crossings(base, edges) if edges else 0 + trial = dict(base) + used: set[str] = set(blocked or ()) + for ring in rings[:8]: + if any(n in used for n in ring): + continue + order = _cycle_order(ring, adj, names) + pts = [trial[n] for n in order if n in trial] + if len(pts) < 4: + continue + xs, ys = [p[0] for p in pts], [p[1] for p in pts] + cx, cy = sum(xs) / len(xs), sum(ys) / len(ys) + w, h = _squarish_rect_dims(len(order), params) + # Keep local scale of the cluster + w = max(w * 0.55, max(xs) - min(xs), params.pitch * 2) + h = max(h * 0.55, max(ys) - min(ys), params.side) + trial.update(_place_on_rect_edge(order, cx - w / 2, cy - h / 2, w, h)) + used.update(order) + c1 = count_edge_crossings(trial, edges) if edges else 0 + if c1 <= max(c0 * 1.25, c0 + 20): + return trial + return base + + +def _compact_petal_aspect( + pos: dict[str, tuple[float, float]], + *, + max_aspect: float = 2.4, +) -> dict[str, tuple[float, float]]: + """Soft axis compress so each AN territory is not a mega-wide strip.""" + if len(pos) < 3: + return pos + x0, y0, x1, y1 = bbox(pos) + w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0) + aspect = max(w / h, h / w) + if aspect <= max_aspect: + return pos + cx, cy = (x0 + x1) / 2, (y0 + y1) / 2 + if w >= h: + sx = (max_aspect * h) / w + return {n: (cx + (x - cx) * sx, y) for n, (x, y) in pos.items()} + sy = (max_aspect * w) / h + return {n: (x, cy + (y - cy) * sy) for n, (x, y) in pos.items()} + + +def _contract_dangling_feeders( + nodes: list[str], + ens: list[str], + adj: dict[str, set[str]], + names: dict[str, str], + attach_set: set[str], +) -> tuple[list[str], dict[str, set[str]], list[list[str]], set[str]]: + """Contract feeder bodies onto their attach node (leaf…→attach).""" + # Infer ANs from attach_set members that are not in ens (call site passes union). + ens_set = set(ens) + an_guess = {n for n in attach_set if n not in ens_set} + chains = [ + _orient_feeder_outward(ch, attach_set) + for ch in _extract_dangling_feeders( + ens, adj, names, attach_set, min_len=2, an_set=an_guess + ) + ] + if not chains: + node_set = set(nodes) + return list(nodes), {n: set(adj.get(n, ())) & node_set for n in nodes}, [], set() + + feeders: list[list[str]] = [] + hidden: dict[str, str] = {} + for order in chains: + if len(order) < 2: + continue + attach = order[-1] + feeders.append(order) + for n in order[:-1]: + hidden[n] = attach + + reduced = [n for n in nodes if n not in hidden] + reduced_set = set(reduced) + + def remap(v: str) -> str | None: + if v in hidden: + return hidden[v] + if v in reduced_set: + return v + return None + + # Edges from hidden feeder bodies attach to their attach node. + by_attach: dict[str, list[list[str]]] = {} + for order in feeders: + by_attach.setdefault(order[-1], []).append(order) + + cadj: dict[str, set[str]] = {n: set() for n in reduced} + for n in reduced: + srcs = [n] + for order in by_attach.get(n, ()): + srcs.extend(order[:-1]) + for u in srcs: + for v in adj.get(u, ()): + rv = remap(v) + if rv is not None and rv != n: + cadj[n].add(rv) + pinned = {n for order in feeders for n in order[:-1]} + return reduced, cadj, feeders, pinned + + +def _expand_feeder_units( + pos: dict[str, tuple[float, float]], + feeders: list[list[str]], + avoid: dict[str, tuple[float, float]], + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + """Expand feeders as axis-aligned rays outward from attach (away from ring).""" + if not feeders: + return pos + out = dict(pos) + step = max(params.side, 150.0) + # Lateral offset when several feeders share one attach. + per_attach: dict[str, int] = {} + for order in feeders: + if len(order) < 2: + continue + attach = order[-1] + if attach not in out: + continue + ax, ay = out[attach] + cx, cy = avoid.get(attach, (ax, ay - step)) + dx, dy = ax - cx, ay - cy + if abs(dx) + abs(dy) < 1e-6: + ux, uy = 0.0, 1.0 + elif abs(dx) >= abs(dy): + ux, uy = (1.0 if dx >= 0 else -1.0), 0.0 + else: + ux, uy = 0.0, (1.0 if dy >= 0 else -1.0) + # Perpendicular lane so sibling feeders do not overlap. + lane = per_attach.get(attach, 0) + per_attach[attach] = lane + 1 + px, py = -uy, ux + ox = lane * step * 0.9 + n = len(order) + for i, node in enumerate(order): + k = n - 1 - i + out[node] = ( + ax + k * step * ux + ox * px, + ay + k * step * uy + ox * py, + ) + out[attach] = (ax, ay) + return out + + +def _layout_an_petals( + nodes: list[str], + roots: list[str], + state: LayoutState, + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + """Rings (trapezoid) + dangling feeders (uncrossed) + layered remainder.""" + if not nodes: + return {} + g, layers, names = state.adj, state.layers, state.names + ans = [n for n in roots if n in nodes] + an = ans[0] if ans else None + ens = [n for n in nodes if layers.get(n) == "access"] + an_set = {n for n in nodes if layers.get(n) == "agg"} | ({an} if an else set()) + + node_set = set(nodes) + edges = [(a, b) for a, b in state.links if a in node_set and b in node_set] + + # 1) Minimal two-portal rings first (define attach targets for feeders). + ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set) + ring_nodes = _ring_nodes_from_groups(ring_groups) + junctions = set(_access_junctions(ens, g, an_set)) + attach_set = ring_nodes | junctions | an_set + + # 2) Dangling non-ring feeders → contract onto attach, layer backbone. + # Pass ANs in attach_set so AN-hanging ring legs are not mistaken for leaves. + reduced, cadj, feeders, pinned = _contract_dangling_feeders( + nodes, ens, g, names, attach_set | an_set + ) + pos = _sugiyama_column(reduced, ans, state, params, adj=cadj) + c_base = count_edge_crossings(pos, edges) if edges else 0 + if feeders: + plain = _sugiyama_column(nodes, ans, state, params) + c_plain = count_edge_crossings(plain, edges) if edges else 0 + if c_base > max(c_plain * 1.55, c_plain + 100): + pos, feeders, pinned, c_base = plain, {}, set(), c_plain + + # 3) Surround remainder (keep feeder bodies for later expand). + if an and an in pos and ens: + trial = _surround_by_an_subtrees(pos, an, ens, g, names, pinned=pinned) + c1 = count_edge_crossings(trial, edges) if edges else 0 + if c1 <= max(c_base * 1.2, c_base + 30): + pos = trial + + # 4) Place rings as trapezoid bands (hollow units; reject if pierced worse). + pos, ring_pins = _place_two_portal_ring_groups( + pos, + ens, + g, + names, + an_set, + params, + edges, + blocked=set(), + groups=ring_groups, + ) + pinned |= ring_pins + # Side stubs (PNBR etc.) must not sit inside the hollow ring. + pos = _eject_intruders_from_rings( + pos, ring_groups, g, params, edges, protected=ring_nodes + ) + + # Avoid centroids so feeders grow outward, not through the ring. + avoid: dict[str, tuple[float, float]] = {} + if an and an in pos: + for n in attach_set: + avoid[n] = pos[an] + for g_ring in ring_groups: + unit = set() + a, b = g_ring["portals"] # type: ignore[misc] + unit.add(a) + unit.add(b) + for p in g_ring["paths"]: # type: ignore[union-attr] + unit.update(p) + pts = [pos[n] for n in unit if n in pos] + if not pts: + continue + cx = sum(p[0] for p in pts) / len(pts) + cy = sum(p[1] for p in pts) / len(pts) + for n in unit: + avoid[n] = (cx, cy) + + # 5) Expand dangling feeders as uncrossed outward rays. + if feeders: + pos = _expand_feeder_units(pos, feeders, avoid, params) + pinned |= {n for order in feeders for n in order[:-1]} + # Feeder expand can re-enter a ring — eject again. + pos = _eject_intruders_from_rings( + pos, ring_groups, g, params, edges, protected=ring_nodes + ) + + # 6) Other small rings on leftovers. + free_ens = [e for e in ens if e not in pinned] + if free_ens: + before = dict(pos) + snapped = _snap_small_rings( + pos, free_ens, g, names, params, edges, blocked=pinned | ring_nodes + ) + for n in pinned | ring_nodes: + if n in before: + snapped[n] = before[n] + if an and an in before: + snapped[an] = before[an] + pos = snapped + + pos = _compact_petal_aspect(pos, max_aspect=4.0) + + if edges and pos: + c0 = count_edge_crossings(pos, edges) + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + mx = (min(xs) + max(xs)) / 2 + my = (min(ys) + max(ys)) / 2 + for flipped in ( + {n: (2 * mx - x, y) for n, (x, y) in pos.items()}, + {n: (x, 2 * my - y) for n, (x, y) in pos.items()}, + ): + c1 = count_edge_crossings(flipped, edges) + if c1 < c0: + pos, c0 = flipped, c1 + + # Aspect compress / mirror can re-pierce hollow rings — final eject. + # (Ring-side flip waits until petals are packed — cross-AN edges missing here.) + if ring_groups: + pos = _eject_intruders_from_rings( + pos, ring_groups, g, params, edges, protected=ring_nodes + ) + + if pos: + x0, y0, _, _ = bbox(pos) + pos = {n: (x - x0, y - y0) for n, (x, y) in pos.items()} + + if pinned: + bucket = state.meta.setdefault("_chain_pins", set()) + if not isinstance(bucket, set): + bucket = set(bucket) + state.meta["_chain_pins"] = bucket + bucket.update(pinned) + return pos + + +def _layout_column( + nodes: list[str], + roots: list[str], + state: LayoutState, + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + return _layout_an_petals(nodes, roots, state, params) + + +def _pack_columns( + columns: list[dict[str, tuple[float, float]]], + params: LayoutParams, + *, + nrows: int, + gap_x: float | None = None, + gap_y: float | None = None, +) -> dict[str, tuple[float, float]]: + cols = [c for c in columns if c] + if not cols: + return {} + nrows = max(1, min(nrows, len(cols))) + per_row = int(math.ceil(len(cols) / nrows)) + gx = params.an_gap * 1.8 if gap_x is None else gap_x + gy = params.island_pad_y * 1.2 if gap_y is None else gap_y + rows: list[list[int]] = [] + for r in range(nrows): + chunk = list(range(r * per_row, min((r + 1) * per_row, len(cols)))) + if r % 2 == 1: + chunk.reverse() + rows.append(chunk) + row_h = [0.0] * nrows + col_w = [0.0] * per_row + meta: list[tuple[int, int, int]] = [] + for r, chunk in enumerate(rows): + for visual_k, idx in enumerate(chunk): + x0, y0, x1, y1 = bbox(cols[idx]) + w, h = x1 - x0, y1 - y0 + row_h[r] = max(row_h[r], h) + col_w[visual_k] = max(col_w[visual_k], w) + meta.append((r, visual_k, idx)) + x_off = [0.0] + for j in range(per_row): + x_off.append(x_off[-1] + col_w[j] + (gx if j + 1 < per_row else 0.0)) + y_off = [0.0] + for r in range(nrows): + y_off.append(y_off[-1] + row_h[r] + (gy if r + 1 < nrows else 0.0)) + out: dict[str, tuple[float, float]] = {} + for r, visual_k, idx in meta: + c = cols[idx] + x0, y0, x1, y1 = bbox(c) + w = x1 - x0 + ox = x_off[visual_k] + max(0.0, (col_w[visual_k] - w) / 2) + oy = y_off[r] + for nid, (x, y) in c.items(): + out[nid] = (ox + (x - x0), oy + (y - y0)) + return out + + +def _best_square_pack( + columns: list[dict[str, tuple[float, float]]], + comp: list[str], + state: LayoutState, + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + """Pack AN petals favoring UME-like aspect (~1.55) with soft crossing slack.""" + node_set = set(comp) + edges = [(a, b) for a, b in state.links if a in node_set and b in node_set] + n = len([c for c in columns if c]) + candidates = [1] + if n >= 2: + candidates.append(2) + if n >= 4: + candidates.append(3) + target_aspect = 1.55 + scored: list[tuple[float, int, float, dict[str, tuple[float, float]]]] = [] + for nrows in candidates: + packed = _pack_columns(columns, params, nrows=nrows) + if not packed: + continue + cross = count_edge_crossings(packed, edges) if edges else 0 + x0, y0, x1, y1 = bbox(packed) + w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0) + aspect = max(w / h, h / w) + scored.append((aspect, cross, w * h, packed)) + if not scored: + return {} + min_cross = min(t[1] for t in scored) + # Allow larger crossing trade to kill mega-wide strips (UME aspect ~1.6) + slack = max(80, int(1.1 * min_cross)) + pool = [t for t in scored if t[1] <= min_cross + slack] + if not pool: + pool = scored + + def rank(t: tuple[float, int, float, dict]) -> tuple: + aspect, cross, area, _ = t + return ( + abs(math.log(aspect / target_aspect)) * 2.5 + + 0.35 * (cross / max(min_cross, 1)), + area, + ) + + pool.sort(key=rank) + return pool[0][3] + + +def _nearest_core( + n: str, cores: list[str], adj: dict[str, set[str]], names: dict[str, str] +) -> str: + if not cores: + return "" + return _nearest_an(n, cores, adj, names) + + +def _place_component_core_beam( + comp: list[str], + state: LayoutState, + params: LayoutParams, + *, + ans: list[str], + ens: list[str], + cores: list[str], + others: list[str], +) -> dict[str, tuple[float, float]]: + """core_bar: pin cores on a horizontal beam; AN petals hang off (no core-in-column).""" + g, names = state.adj, state.names + ens_set = set(ens) + an_set = set(ans) + core_set = set(cores) + # Prefer stable beam order: degree desc then name + core_order = sorted( + cores, + key=lambda c: (-len(g.get(c, ())), names.get(c, c)), + ) + gap = max(float(params.an_gap or 0) or 0.0, float(params.pitch) * 2.2, 360.0) + petal_dy = max(float(params.side) * 3.2, gap * 1.35) + + # Multi-source ownership: BFS from ANs through access/other (cores are barriers) + claimable = ens_set | set(others) | an_set + owner: dict[str, str] = {} + q: deque[str] = deque() + for a in ans: + owner[a] = a + q.append(a) + while q: + u = q.popleft() + for v in g.get(u, ()): + if v in core_set or v in owner or v not in claimable: + continue + owner[v] = owner[u] + q.append(v) + for e in ens: + if e in owner: + continue + owner[e] = _nearest_core(e, core_order, g, names) or ( + _nearest_an(e, ans, g, names) if ans else e + ) + + out: dict[str, tuple[float, float]] = {} + for i, c in enumerate(core_order): + out[c] = (i * gap, 0.0) + + # Core-owned stubs: local petal around each core (prefer upward / negative Y) + for c in core_order: + col = [c] + [ + n + for n, o in owner.items() + if o == c and n != c and n not in an_set and n not in core_set + ] + if len(col) <= 1: + continue + placed = _layout_an_petals(col, [], state, params) + if c not in placed: + continue + cx0, cy0 = placed[c] + tx, ty = out[c] + for n, (x, y) in placed.items(): + if n == c: + continue + # Flip so stubs mostly sit above the beam + dx, dy = x - cx0, y - cy0 + out[n] = (tx + dx, ty - abs(dy) - params.pitch * 0.15) + + # AN petals: layout without cores, attach under preferred core + an_order = order_ans(ans, ens_set, g, names) + by_core: dict[str, list[str]] = {c: [] for c in core_order} + for a in an_order: + nc = _nearest_core(a, core_order, g, names) or core_order[0] + by_core.setdefault(nc, []).append(a) + + for c in core_order: + group = by_core.get(c, []) + cx, cy = out[c] + n_g = len(group) + for j, a in enumerate(group): + col = [a] + col.extend( + [ + e + for e in ens + if owner.get(e) == a + ] + ) + for o in others: + if o in out or o in core_set: + continue + nbs = [v for v in g.get(o, ()) if v in ens_set or v == a] + if nbs and all(owner.get(v, v) == a or v == a for v in nbs): + col.append(o) + placed = _layout_an_petals(col, [a], state, params) + if not placed or a not in placed: + out[a] = ( + cx + (j - (n_g - 1) / 2) * gap * 0.55 if n_g else cx, + cy + petal_dy, + ) + continue + ax0, ay0 = placed[a] + if n_g <= 1: + ax = cx + else: + ax = cx + (j - (n_g - 1) / 2) * gap * 0.55 + ay = cy + petal_dy + for n, (x, y) in placed.items(): + if n in core_set: + continue + out[n] = (ax + (x - ax0), ay + (y - ay0)) + out[a] = (ax, ay) + + # Leftovers: near neighbor mean + for n in comp: + if n in out: + continue + nbs = [out[v] for v in g.get(n, ()) if v in out] + if nbs: + out[n] = ( + sum(p[0] for p in nbs) / len(nbs), + sum(p[1] for p in nbs) / len(nbs) + params.pitch, + ) + else: + out[n] = (0.0, petal_dy * 2) + + return out + + +def _place_component( + comp: list[str], + state: LayoutState, + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + g, layers, names = state.adj, state.layers, state.names + ans = sorted([n for n in comp if layers.get(n) == "agg"], key=lambda n: names[n]) + ens = [n for n in comp if layers.get(n) == "access"] + cores = [n for n in comp if layers.get(n) == "core"] + others = [n for n in comp if layers.get(n) == "other"] + + if len(ans) <= 1: + out = _layout_an_petals(comp, ans, state, params) + node_set = set(comp) + edges = [(a, b) for a, b in state.links if a in node_set and b in node_set] + if out and edges: + ring_groups = _find_two_portal_ring_groups(ens, g, names, set(ans)) + if ring_groups: + out = _orient_ring_sides( + out, + ring_groups, + edges, + pinned=_ring_nodes_from_groups(ring_groups), + max_interiors=1, + push=max(params.side * 0.25, 40.0), + ) + return out + + # Dual+ cores → core_bar beam-first (do not stuff cores into AN columns). + if len(cores) >= 2: + out = _place_component_core_beam( + comp, + state, + params, + ans=ans, + ens=ens, + cores=cores, + others=others, + ) + node_set = set(comp) + edges = [(a, b) for a, b in state.links if a in node_set and b in node_set] + if out and edges: + an_set = set(ans) + ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set) + if ring_groups: + before = dict(out) + c_before = count_edge_crossings(out, edges) + out = _orient_ring_sides( + out, + ring_groups, + edges, + pinned=_ring_nodes_from_groups(ring_groups), + max_interiors=1, + push=max(params.side * 0.25, 40.0), + ) + out = _eject_intruders_from_rings( + out, + ring_groups, + g, + params, + edges, + protected=_ring_nodes_from_groups(ring_groups), + ) + c_after = count_edge_crossings(out, edges) + if c_after > max(c_before * 1.15, c_before + 20): + out = before + # Keep cores on the beam after ring polish + if len(cores) >= 2: + ys = [out[c][1] for c in cores if c in out] + if ys: + beam_y = sum(ys) / len(ys) + for c in cores: + if c in out: + out[c] = (out[c][0], beam_y) + x0, y0, _, _ = bbox(out) + out = {n: (x - x0, y - y0) for n, (x, y) in out.items()} + return out + + ens_set = set(ens) + an_order = order_ans(ans, ens_set, g, names) + home = {e: _nearest_an(e, an_order, g, names) for e in ens} + core_home = {c: _nearest_an(c, an_order, g, names) for c in cores} + petals: list[dict[str, tuple[float, float]]] = [] + for a in an_order: + col_nodes = [a] + col_nodes.extend([e for e in ens if home.get(e) == a]) + col_nodes.extend([c for c in cores if core_home.get(c) == a]) + for o in others: + nbs = [v for v in g.get(o, ()) if v in ens_set or v in an_order] + if nbs and all(home.get(v, v) == a or v == a for v in nbs): + col_nodes.append(o) + placed = _layout_an_petals(col_nodes, [a], state, params) + if placed: + petals.append(placed) + placed_ids = {n for p in petals for n in p} + leftover = [n for n in comp if n not in placed_ids] + if leftover: + petals.append(_layout_an_petals(leftover, [], state, params)) + + out = _best_square_pack(petals, comp, state, params) + node_set = set(comp) + edges = [(a, b) for a, b in state.links if a in node_set and b in node_set] + if out and edges: + c0 = count_edge_crossings(out, edges) + xs = [p[0] for p in out.values()] + mid = (min(xs) + max(xs)) / 2 + flipped = {n: (2 * mid - x, y) for n, (x, y) in out.items()} + if count_edge_crossings(flipped, edges) < c0: + out = flipped + # Now cross-AN chords exist — flip triangle apexes (VOTI) off them. + an_set = set(ans) + ring_groups = _find_two_portal_ring_groups(ens, g, names, an_set) + if ring_groups: + before = dict(out) + c_before = count_edge_crossings(out, edges) + out = _orient_ring_sides( + out, + ring_groups, + edges, + pinned=_ring_nodes_from_groups(ring_groups), + max_interiors=1, + push=max(params.side * 0.25, 40.0), + ) + out = _eject_intruders_from_rings( + out, + ring_groups, + g, + params, + edges, + protected=_ring_nodes_from_groups(ring_groups), + ) + c_after = count_edge_crossings(out, edges) + if c_after > max(c_before * 1.15, c_before + 20): + out = before + x0, y0, _, _ = bbox(out) + out = {n: (x - x0, y - y0) for n, (x, y) in out.items()} + return out + + +def _pack_islands( + islands: list[dict[str, tuple[float, float]]], + params: LayoutParams, +) -> dict[str, tuple[float, float]]: + islands = sorted([c for c in islands if c], key=lambda p: -len(p)) + if not islands: + return {} + if len(islands) == 1: + return dict(islands[0]) + n = len(islands) + candidates = [1] + ([2] if n >= 2 else []) + ([3] if n >= 4 else []) + target = 1.55 + best = None + best_key = None + for nrows in candidates: + packed = _pack_columns(islands, params, nrows=nrows) + if not packed: + continue + x0, y0, x1, y1 = bbox(packed) + w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0) + aspect = max(w / h, h / w) + key = (abs(math.log(aspect / target)), w * h) + if best_key is None or key < best_key: + best_key = key + best = packed + return best or {} + + +def build_sugiyama_layout( + state: LayoutState, params: LayoutParams | None = None +) -> OpResult: + params = params or LayoutParams() + st = state.copy() + ids = list(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 = [] + for c in comps: + placed = _place_component(c, st, params) + if placed: + islands.append(placed) + pos = _pack_islands(islands, params) if islands else {} + + if others: + if pos: + x0, y0, x1, y1 = bbox(pos) + fx, fy = x1 + params.island_pad_x * 0.35, y0 + else: + fx, fy = 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, my) + else: + pos[n] = (fx + (i % 4) * params.pitch, fy + (i // 4) * params.side) + + for n in ids: + if n not in pos: + pos[n] = (0.0, 0.0) + + st.positions = pos + chain_pins = st.meta.pop("_chain_pins", set()) or set() + if not isinstance(chain_pins, set): + chain_pins = set(chain_pins) + hard = {n for n in pos if st.layers.get(n) in ("agg", "core")} + st.pinned = hard | chain_pins + st.spine = set(chain_pins) + st.last_moved = set(pos.keys()) + st.meta["components"] = len(comps) + st.meta["rings_mode"] = "ume_petals" + st.meta["chain_unit_nodes"] = len(chain_pins) + return OpResult( + state=st, + moved=set(pos.keys()), + op="build_sugiyama_layout", + params={ + "components": len(comps), + "pitch": params.pitch, + "side": params.side, + "mode": "ume_petals", + "chain_unit_nodes": len(chain_pins), + }, + note=f"UME petal rects comps={len(comps)} chains={len(chain_pins)}", + ) diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py new file mode 100644 index 0000000..9596a9f --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/transforms.py @@ -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, +} diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py new file mode 100644 index 0000000..45385b1 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/untangle.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py new file mode 100644 index 0000000..a86c103 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py @@ -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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py new file mode 100644 index 0000000..79000fb --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py @@ -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 + + diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py new file mode 100644 index 0000000..3411ee7 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py @@ -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])}" + ), + } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py new file mode 100644 index 0000000..fb9166c --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py @@ -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 算坐标。" + ), + } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py new file mode 100644 index 0000000..8aecef5 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_topology_quality.py @@ -0,0 +1,334 @@ +"""Mid-tier topology geometry QA: chain cohesion + min-ring integrity. + +- chain(直链成一体): deg≤2 corridors should lay nearly collinear as one unit. +- rings(最小环不被穿): short chordless cycles should not be crossed by foreign edges. +""" + +from __future__ import annotations + +import math +from collections import defaultdict, deque +from typing import Any + +from netx_topology_mcp.layout_metrics import segments_properly_intersect + +# Turn from 180° beyond this → kink (degrees). +CHAIN_KINK_DEG = 40.0 +# Min nodes on a corridor to score as a chain (hub + ≥2 hops, or ≥3 deg≤2). +CHAIN_MIN_NODES = 3 +# Chordless cycles longer than this are ignored (metro min-rings are short). +RING_MAX_LEN = 8 + + +def _adj(links: list[tuple[str, str]]) -> dict[str, set[str]]: + g: dict[str, set[str]] = defaultdict(set) + for a, b in links: + if a == b: + continue + g[a].add(b) + g[b].add(a) + return g + + +def _edge_key(a: str, b: str) -> tuple[str, str]: + return (a, b) if a < b else (b, a) + + +def _walk_path(start: str, nodes: set[str], adj: dict[str, set[str]]) -> list[str]: + ordered = [start] + seen = {start} + prev: str | None = None + cur = start + while True: + nbs = [v for v in adj.get(cur, ()) if v in nodes and v not in seen] + if not nbs: + break + # Prefer continuing along the unique unused neighbor. + nxt = nbs[0] if len(nbs) == 1 else sorted(nbs)[0] + if prev in nbs and len(nbs) > 1: + nbs = [v for v in nbs if v != prev] + nxt = nbs[0] + prev, cur = cur, nxt + ordered.append(cur) + seen.add(cur) + return ordered + + +def extract_chain_paths(adj: dict[str, set[str]]) -> list[list[str]]: + """Maximal deg≤2 corridors, optionally extended by one hub endpoint each side.""" + if not adj: + return [] + deg = {u: len(vs) for u, vs in adj.items()} + low = {u for u, d in deg.items() if 0 < d <= 2} + if not low: + return [] + + seen: set[str] = set() + chains: list[list[str]] = [] + for seed in sorted(low): + if seed in seen: + continue + q = deque([seed]) + seen.add(seed) + comp: set[str] = set() + while q: + u = q.popleft() + comp.add(u) + for v in adj.get(u, ()): + if v in low and v not in seen: + seen.add(v) + q.append(v) + + # Cycle of only low-deg nodes → not a chain corridor. + sub_deg = {u: sum(1 for v in adj.get(u, ()) if v in comp) for u in comp} + ends = [u for u in comp if sub_deg[u] <= 1] + if len(ends) == 0 and len(comp) >= 3: + continue + + start = sorted(ends)[0] if ends else sorted(comp)[0] + ordered = _walk_path(start, comp, adj) + if len(ordered) < len(comp): + # branched low-deg blob: take longest path approximation via ends + best = ordered + for e in ends: + p = _walk_path(e, comp, adj) + if len(p) > len(best): + best = p + ordered = best + + # Extend with unique high-deg portals (hub ends). + def _portal(end: str) -> str | None: + outs = [v for v in adj.get(end, ()) if v not in comp] + return outs[0] if len(outs) == 1 else None + + if ordered: + left = _portal(ordered[0]) + right = _portal(ordered[-1]) + if left: + ordered = [left] + ordered + if right and right != left: + ordered = ordered + [right] + + if len(ordered) >= CHAIN_MIN_NODES: + chains.append(ordered) + return chains + + +def _chain_straightness(path: list[str], pos: dict[str, tuple[float, float]]) -> float: + pts = [pos[n] for n in path if n in pos] + if len(pts) < 2: + return 1.0 + chord = math.hypot(pts[-1][0] - pts[0][0], pts[-1][1] - pts[0][1]) + plen = 0.0 + for i in range(len(pts) - 1): + plen += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) + if plen < 1e-9: + return 1.0 + return max(0.0, min(1.0, chord / plen)) + + +def _chain_kinks(path: list[str], pos: dict[str, tuple[float, float]]) -> int: + pts = [pos[n] for n in path if n in pos] + kinks = 0 + for i in range(1, len(pts) - 1): + ax, ay = pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1] + bx, by = pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1] + la, lb = math.hypot(ax, ay), math.hypot(bx, by) + if la < 1e-9 or lb < 1e-9: + continue + cos = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb))) + turn = abs(math.degrees(math.acos(cos)) - 0.0) # 0 = straight continuation + # acos of dot gives angle between directions; 0° = collinear same way. + # kink if direction changes more than CHAIN_KINK_DEG from straight (0°). + if turn > CHAIN_KINK_DEG: + kinks += 1 + return kinks + + +def compute_chain_cohesion( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], +) -> dict[str, Any]: + """直链成一体:corridor straightness / kink rate → score∈[0,1].""" + adj = _adj(links) + chains = extract_chain_paths(adj) + if not chains: + return { + "chain_count": 0, + "chain_nodes": 0, + "straightness_p50": None, + "kink_count": 0, + "kink_frac": 0.0, + "score": 1.0, + "tip": "无 deg≤2 走廊可评;不扣分。", + } + + straight: list[float] = [] + weights: list[float] = [] + kink_total = 0 + kink_slots = 0 + node_union: set[str] = set() + for path in chains: + node_union.update(path) + s = _chain_straightness(path, pos) + straight.append(s) + weights.append(max(1, len(path) - 1)) + k = _chain_kinks(path, pos) + kink_total += k + kink_slots += max(0, len([n for n in path if n in pos]) - 2) + + # weighted mean straightness + wsum = sum(weights) or 1.0 + mean_s = sum(s * w for s, w in zip(straight, weights)) / wsum + sorted_s = sorted(straight) + p50 = sorted_s[len(sorted_s) // 2] + kink_frac = (kink_total / kink_slots) if kink_slots else 0.0 + # Mid-tier score: mostly straightness, penalize kinks. + score = max(0.0, min(1.0, 0.75 * mean_s + 0.25 * (1.0 - kink_frac))) + + return { + "chain_count": len(chains), + "chain_nodes": len(node_union), + "straightness_p50": round(p50, 4), + "straightness_mean": round(mean_s, 4), + "kink_count": kink_total, + "kink_frac": round(kink_frac, 4), + "score": round(score, 4), + "tip": ( + "deg≤2 走廊应近似共线成一体(chord/path≈1、少折角);" + f"折角阈值 {CHAIN_KINK_DEG:.0f}°。" + ), + } + + +def _is_chordless(cycle: list[str], adj: dict[str, set[str]]) -> bool: + n = len(cycle) + idx = {u: i for i, u in enumerate(cycle)} + for i, u in enumerate(cycle): + for v in adj.get(u, ()): + j = idx.get(v) + if j is None: + continue + dist = min((j - i) % n, (i - j) % n) + if dist > 1: + return False + return True + + +def find_short_chordless_cycles( + adj: dict[str, set[str]], + *, + max_len: int = RING_MAX_LEN, + max_cycles: int = 120, +) -> list[list[str]]: + """Enumerate chordless cycles of length 3..max_len (canonicalized). + + Caps at ``max_cycles`` so giant star canvases stay interactive. + """ + if not adj: + return [] + cycles: list[list[str]] = [] + seen: set[frozenset[str]] = set() + nodes = sorted(adj.keys()) + for start in nodes: + if len(cycles) >= max_cycles: + break + # path grows; first neighbor ordered to cut duplicates + for first in sorted(adj.get(start, ())): + if first <= start: + continue + if len(cycles) >= max_cycles: + break + stack: list[tuple[str, list[str]]] = [(first, [start, first])] + while stack: + if len(cycles) >= max_cycles: + break + u, path = stack.pop() + if len(path) > max_len: + continue + for v in adj.get(u, ()): + if v == start and len(path) >= 3: + key = frozenset(path) + if key in seen: + continue + if _is_chordless(path, adj): + seen.add(key) + cycles.append(list(path)) + if len(cycles) >= max_cycles: + break + continue + if v in path or v < start: + continue + if len(path) + 1 > max_len: + continue + stack.append((v, path + [v])) + return cycles + + +def compute_ring_integrity( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], +) -> dict[str, Any]: + """最小环不被穿:foreign edges crossing a short cycle's boundary.""" + adj = _adj(links) + cycles = find_short_chordless_cycles(adj, max_len=RING_MAX_LEN) + if not cycles: + return { + "ring_count": 0, + "rings_pierced": 0, + "pierce_crossings": 0, + "score": 1.0, + "tip": "无长度≤8 的弦无关短环;不扣分。", + } + + pierced = 0 + pierce_crossings = 0 + for cyc in cycles: + cyc_edges = { + _edge_key(cyc[i], cyc[(i + 1) % len(cyc)]) for i in range(len(cyc)) + } + hit = False + for a, b in links: + ek = _edge_key(a, b) + if ek in cyc_edges: + continue + if a not in pos or b not in pos: + continue + pa, pb = pos[a], pos[b] + for u, v in cyc_edges: + if u not in pos or v not in pos: + continue + if segments_properly_intersect(pa, pb, pos[u], pos[v]): + pierce_crossings += 1 + hit = True + break + # continue scanning to count pierce_crossings + if hit: + pierced += 1 + + n = len(cycles) + # Soften: many pierce events on one ring still one pierced ring; + # also decay by pierce density. + pierce_frac = pierced / n + dens = pierce_crossings / max(1, n) + score = max(0.0, min(1.0, 1.0 - 0.7 * pierce_frac - 0.3 * min(1.0, dens / 3.0))) + + return { + "ring_count": n, + "rings_pierced": pierced, + "pierce_crossings": pierce_crossings, + "score": round(score, 4), + "tip": ( + f"弦无关短环(3–{RING_MAX_LEN})边界不应被环外边穿越;" + "rings_pierced / pierce_crossings 越低越好。" + ), + } + + +def compute_topology_quality( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], +) -> dict[str, Any]: + chain = compute_chain_cohesion(pos, links) + rings = compute_ring_integrity(pos, links) + return {"chains": chain, "rings": rings} diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/server.py b/packages/netx-topology-mcp/src/netx_topology_mcp/server.py index 98d3802..9796ebb 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/server.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/server.py @@ -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 "") diff --git a/packages/netx-topology-mcp/tests/test_channel_metro.py b/packages/netx-topology-mcp/tests/test_channel_metro.py new file mode 100644 index 0000000..7c2f02a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_channel_metro.py @@ -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" diff --git a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py new file mode 100644 index 0000000..773e801 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py @@ -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"] diff --git a/packages/netx-topology-mcp/tests/test_compose_orbit.py b/packages/netx-topology-mcp/tests/test_compose_orbit.py new file mode 100644 index 0000000..6317a6c --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_compose_orbit.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_compose_views.py b/packages/netx-topology-mcp/tests/test_compose_views.py new file mode 100644 index 0000000..2dff15b --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_compose_views.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_core_beam.py b/packages/netx-topology-mcp/tests/test_core_beam.py new file mode 100644 index 0000000..1d29a76 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_core_beam.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_densify_sweep.py b/packages/netx-topology-mcp/tests/test_densify_sweep.py new file mode 100644 index 0000000..7ee2482 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_densify_sweep.py @@ -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"} diff --git a/packages/netx-topology-mcp/tests/test_dual_mass.py b/packages/netx-topology-mcp/tests/test_dual_mass.py new file mode 100644 index 0000000..6b57e9a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_dual_mass.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_dual_units.py b/packages/netx-topology-mcp/tests/test_dual_units.py new file mode 100644 index 0000000..98c4f59 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_dual_units.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_edge_axis.py b/packages/netx-topology-mcp/tests/test_edge_axis.py new file mode 100644 index 0000000..334279d --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_edge_axis.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_fold_chain.py b/packages/netx-topology-mcp/tests/test_fold_chain.py new file mode 100644 index 0000000..3e66829 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_fold_chain.py @@ -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"] diff --git a/packages/netx-topology-mcp/tests/test_force_densify.py b/packages/netx-topology-mcp/tests/test_force_densify.py new file mode 100644 index 0000000..6f2a123 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_force_densify.py @@ -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}" diff --git a/packages/netx-topology-mcp/tests/test_hierarchy.py b/packages/netx-topology-mcp/tests/test_hierarchy.py new file mode 100644 index 0000000..ab70abe --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_hierarchy.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_hotspots.py b/packages/netx-topology-mcp/tests/test_hotspots.py new file mode 100644 index 0000000..2dbeddb --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_hotspots.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_layout_jobs.py b/packages/netx-topology-mcp/tests/test_layout_jobs.py new file mode 100644 index 0000000..b76ac09 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_jobs.py @@ -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" diff --git a/packages/netx-topology-mcp/tests/test_layout_ops.py b/packages/netx-topology-mcp/tests/test_layout_ops.py new file mode 100644 index 0000000..09b30a0 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_ops.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_layout_sight.py b/packages/netx-topology-mcp/tests/test_layout_sight.py new file mode 100644 index 0000000..8ff3595 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_sight.py @@ -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"] diff --git a/packages/netx-topology-mcp/tests/test_layout_stats.py b/packages/netx-topology-mcp/tests/test_layout_stats.py new file mode 100644 index 0000000..e88b8ba --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_stats.py @@ -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"] diff --git a/packages/netx-topology-mcp/tests/test_layout_structure.py b/packages/netx-topology-mcp/tests/test_layout_structure.py new file mode 100644 index 0000000..6f8ea0f --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_structure.py @@ -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"]) diff --git a/packages/netx-topology-mcp/tests/test_layout_tool.py b/packages/netx-topology-mcp/tests/test_layout_tool.py new file mode 100644 index 0000000..0eca842 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_layout_tool.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_mass_merge.py b/packages/netx-topology-mcp/tests/test_mass_merge.py new file mode 100644 index 0000000..58b7a47 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_mass_merge.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py index 33498f2..d28dbc4 100644 --- a/packages/netx-topology-mcp/tests/test_mcp_topology.py +++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py @@ -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"] diff --git a/packages/netx-topology-mcp/tests/test_move_nodes.py b/packages/netx-topology-mcp/tests/test_move_nodes.py new file mode 100644 index 0000000..35e7382 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_move_nodes.py @@ -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" + ) diff --git a/packages/netx-topology-mcp/tests/test_orbit_sweep.py b/packages/netx-topology-mcp/tests/test_orbit_sweep.py new file mode 100644 index 0000000..e87353a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_orbit_sweep.py @@ -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" diff --git a/packages/netx-topology-mcp/tests/test_ortho_metro.py b/packages/netx-topology-mcp/tests/test_ortho_metro.py new file mode 100644 index 0000000..b785775 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_ortho_metro.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_partition.py b/packages/netx-topology-mcp/tests/test_partition.py new file mode 100644 index 0000000..dba55c3 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_partition.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py b/packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py new file mode 100644 index 0000000..515f440 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_pin_beam_soft_petals.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_press_crossings.py b/packages/netx-topology-mcp/tests/test_press_crossings.py new file mode 100644 index 0000000..9f9c34a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_press_crossings.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_rigid_orbit.py b/packages/netx-topology-mcp/tests/test_rigid_orbit.py new file mode 100644 index 0000000..c697b18 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_rigid_orbit.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_rigid_units.py b/packages/netx-topology-mcp/tests/test_rigid_units.py new file mode 100644 index 0000000..be09e4a --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_rigid_units.py @@ -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) diff --git a/packages/netx-topology-mcp/tests/test_sink_dual_units.py b/packages/netx-topology-mcp/tests/test_sink_dual_units.py new file mode 100644 index 0000000..479cd3d --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_sink_dual_units.py @@ -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 diff --git a/packages/netx-topology-mcp/tests/test_topology_quality.py b/packages/netx-topology-mcp/tests/test_topology_quality.py new file mode 100644 index 0000000..2d53791 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_topology_quality.py @@ -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"} diff --git a/packages/netx-topology-mcp/tests/test_untangle.py b/packages/netx-topology-mcp/tests/test_untangle.py new file mode 100644 index 0000000..4fdedb8 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_untangle.py @@ -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