mirror of
https://github.com/hansjone/netx.git
synced 2026-10-19 03:00: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)))
|
max_hops = max(1, min(12, int(max_hops or 6)))
|
||||||
layer_v = str(layer or "physical").strip() or "physical"
|
layer_v = str(layer or "physical").strip() or "physical"
|
||||||
|
|
||||||
edges = db.query(TopoFabricEdge).filter(TopoFabricEdge.layer == layer_v).all()
|
# Lazy adjacency: only fetch edges for nodes the BFS actually expands
|
||||||
edge_map: dict[str, TopoFabricEdge] = {e.id: e for e in edges}
|
# (avoids loading the entire fabric layer on large graphs).
|
||||||
adj: dict[str, list[tuple[str, str]]] = {}
|
adj_cache: dict[str, list[tuple[str, str]]] = {}
|
||||||
for e in edges:
|
adj_loaded: set[str] = set()
|
||||||
adj.setdefault(e.a_node_id, []).append((e.b_node_id, e.id))
|
edge_map: dict[str, TopoFabricEdge] = {}
|
||||||
adj.setdefault(e.b_node_id, []).append((e.a_node_id, e.id))
|
|
||||||
|
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.
|
# BFS for simple paths so shorter hops are found first; cap expansions on dense graphs.
|
||||||
_EXPLORE_CAP = 5000
|
_EXPLORE_CAP = 5000
|
||||||
|
|
@ -519,7 +543,8 @@ def find_fabric_paths(
|
||||||
node, edge_path, visited = queue.popleft()
|
node, edge_path, visited = queue.popleft()
|
||||||
if len(edge_path) >= max_hops:
|
if len(edge_path) >= max_hops:
|
||||||
continue
|
continue
|
||||||
for nbr, eid in adj.get(node, []):
|
_ensure_adj({node})
|
||||||
|
for nbr, eid in adj_cache.get(node, []):
|
||||||
if nbr in visited:
|
if nbr in visited:
|
||||||
continue
|
continue
|
||||||
explored += 1
|
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(
|
def _rerank_by_total(
|
||||||
scored: list[dict[str, Any]],
|
scored: list[dict[str, Any]],
|
||||||
nid: str,
|
nid: str,
|
||||||
pos: dict[str, tuple[float, float]],
|
pos: dict[str, tuple[float, float]],
|
||||||
names: dict[str, str],
|
names: dict[str, str],
|
||||||
links: list[tuple[str, str]],
|
links: list[tuple[str, str]],
|
||||||
adj: dict[str, set[str]],
|
|
||||||
*,
|
*,
|
||||||
|
global0: int,
|
||||||
re_rank_n: int = 20,
|
re_rank_n: int = 20,
|
||||||
) -> list[dict[str, Any]]:
|
) -> tuple[list[dict[str, Any]], bool, float]:
|
||||||
"""Re-rank top candidates by crossing + edge_clearance (multi-objective).
|
"""Re-rank top candidates by weighted crossing+clearance (verdict.total slice).
|
||||||
|
|
||||||
A move may increase crossings but resolve several edge-clearance hits;
|
Returns ``(ranked, clearance_ok, base_partial)``. When edge_clearance is
|
||||||
ranking by ``crossings + edge_clearance_hits`` aligns with verdict.total.
|
skipped on large graphs, ``clearance_ok`` is False and ranking is unchanged.
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
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))
|
n = min(re_rank_n, len(scored))
|
||||||
for c in scored[:n]:
|
for c in scored[:n]:
|
||||||
trial = dict(pos)
|
trial = dict(pos)
|
||||||
trial[nid] = (float(c["x"]), float(c["y"]))
|
trial[nid] = (float(c["x"]), float(c["y"]))
|
||||||
ec = compute_edge_clearance(trial, links, names=names, top_n=1)
|
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_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(
|
head = sorted(
|
||||||
scored[:n],
|
scored[:n],
|
||||||
key=lambda c: (
|
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"]),
|
int(c["crossings"]["incident"]),
|
||||||
float(c.get("stretch") or 1.0),
|
float(c.get("stretch") or 1.0),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return head + scored[n:]
|
return head + scored[n:], True, base_partial
|
||||||
|
|
||||||
|
|
||||||
def _diversify_top(
|
def _diversify_top(
|
||||||
|
|
@ -488,22 +536,22 @@ def orbit_sweep_node(
|
||||||
scored.append(c)
|
scored.append(c)
|
||||||
|
|
||||||
scored.sort(key=_score_key)
|
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
|
use_total = objective == "total" and len(scored) > 1
|
||||||
base_clearance_hits = 0
|
base_partial = 0.0
|
||||||
if use_total:
|
if use_total:
|
||||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
scored, clr_ok, base_partial = _rerank_by_total(
|
||||||
|
scored, nid, pos, names, links, global0=int(global0)
|
||||||
ec0 = compute_edge_clearance(pos, links, names=names, top_n=1)
|
)
|
||||||
base_clearance_hits = int(ec0.get("edge_clearance_hits") or 0)
|
if not clr_ok:
|
||||||
scored = _rerank_by_total(scored, nid, pos, names, links, adj)
|
# Large-graph clearance skip → fall back to crossing-only ranking.
|
||||||
|
use_total = False
|
||||||
# Prefer improving moves; still return best even if none improve.
|
# Prefer improving moves; still return best even if none improve.
|
||||||
if use_total:
|
if use_total:
|
||||||
base_total = int(global0) + base_clearance_hits
|
|
||||||
improving = [
|
improving = [
|
||||||
c
|
c
|
||||||
for c in scored
|
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:
|
else:
|
||||||
improving = [c for c in scored if c["delta"]["global"] < 0]
|
improving = [c for c in scored if c["delta"]["global"] < 0]
|
||||||
|
|
@ -523,7 +571,9 @@ def orbit_sweep_node(
|
||||||
"improving_n": len(improving),
|
"improving_n": len(improving),
|
||||||
"max_jump": jump,
|
"max_jump": jump,
|
||||||
"angle_step": angle_step,
|
"angle_step": angle_step,
|
||||||
"objective": objective,
|
"objective": "total" if use_total else (
|
||||||
|
"crossing" if objective != "total" else "crossing_fallback"
|
||||||
|
),
|
||||||
"y_band": (
|
"y_band": (
|
||||||
None if (y_min is None and y_max is None)
|
None if (y_min is None and y_max is None)
|
||||||
else [y_min, y_max]
|
else [y_min, y_max]
|
||||||
|
|
@ -531,8 +581,15 @@ def orbit_sweep_node(
|
||||||
"hint": (
|
"hint": (
|
||||||
"prefer rank1 unless util/label concern; then pick 2/3. "
|
"prefer rank1 unless util/label concern; then pick 2/3. "
|
||||||
"apply with params.pick=1|2|3 or updateTopologyViewPositions."
|
"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")
|
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_cross["ok"] is True and by_total["ok"] is True
|
||||||
assert by_total.get("objective") == "total"
|
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
|
# y_band plumbing
|
||||||
banded = orbit_sweep_node(
|
banded = orbit_sweep_node(
|
||||||
st, "h", max_jump=400, nn_floor=20.0, objective="total", y_min=0.0, y_max=80.0
|
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]
|
assert banded.get("y_band") == [0.0, 80.0]
|
||||||
for c in banded.get("candidates") or []:
|
for c in banded.get("candidates") or []:
|
||||||
assert 0.0 <= float(c["y"]) <= 80.0
|
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