diff --git a/netx_api/topology_fabric_nodes.py b/netx_api/topology_fabric_nodes.py index 2b478f0..c4cd238 100644 --- a/netx_api/topology_fabric_nodes.py +++ b/netx_api/topology_fabric_nodes.py @@ -503,12 +503,36 @@ def find_fabric_paths( max_hops = max(1, min(12, int(max_hops or 6))) layer_v = str(layer or "physical").strip() or "physical" - edges = db.query(TopoFabricEdge).filter(TopoFabricEdge.layer == layer_v).all() - edge_map: dict[str, TopoFabricEdge] = {e.id: e for e in edges} - adj: dict[str, list[tuple[str, str]]] = {} - for e in edges: - adj.setdefault(e.a_node_id, []).append((e.b_node_id, e.id)) - adj.setdefault(e.b_node_id, []).append((e.a_node_id, e.id)) + # Lazy adjacency: only fetch edges for nodes the BFS actually expands + # (avoids loading the entire fabric layer on large graphs). + adj_cache: dict[str, list[tuple[str, str]]] = {} + adj_loaded: set[str] = set() + edge_map: dict[str, TopoFabricEdge] = {} + + def _ensure_adj(node_ids: set[str]) -> None: + missing = [n for n in node_ids if n not in adj_loaded] + if not missing: + return + batch = ( + db.query(TopoFabricEdge) + .filter( + TopoFabricEdge.layer == layer_v, + or_( + TopoFabricEdge.a_node_id.in_(missing), + TopoFabricEdge.b_node_id.in_(missing), + ), + ) + .all() + ) + for nid in missing: + adj_cache[nid] = [] + adj_loaded.add(nid) + for e in batch: + edge_map[e.id] = e + if e.a_node_id in missing: + adj_cache[e.a_node_id].append((e.b_node_id, e.id)) + if e.b_node_id in missing: + adj_cache[e.b_node_id].append((e.a_node_id, e.id)) # BFS for simple paths so shorter hops are found first; cap expansions on dense graphs. _EXPLORE_CAP = 5000 @@ -519,7 +543,8 @@ def find_fabric_paths( node, edge_path, visited = queue.popleft() if len(edge_path) >= max_hops: continue - for nbr, eid in adj.get(node, []): + _ensure_adj({node}) + for nbr, eid in adj_cache.get(node, []): if nbr in visited: continue explored += 1 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 dd1faff..8bf51e3 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/layout_ops/orbit_sweep.py @@ -184,38 +184,86 @@ 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 + + +def _crossing_part_score(crossings: int, *, n_links: int, n_nodes: int) -> float: + """Match layout_stats crossing sub-score in [0,1] from raw crossing count.""" + cpl = float(crossings) / max(int(n_links), 1) + if n_nodes <= 50: + cpl_ok, cpl_bad = 0.05, 0.20 + elif n_nodes <= 200: + cpl_ok, cpl_bad = 0.10, 0.25 + else: + cpl_ok, cpl_bad = 0.16, 0.30 + if cpl <= cpl_ok: + return 1.0 + if cpl >= cpl_bad: + return 0.0 + 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).""" + 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))) + ) + + def _rerank_by_total( scored: list[dict[str, Any]], nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str], links: list[tuple[str, str]], - adj: dict[str, set[str]], *, + global0: int, re_rank_n: int = 20, -) -> list[dict[str, Any]]: - """Re-rank top candidates by crossing + edge_clearance (multi-objective). +) -> tuple[list[dict[str, Any]], bool, float]: + """Re-rank top candidates by weighted crossing+clearance (verdict.total slice). - A move may increase crossings but resolve several edge-clearance hits; - ranking by ``crossings + edge_clearance_hits`` aligns with verdict.total. + Returns ``(ranked, clearance_ok, base_partial)``. When edge_clearance is + skipped on large graphs, ``clearance_ok`` is False and ranking is unchanged. """ from netx_topology_mcp.layout_metrics import compute_edge_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) + 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) + c["edge_clearance_score"] = clr_s + c["verdict_partial"] = _verdict_partial( + int(c["crossings"]["global"]), + clr_s, + n_links=n_links, + n_nodes=n_nodes, + ) head = sorted( scored[:n], key=lambda c: ( - int(c["crossings"]["global"]) + int(c.get("edge_clearance_hits", 0)), + -float(c.get("verdict_partial") or 0.0), int(c["crossings"]["incident"]), float(c.get("stretch") or 1.0), ), ) - return head + scored[n:] + return head + scored[n:], True, base_partial def _diversify_top( @@ -488,22 +536,22 @@ def orbit_sweep_node( scored.append(c) scored.sort(key=_score_key) - # Multi-objective re-rank: optimize total score, not just crossings + # Multi-objective re-rank: weighted crossing+clearance slice of verdict.total use_total = objective == "total" and len(scored) > 1 - base_clearance_hits = 0 + base_partial = 0.0 if use_total: - from netx_topology_mcp.layout_metrics import compute_edge_clearance - - ec0 = compute_edge_clearance(pos, links, names=names, top_n=1) - base_clearance_hits = int(ec0.get("edge_clearance_hits") or 0) - scored = _rerank_by_total(scored, nid, pos, names, links, adj) + scored, clr_ok, base_partial = _rerank_by_total( + scored, nid, pos, names, links, global0=int(global0) + ) + if not clr_ok: + # Large-graph clearance skip → fall back to crossing-only ranking. + use_total = False # Prefer improving moves; still return best even if none improve. if use_total: - base_total = int(global0) + base_clearance_hits improving = [ c for c in scored - if int(c["crossings"]["global"]) + int(c.get("edge_clearance_hits", 0)) < base_total + if float(c.get("verdict_partial") or 0.0) > base_partial ] else: improving = [c for c in scored if c["delta"]["global"] < 0] @@ -523,7 +571,9 @@ def orbit_sweep_node( "improving_n": len(improving), "max_jump": jump, "angle_step": angle_step, - "objective": objective, + "objective": "total" if use_total else ( + "crossing" if objective != "total" else "crossing_fallback" + ), "y_band": ( None if (y_min is None and y_max is None) else [y_min, y_max] @@ -531,8 +581,15 @@ def orbit_sweep_node( "hint": ( "prefer rank1 unless util/label concern; then pick 2/3. " "apply with params.pick=1|2|3 or updateTopologyViewPositions." - + (" objective=total: rank by crossing+edge_clearance, may trade crossings for clearance." - if objective == "total" else "") + + ( + " objective=total: rank by weighted crossing+edge_clearance (verdict slice)." + if use_total + else ( + " objective=total skipped clearance (graph too large); ranked by crossings." + if objective == "total" + else "" + ) + ) ), } diff --git a/packages/netx-topology-mcp/tests/test_orbit_sweep.py b/packages/netx-topology-mcp/tests/test_orbit_sweep.py index 943f5c0..75eab4b 100644 --- a/packages/netx-topology-mcp/tests/test_orbit_sweep.py +++ b/packages/netx-topology-mcp/tests/test_orbit_sweep.py @@ -200,6 +200,9 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None: by_total = orbit_sweep_node(st, "h", max_jump=400, nn_floor=20.0, objective="total") assert by_cross["ok"] is True and by_total["ok"] is True assert by_total.get("objective") == "total" + # Tiny graph: clearance runs, so ranked candidates carry verdict_partial. + assert by_total.get("candidates") + assert all("verdict_partial" in c for c in by_total["candidates"]) # y_band plumbing banded = orbit_sweep_node( st, "h", max_jump=400, nn_floor=20.0, objective="total", y_min=0.0, y_max=80.0 @@ -208,3 +211,23 @@ def test_orbit_objective_total_ranks_clearance_trade() -> None: assert banded.get("y_band") == [0.0, 80.0] for c in banded.get("candidates") or []: assert 0.0 <= float(c["y"]) <= 80.0 + + +def test_verdict_partial_weights_match_layout_stats() -> None: + from netx_topology_mcp.layout_ops.orbit_sweep import ( + _W_CLR, + _W_CROSS, + _crossing_part_score, + _verdict_partial, + ) + + assert abs(_W_CROSS - 0.18) < 1e-9 + assert abs(_W_CLR - 0.08) < 1e-9 + # Zero crossings on small graph → crossing part 1.0 + assert _crossing_part_score(0, n_links=10, n_nodes=10) == 1.0 + # Perfect clearance + perfect crossing + assert abs(_verdict_partial(0, 1.0, n_links=10, n_nodes=10) - (_W_CROSS + _W_CLR)) < 1e-9 + # Worse crossings lower the partial + assert _verdict_partial(5, 1.0, n_links=10, n_nodes=10) < _verdict_partial( + 0, 1.0, n_links=10, n_nodes=10 + )