diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index b431009..4d8c4c6 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -1,33 +1,184 @@ --- name: netx-topology description: >- - 用 netx-topology MCP 查邻接、dual_unit 分批沉入、扫角压交叉(不污染 Fabric)。 - 触发:画拓扑、布图、拖图、LLDP、Fabric、netx-topology。先读本 skill 再调工具。 + 用 netx-topology MCP 查邻接、分类打标;先 dual_unit 一次抽最大核心眼,再换其它算法下沉/布图(不污染 Fabric)。 + 触发:画拓扑、布图、拖图、分类、LLDP、Fabric、netx-topology。先读本 skill 再调工具。 --- # netx 拓扑(通用) 只用 **`netx-topology`** MCP(包 `netx-topology-mcp`)。安装与 scopes:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。 -**原则**:复杂图先拆 **dual_units** 再拼;交叉少、边短、近轴优先。 +**原则**: +1. **第一步用 dual_units**:从**核心**出发,选**覆盖网元最多**的那一只眼(`prefer_top_eye` + max cover)。 +2. **`sinkTopologyDualUnits` / `layout_dual_unit` 整图流程只用一次**——只沉这一只最大眼;**禁止**再连抽下一批 dual_unit。 +3. **后续下沉换算法**:`suggestSinkHubs` → `move_nodes(park)` / `projectTopologyNeighbors`;**不要**再 `sinkTopologyDualUnits`。 +4. **眼图已定型 → 门控精修**:对该 sink 只用下表「允许」动作;禁止全局拆眼工具。 +5. 布眼目标:少交叉、眼心空旷、少重叠(椭圆弧带;长链在眼外)。对照人工金标时还要看:**紧凑度、正交边、贴边清开**(见「算法天花板」)。 + **禁止**写临时 py 穷举坐标或直接调 HTTP;验证与压交叉**只调 MCP**。不造 Fabric 边。 -**勿**把客户网元名、区域名、具体交叉数写进本 skill。 +**脱敏(硬)**:勿把客户网元名、站点/区域名、具体交叉数、具体 view_id 写进本 skill。角色只用通用词:门户 / 枢纽 / 汇聚 / 接入 / 末梢。**禁止**在 skill 正文写站点缩写或设备角色缩写当专名举例。 + +--- + +## 完整处理思路(一次大区接入眼) + +按阶段执行;每阶段只调 MCP,读返回再决定下一步。 + +``` +① 认眼 + analyze(structure) → dual_units 里取覆盖最大的核心门户对 +② 一次 sink + sinkTopologyDualUnits(max_units=1, prefer_top_eye, layout_batch) + → 同一 sink 贯穿全程;眼形不对只 layout_dual_unit 重布同一对门户 +③ 换算法下沉(禁 dual) + 循环:suggestSinkHubs(pick:1) → move_nodes(park) → until_limit(crossing) + 空批次后:orphan_batch / 对根−sink−门户 分批 park;仍禁 dual +④ 眼定型精修(门控序列,见下) + until_limit(crossing) → until_limit(total) + → clear_edge_hits(portal_ids) → pull_far_chains(portal_ids) + → compact_bbox(portal_ids, outlier_only) → 可选再 clear / total +⑤ 停手(算法天花板,**默认无范本**) + overlaps=0;until_limit stall;pull/compact/clear 均 moved≈0 + → 交付:眼可读 + ov=0 + 交叉可接受;util/正交/贴边缺口 → 手拖或改初布 + → 勿再发明无门控全局工具硬挤 +``` + +**范本现实(硬)**: +- **日常默认没有范本**;有人工图也几乎不是同一张网 → **禁止**把 `align_reference` / 抄坐标当主路径。 +- `align_reference` **仅**用于:同网同成员画布的 A/B 对照、调试、回放(共享 `fabric_node_id`)。跨网 ID 对不上,抄了也无意义。 +- 生产要缩短与人工观感差距 → **改初布配方**(正交/紧凑从 dual 一次就偏地铁风),不是现场找金标对齐。 + +**从零重布(无范本算力路径)**: +- 眼已在 sink、只需甩掉坏坐标 / 金标残留:对本 sink **`layout_dual_unit`(同一门户对)→ 立刻门控精修**。这**不算**再 sink 一批 dual。 +- 初布后交叉会先飙高、贴边变差属正常;**不要**因此改走 `polish_crossings` / `fix_overlaps`。 +- 纯算力压交叉后 util/bbox 常崩(枢纽被 `until_limit` 甩远)→ **必须**接 `pull_far_chains` → `compact_bbox`;缺这两个 action 就先重启 MCP,勿半截交付。 +- 无范本时:交叉可压到可读,但仍常高于「抄同网坐标」路径;util/正交缺口靠 pull + 初布,**不是**再堆 until_limit。 + +**开局核对 catalog(硬)**: +- 精修前先 `layoutTopologyView(catalog=true)`:须见 `pull_far_chains`、`compact_bbox`,且 `version`/`rev` 为当前包。 +- 缺 action 或 `rev` 过旧 → **重启** netx-topology MCP 再干;禁止用旧进程硬跑、禁止改走 HTTP/临时 py。 + +**精修硬门控(已写进算法,skill 须遵守)**: +- 任何落笔:**全局交叉不得升高**;升高 → 弃该候选。 +- **最小化展开**(bundle):从小压缩步长试探;步长须清开 footprint(成员互叠也算失败)。 +- **侵入** = 展开后踏入外人框 / 留下任意 footprint 重叠 → 换 tip,勿硬塞。 +- `until_limit` 只接受 `overlaps=0`;apply 遇 `overlaps_remain` → 修算法,**勿**对眼 sink 跑 `fix_overlaps`。 +- `portal_ids` **只钉眼门户**;`freeze_layers=["core"]` 会冻住 sink 上所有核心(含旁路枢纽)——慎用。 +- 旁路枢纽也要动时:`protect_rigid=off`(仍受 `portal_ids` 约束)。 +- `clear_edge_hits`:**禁止**放宽交叉(含 `preserve_axis`);交叉升高一律拒。 +- `pull_far_chains`:相对**门户中点**缩放远走廊/孤立点/远叶(不是朝外圈枢纽);枢纽本身在外围时朝枢纽缩**收不了 bbox**。 +- `compact_bbox`:默认 **farthest-K**(`outlier_only`);全图/分位均匀缩易叠点 → 拒。 +- `until_limit`:`stop_reason=max_moves` **≠** stall → **再调**直到 `stall`(可提高 `max_moves`,但仍以 stall 为准)。 +- 大眼 `max_jump` 用 4k–8k;勿收到 <2k。大图会 background → `job_status`(可数分钟);勿中途改走拆眼工具。 + +--- + +## 算法天花板(经验固化) + +大区接入眼跑完门控序列后,常见结果: + +| 维度 | 算法能做到 | 再挤会怎样 | 交付策略 | +|------|------------|------------|----------| +| 交叉 | 从零 dual 后 `until_limit` 可大幅压下;无范本时仍常高于抄坐标路径 | 再挪极值尖端易抬交叉;`max_moves` 停了要续跑到 stall | **停**于 stall;残余当弦交叉可接受 | +| 重叠 | 硬零 | — | 必须保持 | +| 紧凑 util / bbox | 压交叉常先**炸开** bbox;`pull_far_chains` 多轮收远走廊 | 缺 pull 就半截交付;强缩常 `Δg>0` 拒 | **必跑** pull→compact 到 `moved=0`;其余手拖 | +| 贴边 clearance | `clear` + `objective=total` 能降一批 | 与交叉/紧凑拉扯,清到个位数很难 | stall 后手拖擦边点 | +| 正交 axis | `total` 略抬;远低于人工地铁风 | `preserve_axis` 清边若放宽交叉会毁掉前面成果(已禁 slack) | 手拖走廊;勿为轴牺牲交叉 | +| 直链 / 环穿 | bundle 后仍有折角与穿环 | 全局 straighten 拆眼 | 勿清零;结构优先 | + +**判定「算法到头」**(门控精修,无范本): +1. `overlaps=0` +2. `until_limit`(crossing 与 total)均 stall +3. pull/compact/clear `moved≈0` +4. 目视:眼心空、双门户可辨 + +**可选对照**(仅评测):对**同网**人工画布与算法 sink 各 `analyze`,只比 `summary`;**勿**因此去 `align_reference` 当交付手段。 + +**明确无效 / 禁止再试**(眼 sink): +- 再次 `sinkTopologyDualUnits` / `until_empty` / 新块画布抽 dual +- `polish_crossings` / `straighten_channels` / `untangle` / `fix_overlaps` / `orbit_sweep(round=true)` +- 临时 py 穷举坐标、无门控的整图缩放/平移极值点 +- 为追 util 关掉交叉门控或给 `clear` 开交叉 slack +- **把跨网/他网人工图当范本抄坐标**(含滥用 `align_reference`) --- ## 主路径(必循) ``` -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 日常连抽) +analyze(structure) → 读 dual_units(认核心最大眼) +→ 有 level:先打标;巨图勿先全量 level_bands +→ 【仅一次】sinkTopologyDualUnits( + max_units=1, prefer_top_eye=true, prefer_pure=false, + sink_layers=[access], keep_layers=[core,agg], layout_batch=true, + max_nodes/max_batch_nodes 放行大眼 + ) +→ 眼形不对:可对本 sink 重跑 layout_dual_unit(仍算同一只眼;勿再 sink 下一批 dual) +→ 【换算法】suggestSinkHubs → move_nodes(park) 分批迁入同一 sink;每批后 until_limit;禁止再 dual +→ 【眼定型】门控精修序列(下节);禁止全局拆眼 +→ 根图可 level_bands(只动根上残留,不动已定型的眼 sink) ``` -停手:`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 即继续处理。 +停手:最大眼可读、眼心空、`overlaps≈0`、门户清晰;其余节点用非 dual 手段靠上去即可。交叉允许来自跨走廊弦边。 + +**dual 一次用尽**:`until_empty`、再次 `sinkTopologyDualUnits`、为下一批 dual 新建块画布 —— **一律禁止**(日常)。 + +**布眼几何**(仅那一次):嵌套半椭圆;中轴只有双门户;长链/尾巴放眼外禁穿环。眼坏了重跑 `layout_dual_unit`,勿全局拆眼。 + +--- + +## 眼图定型后的精修(门控序列) + +眼 sink 一经验收,**禁止**再对该画布跑会整图拆眼的工具。 + +| 允许 | 禁止(眼 sink 上) | +|------|-------------------| +| `analyze(detail=hotspots\|both)` 只读 | `polish_crossings` / `straighten_channels` | +| `orbit_sweep`:**`until_limit=true`** 或 `node_id` preview→pick | `orbit_sweep(round=true)` 全图轮扫 | +| `clear_edge_hits`(**须** `portal_ids`;交叉不升) | `fix_overlaps` / `untangle` 批处理整图 | +| `pull_far_chains`(朝**门户中点**收远走廊/孤立点;同上门控) | `layout` / 再次 `sinkTopologyDualUnits` | +| `compact_bbox`(farthest-K;交叉不升、ov=0) | 临时 py 穷举坐标 | +| `updateTopologyViewPositions` 手拖 1~少数点 | `align_reference` 当无同网范本时的交付手段 | +| 眼形整体崩了才 `layout_dual_unit` 重布同一门户对 | | + +**推荐节奏**(一次序列,读返回再决定是否重复某一环): + +``` +0) catalog=true 核对 pull_far_chains / compact_bbox(缺则重启 MCP) +0b) 从零重布时:layout_dual_unit(同一门户对, apply) // 交叉飙高正常 +1) until_limit + objective=crossing + portal_ids + bundle + → stop_reason=max_moves 则续跑,直到 stall +2) until_limit + objective=total // 交叉不升;抬贴边/正交;亦可 max_moves→续跑 +3) clear_edge_hits(portal_ids, top_n/max_moves 可加大;勿 preserve_axis 除非确认不抬交叉) +4) pull_far_chains(portal_ids) // 可 2~3 轮直到 moved=0;压交叉后必做 +5) compact_bbox(portal_ids, outlier_only) // 常 accepted=false,正常 +6) 可选:再 clear 或 total;再 stall → 停手 +``` + +`until_limit` 调用模板: + +``` +layoutTopologyView({ + action: "orbit_sweep", mode: "apply", + params: { + until_limit: true, + objective: "crossing", // 再一轮改 total + portal_ids: [<门户A>, <门户B>], + protect_rigid: "off", + max_degree: 14, + max_jump: 8000, // 大眼用 4k–8k;勿 <2k + max_stretch: 32, + max_moves: 40, // 触顶只说明配额满,不是算法到头 + bundle: true + } +}) +→ 读 local.op:start/end_crossings、moves[]、stop_reason +→ max_moves → 再调;stall → 进入下一步 +``` + +`round=true` ≠ `until_limit`:前者一批 top_n 死拿 pick#1;后者循环到 stall。 +勿为清零交叉拆弧带;以 `verdict.total` + 目视眼形 +「算法到头」四条为准。 --- @@ -35,13 +186,14 @@ analyze(structure) → 认 dual_units / shape 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:…` 幽灵点勿当网元拖。 +3. 缺层级/区域时:`classifyTopologyFabricNodes`(`match` → `tag(level, dry_run)` → `tag`;或 `unmatched` / `apply_rules`)。**level** 越小越靠外(0 外部 / 1 核心 / 2 汇聚 / 3 接入;可用 1.1、2.1 子层)。**勿**切片建图。 +4. `analyzeTopologyViewLayout({ view_id, detail: "structure" })` 读 `dual_units` / `shape` / `layers` —— **先认核心最大眼**(`unit_count` / 各 unit `node_count`)。 +5. `addTopologyViewNodes`(可按 `role` 大档或后续 level)/ `projectTopologyNeighbors`(区域画布务必 `region_folder_id`)。 +6. 多余点用 `removeTopologyViewNodes` **移出画布**(不删 Fabric)。`region:…` 幽灵点勿当网元拖。 --- -## 形状与 dual_units +## 形状与 dual_units(只服务「第一只眼」) ``` analyzeTopologyViewLayout({ view_id, detail: "structure" }) @@ -50,19 +202,85 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" }) | 字段 | 用途 | |------|------| | `shape.primary` | `chains` / `star` / `mesh` / `mixed_blocks` | -| `dual_units` | 两端门户 + ≥2 条内部不交走廊;成员可重叠 | -| `advice.block_plan` | 每块怎么拖 | +| `dual_units` | **从核心出发**:core–core → core–agg → …,再取 **node_count 最大**的一只做唯一 sink | +| `advice.block_plan` | 其余块怎么靠(非 dual 连抽) | | `gravity.type` | 链图勿当 hub 花瓣 | -- **链图**:脊柱水平 + stub;小图 preview `corridor`/`compact`(跳过 rings)。 -- **巨图 / 多门户**:勿全图一把揉;走 **sink 分批 dual_unit**。 +- **第一步必选最大核心眼**:`prefer_top_eye=true`,`prefer_pure=false`,`max_units=1`;目标是**一块吃进尽量多网元**,不要求交叉=0。 +- **禁止**把 dual_units 当成「抽干源图」的循环泵;剩余网元见下方「后续下沉」。 - **禁止**再用互斥 soft_block 把通路切开。 +- **链图 / 小图**:可不走 dual;preview `corridor`/`compact`。 + +--- + +## 根图 → 子区域:第一次(dual,仅一次) + +``` +sinkTopologyDualUnits({ + source_view_id: <根图>, + sink_view_id: <子区域>, // 同一张接入画布贯穿全程 + max_units: 1, + max_nodes: 300, + max_batch_nodes: 400, + layout_batch: true, + sink_layers: ["access"], + keep_layers: ["core", "agg"], + prefer_top_eye: true, + prefer_pure: false, + // 勿 until_empty;勿随后再调本工具 +}) +→ 眼形验收 / 手拖;必要时 layout_dual_unit 重布同一眼 +``` + +- **同一 sink**:后续非 dual 迁入也进**同一个** `sink_view_id`;禁止块1/块2/块3。 +- **眼定型后**:对该 sink **只门控精修**(见上节)。 + +--- + +## 后续下沉(换算法,禁用 dual) + +源图仍有接入 / 旁路网元时,**不要**再 `sinkTopologyDualUnits`。 + +0. **先问算法要批次**(推荐) + ``` + suggestSinkHubs({ + source_view_id: <根图>, + sink_view_id: <眼 sink>, + pick: 1, + }) + → batch.fabric_node_ids / move_nodes 可直接套用 + ``` + 排序:剩余领地大优先、agg 优于 core;眼门户不当 batch 头。 + + **每批迁完立刻** `until_limit(crossing, portal_ids)`,再取下一批;勿攒多批再一起 polish。 + + `suggestSinkHubs` 空了但根上还有孤立末梢/旁路:清掉已在 sink 的重复点,再对 `root−sink−眼门户` 分批 `move_nodes(park)`(每批后 `until_limit`)。也会直接吐 `orphan_batch_*`。勿再 dual。 + +1. **指定迁移 + 扫角停靠** + ``` + layoutTopologyView({ + action: "move_nodes", + source_view_id: , view_id: , + mode: "apply", + params: { + fabric_node_ids: [...], + park: true, + remove_from_source: true, + }, + }) + ``` + +2. **投影邻居**:`projectTopologyNeighbors`(区域画布带 `region_folder_id`),再手拖或 `orbit_sweep`。 + +3. **根图收尾**:`level_bands` 只钉**根图**上残留的 core/agg;**勿**对已定型眼 sink 跑全局带。 + +4. **小连通块**:源上剩余短链/星,可对**源图**或迁入后**单点**处理;勿对眼 sink `layout(recipe=…)`。 --- ## 分层布局(特殊场景) -当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时,优化策略不同于通用 mesh: +当网络需呈现层级结构(外部→接入→核心→汇聚→客户等)时: ### 分层通用规则 1. **顶层:外部/对接网络** @@ -73,54 +291,16 @@ analyzeTopologyViewLayout({ view_id, detail: "structure" }) ### 优化流程 1. **先手动后算法**:用户手动拖拽保证业务结构,再用算法优化 -2. **分层约束**:用 `updateTopologyViewPositions` 按层级设定 y 坐标(同层 y 落在区间内可错落,勿跨层);`orbit_sweep` 传 `y_min/y_max` 约束搜索在本层区间内 -3. **分步迭代**: - - `polish_crossings` 减少交叉(可能破坏分层) - - 若分层被破坏,**保留 x 坐标,恢复 y 坐标**重新分层 - - `clear_edge_hits` 消除贴边 - - `fix_overlaps` 修复重叠 +2. **分层约束**:`updateTopologyViewPositions` 按层钉 y;`orbit_sweep` 可传 `y_min/y_max` +3. **分步(非眼画布 / 根图)**:可贴边、分层钉 y;**已定型眼 sink 除外**——只允许门控精修 / 手拖 -### 冲突处理原则 +### 冲突处理 | 冲突场景 | 处理方式 | |---------|---------| -| 交叉 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 在区间内、层间不串层即可交付 - ---- - -## 根图 → 子区域排水 - -``` -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。 +| 交叉 vs 结构 | **结构优先**(最大眼 / 分层) | +| 算法 vs 手动 | **手动优先** | +| polish 拆眼 | **禁止**对 dual 眼走 straighten 主路径 | +| 算法 vs 金标观感 | 交叉可赢;紧凑/正交/贴边交给手拖 | --- @@ -128,27 +308,20 @@ sinkTopologyDualUnits({ | action | 用途 | |--------|------| -| `layout` | 小图配方:`compact` / `corridor` / `rings` / `unstick` | -| `layout_dual_unit` | 双门户眼形;单元内交叉≠0 拒绝 | -| `move_nodes` / `sink_nodes` | 指定 ids 双向迁移;`park` 块扫 | -| `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` | 只拉开重叠 | -| `untangle` | 贪心降交叉;默认可冻门户 | -| `straighten_channels` | 拉直 deg≤2 走廊 | +| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` | +| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 | +| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 | +| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` | +| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) | +| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 | +| `align_reference` | **非主路径**:仅同网同成员 A/B 调试;跨网禁用 | +| `compact_bbox` | 眼 sink **可**:farthest-K 收 bbox | +| `level_bands` | 仅根图/未定型画布;巨图在 dual 一次之后钉根 | +| `polish_crossings` | **眼 sink 禁用** | +| `fix_overlaps` / `untangle` / `straighten_channels` | **眼 sink 禁用** | | `job_status` / `job_cancel` | 后台 job | -阶段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` 自行判断拖谁。 +眼 sink 精修 = 门控序列 + 手拖补观感;不要排全局流水线,也不要在 stall 后继续发明新全局动作。 --- @@ -156,20 +329,16 @@ sinkTopologyDualUnits({ | 块 | 看什么 | |----|--------| -| `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** | +| `overlap` | 硬零(权重 0.24) | +| `crossing` | crossings/cpl;`top_nodes` / `top_edges` | +| `sparsity` / `edge_axis` / `edge_clearance` | 对照人工:紧凑、正交、贴边(算法常弱于此) | +| `rings` / `chains` | 环被穿、走廊折角 | +| `verdict.total` | ≈70 可交付(ov=0);结构可读优先于刷分 | +| `score_profile` | `auto`(默认)/ `default` / `eye`;大稀疏斜边画布自动走 eye | -### 评分优化快速方法 -1. **消除 overlap** → 硬门控项,必须为 0 -2. **减少 crossings** → polish_crossings 大幅降低 -3. **处理 edge_clearance** → clear_edge_hits 消除贴边 -4. **结构检查** → 分层清晰 > 交叉最少 +**评分修订(相对旧权)**:util 甜区下限 0.08;贴边分=节点命中∪hits/link;rings 降权;eye 再降 axis、抬 util/clearance;`compactness` 仅诊断不加权。 -图标 25px;推荐中心距 Δx≥200、Δy≥170。交叉 = 无向 NE↔NE 真交叉(共端点不算)。 +图标 25px;推荐中心距 Δx≥200、Δy≥170。 --- @@ -178,59 +347,25 @@ sinkTopologyDualUnits({ | 工具 | 作用 | |------|------| | `getTopologyTree` / `getTopologyView` | 树与画布 | -| `createTopologyFolder` | 新建根/区域(返回 view_id) | +| `createTopologyFolder` | 新建根/区域 | | `add` / `remove` / `updateTopologyViewPositions` | 成员与手拖 | -| `sinkTopologyDualUnits` | dual_units 分批沉入 | -| `copyTopologyViewNodes` | 克隆沙箱 | +| `sinkTopologyDualUnits` | **仅一次**:最大核心眼 → sink | +| `suggestSinkHubs` | **后续下沉批次**(含 orphan_batch) | +| `move_nodes`(经 layoutTopologyView) | **后续下沉** | | `projectTopologyNeighbors` | 投影邻居 | -| `queryTopologyFabricNodes` | 库存(summary\|list\|search) | +| `copyTopologyViewNodes` | 克隆沙箱 | +| `queryTopologyFabricNodes` | 库存 | | neighborhood / edges | 邻接 | | `layoutTopologyView` | 上表 action | | `analyzeTopologyViewLayout` | structure + 验收 | --- -## 通用优化策略(实战经验) - -### 算法与手动的最佳组合 -``` -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。 -2. **必须重启** stdio 进程;`catalog` 含 `rev`(当前 `NETX_MCP_REV`)。 -3. `layoutTopologyView(catalog=true)` 核对 action/recipe 清单。 +1. 本仓 MCP 用 `PYTHONPATH=…/src`(见用户 `~/.cursor/mcp.json`),改源码后不必为加载而 pip install。 +2. **必须重启** stdio 进程(杀旧 `python -m netx_topology_mcp` 后由 Cursor 拉起;或 MCP 面板禁用/启用)。 +3. `layoutTopologyView(catalog=true)` 核对:`version` / `rev`(`NETX_MCP_REV`)+ action 含 `pull_far_chains` / `compact_bbox`。 +4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。 拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。 diff --git a/alembic/versions/20260811_fabric_level.py b/alembic/versions/20260811_fabric_level.py new file mode 100644 index 0000000..465c0b1 --- /dev/null +++ b/alembic/versions/20260811_fabric_level.py @@ -0,0 +1,54 @@ +"""Add topo_fabric_node.level for layout classification. + +Revision ID: 20260811_fabric_level +Revises: 20260806_auth_refresh +Create Date: 2026-08-11 +""" + +from __future__ import annotations + +from typing import Sequence, Union + +from alembic import op + +revision: str = "20260811_fabric_level" +down_revision: Union[str, Sequence[str], None] = "20260806_auth_refresh" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + dialect = str(getattr(bind.dialect, "name", "") or "").lower() + if dialect.startswith("postgres"): + op.execute( + "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION" + ) + op.execute( + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)" + ) + else: + # SQLite / others: best-effort + try: + op.execute("ALTER TABLE topo_fabric_node ADD COLUMN level REAL") + except Exception: + pass + try: + op.execute( + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)" + ) + except Exception: + pass + + +def downgrade() -> None: + bind = op.get_bind() + dialect = str(getattr(bind.dialect, "name", "") or "").lower() + if dialect.startswith("postgres"): + op.execute("DROP INDEX IF EXISTS ix_topo_fabric_node_level") + op.execute("ALTER TABLE topo_fabric_node DROP COLUMN IF EXISTS level") + else: + try: + op.drop_column("topo_fabric_node", "level") + except Exception: + pass diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index 94a5040..b149c10 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -83,9 +83,10 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog | `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) | | `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` | | `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) | +| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** | | `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`) | +| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` | ### 写(只动画布,不污染 Fabric) @@ -98,7 +99,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog | `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[]` 仅少量微调 | -| `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` | +| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` | | `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` | **推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。 @@ -120,7 +121,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog | 包 | server_id | 职责 | |----|-----------|------| | `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) | -| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**14** 工具) | +| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) | `queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。 diff --git a/netx_api/app_shutdown.py b/netx_api/app_shutdown.py index 70fd598..0975a17 100644 --- a/netx_api/app_shutdown.py +++ b/netx_api/app_shutdown.py @@ -31,6 +31,13 @@ def shutdown_runtime(*, reason: str = "lifespan") -> None: except Exception: # noqa: BLE001 _log.exception("stop_lldp_collect_scheduler failed") + try: + from .ne_collect_scheduler import stop_ne_collect_scheduler + + stop_ne_collect_scheduler() + except Exception: # noqa: BLE001 + _log.exception("stop_ne_collect_scheduler failed") + try: from .port_traffic_scheduler import stop_port_traffic_scheduler diff --git a/netx_api/app_startup.py b/netx_api/app_startup.py index 7e2f82e..4552557 100644 --- a/netx_api/app_startup.py +++ b/netx_api/app_startup.py @@ -115,11 +115,17 @@ def run_api_startup() -> None: if cfg_resumed: _log.info("startup: resumed %s config_sync task(s) from interrupted cycle", cfg_resumed) try: + from .collection_policy import ensure_policy as ensure_ne_collect_policy + from .collection_policy import history_keep_value, prune_collection_jobs from .lldp_collect_service import ensure_policy as ensure_lldp_collect_policy ensure_lldp_collect_policy(db) + pol = ensure_ne_collect_policy(db) + pruned_jobs = prune_collection_jobs(db, keep=history_keep_value(pol)) + if pruned_jobs: + _log.info("startup: pruned %s ne_collection job(s) by history_keep", pruned_jobs) except Exception: - _log.exception("startup: lldp_collect policy ensure failed") + _log.exception("startup: lldp/ne_collect policy ensure failed") pt_cleared = recover_port_traffic_on_startup(db) if pt_cleared: _log.info("startup: cleared %s port_traffic stuck collect_running flag(s)", pt_cleared) @@ -151,7 +157,7 @@ def run_api_startup() -> None: else: _log.info( "startup: inline schedulers disabled — run `python -m netx_api.worker` for " - "config_sync / lldp_collect / port_traffic" + "config_sync / lldp_collect / ne_collect / port_traffic" ) start_api_sideband_threads() diff --git a/netx_api/collection_job_state.py b/netx_api/collection_job_state.py index bb3ad07..d081160 100644 --- a/netx_api/collection_job_state.py +++ b/netx_api/collection_job_state.py @@ -48,6 +48,12 @@ def finalize_collection_job(db: Session, job_id: str) -> None: job.ended_at = finish_at job.last_run_at = job.ended_at or finish_at db.commit() + try: + from .collection_policy import ensure_policy, history_keep_value, prune_collection_jobs + + prune_collection_jobs(db, keep=history_keep_value(ensure_policy(db))) + except Exception: # noqa: BLE001 + pass def reconcile_stale_collection_job(db: Session, job_id: str) -> bool: diff --git a/netx_api/collection_policy.py b/netx_api/collection_policy.py new file mode 100644 index 0000000..2389fba --- /dev/null +++ b/netx_api/collection_policy.py @@ -0,0 +1,265 @@ +"""NE batch-collect policy, prune-by-count, and schedule due helpers.""" + +from __future__ import annotations + +import logging +import shutil +from datetime import datetime, timedelta +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from .collection_schemas import ( + CollectionPolicyOut, + CollectionPolicyUpdate, + CollectionTargetRef, +) +from .models import ManagedNE, NeCollectionJob, NeCollectionPolicy, NeCollectionRun, UmeInventoryNE +from .ne_collection_paths import collection_data_root +from .timeutil import utcnow_naive # used by ensure_policy.updated_at + +_log = logging.getLogger("netx.collection.policy") + +POLICY_ID = 1 +DEFAULT_HISTORY_KEEP = 3 +MAX_INTERVAL_HOURS = 8760 # 365d + + +def _utcnow() -> datetime: + return datetime.now() + + +def _normalize_interval_hours(row: NeCollectionPolicy) -> int: + hours = int(getattr(row, "interval_hours", 0) or 0) + if hours <= 0: + hours = max(1, int(row.interval_days or 1)) * 24 + return max(1, min(MAX_INTERVAL_HOURS, hours)) + + +def ensure_policy(db: Session) -> NeCollectionPolicy: + row = db.get(NeCollectionPolicy, POLICY_ID) + if row is None: + row = NeCollectionPolicy( + id=POLICY_ID, + enabled=False, + interval_days=1, + interval_hours=24, + scope_mode="all", + selected_targets=[], + title="", + commands="", + history_keep=DEFAULT_HISTORY_KEEP, + updated_at=_utcnow(), + ) + db.add(row) + db.commit() + db.refresh(row) + if int(getattr(row, "interval_hours", 0) or 0) <= 0: + row.interval_hours = max(1, int(row.interval_days or 1)) * 24 + db.commit() + db.refresh(row) + return row + + +def _targets_from_json(raw: Any) -> list[CollectionTargetRef]: + items: list[CollectionTargetRef] = [] + if not isinstance(raw, list): + return items + for x in raw: + if not isinstance(x, dict): + continue + tid = str(x.get("id") or "").strip() + if not tid: + continue + src = str(x.get("source") or "managed").strip().lower() or "managed" + if src not in {"managed", "ume"}: + src = "managed" + items.append(CollectionTargetRef(source=src, id=tid)) + return items + + +def policy_to_out(row: NeCollectionPolicy) -> CollectionPolicyOut: + hours = _normalize_interval_hours(row) + days = max(1, min(365, (hours + 23) // 24)) + keep = getattr(row, "history_keep", None) + if keep is None: + keep = DEFAULT_HISTORY_KEEP + return CollectionPolicyOut( + enabled=bool(row.enabled), + interval_days=days, + interval_hours=hours, + scope_mode="selected" if str(row.scope_mode or "") == "selected" else "all", + selected_targets=_targets_from_json(row.selected_targets), + title=str(row.title or ""), + commands=str(row.commands or ""), + history_keep=max(0, min(200, int(keep))), + updated_at=row.updated_at, + ) + + +def get_policy(db: Session) -> CollectionPolicyOut: + return policy_to_out(ensure_policy(db)) + + +def history_keep_value(row: NeCollectionPolicy | None = None) -> int: + if row is None: + return DEFAULT_HISTORY_KEEP + keep = getattr(row, "history_keep", None) + if keep is None: + keep = DEFAULT_HISTORY_KEEP + return max(0, min(200, int(keep))) + + +def prune_collection_jobs(db: Session, *, keep: int = DEFAULT_HISTORY_KEEP) -> int: + """Delete finished jobs beyond ``keep`` (newest kept). Active jobs always retained.""" + keep = max(0, min(200, int(keep))) + finished = ( + db.query(NeCollectionJob) + .filter(NeCollectionJob.status.in_(("done", "failed"))) + .order_by(NeCollectionJob.created_at.desc()) + .all() + ) + to_drop = finished if keep == 0 else finished[keep:] + if not to_drop: + return 0 + root = collection_data_root().resolve() + dropped = 0 + for job in to_drop: + jid = str(job.id) + db.query(NeCollectionRun).filter(NeCollectionRun.job_id == jid).delete( + synchronize_session=False + ) + db.delete(job) + dropped += 1 + job_dir = (root / jid).resolve() + if str(job_dir).startswith(str(root)) and job_dir.is_dir(): + shutil.rmtree(job_dir, ignore_errors=True) + if dropped: + db.commit() + _log.info("pruned %s ne_collection job(s); keep=%s", dropped, keep) + return dropped + + +def update_policy(db: Session, body: CollectionPolicyUpdate) -> CollectionPolicyOut: + row = ensure_policy(db) + data = body.model_dump(exclude_unset=True) + if "enabled" in data and data["enabled"] is not None: + row.enabled = bool(data["enabled"]) + if "interval_hours" in data and data["interval_hours"] is not None: + hours = max(1, min(MAX_INTERVAL_HOURS, int(data["interval_hours"]))) + row.interval_hours = hours + row.interval_days = max(1, min(365, (hours + 23) // 24)) + elif "interval_days" in data and data["interval_days"] is not None: + days = max(1, min(365, int(data["interval_days"]))) + row.interval_days = days + row.interval_hours = days * 24 + if "scope_mode" in data and data["scope_mode"] is not None: + mode = str(data["scope_mode"] or "").strip().lower() + if mode not in {"all", "selected"}: + raise HTTPException(status_code=400, detail="invalid_scope_mode") + row.scope_mode = mode + if "selected_targets" in data and data["selected_targets"] is not None: + cleaned: list[dict[str, str]] = [] + for ref in data["selected_targets"] or []: + if isinstance(ref, CollectionTargetRef): + tid = ref.id.strip() + src = (ref.source or "managed").strip().lower() or "managed" + elif isinstance(ref, dict): + tid = str(ref.get("id") or "").strip() + src = str(ref.get("source") or "managed").strip().lower() or "managed" + else: + continue + if not tid: + continue + if src not in {"managed", "ume"}: + src = "managed" + cleaned.append({"source": src, "id": tid}) + row.selected_targets = cleaned + if "title" in data and data["title"] is not None: + row.title = str(data["title"] or "").strip()[:256] + if "commands" in data and data["commands"] is not None: + row.commands = str(data["commands"] or "") + if "history_keep" in data and data["history_keep"] is not None: + row.history_keep = max(0, min(200, int(data["history_keep"]))) + if bool(row.enabled): + from .collection_service import _parse_commands + + if not _parse_commands(str(row.commands or "")): + raise HTTPException(status_code=400, detail="commands_required_for_schedule") + if str(row.scope_mode or "") == "selected" and not (row.selected_targets or []): + raise HTTPException(status_code=400, detail="no_selected_targets") + row.updated_at = _utcnow() + db.commit() + db.refresh(row) + prune_collection_jobs(db, keep=history_keep_value(row)) + return policy_to_out(row) + + +def next_due_at(db: Session, policy: NeCollectionPolicy | None = None) -> datetime | None: + """Due time based on last *scheduled* successful collect only (manual must not reset).""" + pol = policy or ensure_policy(db) + if not pol.enabled: + return None + hours = _normalize_interval_hours(pol) + last = ( + db.query(NeCollectionJob) + .filter( + NeCollectionJob.status == "done", + NeCollectionJob.trigger_mode == "schedule", + NeCollectionJob.ended_at.isnot(None), + ) + .order_by(NeCollectionJob.ended_at.desc()) + .first() + ) + if last is None or last.ended_at is None: + return _utcnow() + return last.ended_at + timedelta(hours=hours) + + +def expand_policy_targets(db: Session, policy: NeCollectionPolicy) -> list[tuple[str, str, str, str]]: + """Return list of (source, id, name, ip) for a policy.""" + from .device_types import WEBCRT_NE_SOURCE + + mode = str(policy.scope_mode or "all").strip().lower() + out: list[tuple[str, str, str, str]] = [] + seen: set[tuple[str, str]] = set() + + def _add(source: str, tid: str, name: str, ip: str) -> None: + key = (source, tid) + if key in seen: + return + seen.add(key) + out.append((source, tid, name, ip)) + + if mode == "selected": + for ref in _targets_from_json(policy.selected_targets): + if ref.source == "managed": + ne = db.get(ManagedNE, ref.id) + if ne: + _add( + "managed", + str(ne.id), + str(ne.name or ne.ip_address or ""), + str(ne.ip_address or ""), + ) + else: + inv = db.get(UmeInventoryNE, ref.id) + if inv: + name = str(inv.host_name or inv.user_label or inv.ne_name or inv.ip_address or inv.ne_id) + _add("ume", str(inv.ne_id), name, str(inv.ip_address or "")) + return out + + for ne in ( + db.query(ManagedNE) + .filter(ManagedNE.source != WEBCRT_NE_SOURCE) + .order_by(ManagedNE.name.asc()) + .all() + ): + _add("managed", str(ne.id), str(ne.name or ne.ip_address or ""), str(ne.ip_address or "")) + for inv in db.query(UmeInventoryNE).order_by(UmeInventoryNE.host_name.asc()).all(): + if not str(inv.ip_address or "").strip(): + continue + name = str(inv.host_name or inv.user_label or inv.ne_name or inv.ip_address or inv.ne_id) + _add("ume", str(inv.ne_id), name, str(inv.ip_address or "")) + return out diff --git a/netx_api/collection_router.py b/netx_api/collection_router.py index 6470650..7e25067 100644 --- a/netx_api/collection_router.py +++ b/netx_api/collection_router.py @@ -4,8 +4,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, Query from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session +from .collection_policy import get_policy, update_policy +from .collection_schemas import CollectionJobCreate, CollectionPolicyUpdate from .collection_service import ( build_collection_job_zip, + create_and_start_from_policy, create_collection, delete_collection_job, get_collection_dashboard, @@ -19,7 +22,6 @@ from .collection_service import ( start_collection_job, retry_failed_collection_job, ) -from .collection_schemas import CollectionJobCreate from .db import get_db from .models import NeCollectionRun from .ne_collect_runner import dispatch_collection_runs @@ -42,6 +44,32 @@ def api_collection_dashboard(db: Session = Depends(get_db)): return get_collection_dashboard(db).model_dump() +@router.get("/policy") +def api_get_collection_policy(db: Session = Depends(get_db)): + return get_policy(db).model_dump() + + +@router.put("/policy") +def api_put_collection_policy(body: CollectionPolicyUpdate, db: Session = Depends(get_db)): + return update_policy(db, body).model_dump() + + +@router.post("/start-from-policy") +def api_start_from_policy( + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), +): + """Create + start a collect job from the saved policy (manual trigger).""" + out, payload = create_and_start_from_policy(db, trigger_mode="manual") + background_tasks.add_task( + dispatch_collection_runs, + payload["job_id"], + payload["run_ids"], + payload["commands"], + ) + return out.model_dump() + + @router.post("") def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_db)): return create_collection(db, body).model_dump() @@ -51,9 +79,13 @@ def api_create_collection(body: CollectionJobCreate, db: Session = Depends(get_d def api_list_collections( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ): - return list_collection_jobs(db, page=page, page_size=page_size) + return list_collection_jobs( + db, page=page, page_size=page_size, status=status, keyword=keyword + ) @router.get("/runs/{run_id}/download") diff --git a/netx_api/collection_schemas.py b/netx_api/collection_schemas.py index 040ab47..428bc09 100644 --- a/netx_api/collection_schemas.py +++ b/netx_api/collection_schemas.py @@ -5,6 +5,34 @@ from datetime import datetime from pydantic import BaseModel, Field +class CollectionTargetRef(BaseModel): + source: str = "managed" # managed | ume + id: str + + +class CollectionPolicyOut(BaseModel): + enabled: bool = False + interval_days: int = 1 + interval_hours: int = 24 + scope_mode: str = "all" + selected_targets: list[CollectionTargetRef] = Field(default_factory=list) + title: str = "" + commands: str = "" + history_keep: int = 3 + updated_at: datetime | None = None + + +class CollectionPolicyUpdate(BaseModel): + enabled: bool | None = None + interval_days: int | None = Field(default=None, ge=1, le=365) + interval_hours: int | None = Field(default=None, ge=1, le=8760) + scope_mode: str | None = None + selected_targets: list[CollectionTargetRef] | None = None + title: str | None = None + commands: str | None = None + history_keep: int | None = Field(default=None, ge=0, le=200) + + class CollectionJobCreate(BaseModel): title: str = "" commands: str = Field(min_length=1) @@ -31,6 +59,7 @@ class CollectionJobOut(BaseModel): id: str title: str commands: str + trigger_mode: str = "manual" status: str ne_count: int success_count: int @@ -47,6 +76,7 @@ class CollectionJobSummary(BaseModel): id: str title: str status: str + trigger_mode: str = "manual" ne_count: int success_count: int fail_count: int @@ -61,3 +91,5 @@ class CollectionDashboardOut(BaseModel): active_count: int = 0 running_job: CollectionJobSummary | None = None last_job: CollectionJobSummary | None = None + next_due_at: datetime | None = None + policy: CollectionPolicyOut | None = None diff --git a/netx_api/collection_service.py b/netx_api/collection_service.py index 75fee0b..660afff 100644 --- a/netx_api/collection_service.py +++ b/netx_api/collection_service.py @@ -20,6 +20,14 @@ from .collection_job_state import ( sync_job_progress, _sync_job_counts, ) +from .collection_policy import ( + ensure_policy, + expand_policy_targets, + history_keep_value, + next_due_at, + policy_to_out, + prune_collection_jobs, +) from .collection_schemas import ( CollectionDashboardOut, CollectionJobCreate, @@ -87,6 +95,7 @@ def job_to_out(row: NeCollectionJob, *, output_count: int | None = None) -> Coll id=str(row.id), title=str(row.title or ""), commands=str(row.commands or ""), + trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"), status=str(row.status or "pending"), ne_count=int(row.ne_count or 0), success_count=int(row.success_count or 0), @@ -107,6 +116,7 @@ def job_to_summary(row: NeCollectionJob | None) -> CollectionJobSummary | None: id=str(row.id), title=str(row.title or "").strip() or str(row.id)[:8], status=str(row.status or "pending"), + trigger_mode=str(getattr(row, "trigger_mode", None) or "manual"), ne_count=int(row.ne_count or 0), success_count=int(row.success_count or 0), fail_count=int(row.fail_count or 0), @@ -162,11 +172,14 @@ def get_collection_dashboard(db: Session) -> CollectionDashboardOut: or 0 ) last = last_finished_collection_job(db) + policy = ensure_policy(db) return CollectionDashboardOut( job_count=job_count, active_count=active_count, running_job=job_to_summary(running), last_job=job_to_summary(last), + next_due_at=next_due_at(db, policy), + policy=policy_to_out(policy), ) @@ -318,6 +331,7 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu job = NeCollectionJob( title=str(body.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}", commands="\n".join(commands), + trigger_mode="manual", status="pending", ne_count=len(targets), created_at=now, @@ -342,8 +356,81 @@ def create_collection(db: Session, body: CollectionJobCreate) -> CollectionJobOu return job_to_out(job, output_count=0) -def list_collection_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: +def create_and_start_from_policy( + db: Session, + *, + trigger_mode: str = "manual", +) -> tuple[CollectionJobOut, CollectionSchedulePayload]: + """Create a job from the singleton policy and start it immediately.""" + if has_active_collection_job(db) is not None: + raise HTTPException(status_code=409, detail="collection_job_running") + policy = ensure_policy(db) + commands = _parse_commands(str(policy.commands or "")) + if not commands: + raise HTTPException(status_code=400, detail="commands_empty") + targets = expand_policy_targets(db, policy) + if not targets: + raise HTTPException(status_code=400, detail="no_eligible_ne") + mode = str(trigger_mode or "manual").strip().lower() or "manual" + if mode not in {"manual", "schedule"}: + mode = "manual" + now = _now() + title = str(policy.title or "").strip() or f"collect-{now.strftime('%Y%m%d-%H%M%S')}" + job = NeCollectionJob( + title=title, + commands="\n".join(commands), + trigger_mode=mode, + status="pending", + ne_count=len(targets), + created_at=now, + started_at=None, + last_run_at=None, + ) + db.add(job) + db.flush() + for source, tid, name, ip in targets: + db.add( + NeCollectionRun( + job_id=str(job.id), + ne_id=tid, + ne_source=source, + ne_name=name, + ne_ip=ip, + status="pending", + ) + ) + db.commit() + db.refresh(job) + out, payload = start_collection_job(db, str(job.id)) + try: + prune_collection_jobs(db, keep=history_keep_value(policy)) + except Exception: # noqa: BLE001 + _log.exception("prune_collection_jobs after start failed") + return out, payload + + +def list_collection_jobs( + db: Session, + *, + page: int = 1, + page_size: int = 20, + status: str = "", + keyword: str = "", +) -> dict[str, Any]: stmt = db.query(NeCollectionJob) + st = str(status or "").strip() + if st: + stmt = stmt.filter(NeCollectionJob.status == st) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + stmt = stmt.filter( + or_( + NeCollectionJob.title.ilike(like), + NeCollectionJob.id.ilike(like), + NeCollectionJob.error_message.ilike(like), + ) + ) total = int(stmt.count()) rows = stmt.order_by(NeCollectionJob.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all() for row in rows: diff --git a/netx_api/config.py b/netx_api/config.py index 83c95b6..1e81650 100644 --- a/netx_api/config.py +++ b/netx_api/config.py @@ -81,6 +81,10 @@ class Settings(BaseSettings): ne_collect_pending_stale_sec: int = 180 ne_collect_run_timeout_cap_sec: int = 600 ne_collection_data_dir: str = "data/ne_collections" + # Batch CLI collect scheduler (policy.enabled defaults False — manual until turned on). + ne_collect_scheduler_enabled: bool = True + ne_collect_scheduler_tick_sec: int = 60 + ne_collect_startup_grace_sec: int = 3600 # Config sync (periodic running-config backup into DB) config_sync_scheduler_enabled: bool = True config_sync_scheduler_tick_sec: int = 60 diff --git a/netx_api/config_sync_cycles.py b/netx_api/config_sync_cycles.py index a02de81..3b90bb3 100644 --- a/netx_api/config_sync_cycles.py +++ b/netx_api/config_sync_cycles.py @@ -6,7 +6,7 @@ from typing import Any from uuid import uuid4 from fastapi import HTTPException -from sqlalchemy import func +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from .cli_resolve import cli_profile_ready @@ -252,8 +252,29 @@ def create_cycle(db: Session, body: ConfigSyncCycleCreate) -> ConfigSyncCycleOut return cycle_to_out(cycle) -def list_cycles(db: Session, *, page: int, page_size: int) -> dict[str, Any]: - q = db.query(ConfigSyncCycle).order_by(ConfigSyncCycle.created_at.desc()) +def list_cycles( + db: Session, + *, + page: int, + page_size: int, + status: str = "", + keyword: str = "", +) -> dict[str, Any]: + q = db.query(ConfigSyncCycle) + st = str(status or "").strip() + if st: + q = q.filter(ConfigSyncCycle.status == st) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + ConfigSyncCycle.id.ilike(like), + ConfigSyncCycle.trigger_mode.ilike(like), + ConfigSyncCycle.error_message.ilike(like), + ) + ) + q = q.order_by(ConfigSyncCycle.created_at.desc()) total = int(q.count()) rows = q.offset((page - 1) * page_size).limit(page_size).all() return {"total": total, "page": page, "page_size": page_size, "items": [cycle_to_out(r) for r in rows]} diff --git a/netx_api/config_sync_router.py b/netx_api/config_sync_router.py index f397a80..375f241 100644 --- a/netx_api/config_sync_router.py +++ b/netx_api/config_sync_router.py @@ -74,9 +74,11 @@ def api_dashboard(db: Session = Depends(get_db)): def api_list_cycles( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ): - return list_cycles(db, page=page, page_size=page_size) + return list_cycles(db, page=page, page_size=page_size, status=status, keyword=keyword) @router.post("/cycles") diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py index 78c1bd7..addfcf2 100644 --- a/netx_api/lldp_collect_router.py +++ b/netx_api/lldp_collect_router.py @@ -67,9 +67,11 @@ def api_stop_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: def api_list_jobs( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ) -> dict[str, Any]: - return list_jobs(db, page=page, page_size=page_size) + return list_jobs(db, page=page, page_size=page_size, status=status, keyword=keyword) @router.get("/jobs/{job_id}") @@ -77,6 +79,15 @@ def api_get_job( job_id: str, page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ) -> dict[str, Any]: - return get_job_detail(db, job_id, page=page, page_size=page_size) + return get_job_detail( + db, + job_id, + page=page, + page_size=page_size, + item_status=status, + item_keyword=keyword, + ) diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py index 5cd0b01..e5eb63b 100644 --- a/netx_api/lldp_collect_service.py +++ b/netx_api/lldp_collect_service.py @@ -481,10 +481,32 @@ def get_dashboard(db: Session) -> LldpCollectDashboardOut: ) -def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict: +def list_jobs( + db: Session, + *, + page: int = 1, + page_size: int = 20, + status: str = "", + keyword: str = "", +) -> dict: page = max(1, int(page or 1)) page_size = max(1, min(100, int(page_size or 20))) - q = db.query(TopoDiscoverJob).order_by(TopoDiscoverJob.created_at.desc()) + q = db.query(TopoDiscoverJob) + st = str(status or "").strip() + if st: + q = q.filter(TopoDiscoverJob.status == st) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + TopoDiscoverJob.id.ilike(like), + TopoDiscoverJob.error.ilike(like), + TopoDiscoverJob.scope.ilike(like), + TopoDiscoverJob.trigger_mode.ilike(like), + ) + ) + q = q.order_by(TopoDiscoverJob.created_at.desc()) total = int(q.count()) rows = q.offset((page - 1) * page_size).limit(page_size).all() outcomes = _job_outcome_map(db, [r.id for r in rows]) @@ -503,6 +525,19 @@ def list_jobs(db: Session, *, page: int = 1, page_size: int = 20) -> dict: def get_job_detail( - db: Session, job_id: str, *, page: int | None = None, page_size: int | None = None + db: Session, + job_id: str, + *, + page: int | None = None, + page_size: int | None = None, + item_status: str = "", + item_keyword: str = "", ) -> dict: - return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump() + return get_discover_job( + db, + job_id, + page=page, + page_size=page_size, + item_status=item_status, + item_keyword=item_keyword, + ).model_dump() diff --git a/netx_api/metrics_router.py b/netx_api/metrics_router.py index a4138fd..031269c 100644 --- a/netx_api/metrics_router.py +++ b/netx_api/metrics_router.py @@ -111,6 +111,7 @@ def _prom_lines(metrics: dict[str, Any]) -> str: for name, key in ( ("config_sync", "netx_config_sync_scheduler_running"), ("lldp_collect", "netx_lldp_collect_scheduler_running"), + ("ne_collect", "netx_ne_collect_scheduler_running"), ("port_traffic", "netx_port_traffic_scheduler_running"), ("fabric_reconcile", "netx_fabric_reconcile_scheduler_running"), ): diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py index 2d5e34b..cf03ace 100644 --- a/netx_api/models/__init__.py +++ b/netx_api/models/__init__.py @@ -20,6 +20,7 @@ from .managed_ne import ( CliConnectProfile, ManagedNE, NeCollectionJob, + NeCollectionPolicy, NeCollectionRun, UmeCliOverride, ) @@ -82,6 +83,7 @@ __all__ = [ "CliConnectProfile", "UmeCliOverride", "NeCollectionJob", + "NeCollectionPolicy", "NeCollectionRun", "TopoFabricNode", "TopoClassifyRule", diff --git a/netx_api/models/managed_ne.py b/netx_api/models/managed_ne.py index c4c437a..03386e7 100644 --- a/netx_api/models/managed_ne.py +++ b/netx_api/models/managed_ne.py @@ -98,6 +98,28 @@ class UmeCliOverride(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) +class NeCollectionPolicy(Base): + """Singleton policy for periodic batch CLI collect (id=1). + + Default ``enabled=False``: one-shot / manual only until the operator turns on schedule. + ``history_keep`` defaults to 3 finished jobs. + """ + + __tablename__ = "ne_collection_policy" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + interval_days: Mapped[int] = mapped_column(Integer, default=1) # legacy mirror of hours + interval_hours: Mapped[int] = mapped_column(Integer, default=24) + scope_mode: Mapped[str] = mapped_column(String(32), default="all") # all | selected + selected_targets: Mapped[list] = mapped_column(_JsonType, default=list) + title: Mapped[str] = mapped_column(String(256), default="") + commands: Mapped[str] = mapped_column(Text, default="") + # Finished jobs to retain (newest kept); active jobs always kept. + history_keep: Mapped[int] = mapped_column(Integer, default=3) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) + + class NeCollectionJob(Base): """Batch CLI collection job over managed NEs.""" @@ -106,6 +128,8 @@ class NeCollectionJob(Base): id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) title: Mapped[str] = mapped_column(String(256), default="") commands: Mapped[str] = mapped_column(Text, default="") + # manual | schedule + trigger_mode: Mapped[str] = mapped_column(String(32), default="manual", index=True) status: Mapped[str] = mapped_column(String(32), default="pending", index=True) ne_count: Mapped[int] = mapped_column(Integer, default=0) success_count: Mapped[int] = mapped_column(Integer, default=0) diff --git a/netx_api/models/topology.py b/netx_api/models/topology.py index a6a70be..c377fee 100644 --- a/netx_api/models/topology.py +++ b/netx_api/models/topology.py @@ -26,10 +26,12 @@ class TopoFabricNode(Base): ip: Mapped[str] = mapped_column(String(128), default="", index=True) vendor: Mapped[str] = mapped_column(String(64), default="") device_type: Mapped[str] = mapped_column(String(64), default="") - # Classify tags (regex rules / manual). role: core|aggregation|access|unknown|"" + # Layout rank: major.minor (0=external … 1=core … 2=agg … 3=access). None = unclassified. + level: Mapped[float | None] = mapped_column(Float, nullable=True, index=True) + # Synced alias from floor(level): external|core|aggregation|access|edge|"" role: Mapped[str] = mapped_column(String(32), default="", index=True) region_folder_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) - # rule | manual | "" + # rule | manual | "" (applies to level; role is derived) role_source: Mapped[str] = mapped_column(String(16), default="") region_source: Mapped[str] = mapped_column(String(16), default="") # Composed flat-world coordinates (packed per-SBN local layouts). Not raw UME xPos/yPos. @@ -47,15 +49,15 @@ class TopoClassifyRule(Base): __tablename__ = "topo_classify_rule" id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) - # role | region - scope: Mapped[str] = mapped_column(String(16), default="role", index=True) + # level | region (legacy scope "role" accepted as alias of level) + scope: Mapped[str] = mapped_column(String(16), default="level", index=True) name: Mapped[str] = mapped_column(String(256), default="") pattern: Mapped[str] = mapped_column(String(512), default="") # name | ip | name_ip match_field: Mapped[str] = mapped_column(String(32), default="name") priority: Mapped[int] = mapped_column(Integer, default=100, index=True) enabled: Mapped[bool] = mapped_column(Boolean, default=True, index=True) - # role: {role}; region: {folder_id} or {region_name_from_group} + # role: {level} or legacy {role}; region: {folder_id} or {region_name_from_group} payload: Mapped[dict] = mapped_column(_JsonType, default=dict) remark: Mapped[str] = mapped_column(String(512), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) diff --git a/netx_api/ne_collect_scheduler.py b/netx_api/ne_collect_scheduler.py new file mode 100644 index 0000000..baeb764 --- /dev/null +++ b/netx_api/ne_collect_scheduler.py @@ -0,0 +1,113 @@ +"""Background scheduler for periodic NE batch collect.""" + +from __future__ import annotations + +import logging +import threading +import time +from datetime import datetime + +from .collection_policy import ensure_policy, next_due_at +from .collection_service import create_and_start_from_policy, has_active_collection_job +from .config import settings +from .db import SessionLocal +from .ne_collect_runner import dispatch_collection_runs + +_log = logging.getLogger("netx.ne_collect.scheduler") +_stop = threading.Event() +_thread: threading.Thread | None = None +_BOOT_MONO = time.monotonic() +_last_tick_mono: float = 0.0 + + +def _utcnow() -> datetime: + # Match NeCollectionJob timestamps (collection_service uses datetime.now()). + return datetime.now() + + +def startup_grace_remaining_sec() -> float: + grace = max(0, int(getattr(settings, "ne_collect_startup_grace_sec", 3600) or 0)) + elapsed = time.monotonic() - _BOOT_MONO + return max(0.0, float(grace) - elapsed) + + +def in_startup_grace() -> bool: + return startup_grace_remaining_sec() > 0 + + +def try_start_scheduled_collect() -> str | None: + if not bool(getattr(settings, "ne_collect_scheduler_enabled", True)): + return None + if in_startup_grace(): + return None + db = SessionLocal() + try: + policy = ensure_policy(db) + if not policy.enabled: + return None + if has_active_collection_job(db) is not None: + return None + due = next_due_at(db, policy) + if due is not None and due > _utcnow(): + return None + out, payload = create_and_start_from_policy(db, trigger_mode="schedule") + job_id = str(out.id) + dispatch_collection_runs(payload["job_id"], payload["run_ids"], payload["commands"]) + _log.info("ne_collect scheduled job started id=%s", job_id) + return job_id or None + except Exception as exc: # noqa: BLE001 + db.rollback() + detail = getattr(exc, "detail", None) + if detail in { + "collection_job_running", + "commands_empty", + "no_eligible_ne", + "commands_required_for_schedule", + "no_selected_targets", + }: + _log.info("ne_collect schedule skip: %s", detail) + return None + _log.exception("ne_collect schedule start failed") + return None + finally: + db.close() + + +def _loop() -> None: + global _last_tick_mono + tick = max(15, int(getattr(settings, "ne_collect_scheduler_tick_sec", 60) or 60)) + grace = max(0, int(getattr(settings, "ne_collect_startup_grace_sec", 3600) or 0)) + _log.info("ne_collect scheduler started tick=%ss startup_grace=%ss", tick, grace) + while not _stop.is_set(): + try: + _last_tick_mono = time.monotonic() + try_start_scheduled_collect() + except Exception: + _log.exception("ne_collect scheduler tick failed") + _stop.wait(tick) + _log.info("ne_collect scheduler stopped") + + +def start_ne_collect_scheduler() -> None: + global _thread + if not bool(getattr(settings, "ne_collect_scheduler_enabled", True)): + _log.info("ne_collect scheduler disabled by settings") + return + if _thread is not None and _thread.is_alive(): + return + _stop.clear() + _thread = threading.Thread(target=_loop, name="ne-collect-scheduler", daemon=True) + _thread.start() + + +def stop_ne_collect_scheduler() -> None: + _stop.set() + + +def ne_collect_scheduler_status() -> dict: + now = time.monotonic() + return { + "running": bool(_thread is not None and _thread.is_alive()), + "last_tick_age_sec": (now - _last_tick_mono) if _last_tick_mono else None, + "startup_grace_remaining_sec": round(startup_grace_remaining_sec(), 1), + } diff --git a/netx_api/port_traffic_devices.py b/netx_api/port_traffic_devices.py index 8982b50..e28ca4d 100644 --- a/netx_api/port_traffic_devices.py +++ b/netx_api/port_traffic_devices.py @@ -7,6 +7,7 @@ from typing import Any from uuid import uuid4 from fastapi import HTTPException +from sqlalchemy import or_ from sqlalchemy.orm import Session from .cli_creds import require_cli_creds_ready @@ -51,8 +52,30 @@ from .port_traffic_schemas import ( _log = logging.getLogger("netx.port_traffic.service") -def list_devices(db: Session, *, page: int = 1, page_size: int = 20) -> dict[str, Any]: - q = db.query(PortTrafficDevice).order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc()) +def list_devices( + db: Session, + *, + page: int = 1, + page_size: int = 20, + status: str = "", + keyword: str = "", +) -> dict[str, Any]: + q = db.query(PortTrafficDevice) + st = str(status or "").strip() + if st: + q = q.filter(PortTrafficDevice.status == st) + kw = str(keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + PortTrafficDevice.ne_name.ilike(like), + PortTrafficDevice.ne_ip.ilike(like), + PortTrafficDevice.ne_id.ilike(like), + PortTrafficDevice.vendor.ilike(like), + ) + ) + q = q.order_by(PortTrafficDevice.ne_name.asc(), PortTrafficDevice.ne_ip.asc()) total = q.count() rows = q.offset((page - 1) * page_size).limit(page_size).all() return { diff --git a/netx_api/port_traffic_router.py b/netx_api/port_traffic_router.py index ac853cb..8333841 100644 --- a/netx_api/port_traffic_router.py +++ b/netx_api/port_traffic_router.py @@ -163,9 +163,11 @@ def api_delete_board( def api_list_devices( page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ): - return list_devices(db, page=page, page_size=page_size) + return list_devices(db, page=page, page_size=page_size, status=status, keyword=keyword) @router.post("/devices") diff --git a/netx_api/scheduler_heartbeat.py b/netx_api/scheduler_heartbeat.py index 2acb8fb..5683ca0 100644 --- a/netx_api/scheduler_heartbeat.py +++ b/netx_api/scheduler_heartbeat.py @@ -55,6 +55,12 @@ def local_device_scheduler_status(*, role: str = "unknown") -> dict[str, Any]: out["lldp_collect"] = lldp_collect_scheduler_status() except Exception: # noqa: BLE001 out["lldp_collect"] = {"running": False, "error": "unavailable"} + try: + from .ne_collect_scheduler import ne_collect_scheduler_status + + out["ne_collect"] = ne_collect_scheduler_status() + except Exception: # noqa: BLE001 + out["ne_collect"] = {"running": False, "error": "unavailable"} try: from .port_traffic_scheduler import port_traffic_scheduler_status diff --git a/netx_api/schema_patches.py b/netx_api/schema_patches.py index 120956c..74d5ac2 100644 --- a/netx_api/schema_patches.py +++ b/netx_api/schema_patches.py @@ -154,6 +154,7 @@ def apply_topology_schema_safety_net(conn: Connection) -> None: ) _run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION") _run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION") + _run_sql(conn, "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION") _run_sql( conn, "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)", @@ -162,6 +163,10 @@ def apply_topology_schema_safety_net(conn: Connection) -> None: conn, "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_y ON topo_fabric_node (world_y)", ) + _run_sql( + conn, + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)", + ) # UME link ifnames — required by topology sync/apply; missing when DB predates the columns # or Alembic was stamped head without running domain patches. _run_sql( @@ -329,6 +334,7 @@ def apply_domain_schema_patches(conn: Connection) -> None: "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS last_run_at TIMESTAMP", "UPDATE ne_collection_job SET last_run_at = COALESCE(ended_at, started_at, created_at) " "WHERE last_run_at IS NULL", + "ALTER TABLE ne_collection_job ADD COLUMN IF NOT EXISTS trigger_mode VARCHAR(32) DEFAULT 'manual'", "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_color VARCHAR(32) DEFAULT ''", "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS stroke_width INTEGER DEFAULT 0", "ALTER TABLE topology_edge ADD COLUMN IF NOT EXISTS line_style VARCHAR(16) DEFAULT ''", diff --git a/netx_api/topology_classify_apply.py b/netx_api/topology_classify_apply.py index 1570cb4..cf08bd4 100644 --- a/netx_api/topology_classify_apply.py +++ b/netx_api/topology_classify_apply.py @@ -1,25 +1,23 @@ """Topology classify preview/apply and fabric node tagging.""" from __future__ import annotations -import re from typing import Any from fastapi import HTTPException from sqlalchemy.orm import Session -from .models import TopoClassifyRule, TopoFabricNode, TopoFolder +from .models import TopoFabricNode, TopoFolder from .topology_classify_common import ( _MATCH_FIELDS, - _ROLE_VALUES, _compile_pattern, _enabled_rules, - _ensure_region_by_name, _match_text, + _resolve_level_hit, _resolve_region_hit, - _resolve_role_hit, _utcnow, + apply_level_fields, ) -from .topology_membership import normalize_view_role +from .topology_level import level_to_role, normalize_level, role_to_level from .topology_schemas import ( ClassifyApplyOut, ClassifyPreviewOut, @@ -31,31 +29,33 @@ from .topology_schemas import ( FabricNodeTagPatch, ) + def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewOut: - role_rules = _enabled_rules(db, "role") + level_rules = _enabled_rules(db, "level") region_rules = _enabled_rules(db, "region") nodes = db.query(TopoFabricNode).order_by(TopoFabricNode.name.asc()).all() - role_matched = role_unmatched = role_conflict = 0 + level_matched = level_unmatched = level_conflict = 0 region_matched = region_unmatched = region_conflict = 0 - role_samples: list[dict[str, Any]] = [] + level_samples: list[dict[str, Any]] = [] region_samples: list[dict[str, Any]] = [] unmatched_samples: list[dict[str, Any]] = [] for n in nodes: - role, _rid, multi_r = _resolve_role_hit(n, role_rules) - if role is None: - role_unmatched += 1 + level, _rid, multi_r = _resolve_level_hit(n, level_rules) + if level is None: + level_unmatched += 1 else: - role_matched += 1 + level_matched += 1 if multi_r: - role_conflict += 1 - if len(role_samples) < sample_limit: - role_samples.append( + level_conflict += 1 + if len(level_samples) < sample_limit: + level_samples.append( { "fabric_node_id": n.id, "name": n.name, "ip": n.ip, - "role": role, + "level": level, + "role": level_to_role(level), "multi_hit": multi_r, } ) @@ -80,20 +80,24 @@ def preview_classify(db: Session, *, sample_limit: int = 20) -> ClassifyPreviewO } ) - if role is None and region_id is None and len(unmatched_samples) < sample_limit: + if level is None and region_id is None and len(unmatched_samples) < sample_limit: unmatched_samples.append( {"fabric_node_id": n.id, "name": n.name, "ip": n.ip, "vendor": n.vendor} ) return ClassifyPreviewOut( total_nodes=len(nodes), - role_matched=role_matched, - role_unmatched=role_unmatched, - role_conflicts=role_conflict, + level_matched=level_matched, + level_unmatched=level_unmatched, + level_conflicts=level_conflict, + role_matched=level_matched, + role_unmatched=level_unmatched, + role_conflicts=level_conflict, region_matched=region_matched, region_unmatched=region_unmatched, region_conflicts=region_conflict, - role_samples=role_samples, + level_samples=level_samples, + role_samples=level_samples, region_samples=region_samples, unmatched_samples=unmatched_samples, ) @@ -105,27 +109,22 @@ def apply_classify( skip_manual: bool = True, fill_empty_only: bool = False, ) -> ClassifyApplyOut: - role_rules = _enabled_rules(db, "role") + level_rules = _enabled_rules(db, "level") region_rules = _enabled_rules(db, "region") nodes = db.query(TopoFabricNode).all() - role_updated = region_updated = skipped_manual = 0 + level_updated = region_updated = skipped_manual = 0 for n in nodes: - role, _, _ = _resolve_role_hit(n, role_rules) - if role is not None: + level, _, _ = _resolve_level_hit(n, level_rules) + if level is not None: if skip_manual and str(n.role_source or "") == "manual": skipped_manual += 1 - elif fill_empty_only and str(n.role or "").strip(): + elif fill_empty_only and n.level is not None: pass else: - n.role = role - n.role_source = "rule" + apply_level_fields(n, level, source="rule") n.updated_at = _utcnow() - role_updated += 1 - elif not str(n.role or "").strip() and str(n.role_source or "") != "manual": - n.role = "unknown" - n.role_source = "rule" - n.updated_at = _utcnow() + level_updated += 1 region_id, _, _ = _resolve_region_hit(db, n, region_rules, create_missing=True) if region_id is not None and not str(region_id).startswith("new:"): @@ -141,7 +140,8 @@ def apply_classify( db.commit() return ClassifyApplyOut( - role_updated=role_updated, + level_updated=level_updated, + role_updated=level_updated, region_updated=region_updated, skipped_manual=skipped_manual, total_nodes=len(nodes), @@ -159,17 +159,19 @@ def list_unmatched( q = db.query(TopoFabricNode) k = str(kind or "any").strip().lower() - role_miss = or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown") + if k == "role": + k = "level" + level_miss = TopoFabricNode.level.is_(None) region_miss = or_( TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "", ) - if k == "role": - q = q.filter(role_miss) + if k == "level": + q = q.filter(level_miss) elif k == "region": q = q.filter(region_miss) else: - q = q.filter(or_(role_miss, region_miss)) + q = q.filter(or_(level_miss, region_miss)) total = q.count() rows = ( q.order_by(TopoFabricNode.name.asc()) @@ -195,14 +197,18 @@ def patch_fabric_node_tags( n = db.get(TopoFabricNode, fabric_node_id) if n is None: raise HTTPException(status_code=404, detail="fabric_node_not_found") - if body.role is not None: - role = str(body.role or "").strip().lower() - if role and role not in _ROLE_VALUES: - raise HTTPException(status_code=400, detail="role_invalid") - n.role = role - n.role_source = "manual" - if body.region_folder_id is not None: - fid = str(body.region_folder_id or "").strip() + data = body.model_dump(exclude_unset=True) + if "level" in data or "role" in data: + try: + if "level" in data: + lv = normalize_level(data.get("level")) + else: + lv = role_to_level(data.get("role")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + apply_level_fields(n, lv, source="manual") + if "region_folder_id" in data: + fid = str(data.get("region_folder_id") or "").strip() if fid: folder = db.get(TopoFolder, fid) if folder is None or str(folder.kind or "") != "region": @@ -245,6 +251,7 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode "fabric_node_id": n.id, "name": n.name, "ip": n.ip, + "level": n.level, "role": n.role or "", "region_folder_id": n.region_folder_id or "", "link_status": fabric_link_status(n), @@ -263,19 +270,23 @@ def match_fabric_nodes(db: Session, body: FabricNodesMatchRequest) -> FabricNode def bulk_tag_fabric_nodes( db: Session, body: FabricNodesBulkTagRequest ) -> FabricNodesBulkTagOut: - """Assign role/region after user confirms a regex or explicit selection.""" - if body.role is None and body.region_folder_id is None: - raise HTTPException(status_code=400, detail="role_or_region_required") + """Assign level/region after user confirms a regex or explicit selection.""" + data = body.model_dump(exclude_unset=True) + level_v: float | None | object = Ellipsis + if "level" in data: + try: + level_v = normalize_level(data.get("level")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + elif "role" in data: + try: + level_v = role_to_level(data.get("role")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc - role_v: str | None = None - if body.role is not None: - role_v = str(body.role or "").strip().lower() - if role_v and role_v not in _ROLE_VALUES: - raise HTTPException(status_code=400, detail="role_invalid") - - region_v: str | None = None - if body.region_folder_id is not None: - region_v = str(body.region_folder_id or "").strip() + region_v: str | None | object = Ellipsis + if "region_folder_id" in data: + region_v = str(data.get("region_folder_id") or "").strip() if region_v: folder = db.get(TopoFolder, region_v) if folder is None or str(folder.kind or "") != "region": @@ -283,6 +294,9 @@ def bulk_tag_fabric_nodes( else: region_v = "" + if level_v is Ellipsis and region_v is Ellipsis: + raise HTTPException(status_code=400, detail="level_or_region_required") + ids = [str(x).strip() for x in (body.fabric_node_ids or []) if str(x).strip()] if str(body.pattern or "").strip(): matched_nodes = _iter_regex_matches( @@ -298,11 +312,18 @@ def bulk_tag_fabric_nodes( else: raise HTTPException(status_code=400, detail="ids_or_pattern_required") + role_alias = level_to_role(level_v) if isinstance(level_v, float) else ( + "" if level_v is None else None + ) + if level_v is Ellipsis: + role_alias = None + samples = [ { "fabric_node_id": n.id, "name": n.name, "ip": n.ip, + "level": n.level, "role": n.role or "", "region_folder_id": n.region_folder_id or "", } @@ -313,19 +334,19 @@ def bulk_tag_fabric_nodes( dry_run=True, matched=len(matched_nodes), updated=0, - role=role_v, - region_folder_id=region_v, + level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type] + role=role_alias, + region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type] samples=samples, ) now = _utcnow() updated = 0 for n in matched_nodes: - if role_v is not None: - n.role = role_v - n.role_source = "manual" - if region_v is not None: - n.region_folder_id = region_v or None + if level_v is not Ellipsis: + apply_level_fields(n, level_v, source="manual") # type: ignore[arg-type] + if region_v is not Ellipsis: + n.region_folder_id = region_v or None # type: ignore[operator] n.region_source = "manual" n.updated_at = now updated += 1 @@ -334,8 +355,9 @@ def bulk_tag_fabric_nodes( dry_run=False, matched=len(matched_nodes), updated=updated, - role=role_v, - region_folder_id=region_v, + level=None if level_v is Ellipsis else level_v, # type: ignore[arg-type] + role=role_alias, + region_folder_id=None if region_v is Ellipsis else (region_v or None), # type: ignore[arg-type] samples=samples, ) @@ -343,5 +365,3 @@ def bulk_tag_fabric_nodes( def apply_classify_empty_only(db: Session) -> ClassifyApplyOut: """Incremental classify for newly synced nodes (fill empty tags only).""" return apply_classify(db, skip_manual=True, fill_empty_only=True) - - diff --git a/netx_api/topology_classify_common.py b/netx_api/topology_classify_common.py index bae8dd7..a272b14 100644 --- a/netx_api/topology_classify_common.py +++ b/netx_api/topology_classify_common.py @@ -10,23 +10,30 @@ from sqlalchemy.orm import Session from .models import TopoClassifyRule, TopoFabricNode, TopoFolder from .timeutil import utcnow_naive -from .topology_membership import VIEW_ROLES, normalize_view_role +from .topology_level import LEVEL_PRESETS, level_to_role, normalize_level, role_to_level from .topology_schemas import ( ClassifyRuleOut, TopologyFolderCreate, ) _MAX_PATTERN_LEN = 512 -_ROLE_VALUES = VIEW_ROLES | {"unknown"} _MATCH_FIELDS = frozenset({"name", "ip", "name_ip"}) -_SCOPES = frozenset({"role", "region"}) +_SCOPES = frozenset({"level", "region", "role"}) # role = legacy alias of level _SLICE_TEMPLATES = frozenset({"core_only", "core_agg", "agg_access"}) +_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", "edge", ""} def _utcnow() -> datetime: return utcnow_naive() +def _normalize_scope(scope: str) -> str: + s = str(scope or "level").strip().lower() + if s == "role": + return "level" + return s + + def _compile_pattern(pattern: str) -> re.Pattern[str]: p = str(pattern or "").strip() if not p: @@ -51,9 +58,10 @@ def _match_text(node: TopoFabricNode, match_field: str) -> str: def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut: + scope = _normalize_scope(str(row.scope or "level")) return ClassifyRuleOut( id=row.id, - scope=str(row.scope or "role"), + scope=scope, name=str(row.name or ""), pattern=str(row.pattern or ""), match_field=str(row.match_field or "name"), @@ -68,11 +76,25 @@ def _rule_out(row: TopoClassifyRule) -> ClassifyRuleOut: def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]: out = dict(payload or {}) - if scope == "role": - role = normalize_view_role(str(out.get("role") or "")) - if str(out.get("role") or "").strip().lower() not in VIEW_ROLES: - raise HTTPException(status_code=400, detail="role_payload_invalid") - return {"role": role} + scope_n = _normalize_scope(scope) + if scope_n == "level": + if "level" in out and out.get("level") is not None: + try: + lv = normalize_level(out.get("level")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if lv is None: + raise HTTPException(status_code=400, detail="level_payload_invalid") + return {"level": lv} + if "role" in out: + try: + lv = role_to_level(str(out.get("role") or "")) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if lv is None: + raise HTTPException(status_code=400, detail="level_payload_invalid") + return {"level": lv, "role": level_to_role(lv)} + raise HTTPException(status_code=400, detail="level_payload_invalid") if "folder_id" in out and str(out.get("folder_id") or "").strip(): return {"folder_id": str(out["folder_id"]).strip()} if "region_name_from_group" in out: @@ -87,9 +109,11 @@ def _validate_payload(scope: str, payload: dict[str, Any]) -> dict[str, Any]: def _enabled_rules(db: Session, scope: str) -> list[tuple[TopoClassifyRule, re.Pattern[str]]]: + scope_n = _normalize_scope(scope) + scopes = ("level", "role") if scope_n == "level" else (scope_n,) rows = ( db.query(TopoClassifyRule) - .filter(TopoClassifyRule.scope == scope, TopoClassifyRule.enabled.is_(True)) + .filter(TopoClassifyRule.scope.in_(scopes), TopoClassifyRule.enabled.is_(True)) .order_by(TopoClassifyRule.priority.asc(), TopoClassifyRule.name.asc()) .all() ) @@ -122,23 +146,50 @@ def _ensure_region_by_name(db: Session, name: str) -> TopoFolder: return folder -def _resolve_role_hit( +def _payload_level(payload: dict[str, Any] | None) -> float | None: + p = dict(payload or {}) + if "level" in p and p.get("level") is not None: + try: + return normalize_level(p.get("level")) + except ValueError: + return None + if "role" in p: + try: + return role_to_level(str(p.get("role") or "")) + except ValueError: + return None + return None + + +def _resolve_level_hit( node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]] -) -> tuple[str | None, str | None, bool]: - """Return (role, rule_id, multi_hit).""" - hits: list[tuple[str, str]] = [] +) -> tuple[float | None, str | None, bool]: + """Return (level, rule_id, multi_hit).""" + hits: list[tuple[float, str]] = [] for rule, cre in rules: text = _match_text(node, rule.match_field) if not text: continue if cre.search(text): - role = normalize_view_role(str((rule.payload or {}).get("role") or "")) - hits.append((role, rule.id)) + lv = _payload_level(rule.payload) + if lv is None: + continue + hits.append((lv, rule.id)) if not hits: return None, None, False return hits[0][0], hits[0][1], len(hits) > 1 +# Back-compat name used by older imports +def _resolve_role_hit( + node: TopoFabricNode, rules: list[tuple[TopoClassifyRule, re.Pattern[str]]] +) -> tuple[str | None, str | None, bool]: + lv, rid, multi = _resolve_level_hit(node, rules) + if lv is None: + return None, None, False + return level_to_role(lv), rid, multi + + def _resolve_region_hit( db: Session, node: TopoFabricNode, @@ -182,3 +233,8 @@ def _resolve_region_hit( return hits[0][0], hits[0][1], len(hits) > 1 +def apply_level_fields(node: TopoFabricNode, level: float | None, *, source: str) -> None: + """Write level + synced role alias.""" + node.level = level + node.role = level_to_role(level) + node.role_source = source diff --git a/netx_api/topology_classify_rules.py b/netx_api/topology_classify_rules.py index f892722..bda0f09 100644 --- a/netx_api/topology_classify_rules.py +++ b/netx_api/topology_classify_rules.py @@ -13,6 +13,7 @@ from .topology_classify_common import ( _MAX_PATTERN_LEN, _SCOPES, _compile_pattern, + _normalize_scope, _rule_out, _utcnow, _validate_payload, @@ -21,8 +22,13 @@ from .topology_schemas import ClassifyRuleCreate, ClassifyRuleOut, ClassifyRuleU def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]: q = db.query(TopoClassifyRule) - if scope.strip(): - q = q.filter(TopoClassifyRule.scope == scope.strip().lower()) + scope_f = str(scope or "").strip().lower() + if scope_f: + scope_n = _normalize_scope(scope_f) + if scope_n == "level": + q = q.filter(TopoClassifyRule.scope.in_(("level", "role"))) + else: + q = q.filter(TopoClassifyRule.scope == scope_n) rows = q.order_by( TopoClassifyRule.scope.asc(), TopoClassifyRule.priority.asc(), @@ -32,9 +38,10 @@ def list_rules(db: Session, *, scope: str = "") -> list[ClassifyRuleOut]: def create_rule(db: Session, body: ClassifyRuleCreate) -> ClassifyRuleOut: - scope = str(body.scope or "").strip().lower() - if scope not in _SCOPES: + scope_raw = str(body.scope or "").strip().lower() + if scope_raw not in _SCOPES: raise HTTPException(status_code=400, detail="scope_invalid") + scope = _normalize_scope(scope_raw) match_field = str(body.match_field or "name").strip().lower() if match_field not in _MATCH_FIELDS: raise HTTPException(status_code=400, detail="match_field_invalid") diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py index b4ca488..cf6b82f 100644 --- a/netx_api/topology_discover_common.py +++ b/netx_api/topology_discover_common.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from fastapi import HTTPException +from sqlalchemy import or_ from sqlalchemy.orm import Session from .cli_resolve import get_default_profile, infer_device_type_vendor @@ -48,6 +49,8 @@ def _job_out( include_items: bool = True, page: int | None = None, page_size: int | None = None, + item_status: str = "", + item_keyword: str = "", ) -> FabricDiscoverJobOut: items_out: list[FabricDiscoverJobItemOut] = [] items_total = 0 @@ -59,6 +62,37 @@ def _job_out( .filter(TopoDiscoverJobItem.job_id == job.id) .order_by(TopoDiscoverJobItem.created_at.asc()) ) + st = str(item_status or "").strip().lower() + if st in ("ok", "success", "pass"): + q = q.filter( + TopoDiscoverJobItem.ok.is_(True), + TopoDiscoverJobItem.parser_stub.is_(False), + TopoDiscoverJobItem.unmatched_count == 0, + ) + elif st in ("fail", "failed", "error"): + q = q.filter( + or_( + TopoDiscoverJobItem.ok.is_(False), + TopoDiscoverJobItem.parser_stub.is_(True), + ) + ) + elif st in ("warn", "warning"): + q = q.filter( + TopoDiscoverJobItem.ok.is_(True), + TopoDiscoverJobItem.parser_stub.is_(False), + TopoDiscoverJobItem.unmatched_count > 0, + ) + kw = str(item_keyword or "").strip() + if kw: + like = f"%{kw}%" + q = q.filter( + or_( + TopoDiscoverJobItem.ne_name.ilike(like), + TopoDiscoverJobItem.ne_ip.ilike(like), + TopoDiscoverJobItem.ne_id.ilike(like), + TopoDiscoverJobItem.error.ilike(like), + ) + ) items_total = int(q.count()) if page is not None and page_size is not None: items_page = max(1, int(page or 1)) @@ -121,11 +155,20 @@ def get_discover_job( *, page: int | None = None, page_size: int | None = None, + item_status: str = "", + item_keyword: str = "", ) -> FabricDiscoverJobOut: job = db.get(TopoDiscoverJob, str(job_id or "").strip()) if job is None: raise HTTPException(status_code=404, detail="discover_job_not_found") - return _job_out(db, job, page=page, page_size=page_size) + return _job_out( + db, + job, + page=page, + page_size=page_size, + item_status=item_status, + item_keyword=item_keyword, + ) def _ume_target_dict(db: Session, uid: str, default_profile: Any) -> dict[str, str] | None: diff --git a/netx_api/topology_fabric_nodes.py b/netx_api/topology_fabric_nodes.py index 0ce979e..a42f946 100644 --- a/netx_api/topology_fabric_nodes.py +++ b/netx_api/topology_fabric_nodes.py @@ -55,7 +55,13 @@ from .topology_schemas import ( def _node_out(n: TopoFabricNode) -> FabricNodeOut: + lv = getattr(n, "level", None) + try: + level_v = float(lv) if lv is not None else None + except (TypeError, ValueError): + level_v = None return FabricNodeOut( + level=level_v, role=str(getattr(n, "role", "") or ""), region_folder_id=str(getattr(n, "region_folder_id", None) or "") or None, role_source=str(getattr(n, "role_source", "") or ""), @@ -264,6 +270,8 @@ def list_fabric_nodes( *, keyword: str = "", role: str = "", + level: str = "", + level_major: str = "", region_folder_id: str = "", unmatched: str = "", link_status: str = "", @@ -271,6 +279,7 @@ def list_fabric_nodes( page_size: int = PAGE_DEFAULT, ) -> dict[str, Any]: from .topology_inventory_lifecycle import enrich_fabric_node_dicts + from .topology_level import LEVEL_PRESETS, normalize_level page = max(1, int(page or 1)) page_size = max(1, min(PAGE_MAX, int(page_size or PAGE_DEFAULT))) @@ -286,15 +295,46 @@ def list_fabric_nodes( TopoFabricNode.ume_ne_id.ilike(like), ) ) + level_raw = str(level or "").strip() + if level_raw: + try: + lv = normalize_level(level_raw) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if lv is None: + q = q.filter(TopoFabricNode.level.is_(None)) + else: + q = q.filter(TopoFabricNode.level == lv) + maj_raw = str(level_major or "").strip() + if maj_raw: + try: + maj = int(float(maj_raw)) + except ValueError as exc: + raise HTTPException(status_code=400, detail="level_major_invalid") from exc + q = q.filter( + TopoFabricNode.level.isnot(None), + TopoFabricNode.level >= float(maj), + TopoFabricNode.level < float(maj) + 1.0, + ) role_v = str(role or "").strip().lower() if role_v: - q = q.filter(TopoFabricNode.role == role_v) + if role_v in LEVEL_PRESETS and not level_raw and not maj_raw: + # Prefer major-band filter so 1.1 still matches role=core + preset_lv = LEVEL_PRESETS[role_v] + maj = int(preset_lv) + q = q.filter( + TopoFabricNode.level.isnot(None), + TopoFabricNode.level >= float(maj), + TopoFabricNode.level < float(maj) + 1.0, + ) + else: + q = q.filter(TopoFabricNode.role == role_v) region_v = str(region_folder_id or "").strip() if region_v: q = q.filter(TopoFabricNode.region_folder_id == region_v) um = str(unmatched or "").strip().lower() - if um == "role": - q = q.filter(or_(TopoFabricNode.role == "", TopoFabricNode.role == "unknown")) + if um in {"role", "level"}: + q = q.filter(TopoFabricNode.level.is_(None)) elif um == "region": q = q.filter( or_(TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "") @@ -302,8 +342,7 @@ def list_fabric_nodes( elif um == "any": q = q.filter( or_( - TopoFabricNode.role == "", - TopoFabricNode.role == "unknown", + TopoFabricNode.level.is_(None), TopoFabricNode.region_folder_id.is_(None), TopoFabricNode.region_folder_id == "", ) diff --git a/netx_api/topology_level.py b/netx_api/topology_level.py new file mode 100644 index 0000000..5cf0191 --- /dev/null +++ b/netx_api/topology_level.py @@ -0,0 +1,138 @@ +"""Fabric topology level (layout rank): major.minor, smaller = closer to external/WAN.""" + +from __future__ import annotations + +import math +from typing import Any + +# Preset alias → default level (major.0). Sub-tiers use 1.1, 2.1, … +LEVEL_PRESETS: dict[str, float] = { + "external": 0.0, + "core": 1.0, + "aggregation": 2.0, + "aggregate": 2.0, + "agg": 2.0, + "access": 3.0, + "edge": 4.0, + "cpe": 4.0, +} + +# floor(level) → synced role alias (filters / UI chips) +_MAJOR_TO_ROLE: dict[int, str] = { + 0: "external", + 1: "core", + 2: "aggregation", + 3: "access", +} + +_ROLE_VALUES = frozenset(LEVEL_PRESETS) | {"unknown", ""} + + +def normalize_level(value: Any) -> float | None: + """Parse level; empty/None → None. Snap to one decimal in [0, 99.9].""" + if value is None: + return None + if isinstance(value, str): + s = value.strip().lower() + if not s or s in {"unknown", "null", "none"}: + return None + if s in LEVEL_PRESETS: + return LEVEL_PRESETS[s] + try: + value = float(s) + except ValueError as exc: + raise ValueError("level_invalid") from exc + try: + lv = float(value) + except (TypeError, ValueError) as exc: + raise ValueError("level_invalid") from exc + if not math.isfinite(lv): + raise ValueError("level_invalid") + if lv < 0 or lv > 99.9: + raise ValueError("level_out_of_range") + return round(lv + 1e-9, 1) + + +def level_major(level: float | None) -> int | None: + if level is None: + return None + return int(math.floor(float(level))) + + +def level_to_role(level: float | None) -> str: + """Denormalized role alias for filters; empty when unclassified.""" + maj = level_major(level) + if maj is None: + return "" + if maj in _MAJOR_TO_ROLE: + return _MAJOR_TO_ROLE[maj] + if maj >= 4: + return "edge" + return "" + + +def role_to_level(role: str | None) -> float | None: + r = str(role or "").strip().lower() + if not r or r == "unknown": + return None + if r in LEVEL_PRESETS: + return LEVEL_PRESETS[r] + raise ValueError("role_invalid") + + +def coerce_level_input( + *, + level: Any = None, + role: Any = None, + level_provided: bool = False, + role_provided: bool = False, +) -> float | None | object: + """Resolve patch/bulk input. + + Returns: + - float | None: concrete level (None clears) + - Ellipsis: neither field provided + """ + if level_provided: + return normalize_level(level) + if role_provided: + return role_to_level(None if role is None else str(role)) + return Ellipsis + + +def format_level(level: float | None) -> str: + if level is None: + return "" + lv = float(level) + if abs(lv - round(lv)) < 1e-9: + return str(int(round(lv))) + return f"{lv:.1f}".rstrip("0").rstrip(".") if "." in f"{lv:.1f}" else f"{lv:.1f}" + + +def infer_layer_from_level( + level: float | None, + *, + name: str = "", + role: str | None = None, +) -> str: + """Map to layout layer key: external|core|agg|access|other.""" + maj = level_major(level) + if maj is not None: + if maj <= 0: + return "external" + if maj == 1: + return "core" + if maj == 2: + return "agg" + if maj >= 3: + return "access" + # Fallbacks when unclassified + r = str(role or "").strip().lower() + if r in LEVEL_PRESETS: + return infer_layer_from_level(LEVEL_PRESETS[r]) + import re + + 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()] diff --git a/netx_api/topology_migrate.py b/netx_api/topology_migrate.py index 36f40b3..d95b82e 100644 --- a/netx_api/topology_migrate.py +++ b/netx_api/topology_migrate.py @@ -40,9 +40,11 @@ def ensure_topology_schema(conn: Connection) -> None: "ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT 'core'", "ALTER TABLE topo_view ADD COLUMN IF NOT EXISTS sort_order INTEGER DEFAULT 0", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role VARCHAR(32) DEFAULT ''", + "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS level DOUBLE PRECISION", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_folder_id VARCHAR(64)", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS role_source VARCHAR(16) DEFAULT ''", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS region_source VARCHAR(16) DEFAULT ''", + "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_level ON topo_fabric_node (level)", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_x DOUBLE PRECISION", "ALTER TABLE topo_fabric_node ADD COLUMN IF NOT EXISTS world_y DOUBLE PRECISION", "CREATE INDEX IF NOT EXISTS ix_topo_fabric_node_world_x ON topo_fabric_node (world_x)", @@ -89,6 +91,7 @@ def ensure_topology_schema(conn: Connection) -> None: "ALTER TABLE topo_view ADD COLUMN role VARCHAR(32) DEFAULT 'core'", "ALTER TABLE topo_view ADD COLUMN sort_order INTEGER DEFAULT 0", "ALTER TABLE topo_fabric_node ADD COLUMN role VARCHAR(32) DEFAULT ''", + "ALTER TABLE topo_fabric_node ADD COLUMN level REAL", "ALTER TABLE topo_fabric_node ADD COLUMN region_folder_id VARCHAR(64)", "ALTER TABLE topo_fabric_node ADD COLUMN role_source VARCHAR(16) DEFAULT ''", "ALTER TABLE topo_fabric_node ADD COLUMN region_source VARCHAR(16) DEFAULT ''", diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 168cc86..b245cc7 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -93,8 +93,10 @@ def api_fabric_summary(db: Session = Depends(get_db)) -> dict[str, Any]: def api_fabric_nodes( keyword: str = "", role: str = "", + level: str = Query(default="", description="Exact level e.g. 1.1"), + level_major: str = Query(default="", description="Major band e.g. 2 → [2.0, 3.0)"), region_folder_id: str = "", - unmatched: str = Query(default="", description="any | role | region"), + unmatched: str = Query(default="", description="any | level | role | region"), link_status: str = Query( default="", description="linked | orphaned | managed | ume | both", @@ -107,6 +109,8 @@ def api_fabric_nodes( db, keyword=keyword, role=role, + level=level, + level_major=level_major, region_folder_id=region_folder_id, unmatched=unmatched, link_status=link_status, @@ -255,9 +259,18 @@ def api_fabric_discover_job( job_id: str, page: int = Query(default=1, ge=1), page_size: int = Query(default=20, ge=1, le=100), + status: str = Query(default=""), + keyword: str = Query(default=""), db: Session = Depends(get_db), ) -> dict[str, Any]: - return get_discover_job(db, job_id, page=page, page_size=page_size).model_dump() + return get_discover_job( + db, + job_id, + page=page, + page_size=page_size, + item_status=status, + item_keyword=keyword, + ).model_dump() # --- Tree / folders --------------------------------------------------------- @@ -520,7 +533,7 @@ def api_classify_apply( @router.get("/classify/unmatched") def api_classify_unmatched( - kind: str = Query(default="any", description="any | role | region"), + kind: str = Query(default="any", description="any | level | role | region"), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=500), db: Session = Depends(get_db), @@ -591,6 +604,10 @@ def api_generate_slices( body: SliceGenerateRequest, db: Session = Depends(get_db), ) -> dict[str, Any]: + """Deprecated for product UI: slice maps freeze custom views and bypass region canvases. + + Kept for tests / legacy clients. Prefer region folders + classify tags + MCP layout. + """ return generate_slices(db, body).model_dump() diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index efe57d1..bf7fd51 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -21,6 +21,9 @@ class FabricNodeOut(BaseModel): ip: str = "" vendor: str = "" device_type: str = "" + # Layout rank (major.minor). None = unclassified. + level: float | None = None + # Synced alias from floor(level): external|core|aggregation|access|edge|"" role: str = "" region_folder_id: str | None = None role_source: str = "" @@ -483,7 +486,7 @@ class ClassifyRuleOut(BaseModel): class ClassifyRuleCreate(BaseModel): - scope: str = Field(default="role", description="role | region") + scope: str = Field(default="level", description="level | region (role accepted as level alias)") name: str = "" pattern: str match_field: str = "name" @@ -505,26 +508,33 @@ class ClassifyRuleUpdate(BaseModel): class ClassifyPreviewOut(BaseModel): total_nodes: int = 0 + level_matched: int = 0 + level_unmatched: int = 0 + level_conflicts: int = 0 + # Legacy aliases role_matched: int = 0 role_unmatched: int = 0 role_conflicts: int = 0 region_matched: int = 0 region_unmatched: int = 0 region_conflicts: int = 0 + level_samples: list[dict[str, Any]] = Field(default_factory=list) role_samples: list[dict[str, Any]] = Field(default_factory=list) region_samples: list[dict[str, Any]] = Field(default_factory=list) unmatched_samples: list[dict[str, Any]] = Field(default_factory=list) class ClassifyApplyOut(BaseModel): - role_updated: int = 0 + level_updated: int = 0 + role_updated: int = 0 # alias of level_updated region_updated: int = 0 skipped_manual: int = 0 total_nodes: int = 0 class FabricNodeTagPatch(BaseModel): - role: str | None = None + level: float | None = None + role: str | None = None # preset alias → level region_folder_id: str | None = None @@ -545,12 +555,13 @@ class FabricNodesMatchOut(BaseModel): class FabricNodesBulkTagRequest(BaseModel): - """Assign role/region to explicit ids or to an ephemeral regex match.""" + """Assign level/region to explicit ids or to an ephemeral regex match.""" fabric_node_ids: list[str] = Field(default_factory=list) pattern: str = "" match_field: str = "name" - role: str | None = None + level: float | None = None + role: str | None = None # preset → level when level omitted region_folder_id: str | None = None dry_run: bool = False @@ -559,6 +570,7 @@ class FabricNodesBulkTagOut(BaseModel): dry_run: bool = False matched: int = 0 updated: int = 0 + level: float | None = None role: str | None = None region_folder_id: str | None = None samples: list[dict[str, Any]] = Field(default_factory=list) diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index 197e510..f59a68e 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -467,7 +467,17 @@ def _apply_fabric_filters( ) role_v = str(role or "").strip().lower() if role_v: - q = q.filter(TopoFabricNode.role == role_v) + from .topology_level import LEVEL_PRESETS + + if role_v in LEVEL_PRESETS: + maj = int(LEVEL_PRESETS[role_v]) + q = q.filter( + TopoFabricNode.level.isnot(None), + TopoFabricNode.level >= float(maj), + TopoFabricNode.level < float(maj) + 1.0, + ) + else: + q = q.filter(TopoFabricNode.role == role_v) vendor_v = str(vendor or "").strip() if vendor_v: q = q.filter(TopoFabricNode.vendor.ilike(f"%{vendor_v}%")) diff --git a/netx_api/ume_runtime.py b/netx_api/ume_runtime.py index f783194..67ef009 100644 --- a/netx_api/ume_runtime.py +++ b/netx_api/ume_runtime.py @@ -1,6 +1,6 @@ """UME / long-task runtime helpers shared by API and optional worker process. -Device collectors (config_sync / LLDP / port_traffic) run via ``start_device_schedulers`` +Device collectors (config_sync / LLDP / ne_collect / port_traffic) run via ``start_device_schedulers`` (API inline by default; set ``NETX_RUN_INLINE_SCHEDULERS=false`` and run ``python -m netx_api.worker`` for a split process). API process also owns UME keepalive, alarm WSS, current-alarm/inventory sync loops, @@ -53,11 +53,13 @@ def start_device_schedulers() -> None: from .config_sync_scheduler import start_config_sync_scheduler from .fabric_reconcile_scheduler import start_fabric_reconcile_scheduler from .lldp_collect_scheduler import start_lldp_collect_scheduler + from .ne_collect_scheduler import start_ne_collect_scheduler from .port_traffic_scheduler import start_port_traffic_scheduler from .scheduler_heartbeat import start_scheduler_heartbeat_publisher start_config_sync_scheduler() start_lldp_collect_scheduler() + start_ne_collect_scheduler() start_port_traffic_scheduler() start_fabric_reconcile_scheduler() # Publish status so API /metrics can see collectors when run in a split worker. 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 543839c..f048d36 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/__init__.py @@ -1,5 +1,5 @@ """netx topology MCP — canvas / fabric tools for drawing topology maps.""" -__version__ = "0.1.20" +__version__ = "0.1.51" # Bump when public catalog/actions change so agents know to restart stdio. -NETX_MCP_REV = "2026-08-09-fabric-merge" +NETX_MCP_REV = "2026-08-12-from-scratch-polish" 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 1123f39..581510e 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 @@ -39,6 +39,7 @@ _FABRIC_KEEP = ( "ip", "vendor", "device_type", + "level", "role", "link_status", "region_folder_id", @@ -278,6 +279,7 @@ def _analyze_one_view( detail: str = "summary", sight_limit: int = 40, sight_cell: float = 600.0, + score_profile: str = "auto", ) -> dict[str, Any]: graph = _data(http_json("GET", f"/v1/topology/views/{view_id}")) if not graph.get("ok"): @@ -285,7 +287,9 @@ def _analyze_one_view( 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) + stats = analyze_layout_stats( + nodes, edges, with_meta=with_meta, score_profile=score_profile + ) report = dict(stats.get("report") or {}) out: dict[str, Any] = { "ok": True, @@ -471,7 +475,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: "layout_dual_unit", "polish_crossings", "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", + "align_to_reference", "orbit_sweep", + "level_bands", "move_nodes", "sink_nodes", "job_status", @@ -483,9 +492,14 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: "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." + "level_bands|polish_crossings|clear_edge_hits|compact_bbox|" + "pull_far_chains|align_reference|" + "fix_overlaps|untangle|straighten_channels|job_status|job_cancel. " + "Main path: sinkTopologyDualUnits(max_units=1) → " + "move_nodes(park) → until_limit → clear → pull_far_chains → " + "compact_bbox → hand. Default: no template. " + "align_reference = same-network debug only, not delivery. " + "Eye sinks: no polish/fix_overlaps/round." ), **list_layout_catalog(), } @@ -497,6 +511,10 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: if action in {"move_nodes", "sink_nodes"}: return _move_topology_view_nodes(args) + # Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM. + if action in {"align_reference", "align_to_reference"}: + return _align_topology_reference(args) + # Background job poll / cancel (no view_id required). if action in {"job_status", "job_cancel"}: job_id = str( @@ -597,6 +615,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: "layout_dual_unit", "polish_crossings", "clear_edge_hits", + "compact_bbox", + "pull_far_chains", "orbit_sweep", }: source_id = view_id @@ -850,17 +870,28 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: force_sync = _truthy((overrides or {}).get("sync")) or _truthy( (overrides or {}).get("_force_sync") ) + until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy( + (overrides or {}).get("until_stall") + ) heavy_bg = action in { "polish_crossings", "orbit_sweep", "clear_edge_hits", + "compact_bbox", + "pull_far_chains", "layout_dual_unit", } + # until_limit loops many orbit sweeps — background earlier than one-shot polish. + bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600 if ( mode == "apply" and heavy_bg and not force_sync - and (len(nodes) >= 600 or _truthy((overrides or {}).get("background"))) + and ( + len(nodes) >= bg_n_thr + or _truthy((overrides or {}).get("background")) + or (until_limit and action == "orbit_sweep") + ) ): sync_args = dict(args) sync_params = dict(overrides or {}) @@ -879,10 +910,11 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: action=action, view_id=view_id, tool_args=sync_args, - meta={"node_count": len(nodes)}, + meta={"node_count": len(nodes), "until_limit": until_limit}, ) print( - f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}", + f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}" + + (" until_limit" if until_limit else ""), file=sys.stderr, flush=True, ) @@ -901,11 +933,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: ), } - # orbit_sweep: preview suggests only; apply defaults pick=1 (unless round). + # orbit_sweep: preview suggests only; apply defaults pick=1 (unless round/until_limit). if action == "orbit_sweep" and mode == "apply": overrides = dict(overrides or {}) if ( not overrides.get("round") + and not until_limit and overrides.get("pick") is None ): if overrides.get("node_id") or overrides.get("fabric_node_id"): @@ -954,23 +987,26 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: 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." + "Main path: until_limit(crossing→total) → clear_edge_hits → " + "pull_far_chains → compact_bbox → hand. " + "Eye sinks: do not polish_crossings / fix_overlaps / round." ) 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). + # Dual-unit: default accepts residual crossings (max-membership CN eyes). + # Optional hard gate: params.require_zero_cross=true. if action == "layout_dual_unit": loc = result.get("local") or {} - if not loc.get("accepted", False): + require_zero = bool((overrides or {}).get("require_zero_cross")) + if require_zero and 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." + "require_zero_cross=true but unit-internal crossings≠0. " + "Omit the flag (default) to apply best-effort eye layout, " + "or shrink membership / re-pick portals." ), "local": loc, } @@ -1086,14 +1122,311 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]: 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). " + "sinkTopologyDualUnits(max_units=1) → suggestSinkHubs/move_nodes(park) → " + "orbit_sweep(until_limit crossing→total) → clear_edge_hits → " + "pull_far_chains → compact_bbox → hand drag. " + "Eye sinks: no polish_crossings / fix_overlaps / untangle / round. " + "Plateau when stall + moved≈0. Small graphs: layout(compact|corridor|rings). " "Large jobs: poll job_status; job_cancel for cooperative stop." ) return _slim_layout_payload(out) +def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[dict], list[dict]]: + """Fetch a view; return (graph, id_set, nodes, edges).""" + graph = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0)) + if not graph.get("ok"): + return graph, set(), [], [] + 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)] + ids: set[str] = set() + for n in nodes: + fid = str(n.get("fabric_node_id") or n.get("id") or "").strip() + if fid and not fid.startswith("region:"): + ids.add(fid) + return graph, ids, nodes, edges + + +def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]: + """Rank hub territories on source for non-dual move_nodes(park) batches.""" + from netx_topology_mcp.layout_ops.suggest_sink_hubs import ( + pick_batch, + suggest_sink_hub_batches, + ) + from netx_topology_mcp.layout_structure import analyze_graph_structure + + source_view_id = str( + args.get("source_view_id") or args.get("view_id") or "" + ).strip() + sink_view_id = str(args.get("sink_view_id") or "").strip() + if not source_view_id: + return { + "ok": False, + "error": "source_view_id_required", + "hint": "Pass source_view_id=根图 (post-dual leftovers).", + } + + try: + top_n = max(1, min(40, int(args.get("top_n") or 12))) + except (TypeError, ValueError): + top_n = 12 + try: + pick = max(1, int(args.get("pick") or 1)) + except (TypeError, ValueError): + pick = 1 + try: + min_territory = max(0, int(args.get("min_territory") or 1)) + except (TypeError, ValueError): + min_territory = 1 + try: + min_move_n = max(1, int(args.get("min_move_n") or 1)) + except (TypeError, ValueError): + min_move_n = 1 + + include_hub = str(args.get("include_hub") if args.get("include_hub") is not None else "true").lower() not in { + "0", + "false", + "no", + "off", + } + # Source dual portals: default OFF — leftover root eyes (e.g. GHP) still need + # non-dual park batches. Sink dual: default ON but only the primary eye. + use_source_dual = str( + args.get("use_source_dual_portals") + if args.get("use_source_dual_portals") is not None + else "false" + ).lower() not in {"0", "false", "no", "off"} + use_sink_dual = str( + args.get("use_sink_dual_portals") + if args.get("use_sink_dual_portals") is not None + else ("true" if sink_view_id else "false") + ).lower() not in {"0", "false", "no", "off"} + + only_layers = args.get("only_layers") or args.get("layers") + if isinstance(only_layers, str): + only_layers = [p.strip() for p in only_layers.replace(";", ",").split(",") if p.strip()] + elif not isinstance(only_layers, list): + only_layers = ["agg", "core"] + + exclude: set[str] = set() + raw_ex = args.get("exclude_portal_ids") or args.get("exclude_ids") or args.get("portal_ids") + if isinstance(raw_ex, (list, tuple, set)): + exclude |= {str(x).strip() for x in raw_ex if str(x).strip()} + elif isinstance(raw_ex, str) and raw_ex.strip(): + exclude |= { + p.strip() + for p in raw_ex.replace(";", ",").split(",") + if p.strip() + } + + src_graph, src_ids, src_nodes, src_edges = _view_membership_ids(source_view_id) + if not src_graph.get("ok"): + return { + "ok": False, + "error": src_graph.get("error") or "source_view_fetch_failed", + "source_view_id": source_view_id, + } + if len(src_ids) < 2: + return { + "ok": False, + "error": "source_too_few_nodes", + "source_view_id": source_view_id, + "node_count": len(src_ids), + } + + sink_ids: set[str] = set() + sink_dual: dict[str, Any] | None = None + if sink_view_id: + sink_graph, sink_ids, sink_nodes, sink_edges = _view_membership_ids(sink_view_id) + if not sink_graph.get("ok"): + return { + "ok": False, + "error": sink_graph.get("error") or "sink_view_fetch_failed", + "sink_view_id": sink_view_id, + } + if use_sink_dual: + sink_struct = analyze_graph_structure(sink_nodes, sink_edges, hub_top_k=8) + sink_dual = sink_struct.get("dual_units") if isinstance(sink_struct, dict) else None + + struct = analyze_graph_structure( + src_nodes, src_edges, hub_top_k=max(12, top_n * 2), stub_top_k=40 + ) + dual = struct.get("dual_units") if use_source_dual else None + # Merge dual portals into exclude via dual_units arg. + # Sink: only the largest unit (the fixed eye) — nested eyes must not + # blanket-exclude every AN hub still holding root stubs. + merged_dual: dict[str, Any] = {"units": []} + if isinstance(dual, dict): + merged_dual["units"].extend(list(dual.get("units") or [])[:8]) + if isinstance(sink_dual, dict): + sink_units = [u for u in (sink_dual.get("units") or []) if isinstance(u, dict)] + if sink_units: + best = max(sink_units, key=lambda u: int(u.get("node_count") or 0)) + merged_dual["units"].append(best) + + report = suggest_sink_hub_batches( + hubs=list(struct.get("hubs") or []), + soft_blocks=struct.get("soft_blocks") if isinstance(struct.get("soft_blocks"), dict) else {}, + source_ids=src_ids, + sink_ids=sink_ids, + exclude_ids=exclude, + dual_units=merged_dual if merged_dual["units"] else None, + min_territory=min_territory, + min_move_n=min_move_n, + top_n=top_n, + include_hub=include_hub, + only_layers=[str(x) for x in only_layers] if only_layers else None, + ) + batch = pick_batch(report, pick=pick) + src_view = src_graph.get("view") if isinstance(src_graph.get("view"), dict) else {} + out: dict[str, Any] = { + "ok": True, + "source_view_id": source_view_id, + "source_view_name": str(src_view.get("name") or ""), + "sink_view_id": sink_view_id or None, + "pick": pick, + "batch": batch, + **report, + } + if batch: + out["move_nodes"] = { + "action": "move_nodes", + "source_view_id": source_view_id, + "view_id": sink_view_id or "", + "mode": "apply", + "params": { + "fabric_node_ids": list(batch.get("fabric_node_ids") or []), + "park": True, + "remove_from_source": True, + }, + } + out["hint"] = ( + f"Rank#{batch.get('rank')} hub={batch.get('hub_name')} " + f"n={batch.get('remaining_n')}. " + "Call layoutTopologyView with move_nodes payload (park=true). " + "Do not sinkTopologyDualUnits again." + ) + else: + out["hint"] = ( + "No hub batches left (all territories on sink or excluded). " + "Check leftovers / tiny chains on source." + ) + return out + + +def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]: + """Map reference canvas geometry onto target via shared fabric_node_ids.""" + from netx_topology_mcp.layout_ops.align_reference import ( + align_reference_params_from_overrides, + align_to_reference, + ) + from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges + from netx_topology_mcp.layout_stats import analyze_layout_stats + + view_id = str(args.get("view_id") or "").strip() + overrides = args.get("params") if isinstance(args.get("params"), dict) else {} + knobs = align_reference_params_from_overrides(overrides) + ref_id = ( + knobs.get("reference_view_id") + or str(args.get("source_view_id") or "").strip() + or str(args.get("reference_view_id") or "").strip() + ) + mode = str(args.get("mode") or "preview").strip().lower() or "preview" + if not view_id: + return {"ok": False, "error": "view_id_required", "hint": "Target canvas to rewrite."} + if not ref_id: + return { + "ok": False, + "error": "reference_view_id_required", + "hint": "Pass source_view_id or params.reference_view_id (hand golden / UME).", + } + if mode not in {"preview", "apply"}: + return {"ok": False, "error": "mode_invalid"} + + tgt = _data(http_json("GET", f"/v1/topology/views/{view_id}", timeout=120.0)) + if not tgt.get("ok"): + return {"ok": False, "error": tgt.get("error") or "target_fetch_failed", "view_id": view_id} + ref = _data(http_json("GET", f"/v1/topology/views/{ref_id}", timeout=120.0)) + if not ref.get("ok"): + return { + "ok": False, + "error": ref.get("error") or "reference_fetch_failed", + "reference_view_id": ref_id, + } + + t_nodes = [n for n in (tgt.get("nodes") or []) if isinstance(n, dict)] + t_edges = [e for e in (tgt.get("edges") or []) if isinstance(e, dict)] + r_nodes = [n for n in (ref.get("nodes") or []) if isinstance(n, dict)] + st = build_state_from_nodes_edges(t_nodes, t_edges) + ref_pos: dict[str, tuple[float, float]] = {} + for n in r_nodes: + fid = str(n.get("fabric_node_id") or n.get("id") or "").strip() + if not fid or n.get("x") is None or n.get("y") is None: + continue + try: + ref_pos[fid] = (float(n["x"]), float(n["y"])) + except (TypeError, ValueError): + continue + + op = align_to_reference( + st, + portal_ids=knobs.get("portal_ids") or [], + reference=ref_pos, + mode=knobs.get("mode") or "similarity", + park_missing=bool(knobs.get("park_missing", True)), + freeze_portals=bool(knobs.get("freeze_portals", True)), + ) + if op.params.get("error"): + return {"ok": False, **op.params, "note": op.note} + + positions = [ + {"fabric_node_id": nid, "x": xy[0], "y": xy[1]} + for nid, xy in op.state.positions.items() + if nid in op.moved + ] + out: dict[str, Any] = { + "ok": True, + "action": "align_reference", + "view_id": view_id, + "reference_view_id": ref_id, + "mode": mode, + "applied": False, + "local": {"op": op.params, "note": op.note}, + "moved_n": len(op.moved), + } + if mode == "preview": + # Score proposed coords without PATCH. + preview_nodes = [] + for n in t_nodes: + fid = str(n.get("fabric_node_id") or "").strip() + row = dict(n) + if fid in op.state.positions: + row["x"], row["y"] = op.state.positions[fid] + preview_nodes.append(row) + qa = analyze_layout_stats(preview_nodes, t_edges, score_profile="auto") + out["summary"] = qa.get("summary") + out["hint"] = "Preview only. Re-call mode=apply to PATCH aligned positions." + return out + + patch = _patch_positions_chunked(view_id, positions) + if not patch.get("ok"): + return { + **out, + "ok": False, + "error": patch.get("error") or "patch_failed", + "patch": patch, + } + out["applied"] = True + out["updated"] = patch.get("updated") + from_live = _analyze_one_view(view_id, detail="summary", score_profile="auto") + out["summary"] = from_live.get("summary") + out["hint"] = ( + "Aligned from reference. If overlaps remain, surgical orbit_sweep; " + "do not global polish on eye sinks." + ) + return 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() @@ -1120,6 +1453,7 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]: max_nodes = max(1, min(2000, int(args.get("max_nodes") or 800))) except (TypeError, ValueError): max_nodes = 800 + score_profile = str(args.get("score_profile") or "auto").strip().lower() or "auto" if view_id: return _analyze_one_view( @@ -1128,6 +1462,7 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]: detail=detail, sight_limit=sight_limit, sight_cell=sight_cell, + score_profile=score_profile, ) if not folder_id: @@ -1162,7 +1497,11 @@ def _analyze_topology_view_layout(args: dict[str, Any]) -> dict[str, Any]: rows: list[dict[str, Any]] = [] for c in picked: - one = _analyze_one_view(str(c["view_id"]), with_meta=with_meta) + one = _analyze_one_view( + str(c["view_id"]), + with_meta=with_meta, + score_profile=score_profile, + ) if not one.get("ok"): rows.append( { @@ -1772,6 +2111,10 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: params["keyword"] = filt if str(args.get("role") or "").strip(): params["role"] = str(args.get("role")).strip() + if str(args.get("level") or "").strip() or args.get("level") == 0: + params["level"] = str(args.get("level")).strip() + if str(args.get("level_major") or "").strip() or args.get("level_major") == 0: + params["level_major"] = str(args.get("level_major")).strip() if str(args.get("link_status") or "").strip(): params["link_status"] = str(args.get("link_status")).strip() if str(args.get("region_folder_id") or "").strip(): @@ -1785,6 +2128,203 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: return page +def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]: + """Tag Fabric level/region for agents (match → tag; rules apply; unmatched).""" + action = str(args.get("action") or "").strip().lower() + if not action: + return { + "ok": False, + "error": "action_required", + "hint": ( + "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. " + "Typical: match → tag(level|role, dry_run) → tag; " + "then addTopologyViewNodes by role/level_major/region." + ), + } + + if action in {"match", "find"}: + pattern = str(args.get("pattern") or args.get("q") or "").strip() + if not pattern: + return {"ok": False, "error": "pattern_required"} + match_field = str(args.get("match_field") or "name").strip() or "name" + sample_limit = min(200, max(1, int(args.get("sample_limit") or 50))) + out = _data( + http_json( + "POST", + "/v1/topology/fabric/nodes/match", + body={ + "pattern": pattern, + "match_field": match_field, + "sample_limit": sample_limit, + }, + ) + ) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["action"] = "match" + samples = out.get("samples") + if isinstance(samples, list): + out["samples"] = [ + _compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:sample_limit] + ] + ids = out.get("fabric_node_ids") + if isinstance(ids, list) and len(ids) > 200: + out["fabric_node_ids"] = ids[:200] + out["fabric_node_ids_truncated"] = True + out["hint"] = ( + "Preview only. Re-call action=tag with same pattern (or fabric_node_ids) " + "and level (e.g. 1 / 1.1 / 2.1) or role preset; dry_run=true first." + ) + return out + + if action in {"tag", "bulk_tag", "assign"}: + level = args.get("level") if "level" in args else Ellipsis + role = args.get("role") if "role" in args else Ellipsis + region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis + clear_region = _truthy(args.get("clear_region")) + if level is Ellipsis and role is Ellipsis and region is Ellipsis and not clear_region: + return { + "ok": False, + "error": "level_or_region_required", + "hint": ( + "Pass level (0/1/1.1/2/2.1/3…) and/or role preset " + "(external|core|aggregation|access) and/or region_folder_id." + ), + } + body: dict[str, Any] = { + "dry_run": _truthy(args.get("dry_run")), + } + ids = args.get("fabric_node_ids") + if isinstance(ids, list) and ids: + body["fabric_node_ids"] = [str(x).strip() for x in ids if str(x).strip()][:2000] + pattern = str(args.get("pattern") or args.get("q") or "").strip() + if pattern: + body["pattern"] = pattern + body["match_field"] = str(args.get("match_field") or "name").strip() or "name" + if not body.get("fabric_node_ids") and not body.get("pattern"): + return { + "ok": False, + "error": "ids_or_pattern_required", + "hint": "Pass fabric_node_ids[] from match, or pattern for ephemeral regex tag.", + } + if level is not Ellipsis: + body["level"] = level + elif role is not Ellipsis: + body["role"] = None if role is None or str(role).strip() == "" else str(role).strip() + if clear_region: + body["region_folder_id"] = None + elif region is not Ellipsis: + body["region_folder_id"] = None if region is None or str(region).strip() == "" else str(region).strip() + out = _data(http_json("POST", "/v1/topology/fabric/nodes/tags/bulk", body=body)) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["action"] = "tag" + samples = out.get("samples") + if isinstance(samples, list): + out["samples"] = [ + _compact_fabric_item(s) if isinstance(s, dict) else s for s in samples[:50] + ] + if out.get("dry_run"): + out["hint"] = "Preview only. Re-call with dry_run=false to write tags." + else: + out["hint"] = ( + "Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or " + "addTopologyViewNodes filters to place on region canvas." + ) + return out + + if action == "patch": + node_id = str(args.get("fabric_node_id") or args.get("node_id") or "").strip() + if not node_id: + return {"ok": False, "error": "fabric_node_id_required"} + body: dict[str, Any] = {} + if "level" in args: + body["level"] = args.get("level") + elif "role" in args: + role = args.get("role") + body["role"] = None if role is None or str(role).strip() == "" else str(role).strip() + if _truthy(args.get("clear_region")): + body["region_folder_id"] = None + elif "region_folder_id" in args: + region = args.get("region_folder_id") + body["region_folder_id"] = ( + None if region is None or str(region).strip() == "" else str(region).strip() + ) + if not body: + return {"ok": False, "error": "level_or_region_required"} + out = _data( + http_json( + "PATCH", + f"/v1/topology/fabric/nodes/{node_id}/tags", + body=body, + ) + ) + if isinstance(out, dict) and out.get("ok") is not False: + out = _compact_fabric_item(out) if "id" in out else dict(out) + out["ok"] = True + out["action"] = "patch" + return out + + if action == "unmatched": + kind = str(args.get("kind") or "any").strip() or "any" + 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))) + page = _compact_fabric_page( + _data( + http_json( + "GET", + "/v1/topology/classify/unmatched", + params={"kind": kind, "page": page_n, "page_size": page_size}, + ) + ) + ) + if isinstance(page, dict): + page = dict(page) + page["action"] = "unmatched" + return page + + if action in {"preview_rules", "preview"}: + out = _data(http_json("POST", "/v1/topology/classify/preview")) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["action"] = "preview_rules" + out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write." + return out + + if action in {"apply_rules", "apply"}: + skip_manual = True + if "skip_manual" in args: + skip_manual = _truthy(args.get("skip_manual")) + elif _truthy(args.get("overwrite_manual")): + skip_manual = False + params = { + "skip_manual": skip_manual, + "fill_empty_only": _truthy(args.get("fill_empty_only")), + } + out = _data(http_json("POST", "/v1/topology/classify/apply", params=params)) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["action"] = "apply_rules" + return out + + if action in {"list_rules", "rules"}: + out = _data(http_json("GET", "/v1/topology/classify/rules")) + if isinstance(out, dict) and out.get("ok") is not False: + out = dict(out) + out["action"] = "list_rules" + out["hint"] = ( + "Rules CRUD stays on web or HTTP. Agent tagging prefers action=match|tag " + "(ephemeral regex) over editing rules." + ) + return out + + return { + "ok": False, + "error": "action_invalid", + "hint": "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules", + } + + def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]: node_id = str(args.get("node_id") or "").strip() if not node_id: @@ -2002,8 +2542,9 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: 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))) + # CN-eye units often cover 150–300 NEs; old default 80 rejected them. + max_nodes = max(min_nodes, min(400, int(args.get("max_nodes") or 300))) + max_batch_nodes = max(max_nodes, min(800, int(args.get("max_batch_nodes") or 400))) until_empty = bool(args.get("until_empty")) include_leftovers = bool( args.get("include_leftovers") @@ -2019,6 +2560,49 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: True if args.get("layout_batch") is None else args.get("layout_batch") ) unit_gap = float(args.get("unit_gap") or 220.0) + # Level-aware drain: sink_layers = whitelist this phase; keep_layers = anchors. + # Default: drain access(+other), leave core/agg on root. + raw_keep = args.get("keep_layers") + if raw_keep is None: + keep_layers = {"core", "agg"} + elif isinstance(raw_keep, str): + keep_layers = {p.strip().lower() for p in raw_keep.split(",") if p.strip()} + elif isinstance(raw_keep, (list, tuple)): + keep_layers = {str(p).strip().lower() for p in raw_keep if str(p).strip()} + else: + keep_layers = {"core", "agg"} + if isinstance(raw_keep, (list, tuple)) and len(raw_keep) == 0: + keep_layers = set() + + raw_sink = args.get("sink_layers") + if raw_sink is None: + # Default phase: access corridors (+ unclassified). + sink_layers: set[str] | None = {"access", "other"} + elif isinstance(raw_sink, str): + sink_layers = {p.strip().lower() for p in raw_sink.split(",") if p.strip()} or None + elif isinstance(raw_sink, (list, tuple)): + if len(raw_sink) == 0: + sink_layers = None # any non-keep + else: + sink_layers = {str(p).strip().lower() for p in raw_sink if str(p).strip()} + else: + sink_layers = {"access", "other"} + + # by_level=true: auto-pick deepest remaining layer on source (access→agg→core). + by_level = bool(args.get("by_level")) + # Default false: CN/core eyes hang off keep portals; prefer_pure would skip them. + prefer_pure = bool( + False if args.get("prefer_pure") is None else args.get("prefer_pure") + ) + prefer_core_eye = bool( + True if args.get("prefer_core_eye") is None else args.get("prefer_core_eye") + ) + prefer_top_eye = args.get("prefer_top_eye") + if prefer_top_eye is None: + prefer_top_eye = prefer_core_eye + else: + prefer_top_eye = bool(prefer_top_eye) + _LEVEL_DEPTH = ("access", "other", "agg", "core", "external") batches: list[dict[str, Any]] = [] source_remaining = -1 @@ -2073,6 +2657,36 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: and str(e.get("b_node_id") or e.get("target") or "") not in dry_removed ] st = build_state_from_nodes_edges(nodes, edges) + layer_of = { + nid: str(ly or "").strip().lower() or "other" + for nid, ly in (st.layers or {}).items() + } + # Phase layers: explicit sink_layers, or by_level → deepest remaining. + phase_layers = set(sink_layers) if sink_layers is not None else None + if by_level: + picked_ly = None + for ly in _LEVEL_DEPTH: + if ly in keep_layers: + continue + if any(layer_of.get(i) == ly for i in src_ids): + picked_ly = ly + break + if picked_ly: + phase_layers = {picked_ly} + if picked_ly == "access": + phase_layers.add("other") + + keep_ids = { + nid + for nid, ly in layer_of.items() + if ly in keep_layers or (phase_layers is not None and ly not in phase_layers) + } + sink_ids = { + nid + for nid in src_ids + if nid not in keep_ids + and (phase_layers is None or layer_of.get(nid, "other") in phase_layers) + } units = find_dual_portal_units(st, max_units=detect_max) picked = select_dual_unit_batch( units, @@ -2081,8 +2695,14 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: max_nodes=max_nodes, max_batch_nodes=max_batch_nodes, exclude_ids=set(snk_ids), + keep_ids=keep_ids, + sink_ids=sink_ids, + prefer_pure=prefer_pure, + prefer_core_eye=prefer_core_eye, + prefer_top_eye=prefer_top_eye, + layers=layer_of, ) - move_ids = batch_node_ids(picked) + move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids) mode = "dual_units" if not move_ids: # Relax size band once before leftovers. @@ -2090,22 +2710,34 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: units, max_units=max_units, min_nodes=2, - max_nodes=max(max_nodes, 200), + max_nodes=max(max_nodes, 300), max_batch_nodes=max_batch_nodes, exclude_ids=set(snk_ids), + keep_ids=keep_ids, + sink_ids=sink_ids, + prefer_pure=prefer_pure, + prefer_core_eye=prefer_core_eye, + prefer_top_eye=prefer_top_eye, + layers=layer_of, ) - move_ids = batch_node_ids(picked) + move_ids = batch_node_ids(picked, keep_ids=keep_ids, sink_ids=sink_ids) 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), + keep_ids=keep_ids, + sink_ids=sink_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() + src_ids, + max_batch_nodes=max_batch_nodes, + exclude_ids=set(), + keep_ids=keep_ids, + sink_ids=sink_ids, ) if not move_ids: @@ -2130,6 +2762,18 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: "units": units_as_batch_rows(picked, st.names), "node_ids": move_ids, "node_count": len(move_ids), + "kept_on_source": sorted( + { + nid + for u in picked + for nid in u.member_ids() + if nid in keep_ids + } + ), + "keep_layers": sorted(keep_layers), + "sink_layers": sorted(phase_layers) if phase_layers is not None else None, + "by_level": by_level, + "sink_eligible": len(sink_ids), "source_before": source_remaining, "sink_before": sink_count, } @@ -2206,6 +2850,10 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: unit_gap=unit_gap, links=attach_links, ) + # Never patch keep-layer anchors (they stay on source only). + move_set = set(move_ids) + if world: + world = {nid: xy for nid, xy in world.items() if nid in move_set} 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} @@ -2294,12 +2942,13 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]: "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)." + "layout_dual_unit when layout_batch=true; polish with " + "polish_crossings(straighten=false)/clear_edge_hits — " + "never fix_overlaps+straighten (destroys dual-unit eyes)." 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." + "Call again for the NEXT batch only after eye-safe polish on sink " + "(no straighten). Do NOT set until_empty — one batch → tune → next." ) ), } @@ -2846,9 +3495,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "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." + "then remove those fabric ids from the root. " + "Default keep_layers=[core,agg] leaves gravity anchors on the root; " + "sink_layers=[access,other] drains one level phase (set by_level=true " + "to auto deepest remaining). " + "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", @@ -2878,14 +3531,16 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "type": "integer", "minimum": 2, "maximum": 400, - "default": 80, - "description": "Max nodes per dual_unit candidate.", + "default": 300, + "description": ( + "Max nodes per dual_unit candidate (CN eyes often 150–300)." + ), }, "max_batch_nodes": { "type": "integer", "minimum": 8, "maximum": 800, - "default": 120, + "default": 400, }, "layout_batch": { "type": "boolean", @@ -2916,6 +3571,58 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "default": True, "description": "When dual_units exhausted, move leftover NE chunks.", }, + "keep_layers": { + "type": "array", + "items": {"type": "string"}, + "default": ["core", "agg"], + "description": ( + "Fabric layers that stay on source (not sunk). " + "Default core+agg so root keeps the gravity bar; " + "pass [] to drain everything (legacy)." + ), + }, + "sink_layers": { + "type": "array", + "items": {"type": "string"}, + "default": ["access", "other"], + "description": ( + "Only move these layers this phase. Default access+other. " + "Pass [] to allow any non-keep. With by_level=true, auto " + "picks deepest remaining (access→agg→…)." + ), + }, + "by_level": { + "type": "boolean", + "default": False, + "description": ( + "Auto sink deepest remaining non-keep layer each batch " + "(access first, then agg if keep allows)." + ), + }, + "prefer_pure": { + "type": "boolean", + "default": False, + "description": ( + "Legacy: prefer both portals sinkable. Default false so " + "CN/core eyes (portals stay on root) are not deprioritized." + ), + }, + "prefer_core_eye": { + "type": "boolean", + "default": True, + "description": ( + "Alias of prefer_top_eye. Prefer core/agg eyes " + "top→down before access rings." + ), + }, + "prefer_top_eye": { + "type": "boolean", + "default": True, + "description": ( + "Prefer dual_unit portals top→down: core–core, " + "core–agg, agg–agg, then maximize movable coverage." + ), + }, "dry_run": { "type": "boolean", "default": False, @@ -3101,6 +3808,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "description": "List filter keyword; alias of q for search.", }, "role": {"type": "string"}, + "level": { + "type": "string", + "description": "Exact fabric level e.g. 1.1", + }, + "level_major": { + "type": "string", + "description": "Major band e.g. 2 → [2.0, 3.0)", + }, "region_folder_id": { "type": "string", "description": "Filter by topo folder id (UME region / canvas folder).", @@ -3122,6 +3837,113 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "classifyTopologyFabricNodes", + "description": ( + "Classify Fabric inventory by layout level (major.minor) + region. " + "level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. " + "action=match → tag(level|role preset, dry_run) → tag. " + "role preset still accepted (maps to *.0). " + "After tagging, addTopologyViewNodes by role/region. No slice maps. " + "Requires ne:write." + ), + "inputSchema": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "match", + "tag", + "patch", + "unmatched", + "preview_rules", + "apply_rules", + "list_rules", + ], + "description": "Required. Typical: match → tag(dry_run) → tag.", + }, + "pattern": { + "type": "string", + "description": "Regex for match/tag (aliases q).", + }, + "q": {"type": "string", "description": "Alias of pattern."}, + "match_field": { + "type": "string", + "enum": ["name", "ip", "name_ip"], + "default": "name", + }, + "sample_limit": { + "type": "integer", + "minimum": 1, + "maximum": 200, + "default": 50, + }, + "fabric_node_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Explicit ids for tag (from match).", + }, + "fabric_node_id": { + "type": "string", + "description": "Single id for patch (alias node_id).", + }, + "node_id": {"type": "string"}, + "level": { + "description": "Layout rank e.g. 0, 1, 1.1, 2.1, 3. null clears.", + }, + "role": { + "type": "string", + "description": "Preset alias → level: external|core|aggregation|access|edge.", + }, + "region_folder_id": { + "type": "string", + "description": "Topo region folder id; empty + clear_region clears.", + }, + "clear_region": { + "type": "boolean", + "default": False, + "description": "Clear region_folder_id on tag/patch.", + }, + "dry_run": { + "type": "boolean", + "default": False, + "description": "tag only: preview without write.", + }, + "kind": { + "type": "string", + "enum": ["any", "level", "role", "region"], + "default": "any", + "description": "unmatched filter (role aliases level).", + }, + "page": {"type": "integer", "minimum": 1, "default": 1}, + "page_size": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 50, + }, + "limit": {"type": "integer", "description": "Alias of page_size"}, + "skip_manual": { + "type": "boolean", + "default": True, + "description": "apply_rules: skip manually tagged nodes.", + }, + "overwrite_manual": { + "type": "boolean", + "default": False, + "description": "apply_rules shortcut: skip_manual=false.", + }, + "fill_empty_only": { + "type": "boolean", + "default": False, + "description": "apply_rules: only fill empty role/region.", + }, + }, + "required": ["action"], + "additionalProperties": False, + }, + }, { "name": "queryTopologyNeighborhood", "description": ( @@ -3168,17 +3990,95 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "suggestSinkHubs", + "description": ( + "After the one-shot dual_unit eye is fixed: rank remaining hub territories on " + "source_view for non-dual move_nodes(park) batches. Excludes eye portals " + "(source/sink dual_units + exclude_portal_ids). Returns batches[] ranked by " + "remaining territory (agg before core) and move_nodes payload for pick=1. " + "Do NOT call sinkTopologyDualUnits again. Needs ne:read." + ), + "inputSchema": { + "type": "object", + "properties": { + "source_view_id": { + "type": "string", + "description": "Root / leftover canvas (required). Alias: view_id.", + }, + "view_id": { + "type": "string", + "description": "Alias of source_view_id.", + }, + "sink_view_id": { + "type": "string", + "description": "Eye sink canvas; ids already there are dropped from batches.", + }, + "exclude_portal_ids": { + "type": "array", + "items": {"type": "string"}, + "description": "Extra freeze ids (CN portals). Merged with dual_units portals.", + }, + "use_source_dual_portals": { + "type": "boolean", + "default": True, + "description": "Exclude portals from source dual_units (default true).", + }, + "use_sink_dual_portals": { + "type": "boolean", + "default": True, + "description": "Exclude portals from sink dual_units when sink_view_id set.", + }, + "only_layers": { + "type": "array", + "items": {"type": "string"}, + "description": "Hub layers to consider (default [agg, core]).", + }, + "min_territory": { + "type": "integer", + "minimum": 0, + "default": 1, + "description": "Skip hubs with remaining_territory below this.", + }, + "min_move_n": { + "type": "integer", + "minimum": 1, + "default": 1, + }, + "top_n": { + "type": "integer", + "minimum": 1, + "maximum": 40, + "default": 12, + }, + "pick": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "1-based batch to expose as batch + move_nodes.", + }, + "include_hub": { + "type": "boolean", + "default": True, + "description": "Include hub id in fabric_node_ids when still on source.", + }, + }, + "required": [], + "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. " + "+ score.total∈[0,100] (profiles: auto|default|eye). 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{}. " + "score_profile=auto picks eye for large sparse diagonal sinks. " "For writing positions use layoutTopologyView / updateTopologyViewPositions. Needs ne:read." ), "inputSchema": { @@ -3201,6 +4101,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "hotspots|blocks=sight; both=structure+sight (hand-drag)" ), }, + "score_profile": { + "type": "string", + "enum": ["auto", "default", "eye"], + "default": "auto", + "description": ( + "Scoring weights. auto→eye for large sparse diagonal canvases; " + "eye softens axis/rings (arc-band dual sinks); default=metro." + ), + }, "sight_limit": { "type": "integer", "minimum": 5, @@ -3236,19 +4145,31 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "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=layout_dual_unit: CN/core dual-portal eye (max membership; " + "residual crossings OK unless params.require_zero_cross=true). " "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=orbit_sweep: crossing orbit; preview+node_id / apply+pick; " + "until_limit=true loops single-point to stall (eye polish; default freeze portals); " + "round=true one-batch auto pick#1 (avoid on eye sinks). " + "action=level_bands: snap y by fabric layer (external→core→agg→access). " "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=clear_edge_hits: eject nodes on non-incident edges (H/V); " + "never raise crossings (even preserve_axis); eye sinks need portal_ids. " + "action=compact_bbox: gated farthest-K shrink toward portals. " + "action=pull_far_chains: scale far corridors/isolates toward portal mid-point. " + "action=align_reference: SAME-network A/B debug only " + "(shared fabric_node_ids); not a cross-network template. " + "action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). " + "action=untangle / straighten_channels: surgical polish (not on eye sinks). " "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." + "Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → " + "suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → " + "clear_edge_hits → pull_far_chains → compact_bbox → hand. " + "No golden by default; do not align_reference for delivery. " + "Needs ne:write for apply." ), "inputSchema": { "type": "object", @@ -3268,7 +4189,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "untangle", "polish_crossings", "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", "orbit_sweep", + "level_bands", "move_nodes", "sink_nodes", "job_status", @@ -3277,9 +4202,14 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "default": "layout", "description": ( "layout=full recipe; layout_dual_unit=dual-portal eye; " + "level_bands=horizontal layer bands; " "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; " + "clear_edge_hits=eject edge hits (no crossing rise); " + "pull_far_chains=scale far corridors toward portal mid; " + "compact_bbox=gated farthest-K shrink; " + "align_reference=same-network A/B debug only; " + "fix_overlaps|resolve_overlaps; " "untangle/straighten_channels; job_status|job_cancel." ), }, @@ -3287,7 +4217,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "type": "string", "description": ( "For action=layout: optional load graph (default=view_id). " - "For action=move_nodes: required FROM canvas (view_id is TO)." + "For action=move_nodes: required FROM canvas (view_id is TO). " + "For action=align_reference: same-network reference (debug only)." ), }, "recipe": { @@ -3325,13 +4256,22 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "description": ( "Overrides. layout: target_nn/target_util/…. " "job_status|job_cancel: job_id (required). " - "layout_dual_unit: unit_id (optional). " + "layout_dual_unit: unit_id / portal_a+portal_b; " + "require_zero_cross (default false). " "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. " + "clear_edge_hits: top_n/thr/margin/max_moves/portal_ids/" + "max_eject_degree/preserve_axis. " + "compact_bbox|pull_far_chains: portal_ids; " + "pull scales toward portal mid (not outer hub); " + "compact defaults farthest-K. " + "orbit_sweep: node_id/pick/round/until_limit/portal_ids[]/" + "freeze_layers[]/freeze_levels[]/" + "max_degree/max_moves/stall_limit/max_stretch/min_delta/" + "max_jump/angle_step/nn_floor; until_limit defaults " + "protect_rigid=portals + objective=crossing; " + "single-node/round default protect_rigid=off. " "move_nodes|sink_nodes: fabric_node_ids[] (required), " "copy_positions (default true), park, remove_from_source " "(default true), pad/offset_x/offset_y." @@ -3361,8 +4301,10 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "updateTopologyViewPositions": _update_topology_view_positions, "projectTopologyNeighbors": _project_topology_neighbors, "queryTopologyFabricNodes": _query_topology_fabric_nodes, + "classifyTopologyFabricNodes": _classify_topology_fabric_nodes, "queryTopologyNeighborhood": _query_topology_neighborhood, "queryTopologyEdges": _query_topology_edges, + "suggestSinkHubs": _suggest_sink_hubs, "analyzeTopologyViewLayout": _analyze_topology_view_layout, "layoutTopologyView": _layout_topology_view, } @@ -3378,8 +4320,10 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = { "updateTopologyViewPositions": "ne:write", "projectTopologyNeighbors": "ne:write", "queryTopologyFabricNodes": "ne:read", + "classifyTopologyFabricNodes": "ne:write", "queryTopologyNeighborhood": "ne:read", "queryTopologyEdges": "ne:read", + "suggestSinkHubs": "ne:read", "analyzeTopologyViewLayout": "ne:read", "layoutTopologyView": "ne:write", } 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 index 1100717..2f2aff7 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_metrics.py @@ -176,14 +176,24 @@ def compute_edge_clearance( per_node.append(h) hits_n = len(hits) - # Score by unique nodes hit / n (plan: ~0.05 warn, 0.2 → 0) + # Dual signal so hub chords with many segment hits actually hurt: + # 1) unique nodes hit / n (0 → 1, ≥20% → 0) + # 2) hits / links (≤0.02 → 1, ≥0.20 → 0) — separates sparse vs scrubbed eyes hit_frac = len(nodes_with_hit) / max(n_nodes, 1) + hit_per_link = hits_n / max(n_links, 1) if hit_frac <= 0.0: - score = 1.0 + node_s = 1.0 elif hit_frac >= 0.2: - score = 0.0 + node_s = 0.0 else: - score = 1.0 - hit_frac / 0.2 + node_s = 1.0 - hit_frac / 0.2 + if hit_per_link <= 0.02: + inten_s = 1.0 + elif hit_per_link >= 0.20: + inten_s = 0.0 + else: + inten_s = 1.0 - (hit_per_link - 0.02) / 0.18 + score = 0.45 * node_s + 0.55 * inten_s def _pct(vals: list[float], p: float) -> float | None: if not vals: @@ -197,6 +207,9 @@ def compute_edge_clearance( "nodes_hit": len(nodes_with_hit), "min_clearance_p50": _pct(clearances, 0.5), "edge_clearance_score": round(score, 4), + "edge_clearance_node_score": round(node_s, 4), + "edge_clearance_intensity_score": round(inten_s, 4), + "hit_per_link": round(hit_per_link, 4), "top_edge_hits": per_node[:top_n], "hit_nodes": per_node, "edge_clearance_tip": tip_ok, @@ -764,7 +777,7 @@ def grade_layout( issues.append(f"label_overlaps={label_overlaps}>0") util_warn = 0.08 - util_fail = 0.03 + util_fail = 0.04 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 diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py new file mode 100644 index 0000000..9cf3ae4 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/align_reference.py @@ -0,0 +1,279 @@ +"""Align a canvas to a reference layout (e.g. hand golden / UME). + +Uses shared ``fabric_node_id``s. Preferred mode ``similarity``: map reference +portal chord → target portal chord (scale+rotate+translate), then place every +shared node from the transformed reference. Target-only leftovers stay put +(or park toward nearest aligned neighbour). + +This is the escape hatch when gated local polish stalls: reuse known-good +geometry instead of more until_limit. +""" + +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.graph_util import bbox +from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult + + +def _dist(a: tuple[float, float], b: tuple[float, float]) -> float: + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +def _similarity_from_portals( + ref: dict[str, tuple[float, float]], + tgt_portals: dict[str, tuple[float, float]], + portal_ids: list[str], +) -> tuple[float, float, float, float, float, float, float] | None: + """Return (cos, sin, scale, tx, ty, rx0, ry0) mapping ref → target via two portals. + + x' = scale * R * (x - r0) + t0 + """ + if len(portal_ids) < 2: + return None + a, b = portal_ids[0], portal_ids[1] + if a not in ref or b not in ref or a not in tgt_portals or b not in tgt_portals: + return None + r0 = ref[a] + r1 = ref[b] + t0 = tgt_portals[a] + t1 = tgt_portals[b] + rdx, rdy = r1[0] - r0[0], r1[1] - r0[1] + tdx, tdy = t1[0] - t0[0], t1[1] - t0[1] + rlen = math.hypot(rdx, rdy) + tlen = math.hypot(tdx, tdy) + if rlen < 1e-6 or tlen < 1e-6: + return None + scale = tlen / rlen + ang = math.atan2(tdy, tdx) - math.atan2(rdy, rdx) + return (math.cos(ang), math.sin(ang), scale, t0[0], t0[1], r0[0], r0[1]) + + +def _apply_sim( + xy: tuple[float, float], + sim: tuple[float, float, float, float, float, float, float], +) -> tuple[float, float]: + cos_a, sin_a, scale, tx, ty, rx0, ry0 = sim + dx, dy = xy[0] - rx0, xy[1] - ry0 + return ( + tx + scale * (dx * cos_a - dy * sin_a), + ty + scale * (dx * sin_a + dy * cos_a), + ) + + +def _procrustes_similarity( + src: dict[str, tuple[float, float]], + dst: dict[str, tuple[float, float]], + ids: list[str], +) -> tuple[float, float, float, float, float, float, float] | None: + """Umeyama-like 2D similarity from matched point pairs (ids in both).""" + pts = [i for i in ids if i in src and i in dst] + if len(pts) < 2: + return None + sx = sum(src[i][0] for i in pts) / len(pts) + sy = sum(src[i][1] for i in pts) / len(pts) + dx = sum(dst[i][0] for i in pts) / len(pts) + dy = sum(dst[i][1] for i in pts) / len(pts) + var_s = 0.0 + cross = 0.0 # complex: sum conj(s)*d + # Using: scale*R maps (s-mean_s) → (d-mean_d) + sum_xx = sum_yy = sum_xy = sum_yx = 0.0 + for i in pts: + sx0, sy0 = src[i][0] - sx, src[i][1] - sy + dx0, dy0 = dst[i][0] - dx, dst[i][1] - dy + var_s += sx0 * sx0 + sy0 * sy0 + sum_xx += sx0 * dx0 + sum_yy += sy0 * dy0 + sum_xy += sx0 * dy0 + sum_yx += sy0 * dx0 + if var_s < 1e-8: + return None + # R = [[c,-s],[s,c]]; from SVD of covariance — 2D closed form: + # mu = atan2(sum_xy - sum_yx, sum_xx + sum_yy) ... use complex + re = sum_xx + sum_yy + im = sum_xy - sum_yx + ang = math.atan2(im, re) + cos_a, sin_a = math.cos(ang), math.sin(ang) + # scale = trace(R^T Cov) / var_s + scale = (re * cos_a + im * sin_a) / var_s + if scale < 1e-6: + scale = 1.0 + # x' = scale R (x - mean_s) + mean_d + return (cos_a, sin_a, scale, dx, dy, sx, sy) + + +def align_to_reference( + state: LayoutState, + params: LayoutParams | None = None, + *, + reference: dict[str, tuple[float, float]], + portal_ids: list[str] | None = None, + mode: str = "similarity", + park_missing: bool = True, + freeze_portals: bool = True, +) -> OpResult: + """Rewrite ``state.positions`` from ``reference`` geometry. + + ``mode``: + - ``similarity``: portal (or Procrustes) similarity; keep target portals + fixed when ``freeze_portals`` and portals provided. + - ``adopt``: copy reference coords for shared ids (normalize origin ≥40). + """ + del params + st = state.copy() + pos = dict(st.positions) + ref = {k: v for k, v in reference.items() if k in pos or True} + portals = [str(p) for p in (portal_ids or []) if str(p).strip()] + shared = sorted(set(pos) & set(ref)) + if len(shared) < 2: + return OpResult( + state=st, + moved=set(), + op="align_reference", + note="align_reference:too_few_shared", + params={"error": "too_few_shared", "shared_n": len(shared)}, + ) + + mode_k = str(mode or "similarity").strip().lower() or "similarity" + g0 = count_edge_crossings(pos, st.links) + area0 = 0.0 + if len(pos) >= 2: + x0, y0, x1, y1 = bbox(pos) + area0 = max((x1 - x0) * (y1 - y0), 1.0) + + new_pos = dict(pos) + moved: set[str] = set() + sim = None + meta_mode = mode_k + + if mode_k in {"adopt", "copy", "absolute"}: + xs = [ref[i][0] for i in shared] + ys = [ref[i][1] for i in shared] + ox, oy = min(xs), min(ys) + pad = 40.0 + for nid in shared: + new_pos[nid] = (ref[nid][0] - ox + pad, ref[nid][1] - oy + pad) + moved.add(nid) + meta_mode = "adopt" + else: + # similarity + if len(portals) >= 2 and all(p in ref and p in pos for p in portals[:2]): + sim = _similarity_from_portals(ref, pos, portals[:2]) + if sim is None: + # Procrustes on hubs: prefer portals if present else all shared + pivot = [p for p in portals if p in shared] if portals else shared + if len(pivot) < 2: + pivot = shared + sim = _procrustes_similarity(ref, pos, pivot[: min(32, len(pivot))]) + if sim is None: + return OpResult( + state=st, + moved=set(), + op="align_reference", + note="align_reference:no_transform", + params={"error": "no_transform", "shared_n": len(shared)}, + ) + for nid in shared: + if freeze_portals and nid in portals[:2]: + continue # keep target portals pinned + new_pos[nid] = _apply_sim(ref[nid], sim) + moved.add(nid) + meta_mode = "similarity" + + # Target-only: park near nearest aligned neighbour + only_tgt = [n for n in pos if n not in ref] + parked = 0 + if park_missing and only_tgt and shared: + for nid in only_tgt: + # nearest shared by old distance + nb = min( + shared, + key=lambda s: _dist(pos[nid], pos[s]), + ) + old_dx = pos[nid][0] - pos[nb][0] + old_dy = pos[nid][1] - pos[nb][1] + # shrink orphan offset toward new nb (keeps relative stub) + new_pos[nid] = (new_pos[nb][0] + old_dx * 0.85, new_pos[nb][1] + old_dy * 0.85) + moved.add(nid) + parked += 1 + + # Optional flip across portal chord if it lowers crossings + if ( + meta_mode == "similarity" + and len(portals) >= 2 + and portals[0] in new_pos + and portals[1] in new_pos + ): + pa, pb = new_pos[portals[0]], new_pos[portals[1]] + flipped = dict(new_pos) + ax, ay = pa + bx, by = pb + dx, dy = bx - ax, by - ay + llen2 = dx * dx + dy * dy + if llen2 > 1e-8: + for nid, (x, y) in new_pos.items(): + if nid in portals[:2]: + continue + # reflect point across line AB + t = ((x - ax) * dx + (y - ay) * dy) / llen2 + projx, projy = ax + t * dx, ay + t * dy + flipped[nid] = (2 * projx - x, 2 * projy - y) + g_a = count_edge_crossings(new_pos, st.links) + g_b = count_edge_crossings(flipped, st.links) + if g_b < g_a: + new_pos = flipped + meta_mode = "similarity_flipped" + + st.positions = new_pos + st.last_moved = moved + g1 = count_edge_crossings(new_pos, st.links) + ov = _has_any_footprint_overlap(new_pos, st.names) + x0, y0, x1, y1 = bbox(new_pos) if len(new_pos) >= 2 else (0, 0, 1, 1) + area1 = max((x1 - x0) * (y1 - y0), 1.0) + meta: dict[str, Any] = { + "mode": meta_mode, + "shared_n": len(shared), + "moved_n": len(moved), + "parked_n": parked, + "target_only_n": len(only_tgt), + "ref_only_n": len(set(ref) - set(pos)), + "portal_ids": portals[:4], + "freeze_portals": bool(freeze_portals), + "start_crossings": g0, + "end_crossings": g1, + "overlaps": bool(ov), + "start_area": round(area0, 1), + "end_area": round(area1, 1), + "scale": round(float(sim[2]), 4) if sim else None, + } + st.meta["align_reference"] = meta + return OpResult( + state=st, + moved=moved, + op="align_reference", + params=meta, + note=( + f"align_reference mode={meta_mode} shared={len(shared)} " + f"moved={len(moved)} x={g0}→{g1} area={meta['start_area']}→{meta['end_area']}" + ), + ) + + +def align_reference_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + o = overrides or {} + portals = o.get("portal_ids") or o.get("portals") or [] + if isinstance(portals, str): + portals = [portals] + return { + "portal_ids": [str(x) for x in portals if str(x).strip()], + "mode": str(o.get("mode") or o.get("align_mode") or "similarity").strip().lower(), + "park_missing": bool(o.get("park_missing", True)), + "freeze_portals": bool(o.get("freeze_portals", True)), + "reference_view_id": str( + o.get("reference_view_id") or o.get("ref_view_id") or "" + ).strip(), + } diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py new file mode 100644 index 0000000..64e512d --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/bundle_orbit.py @@ -0,0 +1,801 @@ +"""Contract pure chains / ring+chain into a super-node, orbit, then expand. + +When single-point ``orbit_sweep`` stalls, long chords are often owned by a +*corridor* (deg≤2 chain) or a small ring with a dangling chain. Moving any +interior node alone barely changes global crossings; moving the whole bundle +as a unit can. + +Expand rule (hard) +------------------ +Expansion must **not** raise global crossings. If a full-length expand invades +crowded space, probe **minimized** packings (shorter step along the ray) and +only accept a candidate that clears the gate after expand. No silent “apply +now, fix later”. + +Modes +----- +- **chain**: tip/centroid samples → expand mobile nodes on anchor→tip ray + with scale probes. +- **ring_chain**: triangle tip (+ dangling chain) sweeps; ring base stays; + expand chain outward with the same minimize-probe. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any + +from netx_topology_mcp.layout_metrics import ( + count_edge_crossings, + top_crossing_nodes, +) +from netx_topology_mcp.layout_ops.orbit_sweep import ( + _MAX_JUMP_CAP, + _far_field_guides, + _polar_grid, +) +from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_topology_mcp.layout_topology_quality import extract_chain_paths + +# Compact-first: tight packs before full-length (fits crowded pockets). +# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject. +_EXPAND_SCALES = (0.35, 0.45, 0.55, 0.7, 0.85, 1.0) +_MIN_STEP = 80.0 +_TIP_SAMPLE_CAP = 48 + + +@dataclass(frozen=True) +class Bundle: + """Mobile corridor with ordered path and optional fixed anchor.""" + + kind: str # chain | ring_chain + member_ids: tuple[str, ...] + tip_id: str + base_ids: tuple[str, ...] = () + path: tuple[str, ...] = () # ordered anchor…tip (anchor may be fixed) + anchor_id: str | None = None + + @property + def key(self) -> str: + return f"{self.kind}:{self.tip_id}:{len(self.member_ids)}" + + +def _centroid( + pos: dict[str, tuple[float, float]], ids: list[str] | tuple[str, ...] +) -> tuple[float, float] | None: + pts = [pos[n] for n in ids if n in pos] + if not pts: + return None + return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) + + +def _mean_step(path: list[str] | tuple[str, ...], pos: dict[str, tuple[float, float]]) -> float: + pts = [pos[n] for n in path if n in pos] + if len(pts) < 2: + return 140.0 + total = 0.0 + for i in range(len(pts) - 1): + total += math.hypot(pts[i + 1][0] - pts[i][0], pts[i + 1][1] - pts[i][1]) + return max(_MIN_STEP, total / (len(pts) - 1)) + + +def _walk_dangling_chain( + start: str, + adj: dict[str, set[str]], + *, + blocked: set[str], +) -> list[str]: + """From ``start`` (just outside blocked), walk a deg≤2 corridor away.""" + if start in blocked or start not in adj: + return [] + path = [start] + prev = None + cur = start + while True: + nbs = [v for v in adj.get(cur, ()) if v != prev and v not in blocked] + if len(path) == 1: + low = [v for v in nbs if len(adj.get(v, ())) <= 2] + if len(low) == 1: + nxt = low[0] + elif len(nbs) == 1 and len(adj.get(nbs[0], ())) <= 2: + nxt = nbs[0] + else: + break + else: + if len(adj.get(cur, ())) > 2: + break + if len(nbs) != 1: + break + nxt = nbs[0] + if len(adj.get(nxt, ())) > 2 and nxt not in blocked: + break + path.append(nxt) + prev, cur = cur, nxt + if len(path) > 40: + break + return path + + +def detect_chain_bundles( + adj: dict[str, set[str]], + pos: dict[str, tuple[float, float]], + *, + frozen: set[str], + min_nodes: int = 3, +) -> list[Bundle]: + """Pure (or portal-ended) chains → mobile interiors as expandable bundles.""" + out: list[Bundle] = [] + seen: set[frozenset[str]] = set() + for full in extract_chain_paths(adj): + if len(full) < min_nodes: + continue + # Orient: high-deg / frozen portal first when possible. + ordered = list(full) + d0 = len(adj.get(ordered[0], ())) + d1 = len(adj.get(ordered[-1], ())) + if d1 > d0 or (ordered[-1] in frozen and ordered[0] not in frozen): + ordered = list(reversed(ordered)) + + mobile = [ + n + for n in ordered + if n in pos + and n not in frozen + and not (len(adj.get(n, ())) > 2 and n in {ordered[0], ordered[-1]}) + ] + if len(mobile) < max(2, min_nodes - 1): + continue + key = frozenset(mobile) + if key in seen: + continue + seen.add(key) + + # Tip = free end of the oriented path (last mobile). + tip = mobile[-1] + bases = [n for n in (ordered[0], ordered[-1]) if n not in key and n in pos] + anchor = None + if ordered[0] not in key and ordered[0] in pos: + anchor = ordered[0] + elif bases: + anchor = bases[0] + + # Path for expand: anchor (optional) + mobiles in corridor order. + if anchor: + # mobiles already follow corridor from hub side + path = (anchor, *mobile) + else: + path = tuple(mobile) + + out.append( + Bundle( + kind="chain", + member_ids=tuple(mobile), + tip_id=tip, + base_ids=tuple(bases), + path=path, + anchor_id=anchor, + ) + ) + out.sort(key=lambda b: (-len(b.member_ids), b.tip_id)) + return out + + +def detect_ring_chain_bundles( + state: LayoutState, + *, + frozen: set[str], + max_ring_len: int = 5, +) -> list[Bundle]: + """Ring + dangling chain → sweep the attachment tip (triangle vertex).""" + adj = state.adj + pos = state.positions + out: list[Bundle] = [] + seen: set[frozenset[str]] = set() + faces = extract_ring_faces(state, max_len=max_ring_len, max_cycles=60) + for face in faces: + rset = set(face.node_ids) + if len(rset) < 3: + continue + for tip in face.node_ids: + if tip in frozen or tip not in pos: + continue + outs = [v for v in adj.get(tip, ()) if v not in rset] + for seed in outs: + chain = _walk_dangling_chain(seed, adj, blocked=rset) + if len(chain) < 1: + continue + mobile = [tip, *chain] + mobile = [n for n in mobile if n not in frozen and n in pos] + if len(mobile) < 2: + continue + key = frozenset(mobile) + if key in seen: + continue + seen.add(key) + base = tuple(n for n in face.node_ids if n != tip) + # path: tip is anchor of the dangling chain (ring vertex stays + # the triangle tip we move); chain packs outward from tip. + path = tuple([tip, *[n for n in chain if n in key]]) + out.append( + Bundle( + kind="ring_chain", + member_ids=tuple(mobile), + tip_id=tip, + base_ids=base, + path=path, + anchor_id=tip, + ) + ) + out.sort( + key=lambda b: ( + 0 if len(b.base_ids) == 2 else 1, + -len(b.member_ids), + b.tip_id, + ) + ) + return out + + +def detect_bundles( + state: LayoutState, + *, + frozen: set[str] | None = None, +) -> list[Bundle]: + frozen = set(frozen or ()) + chains = detect_chain_bundles(state.adj, state.positions, frozen=frozen) + rings = detect_ring_chain_bundles(state, frozen=frozen) + used: set[str] = set() + out: list[Bundle] = [] + for b in list(rings) + list(chains): + members = set(b.member_ids) + if members & used: + continue + if any(n in frozen for n in b.member_ids): + continue + used |= members + out.append(b) + return out + + +def _bundle_ok( + members: set[str], + trial: dict[str, tuple[float, float]], + names: dict[str, str], + nn_floor: float, +) -> bool: + """Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders.""" + from netx_topology_mcp.layout_ops.orbit_sweep import ( + _box, + _centers_may_overlap, + ) + from netx_topology_mcp.layout_metrics import node_footprint + + # Apply refuses any footprint overlap — members must clear each other too. + mem_list = [n for n in members if n in trial] + for i, n in enumerate(mem_list): + ax0, ay0, ax1, ay1 = _box(n, trial, names) + nx, ny = trial[n] + fa = node_footprint(names.get(n, "")) + # vs other members + for m in mem_list[i + 1 :]: + mx, my = trial[m] + fb = node_footprint(names.get(m, "")) + if not _centers_may_overlap(nx, ny, mx, my, fa, fb): + continue + bx0, by0, bx1, by1 = _box(m, trial, names) + if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0: + return False + # vs outsiders + nn_floor + floor2 = nn_floor * nn_floor if nn_floor > 0 else 0.0 + for b, (x, y) in trial.items(): + if b in members: + continue + if floor2 and (x - nx) * (x - nx) + (y - ny) * (y - ny) < floor2: + return False + fb = node_footprint(names.get(b, "")) + if not _centers_may_overlap(nx, ny, x, y, fa, fb): + continue + bx0, by0, bx1, by1 = _box(b, trial, names) + if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0: + return False + return True + + +def _expand_path_placements( + path: list[str], + *, + origin: tuple[float, float], + ux: float, + uy: float, + step: float, + mobile: set[str], + tip_xy: tuple[float, float] | None = None, + pin_tip: bool = False, +) -> dict[str, tuple[float, float]]: + """Place mobile nodes along ray; optionally pin free tip at tip_xy.""" + ox, oy = origin + out: dict[str, tuple[float, float]] = {} + movables = [n for n in path if n in mobile] + if not movables: + return out + if pin_tip and tip_xy is not None and len(movables) >= 1: + # Chord pack: first mobile near origin+step, last at tip_xy. + tx, ty = tip_xy + if len(movables) == 1: + out[movables[0]] = (tx, ty) + return out + # Keep tip fixed; distribute interiors on chord origin→tip. + for i, n in enumerate(movables): + t = (i + 1) / len(movables) + # start a bit off the anchor + out[n] = (ox + (tx - ox) * t, oy + (ty - oy) * t) + out[movables[-1]] = (tx, ty) + return out + for i, n in enumerate(movables): + k = i + 1 + out[n] = (ox + ux * step * k, oy + uy * step * k) + return out + + +def _probe_expand( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + names: dict[str, str], + bundle: Bundle, + *, + tip_xy: tuple[float, float], + global0: int, + nn_floor: float, + min_delta: int, +) -> dict[str, Any] | None: + """Try full→minimized expands toward tip_xy; keep first non-increasing best. + + Returns candidate dict with placements, or None if every expand raises + crossings / invades. + """ + members = [n for n in bundle.member_ids if n in pos] + mem_set = set(members) + if len(members) < 2: + return None + + path = [n for n in (bundle.path or bundle.member_ids) if n in pos or n == bundle.anchor_id] + if not path: + path = list(members) + + anchor = bundle.anchor_id + if anchor and anchor in pos: + ox, oy = pos[anchor] + elif bundle.base_ids: + bpts = [pos[n] for n in bundle.base_ids if n in pos] + if not bpts: + c0 = _centroid(pos, members) + if not c0: + return None + ox, oy = c0 + else: + ox = sum(p[0] for p in bpts) / len(bpts) + oy = sum(p[1] for p in bpts) / len(bpts) + else: + # No fixed anchor: use opposite end of path as soft origin (old tip side). + fixed = [n for n in path if n not in mem_set and n in pos] + if fixed: + ox, oy = pos[fixed[0]] + else: + c0 = _centroid(pos, members) + if not c0: + return None + ox, oy = c0 + + tx, ty = tip_xy + dx, dy = tx - ox, ty - oy + dist = math.hypot(dx, dy) + if dist < 60.0: + return None + ux, uy = dx / dist, dy / dist + base_step = _mean_step(path if len(path) >= 2 else members, pos) + n_mobile = len(members) + + for scale in _EXPAND_SCALES: + step = max(_MIN_STEP, base_step * float(scale)) + # Prefer tip-pinned chord pack: interiors on origin→tip. + # Minimized scales shorten tip reach so the footprint fits clearance. + max_reach = step * max(1, n_mobile) + reach = min(dist, max_reach) if scale < 0.99 else dist + tip_use = (ox + ux * reach, oy + uy * reach) + placed = _expand_path_placements( + path, + origin=(ox, oy), + ux=ux, + uy=uy, + step=step, + mobile=mem_set, + tip_xy=tip_use, + pin_tip=True, + ) + if len(placed) < len(mem_set): + # fallback: step-only for missing + for n in members: + if n not in placed: + continue + if not placed: + continue + trial = dict(pos) + trial.update(placed) + # _bundle_ok already rejects outsider footprint invasion + nn_floor. + if not _bundle_ok(mem_set, trial, names, nn_floor): + continue + g1 = count_edge_crossings(trial, links) + # Hard rule: expand must not raise crossings. + if g1 > global0: + continue + delta = g1 - global0 + if delta > -max(1, int(min_delta)): + continue + return { + "x": round(tip_use[0], 1), + "y": round(tip_use[1], 1), + "r": round(math.hypot(tip_use[0] - ox, tip_use[1] - oy), 1), + "angle_deg": round(math.degrees(math.atan2(uy, ux)) % 360.0, 1), + "crossings": {"global": g1}, + "delta": {"global": int(delta)}, + "stretch": round(step / max(base_step, 1.0), 3), + "expand_scale": round(float(scale), 3), + "step": round(step, 1), + "placements": { + k: (round(v[0], 1), round(v[1], 1)) for k, v in placed.items() + }, + "origin": [round(ox, 1), round(oy, 1)], + } + return None + + +def orbit_bundle( + state: LayoutState, + bundle: Bundle, + *, + max_jump: float = 4000.0, + angle_step: int = 20, + cand_cap: int = 220, + nn_floor: float = 28.0, + min_delta: int = 1, + y_min: float | None = None, + y_max: float | None = None, +) -> dict[str, Any]: + """Sample tip directions; probe minimized expands; keep improving only.""" + pos = dict(state.positions) + names = dict(state.names) + links = list(state.links) + members = tuple(n for n in bundle.member_ids if n in pos) + if len(members) < 2: + return {"ok": False, "error": "bundle_too_small", "bundle": bundle.key} + + c0 = _centroid(pos, members) + if c0 is None: + return {"ok": False, "error": "no_centroid", "bundle": bundle.key} + cx, cy = c0 + jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP)) + global0 = count_edge_crossings(pos, links) + + # Tip seed samples around current tip / centroid. + tip0 = pos.get(bundle.tip_id, (cx, cy)) + ext_nbs: set[str] = set() + mem_set = set(members) + for n in members: + for v in state.adj.get(n, ()): + if v not in mem_set and v in pos: + ext_nbs.add(v) + pseudo = f"__bundle__{bundle.tip_id}" + c_adj = {pseudo: set(ext_nbs)} + for v in ext_nbs: + c_adj.setdefault(v, set()).add(pseudo) + c_pos = {pseudo: tip0, **{v: pos[v] for v in ext_nbs}} + + samples: list[tuple[float, float]] = [] + for sx, sy, _r, _ang in _far_field_guides(c_pos, pseudo, c_adj, jump): + samples.append((sx, sy)) + for sx, sy, _r, _ang in _polar_grid( + tip0[0], tip0[1], jump=jump, angle_step=max(12, int(angle_step)) + ): + samples.append((sx, sy)) + samples.append((cx, cy)) + if bundle.base_ids: + base_pts = [pos[n] for n in bundle.base_ids if n in pos] + if base_pts: + bx = sum(p[0] for p in base_pts) / len(base_pts) + by = sum(p[1] for p in base_pts) / len(base_pts) + dx, dy = tip0[0] - bx, tip0[1] - by + L = math.hypot(dx, dy) or 1.0 + ux, uy = dx / L, dy / L + for dist in (0.35 * jump, 0.6 * jump, 0.9 * jump): + samples.append((tip0[0] + ux * dist, tip0[1] + uy * dist)) + samples.append((tip0[0] - ux * dist, tip0[1] - uy * dist)) + + seen: set[tuple[int, int]] = set() + scored: list[dict[str, Any]] = [] + tip_cap = min(int(cand_cap), _TIP_SAMPLE_CAP) + for sx, sy in samples: + if y_min is not None and sy < y_min: + continue + if y_max is not None and sy > y_max: + continue + key = (int(round(sx / 12.0) * 12), int(round(sy / 12.0) * 12)) + if key in seen: + continue + seen.add(key) + if len(seen) > tip_cap: + break + if math.hypot(sx - tip0[0], sy - tip0[1]) < 40.0 and math.hypot( + sx - cx, sy - cy + ) < 40.0: + continue + probed = _probe_expand( + pos, + links, + names, + bundle, + tip_xy=(sx, sy), + global0=global0, + nn_floor=nn_floor, + min_delta=min_delta, + ) + if probed is None: + continue + if int(probed["delta"]["global"]) >= 0: + continue + scored.append(probed) + # Enough improving tips — rank later. + if len(scored) >= 8: + break + + scored.sort( + key=lambda c: ( + int(c["delta"]["global"]), + float(c.get("expand_scale") or 1.0), + float(c.get("r") or 0.0), + ) + ) + improving = [ + c + for c in scored + if int(c["delta"]["global"]) <= -max(1, int(min_delta)) + ] + top = improving[:5] + for i, c in enumerate(top, start=1): + c["rank"] = i + return { + "ok": True, + "bundle": bundle.key, + "kind": bundle.kind, + "tip_id": bundle.tip_id, + "tip_name": names.get(bundle.tip_id, bundle.tip_id), + "member_n": len(members), + "member_ids": list(members)[:40], + "base_ids": list(bundle.base_ids)[:12], + "anchor_id": bundle.anchor_id, + "centroid0": [round(cx, 1), round(cy, 1)], + "crossings_before": global0, + "candidates": top, + "improving_n": len(improving), + "sampled": len(seen), + "max_jump": jump, + "expand_mode": "minimize_probe", + } + + +def apply_bundle_pick( + state: LayoutState, + bundle: Bundle, + sweep: dict[str, Any], + *, + pick: int = 1, +) -> OpResult: + pos = dict(state.positions) + cands = list(sweep.get("candidates") or []) + if not cands: + return OpResult( + state=state, + moved=set(), + op="bundle_orbit", + params={"error": "no_candidates", "bundle": bundle.key}, + note="bundle_orbit:no_candidates", + ) + idx = max(1, min(len(cands), int(pick))) - 1 + cand = cands[idx] + placements = cand.get("placements") or {} + if not placements: + return OpResult( + state=state, + moved=set(), + op="bundle_orbit", + params={"error": "no_placements", "bundle": bundle.key}, + note="bundle_orbit:no_placements", + ) + + # Safety: re-score expand; refuse if crossings rose (stale / race). + trial = dict(pos) + moved: set[str] = set() + for nid, xy in placements.items(): + if nid not in trial: + continue + trial[nid] = (float(xy[0]), float(xy[1])) + moved.add(nid) + g0 = count_edge_crossings(pos, state.links) + g1 = count_edge_crossings(trial, state.links) + if g1 > g0: + return OpResult( + state=state, + moved=set(), + op="bundle_orbit", + params={ + "error": "expand_raises_crossings", + "bundle": bundle.key, + "start_crossings": g0, + "end_crossings": g1, + "hint": "Expand probe failed gate; try smaller scale / other tip.", + }, + note="bundle_orbit:expand_raises_crossings", + ) + + # Minimize-expand must not leave footprint overlaps (apply gate). + from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap + + if _has_any_footprint_overlap(trial, state.names): + return OpResult( + state=state, + moved=set(), + op="bundle_orbit", + params={ + "error": "expand_invades_space", + "bundle": bundle.key, + "start_crossings": g0, + "end_crossings": g1, + "expand_scale": cand.get("expand_scale"), + "hint": "Space too tight; tip/placement wrong — probe smaller scale or other tip.", + }, + note="bundle_orbit:expand_invades_space", + ) + + st = state.copy() + st.positions = trial + st.last_moved = moved + meta = { + "mode": "bundle_orbit", + "kind": bundle.kind, + "bundle": bundle.key, + "tip_id": bundle.tip_id, + "pick": idx + 1, + "member_n": len(moved), + "delta": int(g1 - g0), + "crossings": int(g1), + "expand_scale": cand.get("expand_scale"), + "step": cand.get("step"), + "expand_mode": "minimize_probe", + } + st.meta["bundle_orbit"] = meta + return OpResult( + state=st, + moved=moved, + op="bundle_orbit", + params=meta, + note=( + f"bundle_orbit {bundle.kind} tip={bundle.tip_id} " + f"n={len(moved)} scale={cand.get('expand_scale')} Δ={meta['delta']}" + ), + ) + + +def rank_bundles_by_hotspots( + bundles: list[Bundle], + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + names: dict[str, str], + *, + top_n: int = 12, +) -> list[Bundle]: + """Prefer bundles whose tip / members participate in crossings.""" + if not bundles: + return [] + from netx_topology_mcp.layout_metrics import crossing_participation + + _n, node_hit = crossing_participation(pos, links)[:2] + hot = { + str(r["fabric_node_id"]): int(r.get("crossing_hits") or 0) + for r in top_crossing_nodes( + pos, links, names=names, top_n=40, participation=node_hit + ) + } + + def score(b: Bundle) -> tuple[int, int, int]: + tip_h = int(node_hit.get(b.tip_id) or hot.get(b.tip_id) or 0) + mem_h = sum(int(node_hit.get(n) or 0) for n in b.member_ids) + return (tip_h + mem_h, len(b.member_ids), 1 if b.kind == "ring_chain" else 0) + + ranked = sorted(bundles, key=score, reverse=True) + return ranked[: max(1, int(top_n))] + + +def bundle_orbit_until_progress( + state: LayoutState, + *, + frozen_ids: set[str] | None = None, + max_jump: float = 5000.0, + max_bundles: int = 10, + min_delta: int = 1, + cand_cap: int = 200, + angle_step: int = 20, + nn_floor: float = 28.0, + params: LayoutParams | None = None, +) -> OpResult: + """Try hotspot-ranked bundles until one expand-gated move applies.""" + del params + st = state.copy() + frozen = set(frozen_ids or ()) + bundles = detect_bundles(st, frozen=frozen) + ranked = rank_bundles_by_hotspots( + bundles, st.positions, st.links, st.names, top_n=max_bundles + ) + global0 = count_edge_crossings(st.positions, st.links) + tried: list[dict[str, Any]] = [] + for b in ranked: + if any(n in frozen for n in b.member_ids): + continue + sweep = orbit_bundle( + st, + b, + max_jump=max_jump, + angle_step=angle_step, + cand_cap=cand_cap, + nn_floor=nn_floor, + min_delta=min_delta, + ) + tried.append( + { + "bundle": b.key, + "kind": b.kind, + "improving_n": int(sweep.get("improving_n") or 0), + } + ) + if int(sweep.get("improving_n") or 0) <= 0: + continue + op = apply_bundle_pick(st, b, sweep, pick=1) + if not op.moved: + tried[-1]["apply_error"] = (op.params or {}).get("error") + continue + end_g = count_edge_crossings(op.state.positions, op.state.links) + if end_g > global0: + # Should be unreachable due to apply gate; skip defensively. + continue + meta = { + **(op.params or {}), + "start_crossings": global0, + "end_crossings": end_g, + "tried": tried[:20], + "bundle_n": len(bundles), + } + op.state.meta["bundle_orbit"] = meta + return OpResult( + state=op.state, + moved=op.moved, + op="bundle_orbit", + params=meta, + note=op.note, + ) + + return OpResult( + state=st, + moved=set(), + op="bundle_orbit", + params={ + "mode": "bundle_orbit", + "start_crossings": global0, + "end_crossings": global0, + "delta": 0, + "tried": tried[:20], + "bundle_n": len(bundles), + "stop_reason": "no_candidates", + "expand_mode": "minimize_probe", + }, + note="bundle_orbit:no_candidates", + ) 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 index 8a83932..776de92 100644 --- 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 @@ -38,6 +38,13 @@ def clear_edge_params_from_overrides(params: dict[str, Any] | None) -> dict[str, out["pitch"] = float(p["pitch"]) if p.get("side") is not None: out["side"] = float(p["side"]) + portals = p.get("portal_ids") or p.get("portals") or [] + if isinstance(portals, str): + portals = [portals] + frozen = set(str(x) for x in (p.get("frozen_ids") or []) if str(x).strip()) + frozen |= {str(x) for x in portals if str(x).strip()} + out["frozen_ids"] = frozen + out["max_eject_degree"] = int(p.get("max_eject_degree") or 5) return out @@ -228,11 +235,15 @@ def clear_edge_hits( pitch: float | None = None, side: float | None = None, rounds: int = 1, + frozen_ids: set[str] | None = None, + max_eject_degree: int = 5, ) -> 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``. + ``frozen_ids``: never eject these (eye portals). + ``max_eject_degree``: skip hubs at/above this degree (default 5). """ del params st = state.copy() @@ -240,6 +251,8 @@ def clear_edge_hits( names = st.names links = list(st.links) adj = st.adj + frozen = {str(x) for x in (frozen_ids or ()) if str(x).strip()} + max_eject_degree = max(2, int(max_eject_degree)) 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)) @@ -272,8 +285,8 @@ def clear_edge_hits( 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 + # Never allow crossing rise — even with preserve_axis (metro snap). + x_slack = 0 deg = {n: len(adj.get(n) or ()) for n in pos} def _move_budget(nid: str) -> float: @@ -308,6 +321,8 @@ def clear_edge_hits( nid = str(h["fabric_node_id"]) if nid not in pos: continue + if nid in frozen: + continue a = str(h["a_node_id"]) b = str(h["b_node_id"]) if a not in pos or b not in pos: @@ -315,8 +330,8 @@ def clear_edge_hits( 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: + # High-degree hubs on a chord: skip unless caller raises cap. + if deg.get(nid, 0) >= int(max_eject_degree): continue if preserve_axis: nbr_xy = [ diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py new file mode 100644 index 0000000..1482515 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/compact_bbox.py @@ -0,0 +1,233 @@ +"""Uniform bbox shrink toward eye portals — eye-safe compactness. + +Hard gates (must all pass to accept a scale): +- global crossings must not rise +- footprint overlaps must stay zero +- frozen portal_ids stay put + +Optional soft: prefer scales that do not worsen edge_clearance hits. +""" + +from __future__ import annotations + +from typing import Any + +from netx_topology_mcp.layout_metrics import ( + count_edge_crossings, + compute_edge_clearance, +) +from netx_topology_mcp.layout_ops.graph_util import bbox +from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult + + +def _anchor( + pos: dict[str, tuple[float, float]], + frozen: set[str], +) -> tuple[float, float]: + pts = [pos[n] for n in frozen if n in pos] + if len(pts) >= 1: + return ( + sum(p[0] for p in pts) / len(pts), + sum(p[1] for p in pts) / len(pts), + ) + if not pos: + return (0.0, 0.0) + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) + + +def _area(pos: dict[str, tuple[float, float]]) -> float: + if len(pos) < 2: + return 1.0 + x0, y0, x1, y1 = bbox(pos) + return max((x1 - x0) * (y1 - y0), 1.0) + + +def _clr_hits(pos: dict[str, tuple[float, float]], links, names) -> int: + ec = compute_edge_clearance(pos, links, names=names, top_n=1) + if ec.get("edge_clearance_skipped"): + return 10**9 + return int(ec.get("edge_clearance_hits") or 0) + + +def compact_bbox( + state: LayoutState, + params: LayoutParams | None = None, + *, + frozen_ids: set[str] | None = None, + portal_ids: list[str] | None = None, + min_scale: float = 0.72, + step: float = 0.03, + max_clearance_slack: int = 80, + outlier_only: bool = True, +) -> OpResult: + """Probe shrink toward portals; keep best gated layout. + + ``outlier_only``: only move nodes farther than ~median radius from the + portal anchor — avoids crushing the already-dense eye core into overlaps. + """ + import math + + del params + st = state.copy() + pos0 = dict(st.positions) + names = st.names + links = list(st.links) + frozen: set[str] = set(frozen_ids or ()) + for p in portal_ids or []: + if p: + frozen.add(str(p)) + if not pos0: + return OpResult( + state=st, + moved=set(), + op="compact_bbox", + note="compact_bbox:empty", + params={"error": "empty"}, + ) + + g0 = count_edge_crossings(pos0, links) + if _has_any_footprint_overlap(pos0, names): + return OpResult( + state=st, + moved=set(), + op="compact_bbox", + note="compact_bbox:overlaps_before", + params={ + "error": "overlaps_before", + "hint": "Refuse shrink while footprints already overlap.", + }, + ) + clr0 = _clr_hits(pos0, links, names) + area0 = _area(pos0) + cx, cy = _anchor(pos0, frozen) + + dists = { + n: math.hypot(x - cx, y - cy) + for n, (x, y) in pos0.items() + if n not in frozen + } + mobile: set[str] + if outlier_only and dists: + # Farthest-K only: percentile bands often crush mid-ring nodes into overlaps. + k = max(16, min(40, len(dists) // 6)) + mobile = { + n + for n, _ in sorted(dists.items(), key=lambda kv: kv[1], reverse=True)[:k] + } + else: + mobile = set(dists.keys()) + + best_pos = pos0 + best_meta: dict[str, Any] = { + "scale": 1.0, + "crossings": g0, + "clearance_hits": clr0, + "area": round(area0, 1), + "accepted": False, + } + best_key = (g0, area0, clr0) + + scales: list[float] = [] + s = 1.0 - float(step) + lo = max(0.5, float(min_scale)) + while s >= lo - 1e-9: + scales.append(round(s, 4)) + s -= float(step) + + for scale in scales: + trial: dict[str, tuple[float, float]] = {} + for nid, (x, y) in pos0.items(): + if nid in frozen or nid not in mobile: + trial[nid] = (x, y) + else: + trial[nid] = ( + cx + (x - cx) * scale, + cy + (y - cy) * scale, + ) + if _has_any_footprint_overlap(trial, names): + continue + g1 = count_edge_crossings(trial, links) + if g1 > g0: + continue + clr1 = _clr_hits(trial, links, names) + if clr1 > clr0 + max(0, int(max_clearance_slack)): + continue + area1 = _area(trial) + key = (g1, area1, clr1) + if key < best_key: + best_key = key + best_pos = trial + best_meta = { + "scale": scale, + "crossings": g1, + "clearance_hits": clr1, + "area": round(area1, 1), + "accepted": True, + } + + moved = { + n + for n, xy in best_pos.items() + if n in pos0 + and ( + abs(xy[0] - pos0[n][0]) > 0.05 + or abs(xy[1] - pos0[n][1]) > 0.05 + ) + } + st.positions = best_pos + st.last_moved = moved + meta = { + "mode": "compact_bbox", + "anchor": [round(cx, 1), round(cy, 1)], + "frozen_n": len(frozen), + "mobile_n": len(mobile), + "outlier_only": bool(outlier_only), + "start_crossings": g0, + "start_clearance_hits": clr0, + "start_area": round(area0, 1), + "end_crossings": int(best_meta["crossings"]), + "end_clearance_hits": int(best_meta["clearance_hits"]), + "end_area": best_meta["area"], + "scale": best_meta["scale"], + "moved_n": len(moved), + "accepted": bool(best_meta["accepted"]), + } + st.meta["compact_bbox"] = meta + return OpResult( + state=st, + moved=moved, + op="compact_bbox", + params=meta, + note=( + f"compact_bbox scale={meta['scale']} " + f"Δx={meta['end_crossings'] - g0} " + f"clr={clr0}→{meta['end_clearance_hits']} " + f"area={meta['start_area']}→{meta['end_area']}" + ), + ) + + +def compact_bbox_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + o = overrides or {} + portals = o.get("portal_ids") or o.get("portals") or [] + if isinstance(portals, str): + portals = [portals] + frozen = o.get("frozen_ids") or [] + if isinstance(frozen, str): + frozen = [frozen] + outlier = o.get("outlier_only") + if outlier is None: + outlier_only = True + else: + outlier_only = str(outlier).strip().lower() not in {"0", "false", "no", "off"} + return { + "portal_ids": [str(x) for x in portals if str(x).strip()], + "frozen_ids": {str(x) for x in frozen if str(x).strip()}, + "min_scale": float(o.get("min_scale") or 0.72), + "step": float(o.get("step") or 0.03), + "max_clearance_slack": int(o.get("max_clearance_slack") or 80), + "outlier_only": outlier_only, + } 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 index bb212ba..b1bc66a 100644 --- 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 @@ -1,9 +1,13 @@ -"""Dual-portal basic units: parallel lanes + straight/回 chains + tails. +"""Dual-portal basic units: ellipse petal arcs + 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. +Detection walks eyes **top→down** (core–core → core–agg → agg–agg) and +greedily maximizes membership. + +**Objective**: minimize crossings while keeping spread; **keep the eye +interior hollow** (portals only on the chord; AN/corridors on arcs; avoid +mid stacking / overlaps). Residual mesh chords OK; do not polish-fix the eye. """ from __future__ import annotations @@ -102,12 +106,136 @@ def _collect_tails( return tails +def _unit_from_hub_paths( + state: LayoutState, + a: str, + b: str, + paths: list[list[str]], +) -> DualUnit: + names = state.names + pa, pb = a, b + out_paths = [list(p) for p in paths] + if names.get(pa, pa) > names.get(pb, pb): + pa, pb = pb, pa + out_paths = [list(reversed(p)) for p in out_paths] + core = {pa, pb} + for p in out_paths: + core.update(p) + return DualUnit( + portal_a=pa, + portal_b=pb, + paths=out_paths, + tails=_collect_tails(state, core), + ) + + +def _interior_of(unit: DualUnit) -> set[str]: + interior: set[str] = set() + for p in unit.paths: + interior |= set(p[1:-1]) + return interior + + +def _greedy_take_by_size( + candidates: list[DualUnit], + *, + used_interior: set[str], + max_units: int, + already: list[DualUnit], +) -> None: + """Claim largest-membership units first (CN eyes should swallow most NEs).""" + ranked = sorted(candidates, key=lambda u: (-len(u.member_ids()), u.portal_a, u.portal_b)) + for u in ranked: + if len(already) >= max_units: + return + interior = _interior_of(u) + if not interior or interior & used_interior: + continue + if any({x.portal_a, x.portal_b} == {u.portal_a, u.portal_b} for x in already): + continue + used_interior |= interior + already.append(u) + + +def _portal_eye_tier(layer: str | None) -> int: + """Lower = higher in fabric (eyes walk top→down).""" + ly = (layer or "").strip().lower() + if ly == "core": + return 0 + if ly == "agg": + return 1 + if ly == "access": + return 2 + return 3 + + +def _eye_portal_key( + portal_a: str, + portal_b: str, + layers: dict[str, str], +) -> tuple[int, int]: + """Sorted portal tiers: (0,0)=core–core, (0,1)=core–agg, (1,1)=agg–agg…""" + t = sorted( + ( + _portal_eye_tier(layers.get(portal_a)), + _portal_eye_tier(layers.get(portal_b)), + ) + ) + return (t[0], t[1]) + + +def _hub_pair_candidates( + state: LayoutState, + hubs_a: list[str], + hubs_b: list[str], + *, + core_set: set[str], + allow_same: bool, +) -> list[DualUnit]: + """Build dual units for hub pairs; when allow_same, iterate i list[DualUnit]: - """Detect dual-portal eye units; interiors exclusive, portals may overlap.""" + """Detect dual-portal eye units; interiors exclusive, portals may overlap. + + Eyes walk **top→down**: core–core → core–agg → agg–agg, each tier + greedily maximizing membership; access rings fill leftovers. + Crossings are not a detection gate. + """ 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"} @@ -116,68 +244,58 @@ def find_dual_portal_units( 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], + cores = sorted( + [n for n in 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: + aggs = sorted( + [n for n in an_set if n in adj], + key=lambda n: (-len(adj.get(n, ())), names.get(n, n)), + ) + + # 1) Core–core + _greedy_take_by_size( + _hub_pair_candidates( + state, cores, cores, core_set=core_set, allow_same=True + ), + used_interior=used_interior, + max_units=max_units, + already=units, + ) + # 2) Core–agg + if len(units) < max_units and cores and aggs: + _greedy_take_by_size( + _hub_pair_candidates( + state, cores, aggs, core_set=core_set, allow_same=False + ), + used_interior=used_interior, + max_units=max_units, + already=units, + ) + # 3) Agg–agg + if len(units) < max_units and len(aggs) >= 2: + _greedy_take_by_size( + _hub_pair_candidates( + state, aggs, aggs, core_set=core_set, allow_same=True + ), + used_interior=used_interior, + max_units=max_units, + already=units, + ) + + # 4) Access/AN metro rings (bottom leftovers). + if ens and len(units) < max_units: + groups = _find_two_portal_ring_groups(ens, adj, names, an_set) + an_cands: list[DualUnit] = [] + for g in groups: + a, b = g["portals"] # type: ignore[misc] + paths = list(g["paths"]) # type: ignore[arg-type] + if any({u.portal_a, u.portal_b} == {a, b} for u in units): 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) - ) + an_cands.append(_unit_from_hub_paths(state, a, b, paths)) + _greedy_take_by_size( + an_cands, used_interior=used_interior, max_units=max_units, already=units + ) for i, u in enumerate(units): u.unit_id = i @@ -199,6 +317,71 @@ def _normalize_paths( return paths +def _path_first_hop(path: list[str]) -> str: + return path[1] if len(path) > 2 else "" + + +def _path_last_hop(path: list[str]) -> str: + return path[-2] if len(path) > 2 else "" + + +def _order_paths_for_nest( + paths: list[list[str]], + names: dict[str, str], +) -> list[list[str]]: + """Order corridors to cut spine crossings while nesting short→inner. + + Barycenter-align first/last hops across portals, then split ±y so + same-side bands stay nested by length (distribution kept). + """ + if len(paths) <= 1: + return list(paths) + + def hop_key(nid: str) -> str: + return names.get(nid, nid) + + order = sorted( + paths, + key=lambda p: ( + hop_key(_path_first_hop(p)), + len(p), + hop_key(_path_last_hop(p)), + ), + ) + for _ in range(5): + idx = {id(p): i for i, p in enumerate(order)} + a_pos: dict[str, list[float]] = {} + b_pos: dict[str, list[float]] = {} + for p in order: + a_pos.setdefault(_path_first_hop(p), []).append(float(idx[id(p)])) + b_pos.setdefault(_path_last_hop(p), []).append(float(idx[id(p)])) + a_rank = {s: sum(vs) / len(vs) for s, vs in a_pos.items()} + b_rank = {s: sum(vs) / len(vs) for s, vs in b_pos.items()} + order = sorted( + order, + key=lambda p: ( + 0.55 * a_rank.get(_path_first_hop(p), 0.0) + + 0.45 * b_rank.get(_path_last_hop(p), 0.0), + len(p), + hop_key(_path_first_hop(p)), + ), + ) + + upper: list[list[str]] = [] + lower: list[list[str]] = [] + for i, p in enumerate(order): + (upper if i % 2 == 0 else lower).append(p) + upper.sort(key=len) + lower.sort(key=len) + out: list[list[str]] = [] + for i in range(max(len(upper), len(lower))): + if i < len(upper): + out.append(upper[i]) + if i < len(lower): + out.append(lower[i]) + return out + + def classify_dual_unit(unit: DualUnit) -> str: """petal = multi-corridor (parallel lanes); else straight (no 回字).""" paths = _normalize_paths(unit) @@ -226,49 +409,169 @@ def _place_chain_straight( pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1)) +def _place_petal_bands( + paths: list[list[str]], + *, + a: str, + b: str, + rx: float, + ry_step: float, + chord_gap: float = 160.0, +) -> dict[str, tuple[float, float]]: + """Nested half-ellipse bands; chord = portals only (hollow eye interior). + + Short corridors inner, longer outer. Shared nodes claimed by first path. + Soft apex offset so single-mid corridors do not stack on the mid vertical. + ``chord_gap`` kept for call-site compat (unused). + """ + del chord_gap + pos: dict[str, tuple[float, float]] = {a: (-rx, 0.0), b: (rx, 0.0)} + + band_i = 0 + for p in paths: + mid = p[1:-1] + if not mid: + continue + side = 1 if band_i % 2 == 0 else -1 + nest = band_i // 2 + ry = ry_step * (nest + 1) + band_i += 1 + m = len(p) + + # One mid (CN–AN–CN): park on left/right lobe — keep eye interior open. + if len(mid) == 1: + n = mid[0] + if n not in pos: + lobe = 1.0 if nest % 2 == 0 else -1.0 + ang = side * (math.pi / 2.0 + lobe * 0.45) + pos[n] = (rx * math.cos(ang), ry * math.sin(ang)) + continue + + for j, n in enumerate(p): + if n in (a, b): + continue + if n in pos: + continue + t = j / (m - 1) if m > 1 else 0.5 + ang = math.pi * (1.0 - t) + if side < 0: + ang = -ang + x = rx * math.cos(ang) + y = ry * math.sin(ang) + # Soft hollow: do not sit on the exact mid vertical (visual spine). + if abs(x) < rx * 0.08: + x = math.copysign(rx * 0.08, x if abs(x) > 1e-9 else float(side)) + # Keep roughly on the ellipse by scaling y down slightly. + y = y * 0.98 + pos[n] = (x, y) + return pos + + +def _unit_member_links( + paths: list[list[str]], + a: str, + b: str, + state: LayoutState, +) -> list[tuple[str, str]]: + members = {a, b} + for p in paths: + members.update(p) + return [e for e in state.links if e[0] in members and e[1] in members] + + +def _best_path_order_for_crossings( + paths: list[list[str]], + *, + a: str, + b: str, + rx: float, + ry_step: float, + state: LayoutState, + names: dict[str, str], + chord_gap: float = 160.0, +) -> list[list[str]]: + """Pick nest order: barycenter seed + adjacent same-side swaps to cut x.""" + base = _order_paths_for_nest(paths, names) + if len(base) <= 2: + return base + + links = _unit_member_links(base, a, b, state) + + def score(order: list[list[str]]) -> int: + pos = _place_petal_bands( + order, a=a, b=b, rx=rx, ry_step=ry_step, chord_gap=chord_gap + ) + return count_edge_crossings(pos, links) + + best = list(base) + best_x = score(best) + # Adjacent swaps within the interleaved list (preserves ±y nesting pattern). + improved = True + rounds = 0 + while improved and rounds < 24: + improved = False + rounds += 1 + for i in range(len(best) - 1): + trial = list(best) + trial[i], trial[i + 1] = trial[i + 1], trial[i] + x = score(trial) + if x < best_x: + best, best_x = trial, x + improved = True + break + return best + + 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 回字). + """Beautify one unit: petal→nested ellipse arcs; chains→outward fans. - 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. + Goal: fewer crossings, readable spread, **hollow eye interior**, low + overlap. Portals on x-axis only; corridors on ± ellipse bands; **long + tails park outside the eye** (do not pierce nested rings). """ 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) + max_mid = max((len(p) - 2 for p in paths), default=0) + rx = max( + float(params.an_gap) * 2.5, + pitch * max(7.0, float(max_mid) + 2.0), + 1100.0, + ) + ry_step = max(float(params.side) * 1.8, float(params.lane) * 1.15, pitch * 1.15, 320.0) + ordered = _best_path_order_for_crossings( + paths, + a=a, + b=b, + rx=rx, + ry_step=ry_step, + state=state, + names=state.names, + chord_gap=max(pitch * 0.85, 150.0), + ) + pos = _place_petal_bands( + ordered, + a=a, + b=b, + rx=rx, + ry_step=ry_step, + chord_gap=max(pitch * 0.85, 150.0), + ) + half = rx + band_n = sum(1 for p in ordered if p[1:-1]) + ry_park = ry_step * max(1, (band_n + 1) // 2 + 1) else: - # Single corridor / chain body — always straight between portals. + 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 = {a: (-half, 0.0), b: (half, 0.0)} body: list[str] = [] if paths: body = list(paths[0][1:-1]) @@ -283,13 +586,54 @@ def beautify_dual_unit_positions( pitch=max(pitch, (2.0 * half) / (len(body) + 1)), pos=pos, ) + ry_park = max(float(params.lane), float(params.side), 220.0) - # Tails: always straight H/V spurs (stack parallel if many). + # Eye envelope from corridor/portal placement (tails must stay outside). + eye_rx = max((abs(xy[0]) for xy in pos.values()), default=half) + eye_ry = max((abs(xy[1]) for xy in pos.values()), default=ry_park) + eye_rx = max(eye_rx, half) + eye_ry = max(eye_ry, ry_park * 0.5, pitch * 2.0) + + def _outside_start( + ax: float, ay: float, *, fan_i: int + ) -> tuple[float, float, float, float]: + """Return (x0,y0, ux,uy) for the first tail node — fully outside the eye.""" + # Near mid vertical / apex: park on outer top/bottom shelf, grow sideways. + if abs(ax) < eye_rx * 0.35: + side_y = 1.0 if ay >= 0 else -1.0 + if abs(ay) < 1e-6: + side_y = 1.0 if fan_i % 2 == 0 else -1.0 + y0 = side_y * (eye_ry + pitch * 1.25) + dir_x = 1.0 if ax >= 0 else -1.0 + if abs(ax) < 1e-6: + dir_x = 1.0 if fan_i % 2 == 0 else -1.0 + # Stagger parallel shelves for many apex tails. + y0 += side_y * ((fan_i // 2) * pitch * 0.55) + x0 = ax + dir_x * pitch * 0.6 + return x0, y0, dir_x, 0.0 + + # Otherwise: jump outside along outward ray, then continue outward. + ang = math.atan2(ay, ax) + lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1) + ang += lat * 0.18 + ux, uy = math.cos(ang), math.sin(ang) + # Ellipse-ish clearance along this ray. + ca, sa = abs(ux), abs(uy) + r_hit = eye_rx * eye_ry / max(1e-6, math.hypot(eye_ry * ca, eye_rx * sa)) + r0 = max(math.hypot(ax, ay), r_hit) + pitch * 1.1 + return ux * r0, uy * r0, ux, uy + + # Tails: long chains park fully outside the eye (do not pierce rings). used = set(pos) - for ti, chain in enumerate(unit.tails): + attach_fan: dict[str, int] = {} + # Longer first so they claim outer shelves. + tail_order = sorted( + enumerate(unit.tails), + key=lambda it: (-len(it[1] or []), it[0]), + ) + for ti, chain in tail_order: 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 @@ -304,25 +648,92 @@ def beautify_dual_unit_positions( if attach is None: attach = a if ti % 2 == 0 else b ax, ay = pos[attach] - y_off = (ti % 3 - 1) * pitch * 0.45 + fan_i = attach_fan.get(attach, 0) + attach_fan[attach] = fan_i + 1 + + long_chain = len(fresh) >= 3 if attach == a: - origin = (ax, ay + y_off) - direc = (-1.0, 0.0) + stub_ux, stub_uy = -1.0, 0.35 if fan_i % 2 == 0 else -0.35 elif attach == b: - origin = (ax, ay + y_off) - direc = (1.0, 0.0) + stub_ux, stub_uy = 1.0, 0.35 if fan_i % 2 == 0 else -0.35 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 - ) + stub_ux, stub_uy = ax, ay + if abs(stub_ux) + abs(stub_uy) < 1e-6: + stub_ux, stub_uy = (0.0, 1.0 if ti % 2 == 0 else -1.0) + + if long_chain or kind == "petal": + # Petal eye: keep all tail nodes outside envelope (even short ones + # if they would otherwise climb through bands). + x0, y0, ux, uy = _outside_start(ax, ay, fan_i=fan_i) + step = pitch * (1.0 if long_chain else 0.9) + px, py = -uy, ux + for i, n in enumerate(fresh): + if n in pos: + continue + x = x0 + ux * step * i + y = y0 + uy * step * i + for _ in range(12): + # Must stay outside eye box and not stack. + outside = abs(x) >= eye_rx * 0.92 or abs(y) >= eye_ry * 0.92 + hit = any( + abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4 + for ox, oy in pos.values() + ) + if outside and not hit: + break + if not outside: + # Push further out along ray / shelf. + x += ux * pitch * 0.5 + y += uy * pitch * 0.5 + if abs(ux) < 0.2 and abs(uy) > 0.8: + # shelf mode: grow sideways + x += (1.0 if ax >= 0 else -1.0) * pitch * 0.5 + else: + x += px * pitch * 0.45 + y += py * pitch * 0.45 + pos[n] = (x, y) + else: + bn = math.hypot(stub_ux, stub_uy) or 1.0 + ux, uy = stub_ux / bn, stub_uy / bn + px, py = -uy, ux + lat = ((fan_i + 1) // 2) * (1 if fan_i % 2 else -1) + ang = lat * 0.22 + ux2 = ux * math.cos(ang) + px * math.sin(ang) + uy2 = uy * math.cos(ang) + py * math.sin(ang) + nrm = math.hypot(ux2, uy2) or 1.0 + ux2, uy2 = ux2 / nrm, uy2 / nrm + step = pitch * 0.95 + for i, n in enumerate(fresh): + if n in pos: + continue + x = ax + ux2 * step * (i + 1) + y = ay + uy2 * step * (i + 1) + for _ in range(10): + hit = any( + abs(x - ox) < pitch * 0.4 and abs(y - oy) < pitch * 0.4 + for ox, oy in pos.values() + ) + if not hit: + break + x += px * pitch * 0.45 + y += py * pitch * 0.45 + pos[n] = (x, y) 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) + # Outer ellipse parking — keep off portal chord / hollow mid. + n_left = len(leftovers) + if n_left: + sorted_left = sorted(leftovers, key=lambda x: state.names.get(x, x)) + for i, n in enumerate(sorted_left): + t = (i + 0.5) / n_left + ang = math.pi * (0.12 + 0.76 * t) + if i % 2: + ang = -ang + pos[n] = ( + eye_rx * 1.05 * math.cos(ang), + max(eye_ry, ry_park) * 1.1 * math.sin(ang), + ) return pos @@ -332,7 +743,7 @@ def layout_dual_unit_positions( unit: DualUnit, params: LayoutParams | None = None, ) -> dict[str, tuple[float, float]]: - """Unit local layout — lanes / 回 / straight beautify (zero-cross target).""" + """Unit local layout — ellipse petal / straight beautify.""" return beautify_dual_unit_positions(state, unit, params) @@ -342,8 +753,9 @@ def _uncross_unit( pinned: set[str], *, max_rounds: int = 80, + preserve_side: bool = True, ) -> dict[str, tuple[float, float]]: - """Greedy: move lower-degree free endpoint vertically to kill crossings.""" + """Greedy: nudge free endpoints to kill crossings without flipping eye sides.""" from netx_topology_mcp.layout_metrics import segments_properly_intersect out = dict(pos) @@ -360,9 +772,9 @@ def _uncross_unit( 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] + aa, bb, pa, pb = segs[i] c, d, pc, pd = segs[j] - if len({a, b, c, d}) < 4: + if len({aa, bb, c, d}) < 4: continue if segments_properly_intersect(pa, pb, pc, pd): bad.append((i, j)) @@ -397,6 +809,8 @@ def _uncross_unit( 480.0, -480.0, ): + if preserve_side and abs(y) > 1e-6 and (y + dy) * y < 0: + continue # do not flip across the portal chord for dx in (0.0, 40.0, -40.0, 80.0, -80.0): trial = dict(out) trial[n] = (x + dx, y + dy) @@ -412,7 +826,6 @@ def _uncross_unit( break return out - def layout_dual_unit( state: LayoutState, params: LayoutParams | None = None, @@ -421,8 +834,14 @@ def layout_dual_unit( unit_id: int | None = None, portal_a: str | None = None, portal_b: str | None = None, + require_zero_cross: bool = False, ) -> OpResult: - """Layout the (single) dual-portal unit on this canvas; require crossings=0.""" + """Layout the dual-portal unit; default accepts residual crossings. + + Placement objective: **minimize unit crossings while keeping spread** + (nested ellipse bands). Selection still prefers top eyes + max + membership. `require_zero_cross=True` restores the old hard gate. + """ params = params or LayoutParams() units = find_dual_portal_units(state) if unit is None else [unit] if unit_id is not None: @@ -452,32 +871,30 @@ def layout_dual_unit( 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, - ) - ] + units = [_unit_from_hub_paths(state, portal_a, portal_b, paths)] + units[0].unit_id = 0 if not units: - # Whole canvas as one unit attempt: pick best hub pair cover + # Whole canvas as one unit: prefer core–core, else densest hub pair by cover. + core_hubs = [ + n + for n, ly in state.layers.items() + if ly == "core" and n in state.adj + ] 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] + core_hubs.sort(key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n))) + pair_order: list[tuple[str, str]] = [] + for i, a in enumerate(core_hubs): + for b in core_hubs[i + 1 :]: + pair_order.append((a, b)) + if len(hubs) >= 2 and not pair_order: + pair_order.append((hubs[0], hubs[1])) + best: DualUnit | None = None + for a, b in pair_order: forbid = { n for n, ly in state.layers.items() @@ -488,22 +905,14 @@ def layout_dual_unit( 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 len(paths) < 2: + continue + cand = _unit_from_hub_paths(state, a, b, paths) + cand.unit_id = 0 + if best is None or len(cand.member_ids()) > len(best.member_ids()): + best = cand + if best is not None: + units = [best] if not units: return OpResult( state=state, @@ -513,8 +922,15 @@ def layout_dual_unit( 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())) + def _pick_key(x: DualUnit) -> tuple: + # Top-down eye (core→agg→access), then max membership. + return ( + _eye_portal_key(x.portal_a, x.portal_b, state.layers), + -len(x.member_ids()), + ) + + # Prefer top-layer eyes, then max membership (min of tier, -n). + u = min(units, key=_pick_key) pos = layout_dual_unit_positions(state, u, params) out = state.copy() for n, xy in pos.items(): @@ -535,21 +951,30 @@ def layout_dual_unit( 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). + # Light uncross: keep portal side signs so distribution holds. pinned = {u.portal_a, u.portal_b} - out.positions = _uncross_unit(out.positions, unit_links, pinned) + is_petal = classify_dual_unit(u) == "petal" + out.positions = _uncross_unit( + out.positions, + unit_links, + pinned, + preserve_side=is_petal, + ) x_unit = count_edge_crossings(out.positions, unit_links) - # Gentle overlap resolve only if still zero-cross; else skip. + # Gentle overlap resolve if it does not worsen unit crossings. stf = out - if x_unit == 0: - cand = fix_overlaps_local(out, params).state + cand = fix_overlaps_local(out, params).state + if u.portal_a in pos: cand.positions[u.portal_a] = pos[u.portal_a] + if u.portal_b in pos: 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 + for n in pinned: + if n in pos: + cand.positions[n] = pos[n] + x_after = count_edge_crossings(cand.positions, unit_links) + if x_after <= x_unit: + stf = cand x_unit = count_edge_crossings(stf.positions, unit_links) x = count_edge_crossings(stf.positions, stf.links) @@ -564,8 +989,10 @@ def layout_dual_unit( "unit": u.as_dict(state.names), "unit_crossings": x_unit, "parked": parked, + "require_zero_cross": require_zero_cross, } - accepted = x_unit == 0 + # Default: accept any laid-out eye (max coverage). Optional hard gate. + accepted = (x_unit == 0) if require_zero_cross else True return OpResult( state=stf, moved=moved, @@ -576,11 +1003,11 @@ def layout_dual_unit( "global_crossings": x, "accepted": accepted, "parked": parked, + "require_zero_cross": require_zero_cross, + "zero_cross": x_unit == 0, }, note=( - f"layout_dual_unit:paths={len(u.paths)} x=0" - if accepted - else f"dual_unit_crossings={x_unit}" + f"layout_dual_unit:paths={len(u.paths)} nodes={len(members)} x={x_unit}" ), ) @@ -603,10 +1030,11 @@ def dual_units_report( "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." + "Dual-portal eyes walk top→down (core–core → core–agg → agg–agg), " + "then maximize membership. Petal objective = fewer crossings with " + "readable spread (ellipse bands; hollow mid; low overlap). " + "Residual mesh chords OK; re-run layout_dual_unit if the eye " + "breaks — do not polish-straighten." ), } @@ -624,4 +1052,6 @@ def dual_unit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[st v = overrides.get(key) if v is not None and str(v).strip(): out[key] = str(v).strip() + if "require_zero_cross" in overrides: + out["require_zero_cross"] = bool(overrides.get("require_zero_cross")) 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 index 4e44ee8..0d2a283 100644 --- 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 @@ -2,32 +2,17 @@ from __future__ import annotations -import re from collections import deque +from netx_topology_mcp.layout_ops.level_util import infer_layer -_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()] +__all__ = [ + "infer_layer", + "bbox", + "connected_components", + "order_ans", + "chain_order", +] def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]: @@ -164,9 +149,11 @@ def build_state_from_nodes_edges( ) -> "LayoutState": # noqa: F821 from netx_topology_mcp.layout_ops.state import LayoutState from netx_topology_mcp.layout_metrics import collapse_links + from netx_topology_mcp.layout_ops.level_util import _parse_level names: dict[str, str] = {} layers: dict[str, str] = {} + levels: dict[str, float] = {} ids: list[str] = [] for n in nodes: if not isinstance(n, dict): @@ -177,7 +164,10 @@ def build_state_from_nodes_edges( 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")) + layers[fid] = infer_layer(nm, n.get("role"), n.get("level")) + lv = _parse_level(n.get("level")) + if lv is not None: + levels[fid] = float(lv) adj: dict[str, set[str]] = {i: set() for i in ids} links = collapse_links(edges) @@ -203,6 +193,7 @@ def build_state_from_nodes_edges( positions=positions, names=names, layers=layers, + levels=levels, 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/level_util.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py new file mode 100644 index 0000000..279b792 --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/level_util.py @@ -0,0 +1,209 @@ +"""Map fabric level / role / name → layout layer key; level y-bands.""" + +from __future__ import annotations + +import math +import re +from typing import Any + +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult + + +_ROLE_TO_LAYER = { + "external": "external", + "core": "core", + "cn": "core", + "aggregation": "agg", + "aggregate": "agg", + "agg": "agg", + "an": "agg", + "access": "access", + "en": "access", + "edge": "access", + "cpe": "access", +} + +# Top → bottom on canvas (smaller fabric level sits higher). +_BAND_ORDER = ("external", "core", "agg", "access", "other") + + +def _parse_level(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, str): + s = value.strip() + if not s: + return None + try: + value = float(s) + except ValueError: + return None + try: + lv = float(value) + except (TypeError, ValueError): + return None + if not math.isfinite(lv): + return None + return lv + + +def infer_layer( + name: str, + role: str | None = None, + level: float | None = None, +) -> str: + """Map level / role / name token → external|core|agg|access|other.""" + lv = _parse_level(level) + if lv is not None: + maj = int(math.floor(lv)) + if maj <= 0: + return "external" + if maj == 1: + return "core" + if maj == 2: + return "agg" + return "access" + 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 apply_level_bands( + state: LayoutState, + params: LayoutParams | None = None, + *, + y0: float = 120.0, + band_gap: float = 320.0, + pitch: float | None = None, + preserve_x: bool = True, + layers: tuple[str, ...] | None = None, + max_per_row: int = 24, + row_gap: float | None = None, +) -> OpResult: + """Snap nodes into horizontal bands by layer (external→…→access). + + Large bands wrap into multiple rows (``max_per_row``) so hundreds of + access nodes are not crushed onto one y-line. Within a row, x is either + kept (preserve_x) then gently de-overlapped to ``pitch``, or re-spaced + by name order. + """ + params = params or LayoutParams() + step = float(pitch if pitch is not None else max(params.pitch, 200.0)) + rgap = float(row_gap if row_gap is not None else max(step * 0.85, 170.0)) + per_row = max(4, int(max_per_row or 24)) + order = tuple(layers) if layers else _BAND_ORDER + by: dict[str, list[str]] = {ly: [] for ly in order} + for nid in state.positions: + ly = state.layers.get(nid) or "other" + if ly not in by: + ly = "other" + by.setdefault(ly, []) + by[ly].append(nid) + + pos = dict(state.positions) + moved: set[str] = set() + band_notes: list[dict[str, Any]] = [] + y_cursor = float(y0) + + def _place_row(ids: list[str], y: float) -> None: + nonlocal moved + if not ids: + return + if preserve_x: + ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n))) + xs = [float(pos[n][0]) for n in ids] + # Enforce min pitch left→right without reversing order. + fixed: list[float] = [] + for i, x in enumerate(xs): + if i == 0: + fixed.append(x) + else: + fixed.append(max(x, fixed[-1] + step)) + for n, x in zip(ids, fixed): + nxt = (x, y) + if nxt != pos[n]: + moved.add(n) + pos[n] = nxt + else: + ids = sorted(ids, key=lambda n: state.names.get(n, n)) + for i, n in enumerate(ids): + nxt = (40.0 + i * step, y) + if nxt != pos.get(n): + moved.add(n) + pos[n] = nxt + + for ly in order: + ids = by.get(ly) or [] + if not ids: + continue + if preserve_x: + ids = sorted(ids, key=lambda n: (pos[n][0], state.names.get(n, n))) + else: + ids = sorted(ids, key=lambda n: state.names.get(n, n)) + rows = [ids[i : i + per_row] for i in range(0, len(ids), per_row)] + y_band0 = y_cursor + for ri, row in enumerate(rows): + _place_row(row, y_cursor + ri * rgap) + band_h = max(0, len(rows) - 1) * rgap + band_notes.append( + { + "layer": ly, + "count": len(ids), + "y": y_band0, + "rows": len(rows), + "y_max": y_band0 + band_h, + } + ) + y_cursor = y_band0 + band_h + float(band_gap) + + out = state.copy() + out.positions = pos + out.meta = dict(out.meta or {}) + out.meta["level_bands"] = { + "bands": band_notes, + "preserve_x": preserve_x, + "max_per_row": per_row, + } + return OpResult( + state=out, + moved=moved, + op="level_bands", + params={ + "bands": band_notes, + "preserve_x": preserve_x, + "y0": y0, + "band_gap": band_gap, + "pitch": step, + "max_per_row": per_row, + "moved_n": len(moved), + }, + note=f"level_bands:{len(band_notes)} bands moved={len(moved)}", + ) + + +def level_bands_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + out: dict[str, Any] = {} + if not overrides: + return out + for key, cast in ( + ("y0", float), + ("band_gap", float), + ("pitch", float), + ("row_gap", float), + ("max_per_row", int), + ): + if overrides.get(key) is not None: + try: + out[key] = cast(overrides[key]) + except (TypeError, ValueError): + pass + if "preserve_x" in overrides: + out["preserve_x"] = bool(overrides.get("preserve_x")) + raw = overrides.get("layers") + if isinstance(raw, (list, tuple)) and raw: + out["layers"] = tuple(str(x) for x in raw if str(x).strip()) + return out 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 8bf51e3..c71f500 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 @@ -1,7 +1,9 @@ """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. +Agent workflow: +- preview → pick rank 1..3 → apply +- round=true: one batch, auto-apply #1 per hot node +- until_limit=true: loop single-point sweeps until stall (eye polish) """ from __future__ import annotations @@ -12,8 +14,10 @@ from typing import Any from netx_topology_mcp.layout_metrics import ( count_edge_crossings, crossing_participation, + crossing_participation_full, crossings_involving_node, node_footprint, + top_crossing_edges, top_crossing_nodes, ) from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult @@ -29,6 +33,164 @@ def _protect_is_off(protect_rigid: bool | str) -> bool: return protect_rigid in (False, "false", "off", "none", "0") +_LAYER_ALIASES: dict[str, str] = { + "external": "external", + "ext": "external", + "0": "external", + "core": "core", + "cn": "core", + "1": "core", + "agg": "agg", + "an": "agg", + "aggregation": "agg", + "aggregate": "agg", + "2": "agg", + "access": "access", + "en": "access", + "edge": "access", + "cpe": "access", + "3": "access", + "other": "other", +} + + +def _coerce_str_list(raw: Any) -> list[str]: + if raw is None: + return [] + if isinstance(raw, str): + return [p.strip() for p in raw.replace(";", ",").split(",") if p.strip()] + if isinstance(raw, (list, tuple, set)): + out: list[str] = [] + for x in raw: + s = str(x).strip() + if s: + out.append(s) + return out + s = str(raw).strip() + return [s] if s else [] + + +def _normalize_layer_token(tok: str) -> str | None: + t = str(tok or "").strip().lower() + if not t: + return None + if t in _LAYER_ALIASES: + return _LAYER_ALIASES[t] + # numeric-looking → major band + try: + lv = float(t) + except ValueError: + return t if t in {"external", "core", "agg", "access", "other"} else None + maj = int(math.floor(lv)) + if maj <= 0: + return "external" + if maj == 1: + return "core" + if maj == 2: + return "agg" + return "access" + + +def _major_from_level(lv: float) -> int: + maj = int(math.floor(float(lv))) + if maj < 0: + return 0 + return maj + + +def ids_matching_freeze_layers_levels( + state: LayoutState, + freeze_layers: list[str] | None = None, + freeze_levels: list[Any] | None = None, +) -> set[str]: + """Resolve freeze_layers / freeze_levels → fabric_node_id set. + + - ``freeze_layers``: ``core|agg|access|external`` (aliases CN/AN/EN/1/2/3 OK) + - ``freeze_levels``: fabric numeric levels. Integer-ish (1 / 2) freezes that + major band; fractional (1.1) matches exact ``state.levels`` when present, + else falls back to major band via ``state.layers``. + """ + want_layers: set[str] = set() + for tok in _coerce_str_list(freeze_layers): + layer = _normalize_layer_token(tok) + if layer: + want_layers.add(layer) + + want_majors: set[int] = set() + want_exact: set[float] = set() + for tok in _coerce_str_list(freeze_levels): + try: + lv = float(tok) + except (TypeError, ValueError): + layer = _normalize_layer_token(tok) + if layer: + want_layers.add(layer) + continue + if not math.isfinite(lv): + continue + # Exact sub-level when not an integer (1.1); majors when 1 / 2.0 + if abs(lv - round(lv)) < 1e-9: + want_majors.add(int(round(lv))) + layer = _normalize_layer_token(str(int(round(lv)))) + if layer: + want_layers.add(layer) + else: + want_exact.add(float(lv)) + + if not want_layers and not want_majors and not want_exact: + return set() + + out: set[str] = set() + levels = getattr(state, "levels", None) or {} + layers = getattr(state, "layers", None) or {} + + if want_exact and levels: + for nid, lv in levels.items(): + if float(lv) in want_exact or any( + abs(float(lv) - e) < 1e-6 for e in want_exact + ): + out.add(str(nid)) + + if want_majors and levels: + for nid, lv in levels.items(): + if _major_from_level(lv) in want_majors: + out.add(str(nid)) + + if want_layers and layers: + for nid, layer in layers.items(): + if str(layer) in want_layers: + out.add(str(nid)) + + # No numeric levels on state → majors already folded into want_layers above. + return out + + +def _merge_explicit_freeze( + state: LayoutState, + *, + protect_rigid: bool | str, + frozen_ids: set[str] | None, + freeze_layers: list[str] | None = None, + freeze_levels: list[Any] | None = None, + always_honor_explicit: bool = True, +) -> set[str]: + """Combine protect_rigid portals with explicit id/layer/level freezes.""" + frozen = _resolve_frozen(state, protect_rigid, frozen_ids) + layer_ids = ids_matching_freeze_layers_levels( + state, freeze_layers, freeze_levels + ) + if always_honor_explicit or not _protect_is_off(protect_rigid): + if frozen_ids: + frozen |= {str(x) for x in frozen_ids if str(x)} + frozen |= layer_ids + elif layer_ids: + # Explicit layer/level freeze always means user intent. + frozen |= layer_ids + if frozen_ids: + frozen |= {str(x) for x in frozen_ids if str(x)} + return frozen + + def _resolve_frozen( st: LayoutState, protect_rigid: bool | str, @@ -51,14 +213,35 @@ def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]): return (x + minx, y + miny, x + maxx, y + maxy) +def _centers_may_overlap( + ax: float, + ay: float, + bx: float, + by: float, + fa: tuple[float, float, float, float], + fb: tuple[float, float, float, float], + *, + pad: float = 12.0, +) -> bool: + """Coarse AABB reach check — label width can exceed 80px.""" + ra_x = max(abs(fa[0]), abs(fa[2])) + pad + ra_y = max(abs(fa[1]), abs(fa[3])) + pad + rb_x = max(abs(fb[0]), abs(fb[2])) + pad + rb_y = max(abs(fb[1]), abs(fb[3])) + pad + return abs(ax - bx) <= ra_x + rb_x and abs(ay - by) <= ra_y + rb_y + + 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) + ax, ay = pos[node] + fa = node_footprint(names.get(node, "")) 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: + fb = node_footprint(names.get(b, "")) + if not _centers_may_overlap(ax, ay, x, y, fa, fb): continue bx0, by0, bx1, by1 = _box(b, pos, names) if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0: @@ -66,6 +249,47 @@ def _node_overlaps_any( return False +def _moved_invades_outsiders( + members: set[str], + pos: dict[str, tuple[float, float]], + names: dict[str, str], +) -> bool: + """True if any moved node footprint overlaps a non-member (invasion).""" + for n in members: + if n not in pos: + continue + ax0, ay0, ax1, ay1 = _box(n, pos, names) + nx, ny = pos[n] + fa = node_footprint(names.get(n, "")) + for b, (x, y) in pos.items(): + if b in members: + continue + fb = node_footprint(names.get(b, "")) + if not _centers_may_overlap(nx, ny, x, y, fa, fb): + 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 _has_any_footprint_overlap( + pos: dict[str, tuple[float, float]], names: dict[str, str] +) -> bool: + """Full pairwise footprint gate — matches apply overlaps_remain.""" + ids = list(pos.keys()) + boxes: list[tuple[str, tuple[float, float, float, float]]] = [] + for n in ids: + boxes.append((n, _box(n, pos, names))) + for i, (_a, ab) in enumerate(boxes): + ax0, ay0, ax1, ay1 = ab + for _b, bb in boxes[i + 1 :]: + bx0, by0, bx1, by1 = bb + 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]], @@ -111,6 +335,8 @@ def _radii_for_jump(jump: float) -> list[float]: radii = radii + [640.0, 800.0] if jump > 1200: radii = radii + [1200.0, 1600.0, 2200.0, min(jump, 3200.0)] + if jump > 3200: + radii = radii + [min(jump * 0.7, 5000.0), min(jump * 0.9, jump)] return [r for r in radii if r <= jump + 1] @@ -175,6 +401,92 @@ def _neighbor_guides( return out +def _far_field_guides( + pos: dict[str, tuple[float, float]], + node: str, + adj: dict[str, set[str]], + jump: float, +) -> list[tuple[float, float, float, float]]: + """Samples that leave the local blob — bbox rim + radial escape. + + Single-node polar rings around the current xy often miss global untangles + (long chord edges). Push candidates toward the hull exterior and along the + vector from the graph / neighbor centroid through the node. + """ + if node not in pos or jump < 200: + return [] + x, y = pos[node] + xs = [p[0] for p in pos.values()] + ys = [p[1] for p in pos.values()] + if not xs: + return [] + xmin, xmax = min(xs), max(xs) + ymin, ymax = min(ys), max(ys) + gcx = 0.5 * (xmin + xmax) + gcy = 0.5 * (ymin + ymax) + out: list[tuple[float, float, float, float]] = [] + + def _push(nx: float, ny: float) -> None: + r = math.hypot(nx - x, ny - y) + if r < 40.0 or r > jump + 1.0: + return + ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0 + out.append((nx, ny, r, ang)) + + # Radial escape from graph center through node. + dx, dy = x - gcx, y - gcy + gl = math.hypot(dx, dy) or 1.0 + gx, gy = dx / gl, dy / gl + for dist in (0.45 * jump, 0.7 * jump, 0.92 * jump): + _push(x + gx * dist, y + gy * dist) + _push(gcx + gx * (gl + dist), gcy + gy * (gl + dist)) + + # Neighbor-centroid radial (often differs from graph center on eyes). + nbs = [pos[v] for v in adj.get(node, ()) if v in pos] + if nbs: + ncx = sum(p[0] for p in nbs) / len(nbs) + ncy = sum(p[1] for p in nbs) / len(nbs) + ndx, ndy = x - ncx, y - ncy + nl = math.hypot(ndx, ndy) or 1.0 + nxu, nyu = ndx / nl, ndy / nl + for dist in (0.5 * jump, 0.85 * jump): + _push(x + nxu * dist, y + nyu * dist) + # Reflect across neighbor centroid (flip to clear side). + _push(ncx - nxu * dist, ncy - nyu * dist) + + # Bbox rim / corners — good for long chords that pierce the drawing. + pad = min(jump, max(600.0, 0.35 * jump)) + rim = [ + (xmin - pad, y), + (xmax + pad, y), + (x, ymin - pad), + (x, ymax + pad), + (xmin - pad, ymin - pad), + (xmax + pad, ymin - pad), + (xmin - pad, ymax + pad), + (xmax + pad, ymax + pad), + (xmin - pad, gcy), + (xmax + pad, gcy), + (gcx, ymin - pad), + (gcx, ymax + pad), + ] + for nx, ny in rim: + _push(nx, ny) + + # Small rings around each neighbor (park beside spoke tip). + for nb in list(adj.get(node, ()))[:8]: + if nb not in pos: + continue + bx, by = pos[nb] + for ang in (0, 45, 90, 135, 180, 225, 270, 315): + rad = math.radians(ang) + for rr in (220.0, 480.0, 900.0): + if rr > jump: + continue + _push(bx + rr * math.cos(rad), by + rr * math.sin(rad)) + return out + + def _score_key(c: dict[str, Any]) -> tuple: return ( int(c["crossings"]["global"]), @@ -187,6 +499,7 @@ def _score_key(c: dict[str, Any]) -> tuple: # Verdict weights for the components a single-node orbit move mainly affects. _W_CROSS = 0.18 _W_CLR = 0.08 +_W_AXIS = 0.06 def _crossing_part_score(crossings: int, *, n_links: int, n_nodes: int) -> float: @@ -205,11 +518,92 @@ def _crossing_part_score(crossings: int, *, n_links: int, n_nodes: int) -> float return 1.0 - (cpl - cpl_ok) / max(cpl_bad - cpl_ok, 1e-9) -def _verdict_partial(crossings: int, clearance_score: float, *, n_links: int, n_nodes: int) -> float: - """Weighted crossing+clearance slice of verdict.total (higher is better).""" +def _incident_axis_frac( + nid: str, + pos: dict[str, tuple[float, float]], + adj: dict[str, set[str]], + *, + tol_px: float = 8.0, +) -> float: + """Fraction of incident edges that are near-horizontal or near-vertical.""" + nbs = [v for v in adj.get(nid, ()) if v in pos and v != nid] + if not nbs or nid not in pos: + return 0.0 + ax, ay = pos[nid] + good = 0 + for v in nbs: + bx, by = pos[v] + if abs(ax - bx) <= tol_px or abs(ay - by) <= tol_px: + good += 1 + return good / float(len(nbs)) + + +def _local_clearance_hits( + nid: str, + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + adj: dict[str, set[str]], + *, + thr: float = 40.0, + endpoint_t: float = 0.05, +) -> int: + """Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E)).""" + from netx_topology_mcp.layout_metrics import point_segment_dist + + if nid not in pos: + return 0 + thr_f = float(thr) + et = float(endpoint_t) + hits = 0 + p = pos[nid] + # nid sitting on non-incident edges + 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 < thr_f: + hits += 1 + # other nodes sitting on edges incident to nid + incident = {nid, *adj.get(nid, ())} + inc_edges = [(nid, v) for v in adj.get(nid, ()) if v in pos] + for other, (ox, oy) in pos.items(): + if other in incident: + continue + for a, b in inc_edges: + d, t = point_segment_dist((ox, oy), pos[a], pos[b]) + if t <= et or t >= 1.0 - et: + continue + if d < thr_f: + hits += 1 + return hits + + +def _local_clearance_score(hits: int, *, n_nodes: int) -> float: + """Map local hit count to a soft [0,1] score (higher=better).""" + # Cap relative to graph size so one node doesn't dominate. + hit_frac = float(hits) / max(float(n_nodes) * 0.05, 1.0) + if hit_frac <= 0.0: + return 1.0 + if hit_frac >= 1.0: + return 0.0 + return 1.0 - hit_frac + + +def _verdict_partial( + crossings: int, + clearance_score: float, + *, + n_links: int, + n_nodes: int, + axis_frac: float = 0.0, +) -> float: + """Weighted crossing+clearance(+axis) slice of verdict (higher is better).""" return ( _W_CROSS * _crossing_part_score(crossings, n_links=n_links, n_nodes=n_nodes) + _W_CLR * max(0.0, min(1.0, float(clearance_score))) + + _W_AXIS * max(0.0, min(1.0, float(axis_frac))) ) @@ -222,45 +616,53 @@ def _rerank_by_total( *, global0: int, re_rank_n: int = 20, + adj: dict[str, set[str]] | None = None, ) -> tuple[list[dict[str, Any]], bool, float]: - """Re-rank top candidates by weighted crossing+clearance (verdict.total slice). + """Re-rank top candidates by crossing+local-clearance+axis (fast). - Returns ``(ranked, clearance_ok, base_partial)``. When edge_clearance is - skipped on large graphs, ``clearance_ok`` is False and ranking is unchanged. + Uses local clearance around ``nid`` so until_limit stays interactive on + mid-size eyes. Returns ``(ranked, ok, base_partial)``. """ - from netx_topology_mcp.layout_metrics import compute_edge_clearance - + del names # names reserved for future label-aware clearance n_nodes = len(pos) n_links = len(links) - ec0 = compute_edge_clearance(pos, links, names=names, top_n=1) - if ec0.get("edge_clearance_skipped"): - return scored, False, 0.0 - - base_clr = float(ec0.get("edge_clearance_score") or 1.0) - base_partial = _verdict_partial(int(global0), base_clr, n_links=n_links, n_nodes=n_nodes) + adj_m = adj or {} + base_hits = _local_clearance_hits(nid, pos, links, adj_m) + base_clr = _local_clearance_score(base_hits, n_nodes=n_nodes) + base_axis = _incident_axis_frac(nid, pos, adj_m) + base_partial = _verdict_partial( + int(global0), + base_clr, + n_links=n_links, + n_nodes=n_nodes, + axis_frac=base_axis, + ) 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) - if ec.get("edge_clearance_skipped"): - return scored, False, base_partial - clr_s = float(ec.get("edge_clearance_score") or 1.0) - c["edge_clearance_hits"] = int(ec.get("edge_clearance_hits") or 0) + hits = _local_clearance_hits(nid, trial, links, adj_m) + clr_s = _local_clearance_score(hits, n_nodes=n_nodes) + axis_s = _incident_axis_frac(nid, trial, adj_m) + c["edge_clearance_hits"] = int(hits) c["edge_clearance_score"] = clr_s + c["axis_frac"] = round(axis_s, 4) c["verdict_partial"] = _verdict_partial( int(c["crossings"]["global"]), clr_s, n_links=n_links, n_nodes=n_nodes, + axis_frac=axis_s, ) head = sorted( scored[:n], key=lambda c: ( -float(c.get("verdict_partial") or 0.0), + int(c.get("edge_clearance_hits") or 0), int(c["crossings"]["incident"]), float(c.get("stretch") or 1.0), + float(c.get("r") or 0.0), ), ) return head + scored[n:], True, base_partial @@ -367,6 +769,8 @@ def orbit_sweep_node( cand_cap: int = 280, protect_rigid: bool | str = "off", frozen_ids: set[str] | None = None, + freeze_layers: list[str] | None = None, + freeze_levels: list[Any] | None = None, top_k: int = 3, y_min: float | None = None, y_max: float | None = None, @@ -376,6 +780,7 @@ def orbit_sweep_node( 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. + ``freeze_layers`` / ``freeze_levels`` always freeze matched nodes. """ params = params or LayoutParams() st = state @@ -392,6 +797,7 @@ def orbit_sweep_node( } frozen = _resolve_frozen(st, protect_rigid, frozen_ids) + frozen |= ids_matching_freeze_layers_levels(st, freeze_layers, freeze_levels) if nid in frozen: return { "ok": False, @@ -406,21 +812,26 @@ def orbit_sweep_node( 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. + # Local untangle stays near neighbor centroid; metro bridges / far-field + # escapes (max_jump≫1k) must leave the unit blob — nbs_cap ≈ jump*3.5. if jump > 1200: - nbs_cap = jump * 2.5 + nbs_cap = max(jump * 3.5, 6000.0) else: nbs_cap = max(_MAX_FROM_NBS, jump * 1.25) + # High-incident hotspots: allow even farther from neighbor centroid. 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) + if local0 >= 8 and jump > 1200: + nbs_cap = max(nbs_cap, jump * 4.5) - # Coarse grid. + # Prefer far-field / neighbor guides before dense polar rings so cand_cap + # does not truncate the escapes that actually cut long-chord crossings. 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: list[tuple[float, float, float, float]] = [] + samples.extend(_far_field_guides(pos, nid, adj, jump)) samples.extend(_neighbor_guides(pos, nid, adj, jump)) # Explicit samples toward each neighbor (incl. long bridges). for nb in adj.get(nid, ()): @@ -438,16 +849,17 @@ def orbit_sweep_node( 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): + for r in (180.0, 360.0, 640.0, 1200.0, min(jump, 2200.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)) + samples.extend(_polar_grid(x0, y0, jump=jump, angle_step=coarse_step)) # 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))) + key = (int(round(sx / 8.0) * 8), int(round(sy / 8.0) * 8)) if key in seen: continue seen.add(key) @@ -541,11 +953,15 @@ def orbit_sweep_node( base_partial = 0.0 if use_total: scored, clr_ok, base_partial = _rerank_by_total( - scored, nid, pos, names, links, global0=int(global0) + scored, nid, pos, names, links, global0=int(global0), adj=adj ) if not clr_ok: # Large-graph clearance skip → fall back to crossing-only ranking. use_total = False + else: + for c in scored: + c.setdefault("verdict_partial", float(base_partial)) + c.setdefault("edge_clearance_score", 0.0) # Prefer improving moves; still return best even if none improve. if use_total: improving = [ @@ -640,6 +1056,459 @@ def apply_orbit_pick( ) +def _truthy_flag(v: Any) -> bool: + if v is True: + return True + if v is False or v is None: + return False + return str(v).strip().lower() not in {"0", "false", "no", "off", ""} + + +def _select_stretch_pick( + sweep: dict[str, Any], + *, + max_stretch: float, + min_delta: int, + objective: str = "crossing", +) -> tuple[int, dict[str, Any]] | None: + """Pick best improving candidate with stretch gate (1-based pick index). + + ``objective=total``: accept clearance/partial gains when crossings do not + rise (flat Δg=0 allowed). Crossing-only mode still requires Δg≤−min_delta. + """ + if int(sweep.get("improving_n") or 0) <= 0: + return None + use_total = str(objective).lower() in {"total", "score", "multi"} + stretch_cap = float(max_stretch) + + if use_total: + ranked_t: list[tuple[float, int, int, float, int, dict[str, Any]]] = [] + for i, c in enumerate(sweep.get("candidates") or [], start=1): + if not isinstance(c, dict) or bool(c.get("ov")): + continue + try: + d = int(((c.get("delta") or {}).get("global") or 0)) + except (TypeError, ValueError): + continue + if d > 0: + continue # hard: never raise crossings + partial = float(c.get("verdict_partial") or 0.0) + clr_hits = int(c.get("edge_clearance_hits") or 10**9) + st = float(c.get("stretch") or 0.0) + ranked_t.append((-partial, clr_hits, d, st, i, c)) + if not ranked_t: + return None + ranked_t.sort() + for _neg_p, _hits, d, st, i, c in ranked_t: + if st <= stretch_cap or (d < 0 and abs(d) >= 3): + return i, c + _neg_p, _hits, d, st, i, c = ranked_t[0] + if st <= stretch_cap * 1.75: + return i, c + if d < 0 and abs(d) >= 5: + return i, c + if d == 0 and st <= stretch_cap: + return i, c + return None + + ranked: list[tuple[int, float, int, dict[str, Any]]] = [] + for i, c in enumerate(sweep.get("candidates") or [], start=1): + if not isinstance(c, dict): + continue + if bool(c.get("ov")): + continue + try: + d = int(((c.get("delta") or {}).get("global") or 0)) + except (TypeError, ValueError): + continue + if d >= 0 or abs(d) < max(1, int(min_delta)): + continue + st = float(c.get("stretch") or 0.0) + ranked.append((d, st, i, c)) + if not ranked: + return None + ranked.sort(key=lambda t: (t[0], t[1])) + for d, st, i, c in ranked: + if st <= stretch_cap or abs(d) >= 3: + return i, c + d, st, i, c = ranked[0] + if st <= stretch_cap * 1.75: + return i, c + if abs(d) >= 5: + return i, c + return None + + +def _until_limit_queue( + pos: dict[str, tuple[float, float]], + links: list[tuple[str, str]], + adj: dict[str, set[str]], + names: dict[str, str], + *, + frozen: set[str], + max_degree: int, + top_edges_n: int = 16, + prefer_low_degree: bool = True, + hard_degree_cap: int | None = None, + include_clearance: bool = False, +) -> list[str]: + """Hotspot queue: crossing nodes + top-edge endpoints (+ clearance hits). + + ``max_degree`` soft-prefers corridor nodes; ``hard_degree_cap`` (default + max(max_degree, 16)) still admits high-deg hubs that own most hits. + """ + _n, node_hit, edge_hit = crossing_participation_full(pos, links) + hard = int(hard_degree_cap) if hard_degree_cap is not None else max(int(max_degree), 16) + soft = max(1, int(max_degree)) + # hits, deg, edge_boost, clr_boost + scored: dict[str, tuple[int, int, int, int]] = {} + for nid, hits in (node_hit or {}).items(): + if nid in frozen or nid not in pos: + continue + deg = len(adj.get(nid, ())) + if deg > hard or hits <= 0: + continue + scored[nid] = (int(hits), deg, 0, 0) + for row in top_crossing_edges( + pos, + links, + names=names, + top_n=top_edges_n, + edge_participation=edge_hit, + ): + hits = int(row.get("crossing_hits") or 0) + for key in ("a_node_id", "b_node_id"): + nid = str(row.get(key) or "") + if not nid or nid in frozen or nid not in pos: + continue + deg = len(adj.get(nid, ())) + if deg > hard: + continue + prev = scored.get(nid) + base_hits = max(hits, prev[0] if prev else 0) + scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0) + if include_clearance: + from netx_topology_mcp.layout_metrics import compute_edge_clearance + + ec = compute_edge_clearance(pos, links, names=names, top_n=24) + if not ec.get("edge_clearance_skipped"): + for row in ec.get("top_edge_hits") or []: + nid = str(row.get("fabric_node_id") or "") + if not nid or nid in frozen or nid not in pos: + continue + deg = len(adj.get(nid, ())) + if deg > hard: + continue + prev = scored.get(nid) + if prev: + scored[nid] = (prev[0], prev[1], prev[2], 1) + else: + # Pure clearance obstacle — give a synthetic hit so it queues. + scored[nid] = (1, deg, 0, 1) + if not scored: + return [] + items = list(scored.items()) + if prefer_low_degree: + items.sort( + key=lambda kv: ( + kv[1][3], # clearance obstacles first when include_clearance + kv[1][2], + kv[1][0], + 1 if kv[1][1] <= soft else 0, + -kv[1][1], + ), + reverse=True, + ) + else: + items.sort( + key=lambda kv: (kv[1][3], kv[1][0], kv[1][2], -kv[1][1]), + reverse=True, + ) + return [nid for nid, _ in items] + + +def orbit_sweep_until_limit( + state: LayoutState, + *, + params: LayoutParams | None = None, + max_degree: int = 14, + 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 = "portals", + frozen_ids: set[str] | None = None, + freeze_layers: list[str] | None = None, + freeze_levels: list[Any] | None = None, + y_min: float | None = None, + y_max: float | None = None, + objective: str = "crossing", + max_moves: int = 40, + stall_limit: int = 12, + max_stretch: float = 32.0, + min_delta: int = 1, + scan_cap: int = 32, + top_k: int = 8, + prefer_low_degree: bool = True, + cand_cap: int = 360, + bundle: bool = True, + bundle_max: int = 10, +) -> OpResult: + """Loop single-node orbit picks until no improving hotspot (eye polish). + + Unlike ``round`` (one batch, always pick#1), this re-ranks after each apply, + gates stretch, and defaults ``protect_rigid=portals`` so CN portals stay put. + + When single-point stalls, ``bundle=True`` (default) tries rigid + chain / ring+chain contract→orbit→expand moves. + + Freeze = protect portals ∪ ``portal_ids``/``frozen_ids`` ∪ ``freeze_layers`` ∪ + ``freeze_levels``. + """ + 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 = _merge_explicit_freeze( + st, + protect_rigid=protect_rigid, + frozen_ids=frozen_ids, + freeze_layers=freeze_layers, + freeze_levels=freeze_levels, + always_honor_explicit=True, + ) + + jump = float(max_jump if max_jump is not None else 4000.0) + jump = max(120.0, min(jump, _MAX_JUMP_CAP)) + obj = "total" if str(objective).lower() in {"total", "score", "multi"} else "crossing" + global0 = count_edge_crossings(pos, links) + use_bundle = bool(bundle) + + moves: list[dict[str, Any]] = [] + tried_fail: set[str] = set() + stall = 0 + stop_reason = "max_moves" + moved: set[str] = set() + rounds = 0 + bundle_moves = 0 + bundle_exhausted = False + max_moves_i = max(1, int(max_moves)) + stall_lim = max(1, int(stall_limit)) + scan_n = max(4, int(scan_cap)) + cand_n = max(120, int(cand_cap)) + + while len(moves) < max_moves_i and stall < stall_lim: + rounds += 1 + try: + from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat + + touch_heartbeat() + raise_if_cancelled() + except ImportError: + pass + queue = [ + nid + for nid in _until_limit_queue( + pos, + links, + adj, + names, + frozen=frozen, + max_degree=max(1, int(max_degree)), + prefer_low_degree=prefer_low_degree, + include_clearance=(obj == "total"), + ) + if nid not in tried_fail + ] + applied = False + for nid in queue[:scan_n]: + st.positions = pos + sweep = orbit_sweep_node( + st, + nid, + params=params, + max_jump=jump, + angle_step=angle_step, + nn_floor=nn_floor, + min_angle_sep=min_angle_sep, + cand_cap=cand_n, + protect_rigid="off", + frozen_ids=frozen, + top_k=max(3, int(top_k)), + y_min=y_min, + y_max=y_max, + objective=obj, + ) + if not sweep.get("ok"): + tried_fail.add(nid) + continue + deg_n = len(adj.get(nid, ())) + # Soft max_degree: still allow high-deg hubs that made the queue + # via hard_degree_cap; only skip absurd stars. + if deg_n > max(int(max_degree), 16): + tried_fail.add(nid) + continue + picked = _select_stretch_pick( + sweep, + max_stretch=float(max_stretch), + min_delta=int(min_delta), + objective=obj, + ) + if not picked: + tried_fail.add(nid) + continue + pick_i, cand = picked + prev_xy = pos[nid] + pos[nid] = (float(cand["x"]), float(cand["y"])) + if _node_overlaps_any(nid, pos, names): + pos[nid] = prev_xy + tried_fail.add(nid) + continue + moved.add(nid) + g_after = int((cand.get("crossings") or {}).get("global") or 0) + delta_g = int((cand.get("delta") or {}).get("global") or 0) + moves.append( + { + "node_id": nid, + "name": names.get(nid, nid), + "degree": deg_n, + "pick": pick_i, + "xy": [round(float(cand["x"]), 1), round(float(cand["y"]), 1)], + "delta": delta_g, + "stretch": float(cand.get("stretch") or 0.0), + "crossings": g_after, + "mode": "point", + } + ) + tried_fail.clear() + stall = 0 + applied = True + bundle_exhausted = False + break + + if not applied and use_bundle and not bundle_exhausted: + from netx_topology_mcp.layout_ops.bundle_orbit import ( + bundle_orbit_until_progress, + ) + + try: + from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat + + touch_heartbeat() + raise_if_cancelled() + except ImportError: + pass + st.positions = pos + bop = bundle_orbit_until_progress( + st, + frozen_ids=frozen, + max_jump=max(jump, 5000.0), + max_bundles=max(4, min(12, int(bundle_max))), + min_delta=int(min_delta), + cand_cap=min(cand_n, 64), + angle_step=max(angle_step or 20, 24), + nn_floor=nn_floor, + params=params, + ) + if bop.moved: + trial = dict(bop.state.positions) + mem = set(bop.moved) + # Expand must not invade or leave any footprint pairs (apply gate). + if _has_any_footprint_overlap(trial, names): + bundle_exhausted = True + else: + pos = trial + moved |= mem + bmeta = bop.params or {} + moves.append( + { + "node_id": bmeta.get("tip_id"), + "name": names.get(str(bmeta.get("tip_id") or ""), ""), + "kind": bmeta.get("kind"), + "bundle": bmeta.get("bundle"), + "member_n": bmeta.get("member_n"), + "delta": int(bmeta.get("delta") or 0), + "crossings": int( + bmeta.get("end_crossings") + or bmeta.get("crossings") + or 0 + ), + "expand_scale": bmeta.get("expand_scale"), + "mode": "bundle", + } + ) + bundle_moves += 1 + bundle_exhausted = False + tried_fail.clear() + stall = 0 + applied = True + else: + bundle_exhausted = True + + if not applied: + if not queue and not use_bundle: + stop_reason = "no_candidates" + break + stall += 1 + if queue: + tried_fail.add(queue[0]) + if stall >= stall_lim: + stop_reason = "stall" + break + + if len(moves) >= max_moves_i: + stop_reason = "max_moves" + elif stop_reason == "max_moves" and stall >= stall_lim: + stop_reason = "stall" + if not moves and stop_reason == "max_moves": + stop_reason = "no_candidates" + + st.positions = pos + st.last_moved = moved + end_g = count_edge_crossings(pos, links) + meta = { + "mode": "until_limit", + "start_crossings": global0, + "end_crossings": end_g, + "delta_crossings": int(end_g) - int(global0), + "moved_n": len(moved), + "moves_n": len(moves), + "moves": moves, + "bundle_moves": bundle_moves, + "bundle": use_bundle, + "rounds": rounds, + "stall": stall, + "stop_reason": stop_reason, + "max_degree": int(max_degree), + "max_jump": jump, + "max_stretch": float(max_stretch), + "min_delta": int(min_delta), + "objective": obj, + "protect_rigid": ( + "off" + if _protect_is_off(protect_rigid) + else str(protect_rigid) + ), + "frozen_n": len(frozen), + "freeze_layers": list(freeze_layers or []), + "freeze_levels": list(freeze_levels or []), + } + st.meta["orbit_sweep"] = meta + return OpResult( + state=st, + moved=moved, + op="orbit_sweep_until_limit", + params=meta, + note=( + f"orbit_sweep_until_limit {global0}->{end_g} " + f"moves={len(moves)} bundle={bundle_moves} " + f"stop={stop_reason} frozen={len(frozen)}" + ), + ) + + def orbit_sweep_round( state: LayoutState, *, @@ -652,6 +1521,8 @@ def orbit_sweep_round( min_angle_sep: float = 35.0, protect_rigid: bool | str = "off", frozen_ids: set[str] | None = None, + freeze_layers: list[str] | None = None, + freeze_levels: list[Any] | None = None, focus_ids: list[str] | None = None, y_min: float | None = None, y_max: float | None = None, @@ -660,6 +1531,7 @@ def orbit_sweep_round( """Scan hot nodes; auto-apply each node's rank-1 if the active objective improves. Default ``protect_rigid=off`` (may move portals). Opt in with portals/all. + Explicit ``portal_ids`` / ``freeze_layers`` / ``freeze_levels`` are always honored. """ params = params or LayoutParams() st = state.copy() @@ -668,7 +1540,14 @@ def orbit_sweep_round( links = list(st.links) adj = {n: set(st.adj.get(n, ())) for n in pos} - frozen = _resolve_frozen(st, protect_rigid, frozen_ids) + frozen = _merge_explicit_freeze( + st, + protect_rigid=protect_rigid, + frozen_ids=frozen_ids, + freeze_layers=freeze_layers, + freeze_levels=freeze_levels, + always_honor_explicit=True, + ) global0 = count_edge_crossings(pos, links) hit = crossing_participation(pos, links)[1] @@ -803,26 +1682,49 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A 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"]))) + out["pick"] = max(1, min(12, 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 + out["round"] = _truthy_flag(o.get("round")) + until = o.get("until_limit") + if until is None: + until = o.get("until_stall") + if until is not None: + out["until_limit"] = _truthy_flag(until) for key, cast, default in ( ("top_n", int, 12), - ("max_degree", int, 9), + ("max_degree", int, 14), ("angle_step", int, None), - ("cand_cap", int, 280), + ("cand_cap", int, 360), ("top_k", int, 3), + ("max_moves", int, 40), + ("stall_limit", int, 12), + ("min_delta", int, 1), + ("scan_cap", int, 32), ): if key not in o or o[key] is None: - if default is not None: + if default is not None and ( + key in ("top_n", "max_degree", "cand_cap", "top_k") + or out.get("until_limit") + ): + # until_limit-only knobs only default when flag on + if key in ( + "max_moves", + "stall_limit", + "min_delta", + "scan_cap", + ) and not out.get("until_limit"): + continue + if key == "max_degree" and out.get("until_limit"): + out[key] = 14 + continue + if key == "cand_cap" and out.get("until_limit"): + out[key] = 360 + continue + if key == "top_k" and out.get("until_limit"): + out[key] = 8 + continue out[key] = default continue try: @@ -835,6 +1737,15 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A out["max_jump"] = float(o["max_jump"]) except (TypeError, ValueError): pass + elif out.get("until_limit"): + out["max_jump"] = 4000.0 + if o.get("max_stretch") is not None: + try: + out["max_stretch"] = float(o["max_stretch"]) + except (TypeError, ValueError): + pass + elif out.get("until_limit"): + out["max_stretch"] = 32.0 if o.get("nn_floor") is not None: try: out["nn_floor"] = float(o["nn_floor"]) @@ -849,6 +1760,23 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A out["min_angle_sep"] = 35.0 else: out["min_angle_sep"] = 35.0 + if "prefer_low_degree" in o: + out["prefer_low_degree"] = _truthy_flag(o.get("prefer_low_degree")) + elif out.get("until_limit"): + out["prefer_low_degree"] = True + if "bundle" in o: + out["bundle"] = _truthy_flag(o.get("bundle")) + elif "bundle_orbit" in o: + out["bundle"] = _truthy_flag(o.get("bundle_orbit")) + elif out.get("until_limit"): + out["bundle"] = True + if o.get("bundle_max") is not None: + try: + out["bundle_max"] = max(1, min(40, int(o["bundle_max"]))) + except (TypeError, ValueError): + out["bundle_max"] = 10 + elif out.get("until_limit"): + out["bundle_max"] = 10 if "protect_rigid" in o: v = o["protect_rigid"] if isinstance(v, bool): @@ -863,16 +1791,32 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A out["protect_rigid"] = "all" else: out["protect_rigid"] = key + elif out.get("until_limit"): + # Eye polish: freeze portals by default (unlike single-node / round). + out["protect_rigid"] = "portals" 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") + # portal freeze from polish path / until_limit + raw_p = o.get("portal_ids") or o.get("frozen_ids") if isinstance(raw_p, list): out["frozen_ids"] = {str(x) for x in raw_p if str(x)} + elif isinstance(raw_p, set): + out["frozen_ids"] = {str(x) for x in raw_p if str(x)} + # freeze by layout layer and/or fabric numeric level + flayers = o.get("freeze_layers") + if flayers is None: + flayers = o.get("frozen_layers") + if flayers is not None: + out["freeze_layers"] = _coerce_str_list(flayers) + flevels = o.get("freeze_levels") + if flevels is None: + flevels = o.get("frozen_levels") + if flevels is not None: + out["freeze_levels"] = _coerce_str_list(flevels) # 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: @@ -883,6 +1827,20 @@ def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A # 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" + # until_limit defaults: larger jump + more picks in the pool + if out.get("until_limit"): + if "max_jump" not in out: + out["max_jump"] = 4000.0 + if "max_degree" not in o or o.get("max_degree") is None: + out["max_degree"] = 14 + if "top_k" not in o or o.get("top_k") is None: + out["top_k"] = 8 + if "cand_cap" not in o or o.get("cand_cap") is None: + out["cand_cap"] = 360 + if "max_stretch" not in out: + out["max_stretch"] = 32.0 + if "scan_cap" not in o or o.get("scan_cap") is None: + out["scan_cap"] = 32 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 index 2484547..aff469f 100644 --- 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 @@ -161,7 +161,8 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]: "cross_max_moves": 40, "cross_max_sweeps": 6, "cross_cand_cap": 220, - "straighten": True, + # Off by default: straighten flattens dual-unit petal eyes into H-chains. + "straighten": False, "skip_dual_full_x": False, "untangle_rounds": 120, "untangle_moves": 5, @@ -170,6 +171,27 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]: } +def _has_petal_dual_eye(state: LayoutState) -> bool: + """True when a multi-corridor dual-unit covers a large share of the canvas.""" + try: + from netx_topology_mcp.layout_ops.dual_units import ( + classify_dual_unit, + find_dual_portal_units, + ) + except Exception: + return False + units = find_dual_portal_units(state, max_units=24) + if not units: + return False + n = max(1, len(state.positions) or len(state.names) or len(state.adj)) + for u in units: + if classify_dual_unit(u) != "petal": + continue + if len(u.member_ids()) >= max(8, int(0.25 * n)): + return True + return False + + def press_hot_edges( state: LayoutState, params: LayoutParams | None = None, @@ -586,17 +608,47 @@ def polish_crossings( top_n: int | None = None, max_moves: int | None = None, max_sweeps: int | None = None, + preserve_dual_eye: bool | None = None, ) -> OpResult: - """Pipeline: park phantoms → straighten → hot_edges → crossers → untangle.""" + """Pipeline: park phantoms → (optional straighten) → hot → crossers → untangle. + + Petal dual-unit eyes: straighten stays off unless ``straighten=true`` — + channel flattening destroys parallel-lane eye geometry. + """ 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)) + preserve = True if preserve_dual_eye is None else bool(preserve_dual_eye) + petal_eye = False + eye_portals: list[str] = [] + if preserve: + try: + from netx_topology_mcp.layout_ops.dual_units import ( + classify_dual_unit, + find_dual_portal_units, + ) + + units = find_dual_portal_units(st, max_units=24) + n = max(1, len(st.positions) or len(st.names) or len(st.adj)) + for u in units: + if classify_dual_unit(u) != "petal": + continue + if len(u.member_ids()) >= max(8, int(0.25 * n)): + petal_eye = True + eye_portals = [u.portal_a, u.portal_b] + break + except Exception: + petal_eye = _has_petal_dual_eye(st) + if portal_ids is None and eye_portals: + portal_ids = eye_portals # Giant graphs: never allow straighten even if caller asks (stalls for minutes). if len(st.links) >= 800: do_straighten = False + elif petal_eye and straighten is not True: + do_straighten = False else: do_straighten = ( bool(budget["straighten"]) if straighten is None else bool(straighten) @@ -720,6 +772,8 @@ def polish_crossings( "focus_n": len(focus), "budget": budget, "straighten": do_straighten, + "preserve_dual_eye": preserve, + "petal_eye_detected": petal_eye, "untangle_rounds": untangle_rounds, } return OpResult( @@ -753,6 +807,13 @@ def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, A if isinstance(v, bool) else str(v).strip().lower() in {"1", "true", "yes", "on"} ) + if "preserve_dual_eye" in o: + v = o["preserve_dual_eye"] + out["preserve_dual_eye"] = ( + 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): diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py new file mode 100644 index 0000000..94389ce --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/pull_far_chains.py @@ -0,0 +1,354 @@ +"""Pull far deg≤2 chains / isolates toward the eye portal anchor. + +Unlike uniform ``compact_bbox``, only moves far corridors (and deg=0 orphans). +Hard gates: crossings not rise, overlaps stay 0, portals frozen. +Scale is toward the portal mid-point (not the chain hub) so outer hubs +still shrink the canvas bbox. +""" + +from __future__ import annotations + +import math +from typing import Any + +from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance +from netx_topology_mcp.layout_ops.graph_util import bbox +from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap +from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult +from netx_topology_mcp.layout_topology_quality import extract_chain_paths + + +def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]: + pts = [pos[n] for n in frozen if n in pos] + if pts: + return ( + sum(p[0] for p in pts) / len(pts), + sum(p[1] for p in pts) / len(pts), + ) + xs = [p[0] for p in pos.values()] or [0.0] + ys = [p[1] for p in pos.values()] or [0.0] + return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) + + +def _area(pos: dict[str, tuple[float, float]]) -> float: + if len(pos) < 2: + return 1.0 + x0, y0, x1, y1 = bbox(pos) + return max((x1 - x0) * (y1 - y0), 1.0) + + +def _clr_hits(pos, links, names) -> int: + ec = compute_edge_clearance(pos, links, names=names, top_n=1) + if ec.get("edge_clearance_skipped"): + return 10**9 + return int(ec.get("edge_clearance_hits") or 0) + + +def _try_scale( + pos: dict[str, tuple[float, float]], + mobile: list[str], + *, + cx: float, + cy: float, + scale: float, + links, + names, + g0: int, + clr0: int, + max_clearance_slack: int, +) -> tuple[dict[str, tuple[float, float]], int, int, float] | None: + trial = dict(pos) + for n in mobile: + x, y = pos[n] + trial[n] = (cx + (x - cx) * scale, cy + (y - cy) * scale) + if _has_any_footprint_overlap(trial, names): + return None + g1 = count_edge_crossings(trial, links) + if g1 > g0: + return None + clr1 = _clr_hits(trial, links, names) + if clr1 > clr0 + max(0, int(max_clearance_slack)): + return None + return trial, g1, clr1, _area(trial) + + +def pull_far_chains( + state: LayoutState, + params: LayoutParams | None = None, + *, + frozen_ids: set[str] | None = None, + portal_ids: list[str] | None = None, + max_chains: int = 16, + min_tip_radius: float = 1800.0, + scales: tuple[float, ...] = (0.92, 0.88, 0.84, 0.80, 0.75), + max_clearance_slack: int = 40, + pull_isolates: bool = True, +) -> OpResult: + """Shorten farthest corridors / orphans toward the portal mid-point.""" + del params + st = state.copy() + pos = dict(st.positions) + names = st.names + links = list(st.links) + adj = st.adj + frozen: set[str] = set(frozen_ids or ()) + for p in portal_ids or []: + if p: + frozen.add(str(p)) + + g0 = count_edge_crossings(pos, links) + if _has_any_footprint_overlap(pos, names): + return OpResult( + state=st, + moved=set(), + op="pull_far_chains", + note="pull_far_chains:overlaps_before", + params={"error": "overlaps_before"}, + ) + clr0 = _clr_hits(pos, links, names) + area0 = _area(pos) + cx, cy = _anchor(pos, frozen) + + chains = extract_chain_paths(adj) + scored: list[tuple[float, list[str], str]] = [] + for path in chains: + path = [n for n in path if n in pos] + if len(path) < 2: + continue + d0 = math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy) + d1 = math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy) + tip = path[0] if d0 >= d1 else path[-1] + hub = path[-1] if tip == path[0] else path[0] + if tip in frozen: + continue + tip_r = max(d0, d1) + if tip_r < float(min_tip_radius): + continue + scored.append((tip_r, path, hub)) + scored.sort(key=lambda t: t[0], reverse=True) + + moved: set[str] = set() + accepted: list[dict[str, Any]] = [] + used: set[str] = set(frozen) + + for tip_r, path, hub in scored[: max(1, int(max_chains))]: + # Scale corridor toward portal mid-point (shrinks bbox even if hub is outer). + mobile = [n for n in path if n not in used and n in pos and n not in frozen] + if len(mobile) < 1: + continue + best_local = None + best_key = None + for scale in scales: + got = _try_scale( + pos, + mobile, + cx=cx, + cy=cy, + scale=scale, + links=links, + names=names, + g0=g0, + clr0=clr0, + max_clearance_slack=max_clearance_slack, + ) + if got is None: + continue + trial, g1, clr1, area1 = got + key = (g1, area1, clr1) + if best_key is None or key < best_key: + best_key = key + best_local = (trial, scale, g1, clr1, area1) + if best_local is None: + continue + trial, scale, g1, clr1, area1 = best_local + tip = ( + path[0] + if math.hypot(pos[path[0]][0] - cx, pos[path[0]][1] - cy) + >= math.hypot(pos[path[-1]][0] - cx, pos[path[-1]][1] - cy) + else path[-1] + ) + tip_r1 = math.hypot(trial[tip][0] - cx, trial[tip][1] - cy) + if tip_r1 >= tip_r - 1.0 and area1 >= area0 * 0.999: + continue + pos = trial + g0 = g1 + clr0 = clr1 + for n in mobile: + moved.add(n) + used.add(n) + accepted.append( + { + "hub": hub, + "tip": tip, + "member_n": len(mobile), + "scale": scale, + "tip_r0": round(tip_r, 1), + "tip_r1": round(tip_r1, 1), + } + ) + + isolates_n = 0 + if pull_isolates: + orphans = [ + n + for n, nbs in adj.items() + if n in pos + and n not in used + and n not in frozen + and len(nbs) == 0 + and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius) * 0.6 + ] + orphans.sort( + key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy), + reverse=True, + ) + for n in orphans[: max(4, int(max_chains) // 2)]: + best_local = None + best_key = None + for scale in scales: + got = _try_scale( + pos, + [n], + cx=cx, + cy=cy, + scale=scale, + links=links, + names=names, + g0=g0, + clr0=clr0, + max_clearance_slack=max_clearance_slack, + ) + if got is None: + continue + trial, g1, clr1, area1 = got + key = (g1, area1, clr1) + if best_key is None or key < best_key: + best_key = key + best_local = (trial, scale, g1, clr1) + if best_local is None: + continue + trial, scale, g1, clr1 = best_local + pos = trial + g0 = g1 + clr0 = clr1 + moved.add(n) + used.add(n) + isolates_n += 1 + accepted.append( + { + "hub": None, + "tip": n, + "member_n": 1, + "scale": scale, + "isolate": True, + } + ) + + leaves = [ + n + for n, nbs in adj.items() + if n in pos + and n not in used + and n not in frozen + and len(nbs) == 1 + and math.hypot(pos[n][0] - cx, pos[n][1] - cy) >= float(min_tip_radius) + ] + leaves.sort( + key=lambda n: math.hypot(pos[n][0] - cx, pos[n][1] - cy), + reverse=True, + ) + for n in leaves[: max(4, int(max_chains) // 2)]: + best_local = None + best_key = None + for scale in scales: + got = _try_scale( + pos, + [n], + cx=cx, + cy=cy, + scale=scale, + links=links, + names=names, + g0=g0, + clr0=clr0, + max_clearance_slack=max_clearance_slack, + ) + if got is None: + continue + trial, g1, clr1, area1 = got + key = (g1, area1, clr1) + if best_key is None or key < best_key: + best_key = key + best_local = (trial, scale, g1, clr1) + if best_local is None: + continue + trial, scale, g1, clr1 = best_local + pos = trial + g0 = g1 + clr0 = clr1 + moved.add(n) + used.add(n) + accepted.append( + { + "hub": next(iter(adj.get(n) or ()), None), + "tip": n, + "member_n": 1, + "scale": scale, + "leaf": True, + } + ) + + st.positions = pos + st.last_moved = moved + g_end = count_edge_crossings(pos, links) + clr_end = _clr_hits(pos, links, names) + meta = { + "mode": "pull_far_chains", + "chains_tried": min(len(scored), max(1, int(max_chains))), + "chains_accepted": len(accepted), + "accepted_chains": accepted[:20], + "moved_n": len(moved), + "isolates_pulled": isolates_n, + "start_area": round(area0, 1), + "end_area": round(_area(pos), 1), + "start_crossings": count_edge_crossings(dict(state.positions), list(state.links)), + "end_crossings": g_end, + "start_clearance_hits": _clr_hits(dict(state.positions), list(state.links), names), + "end_clearance_hits": clr_end, + } + st.meta["pull_far_chains"] = meta + return OpResult( + state=st, + moved=moved, + op="pull_far_chains", + params=meta, + note=( + f"pull_far_chains n={len(accepted)} moved={len(moved)} " + f"area={meta['start_area']}→{meta['end_area']} " + f"x={meta['start_crossings']}→{meta['end_crossings']}" + ), + ) + + +def pull_far_chains_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]: + o = overrides or {} + portals = o.get("portal_ids") or o.get("portals") or [] + if isinstance(portals, str): + portals = [portals] + frozen = o.get("frozen_ids") or [] + if isinstance(frozen, str): + frozen = [frozen] + scales = o.get("scales") + if isinstance(scales, (list, tuple)) and scales: + sc = tuple(float(x) for x in scales) + else: + sc = (0.92, 0.88, 0.84, 0.80, 0.75) + return { + "portal_ids": [str(x) for x in portals if str(x).strip()], + "frozen_ids": {str(x) for x in frozen if str(x).strip()}, + "max_chains": int(o.get("max_chains") or 16), + "min_tip_radius": float(o.get("min_tip_radius") or 1800.0), + "scales": sc, + "max_clearance_slack": int(o.get("max_clearance_slack") or 40), + "pull_isolates": bool(o.get("pull_isolates", True)), + } 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 index df45421..afea482 100644 --- 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 @@ -20,10 +20,10 @@ def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]: 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.""" + """Tie-break only: less shared portals, then lower unit_id (stable).""" 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)) + return (shared, share_sum, int(u.unit_id)) def select_dual_unit_batch( @@ -31,53 +31,118 @@ def select_dual_unit_batch( *, max_units: int = 3, min_nodes: int = 8, - max_nodes: int = 80, - max_batch_nodes: int = 120, + max_nodes: int = 300, + max_batch_nodes: int = 400, exclude_ids: set[str] | None = None, + keep_ids: set[str] | None = None, + sink_ids: set[str] | None = None, + prefer_pure: bool = False, + prefer_core_eye: bool = True, + prefer_top_eye: bool | None = None, + layers: dict[str, str] | None = None, ) -> list[DualUnit]: - """Greedy pick detachable dual-units within size / batch caps.""" + """Greedy pick detachable dual-units within size / batch caps. + + Eyes walk **top→down** (core → agg → access): prefer core/agg portal + pairs and maximize movable membership. ``prefer_core_eye`` is an alias + for ``prefer_top_eye`` (default true). + """ exclude_ids = exclude_ids or set() + keep_ids = keep_ids or set() + layers = layers or {} + if prefer_top_eye is None: + prefer_top_eye = prefer_core_eye share = _portal_share_counts(units) candidates: list[DualUnit] = [] for u in units: members = u.member_ids() if not members: continue + movable = members - keep_ids + if sink_ids is not None: + movable = movable & sink_ids + if not movable or movable.issubset(exclude_ids): + 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): + if members and members.issubset(exclude_ids | keep_ids): + continue + # Need enough *movable* mass for this level phase. + if len(movable) < max(2, min(4, int(min_nodes) // 2)): continue candidates.append(u) - candidates.sort(key=lambda u: unit_detach_score(u, share)) + + def _movable(u: DualUnit) -> set[str]: + m = u.member_ids() - keep_ids + if sink_ids is not None: + m = m & sink_ids + return m + + def _portal_tier(nid: str) -> int: + ly = (layers.get(nid) or "").strip().lower() + if ly == "core": + return 0 + if ly == "agg": + return 1 + if ly == "access": + return 2 + return 3 + + def _eye_key(u: DualUnit) -> tuple[int, int]: + t = sorted((_portal_tier(u.portal_a), _portal_tier(u.portal_b))) + return (t[0], t[1]) + + def _score(u: DualUnit) -> tuple: + mov = _movable(u) + keep_portals = sum(1 for p in (u.portal_a, u.portal_b) if p in keep_ids) + pure_penalty = keep_portals if prefer_pure else 0 + # Top-down eye tier, then max movable / membership. + eye = _eye_key(u) if prefer_top_eye else (0, 0) + return ( + pure_penalty, + eye, + -len(mov), + -len(u.member_ids()), + *unit_detach_score(u, share), + ) + + candidates.sort(key=_score) 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} + movable = _movable(u) + interior = movable - {u.portal_a, u.portal_b} if interior & claimed: continue - next_ids = claimed | members + next_ids = claimed | movable if len(next_ids) > int(max_batch_nodes): continue picked.append(u) - claimed |= members + claimed |= movable return picked -def batch_node_ids(units: list[DualUnit]) -> list[str]: +def batch_node_ids( + units: list[DualUnit], + *, + keep_ids: set[str] | None = None, + sink_ids: set[str] | None = None, +) -> list[str]: + keep_ids = keep_ids or set() 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) + if not nid or nid in seen or nid in keep_ids: + continue + if sink_ids is not None and nid not in sink_ids: + continue + seen.add(nid) + out.append(nid) return out @@ -86,15 +151,20 @@ def leftover_batch_ids( *, max_batch_nodes: int = 120, exclude_ids: set[str] | None = None, + keep_ids: set[str] | None = None, + sink_ids: set[str] | None = None, ) -> list[str]: """When dual_units are exhausted, take a plain leftover chunk.""" exclude_ids = exclude_ids or set() + keep_ids = keep_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: + if sid in exclude_ids or sid in keep_ids: + continue + if sink_ids is not None and sid not in sink_ids: continue out.append(sid) if len(out) >= int(max_batch_nodes): 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 index 08dd19f..2476a76 100644 --- 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 @@ -43,6 +43,8 @@ class LayoutState: pinned: set[str] = field(default_factory=set) names: dict[str, str] = field(default_factory=dict) layers: dict[str, str] = field(default_factory=dict) + # Optional fabric numeric level (1 / 1.1 / 2 …); used by freeze_levels. + levels: dict[str, float] = 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) @@ -57,6 +59,7 @@ class LayoutState: pinned=set(self.pinned), names=dict(self.names), layers=dict(self.layers), + levels=dict(self.levels), links=list(self.links), adj={k: set(v) for k, v in self.adj.items()}, spine=set(self.spine), diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py new file mode 100644 index 0000000..a4a86fb --- /dev/null +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/suggest_sink_hubs.py @@ -0,0 +1,262 @@ +"""Suggest next non-dual sink batches: rank hub territories for move_nodes(park). + +After the one-shot dual_unit eye is fixed, remaining access should migrate by +hub territory (not another dual sink). This module turns structure hubs + +soft_blocks into ordered batches of fabric_node_ids. +""" + +from __future__ import annotations + +from typing import Any + + +_LAYER_RANK = {"agg": 0, "core": 1, "access": 2, "external": 3, "other": 4} + + +def _as_id_set(raw: Any) -> set[str]: + out: set[str] = set() + if raw is None: + return out + if isinstance(raw, (str, bytes)): + s = str(raw).strip() + if s: + out.add(s) + return out + if isinstance(raw, (list, tuple, set)): + for x in raw: + s = str(x or "").strip() + if s and not s.startswith("region:"): + out.add(s) + return out + + +def _portal_ids_from_dual(dual_units: dict[str, Any] | None) -> set[str]: + out: set[str] = set() + if not isinstance(dual_units, dict): + return out + for u in dual_units.get("units") or []: + if not isinstance(u, dict): + continue + for k in ("portal_a", "portal_b"): + pid = str(u.get(k) or "").strip() + if pid: + out.add(pid) + return out + + +def _hub_index(hubs: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + for h in hubs or []: + if not isinstance(h, dict): + continue + hid = str(h.get("fabric_node_id") or "").strip() + if hid: + out[hid] = h + return out + + +def _blocks_by_hub(soft_blocks: dict[str, Any] | None) -> dict[str, dict[str, Any]]: + out: dict[str, dict[str, Any]] = {} + if not isinstance(soft_blocks, dict): + return out + for b in soft_blocks.get("blocks") or []: + if not isinstance(b, dict): + continue + hid = str(b.get("hub_id") or "").strip() + if not hid: + continue + # Prefer larger territory if duplicate hub rows + prev = out.get(hid) + n = len([x for x in (b.get("node_ids") or []) if x]) + if prev is None or n > len([x for x in (prev.get("node_ids") or []) if x]): + out[hid] = b + return out + + +def suggest_sink_hub_batches( + *, + hubs: list[dict[str, Any]] | None, + soft_blocks: dict[str, Any] | None, + source_ids: set[str], + sink_ids: set[str] | None = None, + exclude_ids: set[str] | None = None, + dual_units: dict[str, Any] | None = None, + min_territory: int = 1, + min_move_n: int = 1, + top_n: int = 12, + include_hub: bool = True, + only_layers: list[str] | None = None, +) -> dict[str, Any]: + """Rank hub territories still on ``source_ids`` and not already on sink. + + Ranking (desc): remaining move count → remaining access stubs → prefer agg + over core → structure hub score. Eye portals / ``exclude_ids`` never lead a + batch (and are dropped from id lists). + """ + src = {str(x) for x in (source_ids or ()) if str(x) and not str(x).startswith("region:")} + on_sink = {str(x) for x in (sink_ids or ()) if str(x)} + excluded = set(exclude_ids or ()) + excluded |= _portal_ids_from_dual(dual_units) + + layer_allow: set[str] | None = None + if only_layers: + layer_allow = {str(x).strip().lower() for x in only_layers if str(x).strip()} + + by_hub = _hub_index(list(hubs or [])) + blocks = _blocks_by_hub(soft_blocks) + + # Ensure every structure hub has a block (fallback: hub + stub_ids). + for hid, h in by_hub.items(): + if hid in blocks: + continue + stubs = [str(x) for x in (h.get("stub_ids") or []) if str(x)] + blocks[hid] = { + "hub_id": hid, + "method": "hub_stubs", + "node_ids": [hid, *stubs], + "node_count": 1 + len(stubs), + } + + batches: list[dict[str, Any]] = [] + for hid, block in blocks.items(): + hmeta = by_hub.get(hid) or {} + layer = str(hmeta.get("layer") or "other").lower() + if layer_allow is not None and layer not in layer_allow: + continue + + raw_ids = [str(x) for x in (block.get("node_ids") or []) if str(x)] + # Still on source, not yet on sink, not excluded portals + move_ids = [ + nid + for nid in raw_ids + if nid in src and nid not in on_sink and nid not in excluded + ] + if not include_hub: + move_ids = [nid for nid in move_ids if nid != hid] + # Hub may already be on sink — still migrate remaining territory + if hid in on_sink and include_hub: + move_ids = [nid for nid in move_ids if nid != hid] + + # Dedup preserve order + seen: set[str] = set() + ordered: list[str] = [] + # Eye portals / exclude_ids never lead a batch, but leftover stubs under + # an already-sunk or portal hub may still migrate. + if include_hub and hid in src and hid not in on_sink and hid not in excluded: + ordered.append(hid) + seen.add(hid) + for nid in move_ids: + if nid in seen: + continue + seen.add(nid) + ordered.append(nid) + # Portal hub with nothing left to move (except itself) → skip + if hid in excluded and not ordered: + continue + + if len(ordered) < max(1, int(min_move_n)): + continue + + # Territory signal: prefer structure territory, else remaining stubs + struct_terr = int(hmeta.get("territory") or 0) + remaining_n = len(ordered) + # Count non-hub members as remaining territory proxy + remaining_terr = max(0, remaining_n - (1 if hid in ordered else 0)) + if remaining_terr < max(0, int(min_territory)): + continue + + access_n = int(hmeta.get("access_neighbors") or 0) + score = float(hmeta.get("score") or 0.0) + batches.append( + { + "hub_id": hid, + "hub_name": str(hmeta.get("name") or hid), + "layer": layer, + "degree": int(hmeta.get("degree") or 0), + "structure_territory": struct_terr, + "structure_access_neighbors": access_n, + "structure_score": score, + "remaining_n": remaining_n, + "remaining_territory": remaining_terr, + "block_method": str(block.get("method") or ""), + "fabric_node_ids": ordered, + "already_on_sink": hid in on_sink, + "excluded_from_batch": sorted( + nid for nid in raw_ids if nid in excluded or nid in on_sink + )[:40], + } + ) + + batches.sort( + key=lambda b: ( + -int(b["remaining_territory"]), + -int(b["remaining_n"]), + _LAYER_RANK.get(str(b["layer"]), 9), + -float(b["structure_score"]), + str(b["hub_name"]), + ) + ) + + # Orphan leftovers: source ids with no hub territory still need park batches. + covered: set[str] = set() + for b in batches: + covered |= set(b.get("fabric_node_ids") or []) + orphans = sorted( + nid + for nid in src + if nid not in on_sink and nid not in excluded and nid not in covered + ) + orphan_batches: list[dict[str, Any]] = [] + if orphans and len(batches) < max(1, min(40, int(top_n))): + # Chunk orphans so park stays stable (~8 per batch). + chunk = 8 + for i in range(0, len(orphans), chunk): + ids = orphans[i : i + chunk] + orphan_batches.append( + { + "hub_id": ids[0], + "hub_name": f"orphan_batch_{i // chunk + 1}", + "layer": "other", + "degree": 0, + "structure_territory": 0, + "structure_access_neighbors": 0, + "structure_score": 0.0, + "remaining_n": len(ids), + "remaining_territory": len(ids), + "block_method": "orphan_leftovers", + "fabric_node_ids": ids, + "already_on_sink": False, + "excluded_from_batch": [], + "orphan": True, + } + ) + batches.extend(orphan_batches) + + top = batches[: max(1, min(40, int(top_n)))] + for i, b in enumerate(top, start=1): + b["rank"] = i + + return { + "ok": True, + "batch_count": len(top), + "excluded_n": len(excluded), + "excluded_ids": sorted(excluded)[:40], + "source_n": len(src), + "sink_n": len(on_sink), + "orphan_n": len(orphans), + "batches": top, + "hint": ( + "Pick batches[0] (or pick=N) → layoutTopologyView move_nodes " + "source→sink with park=true. Do NOT sinkTopologyDualUnits again. " + "Eye portals are excluded from leading a batch. " + "orphan_batch_* = disconnected leftovers (park in chunks)." + ), + } + + +def pick_batch(report: dict[str, Any], pick: int = 1) -> dict[str, Any] | None: + batches = list(report.get("batches") or []) + if not batches: + return None + idx = max(1, min(len(batches), int(pick or 1))) - 1 + return dict(batches[idx]) 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 index 79000fb..ab667ff 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_stats.py @@ -21,12 +21,65 @@ from netx_topology_mcp.layout_metrics import ( NN_SWEET_LO = 140.0 NN_SWEET_HI = 220.0 # Ideal space_utilization band (n * rec_tile / bbox_area). -UTIL_SWEET_LO = 0.12 +# Floor aligned with real mid-size IPRAN / hand golden (~0.08–0.10), not 0.12 fantasy. +UTIL_SWEET_LO = 0.08 UTIL_SWEET_HI = 0.45 # Ideal median undirected edge length / recommended pitch. EDGE_SWEET_LO = 0.7 EDGE_SWEET_HI = 2.2 +# Score profiles: weights must sum to 1.0. +# default = metro/flat canvases; eye = dual-portal arc-band sinks (diagonals expected). +_SCORE_WEIGHTS: dict[str, dict[str, float]] = { + "default": { + "overlap": 0.24, + "crossing": 0.17, + "utilization": 0.13, + "chain": 0.10, + "rings": 0.06, + "edge_clearance": 0.10, + "edge_axis": 0.06, + "grid": 0.05, + "nn": 0.04, + "hull": 0.03, + "stretch": 0.02, + }, + "eye": { + "overlap": 0.24, + "crossing": 0.16, + "utilization": 0.14, + "chain": 0.10, + "rings": 0.05, + "edge_clearance": 0.12, + "edge_axis": 0.03, # arc-band eyes intentionally diagonal + "grid": 0.05, + "nn": 0.04, + "hull": 0.05, + "stretch": 0.02, + }, +} + + +def resolve_score_profile( + requested: str | None, + *, + node_count: int = 0, + space_utilization: float | None = None, + axis_frac: float | None = None, +) -> str: + """Map request → default|eye. ``auto`` uses size/sparsity/axis heuristic.""" + raw = str(requested or "auto").strip().lower() or "auto" + if raw in {"default", "metro", "flat", "corridor"}: + return "default" + if raw in {"eye", "dual", "dual_unit", "access_eye"}: + return "eye" + util = float(space_utilization) if space_utilization is not None else 1.0 + axis = float(axis_frac) if axis_frac is not None else 1.0 + # Large, still-sparse, diagonal-heavy → treat as dual-eye sink for scoring. + if int(node_count) >= 80 and util < 0.10 and axis < 0.55: + return "eye" + return "default" + def _convex_hull(points: list[tuple[float, float]]) -> list[tuple[float, float]]: """Andrew monotone chain; returns hull CCW, or [] if < 3 unique points.""" @@ -175,11 +228,15 @@ def compute_density_stats( } -def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: +def score_layout_components( + metrics: dict[str, Any], + *, + score_profile: str | None = None, +) -> 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. + Profiles: ``default`` (metro) | ``eye`` (dual-portal arc sink) | ``auto``. """ n = int(metrics.get("node_count") or 0) overlaps = int(metrics.get("footprint_overlap_pairs") or 0) @@ -204,6 +261,19 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: if metrics.get("edge_axis_score") is not None else 1.0 ) + axis_frac = metrics.get("axis_frac") + try: + axis_frac_f = float(axis_frac) if axis_frac is not None else None + except (TypeError, ValueError): + axis_frac_f = None + + profile = resolve_score_profile( + score_profile if score_profile is not None else metrics.get("score_profile"), + node_count=n, + space_utilization=util, + axis_frac=axis_frac_f, + ) + weights = dict(_SCORE_WEIGHTS.get(profile) or _SCORE_WEIGHTS["default"]) # Crossing: UME mid-size ~0.16; 0 → 1.0, 0.30 → 0 if n <= 50: @@ -221,29 +291,22 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: 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) + # Eye sinks: blend bbox util with hull util so long corridors don't auto-fail. + if profile == "eye": + util_blend = 0.55 * util + 0.45 * hull_u + util_s = _band_score(util_blend, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2) + hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5) + else: + util_s = _band_score(util, UTIL_SWEET_LO, UTIL_SWEET_HI, hard_lo=0.02, hard_hi=1.2) + hull_s = _band_score(hull_u, UTIL_SWEET_LO, UTIL_SWEET_HI + 0.1, hard_lo=0.03, hard_hi=1.5) + grid_s = _band_score(grid_occ, 0.20, 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 + white_s = max(0.0, 1.0 - white) # less whitespace → better (diagnostic only) 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), @@ -256,9 +319,9 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: "nn": round(nn_s, 4), "hull": round(hull_s, 4), "stretch": round(stretch_s, 4), + # Diagnostic only — not in weights (avoid double-count with util/grid). "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 @@ -268,6 +331,7 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: "total": total_100, "parts": parts, "weights": weights, + "score_profile": profile, "targets": { "nn_sweet": [NN_SWEET_LO, NN_SWEET_HI], "util_sweet": [UTIL_SWEET_LO, UTIL_SWEET_HI], @@ -288,18 +352,16 @@ def score_layout_components(metrics: dict[str, Any]) -> dict[str, Any]: -total_100, int(metrics.get("edge_crossings") or 0), -util, + -edge_clr_s, -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." + f"total∈[0,100] profile={profile}. Overlaps hard-gate. " + "Clearance blends nodes_hit + hits/link. " + "Eye profile softens axis (arc bands) and rings; raises util/clearance. " + "compactness is diagnostic only (not weighted)." ), } @@ -313,9 +375,10 @@ def _status_from_score(part: float, *, fail_below: float = 0.01, warn_below: flo def _sparsity_status(util: float, white: float, grid_occ: float) -> str: - if util < 0.03 or white > 0.85 or grid_occ < 0.05: + # Aligned with UTIL_SWEET_LO=0.08: below sweet → warn; desolate → fail. + if util < 0.04 or white > 0.85 or grid_occ < 0.04: return "fail" - if util < 0.08 or white > 0.65 or grid_occ < 0.15: + if util < UTIL_SWEET_LO or white > 0.65 or grid_occ < 0.12: return "warn" return "ok" @@ -594,8 +657,9 @@ def build_layout_report(metrics: dict[str, Any]) -> dict[str, Any]: "只看本工具即可验收: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)。" + f"score_profile={score.get('score_profile') or 'default'}:" + "eye 下调 axis/rings、上调 util/clearance;" + "贴边分=节点命中∪hits/link;compactness 仅诊断不加权。" "扫参用 score.rank_key(先零重叠,再高 total)。" ), }, @@ -609,11 +673,13 @@ def analyze_layout_stats( with_meta: bool = False, ume_reference: bool = False, fast: bool = False, + score_profile: str | None = "auto", ) -> 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. + ``score_profile``: auto|default|eye — eye softens axis/rings for dual sinks. """ base = analyze_positions(nodes, edges, with_meta=with_meta) pos: dict[str, tuple[float, float]] = {} @@ -682,6 +748,7 @@ def analyze_layout_stats( 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["hit_per_link"] = clr_q.get("hit_per_link") 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") @@ -694,7 +761,8 @@ def analyze_layout_stats( 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) + merged["score_profile"] = score_profile + score = score_layout_components(merged, score_profile=score_profile) grade = grade_layout(merged, ume_reference=ume_reference) packed = { **merged, @@ -703,6 +771,7 @@ def analyze_layout_stats( "summary": { "total": score["total"], "overall": grade.get("overall"), + "score_profile": score.get("score_profile"), "crossings": merged.get("edge_crossings"), "cpl": merged.get("crossings_per_link"), "overlaps": merged.get("footprint_overlap_pairs"), 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 index 3411ee7..c00bddc 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_structure.py @@ -200,7 +200,7 @@ def analyze_graph_structure( 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")) + layers[fid] = infer_layer(nm, n.get("role"), n.get("level")) adj: dict[str, set[str]] = {i: set() for i in ids} links = collapse_links(edges) @@ -211,7 +211,9 @@ def analyze_graph_structure( 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"}) + layer_known = sum( + 1 for i in ids if layers.get(i) in {"external", "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)) @@ -317,6 +319,7 @@ def analyze_graph_structure( } layer_block = { + "external": layer_stats("external"), "core": layer_stats("core"), "agg": layer_stats("agg"), "access": { 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 73148f0..fc30960 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 @@ -42,11 +42,24 @@ from netx_topology_mcp.layout_ops.clear_edge_hits import ( clear_edge_hits, clear_edge_params_from_overrides, ) +from netx_topology_mcp.layout_ops.compact_bbox import ( + compact_bbox, + compact_bbox_params_from_overrides, +) +from netx_topology_mcp.layout_ops.pull_far_chains import ( + pull_far_chains, + pull_far_chains_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, + orbit_sweep_until_limit, +) +from netx_topology_mcp.layout_ops.level_util import ( + apply_level_bands, + level_bands_params_from_overrides, ) # Public recipe names → internal multipass ids @@ -137,7 +150,11 @@ ACTIONS = ( "layout_dual_unit", "polish_crossings", "clear_edge_hits", + "compact_bbox", + "pull_far_chains", + "align_reference", "orbit_sweep", + "level_bands", ) @@ -431,6 +448,7 @@ def run_layout_on_graph( unit_id=knobs.get("unit_id"), portal_a=knobs.get("portal_a"), portal_b=knobs.get("portal_b"), + require_zero_cross=bool(knobs.get("require_zero_cross", False)), ) accepted = bool(op.params.get("accepted", False)) # Always return best-effort dual geometry (even if accepted=False). @@ -452,16 +470,67 @@ def run_layout_on_graph( "note": op.note, "accepted": accepted, "meta": st.meta.get("layout_dual_unit"), - "ensure": {"ran": False, "reason": "dual_unit_preserves_zero_cross"}, + "ensure": { + "ran": False, + "reason": "dual_unit_skips_global_overlap_crush", + }, }, ) if action_key == "orbit_sweep": knobs = orbit_params_from_overrides(params) do_round = bool(knobs.get("round")) + do_until = bool(knobs.get("until_limit")) node_id = str(knobs.get("node_id") or "").strip() frozen = knobs.get("frozen_ids") protect = knobs.get("protect_rigid", "off") + if do_until: + # until_limit wins over round/node_id (single-point loop to stall). + op = orbit_sweep_until_limit( + st0, + params=base_params, + max_degree=int(knobs.get("max_degree") or 14), + 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, + freeze_layers=knobs.get("freeze_layers"), + freeze_levels=knobs.get("freeze_levels"), + y_min=knobs.get("y_min"), + y_max=knobs.get("y_max"), + objective=str(knobs.get("objective") or "crossing"), + max_moves=int(knobs.get("max_moves") or 40), + stall_limit=int(knobs.get("stall_limit") or 12), + max_stretch=float(knobs.get("max_stretch") or 32.0), + min_delta=int(knobs.get("min_delta") or 1), + scan_cap=int(knobs.get("scan_cap") or 32), + top_k=int(knobs.get("top_k") or 8), + prefer_low_degree=bool(knobs.get("prefer_low_degree", True)), + cand_cap=int(knobs.get("cand_cap") or 360), + bundle=bool(knobs.get("bundle", True)), + bundle_max=int(knobs.get("bundle_max") or 10), + ) + 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"), + "until_limit": True, + }, + ) if do_round: op = orbit_sweep_round( st0, @@ -474,6 +543,8 @@ def run_layout_on_graph( min_angle_sep=float(knobs.get("min_angle_sep") or 35.0), protect_rigid=protect, frozen_ids=frozen, + freeze_layers=knobs.get("freeze_layers"), + freeze_levels=knobs.get("freeze_levels"), focus_ids=knobs.get("focus_ids"), y_min=knobs.get("y_min"), y_max=knobs.get("y_max"), @@ -500,8 +571,8 @@ def run_layout_on_graph( ) if not node_id: raise ValueError( - "orbit_sweep_requires_node_id_or_round:" - "params.node_id=… or params.round=true" + "orbit_sweep_requires_node_id_or_round_or_until_limit:" + "params.node_id=… or params.round=true or params.until_limit=true" ) sweep = orbit_sweep_node( st0, @@ -514,6 +585,8 @@ def run_layout_on_graph( cand_cap=int(knobs.get("cand_cap") or 280), protect_rigid=protect, frozen_ids=frozen, + freeze_layers=knobs.get("freeze_layers"), + freeze_levels=knobs.get("freeze_levels"), top_k=int(knobs.get("top_k") or 3), y_min=knobs.get("y_min"), y_max=knobs.get("y_max"), @@ -599,6 +672,8 @@ def run_layout_on_graph( pitch=knobs.get("pitch"), side=knobs.get("side"), rounds=int(knobs.get("rounds") or (6 if preserve else 1)), + frozen_ids=knobs.get("frozen_ids") or set(), + max_eject_degree=int(knobs.get("max_eject_degree") or 5), ) st = normalize_origin(op.state, base_params).state from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx @@ -629,6 +704,100 @@ def run_layout_on_graph( }, ) + if action_key == "compact_bbox": + knobs = compact_bbox_params_from_overrides(params) + op = compact_bbox( + st0, + base_params, + frozen_ids=knobs.get("frozen_ids") or set(), + portal_ids=knobs.get("portal_ids") or [], + min_scale=float(knobs.get("min_scale") or 0.72), + step=float(knobs.get("step") or 0.03), + max_clearance_slack=int(knobs.get("max_clearance_slack") or 80), + outlier_only=bool(knobs.get("outlier_only", True)), + ) + 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("compact_bbox")}, + ) + + if action_key == "pull_far_chains": + knobs = pull_far_chains_params_from_overrides(params) + op = pull_far_chains( + st0, + base_params, + frozen_ids=knobs.get("frozen_ids") or set(), + portal_ids=knobs.get("portal_ids") or [], + max_chains=int(knobs.get("max_chains") or 16), + min_tip_radius=float(knobs.get("min_tip_radius") or 1800.0), + scales=knobs.get("scales") or (0.92, 0.88, 0.84, 0.80, 0.75), + max_clearance_slack=int(knobs.get("max_clearance_slack") or 40), + pull_isolates=bool(knobs.get("pull_isolates", True)), + ) + 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("pull_far_chains"), + }, + ) + + if action_key == "level_bands": + knobs = level_bands_params_from_overrides(params) + op = apply_level_bands(st0, base_params, **knobs) + # Soft unstick only — hard crush fights band geometry and inflates crossings. + st = op.state + ov = overlapping_nodes(st) + fix_meta: dict[str, Any] = {"ran": False, "overlaps_before": len(ov)} + if ov: + st2 = fix_overlaps_local(st, base_params).state + st = st2 + fix_meta = { + "ran": True, + "overlaps_before": len(ov), + "overlaps_after": len(overlapping_nodes(st)), + "mode": "local_only", + } + 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("level_bands"), + "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). @@ -639,6 +808,7 @@ def run_layout_on_graph( base_params, portal_ids=knobs.get("portal_ids"), straighten=knobs.get("straighten"), + preserve_dual_eye=knobs.get("preserve_dual_eye"), max_degree=int(knobs.get("max_degree") or 9), untangle_rounds=knobs.get("untangle_rounds"), top_n=knobs.get("top_n"), @@ -750,13 +920,35 @@ def list_layout_catalog() -> dict[str, Any]: ), "clear_edge_hits": ( "把贴在非关联边上的网元沿垂直方向弹开(直角偏好 H/V);" - "门控:不增交叉、不增重叠。" - "params: top_n/thr/margin/max_moves" + "门控:不增交叉、不增重叠(preserve_axis 亦不放宽交叉)。" + "眼 sink 须 portal_ids;params: top_n/thr/margin/max_moves/max_eject_degree" + ), + "compact_bbox": ( + "眼图安全收 bbox:相对门户中心;默认 farthest-K(outlier_only);" + "门控:交叉不升、overlaps=0、贴边不可大幅恶化。" + "params: portal_ids/min_scale/step" + ), + "pull_far_chains": ( + "眼图安全收远场:deg≤2 走廊/孤立点/远叶相对门户中点缩放;" + "门控:交叉不升、overlaps=0、贴边松弛有限。" + "params: portal_ids/max_chains/min_tip_radius/scales" + ), + "align_reference": ( + "【非主路径】仅同网同成员画布 A/B 调试:" + "把参考画布几何映射到当前画布(共享 fabric_node_id)。" + "日常无范本、跨网人工图 → 禁止当交付手段。" + "params: reference_view_id|source_view_id, portal_ids, mode=similarity|adopt" + ), + "level_bands": ( + "按 fabric level/layer 水平分层(external→core→agg→access);" + "params: y0/band_gap/preserve_x/pitch;分层场景先于 polish" ), "orbit_sweep": ( - "压交叉(默认可动门户):以网元为圆心不定长扫角 top-3;" - "preview+node_id / apply+pick / round=true;" - "protect_rigid 默认 off(portals/all 可恢复刚体冻)" + "压交叉:以网元为圆心不定长扫角;" + "preview+node_id / apply+pick;" + "until_limit=true 单点循环到 stall(默认冻 portals、objective=crossing|total);" + "round=true 一批 top_n 自动 pick#1(眼 sink 禁用);" + "单点/round 默认 protect_rigid=off;bundle 默认开" ), "job_status": ( "轮询后台 job:params.job_id;返回 progress.phase/pct、elapsed_ms、" @@ -789,9 +981,15 @@ def list_layout_catalog() -> dict[str, Any]: "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 算坐标。" + "主路径:analyze(structure) → sinkTopologyDualUnits(max_units=1) → " + "suggestSinkHubs/move_nodes(park) → " + "orbit_sweep(until_limit crossing→total) → " + "clear_edge_hits → pull_far_chains → compact_bbox → 手拖。" + "默认无范本;align_reference 仅同网调试,禁止当跨网交付。" + "眼 sink 禁 polish/fix_overlaps/untangle/round。" + ), + "eye_polish_plateau": ( + "算法到头:overlaps=0 + until_limit stall + " + "pull/compact/clear moved≈0 → 手拖或改初布;勿指望金标对齐。" ), } diff --git a/packages/netx-topology-mcp/tests/test_align_reference.py b/packages/netx-topology-mcp/tests/test_align_reference.py new file mode 100644 index 0000000..6772878 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_align_reference.py @@ -0,0 +1,55 @@ +"""Tests for align_to_reference.""" + +from __future__ import annotations + +from netx_topology_mcp.layout_ops.align_reference import align_to_reference +from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges + + +def test_align_similarity_maps_shared_nodes_to_portal_frame(): + # Target: portals at 0 and 1000. Reference: same topology scaled/rotated. + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0}, + {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0}, + {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 2000.0}, + ] + edges = [ + {"a_node_id": "p1", "b_node_id": "a"}, + {"a_node_id": "a", "b_node_id": "p2"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + # Reference: portals horizontal length 500, leaf above mid. + ref = { + "p1": (10.0, 10.0), + "p2": (510.0, 10.0), + "a": (260.0, 210.0), + } + op = align_to_reference( + st, + reference=ref, + portal_ids=["p1", "p2"], + mode="similarity", + freeze_portals=True, + ) + assert op.params.get("shared_n") == 3 + assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6 + assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6 + # Leaf should land near mid-x, positive y (scaled 2x from ref dy=200 → 400) + assert abs(op.state.positions["a"][0] - 500.0) < 1.0 + assert op.state.positions["a"][1] > 100.0 + + +def test_align_adopt_copies_reference_coords(): + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "x": 999.0, "y": 999.0}, + {"fabric_node_id": "p2", "name": "P2", "x": 1999.0, "y": 999.0}, + {"fabric_node_id": "a", "name": "A", "x": 1500.0, "y": 1500.0}, + ] + edges = [{"a_node_id": "p1", "b_node_id": "p2"}] + st = build_state_from_nodes_edges(nodes, edges) + ref = {"p1": (100.0, 200.0), "p2": (300.0, 200.0), "a": (200.0, 400.0)} + op = align_to_reference(st, reference=ref, mode="adopt", park_missing=False) + # origin normalized: min was 100,200 → pad 40 + assert abs(op.state.positions["p1"][0] - 40.0) < 1e-6 + assert abs(op.state.positions["p1"][1] - 40.0) < 1e-6 + assert abs(op.state.positions["a"][1] - 240.0) < 1e-6 diff --git a/packages/netx-topology-mcp/tests/test_bundle_orbit.py b/packages/netx-topology-mcp/tests/test_bundle_orbit.py new file mode 100644 index 0000000..07799cb --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_bundle_orbit.py @@ -0,0 +1,185 @@ +"""Tests for chain / ring+chain bundle orbit (contract → sweep → expand).""" + +from __future__ import annotations + +from netx_topology_mcp.layout_metrics import count_edge_crossings +from netx_topology_mcp.layout_ops.bundle_orbit import ( + detect_chain_bundles, + detect_ring_chain_bundles, + orbit_bundle, + apply_bundle_pick, + bundle_orbit_until_progress, +) +from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges +from netx_topology_mcp.layout_ops.orbit_sweep import ( + orbit_params_from_overrides, + orbit_sweep_until_limit, +) + + +def _crossed_chain(): + """Hub H with chain leaf that pierces a long chord a—b.""" + # a———b horizontal at y=200; vertical chain crosses it between c1 and c2. + nodes = [ + {"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0}, + {"fabric_node_id": "b", "name": "B", "x": 2000.0, "y": 200.0}, + {"fabric_node_id": "h", "name": "H", "x": 1000.0, "y": 0.0}, + {"fabric_node_id": "c1", "name": "C1", "x": 1000.0, "y": 80.0}, + {"fabric_node_id": "c2", "name": "C2", "x": 1000.0, "y": 320.0}, + {"fabric_node_id": "c3", "name": "C3", "x": 1000.0, "y": 480.0}, + ] + edges = [ + {"a_node_id": "a", "b_node_id": "b"}, + {"a_node_id": "h", "b_node_id": "c1"}, + {"a_node_id": "c1", "b_node_id": "c2"}, + {"a_node_id": "c2", "b_node_id": "c3"}, + ] + return nodes, edges + + +def _triangle_with_chain(): + """Triangle ABC with dangling chain off A that crosses foreign chord.""" + nodes = [ + {"fabric_node_id": "a", "name": "A", "x": 400.0, "y": 0.0}, + {"fabric_node_id": "b", "name": "B", "x": 0.0, "y": 300.0}, + {"fabric_node_id": "c", "name": "C", "x": 800.0, "y": 300.0}, + {"fabric_node_id": "d1", "name": "D1", "x": 400.0, "y": 200.0}, + {"fabric_node_id": "d2", "name": "D2", "x": 400.0, "y": 400.0}, + {"fabric_node_id": "x", "name": "X", "x": 0.0, "y": 200.0}, + {"fabric_node_id": "y", "name": "Y", "x": 800.0, "y": 200.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": "a"}, + {"a_node_id": "a", "b_node_id": "d1"}, + {"a_node_id": "d1", "b_node_id": "d2"}, + {"a_node_id": "x", "b_node_id": "y"}, + ] + return nodes, edges + + +def test_detect_chain_bundle() -> None: + nodes, edges = _crossed_chain() + st = build_state_from_nodes_edges(nodes, edges) + st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes} + bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h"}) + assert bundles + members = set(bundles[0].member_ids) + assert "c1" in members and "c3" in members + assert "h" not in members + + +def test_chain_bundle_orbit_cuts_crossing() -> None: + nodes, edges = _crossed_chain() + 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 + bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"}) + assert bundles + sweep = orbit_bundle(st, bundles[0], max_jump=2500, cand_cap=300, nn_floor=10.0) + assert sweep["ok"] is True + assert sweep.get("expand_mode") == "minimize_probe" + assert int(sweep.get("improving_n") or 0) >= 1 + best = sweep["candidates"][0] + assert "placements" in best + assert float(best.get("expand_scale") or 1.0) <= 1.0 + op = apply_bundle_pick(st, bundles[0], sweep, pick=1) + g1 = count_edge_crossings(op.state.positions, op.state.links) + assert g1 < g0 + # Expand must not raise crossings vs before apply + assert g1 <= g0 + + +def test_expand_refuses_if_crossings_rise() -> None: + nodes, edges = _crossed_chain() + st = build_state_from_nodes_edges(nodes, edges) + # Start cleared (no crossing), then force a bad expand back onto the chord. + st.positions = { + "a": (0.0, 200.0), + "b": (2000.0, 200.0), + "h": (1000.0, 0.0), + "c1": (1400.0, 80.0), + "c2": (1400.0, 200.0), + "c3": (1400.0, 320.0), + } + g0 = count_edge_crossings(st.positions, st.links) + assert g0 == 0 + bundles = detect_chain_bundles(st.adj, st.positions, frozen={"h", "a", "b"}) + assert bundles + bad = { + "candidates": [ + { + "rank": 1, + "delta": {"global": -1}, + "crossings": {"global": 0}, + "expand_scale": 1.0, + "placements": { + "c1": (1000.0, 80.0), + "c2": (1000.0, 320.0), + "c3": (1000.0, 480.0), + }, + } + ] + } + op = apply_bundle_pick(st, bundles[0], bad, pick=1) + assert not op.moved + assert (op.params or {}).get("error") == "expand_raises_crossings" + assert count_edge_crossings(st.positions, st.links) == g0 + + +def test_ring_chain_detect() -> None: + nodes, edges = _triangle_with_chain() + st = build_state_from_nodes_edges(nodes, edges) + st.positions = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes} + bundles = detect_ring_chain_bundles(st, frozen={"b", "c"}) + assert any(b.kind == "ring_chain" for b in bundles) + tip = next(b for b in bundles if b.kind == "ring_chain") + assert tip.tip_id == "a" + assert "d1" in tip.member_ids or "d2" in tip.member_ids + assert set(tip.base_ids) >= {"b", "c"} + + +def test_bundle_until_progress() -> None: + nodes, edges = _crossed_chain() + 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 = bundle_orbit_until_progress( + st, frozen_ids={"h", "a", "b"}, max_jump=2500, nn_floor=10.0 + ) + assert op.moved + g1 = count_edge_crossings(op.state.positions, op.state.links) + assert g1 < g0 + + +def test_until_limit_bundle_default_on() -> None: + knobs = orbit_params_from_overrides({"until_limit": True}) + assert knobs.get("bundle") is True + assert knobs.get("bundle_max") == 10 + + +def test_until_limit_uses_bundle_when_points_stall() -> None: + nodes, edges = _crossed_chain() + 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_until_limit( + st, + protect_rigid="off", + frozen_ids={"h", "a", "b"}, + max_jump=2500, + max_degree=4, + max_moves=8, + stall_limit=4, + nn_floor=10.0, + bundle=True, + bundle_max=6, + ) + meta = op.params or {} + g1 = count_edge_crossings(op.state.positions, op.state.links) + assert g1 <= g0 + # Either point or bundle should have moved if crossings existed + if g0 > 0: + assert int(meta.get("moves_n") or 0) >= 1 or g1 < g0 diff --git a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py index 773e801..b5a0cef 100644 --- a/packages/netx-topology-mcp/tests/test_clear_edge_hits.py +++ b/packages/netx-topology-mcp/tests/test_clear_edge_hits.py @@ -37,7 +37,7 @@ def test_collinear_midpoint_scores_edge_clearance_hit() -> None: 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 abs(m["score"]["weights"]["edge_clearance"] - 0.10) < 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) @@ -118,9 +118,8 @@ def test_score_weights_include_edge_clearance() -> None: "edge_axis_score": 0.2, } ) - assert abs(s["weights"]["edge_clearance"] - 0.08) < 1e-9 + assert abs(s["weights"]["edge_clearance"] - 0.10) < 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( diff --git a/packages/netx-topology-mcp/tests/test_compact_bbox.py b/packages/netx-topology-mcp/tests/test_compact_bbox.py new file mode 100644 index 0000000..cc46d4f --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_compact_bbox.py @@ -0,0 +1,40 @@ +"""Tests for gated compact_bbox.""" + +from __future__ import annotations + +from netx_topology_mcp.layout_metrics import count_edge_crossings +from netx_topology_mcp.layout_ops.compact_bbox import compact_bbox +from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges +from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap + + +def test_compact_bbox_shrinks_without_raising_crossings(): + # Two portals + a far leaf; shrink should pull leaf inward. + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0}, + {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0}, + {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 0.0}, + {"fabric_node_id": "leaf", "name": "Leaf", "x": 500.0, "y": 4000.0}, + ] + edges = [ + {"a_fabric_node_id": "p1", "b_fabric_node_id": "a"}, + {"a_fabric_node_id": "a", "b_fabric_node_id": "p2"}, + {"a_fabric_node_id": "a", "b_fabric_node_id": "leaf"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + g0 = count_edge_crossings(st.positions, st.links) + op = compact_bbox( + st, + portal_ids=["p1", "p2"], + min_scale=0.7, + step=0.05, + max_clearance_slack=50, + ) + assert op.params.get("accepted") is True + assert op.params.get("scale", 1.0) < 1.0 + assert abs(op.state.positions["p1"][0] - 0.0) < 1e-6 + assert abs(op.state.positions["p2"][0] - 1000.0) < 1e-6 + assert op.state.positions["leaf"][1] < 4000.0 + g1 = count_edge_crossings(op.state.positions, op.state.links) + assert g1 <= g0 + assert not _has_any_footprint_overlap(op.state.positions, op.state.names) diff --git a/packages/netx-topology-mcp/tests/test_dual_units.py b/packages/netx-topology-mcp/tests/test_dual_units.py index 98c4f59..4ca5305 100644 --- a/packages/netx-topology-mcp/tests/test_dual_units.py +++ b/packages/netx-topology-mcp/tests/test_dual_units.py @@ -1,4 +1,4 @@ -"""Dual-portal eye units: detect, zero-cross layout, shared-portal compose.""" +"""Dual-portal eye units: detect, CN-first max-cover layout, shared-portal compose.""" from __future__ import annotations @@ -42,6 +42,39 @@ def _eye_graph(): return nodes, edges +def _cn_eye_graph(): + """CN pair covers more NEs than a small AN ring — detection must prefer CN.""" + nodes = [ + {"fabric_node_id": "cn1", "name": "BTM-CN1", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "cn2", "name": "BTM-CN2", "role": "cn", "x": 100, "y": 0}, + {"fabric_node_id": "an1", "name": "BTM-AN1", "role": "an", "x": 0, "y": 0}, + {"fabric_node_id": "an2", "name": "BTM-AN2", "role": "an", "x": 0, "y": 0}, + {"fabric_node_id": "e1", "name": "BTM-EN1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e2", "name": "BTM-EN2", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e3", "name": "BTM-EN3", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e4", "name": "BTM-EN4", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e5", "name": "BTM-EN5", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e6", "name": "BTM-EN6", "role": "en", "x": 0, "y": 0}, + ] + # Two CN↔CN corridors via AN+EN, plus a tiny AN–AN eye that would steal + # interiors if AN rings were claimed first. + edges = [ + {"a_node_id": "cn1", "b_node_id": "cn2"}, + {"a_node_id": "cn1", "b_node_id": "an1"}, + {"a_node_id": "an1", "b_node_id": "e1"}, + {"a_node_id": "e1", "b_node_id": "e2"}, + {"a_node_id": "e2", "b_node_id": "an2"}, + {"a_node_id": "an2", "b_node_id": "cn2"}, + {"a_node_id": "cn1", "b_node_id": "e3"}, + {"a_node_id": "e3", "b_node_id": "e4"}, + {"a_node_id": "e4", "b_node_id": "cn2"}, + {"a_node_id": "an1", "b_node_id": "e5"}, + {"a_node_id": "e5", "b_node_id": "e6"}, + {"a_node_id": "e6", "b_node_id": "an2"}, + ] + return nodes, edges + + def test_actions_include_layout_dual_unit() -> None: assert "layout_dual_unit" in ACTIONS @@ -60,6 +93,16 @@ def test_find_dual_portal_units_eye() -> None: assert "a1" in interiors or "b1" in interiors or "c1" in interiors +def test_find_prefers_cn_eye_max_cover() -> None: + nodes, edges = _cn_eye_graph() + st = build_state_from_nodes_edges(nodes, edges) + units = find_dual_portal_units(st) + assert units + u0 = units[0] + assert {u0.portal_a, u0.portal_b} == {"cn1", "cn2"} + assert len(u0.member_ids()) >= 8 + + def test_layout_dual_unit_zero_crossings() -> None: nodes, edges = _eye_graph() st = build_state_from_nodes_edges(nodes, edges) @@ -70,14 +113,141 @@ def test_layout_dual_unit_zero_crossings() -> None: 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) + # Path-planar 3-corridor eye should stay clean under ellipse bands. assert x == 0 + assert op.params.get("zero_cross") is True - 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_petal_ellipse_hollow_axis_portals_only() -> None: + """Shared portal-adj nodes stay off the mid-chord (CN gravity / hollow eye).""" + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "s", "name": "SHARED-AN", "role": "an", "x": 0, "y": 0}, + {"fabric_node_id": "u1", "name": "U1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "u2", "name": "U2", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "d1", "name": "D1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "d2", "name": "D2", "role": "en", "x": 0, "y": 0}, + ] + # Two corridors share first hop `s`: p1-s-u1-u2-p2 and p1-s-d1-d2-p2. + edges = [ + {"a_node_id": "p1", "b_node_id": "s"}, + {"a_node_id": "s", "b_node_id": "u1"}, + {"a_node_id": "u1", "b_node_id": "u2"}, + {"a_node_id": "u2", "b_node_id": "p2"}, + {"a_node_id": "s", "b_node_id": "d1"}, + {"a_node_id": "d1", "b_node_id": "d2"}, + {"a_node_id": "d2", "b_node_id": "p2"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0)) + pos = op.state.positions + # Axis = portals only; shared AN sits on an arc band. + assert abs(pos["p1"][1]) < 1e-6 and abs(pos["p2"][1]) < 1e-6 + assert abs(pos["s"][1]) > 50.0 + assert pos["u1"][1] * pos["d1"][1] < 0 + assert abs(pos["u1"][1]) > 200.0 + assert abs(pos["d1"][1]) > 200.0 + + +def test_short_corridor_an_not_on_mid_axis() -> None: + """CN–AN–CN must not drop the AN onto (0,0) mid-chord.""" + nodes = [ + {"fabric_node_id": "cn1", "name": "CN1", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "cn2", "name": "CN2", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "an1", "name": "AN1", "role": "an", "x": 0, "y": 0}, + {"fabric_node_id": "an2", "name": "AN2", "role": "an", "x": 0, "y": 0}, + {"fabric_node_id": "e1", "name": "E1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "e2", "name": "E2", "role": "en", "x": 0, "y": 0}, + ] + edges = [ + {"a_node_id": "cn1", "b_node_id": "an1"}, + {"a_node_id": "an1", "b_node_id": "cn2"}, + {"a_node_id": "cn1", "b_node_id": "e1"}, + {"a_node_id": "e1", "b_node_id": "e2"}, + {"a_node_id": "e2", "b_node_id": "cn2"}, + {"a_node_id": "cn1", "b_node_id": "an2"}, + {"a_node_id": "an2", "b_node_id": "cn2"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + op = layout_dual_unit( + st, LayoutParams(pitch=200.0, lane=300.0), portal_a="cn1", portal_b="cn2" + ) + pos = op.state.positions + assert abs(pos["an1"][1]) > 50.0 + assert abs(pos["an2"][1]) > 50.0 + on_axis = {n for n, (_x, y) in pos.items() if abs(y) < 1e-6} + assert on_axis <= {"cn1", "cn2"} + # Hollow-ish: short ANs should not sit at exact mid vertical. + assert abs(pos["an1"][0]) > 1.0 + assert abs(pos["an2"][0]) > 1.0 + + +def test_long_tail_parks_outside_eye_rings() -> None: + """Long deg≤2 tails must sit outside corridor envelope (no ring pierce).""" + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "p2", "name": "P2", "role": "cn", "x": 0, "y": 0}, + {"fabric_node_id": "a1", "name": "A1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "a2", "name": "A2", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "b1", "name": "B1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "b2", "name": "B2", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "t0", "name": "T0", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "t1", "name": "T1", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "t2", "name": "T2", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "t3", "name": "T3", "role": "en", "x": 0, "y": 0}, + {"fabric_node_id": "t4", "name": "T4", "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"}, + # Long tail hanging off upper corridor mid a1. + {"a_node_id": "a1", "b_node_id": "t0"}, + {"a_node_id": "t0", "b_node_id": "t1"}, + {"a_node_id": "t1", "b_node_id": "t2"}, + {"a_node_id": "t2", "b_node_id": "t3"}, + {"a_node_id": "t3", "b_node_id": "t4"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + op = layout_dual_unit(st, LayoutParams(pitch=200.0, lane=300.0)) + pos = op.state.positions + corridor = {"p1", "p2", "a1", "a2", "b1", "b2"} + eye_ry = max(abs(pos[n][1]) for n in corridor if n in pos) + eye_rx = max(abs(pos[n][0]) for n in corridor if n in pos) + for tid in ("t0", "t1", "t2", "t3", "t4"): + x, y = pos[tid] + assert abs(x) >= eye_rx * 0.9 or abs(y) >= eye_ry * 0.9, (tid, x, y, eye_rx, eye_ry) + + +def test_layout_dual_unit_accepts_residual_cross_by_default() -> None: + """With require_zero_cross=false (default), accepted stays true even if x>0.""" + nodes, edges = _cn_eye_graph() + st = build_state_from_nodes_edges(nodes, edges) + op = layout_dual_unit( + st, + LayoutParams(), + portal_a="cn1", + portal_b="cn2", + require_zero_cross=False, + ) + assert op.params.get("accepted") is True + assert "unit_crossings" in op.params + # Hard gate still available for callers that want the old behavior. + op_hard = layout_dual_unit( + st, + LayoutParams(), + portal_a="cn1", + portal_b="cn2", + require_zero_cross=True, + ) + if int(op_hard.params.get("unit_crossings") or 0) > 0: + assert op_hard.params.get("accepted") is False + else: + assert op_hard.params.get("accepted") is True def test_structure_reports_dual_units() -> None: @@ -85,13 +255,9 @@ def test_structure_reports_dual_units() -> None: 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.""" +def test_shared_portal_compose_preserves_relative() -> None: a = ComposeBlock( key="unit-p1-p2", positions={ @@ -111,11 +277,8 @@ def test_compose_merge_shared_portal_unique_coord() -> None: 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 + # Shared portal merge keeps both blocks; relative spans stay order-of-magnitude. + span_p = ((merged["p3"][0] - merged["p2"][0]) ** 2 + (merged["p3"][1] - merged["p2"][1]) ** 2) ** 0.5 + span_b = ((merged["b1"][0] - merged["p2"][0]) ** 2 + (merged["b1"][1] - merged["p2"][1]) ** 2) ** 0.5 + assert abs(span_p - 200.0) < 10.0 + assert abs(span_b - ((100.0**2 + 60.0**2) ** 0.5)) < 10.0 diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py index d28dbc4..ccaa466 100644 --- a/packages/netx-topology-mcp/tests/test_mcp_topology.py +++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py @@ -262,9 +262,52 @@ def test_tools_for_scopes_filters_write() -> None: assert "queryTopologyEdges" in read_only assert "queryTopologyFabricNodes" in read_only assert "createTopologyFolder" not in read_only + assert "classifyTopologyFabricNodes" not in read_only write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])} assert "createTopologyView" not in write assert "createTopologyFolder" in write + assert "classifyTopologyFabricNodes" in write + + +def test_classify_topology_fabric_nodes_match_tag() -> None: + with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + mock_http.return_value = { + "ok": True, + "data": { + "pattern": "CORE", + "match_field": "name", + "total_matched": 1, + "samples": [{"id": "n1", "name": "CORE-1", "attrs": {"x": 1}}], + "fabric_node_ids": ["n1"], + }, + } + out = call_http_tool( + "classifyTopologyFabricNodes", + {"action": "match", "pattern": "CORE", "match_field": "name"}, + ) + payload = json.loads(out["content"][0]["text"]) + assert payload.get("action") == "match" + assert payload.get("fabric_node_ids") == ["n1"] + assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/match") + + mock_http.return_value = { + "ok": True, + "data": {"dry_run": True, "matched": 1, "updated": 0, "level": 1.0, "samples": []}, + } + out = call_http_tool( + "classifyTopologyFabricNodes", + { + "action": "tag", + "fabric_node_ids": ["n1"], + "level": 1.0, + "dry_run": True, + }, + ) + payload = json.loads(out["content"][0]["text"]) + assert payload.get("action") == "tag" + assert payload.get("dry_run") is True + assert mock_http.call_args[0][:2] == ("POST", "/v1/topology/fabric/nodes/tags/bulk") + assert mock_http.call_args[1]["body"]["level"] == 1.0 def test_query_fabric_nodes_modes() -> None: diff --git a/packages/netx-topology-mcp/tests/test_orbit_sweep.py b/packages/netx-topology-mcp/tests/test_orbit_sweep.py index 75eab4b..c569862 100644 --- a/packages/netx-topology-mcp/tests/test_orbit_sweep.py +++ b/packages/netx-topology-mcp/tests/test_orbit_sweep.py @@ -213,6 +213,221 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None: assert 0.0 <= float(c["y"]) <= 80.0 +def test_orbit_until_limit_params_defaults() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import orbit_params_from_overrides + + knobs = orbit_params_from_overrides({"until_limit": True}) + assert knobs["until_limit"] is True + assert knobs["protect_rigid"] == "portals" + assert knobs["objective"] == "crossing" + assert knobs["max_degree"] == 14 + assert knobs["max_jump"] == 4000.0 + assert knobs["max_stretch"] == 32.0 + assert knobs["cand_cap"] == 360 + assert knobs["top_k"] == 8 + # until_stall alias + assert orbit_params_from_overrides({"until_stall": 1})["until_limit"] is True + + +def test_orbit_far_field_can_cut_long_chord() -> None: + """Node whose improving slot is outside local polar rings.""" + nodes = [ + {"fabric_node_id": "a", "name": "A", "x": 0.0, "y": 200.0}, + {"fabric_node_id": "b", "name": "B", "x": 4000.0, "y": 200.0}, + {"fabric_node_id": "c", "name": "C", "x": 2000.0, "y": 0.0}, + {"fabric_node_id": "leaf", "name": "LEAF", "x": 2000.0, "y": 400.0}, + ] + edges = [ + {"a_node_id": "a", "b_node_id": "b"}, + {"a_node_id": "c", "b_node_id": "leaf"}, + ] + 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 + out = orbit_sweep_node( + st, + "leaf", + protect_rigid="off", + max_jump=5000, + cand_cap=400, + top_k=5, + nn_floor=10.0, + ) + assert out["ok"] is True + assert int(out.get("improving_n") or 0) >= 1 + best = out["candidates"][0] + assert int((best.get("delta") or {}).get("global") or 0) < 0 + + +def test_orbit_sweep_until_limit_cuts_crossings() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + + 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_until_limit( + st, + protect_rigid="off", + max_jump=600, + max_degree=9, + max_moves=10, + stall_limit=4, + max_stretch=30.0, + nn_floor=20.0, + ) + g1 = count_edge_crossings(op.state.positions, op.state.links) + meta = op.params or {} + assert meta.get("mode") == "until_limit" + assert g1 <= g0 + assert meta.get("end_crossings") == g1 + assert meta.get("stop_reason") in { + "stall", + "no_candidates", + "max_moves", + } + + +def test_run_layout_until_limit_preview_moves_in_result() -> None: + nodes, edges = _crossed_pair() + g0_nodes = {n["fabric_node_id"]: (float(n["x"]), float(n["y"])) for n in nodes} + # Build edges list for crossing count on input + st = build_state_from_nodes_edges(nodes, edges) + st.positions = dict(g0_nodes) + g0 = count_edge_crossings(st.positions, st.links) + out = run_layout_on_graph( + nodes, + edges, + action="orbit_sweep", + params={ + "until_limit": True, + "protect_rigid": "off", + "max_jump": 600, + "max_moves": 8, + "stall_limit": 4, + "nn_floor": 20.0, + }, + ) + assert out["ok"] is True + local = out.get("local") or {} + assert local.get("until_limit") is True + meta = local.get("meta") or local.get("op") or {} + assert meta.get("mode") == "until_limit" + by_id = {p["fabric_node_id"]: p for p in out["positions"]} + # Relative geometry: crossings should not rise vs original. + pos1 = {nid: (float(by_id[nid]["x"]), float(by_id[nid]["y"])) for nid in by_id} + g1 = count_edge_crossings(pos1, st.links) + assert g1 <= g0 + + +def test_select_stretch_pick_prefers_lower_stretch() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import _select_stretch_pick + + sweep = { + "improving_n": 2, + "candidates": [ + { + "delta": {"global": -2}, + "stretch": 40.0, + "ov": False, + "x": 1, + "y": 1, + "crossings": {"global": 1}, + }, + { + "delta": {"global": -2}, + "stretch": 5.0, + "ov": False, + "x": 2, + "y": 2, + "crossings": {"global": 1}, + }, + ], + } + picked = _select_stretch_pick(sweep, max_stretch=24.0, min_delta=1) + assert picked is not None + pick_i, cand = picked + assert pick_i == 2 + assert cand["stretch"] == 5.0 + + +def test_until_limit_freezes_portal_ids() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + + 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} + # Freeze both endpoints of the vertical crossing edge. + op = orbit_sweep_until_limit( + st, + protect_rigid="portals", + frozen_ids={"c", "d"}, + max_jump=600, + max_moves=6, + stall_limit=3, + nn_floor=20.0, + ) + moved = set(op.moved or ()) + assert "c" not in moved and "d" not in moved + + +def test_ids_matching_freeze_layers_levels() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import ( + ids_matching_freeze_layers_levels, + ) + + nodes = [ + {"fabric_node_id": "c1", "name": "X-CN1-Y", "x": 0, "y": 0, "level": 1}, + {"fabric_node_id": "c11", "name": "X-CN2-Y", "x": 10, "y": 0, "level": 1.1}, + {"fabric_node_id": "a1", "name": "X-AN1-Y", "x": 20, "y": 0, "level": 2}, + {"fabric_node_id": "e1", "name": "X-EN1-Y", "x": 30, "y": 0, "level": 3}, + ] + edges = [{"a_node_id": "c1", "b_node_id": "a1"}] + st = build_state_from_nodes_edges(nodes, edges) + assert st.layers["c1"] == "core" + assert st.levels["c11"] == 1.1 + by_layer = ids_matching_freeze_layers_levels(st, freeze_layers=["core", "agg"]) + assert by_layer == {"c1", "c11", "a1"} + by_maj = ids_matching_freeze_layers_levels(st, freeze_levels=[1, 2]) + assert "c1" in by_maj and "c11" in by_maj and "a1" in by_maj + assert "e1" not in by_maj + by_exact = ids_matching_freeze_layers_levels(st, freeze_levels=[1.1]) + assert by_exact == {"c11"} + # aliases + assert "c1" in ids_matching_freeze_layers_levels(st, freeze_layers=["CN"]) + + +def test_until_limit_freeze_layers_blocks_core() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import orbit_sweep_until_limit + + nodes = [ + {"fabric_node_id": "a", "name": "AAAAAA-EN-1", "x": 0.0, "y": 200.0, "level": 3}, + {"fabric_node_id": "b", "name": "BBBBBB-EN-2", "x": 400.0, "y": 200.0, "level": 3}, + {"fabric_node_id": "c", "name": "CCCCCC-CN-3", "x": 200.0, "y": 0.0, "level": 1}, + {"fabric_node_id": "d", "name": "DDDDDD-EN-4", "x": 200.0, "y": 400.0, "level": 3}, + {"fabric_node_id": "e", "name": "EEEEEE-EN-5", "x": 200.0, "y": -80.0, "level": 3}, + ] + 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"}, + ] + 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 = orbit_sweep_until_limit( + st, + protect_rigid="off", + freeze_layers=["core"], + max_jump=600, + max_moves=8, + stall_limit=4, + nn_floor=20.0, + ) + assert "c" not in set(op.moved or ()) + assert (op.params or {}).get("freeze_layers") == ["core"] + + def test_verdict_partial_weights_match_layout_stats() -> None: from netx_topology_mcp.layout_ops.orbit_sweep import ( _W_CLR, diff --git a/packages/netx-topology-mcp/tests/test_pull_far_chains.py b/packages/netx-topology-mcp/tests/test_pull_far_chains.py new file mode 100644 index 0000000..9a3ef68 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_pull_far_chains.py @@ -0,0 +1,84 @@ +"""Tests for gated pull_far_chains.""" + +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 _has_any_footprint_overlap +from netx_topology_mcp.layout_ops.pull_far_chains import pull_far_chains + + +def test_pull_far_chains_shortens_corridor_without_raising_crossings(): + # Portal pair + hub + long deg-2 tail stretching south. + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0}, + {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0}, + {"fabric_node_id": "hub", "name": "Hub", "x": 500.0, "y": 200.0}, + {"fabric_node_id": "a", "name": "A", "x": 500.0, "y": 1200.0}, + {"fabric_node_id": "b", "name": "B", "x": 500.0, "y": 2400.0}, + {"fabric_node_id": "c", "name": "C", "x": 500.0, "y": 3600.0}, + {"fabric_node_id": "tip", "name": "Tip", "x": 500.0, "y": 4800.0}, + ] + edges = [ + {"a_node_id": "p1", "b_node_id": "hub"}, + {"a_node_id": "p2", "b_node_id": "hub"}, + {"a_node_id": "hub", "b_node_id": "a"}, + {"a_node_id": "a", "b_node_id": "b"}, + {"a_node_id": "b", "b_node_id": "c"}, + {"a_node_id": "c", "b_node_id": "tip"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + g0 = count_edge_crossings(st.positions, st.links) + y0 = st.positions["tip"][1] + area0 = abs( + (max(p[0] for p in st.positions.values()) - min(p[0] for p in st.positions.values())) + * (max(p[1] for p in st.positions.values()) - min(p[1] for p in st.positions.values())) + ) + op = pull_far_chains( + st, + portal_ids=["p1", "p2"], + max_chains=4, + min_tip_radius=1000.0, + scales=(0.9, 0.8, 0.7), + max_clearance_slack=100, + ) + assert op.params.get("chains_accepted", 0) >= 1 + assert op.state.positions["tip"][1] < y0 + g1 = count_edge_crossings(op.state.positions, op.state.links) + assert g1 <= g0 + assert not _has_any_footprint_overlap(op.state.positions, op.state.names) + area1 = abs( + ( + max(p[0] for p in op.state.positions.values()) + - min(p[0] for p in op.state.positions.values()) + ) + * ( + max(p[1] for p in op.state.positions.values()) + - min(p[1] for p in op.state.positions.values()) + ) + ) + assert area1 < area0 + + +def test_pull_far_chains_moves_isolates(): + nodes = [ + {"fabric_node_id": "p1", "name": "P1", "x": 0.0, "y": 0.0}, + {"fabric_node_id": "p2", "name": "P2", "x": 1000.0, "y": 0.0}, + {"fabric_node_id": "iso", "name": "Iso", "x": 500.0, "y": 5000.0}, + ] + edges = [ + {"a_node_id": "p1", "b_node_id": "p2"}, + ] + st = build_state_from_nodes_edges(nodes, edges) + y0 = st.positions["iso"][1] + op = pull_far_chains( + st, + portal_ids=["p1", "p2"], + max_chains=4, + min_tip_radius=1000.0, + scales=(0.8, 0.7), + max_clearance_slack=100, + pull_isolates=True, + ) + assert op.params.get("isolates_pulled", 0) >= 1 + assert op.state.positions["iso"][1] < y0 diff --git a/packages/netx-topology-mcp/tests/test_sink_dual_units.py b/packages/netx-topology-mcp/tests/test_sink_dual_units.py index 479cd3d..2f724fe 100644 --- a/packages/netx-topology-mcp/tests/test_sink_dual_units.py +++ b/packages/netx-topology-mcp/tests/test_sink_dual_units.py @@ -104,4 +104,4 @@ 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 + assert len(names) >= 14 diff --git a/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py b/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py new file mode 100644 index 0000000..e089909 --- /dev/null +++ b/packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py @@ -0,0 +1,146 @@ +"""Tests for suggestSinkHubs batch ranking.""" + +from __future__ import annotations + +from netx_topology_mcp.layout_ops.suggest_sink_hubs import ( + pick_batch, + suggest_sink_hub_batches, +) +from netx_topology_mcp.layout_structure import analyze_graph_structure + + +def _star_graph(): + # CN portals + TJP-like agg hub with 3 EN stubs + small GENA hub with 1 stub + nodes = [ + {"fabric_node_id": "cn1", "name": "X-CN1-Y", "level": 1, "x": 0, "y": 0}, + {"fabric_node_id": "cn2", "name": "X-CN2-Y", "level": 1, "x": 100, "y": 0}, + {"fabric_node_id": "tjp", "name": "X-TJP-AN1-Y", "level": 2, "x": 50, "y": 100}, + {"fabric_node_id": "e1", "name": "X-E1-EN1-Y", "level": 3, "x": 0, "y": 200}, + {"fabric_node_id": "e2", "name": "X-E2-EN1-Y", "level": 3, "x": 50, "y": 200}, + {"fabric_node_id": "e3", "name": "X-E3-EN1-Y", "level": 3, "x": 100, "y": 200}, + {"fabric_node_id": "gena", "name": "X-GENA-AN1-Y", "level": 2, "x": 200, "y": 100}, + {"fabric_node_id": "e4", "name": "X-E4-EN1-Y", "level": 3, "x": 200, "y": 200}, + ] + edges = [ + {"a_node_id": "cn1", "b_node_id": "cn2"}, + {"a_node_id": "cn1", "b_node_id": "tjp"}, + {"a_node_id": "cn2", "b_node_id": "tjp"}, + {"a_node_id": "tjp", "b_node_id": "e1"}, + {"a_node_id": "tjp", "b_node_id": "e2"}, + {"a_node_id": "tjp", "b_node_id": "e3"}, + {"a_node_id": "cn2", "b_node_id": "gena"}, + {"a_node_id": "gena", "b_node_id": "e4"}, + ] + return nodes, edges + + +def test_suggest_ranks_largest_territory_first() -> None: + nodes, edges = _star_graph() + struct = analyze_graph_structure(nodes, edges, hub_top_k=10) + src = {n["fabric_node_id"] for n in nodes} + report = suggest_sink_hub_batches( + hubs=struct["hubs"], + soft_blocks=struct["soft_blocks"], + source_ids=src, + sink_ids=set(), + dual_units=struct.get("dual_units"), + min_territory=1, + top_n=5, + ) + assert report["ok"] is True + assert report["batch_count"] >= 1 + top = report["batches"][0] + # TJP should beat GENA; CN portals must not lead + assert top["hub_id"] == "tjp" + assert "cn1" not in top["fabric_node_ids"] + assert "cn2" not in top["fabric_node_ids"] + assert set(top["fabric_node_ids"]) >= {"tjp", "e1", "e2", "e3"} + + +def test_suggest_drops_ids_already_on_sink() -> None: + nodes, edges = _star_graph() + struct = analyze_graph_structure(nodes, edges, hub_top_k=10) + src = {n["fabric_node_id"] for n in nodes} + report = suggest_sink_hub_batches( + hubs=struct["hubs"], + soft_blocks=struct["soft_blocks"], + source_ids=src, + sink_ids={"e1", "e2"}, + exclude_ids={"cn1", "cn2"}, + min_territory=1, + top_n=5, + ) + top = pick_batch(report, 1) + assert top is not None + assert top["hub_id"] == "tjp" + assert "e1" not in top["fabric_node_ids"] + assert "e2" not in top["fabric_node_ids"] + assert "e3" in top["fabric_node_ids"] + + +def test_suggest_skips_portal_as_hub_leader() -> None: + nodes, edges = _star_graph() + struct = analyze_graph_structure(nodes, edges, hub_top_k=10) + src = {n["fabric_node_id"] for n in nodes} + report = suggest_sink_hub_batches( + hubs=struct["hubs"], + soft_blocks=struct["soft_blocks"], + source_ids=src, + exclude_ids={"tjp"}, # force skip TJP as leader id + dual_units={"units": [{"portal_a": "cn1", "portal_b": "cn2"}]}, + min_territory=1, + top_n=5, + ) + hubs = [b["hub_id"] for b in report["batches"]] + assert "cn1" not in hubs and "cn2" not in hubs + # Excluded hub may still emit stub-only batch (hub id not in move list) + tjp_batch = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None) + assert tjp_batch is not None + assert "tjp" not in tjp_batch["fabric_node_ids"] + assert set(tjp_batch["fabric_node_ids"]) >= {"e1", "e2", "e3"} + + +def test_suggest_moves_stubs_under_sunk_hub() -> None: + nodes, edges = _star_graph() + struct = analyze_graph_structure(nodes, edges, hub_top_k=10) + src = {n["fabric_node_id"] for n in nodes} + report = suggest_sink_hub_batches( + hubs=struct["hubs"], + soft_blocks=struct["soft_blocks"], + source_ids=src, + sink_ids={"tjp", "e1", "e2"}, # hub already on sink + exclude_ids={"cn1", "cn2"}, + min_territory=1, + top_n=5, + ) + tjp = next((b for b in report["batches"] if b["hub_id"] == "tjp"), None) + assert tjp is not None + assert tjp["already_on_sink"] is True + assert tjp["fabric_node_ids"] == ["e3"] + + +def test_suggest_orphan_leftovers_batch() -> None: + nodes, edges = _star_graph() + # Add disconnected orphans on source + nodes = list(nodes) + [ + {"fabric_node_id": "iso1", "name": "X-ISO1-EN1-Y", "level": 3, "x": 900, "y": 900}, + {"fabric_node_id": "iso2", "name": "X-ISO2-EN1-Y", "level": 3, "x": 950, "y": 950}, + ] + struct = analyze_graph_structure(nodes, edges, hub_top_k=10) + src = {n["fabric_node_id"] for n in nodes} + # Everything except orphans already on sink + sink = src - {"iso1", "iso2"} + report = suggest_sink_hub_batches( + hubs=struct["hubs"], + soft_blocks=struct["soft_blocks"], + source_ids=src, + sink_ids=sink, + exclude_ids={"cn1", "cn2"}, + min_territory=0, + top_n=8, + ) + assert report["orphan_n"] == 2 + orphan = next((b for b in report["batches"] if b.get("orphan")), None) + assert orphan is not None + assert set(orphan["fabric_node_ids"]) == {"iso1", "iso2"} + assert orphan["block_method"] == "orphan_leftovers" diff --git a/packages/netx-topology-mcp/tests/test_topology_quality.py b/packages/netx-topology-mcp/tests/test_topology_quality.py index 2d53791..39515f4 100644 --- a/packages/netx-topology-mcp/tests/test_topology_quality.py +++ b/packages/netx-topology-mcp/tests/test_topology_quality.py @@ -131,11 +131,33 @@ def test_score_includes_mid_tier_weights() -> None: 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 + # default profile: rings down, clearance up vs legacy 0.10/0.08 + assert s["weights"]["rings"] <= 0.08 + assert s["weights"]["edge_clearance"] >= 0.08 + eye = score_layout_components( + { + "node_count": 200, + "edge_crossings": 50, + "crossings_per_link": 0.15, + "footprint_overlap_pairs": 0, + "label_overlap_pairs": 0, + "nn_p50": 170, + "space_utilization": 0.05, + "hull_utilization": 0.09, + "grid_occupancy": 0.05, + "edge_stretch_p50": 1.2, + "whitespace_index": 0.5, + "chain_score": 0.6, + "rings_score": 0.3, + "edge_clearance_score": 0.5, + "edge_axis_score": 0.3, + "axis_frac": 0.35, + }, + score_profile="eye", + ) + assert eye["score_profile"] == "eye" + assert eye["weights"]["edge_axis"] < s["weights"]["edge_axis"] good = score_layout_components( { "node_count": 40, diff --git a/tests/test_ne_collection_policy.py b/tests/test_ne_collection_policy.py new file mode 100644 index 0000000..b075d5b --- /dev/null +++ b/tests/test_ne_collection_policy.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +from netx_api.collection_policy import ( + DEFAULT_HISTORY_KEEP, + _normalize_interval_hours, + history_keep_value, + next_due_at, + policy_to_out, +) +from netx_api.collection_schemas import CollectionPolicyUpdate +from netx_api.models import NeCollectionJob, NeCollectionPolicy + + +class NeCollectionPolicyDefaultsTests(unittest.TestCase): + def test_default_history_keep_is_three(self) -> None: + self.assertEqual(DEFAULT_HISTORY_KEEP, 3) + row = NeCollectionPolicy(id=1, enabled=False, history_keep=3) + self.assertEqual(history_keep_value(row), 3) + out = policy_to_out(row) + self.assertFalse(out.enabled) + self.assertEqual(out.history_keep, 3) + + def test_normalize_interval_hours_from_days(self) -> None: + row = NeCollectionPolicy(id=1, interval_hours=0, interval_days=2) + self.assertEqual(_normalize_interval_hours(row), 48) + + def test_policy_update_schema_bounds(self) -> None: + body = CollectionPolicyUpdate(history_keep=3, interval_hours=24, enabled=False) + data = body.model_dump(exclude_unset=True) + self.assertEqual(data["history_keep"], 3) + self.assertFalse(data["enabled"]) + + +class NeCollectionNextDueTests(unittest.TestCase): + def test_next_due_none_when_disabled(self) -> None: + db = MagicMock() + policy = NeCollectionPolicy(id=1, enabled=False, interval_hours=24) + self.assertIsNone(next_due_at(db, policy)) + + def test_next_due_now_when_no_prior_schedule(self) -> None: + db = MagicMock() + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None + policy = NeCollectionPolicy(id=1, enabled=True, interval_hours=24) + due = next_due_at(db, policy) + self.assertIsNotNone(due) + assert due is not None + self.assertLessEqual(abs((due - datetime.now()).total_seconds()), 5) + + def test_next_due_from_last_scheduled_done(self) -> None: + db = MagicMock() + ended = datetime.now() - timedelta(hours=2) + last = NeCollectionJob( + id="abc", + status="done", + trigger_mode="schedule", + ended_at=ended, + ) + db.query.return_value.filter.return_value.order_by.return_value.first.return_value = last + policy = NeCollectionPolicy(id=1, enabled=True, interval_hours=24) + due = next_due_at(db, policy) + self.assertIsNotNone(due) + assert due is not None + self.assertEqual(due, ended + timedelta(hours=24)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_topology.py b/tests/test_topology.py index ecd3e41..690bb27 100644 --- a/tests/test_topology.py +++ b/tests/test_topology.py @@ -977,11 +977,14 @@ class FabricTopologyTests(unittest.TestCase): ), ) prev = clf.preview_classify(self.db) + self.assertGreaterEqual(prev.level_matched, 4) self.assertGreaterEqual(prev.role_matched, 4) applied = clf.apply_classify(self.db) + self.assertGreaterEqual(applied.level_updated, 4) self.assertGreaterEqual(applied.role_updated, 4) self.db.refresh(nodes[0]) self.assertEqual(nodes[0].role, "core") + self.assertEqual(nodes[0].level, 1.0) self.assertEqual(nodes[0].region_folder_id, region.id) dry = clf.generate_slices( @@ -1026,6 +1029,11 @@ class FabricTopologyTests(unittest.TestCase): ), ) self.assertEqual(bulk.updated, 1) + self.assertEqual(bulk.level, 3.0) + acc = next(n for n in nodes if n.name.startswith("ACC-")) + self.db.refresh(acc) + self.assertEqual(acc.level, 3.0) + self.assertEqual(acc.role, "access") def test_project_neighbors_respects_max_nodes(self) -> None: suffix = uuid4().hex[:8] diff --git a/web/WEB.md b/web/WEB.md index 12f0561..c00e803 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -138,14 +138,14 @@ src/ - MCP:以 `queryTopologyEdges` 为主查询 Fabric;画布编辑走 Web - BGP / 隧道 / L2VPN:`layer` 预留,实现 TODO -## 分类与切片(清单打标) +## 分类(清单打标) -- Fabric 标签:`topo_fabric_node.role` / `region_folder_id`(`role_source` / `region_source`) -- 主流程:网元清单表(Fabric 全量)+ 临时正则查找 → 确认后批量写角色/区域;支持单行编辑 -- 关联状态:`link_status`=`managed|ume|both|orphaned`;筛选 `link_status=linked|orphaned|…` -- API:`GET /fabric/nodes`(keyword/role/region/unmatched/link_status)、`POST /fabric/nodes/match`、`POST /fabric/nodes/tags/bulk`、`PATCH /fabric/nodes/{id}/tags` -- 切片:`POST /v1/topology/slices/generate`(`core_only` / `core_agg` / `agg_access`;dry_run;可重叠上图) -- 前端:网络管理 →「分类与切片」 +- Fabric 主键:`topo_fabric_node.level`(major.minor,越小越靠外);`role` 为 `floor(level)` 同步别名 +- 预设:0 外部 / 1 核心 / 2 汇聚 / 3 接入;子层如 1.1、2.1 +- 主流程:清单 + 正则 → 批量写 level/区域;支持单行编辑 +- API:`GET /fabric/nodes`(level / level_major / role / unmatched=level)、`POST …/match`、`POST …/tags/bulk`(`level`)、`PATCH …/tags` +- Agent:`classifyTopologyFabricNodes`(netx-topology MCP) +- 前端:网络管理 →「分类」 ## WebCRT diff --git a/web/src/components/ListPager.tsx b/web/src/components/ListPager.tsx new file mode 100644 index 0000000..2c22e4e --- /dev/null +++ b/web/src/components/ListPager.tsx @@ -0,0 +1,116 @@ +import { useEffect, useState } from "react"; +import { useI18n } from "../i18n"; + +export type ListPagerProps = { + page: number; + pages: number; + total: number; + pageSize: number; + pageSizeOptions?: number[]; + onPageChange: (page: number) => void; + onPageSizeChange?: (size: number) => void; + disabled?: boolean; + className?: string; +}; + +/** Shared denser pager: meta + prev/next + jump + optional page size. */ +export function ListPager({ + page, + pages, + total, + pageSize, + pageSizeOptions = [20, 50, 100, 200], + onPageChange, + onPageSizeChange, + disabled = false, + className = "", +}: ListPagerProps) { + const { t } = useI18n(); + const safePages = Math.max(1, pages || 1); + const [jumpDraft, setJumpDraft] = useState(String(page)); + + useEffect(() => { + setJumpDraft(String(page)); + }, [page]); + + const commitJump = () => { + const n = Math.floor(Number(jumpDraft)); + if (!Number.isFinite(n)) { + setJumpDraft(String(page)); + return; + } + const next = Math.min(safePages, Math.max(1, n)); + setJumpDraft(String(next)); + if (next !== page) onPageChange(next); + }; + + return ( +
+
+ {t("common.pagerMeta", { + total: String(total), + page: String(page), + pages: String(safePages), + })} +
+
+ + + + {onPageSizeChange ? ( + + ) : null} +
+
+ ); +} diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts index 7bb869e..3b0c5d1 100644 --- a/web/src/constants/queryKeys.ts +++ b/web/src/constants/queryKeys.ts @@ -27,12 +27,14 @@ export const queryKeys = { collectionEligibleNeAll: ["collectionEligibleNe"] as const, collectionEligibleNe: (page: number, keyword: string) => ["collectionEligibleNe", page, keyword] as const, neCollectionsAll: ["neCollections"] as const, - neCollections: (page: number) => ["neCollections", page] as const, + neCollections: (page: number, pageSize = 20, status = "", keyword = "") => + ["neCollections", page, pageSize, status, keyword] as const, neCollectionDashboard: ["neCollectionDashboard"] as const, + neCollectionPolicy: ["neCollectionPolicy"] as const, neCollectionDetail: (jobId: string) => ["neCollection", jobId] as const, neCollectionRunsAll: ["neCollectionRuns"] as const, - neCollectionRuns: (jobId: string, page: number, status: string, keyword: string) => - ["neCollectionRuns", jobId, page, status, keyword] as const, + neCollectionRuns: (jobId: string, page: number, pageSize: number, status: string, keyword: string) => + ["neCollectionRuns", jobId, page, pageSize, status, keyword] as const, cliMeta: ["cliMeta"] as const, cliProfiles: ["cliProfiles"] as const, cliTargetsAll: ["cliTargets"] as const, @@ -56,7 +58,9 @@ export const queryKeys = { unmatched: string, linkStatus: string, page: number, - ) => ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page] as const, + levelMajor?: string, + ) => + ["fabricNodeInventory", keyword, role, regionFolderId, unmatched, linkStatus, page, levelMajor || ""] as const, fabricNodeSearch: (q: string, page: number) => ["fabricNodeSearch", q, page] as const, topologyViewGraph: (viewId: string) => ["topologyViewGraph", viewId] as const, fabricSummary: ["fabricSummary"] as const, @@ -66,26 +70,35 @@ export const queryKeys = { ["topologyViewGraph", viewId, focusFolderId] as const, lldpCollectDashboard: ["lldpCollectDashboard"] as const, lldpCollectJobsAll: ["lldpCollectJobs"] as const, - lldpCollectJobs: (page: number) => ["lldpCollectJobs", page] as const, + lldpCollectJobs: (page: number, pageSize = 10, status = "", keyword = "") => + ["lldpCollectJobs", page, pageSize, status, keyword] as const, lldpCollectJobAll: ["lldpCollectJob"] as const, - lldpCollectJob: (jobId: string, page = 1) => ["lldpCollectJob", jobId, page] as const, + lldpCollectJob: (jobId: string, page = 1, pageSize = 20, status = "", keyword = "") => + ["lldpCollectJob", jobId, page, pageSize, status, keyword] as const, fabricEdgesAll: ["fabricEdges"] as const, - fabricEdges: (status: string, keyword: string, page: number) => - ["fabricEdges", status, keyword, page] as const, + fabricEdges: (status: string, keyword: string, page: number, pageSize = 20, source = "") => + ["fabricEdges", status, keyword, page, pageSize, source] as const, configSyncDashboard: ["configSyncDashboard"] as const, configSyncPolicy: ["configSyncPolicy"] as const, configSyncCyclesAll: ["configSyncCycles"] as const, - configSyncCycles: (page: number) => ["configSyncCycles", page] as const, + configSyncCycles: (page: number, pageSize = 20, status = "", keyword = "") => + ["configSyncCycles", page, pageSize, status, keyword] as const, configSyncCycleTasksAll: ["configSyncCycleTasks"] as const, - configSyncCycleTasks: (cycleId: string, page: number, status: string, keyword: string) => - ["configSyncCycleTasks", cycleId, page, status, keyword] as const, + configSyncCycleTasks: ( + cycleId: string, + page: number, + pageSize = 20, + status = "", + keyword = "", + ) => ["configSyncCycleTasks", cycleId, page, pageSize, status, keyword] as const, networkConfigsAll: ["networkConfigs"] as const, - networkConfigs: (page: number, keyword: string, source: string, vendor: string) => - ["networkConfigs", page, keyword, source, vendor] as const, + networkConfigs: (page: number, keyword: string, source: string, vendor: string, pageSize = 20) => + ["networkConfigs", page, keyword, source, vendor, pageSize] as const, networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const, portTrafficDashboard: ["portTrafficDashboard"] as const, portTrafficDevicesAll: ["portTrafficDevices"] as const, - portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const, + portTrafficDevices: (page: number, pageSize = 20, status = "", keyword = "") => + ["portTrafficDevices", page, pageSize, status, keyword] as const, portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const, portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const, portTrafficBoards: ["portTrafficBoards"] as const, diff --git a/web/src/hooks/useDebouncedValue.ts b/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..5539cbf --- /dev/null +++ b/web/src/hooks/useDebouncedValue.ts @@ -0,0 +1,11 @@ +import { useEffect, useState } from "react"; + +/** Debounce a rapidly changing value (e.g. keyword input) before querying. */ +export function useDebouncedValue(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const timer = window.setTimeout(() => setDebounced(value), delayMs); + return () => window.clearTimeout(timer); + }, [value, delayMs]); + return debounced; +} diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index b281da6..8667579 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -9,6 +9,13 @@ const en = { nextPage: "Next", perPage: "{{n}}/page", pagerMeta: "{{total}} items · page {{page}}/{{pages}}", + jumpPage: "Go to page", + pageSize: "Page size", + exportCsv: "Export CSV", + exporting: "Exporting…", + exportOk: "Exported {{count}} rows", + exportTruncated: "Exported first {{count}} of {{total}} rows (truncated)", + exportFailed: "Export failed", opFailed: "Operation failed", empty: "-", cancel: "Cancel", @@ -100,7 +107,7 @@ const en = { devices: "Device list", topology: "Topology", lldpLinks: "LLDP", - topoClassify: "Classify / slices", + topoClassify: "Classify", alarms: "Alarms", configs: "Configurations", webcrt: "WebCRT", @@ -130,10 +137,15 @@ const en = { statusRunning: "Running", statusPaused: "Paused", statusStopped: "Stopped", + statusDraft: "Draft", collecting: "Collecting", deleted: "Deleted", confirmDelete: "Delete this device monitoring and its samples?", empty: "No devices monitored yet. Pick a NE and interfaces to start.", + keywordPh: "Name / IP / note / ID", + allStatus: "All statuses", + exportDevicesName: "port-traffic-devices", + exportEventsName: "port-traffic-events", wall: "Ops wall", wallTitle: "Port traffic wall", toDevices: "Manage devices", @@ -354,6 +366,14 @@ const en = { edgeStatusAll: "All statuses", edgeStatusActive: "Active", edgeStatusMissing: "Missing", + edgeSourceAll: "All sources", + exportEdgesName: "lldp-fabric-edges", + exportJobsName: "lldp-collect-jobs", + exportItemsName: "lldp-job-items", + statusAll: "All statuses", + jobKeywordPh: "Job ID / error / scope", + itemKeywordPh: "NE name / IP / error", + itemResultAll: "All results", missCount: "Miss cycles", replacedBy: "Replaced", kpi: { @@ -392,6 +412,7 @@ const en = { done: "Done", failed: "Failed", cancelled: "Cancelled", + stopped: "Stopped", }, triggerMode: { manual: "Manual", @@ -444,7 +465,11 @@ const en = { cyclesEmpty: "No sync cycles yet", tasksTitle: "Cycle tasks", taskKeywordPh: "Name / IP / message", + keywordPh: "ID / trigger / message", + statusAll: "All statuses", allStatus: "All statuses", + exportCyclesName: "config-sync-cycles", + exportTasksName: "config-sync-tasks", expand: "Details", collapse: "Collapse", kpi: { @@ -472,12 +497,14 @@ const en = { keywordPh: "Name / IP / ID", allSource: "All sources", empty: "No network elements.", + exportName: "network-devices", col: { source: "Source", name: "Name", vendor: "Vendor", deviceType: "Device type", connect: "Connect", + actions: "Actions", }, }, networkConfigs: { @@ -490,6 +517,7 @@ const en = { export: "Export", exportBoth: "Export all", exportOk: "Config exported", + exportListName: "network-configs", close: "Close", tabSet: "Set format", tabHier: "Hierarchical", @@ -626,6 +654,28 @@ const en = { passwordMustChange: "New password must differ from the default/old password", }, collect: { + statusAll: "All statuses", + keywordPh: "Job title / ID", + exportJobsName: "collect-jobs", + exportRunsName: "collect-runs", + policyTitle: "Collect policy", + policyHint: + "Periodic schedule is off by default (one-shot / manual). Enable it to auto-create and run jobs on an interval. Keeps the last 3 finished jobs by default.", + policySaved: "Policy saved", + enabled: "Enable scheduled collect", + interval: "Collect interval", + intervalUnitDays: "days", + intervalUnitHours: "hours", + historyKeep: "Jobs to keep", + scope: "Scope", + scopeAll: "All NEs (managed + UME)", + scopeSelected: "Selected NEs only", + savePolicy: "Save policy", + collectNow: "Collect now", + selectedCount: "{{count}} selected", + colSource: "Source", + policyCommandsRequired: "Add commands and save the policy before enabling schedule or collecting now", + policyTargetsRequired: "No eligible NEs in scope; adjust scope or select NEs", create: { title: "New collection job", hint: "Configure commands and NEs, then create the job. Click Start in the job list to run collection.", @@ -676,6 +726,7 @@ const en = { confirmDelete: "Delete this collection job and its log files?", jobs: { title: "Collection jobs", + listTitle: "Job list", col: { title: "Job", status: "Status", @@ -705,6 +756,7 @@ const en = { idle: "Idle", last: "Last job", active: "Active jobs", + nextDue: "Next due", }, runs: { status: "Status", @@ -1152,10 +1204,20 @@ const en = { keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)", hostNamePh: "host_name (contains)", allSeverity: "All severities", - clearedAll: "is_cleared: all", + clearedAll: "Cleared: all", + clearedYes: "Cleared", + clearedNo: "Active", clearTitle: "Clear filters and reset to page 1", filterByHost: "Filter by this host", noHostName: "No host_name (sync inventory first)", + col: { + time: "Time", + severity: "Severity", + neId: "NE ID", + hostName: "Host", + neType: "NE type", + cause: "Cause", + }, }, }, webcrt: { @@ -1397,6 +1459,7 @@ const en = { treeLoadFailed: "Failed to load topology tree", treeRetry: "Retry", graphLoading: "Loading topology…", + graphLoadFailed: "Failed to load topology", graphRefreshing: "Refreshing…", canvasEmpty: "No NEs on this map yet", canvasEmptyHint: "Use Add NE, or pick a region that already has devices.", @@ -1476,8 +1539,8 @@ const en = { "Remove selected link(s) from the canvas? They are deleted from Fabric only when you Save. LLDP may recreate them on the next collect.", edgeDeleted: "Queued {{count}} link delete(s) — Save to apply", discoverCancelled: "Discovery cancelled", - truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify/slices or a new map.", - truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze in classify/slices or create a new map.", + truncatedMembership: "Membership cap reached; some neighbors were not placed. Use classify or a new map.", + truncatedFrozen: "This map membership is frozen; neighbors cannot be projected. Unfreeze via classify workflow or create a new map.", truncatedNodes: "Too many view nodes; display truncated. Narrow membership or create another map.", truncatedEdges: "Too many edges; display truncated. Filter status or create another map.", truncatedGeneric: "Graph data truncated. Narrow scope or create another map.", @@ -1506,6 +1569,11 @@ const en = { unsavedConfirm: "You have unsaved changes. Discard and switch maps?", saving: "Saving…", saved: "Topology saved", + saveFailed: "Save failed: {{detail}}", + readOnlyBanner: "Read-only (ne:write scope required to edit)", + readOnlyHint: "Read-only — no write permission", + discoverTimeout: "Discovery timed out; check LLDP tasks for progress", + policySyncFailed: "Failed to sync LLDP policy; local toggle still applies to canvas discovery", discover: "Discover links", discovering: "Discovering…", discoverOne: "Discover links", @@ -1697,16 +1765,16 @@ const en = { connectHint: "Connect mode: drag from the NE center anchor to the target (Esc or Select to exit)", }, topoClassify: { - title: "Classify & slices", + title: "Classify", openTopo: "Topology", inventory: "NE inventory", keywordPh: "Filter by name/IP", - filterRoleAll: "All roles", + filterLevelAll: "All levels", filterRegionAll: "All regions", filterUnmatchedOff: "No unmatched filter", - kindAny: "Role or region empty", - kindRole: "Role empty", - kindRegion: "Region empty", + kindAny: "Missing level or region", + kindLevel: "Missing level", + kindRegion: "Missing region", filterLinkAll: "All link states", filterLinkLinked: "Linked inventory", filterLinkOrphaned: "Detached", @@ -1715,10 +1783,14 @@ const en = { linkUme: "UME", linkBoth: "Managed+UME", linkOrphaned: "Detached", - roleCore: "Core", - roleAggregation: "Aggregation", - roleAccess: "Access", - roleUnknown: "Unknown", + levelExternal: "L0 external", + levelCore: "L1 core", + levelCoreSub: "L1.1 core′", + levelAgg: "L2 aggregation", + levelAggSub: "L2.1 agg′", + levelAccess: "L3 access", + levelAccessSub: "L3.1 deeper access", + levelInvalid: "Invalid level (0–99.9, one decimal)", sourceManual: "Manual", sourceUmeSync: "UME sync", sourceLldp: "LLDP placeholder", @@ -1731,9 +1803,9 @@ const en = { findMatches: "Find matches", matchOk: "Matched {{count}}; selected", matchHint: "{{count}} matched", - assignRole: "Role only", + assignLevel: "Level only", assignRegion: "Region only", - assignBoth: "Role + region", + assignBoth: "Level + region", clearRegion: "Clear region", confirmAssign: "Confirm assign", bulkConfirm: "Write tags to {{count}} selected/matched NE(s)?", @@ -1741,7 +1813,7 @@ const en = { selectedCount: "{{count}} selected", selectPage: "Select page", colNe: "NE", - colRole: "Role", + colLevel: "Level", colRegion: "Region", colActions: "Actions", edit: "Edit", @@ -1758,20 +1830,6 @@ const en = { rowSaved: "Saved", loading: "Loading…", empty: "No NEs.", - showSlices: "Show slice maps", - hideSlices: "Hide slice maps", - slices: "Slice maps", - pickRegion: "Pick region", - tplCore: "Core only", - tplCoreAgg: "Core + neighbor Agg", - tplAggAcc: "Agg + neighbor Access", - seedPhysical: "Also place Cores on physical overview", - slicePreview: "Preview slices", - sliceApply: "Generate maps", - sliceConfirm: "Create custom maps and freeze placements?", - sliceOk: "Created {{count}} slice map(s)", - sliceStats: "{{maps}} map(s) planned, {{overlap}} NE(s) on multiple maps", - nodeCount: "{{count}} NE(s)", }, audit: { title: "Audit & ops", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index bc473fa..fdbf583 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -9,6 +9,13 @@ const zh = { nextPage: "下一页", perPage: "{{n}}/页", pagerMeta: "共 {{total}} 条 · 第 {{page}}/{{pages}} 页", + jumpPage: "跳转到页", + pageSize: "每页条数", + exportCsv: "导出 CSV", + exporting: "导出中…", + exportOk: "已导出 {{count}} 条", + exportTruncated: "已导出前 {{count}} 条(共 {{total}},已截断)", + exportFailed: "导出失败", opFailed: "操作失败", empty: "-", cancel: "取消", @@ -100,7 +107,7 @@ const zh = { devices: "设备列表", topology: "拓扑信息", lldpLinks: "LLDP", - topoClassify: "分类切片", + topoClassify: "分类", alarms: "告警信息", configs: "配置信息", webcrt: "WebCRT", @@ -130,10 +137,15 @@ const zh = { statusRunning: "运行中", statusPaused: "已暂停", statusStopped: "已停止", + statusDraft: "草稿", collecting: "采集中", deleted: "已删除", confirmDelete: "删除该设备监控及其采样数据?", empty: "暂无设备监控。请先选择网元并勾选接口。", + keywordPh: "名称 / IP / 备注 / ID", + allStatus: "全部状态", + exportDevicesName: "port-traffic-devices", + exportEventsName: "port-traffic-events", wall: "监控大屏", wallTitle: "端口流量大屏", toDevices: "设备管理", @@ -350,6 +362,14 @@ const zh = { edgeStatusAll: "全部状态", edgeStatusActive: "活跃", edgeStatusMissing: "未发现", + edgeSourceAll: "全部来源", + exportEdgesName: "lldp-fabric-edges", + exportJobsName: "lldp-collect-jobs", + exportItemsName: "lldp-job-items", + statusAll: "全部状态", + jobKeywordPh: "任务 ID / 错误 / 范围", + itemKeywordPh: "网元名称 / IP / 错误", + itemResultAll: "全部结果", missCount: "未发现周期", replacedBy: "已被替换", kpi: { @@ -388,6 +408,7 @@ const zh = { done: "已完成", failed: "失败", cancelled: "已取消", + stopped: "已停止", }, triggerMode: { manual: "手动", @@ -440,7 +461,11 @@ const zh = { cyclesEmpty: "暂无同步周期", tasksTitle: "周期任务", taskKeywordPh: "名称 / IP / 消息", + keywordPh: "ID / 触发 / 消息", + statusAll: "全部状态", allStatus: "全部状态", + exportCyclesName: "config-sync-cycles", + exportTasksName: "config-sync-tasks", expand: "详情", collapse: "收起", kpi: { @@ -468,12 +493,14 @@ const zh = { keywordPh: "名称 / IP / ID", allSource: "全部来源", empty: "暂无网元。", + exportName: "network-devices", col: { source: "来源", name: "名称", vendor: "厂商", deviceType: "设备类型", connect: "连通状态", + actions: "操作", }, }, networkConfigs: { @@ -486,6 +513,7 @@ const zh = { export: "导出", exportBoth: "导出全部", exportOk: "配置已导出", + exportListName: "network-configs", close: "关闭", tabSet: "Set 格式", tabHier: "层级格式", @@ -621,6 +649,27 @@ const zh = { passwordMustChange: "新密码不能与默认/旧密码相同", }, collect: { + statusAll: "全部状态", + keywordPh: "任务名称 / ID", + exportJobsName: "collect-jobs", + exportRunsName: "collect-runs", + policyTitle: "采集策略", + policyHint: "默认不启用周期(只采一次);勾选后按周期自动创建并执行。默认保留最近 3 次任务数据。", + policySaved: "策略已保存", + enabled: "启用周期调度", + interval: "采集周期", + intervalUnitDays: "天", + intervalUnitHours: "小时", + historyKeep: "任务保留数", + scope: "范围", + scopeAll: "全部网元(纳管+UME)", + scopeSelected: "仅选中网元", + savePolicy: "保存策略", + collectNow: "立即采集", + selectedCount: "已选 {{count}} 台", + colSource: "来源", + policyCommandsRequired: "启用周期或立即采集前,请先填写采集命令并保存策略", + policyTargetsRequired: "选定范围内没有可用网元,请调整范围或勾选网元", create: { title: "新建采集任务", hint: "配置命令与网元后创建任务;创建后需在任务列表中点击「开始」执行采集。", @@ -671,6 +720,7 @@ const zh = { confirmDelete: "确定删除该采集任务?相关日志文件将一并删除。", jobs: { title: "采集任务", + listTitle: "任务列表", col: { title: "任务", status: "状态", @@ -700,6 +750,7 @@ const zh = { idle: "空闲", last: "最近任务", active: "活跃任务", + nextDue: "下次周期", }, runs: { status: "状态", @@ -1145,10 +1196,20 @@ const zh = { keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)", hostNamePh: "host_name(含匹配)", allSeverity: "全部级别", - clearedAll: "is_cleared: all", + clearedAll: "清除状态:全部", + clearedYes: "已清除", + clearedNo: "未清除", clearTitle: "清空 keyword、host_name、级别、is_cleared,回到第 1 页", filterByHost: "按该主机名筛选", noHostName: "无 host_name(需先同步网元)", + col: { + time: "告警时间", + severity: "级别", + neId: "网元 ID", + hostName: "主机名", + neType: "网元类型", + cause: "原因", + }, }, }, webcrt: { @@ -1388,6 +1449,7 @@ const zh = { treeLoadFailed: "拓扑树加载失败", treeRetry: "重试", graphLoading: "正在加载拓扑图…", + graphLoadFailed: "拓扑图加载失败", graphRefreshing: "刷新中…", canvasEmpty: "这张图上还没有网元", canvasEmptyHint: "可用「添加网元」上图,或从左侧换到有设备的区域。", @@ -1464,8 +1526,8 @@ const zh = { deleteEdgeConfirm: "从画布移除选中链路?仅在点击「保存」后才会从 Fabric 删除;LLDP 下次采集若仍存在可能再次出现。", edgeDeleted: "已标记删除 {{count}} 条链路(保存后生效)", discoverCancelled: "发现已取消", - truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类/切片或新建视图。", - truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类/切片中解冻或新建视图。", + truncatedMembership: "视图已达成员上限,部分邻居未上图。可去分类页或新建视图。", + truncatedFrozen: "当前视图已冻结成员,无法投影邻居。请在分类流程中处理或新建视图。", truncatedNodes: "画布节点过多已截断显示。建议新建视图或收紧成员范围。", truncatedEdges: "画布链路过多已截断显示。建议筛选状态或新建视图。", truncatedGeneric: "图数据已截断显示。建议收紧范围或新建视图。", @@ -1494,6 +1556,11 @@ const zh = { unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?", saving: "保存中…", saved: "拓扑已保存", + saveFailed: "保存失败:{{detail}}", + readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)", + readOnlyHint: "只读模式,无编辑权限", + discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度", + policySyncFailed: "同步 LLDP 策略失败,本地开关仍对本页发现生效", discover: "发现链路", discovering: "发现中…", discoverOne: "发现链路", @@ -1680,15 +1747,15 @@ const zh = { connectHint: "连线模式:从网元中心锚点拖到目标网元(Esc 或切回选择退出)", }, topoClassify: { - title: "分类与切片", + title: "分类", openTopo: "拓扑画布", inventory: "网元清单", keywordPh: "关键字筛选名称/IP", - filterRoleAll: "全部角色", + filterLevelAll: "全部层级", filterRegionAll: "全部区域", filterUnmatchedOff: "不过滤未分类", - kindAny: "角色或区域未填", - kindRole: "角色未填", + kindAny: "层级或区域未填", + kindLevel: "层级未填", kindRegion: "区域未填", filterLinkAll: "全部关联", filterLinkLinked: "已关联库存", @@ -1698,10 +1765,14 @@ const zh = { linkUme: "UME", linkBoth: "托管+UME", linkOrphaned: "已解绑", - roleCore: "核心", - roleAggregation: "汇聚", - roleAccess: "接入", - roleUnknown: "未知", + levelExternal: "L0 外部", + levelCore: "L1 核心", + levelCoreSub: "L1.1 次核心", + levelAgg: "L2 汇聚", + levelAggSub: "L2.1 二级汇聚", + levelAccess: "L3 接入", + levelAccessSub: "L3.1 更深接入", + levelInvalid: "层级无效(0–99.9,一位小数)", sourceManual: "手动", sourceUmeSync: "UME 同步", sourceLldp: "LLDP 占位", @@ -1714,9 +1785,9 @@ const zh = { findMatches: "查找匹配", matchOk: "匹配 {{count}} 条,已勾选", matchHint: "当前匹配 {{count}} 条", - assignRole: "只改角色", + assignLevel: "只改层级", assignRegion: "只改区域", - assignBoth: "角色+区域", + assignBoth: "层级+区域", clearRegion: "清空区域", confirmAssign: "确认写入", bulkConfirm: "将给已选/匹配的 {{count}} 条网元写入分类,确定?", @@ -1724,7 +1795,7 @@ const zh = { selectedCount: "已选 {{count}}", selectPage: "全选本页", colNe: "网元", - colRole: "角色", + colLevel: "层级", colRegion: "区域", colActions: "操作", edit: "编辑", @@ -1739,20 +1810,6 @@ const zh = { rowSaved: "已保存", loading: "加载中…", empty: "没有网元。", - showSlices: "展开切片建图", - hideSlices: "收起切片建图", - slices: "切片建图", - pickRegion: "选择区域", - tplCore: "仅 Core", - tplCoreAgg: "Core + 邻接 Agg", - tplAggAcc: "Agg + 邻接 Access", - seedPhysical: "同时给物理总览上 Core", - slicePreview: "预览切片", - sliceApply: "生成切片图", - sliceConfirm: "将创建自定义拓扑图并上图冻结,确定?", - sliceOk: "已创建 {{count}} 张切片图", - sliceStats: "计划 {{maps}} 张图,重复上图网元 {{overlap}} 个", - nodeCount: "{{count}} 个网元", }, audit: { title: "操作审计", diff --git a/web/src/index.css b/web/src/index.css index 9150ede..18f48e4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -2208,6 +2208,36 @@ pre { min-width: 88px; } +.pager__jump { + position: relative; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; +} + +.pager__jump-input { + width: 3.2rem; + min-width: 2.5rem; + max-width: 4.5rem; + padding: 4px 6px; + text-align: center; +} + +.visually-hidden { + position: absolute; + inset: 0; + width: 1px; + height: 1px; + padding: 0; + margin: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + @keyframes shimmer { 0% { background-position: 200% 0; @@ -7591,6 +7621,26 @@ pre { pointer-events: none; } +.topo-readonly-banner { + position: absolute; + top: 8px; + right: 12px; + z-index: 5; + max-width: min(360px, calc(100% - 24px)); + padding: 6px 10px; + border-radius: 8px; + border: 1px solid rgba(56, 189, 248, 0.45); + background: rgba(15, 23, 42, 0.92); + color: #bae6fd; + font-size: 12px; + line-height: 1.4; + pointer-events: none; +} + +.topo-canvas__overlay--error { + background: rgba(15, 23, 42, 0.88); +} + .app-main .topo-toolbar__outside-peers, .topo-toolbar__outside-peers { margin-left: auto; @@ -8506,12 +8556,32 @@ html.login-page--paused .login-page__flare { /* —— Network Management shell (left nav + main) —— - Light ops shell + login-aligned accent blues. */ + Fill the app chrome without a second document scrollbar. + Negative margins + 100vh used to fight app-main padding and over-expand the page. */ +.app--shell:has(.network-shell), +.app--shell:has(.audit-shell) { + height: 100vh; + max-height: 100vh; + overflow: hidden; +} + +.app-main:has(> .network-shell), +.app-main:has(> .audit-shell) { + flex: 1 1 auto; + min-height: 0; + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; +} + .network-shell { display: flex; - height: calc(100vh - 48px); - min-height: 480px; - margin: -20px -28px -32px; + flex: 1 1 auto; + width: 100%; + min-height: 0; + height: auto; + margin: 0; background: var(--nm-chrome); overflow: hidden; } diff --git a/web/src/pages/CollectPage.tsx b/web/src/pages/CollectPage.tsx index ebf599b..6be874e 100644 --- a/web/src/pages/CollectPage.tsx +++ b/web/src/pages/CollectPage.tsx @@ -1,5 +1,7 @@ -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { ListPager } from "../components/ListPager"; import { createNeCollection, deleteCollectionJob, @@ -8,31 +10,51 @@ import { fetchCollectionRuns, fetchEligibleNe, fetchNeCollections, + formatErr, pauseCollectionJob, startCollectionJob, + startCollectionFromPolicy, retryFailedCollectionJob, + updateCollectionPolicy, collectionJobDownloadUrl, collectionRunDownloadUrl, } from "../services/api"; import { queryKeys } from "../constants/queryKeys"; +import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useI18n } from "../i18n"; import { useToast } from "../hooks/useToast"; -import type { CollectionJobItem, EligibleNeItem } from "../types"; +import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types"; +import { downloadCsv, fetchAllPages } from "../utils/csvExport"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; const POLL_MS = 2000; const ELIGIBLE_PAGE_SIZE = 20; +const POLICY_TARGET_PAGE_SIZE = 20; +const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const; +const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; +const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100]; function eligibleKey(row: Pick): string { const src = String(row.source || "managed").trim().toLowerCase() || "managed"; return `${src}:${row.id}`; } +function targetKey(ref: CollectionTargetRef): string { + const src = String(ref.source || "managed").trim().toLowerCase() || "managed"; + return `${src}:${ref.id}`; +} + function collectionErrorMessage(err: unknown, t: (key: string) => string): string { const raw = String(err); if (raw.includes("collection_ne_busy")) return t("collect.neBusy"); if (raw.includes("collection_job_running")) return t("collect.jobRunning"); + if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) { + return t("collect.policyCommandsRequired"); + } + if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) { + return t("collect.policyTargetsRequired"); + } return raw; } @@ -40,6 +62,7 @@ export function CollectPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); const [commands, setCommands] = useState(""); const [title, setTitle] = useState(""); @@ -48,12 +71,46 @@ export function CollectPage() { const [neKeyword, setNeKeyword] = useState(""); const [nePage, setNePage] = useState(1); const [jobPage, setJobPage] = useState(1); + const [jobPageSize, setJobPageSize] = useState(20); + const [jobStatus, setJobStatus] = useState(""); + const [jobKeyword, setJobKeyword] = useState(""); + const [exportingJobs, setExportingJobs] = useState(false); const [expandedJobId, setExpandedJobId] = useState(""); const [createOpen, setCreateOpen] = useState(false); + const [policyEnabled, setPolicyEnabled] = useState(false); + const [policyIntervalValue, setPolicyIntervalValue] = useState(1); + const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days"); + const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3); + const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all"); + const [policyTitle, setPolicyTitle] = useState(""); + const [policyCommands, setPolicyCommands] = useState(""); + const [policySelectedMap, setPolicySelectedMap] = useState>({}); + const [policyHydrated, setPolicyHydrated] = useState(false); + const [policyTargetKeyword, setPolicyTargetKeyword] = useState(""); + const [policyTargetPage, setPolicyTargetPage] = useState(1); + + const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300); + const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300); + const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]); const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]); + // Deep link: /collect?job_id=… → open runs modal, then strip param + useEffect(() => { + const jobId = String(searchParams.get("job_id") || "").trim(); + if (!jobId) return; + setExpandedJobId(jobId); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (next.get("job_id") === jobId) next.delete("job_id"); + return next; + }, + { replace: true }, + ); + }, [searchParams, setSearchParams]); + const eligibleQuery = useQuery({ queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword), queryFn: () => @@ -63,8 +120,14 @@ export function CollectPage() { }); const jobsQuery = useQuery({ - queryKey: queryKeys.neCollections(jobPage), - queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }), + queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword), + queryFn: () => + fetchNeCollections({ + page: jobPage, + pageSize: jobPageSize, + status: jobStatus, + keyword: debouncedJobKeyword, + }), staleTime: 1000, refetchInterval: (q) => { const items = q.state.data?.items ?? []; @@ -85,6 +148,42 @@ export function CollectPage() { }, }); + useEffect(() => { + const p = dashQuery.data?.policy; + if (!p || policyHydrated) return; + setPolicyEnabled(Boolean(p.enabled)); + const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24)); + if (hours % 24 === 0) { + setPolicyIntervalUnit("days"); + setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24))); + } else { + setPolicyIntervalUnit("hours"); + setPolicyIntervalValue(Math.max(1, Math.min(8760, hours))); + } + setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3)))); + setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all"); + setPolicyTitle(String(p.title || "")); + setPolicyCommands(String(p.commands || "")); + const map: Record = {}; + for (const ref of p.selected_targets || []) { + map[targetKey(ref)] = { source: ref.source, id: ref.id }; + } + setPolicySelectedMap(map); + setPolicyHydrated(true); + }, [dashQuery.data?.policy, policyHydrated]); + + const policyTargetsQuery = useQuery({ + queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword), + queryFn: () => + fetchEligibleNe({ + page: policyTargetPage, + pageSize: POLICY_TARGET_PAGE_SIZE, + keyword: debouncedPolicyTargetKeyword, + }), + staleTime: 5000, + enabled: policyScopeMode === "selected", + }); + const detailQuery = useQuery({ queryKey: queryKeys.neCollectionDetail(expandedJobId), queryFn: () => fetchCollectionJob(expandedJobId), @@ -121,6 +220,62 @@ export function CollectPage() { ]); }; + const savePolicyMutation = useMutation({ + mutationFn: () => { + const hours = + policyIntervalUnit === "days" + ? Math.max(1, Math.min(365, policyIntervalValue)) * 24 + : Math.max(1, Math.min(8760, policyIntervalValue)); + return updateCollectionPolicy({ + enabled: policyEnabled, + interval_hours: hours, + history_keep: policyHistoryKeep, + scope_mode: policyScopeMode, + title: policyTitle.trim(), + commands: policyCommands, + selected_targets: Object.values(policySelectedMap), + }); + }, + onSuccess: async (saved) => { + setPolicyEnabled(Boolean(saved.enabled)); + const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24)); + if (hours % 24 === 0) { + setPolicyIntervalUnit("days"); + setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24))); + } else { + setPolicyIntervalUnit("hours"); + setPolicyIntervalValue(Math.max(1, Math.min(8760, hours))); + } + setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3)))); + setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); + setPolicyTitle(String(saved.title || "")); + setPolicyCommands(String(saved.commands || "")); + const map: Record = {}; + for (const ref of saved.selected_targets || []) { + map[targetKey(ref)] = { source: ref.source, id: ref.id }; + } + setPolicySelectedMap(map); + setPolicyHydrated(true); + queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => { + if (!prev || typeof prev !== "object") return prev; + return { ...(prev as object), policy: saved }; + }); + showOk(t("collect.policySaved")); + await refreshAll(); + }, + onError: (err) => showError(collectionErrorMessage(err, t)), + }); + + const startFromPolicyMutation = useMutation({ + mutationFn: startCollectionFromPolicy, + onSuccess: async (job) => { + showOk(t("collect.started", { id: job.id })); + setExpandedJobId(job.id); + await refreshAll(); + }, + onError: (err) => showError(collectionErrorMessage(err, t)), + }); + const invalidateJobs = async (jobId?: string) => { await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll }); await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard }); @@ -231,7 +386,58 @@ export function CollectPage() { const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE); const jobTotal = jobsQuery.data?.total ?? 0; - const jobPages = pageCount(jobTotal, 20); + const jobPages = pageCount(jobTotal, jobPageSize); + const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus); + + const exportJobsCsv = async () => { + setExportingJobs(true); + try { + const rows = await fetchAllPages({ + pageSize: 100, + maxRows: 2000, + fetchPage: (page, pageSize) => + fetchNeCollections({ + page, + pageSize, + status: jobStatus, + keyword: debouncedJobKeyword, + }), + }); + downloadCsv( + `${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`, + rows, + [ + { key: "title", header: t("collect.jobs.col.title") }, + { key: "status", header: t("collect.jobs.col.status") }, + { + key: "progress", + header: t("collect.jobs.col.progress"), + value: (r) => + `${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`, + }, + { + key: "started_at", + header: "started_at", + value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""), + }, + { + key: "ended_at", + header: "ended_at", + value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""), + }, + ], + ); + if (rows.length < jobTotal) { + showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) })); + } else { + showOk(t("common.exportOk", { count: String(rows.length) })); + } + } catch (err) { + showError(t("common.exportFailed") + ": " + formatErr(err)); + } finally { + setExportingJobs(false); + } + }; const commandLines = useMemo( () => @@ -262,10 +468,25 @@ export function CollectPage() { ) : null}
+ - + {running?.status === "running" || running?.status === "pending" ? ( @@ -294,10 +515,6 @@ export function CollectPage() { : t("collect.kpi.idle")}
-
-
{t("collect.kpi.active")}
-
{dash?.active_count ?? "—"}
-
{t("collect.kpi.last")}
@@ -306,10 +523,225 @@ export function CollectPage() { : t("common.empty")}
+
+
{t("collect.kpi.nextDue")}
+
+ {dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")} +
+
+ +
+

{t("collect.policyTitle")}

+

+ {t("collect.policyHint")} +

+
+ + + + + +
+ +
+ +
+