mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Ship gated eye polish, fabric levels, and collection UI refresh.
Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
|
|
@ -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"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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<j on hubs_a."""
|
||||
adj, names = state.adj, state.names
|
||||
out: list[DualUnit] = []
|
||||
if allow_same:
|
||||
for i, a in enumerate(hubs_a):
|
||||
for b in hubs_a[i + 1 :]:
|
||||
forbid = core_set - {a, b}
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
|
||||
if len(paths) < 2:
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=set())
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
out.append(_unit_from_hub_paths(state, a, b, paths))
|
||||
return out
|
||||
seen: set[frozenset[str]] = set()
|
||||
for a in hubs_a:
|
||||
for b in hubs_b:
|
||||
if a == b:
|
||||
continue
|
||||
key = frozenset((a, b))
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
forbid = core_set - {a, b}
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=forbid)
|
||||
if len(paths) < 2:
|
||||
paths = cover_hub_paths(a, b, adj, names, forbid=set())
|
||||
if len(paths) < 2:
|
||||
continue
|
||||
out.append(_unit_from_hub_paths(state, a, b, paths))
|
||||
return out
|
||||
|
||||
|
||||
def find_dual_portal_units(
|
||||
state: LayoutState,
|
||||
*,
|
||||
max_units: int = 120,
|
||||
) -> 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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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 → 手拖或改初布;勿指望金标对齐。"
|
||||
),
|
||||
}
|
||||
|
|
|
|||
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal file
55
packages/netx-topology-mcp/tests/test_align_reference.py
Normal file
|
|
@ -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
|
||||
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal file
185
packages/netx-topology-mcp/tests/test_bundle_orbit.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal file
40
packages/netx-topology-mcp/tests/test_compact_bbox.py
Normal file
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal file
84
packages/netx-topology-mcp/tests/test_pull_far_chains.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
146
packages/netx-topology-mcp/tests/test_suggest_sink_hubs.py
Normal file
|
|
@ -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"
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue