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:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

View file

@ -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

View file

@ -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

View file

@ -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(),
}

View file

@ -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",
)

View file

@ -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 = [

View file

@ -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,
}

View file

@ -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

View file

@ -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},

View file

@ -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

View file

@ -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):

View file

@ -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)),
}

View file

@ -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):

View file

@ -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),

View file

@ -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])

View file

@ -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"),

View file

@ -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": {

View file

@ -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 → 手拖或改初布;勿指望金标对齐。"
),
}

View 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

View 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

View file

@ -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(

View 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)

View file

@ -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

View file

@ -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:

View file

@ -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,

View 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

View file

@ -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

View 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"

View file

@ -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,