From 99a262e34823e246b8dd94701711ddd1db26599a Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 10 Aug 2026 16:44:09 +0800 Subject: [PATCH] Update topology tool skill --- .cursor/skills/netx-topology/SKILL.md | 102 ++++++++++++++++-- .../layout_ops/orbit_sweep.py | 68 ++++++++++++ .../src/netx_topology_mcp/layout_sight.py | 46 ++++++++ .../src/netx_topology_mcp/layout_tool.py | 3 + 4 files changed, 212 insertions(+), 7 deletions(-) diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index d29873c..b431009 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -27,7 +27,7 @@ analyze(structure) → 认 dual_units / shape → 下一批 sink…(禁止 until_empty 日常连抽) ``` -停手:`overlaps=0` 且 `verdict.total≈70`、cpl 中档内即可交付。 +停手:`overlaps=0` 且 `verdict.total≈70`、cpl 中档、`edge_clearance.status≠fail`、`edge_axis.status≠fail` 方可交付。**勿只看 crossing**——orbit_sweep 的 `improving_n=0` 只代表交叉维度卡壳,须逐项检查 edge_clearance/edge_axis/rings,任一 fail 即继续处理。 --- @@ -60,6 +60,41 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" }) --- +## 分层布局(特殊场景) + +当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时,优化策略不同于通用 mesh: + +### 分层通用规则 +1. **顶层:外部/对接网络** +2. **次顶层:终端客户接入层** +3. **中间层:核心层** +4. **核心下层:汇聚层** +5. **底层:接入层/孤立层** + +### 优化流程 +1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化 +2. **分层约束**:用 `updateTopologyViewPositions` 按层级设定 y 坐标(同层 y 落在区间内可错落,勿跨层);`orbit_sweep` 传 `y_min/y_max` 约束搜索在本层区间内 +3. **分步迭代**: + - `polish_crossings` 减少交叉(可能破坏分层) + - 若分层被破坏,**保留 x 坐标,恢复 y 坐标**重新分层 + - `clear_edge_hits` 消除贴边 + - `fix_overlaps` 修复重叠 + +### 冲突处理原则 +| 冲突场景 | 处理方式 | +|---------|---------| +| 交叉 vs 结构 | **结构优先**,接受少量跨层交叉 | +| 算法 vs 手动 | **手动优先**,算法辅助 | +| polish 破坏分层 | 保留 x 坐标,恢复 y 坐标分层 | + +### 关键经验 +1. **跨层交叉是结构性的**:跨层连线必然穿越中间层,属正常 +2. **外层节点必须在外部层**:与 PS/PE 连接的外部网元(RNC、华为设备等)应放在最上方 +3. **orbit_sweep 卡壳更早**:分层约束下 `improving_n=0` 来得快;改传 `objective=total` 让 orbit_sweep 按 crossing+edge_clearance 综合排序 +4. **验收以层级清晰为先**:`verdict.total` 次于结构可读性;同层 y 在区间内、层间不串层即可交付 + +--- + ## 根图 → 子区域排水 ``` @@ -96,7 +131,7 @@ sinkTopologyDualUnits({ | `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` | | `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 | | `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 | -| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800) | +| `orbit_sweep` | 压交叉;`round` + 大 `max_jump`(约 1800–2800);`objective=total` 综合 crossing+edge_clearance;`y_min/y_max` 分层约束 | | `polish_crossings` | 一键:straighten→press→untangle | | `clear_edge_hits` | 网元贴非关联边时正交弹开 | | `fix_overlaps` / `resolve_overlaps` | 只拉开重叠 | @@ -104,19 +139,35 @@ sinkTopologyDualUnits({ | `straighten_channels` | 拉直 deg≤2 走廊 | | `job_status` / `job_cancel` | 后台 job | -阶段2顺序:先 `orbit_sweep` 压交叉 → `polish_crossings` → 看 `edge_clearance` 再 `clear_edge_hits`。 +阶段2是多目标循环,非单向链: + +1. `orbit_sweep` 压交叉 → `polish_crossings` +2. **orbit_sweep `improving_n=0` 时勿停**——立即转看 `edge_clearance`(贴边对视觉可读性影响 ≥ 交叉)→ `clear_edge_hits` 正交弹开 +3. 再看 `edge_axis`(斜边过多)→ `straighten_channels` / 手拖归轴 +4. 回头复检 crossing 是否因上步变动出现新机会 → 再 `orbit_sweep` + `orbit_round` 只在全局交叉严格下降时落笔;卡顿加大 `max_jump` / 单点 preview→pick。 +**关键**:单节点 `updateTopologyViewPositions` 拖动时,某节点移动可能增交叉但解多个贴边——以 `verdict.total` 升降为准,勿只盯 crossing 数。`drag_candidates` 可能为空,此时看 `edge_clearance.top` / `crossing.top_nodes` 自行判断拖谁。 + --- ## 验收 | 块 | 看什么 | |----|--------| -| `overlap` | 硬零 | -| `crossing` | crossings/cpl;`top_nodes` / `top_edges` | -| `edge_clearance` | 贴边 → clear_edge_hits | -| `verdict.total` | ≈70 可交付(ov=0) | +| `overlap` | 硬零(权重 0.24,硬门控) | +| `crossing` | crossings/cpl;`top_nodes` / `top_edges`(权重 0.18) | +| `rings` | 最小环被穿(权重 0.10) | +| `edge_clearance` | 贴边 → clear_edge_hits;**权重 0.08 但视觉影响 ≥ crossing,优先处理** | +| `edge_axis` | 斜边过多 → straighten_channels / 手拖归轴(权重 0.06) | +| `verdict.total` | ≈70 可交付(ov=0);**总分升降为准,勿只盯 crossing** | + +### 评分优化快速方法 +1. **消除 overlap** → 硬门控项,必须为 0 +2. **减少 crossings** → polish_crossings 大幅降低 +3. **处理 edge_clearance** → clear_edge_hits 消除贴边 +4. **结构检查** → 分层清晰 > 交叉最少 图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。 @@ -139,6 +190,43 @@ sinkTopologyDualUnits({ --- +## 通用优化策略(实战经验) + +### 算法与手动的最佳组合 +``` +Step 1: 用户手动拖拽 → 保证业务结构和分层 +Step 2: polish_crossings → 大幅减少交叉(接受可能破坏分层) +Step 3: 检查分层 → 若被破坏,保留 x 恢复 y 重新分层 +Step 4: clear_edge_hits → 处理贴边问题 +Step 5: fix_overlaps → 修复节点重叠 +Step 6: 最终验收 → 结构清晰 > 交叉最少 +``` + +### 工具使用优先级 +| 场景 | 首选工具 | 参数建议 | +|-----|---------|---------| +| 整体减交叉 | `polish_crossings` | `top_n=10, max_moves=50` | +| 单点微调 | `orbit_sweep` | `objective=total, y_min/y_max` | +| 处理贴边 | `clear_edge_hits` | `top_n=15, max_moves=30` | +| 修复重叠 | `fix_overlaps` | 直接调用 | +| 批量调坐标 | `updateTopologyViewPositions` | 保留 x,恢复 y | + +### 常见问题解决方案 +| 问题 | 原因 | 解决方案 | +|-----|------|---------| +| polish 破坏分层 | 算法优先减少交叉 | 保留 x 坐标,恢复 y 分层 | +| orbit_sweep 无改善 | 位置已优化 | 接受现状或手动调整 | +| 节点重叠 | 坐标调整太近 | fix_overlaps 自动修复 | +| 交叉突然增加 | clear_edge_hits 移动节点 | 重新 polish_crossings | + +### 关键原则 +1. **业务结构优先于算法优化**:网络拓扑的分层结构比最少交叉更重要 +2. **局部微调优于全局重置**:用 `orbit_sweep`/`updateTopologyViewPositions` 单点调整,而非 `layout` 全局布局 +3. **预览模式优先于应用模式**:先用 `mode=preview` 查看效果,确认后再 `mode=apply` +4. **分步迭代优于一次性操作**:polish → clear_edge_hits → fix_overlaps 分步骤执行 + +--- + ## 代码热更 1. 本仓 MCP 用 `PYTHONPATH=…/src`,改源码后不必为加载而 pip install。 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 index 9844fb7..c24a1c8 100644 --- 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 @@ -184,6 +184,40 @@ def _score_key(c: dict[str, Any]) -> tuple: ) +def _rerank_by_total( + scored: list[dict[str, Any]], + nid: str, + pos: dict[str, tuple[float, float]], + names: dict[str, str], + links: list[tuple[str, str]], + adj: dict[str, set[str]], + *, + re_rank_n: int = 20, +) -> list[dict[str, Any]]: + """Re-rank top candidates by crossing + edge_clearance (multi-objective). + + A move may increase crossings but resolve several edge-clearance hits; + ranking by ``crossings + edge_clearance_hits`` aligns with verdict.total. + """ + from netx_topology_mcp.layout_metrics import compute_edge_clearance + + n = min(re_rank_n, len(scored)) + for c in scored[:n]: + trial = dict(pos) + trial[nid] = (float(c["x"]), float(c["y"])) + ec = compute_edge_clearance(trial, links, names=names, top_n=1) + c["edge_clearance_hits"] = int(ec.get("edge_clearance_hits") or 0) + head = sorted( + scored[:n], + key=lambda c: ( + int(c["crossings"]["global"]) + int(c.get("edge_clearance_hits", 0)), + int(c["crossings"]["incident"]), + float(c.get("stretch") or 1.0), + ), + ) + return head + scored[n:] + + def _diversify_top( ranked: list[dict[str, Any]], *, @@ -286,6 +320,9 @@ def orbit_sweep_node( protect_rigid: bool | str = "off", frozen_ids: set[str] | None = None, top_k: int = 3, + y_min: float | None = None, + y_max: float | None = None, + objective: str = "crossing", ) -> dict[str, Any]: """Sweep polar candidates for one node; return diversified top-k. @@ -370,6 +407,15 @@ def orbit_sweep_node( if len(uniq) >= cand_cap: break + # Layered y constraint: skip candidates outside [y_min, y_max] + if y_min is not None or y_max is not None: + uniq = [ + (sx, sy, r, ang) + for sx, sy, r, ang in uniq + if (y_min is None or sy >= y_min) + and (y_max is None or sy <= y_max) + ] + scored: list[dict[str, Any]] = [] for sx, sy, r, ang in uniq: c = _eval_candidate( @@ -417,6 +463,8 @@ def orbit_sweep_node( break for sx, sy, r, ang in fine: + if (y_min is not None and sy < y_min) or (y_max is not None and sy > y_max): + continue key = (int(round(sx)), int(round(sy))) if key in seen: continue @@ -440,6 +488,9 @@ def orbit_sweep_node( scored.append(c) scored.sort(key=_score_key) + # Multi-objective re-rank: optimize total score, not just crossings + if objective == "total" and len(scored) > 1: + scored = _rerank_by_total(scored, nid, pos, names, links, adj) # 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 @@ -458,9 +509,16 @@ def orbit_sweep_node( "improving_n": len(improving), "max_jump": jump, "angle_step": angle_step, + "objective": objective, + "y_band": ( + None if (y_min is None and y_max is None) + else [y_min, y_max] + ), "hint": ( "prefer rank1 unless util/label concern; then pick 2/3. " "apply with params.pick=1|2|3 or updateTopologyViewPositions." + + (" objective=total: rank by crossing+edge_clearance, may trade crossings for clearance." + if objective == "total" else "") ), } @@ -736,6 +794,16 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A raw_p = o.get("portal_ids") if isinstance(raw_p, list): out["frozen_ids"] = {str(x) for x in raw_p if str(x)} + # Layered y constraint (y_min/y_max): keep node within its layer band + for yk in ("y_min", "y_max"): + if o.get(yk) is not None: + try: + out[yk] = float(o[yk]) + except (TypeError, ValueError): + pass + # Multi-objective ranking: "crossing" (default) or "total" + obj = str(o.get("objective") or "crossing").strip().lower() + out["objective"] = "total" if obj in ("total", "score", "multi") else "crossing" 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 index a86c103..18d0ea7 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_sight.py @@ -131,6 +131,52 @@ def list_crossings( top_e = top_crossing_edges( pos_xy, links, names=names, top_n=5, edge_participation=edge_hit ) + # Fallback: when no drag_candidates (all hot nodes are high-degree hubs), + # suggest from edge_clearance.top and crossing top_nodes regardless of degree. + if not candidates: + from netx_topology_mcp.layout_metrics import compute_edge_clearance + + ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5) + for eh in (ec.get("top_edge_hits") or [])[:5]: + nid = str(eh.get("fabric_node_id") or "") + if not nid or nid not in pos: + continue + x, y, name = pos[nid] + deg = len(adj.get(nid, ())) + candidates.append({ + "fabric_node_id": nid, + "name": name, + "short": _short_name(name), + "x": round(x, 1), + "y": round(y, 1), + "degree": deg, + "in_crossings": hit.get(nid, 0), + "score": 0.0, + "suggest_xy": [], + "delta_crossings_est": 0, + "reason": "edge_clearance_hit", + }) + for tn in top5: + nid = str(tn.get("fabric_node_id") or "") + if not nid or nid not in pos: + continue + if any(c["fabric_node_id"] == nid for c in candidates): + continue + x, y, name = pos[nid] + deg = len(adj.get(nid, ())) + candidates.append({ + "fabric_node_id": nid, + "name": name, + "short": _short_name(name), + "x": round(x, 1), + "y": round(y, 1), + "degree": deg, + "in_crossings": tn.get("crossing_hits", 0), + "score": round(tn.get("crossing_hits", 0) / max(deg, 1), 3), + "suggest_xy": [], + "delta_crossings_est": 0, + "reason": "top_crossing_high_degree", + }) return { "edge_crossings": total_cross, "crossings_listed": len(crosses), 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 index fb9166c..9f4a552 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_tool.py @@ -512,6 +512,9 @@ def run_layout_on_graph( protect_rigid=protect, frozen_ids=frozen, top_k=int(knobs.get("top_k") or 3), + y_min=knobs.get("y_min"), + y_max=knobs.get("y_max"), + objective=knobs.get("objective", "crossing"), ) if not sweep.get("ok"): fin = score_state(st0)