mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
Speed up fabric path BFS with lazy edge loads and score orbit total by verdict weights.
Only expand adjacency for visited nodes, and rank objective=total with crossing 0.18 + clearance 0.08 (fallback to crossings when clearance is skipped). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e4a135ec16
commit
753740d64e
3 changed files with 131 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
)
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue