Update topology tool skill

This commit is contained in:
oliver 2026-08-10 16:44:09 +08:00
parent 37dd43923f
commit 99a262e348
4 changed files with 212 additions and 7 deletions

View file

@ -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。

View file

@ -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

View file

@ -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),

View file

@ -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)