Converge netx-topology MCP to dual-unit drain + orbit/polish mainline.

Trim public tools to 14, expose only kept layout actions/recipes, merge Fabric queries, drop createTopologyView/listTopologyViews, and align skill/docs with the live catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-09 23:18:53 +08:00
parent 9bcb8ee0e3
commit 79f929de0c
86 changed files with 31578 additions and 240 deletions

View file

@ -0,0 +1,77 @@
"""Composable topology layout atoms + multipass recipes.
Usage:
state = build_state_from_nodes_edges(nodes, edges)
state, trace, score = run_recipe(state, "smd_corridor_v1", LayoutParams(...))
positions = positions_for_api(state)
Scoped / block ops:
select_scope(mode=component|bbox|layer|ids|all)
list_blocks(mode=component|hub_territory|leiden|soft|…)
foreach_blocks in recipes runs sub-passes per block (default=CC).
Soft partition: layout_ops.partition (hub BFS + optional igraph).
"""
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.partition import (
igraph_available,
pack_block_centers,
pack_soft_blocks,
partition_report,
partition_soft_blocks,
resolve_block_mode,
)
from netx_topology_mcp.layout_ops.recipe import (
RECIPES,
REGISTRY,
agg_rings_v1_passes,
positions_for_api,
run_recipe,
smd_corridor_compact_v1_passes,
smd_corridor_unstick_v1_passes,
smd_corridor_v1_passes,
)
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.compose_views import (
compose_into_state,
strip_pack_blocks,
)
from netx_topology_mcp.layout_ops.dual_units import (
find_dual_portal_units,
layout_dual_unit,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
__all__ = [
"LayoutParams",
"LayoutState",
"OpResult",
"REGISTRY",
"RECIPES",
"agg_rings_v1_passes",
"build_ring_skeleton",
"build_state_from_nodes_edges",
"compose_into_state",
"find_dual_portal_units",
"fix_overlaps_local",
"igraph_available",
"layout_dual_unit",
"list_blocks",
"pack_block_centers",
"pack_soft_blocks",
"partition_report",
"partition_soft_blocks",
"positions_for_api",
"relax_hotspots",
"resolve_block_mode",
"run_recipe",
"score_state",
"select_scope",
"strip_pack_blocks",
"smd_corridor_v1_passes",
"smd_corridor_compact_v1_passes",
"smd_corridor_unstick_v1_passes",
]

View file

@ -0,0 +1,72 @@
"""channel_metro: corridor skeleton → core beam pin → channel soft petals.
Composes proven atoms for giant star / core_bar canvases where ~90% of
nodes sit on deg≤2 corridors.
"""
from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import extract_channels
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import normalize_origin
def build_channel_metro_skeleton(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Compose: Tutte skeleton → pin_beam → soft_petals → ring eject → fix ov."""
params = params or LayoutParams()
channels = extract_channels(state)
rings = extract_ring_faces(state, max_len=5, max_cycles=40)
sk = build_skeleton(state, params)
st = sk.state
beam = pin_beam_rigid(st, params)
st = beam.state
petals = soft_petals_greedy(st, params)
st = petals.state
cores = {n for n, ly in st.layers.items() if ly == "core"}
aggs = {n for n, ly in st.layers.items() if ly == "agg"}
st.positions = eject_intruders(
dict(st.positions),
rings,
push=max(params.side * 0.25, 36.0),
protected=cores | aggs,
)
st = fix_overlaps_local(st, params).state
st = normalize_origin(st, params).state
cross = count_edge_crossings(st.positions, st.links)
st.meta = dict(st.meta or {})
st.meta["rings_mode"] = "channel_metro"
st.meta["channel_metro"] = {
"channels": len(channels),
"rings": len(rings),
"pin_beam": beam.note,
"soft_petals": petals.note,
"crossings": cross,
"skeleton": sk.note,
}
return OpResult(
state=st,
moved=set(st.positions.keys()),
op="build_channel_metro_skeleton",
params={
"channel_count": len(channels),
"ring_count": len(rings),
"crossings": cross,
"pin_beam": beam.params,
"soft_petals": petals.params,
},
note=f"channel_metro ch={len(channels)} {beam.note}; {petals.note} x={cross}",
)

View file

@ -0,0 +1,404 @@
"""Path-based channels: maximal deg≤2 corridors as atomic layout units.
Inspired by Path-Based Framework (PBF): treat corridors as first-class
geometry (straight spines), not independent force-directed points.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
def order_stubs_crossing_aware(
hub: str,
stubs: list[str],
adj: dict[str, set[str]],
member_set: set[str],
owner: dict[str, str],
) -> list[str]:
"""Stable stub order by foreign-neighbor hash angle (crossing-aware proxy)."""
if len(stubs) <= 2:
return sorted(stubs)
def score(s: str) -> tuple[float, str]:
terr = {n for n, o in owner.items() if o == s} | {s}
foreign: list[str] = []
for n in terr:
for v in adj.get(n, ()):
if v == hub or v in terr or v in member_set:
continue
foreign.append(v)
ang = (hash(s) % 1000) / 1000.0 * 2 * math.pi
if foreign:
ang = (hash(min(foreign)) % 1000) / 1000.0 * 2 * math.pi
return (ang, s)
return [s for _, s in sorted((score(s) for s in stubs), key=lambda t: t[0])]
@dataclass(frozen=True)
class Channel:
"""Ordered path hub…access… (portals may be high-degree)."""
node_ids: tuple[str, ...]
@property
def length(self) -> int:
return len(self.node_ids)
def as_dict(self) -> dict[str, Any]:
return {
"n": self.length,
"ends": [self.node_ids[0], self.node_ids[-1]] if self.node_ids else [],
"node_ids": list(self.node_ids)[:40],
}
def extract_channels(state: LayoutState) -> list[Channel]:
"""All maximal deg≤2 corridors (with portal hubs when unique)."""
paths = extract_chain_paths(state.adj)
out = [Channel(tuple(p)) for p in paths if len(p) >= 3]
out.sort(key=lambda c: (-c.length, c.node_ids[0] if c.node_ids else ""))
return out
def channels_touching(
channels: list[Channel], node_set: set[str]
) -> list[Channel]:
"""Channels with ≥2 nodes inside ``node_set`` (block-local)."""
return [c for c in channels if sum(1 for n in c.node_ids if n in node_set) >= 2]
def place_channel_ray(
path: list[str],
*,
origin: tuple[float, float],
ux: float,
uy: float,
step: float,
pinned: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Lay path along a unit ray from origin; skip pinned nodes (keep them).
Distance index: if path[0] is pinned (hub at origin), node at path[i]
sits at i*step; otherwise path[0] is at 1*step.
"""
pinned = pinned or set()
ox, oy = origin
out: dict[str, tuple[float, float]] = {}
hub_first = bool(path and path[0] in pinned)
for i, n in enumerate(path):
if n in pinned:
continue
k = i if hub_first else i + 1
out[n] = (ox + ux * step * k, oy + uy * step * k)
return out
def channels_report(state: LayoutState) -> dict[str, Any]:
ch = extract_channels(state)
nodes = {n for c in ch for n in c.node_ids}
return {
"channel_count": len(ch),
"channel_nodes": len(nodes),
"longest": ch[0].length if ch else 0,
"sample": [c.as_dict() for c in ch[:12]],
"tip": "通道是 deg≤2 走廊原子;channel_metro 按射线拉直,勿逐点力导。",
}
def _straighten_path_positions(
path: list[str],
pos: dict[str, tuple[float, float]],
*,
mode: str = "chord",
step: float | None = None,
bend_frac: float = 0.5,
) -> dict[str, tuple[float, float]] | None:
"""Reposition interior (and optionally ends) of path. Ends stay fixed for chord/L."""
pts = [pos[n] for n in path if n in pos]
if len(pts) < 3 or len(pts) != len(path):
return None
x0, y0 = pts[0]
x1, y1 = pts[-1]
n = len(path) - 1
out: dict[str, tuple[float, float]] = {}
if mode == "chord" or mode.startswith("chord_shift"):
dx, dy = x1 - x0, y1 - y0
plen = math.hypot(dx, dy) or 1.0
# lateral offset for chord_shift± (keeps ends fixed via taper)
off = 0.0
if mode.startswith("chord_shift"):
try:
off = float(mode.split(":", 1)[1])
except (IndexError, ValueError):
off = 0.0
px, py = (-dy / plen) * off, (dx / plen) * off
for i, nid in enumerate(path):
if i == 0 or i == len(path) - 1:
continue
t = i / n
# taper offset to 0 at ends (already skipping ends)
taper = math.sin(math.pi * t)
out[nid] = (
x0 + dx * t + px * taper,
y0 + dy * t + py * taper,
)
return out
if mode in {"L_hv", "L_vh"}:
# Orthogonal elbow; both ends fixed. bend_frac ∈ (0,1) picks knee along path.
bf = min(0.85, max(0.15, float(bend_frac)))
bend_i = max(1, min(n - 1, int(round(n * bf))))
for i, nid in enumerate(path):
if i == 0 or i == len(path) - 1:
continue
if mode == "L_hv":
# (x0,y0) → (x1,y0) → (x1,y1)
if i <= bend_i:
t = i / bend_i
out[nid] = (x0 + (x1 - x0) * t, y0)
else:
t = (i - bend_i) / (n - bend_i)
out[nid] = (x1, y0 + (y1 - y0) * t)
else:
# (x0,y0) → (x0,y1) → (x1,y1)
if i <= bend_i:
t = i / bend_i
out[nid] = (x0, y0 + (y1 - y0) * t)
else:
t = (i - bend_i) / (n - bend_i)
out[nid] = (x0 + (x1 - x0) * t, y1)
return out
if mode in {"horizontal", "vertical"}:
# Keep first endpoint; lay along axis with equal step
st = step or (
math.hypot(x1 - x0, y1 - y0) / max(n, 1)
if math.hypot(x1 - x0, y1 - y0) > 1
else 180.0
)
st = max(120.0, min(st, 280.0))
for i, nid in enumerate(path):
if i == 0:
continue
if mode == "horizontal":
out[nid] = (x0 + st * i, y0)
else:
out[nid] = (x0, y0 + st * i)
return out
return None
def _path_axis_credit(
path: list[str],
pos: dict[str, tuple[float, float]],
*,
tol_deg: float = 8.0,
tol_px: float = 4.0,
) -> float:
"""Mean H/V credit along path edges (H=1, V=0.75, diagonal=0)."""
import math
if len(path) < 2:
return 1.0
tol_rad = math.radians(max(0.1, float(tol_deg)))
tol_p = max(0.0, float(tol_px))
credits: list[float] = []
for i in range(len(path) - 1):
a, b = path[i], path[i + 1]
if a not in pos or b not in pos:
continue
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
length = math.hypot(dx, dy)
if length < 1e-9:
credits.append(1.0)
continue
adx, ady = abs(dx), abs(dy)
ang_h = math.atan2(ady, adx)
ang_v = abs(math.pi / 2 - ang_h)
if ady <= tol_p or ang_h <= tol_rad:
credits.append(1.0)
elif adx <= tol_p or ang_v <= tol_rad:
credits.append(0.75)
else:
credits.append(0.0)
return sum(credits) / max(len(credits), 1)
def straighten_channels_greedy(
state: LayoutState,
params: Any | None = None,
*,
step: float | None = None,
min_len: int = 3,
max_passes: int = 3,
):
"""Straighten deg≤2 channels.
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
accept H/V / L modes that raise path axis credit (metro look).
"""
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult
params = params or LayoutParams()
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
pos = dict(state.positions)
links = list(state.links)
cross = count_edge_crossings(pos, links)
accepted: list[dict[str, Any]] = []
base_modes = (
"chord",
"horizontal",
"vertical",
"L_hv",
"L_vh",
)
bend_fracs = (0.35, 0.5, 0.65)
shift_offs = sorted(
{
-2.0 * step_px,
-1.5 * step_px,
-step_px,
-0.5 * step_px,
0.5 * step_px,
step_px,
1.5 * step_px,
2.0 * step_px,
-400.0,
-280.0,
280.0,
400.0,
}
)
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
for _pass in range(max(1, int(max_passes))):
raise_if_cancelled()
channels = extract_channels(state)
# Prefer longer kinked channels first
accepted_pass = 0
n_ch = max(1, len(channels))
for ci, ch in enumerate(channels):
if ci % 8 == 0:
raise_if_cancelled()
report_progress(
"straighten_channels",
pct=48.0 + 6.0 * ((_pass + ci / n_ch) / max(1, int(max_passes))),
message=f"pass {_pass + 1} ch {ci + 1}/{n_ch} x={cross}",
step=ci + 1,
total_steps=n_ch,
crossings=cross,
)
path = list(ch.node_ids)
if len(path) < min_len:
continue
if any(n not in pos for n in path):
continue
axis0 = _path_axis_credit(path, pos)
# Lexicographic key: fewer crossings, then higher axis credit.
best_key = (cross, -axis0)
best_pos = None
best_mode = None
modes = list(base_modes) + [f"chord_shift:{o:.1f}" for o in shift_offs]
for mode in modes:
fracs = bend_fracs if mode.startswith("L_") else (0.5,)
for bf in fracs:
delta = _straighten_path_positions(
path, pos, mode=mode, step=step_px, bend_frac=bf
)
if not delta:
continue
trial = dict(pos)
trial.update(delta)
c1 = count_edge_crossings(trial, links)
if c1 > cross:
continue
a1 = _path_axis_credit(path, trial)
key = (c1, -a1)
if key < best_key:
best_key = key
best_pos = trial
best_mode = f"{mode}@{bf:.2f}" if mode.startswith("L_") else mode
if best_pos is None:
continue
# light overlap fix; keep if still improved
st_try = state.copy()
st_try.positions = best_pos
stf = fix_overlaps_local(st_try, params).state
# restore pinned hubs (core/agg endpoints)
for end in (path[0], path[-1]):
if state.layers.get(end) in ("core", "agg") and end in pos:
stf.positions[end] = pos[end]
trial2 = {k: (float(v[0]), float(v[1])) for k, v in stf.positions.items()}
c2 = count_edge_crossings(trial2, links)
a2 = _path_axis_credit(path, trial2)
if (c2, -a2) >= (cross, -axis0):
continue
pos = trial2
cross = c2
accepted_pass += 1
accepted.append(
{
"ends": [path[0], path[-1]],
"n": len(path),
"mode": best_mode,
"crossings": cross,
"axis": round(a2, 3),
}
)
if accepted_pass == 0:
break
if not accepted:
return OpResult(
state=state,
moved=set(),
op="straighten_channels",
params={"accepted_n": 0},
note="no_channel_improved",
)
out = state.copy()
out.positions = pos
moved = {
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
}
out.meta = dict(out.meta or {})
out.meta["straighten_channels"] = {"accepted": accepted, "crossings": cross}
return OpResult(
state=out,
moved=moved,
op="straighten_channels",
params={"accepted_n": len(accepted), "accepted": accepted[:20], "crossings": cross},
note=f"straighten_channels:{len(accepted)} x->{cross}",
)
def straighten_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("min_len") is not None:
try:
out["min_len"] = int(overrides["min_len"])
except (TypeError, ValueError):
pass
if overrides.get("max_passes") is not None:
try:
out["max_passes"] = int(overrides["max_passes"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,637 @@
"""Eject nodes that sit on / too close to non-incident edges.
Default: orthogonal eject. With ``preserve_axis`` + grid pitch/side, only accept
moves that keep incident H/V edges and snap to the metro grid — safe after
``ortho_metro`` (the old 60px nudge was breaking right angles).
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
EDGE_CLEARANCE_THR,
REC_CENTER_DX,
REC_CENTER_DY,
compute_edge_clearance,
count_edge_crossings,
node_footprint,
point_segment_dist,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
_AXIS_TOL = 8.0
def clear_edge_params_from_overrides(params: dict[str, Any] | None) -> dict[str, Any]:
p = params or {}
out: dict[str, Any] = {
"top_n": int(p.get("top_n") or 12),
"thr": float(p.get("thr") or EDGE_CLEARANCE_THR),
"margin": float(p.get("margin") or 20.0),
"max_moves": int(p.get("max_moves") or 24),
"preserve_axis": bool(p.get("preserve_axis", False)),
"rounds": int(p.get("rounds") or 1),
}
if p.get("pitch") is not None:
out["pitch"] = float(p["pitch"])
if p.get("side") is not None:
out["side"] = float(p["side"])
return out
def _axis_ok(a: tuple[float, float], b: tuple[float, float], tol: float = _AXIS_TOL) -> bool:
return abs(a[0] - b[0]) <= tol or abs(a[1] - b[1]) <= tol
def _overlaps_any(
node: str,
pos: dict[str, tuple[float, float]],
names: dict[str, str],
) -> bool:
x, y = pos[node]
fa = node_footprint(names.get(node, ""))
ax0, ay0, ax1, ay1 = x + fa[0], y + fa[1], x + fa[2], y + fa[3]
for b, (bx, by) in pos.items():
if b == node:
continue
if abs(bx - x) > 120 and abs(by - y) > 100:
continue
fb = node_footprint(names.get(b, ""))
bx0, by0, bx1, by1 = bx + fb[0], by + fb[1], bx + fb[2], by + fb[3]
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _incident_axis_score(
nid: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
) -> int:
s = 0
for nb in adj.get(nid) or ():
if nb in pos and _axis_ok(pos[nid], pos[nb]):
s += 1
return s
def _global_axis_score(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
) -> int:
return sum(
1
for a, b in links
if a in pos and b in pos and _axis_ok(pos[a], pos[b])
)
def _on_open_segment(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
thr: float,
) -> bool:
d, t = point_segment_dist(p, a, b)
return d < thr and 0.05 < t < 0.95
def _candidates_orthogonal(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
target: float,
) -> list[tuple[float, float]]:
"""Axis-aligned eject candidates at distance ``target`` from segment AB."""
ax, ay = a
bx, by = b
px, py = p
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
cands: list[tuple[float, float]] = []
if L < 1e-9:
cands.extend(
[
(px + target, py),
(px - target, py),
(px, py + target),
(px, py - target),
]
)
return cands
t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / (L * L)))
fx, fy = ax + t * dx, ay + t * dy
cands.extend(
[
(fx, fy + target),
(fx, fy - target),
(fx + target, fy),
(fx - target, fy),
]
)
ux, uy = -dy / L, dx / L
cands.append((fx + ux * target, fy + uy * target))
cands.append((fx - ux * target, fy - uy * target))
if abs(dx) >= abs(dy):
cands.insert(0, (px, fy + target if py >= fy else fy - target))
cands.insert(1, (px, fy - target if py >= fy else fy + target))
else:
cands.insert(0, (fx + target if px >= fx else fx - target, py))
cands.insert(1, (fx - target if px >= fx else fx + target, py))
seen: set[tuple[float, float]] = set()
out: list[tuple[float, float]] = []
for xy in cands:
key = (round(xy[0], 2), round(xy[1], 2))
if key in seen:
continue
seen.add(key)
out.append((float(xy[0]), float(xy[1])))
return out
def _candidates_grid_ortho(
p: tuple[float, float],
a: tuple[float, float],
b: tuple[float, float],
*,
pitch: float,
side: float,
max_steps: int = 8,
neighbor_xy: list[tuple[float, float]] | None = None,
) -> list[tuple[float, float]]:
"""Eject perpendicular to H/V trunk by whole grid steps (preserve metro)."""
ax, ay = a
bx, by = b
px, py = p
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
cands: list[tuple[float, float]] = []
# Prefer landing that still shares H/V with a neighbor (keep incident ortho).
for nxy in neighbor_xy or ():
nx, ny = nxy
if horiz:
for sign in (1, -1):
cands.append((nx, py + sign * side)) # V to neighbor, off row
cands.append((nx, py + sign * 2 * side))
elif vert:
for sign in (1, -1):
cands.append((px + sign * pitch, ny)) # H to neighbor, off col
cands.append((px + sign * 2 * pitch, ny))
if horiz:
for s in range(1, max_steps + 1):
for sign in (1, -1):
cands.append((px, py + sign * s * side))
cands.append((px + sign * s * pitch, py + sign * side))
cands.append((px + sign * s * pitch, py - sign * side))
elif vert:
for s in range(1, max_steps + 1):
for sign in (1, -1):
cands.append((px + sign * s * pitch, py))
cands.append((px + sign * pitch, py + sign * s * side))
cands.append((px - sign * pitch, py + sign * s * side))
else:
return _candidates_orthogonal(p, a, b, target=max(side, pitch) * 0.5)
if horiz:
cands = [(px, py + side), (px, py - side)] + cands
elif vert:
cands = [(px + pitch, py), (px - pitch, py)] + cands
seen: set[tuple[float, float]] = set()
out: list[tuple[float, float]] = []
for xy in cands:
key = (round(xy[0], 1), round(xy[1], 1))
if key in seen:
continue
seen.add(key)
out.append(xy)
return out
def clear_edge_hits(
state: LayoutState,
params: LayoutParams,
*,
top_n: int = 12,
thr: float = EDGE_CLEARANCE_THR,
margin: float = 20.0,
max_moves: int = 24,
preserve_axis: bool = False,
pitch: float | None = None,
side: float | None = None,
rounds: int = 1,
) -> 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``.
"""
del params
st = state.copy()
pos = dict(st.positions)
names = st.names
links = list(st.links)
adj = st.adj
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))
if preserve_axis:
rounds_n = max(rounds_n, 6)
top_n = max(top_n, 40)
max_moves = max(max_moves, 80)
clr0 = compute_edge_clearance(pos, links, names=names, thr=thr, top_n=max(top_n, 5))
hits_before = int(clr0.get("edge_clearance_hits") or 0)
if clr0.get("edge_clearance_skipped"):
return OpResult(
state=st,
moved=set(),
op="clear_edge_hits",
note="clear_edge_hits:skipped_large",
params={"hits_before": hits_before, "skipped": True},
)
if hits_before <= 0:
return OpResult(
state=st,
moved=set(),
op="clear_edge_hits",
note="clear_edge_hits:noop",
params={"hits_before": 0, "hits_after": 0, "moved_n": 0},
)
moved: set[str] = set()
accepted: list[dict[str, Any]] = []
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
deg = {n: len(adj.get(n) or ()) for n in pos}
def _move_budget(nid: str) -> float:
d = deg.get(nid, 0)
if d >= 5:
return max(pitch_f, side_f) * 1.5
if d >= 3:
return max(pitch_f, side_f) * 3.0
return max(pitch_f, side_f) * 8.0
def _within_budget(nid: str, xy: tuple[float, float], origin: tuple[float, float]) -> bool:
return math.hypot(xy[0] - origin[0], xy[1] - origin[1]) <= _move_budget(nid) + 1e-6
for _rnd in range(rounds_n):
full = compute_edge_clearance(pos, links, names=names, thr=thr, top_n=top_n)
ordered = list(full.get("hit_nodes") or full.get("top_edge_hits") or [])
if not ordered:
break
# Prefer ejecting low-degree obstacles first (keep core hubs stable).
ordered.sort(
key=lambda h: (
float(h.get("dist") or 0.0),
deg.get(str(h.get("fabric_node_id")), 99),
str(h.get("fabric_node_id")),
)
)
progress = False
moves_this = 0
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
if nid not in pos:
continue
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if a not in pos or b not in pos:
continue
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:
continue
if preserve_axis:
nbr_xy = [
pos[nb]
for nb in (adj.get(nid) or ())
if nb in pos and nb not in (a, b)
]
cands = _candidates_grid_ortho(
p0,
pos[a],
pos[b],
pitch=pitch_f,
side=side_f,
neighbor_xy=nbr_xy,
)
else:
cands = _candidates_orthogonal(p0, pos[a], pos[b], target=target)
inc0 = _incident_axis_score(nid, pos, adj)
for xy in cands:
if not _within_budget(nid, xy, p0):
continue
trial = dict(pos)
trial[nid] = xy
if _overlaps_any(nid, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack:
continue
d1, t1 = point_segment_dist(xy, pos[a], pos[b])
if 0.05 < t1 < 0.95 and d1 < thr:
continue
# Must leave ALL non-incident open segments (not just a-b).
still_hit = False
for ea, eb in links:
if nid in (ea, eb) or ea not in trial or eb not in trial:
continue
if _on_open_segment(xy, trial[ea], trial[eb], thr=thr):
still_hit = True
break
if still_hit:
continue
if preserve_axis:
inc1 = _incident_axis_score(nid, trial, adj)
if inc1 < inc0:
continue
ax1 = _global_axis_score(trial, links)
if ax1 < ax0 - 2: # allow small axis trade for clearance
continue
pos[nid] = xy
moved.add(nid)
moves_this += 1
progress = True
x0 = x1
if preserve_axis:
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": nid,
"from": [round(p0[0], 1), round(p0[1], 1)],
"to": [round(xy[0], 1), round(xy[1], 1)],
"edge": [a, b],
"dist_before": round(float(h["dist"]), 2),
"dist_after": round(d1, 2),
}
)
break
if not progress and preserve_axis:
# Phase 2: translate the whole H/V trunk off the obstacle row/col
# (keeps the edge axis-aligned; frees every node that sat on it).
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
ax, ay = pos[a]
bx, by = pos[b]
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
if not horiz and not vert:
continue
# Prefer moving the lower-degree endpoint pair as a rigid H/V bar
max_trunk_steps = 2 if max(deg.get(a, 0), deg.get(b, 0)) >= 4 else 4
for s in range(1, max_trunk_steps + 1):
deltas = (
[(0.0, s * side_f), (0.0, -s * side_f)]
if horiz
else [(s * pitch_f, 0.0), (-s * pitch_f, 0.0)]
)
for dx, dy in deltas:
trial = dict(pos)
trial[a] = (ax + dx, ay + dy)
trial[b] = (bx + dx, by + dy)
if _overlaps_any(a, trial, names) or _overlaps_any(b, trial, names):
continue
if _global_axis_score(trial, links) < ax0 - 2:
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack:
continue
d1, t1 = point_segment_dist(pos[nid], trial[a], trial[b])
# obstacle stays put; edge moved away
if 0.05 < t1 < 0.95 and d1 < thr:
continue
# also: no OTHER node should sit on the moved trunk
blocked = False
for n2, p2 in trial.items():
if n2 in (a, b):
continue
dd, tt = point_segment_dist(p2, trial[a], trial[b])
if dd < thr and 0.05 < tt < 0.95:
blocked = True
break
if blocked:
continue
pos[a], pos[b] = trial[a], trial[b]
moved.add(a)
moved.add(b)
moves_this += 2
progress = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": a,
"with": b,
"from": [round(ax, 1), round(ay, 1)],
"to": [round(pos[a][0], 1), round(pos[a][1], 1)],
"edge": [a, b],
"mode": "translate_trunk",
"cleared": nid,
}
)
break
if progress:
break
if progress:
break
if not progress and preserve_axis:
# Phase 3: break the occluding H/V chord (prefer low-deg endpoint).
# Prefer this over ejecting hubs — layout sense > pure clearance force.
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
ax, ay = pos[a]
bx, by = pos[b]
horiz = abs(ay - by) <= _AXIS_TOL
vert = abs(ax - bx) <= _AXIS_TOL
if not horiz and not vert:
continue
ends = sorted([a, b], key=lambda n: (deg.get(n, 0), n))
cleared_local = False
for end in ends:
ox, oy = pos[end]
# Hubs: only 1 grid step off the shared track (keep layout sense).
max_step = 1 if deg.get(end, 0) >= 5 else (2 if deg.get(end, 0) >= 3 else 4)
for step in range(1, max_step + 1):
step_deltas = (
[(0.0, step * side_f), (0.0, -step * side_f)]
if horiz
else [(step * pitch_f, 0.0), (-step * pitch_f, 0.0)]
)
for dx, dy in step_deltas:
trial = dict(pos)
trial[end] = (ox + dx, oy + dy)
if _overlaps_any(end, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack + 6:
continue
if _on_open_segment(pos[nid], trial[a], trial[b], thr=thr):
continue
blocked = False
for n2, p2 in trial.items():
if n2 in (a, b):
continue
if _on_open_segment(p2, trial[a], trial[b], thr=thr):
blocked = True
break
if blocked:
continue
pos[end] = trial[end]
moved.add(end)
moves_this += 1
progress = True
cleared_local = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": end,
"from": [round(ox, 1), round(oy, 1)],
"to": [
round(pos[end][0], 1),
round(pos[end][1], 1),
],
"edge": [a, b],
"mode": "break_chord",
"cleared": nid,
}
)
break
if cleared_local:
break
if cleared_local:
break
if progress:
break
if not progress and preserve_axis:
# Phase 4: local force-eject low-deg obstacles only.
for h in ordered:
if moves_this >= max_moves:
break
nid = str(h["fabric_node_id"])
a = str(h["a_node_id"])
b = str(h["b_node_id"])
if nid not in pos or a not in pos or b not in pos:
continue
if deg.get(nid, 0) >= 4:
continue
if not _on_open_segment(pos[nid], pos[a], pos[b], thr=thr):
continue
p0 = pos[nid]
nbr_xy = [pos[nb] for nb in (adj.get(nid) or ()) if nb in pos]
cands = _candidates_grid_ortho(
p0,
pos[a],
pos[b],
pitch=pitch_f,
side=side_f,
neighbor_xy=nbr_xy,
max_steps=6,
)
for xy in cands:
if not _within_budget(nid, xy, p0):
continue
trial = dict(pos)
trial[nid] = xy
if _overlaps_any(nid, trial, names):
continue
x1 = count_edge_crossings(trial, links)
if x1 > x0 + x_slack + 4:
continue
if _on_open_segment(xy, trial[a], trial[b], thr=thr):
continue
still_hit = False
for ea, eb in links:
if nid in (ea, eb) or ea not in trial or eb not in trial:
continue
if _on_open_segment(xy, trial[ea], trial[eb], thr=thr):
still_hit = True
break
if still_hit:
continue
pos[nid] = xy
moved.add(nid)
moves_this += 1
progress = True
x0 = x1
ax0 = _global_axis_score(pos, links)
accepted.append(
{
"fabric_node_id": nid,
"from": [round(p0[0], 1), round(p0[1], 1)],
"to": [round(xy[0], 1), round(xy[1], 1)],
"edge": [a, b],
"mode": "force_eject",
"dist_before": round(float(h["dist"]), 2),
}
)
break
if progress:
break
if not progress:
break
st.positions = pos
clr1 = compute_edge_clearance(pos, links, names=names, thr=thr)
hits_after = int(clr1.get("edge_clearance_hits") or 0)
st.meta = dict(st.meta or {})
st.meta["clear_edge_hits"] = {
"hits_before": hits_before,
"hits_after": hits_after,
"moved_n": len(moved),
"accepted": accepted[:40],
"thr": thr,
"margin": margin,
"preserve_axis": preserve_axis,
}
return OpResult(
state=st,
moved=moved,
op="clear_edge_hits",
note=f"clear_edge_hits {hits_before}->{hits_after} moved={len(moved)}"
+ (" axis" if preserve_axis else ""),
params={
"hits_before": hits_before,
"hits_after": hits_after,
"moved_n": len(moved),
"accepted": accepted[:40],
"thr": thr,
"margin": margin,
"top_n": top_n,
"preserve_axis": preserve_axis,
},
)

View file

@ -0,0 +1,628 @@
"""Incremental compose: attach staging blocks one-by-one with orbit search.
Unlike spring strip-pack ``compose_views``, this grows the world in Prim portal
order. After each block is rigidly aligned onto shared portals, exclusive nodes
are swept on concentric circles / angle grids about the portal pivot (same
spirit as ``orbit_sweep``) and the candidate with fewest partial crossings is
kept.
Degrees of freedom by glue:
- 0 shared: place near hull, then orbit whole block about nearest bridge tip
- 1 shared: rotate+scale-radius of exclusive members about the portal
- 2+ shared: chord lock + flip bank + small bulge rotations about chord mid
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
segments_properly_intersect,
)
from netx_topology_mcp.layout_ops.compose_views import (
ComposeBlock,
_membership_counts,
_portal_grow_order,
_rank_shared_pivots,
_reflect_about_axis,
_rigid_align_to_world,
compose_params_from_overrides,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
_DEFAULT_ANGLE_STEP = 30
_DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95)
def _seg_bbox(
p: tuple[float, float], q: tuple[float, float]
) -> tuple[float, float, float, float]:
return (min(p[0], q[0]), min(p[1], q[1]), max(p[0], q[0]), max(p[1], q[1]))
def _bbox_overlap(
a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> bool:
return not (a[2] < b[0] or b[2] < a[0] or a[3] < b[1] or b[3] < a[1])
def crossings_touching(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
focus: set[str],
) -> int:
"""Count crossings where at least one endpoint is in ``focus`` (partial QA)."""
if not focus or not links:
return 0
focus_segs: list[
tuple[tuple[float, float], tuple[float, float], tuple[float, float, float, float]]
] = []
other_segs: list[
tuple[tuple[float, float], tuple[float, float], tuple[float, float, float, float]]
] = []
for u, v in links:
if u not in pos or v not in pos:
continue
p, q = pos[u], pos[v]
bb = _seg_bbox(p, q)
if u in focus or v in focus:
focus_segs.append((p, q, bb))
else:
other_segs.append((p, q, bb))
n = 0
# focus vs other
for p1, p2, b1 in focus_segs:
for p3, p4, b2 in other_segs:
if not _bbox_overlap(b1, b2):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
# focus vs focus (exclusive internal)
for i in range(len(focus_segs)):
p1, p2, b1 = focus_segs[i]
for j in range(i + 1, len(focus_segs)):
p3, p4, b2 = focus_segs[j]
if not _bbox_overlap(b1, b2):
continue
if segments_properly_intersect(p1, p2, p3, p4):
n += 1
return n
def _bridge_len(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> float:
total = 0.0
for a, b in links:
if a in new_ids and b in old_ids:
if a in pos and b in pos:
total += math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
elif b in new_ids and a in old_ids:
if a in pos and b in pos:
total += math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
return total
def _apply_polar(
base: dict[str, tuple[float, float]],
members: list[str],
pivot: tuple[float, float],
*,
angle: float,
radius_scale: float,
) -> dict[str, tuple[float, float]]:
"""Rotate members about pivot and scale their distance from pivot."""
cos_a, sin_a = math.cos(angle), math.sin(angle)
cx, cy = pivot
out = dict(base)
for n in members:
if n not in out:
continue
x, y = out[n]
dx, dy = x - cx, y - cy
rx = (dx * cos_a - dy * sin_a) * radius_scale
ry = (dx * sin_a + dy * cos_a) * radius_scale
out[n] = (cx + rx, cy + ry)
return out
def _footprint_hits(
pos: dict[str, tuple[float, float]],
new_ids: set[str],
old_ids: set[str],
*,
min_dist: float = 90.0,
) -> int:
"""Cheap exclusive-vs-old center collisions (not full AABB)."""
hits = 0
md2 = min_dist * min_dist
old_pts = [(pos[n][0], pos[n][1]) for n in old_ids if n in pos]
for n in new_ids:
if n not in pos:
continue
x, y = pos[n]
for ox, oy in old_pts:
dx, dy = x - ox, y - oy
if dx * dx + dy * dy < md2:
hits += 1
break
return hits
def _score_attach(
trial: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> tuple[int, int, float]:
x = crossings_touching(trial, links, new_ids)
ov = _footprint_hits(trial, new_ids, old_ids)
br = _bridge_len(trial, links, new_ids, old_ids)
return (x, ov, br)
def _orbit_candidates_one_portal(
world: dict[str, tuple[float, float]],
aligned: dict[str, tuple[float, float]],
portal: str,
exclusive: list[str],
*,
angle_step: int,
radii: tuple[float, ...],
) -> list[dict[str, tuple[float, float]]]:
if portal not in world:
return [aligned]
pivot = world[portal]
base = dict(world)
for nid, xy in aligned.items():
if nid == portal:
continue
base[nid] = xy
# Keep portal frozen.
base[portal] = world[portal]
cands = [dict(base)]
step = max(10, min(90, int(angle_step)))
for deg in range(0, 360, step):
if deg == 0:
angles = [0.0]
else:
angles = [math.radians(deg)]
for ang in angles:
for rs in radii:
if deg == 0 and abs(rs - 1.0) < 1e-9:
continue
trial = _apply_polar(base, exclusive, pivot, angle=ang, radius_scale=rs)
trial[portal] = world[portal]
cands.append(trial)
return cands
def _orbit_candidates_two_portal(
world: dict[str, tuple[float, float]],
aligned: dict[str, tuple[float, float]],
portals: list[str],
exclusive: list[str],
local: dict[str, tuple[float, float]],
*,
angle_step: int,
) -> list[dict[str, tuple[float, float]]]:
"""Chord-locked: base + flip + small bulge twists about chord midpoint."""
p0, p1 = portals[0], portals[1]
if p0 not in world or p1 not in world:
return [aligned]
base = dict(world)
for nid, xy in aligned.items():
if nid in (p0, p1):
continue
base[nid] = xy
base[p0] = world[p0]
base[p1] = world[p1]
cands = [dict(base)]
flipped = _reflect_about_axis(base, exclusive + [p0, p1], world[p0], world[p1])
flipped[p0] = world[p0]
flipped[p1] = world[p1]
cands.append(flipped)
# Small rotations of exclusive bulge about chord midpoint (keep portals).
mx = 0.5 * (world[p0][0] + world[p1][0])
my = 0.5 * (world[p0][1] + world[p1][1])
step = max(15, min(45, int(angle_step)))
for src in (base, flipped):
for deg in (-step, step, 2 * step, -2 * step):
trial = _apply_polar(
src, exclusive, (mx, my), angle=math.radians(deg), radius_scale=1.0
)
trial[p0] = world[p0]
trial[p1] = world[p1]
cands.append(trial)
return cands
def _orbit_candidates_orphan(
world: dict[str, tuple[float, float]],
local: dict[str, tuple[float, float]],
*,
pad: float,
angle_step: int,
radii: tuple[float, ...],
links: list[tuple[str, str]],
) -> list[dict[str, tuple[float, float]]]:
"""No shared portal: park outside bbox then orbit about nearest tip."""
if not world:
# Seed: place local as-is near origin.
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
placed = {n: (x - min_x + 40.0, y - min_y + 40.0) for n, (x, y) in local.items()}
return [placed]
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
min_x, max_x = min(wxs), max(wxs)
min_y, max_y = min(wys), max(wys)
# Default park below bbox.
lxs = [p[0] for p in local.values()]
lys = [p[1] for p in local.values()]
lmin_x, lmin_y = min(lxs), min(lys)
ox, oy = min_x, max_y + float(pad)
placed0 = {
n: (ox + (x - lmin_x), oy + (y - lmin_y)) for n, (x, y) in local.items()
}
# Bridge tip: fabric edge from local ids into world.
local_ids = set(local)
tip = None
for a, b in links:
if a in local_ids and b in world:
tip = world[b]
break
if b in local_ids and a in world:
tip = world[a]
break
if tip is None:
tip = (0.5 * (min_x + max_x), max_y)
members = list(local.keys())
# Always tip-orbit sweep + score (no sticky/default park index).
cx0 = sum(placed0[n][0] for n in members) / len(members)
cy0 = sum(placed0[n][1] for n in members) / len(members)
step = max(15, min(60, int(angle_step)))
base_r = max(200.0, float(pad))
cands: list[dict[str, tuple[float, float]]] = []
for deg in range(0, 360, step):
ang = math.radians(deg)
for rs in radii:
r = base_r * rs
tx = tip[0] + r * math.cos(ang)
ty = tip[1] + r * math.sin(ang)
dx, dy = tx - cx0, ty - cy0
trial = dict(world)
for n in members:
x, y = placed0[n]
trial[n] = (x + dx, y + dy)
cands.append(trial)
return cands or [dict(world) | placed0]
def _pick_best(
cands: list[dict[str, tuple[float, float]]],
links: list[tuple[str, str]],
new_ids: set[str],
old_ids: set[str],
) -> tuple[dict[str, tuple[float, float]], tuple[int, int, float], int]:
"""Rescore every candidate; return the best (no sticky pick)."""
if not cands:
return {}, (0, 0, 0.0), -1
best = cands[0]
best_sc = _score_attach(best, links, new_ids, old_ids)
best_i = 0
for i, c in enumerate(cands[1:], start=1):
sc = _score_attach(c, links, new_ids, old_ids)
if sc < best_sc:
best, best_sc, best_i = c, sc, i
return best, best_sc, best_i
def _thin_candidates(
cands: list[dict[str, tuple[float, float]]],
cand_cap: int,
) -> list[dict[str, tuple[float, float]]]:
"""Even subsample — do not privilege index 0."""
if len(cands) <= cand_cap:
return cands
step = max(1, (len(cands) + cand_cap - 1) // cand_cap)
return cands[::step][:cand_cap]
def orbit_pack_blocks(
blocks: list[ComposeBlock],
*,
pad: float = 500.0,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] = _DEFAULT_RADII,
cand_cap: int = 80,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Grow world block-by-block with orbit attach search."""
if not blocks:
return {}, {"slots": 0, "nodes": 0, "mode": "compose_orbit"}
valid = [b for b in blocks if b.positions]
if not valid:
return {}, {"slots": 0, "nodes": 0, "mode": "compose_orbit"}
slot_meta: dict[str, dict[str, float]] = {}
for b in valid:
xs = [p[0] for p in b.positions.values()]
ys = [p[1] for p in b.positions.values()]
w = max(max(xs) - min(xs), 1.0) + float(pad)
h = max(max(ys) - min(ys), 1.0) + float(pad)
slot_meta[b.key] = {"w": w, "h": h, "area": w * h, "n": float(len(b.positions))}
membership = _membership_counts(valid)
link_list = list(links or [])
key_order = _portal_grow_order(
valid,
slot_meta,
membership,
links=link_list if fabric_bridges else None,
fabric_bridges=fabric_bridges,
)
by_key = {b.key: b for b in valid}
world: dict[str, tuple[float, float]] = {}
owner: dict[str, str] = {}
group_nodes: dict[str, list[str]] = {k: [] for k in key_order}
group_pivots: dict[str, list[str]] = {k: [] for k in key_order}
attach_trace: list[dict[str, Any]] = []
merged_via: dict[str, str] = {}
for key in key_order:
block = by_key[key]
local = dict(block.positions)
shared_raw = [nid for nid in local if nid in world]
shared = _rank_shared_pivots(shared_raw, membership)
old_ids = set(world)
exclusive = [n for n in local if n not in set(shared)]
if not world:
# Seed
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
for nid, (x, y) in local.items():
world[nid] = (x - min_x + 40.0, y - min_y + 40.0)
owner[nid] = key
group_nodes[key].append(nid)
group_pivots[key] = list(shared[:2]) if shared else []
merged_via[key] = "seed"
attach_trace.append(
{
"key": key,
"via": "seed",
"shared_n": len(shared),
"cands": 1,
"pick": 0,
"score": [0, 0, 0.0],
}
)
continue
if shared:
prefer = None
if world:
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
prefer = (0.5 * (min(wxs) + max(wxs)), 0.5 * (min(wys) + max(wys)))
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=link_list,
)
if len(shared) >= 2:
cands = _orbit_candidates_two_portal(
world,
aligned,
shared[:2],
exclusive,
local,
angle_step=angle_step,
)
via = "orbit_dual"
else:
cands = _orbit_candidates_one_portal(
world,
aligned,
shared[0],
exclusive,
angle_step=angle_step,
radii=radii,
)
via = "orbit_portal"
portal_pivots = [n for n in shared if membership.get(n, 1) >= 2][:8]
group_pivots[key] = portal_pivots or list(shared[:2])
else:
cands = _orbit_candidates_orphan(
world,
local,
pad=pad,
angle_step=angle_step,
radii=radii,
links=link_list,
)
via = "orbit_orphan"
group_pivots[key] = []
cands = _thin_candidates(cands, cand_cap)
new_ids = set(exclusive) if exclusive else set(local) - old_ids
best, best_sc, best_i = _pick_best(cands, link_list, new_ids, old_ids)
for nid, xy in best.items():
if nid not in world:
world[nid] = xy
owner[nid] = key
# portals already in world keep first-owner coords
group_nodes[key].append(nid)
# Ensure shared listed
for nid in shared:
if nid not in group_nodes[key]:
group_nodes[key].append(nid)
merged_via[key] = via
attach_trace.append(
{
"key": key,
"via": via,
"shared_n": len(shared),
"shared": shared[:4],
"cands": len(cands),
"best_i": best_i,
"score": [best_sc[0], best_sc[1], round(best_sc[2], 1)],
"exclusive_n": len(exclusive),
}
)
rigid_groups = [
{
"key": k,
"node_ids": sorted(set(group_nodes[k])),
"pivots": sorted(set(group_pivots.get(k) or [])),
"via": merged_via.get(k),
"soft": True,
}
for k in key_order
if group_nodes.get(k)
]
mass_groups = [
{
**g,
"cores": list(g.get("pivots") or []),
"soft": True,
}
for g in rigid_groups
]
# Final global crossing for meta (cheap enough once).
final_x = count_edge_crossings(world, link_list) if link_list else 0
meta: dict[str, Any] = {
"slots": len(key_order),
"nodes": len(world),
"pad": float(pad),
"merge_shared": True,
"mode": "compose_orbit",
"order_mode": "portal_grow",
"pack_mode": "orbit_attach",
"order": key_order,
"angle_step": int(angle_step),
"radii": list(radii),
"attach_trace": attach_trace[:80],
"rigid_groups": rigid_groups,
"mass_groups": mass_groups,
"soft": True,
"merged_via": merged_via,
"final_crossings": final_x,
"tip": (
"compose_orbit: Prim portal order + portal align seed; prefer "
"mass_merge (core/ring/chain attract) over exclusive rigid polish."
),
}
return world, meta
def compose_orbit_into_state(
state: LayoutState,
blocks: list[ComposeBlock],
params: LayoutParams | None = None,
*,
pad: float = 500.0,
fabric_bridges: bool = True,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] | None = None,
cand_cap: int = 80,
) -> OpResult:
del params
merged, meta = orbit_pack_blocks(
blocks,
pad=pad,
links=list(state.links or []),
fabric_bridges=fabric_bridges,
angle_step=angle_step,
radii=radii or _DEFAULT_RADII,
cand_cap=cand_cap,
)
if not merged:
return OpResult(
state=state,
moved=set(),
op="compose_orbit",
params=meta,
note="no_blocks",
)
out = state.copy()
moved: set[str] = set()
for nid, xy in merged.items():
if nid not in out.positions:
out.positions[nid] = xy
moved.add(nid)
continue
if out.positions[nid] != xy:
out.positions[nid] = xy
moved.add(nid)
out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta
try:
from netx_topology_mcp.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
mass = build_mass_field(out, groups=meta.get("mass_groups") or [])
out.meta["mass_field"] = mass
out.meta["compose_views"] = attach_mass_to_compose_meta(meta, mass)
except Exception:
pass
out.last_moved = moved
return OpResult(
state=out,
moved=moved,
op="compose_orbit",
params={**meta, "moved_n": len(moved)},
note=(
f"compose_orbit:slots={meta.get('slots')} nodes={meta.get('nodes')} "
f"x={meta.get('final_crossings')}"
),
)
def compose_orbit_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
base = compose_params_from_overrides(overrides)
o = overrides or {}
try:
base["angle_step"] = int(o.get("angle_step") or _DEFAULT_ANGLE_STEP)
except (TypeError, ValueError):
base["angle_step"] = _DEFAULT_ANGLE_STEP
raw_r = o.get("radii")
if isinstance(raw_r, (list, tuple)) and raw_r:
try:
base["radii"] = tuple(float(x) for x in raw_r)
except (TypeError, ValueError):
base["radii"] = _DEFAULT_RADII
else:
base["radii"] = _DEFAULT_RADII
try:
base["cand_cap"] = max(12, min(200, int(o.get("cand_cap") or 80)))
except (TypeError, ValueError):
base["cand_cap"] = 80
return base

View file

@ -0,0 +1,796 @@
"""Compose multiple view layouts via portal-aware packing + shared-node merge.
Dual-unit staging views intentionally overlap on portals. Pack order grows by
shared membership (Prim-style), then block origins come from a spring layout on
the portal-weighted block graph so units that share hubs sit near each other.
Orphan/misc blocks strip-pack below the glued component — cutting long
cross-unit edges that area-first square packing leaves behind.
When ``merge_shared`` (default), later blocks that share fabric_node_ids are
rigidly translated/rotated onto the first owner's world coordinates.
"""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import dataclass
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
DEFAULT_SLOT_PAD = 600.0
@dataclass(frozen=True)
class ComposeBlock:
"""One staging canvas / dual-unit view to pack."""
key: str
positions: dict[str, tuple[float, float]] # fabric_node_id -> local xy
def _membership_counts(blocks: list[ComposeBlock]) -> dict[str, int]:
counts: dict[str, int] = {}
for b in blocks:
for nid in b.positions:
counts[nid] = counts.get(nid, 0) + 1
return counts
def _shared_weight(
ids_a: set[str],
ids_b: set[str],
membership: dict[str, int],
) -> float:
"""Higher when blocks share portal-like hubs (multi-membership nodes)."""
shared = ids_a & ids_b
if not shared:
return 0.0
# membership>=2 are glue points; boost hubs that appear in many units.
return float(sum(max(1, membership.get(n, 1)) ** 2 for n in shared))
def _fabric_bridge_hits(
ids_a: set[str],
ids_b: set[str],
links: list[tuple[str, str]],
) -> int:
"""Count fabric edges with one end in A and the other in B."""
if not links or not ids_a or not ids_b:
return 0
n = 0
for a, b in links:
if (a in ids_a and b in ids_b) or (a in ids_b and b in ids_a):
n += 1
return n
def _portal_grow_order(
blocks: list[ComposeBlock],
slot_meta: dict[str, dict[str, float]],
membership: dict[str, int],
*,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
) -> list[str]:
"""Order blocks by shared-portal growth (Prim), not raw area.
Seed = block with strongest multi-membership glue (ignore orphan-only
misc), then attach max shared-weight neighbor. Fabric bridges (optional)
also count as glue so long-edge peers attach before pure orphans.
"""
if not blocks:
return []
id_sets = {b.key: set(b.positions) for b in blocks}
remaining = {b.key for b in blocks}
link_list = list(links or []) if fabric_bridges else []
def glue_mass(key: str) -> float:
return float(
sum(
membership.get(n, 1) ** 2
for n in id_sets[key]
if membership.get(n, 1) >= 2
)
)
def fabric_to(ids: set[str], other: set[str]) -> float:
if not link_list:
return 0.0
return float(_fabric_bridge_hits(ids, other, link_list))
glued = [k for k in remaining if glue_mass(k) > 0]
if not glued and link_list:
# Prefer a block that participates in any inter-block fabric edge.
seeded = [
k
for k in remaining
if any(
_fabric_bridge_hits(id_sets[k], id_sets[o], link_list) > 0
for o in remaining
if o != k
)
]
glued = seeded
if glued:
seed = max(glued, key=lambda k: (glue_mass(k), slot_meta[k]["area"], k))
else:
seed = max(remaining, key=lambda k: (slot_meta[k]["area"], k))
order = [seed]
remaining.remove(seed)
placed_nodes = set(id_sets[seed])
while remaining:
best_key = None
best_score: tuple[float, float, float, float, str] | None = None
for k in remaining:
w = _shared_weight(id_sets[k], placed_nodes, membership)
fb = fabric_to(id_sets[k], placed_nodes)
rest_nodes: set[str] = set()
for o in remaining:
if o != k:
rest_nodes |= id_sets[o]
w_rest = (
_shared_weight(id_sets[k], rest_nodes, membership)
if w <= 0 and fb <= 0
else 0.0
)
fb_rest = (
fabric_to(id_sets[k], rest_nodes) if w <= 0 and fb <= 0 else 0.0
)
# Orphans (no portal/fabric glue): larger → later.
if w <= 0 and fb <= 0 and w_rest <= 0 and fb_rest <= 0:
area_term = -slot_meta[k]["area"]
else:
area_term = slot_meta[k]["area"]
score = (w + fb * 4.0, w_rest + fb_rest * 2.0, area_term, fb, k)
if best_score is None or score > best_score:
best_score = score
best_key = k
assert best_key is not None
order.append(best_key)
remaining.remove(best_key)
placed_nodes |= id_sets[best_key]
return order
def _rank_shared_pivots(
shared: list[str], membership: dict[str, int]
) -> list[str]:
"""Prefer multi-unit hubs as rigid-align pivots (not corridor alpha order)."""
return sorted(shared, key=lambda n: (-membership.get(n, 1), n))
def _block_graph(
blocks: list[ComposeBlock],
membership: dict[str, int],
*,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
) -> dict[str, dict[str, float]]:
"""Undirected weighted graph: portal glue + optional fabric-bridge boost.
``bridge_boost`` is MCP-tunable: each inter-block fabric edge adds this
much weight so long-spoke peers sit near each other in the spring pack.
"""
id_sets = {b.key: set(b.positions) for b in blocks}
keys = [b.key for b in blocks]
adj: dict[str, dict[str, float]] = {k: {} for k in keys}
boost = max(0.0, float(bridge_boost))
link_list = list(links or []) if fabric_bridges and boost > 0 else []
for i, ka in enumerate(keys):
for kb in keys[i + 1 :]:
w = _shared_weight(id_sets[ka], id_sets[kb], membership)
if link_list:
w += boost * float(
_fabric_bridge_hits(id_sets[ka], id_sets[kb], link_list)
)
if w <= 0:
continue
adj[ka][kb] = w
adj[kb][ka] = w
return adj
def _reflect_about_axis(
pos: dict[str, tuple[float, float]],
members: list[str],
p0: tuple[float, float],
p1: tuple[float, float],
) -> dict[str, tuple[float, float]]:
ax, ay = p0
bx, by = p1
dx, dy = bx - ax, by - ay
L2 = dx * dx + dy * dy
if L2 < 1e-12:
return dict(pos)
out = dict(pos)
for n in members:
if n not in out:
continue
x, y = out[n]
t = ((x - ax) * dx + (y - ay) * dy) / L2
px, py = ax + t * dx, ay + t * dy
out[n] = (2 * px - x, 2 * py - y)
return out
def _external_bridge_cost(
placed: dict[str, tuple[float, float]],
world: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
new_ids: set[str],
) -> float:
"""Sum of squared lengths for edges that leave this block into ``world``."""
if not links or not new_ids:
return 0.0
cost = 0.0
for a, b in links:
a_new = a in new_ids
b_new = b in new_ids
if a_new == b_new:
continue
pa = placed.get(a) if a_new else world.get(a)
pb = placed.get(b) if b_new else world.get(b)
if pa is None or pb is None:
continue
dx = pa[0] - pb[0]
dy = pa[1] - pb[1]
cost += dx * dx + dy * dy
return cost
def _portal_centroid_origins(
key_order: list[str],
slot_meta: dict[str, dict[str, float]],
graph: dict[str, dict[str, float]],
*,
pad: float,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> dict[str, tuple[float, float]]:
"""Spring-embed block centroids on the portal/fabric graph; orphans strip below.
Origins are used for strip_pack seeds (and as a soft hint before merge).
``ideal_scale`` / ``spring_iters`` are MCP-tunable (smaller scale → denser).
"""
if not key_order:
return {}
glued = [k for k in key_order if graph.get(k)]
orphans = [k for k in key_order if k not in set(glued)]
origins: dict[str, tuple[float, float]] = {}
if not glued:
# Fall back to square strip for everything.
n = len(key_order)
cols = max(1, int(math.ceil(math.sqrt(n))))
col_w = [0.0] * cols
row_h: dict[int, float] = defaultdict(float)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
col_w[c] = max(col_w[c], slot_meta[key]["w"])
row_h[r] = max(row_h[r], slot_meta[key]["h"])
cox = [0.0]
for c in range(cols - 1):
cox.append(cox[-1] + col_w[c])
roy = {0: 0.0}
for r in range(1, (n // cols) + 2):
roy[r] = roy.get(r - 1, 0.0) + row_h.get(r - 1, 0.0)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
origins[key] = (cox[c], roy[r])
return origins
scale = max(0.25, min(float(ideal_scale), 1.2))
iters = max(20, min(int(spring_iters), 240))
# Ideal edge length ~ mean diagonal of the two slots.
def ideal(a: str, b: str) -> float:
da = math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
db = math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
return scale * (da + db) + float(pad)
# Init on a circle scaled by total size.
n_g = len(glued)
span = sum(math.hypot(slot_meta[k]["w"], slot_meta[k]["h"]) for k in glued) / max(
n_g, 1
)
radius = max(span * 0.7 * math.sqrt(n_g), 400.0)
pos: dict[str, list[float]] = {}
for i, key in enumerate(glued):
ang = 2.0 * math.pi * i / n_g
pos[key] = [radius * math.cos(ang), radius * math.sin(ang)]
# Lightweight spring / Fruchterman-style iterations (no numpy).
max_w = max((w for nbrs in graph.values() for w in nbrs.values()), default=1.0)
for _ in range(iters):
disp = {k: [0.0, 0.0] for k in glued}
# Attractive along portal / fabric edges.
for a in glued:
for b, w in graph.get(a, {}).items():
if b not in pos or a >= b:
continue
dx = pos[b][0] - pos[a][0]
dy = pos[b][1] - pos[a][1]
dist = math.hypot(dx, dy) or 1e-6
L = ideal(a, b)
# Stronger weight → stronger pull toward ideal length.
force = (dist - L) * (0.15 + 0.35 * (w / max_w))
ux, uy = dx / dist, dy / dist
disp[a][0] += ux * force
disp[a][1] += uy * force
disp[b][0] -= ux * force
disp[b][1] -= uy * force
# Mild repulsion so slots do not stack.
for i, a in enumerate(glued):
for b in glued[i + 1 :]:
dx = pos[b][0] - pos[a][0]
dy = pos[b][1] - pos[a][1]
dist = math.hypot(dx, dy) or 1e-6
min_d = 0.45 * (
math.hypot(slot_meta[a]["w"], slot_meta[a]["h"])
+ math.hypot(slot_meta[b]["w"], slot_meta[b]["h"])
)
if dist >= min_d:
continue
push = (min_d - dist) * 0.35
ux, uy = dx / dist, dy / dist
disp[a][0] -= ux * push
disp[a][1] -= uy * push
disp[b][0] += ux * push
disp[b][1] += uy * push
for k in glued:
pos[k][0] += max(-800.0, min(800.0, disp[k][0]))
pos[k][1] += max(-800.0, min(800.0, disp[k][1]))
# Convert centers → top-left origins (slot min corner).
for key in glued:
cx, cy = pos[key]
origins[key] = (
cx - 0.5 * slot_meta[key]["w"],
cy - 0.5 * slot_meta[key]["h"],
)
# Orphans: horizontal strip under the glued bbox.
if orphans:
xs = [origins[k][0] for k in glued]
ys = [origins[k][1] for k in glued]
ys2 = [origins[k][1] + slot_meta[k]["h"] for k in glued]
base_y = max(ys2) + float(pad)
base_x = min(xs)
x_cursor = base_x
for key in orphans:
origins[key] = (x_cursor, base_y)
x_cursor += slot_meta[key]["w"] + 0.25 * float(pad)
# Normalize so min corner is near origin.
min_x = min(o[0] for o in origins.values())
min_y = min(o[1] for o in origins.values())
return {k: (x - min_x + 40.0, y - min_y + 40.0) for k, (x, y) in origins.items()}
def _rigid_align_to_world(
local: dict[str, tuple[float, float]],
world: dict[str, tuple[float, float]],
shared: list[str],
*,
prefer_center: tuple[float, float] | None = None,
links: list[tuple[str, str]] | None = None,
) -> dict[str, tuple[float, float]]:
"""Translate (+ rotate about first shared) so shared nodes match world.
With two+ shared pivots, orientation follows the portal chord; also try the
flip across that chord and keep the side with fewer crossings on the
partial world (then shorter external bridges / closer to ``prefer_center``).
With a single pivot, rotate so the block centroid aims at ``prefer_center``.
"""
if not shared:
return dict(local)
p0 = shared[0]
lx0, ly0 = local[p0]
wx0, wy0 = world[p0]
angle = 0.0
if len(shared) >= 2:
p1 = shared[1]
ldx, ldy = local[p1][0] - lx0, local[p1][1] - ly0
wdx, wdy = world[p1][0] - wx0, world[p1][1] - wy0
if (ldx * ldx + ldy * ldy) > 1e-12 and (wdx * wdx + wdy * wdy) > 1e-12:
angle = math.atan2(wdy, wdx) - math.atan2(ldy, ldx)
elif prefer_center is not None and len(local) >= 2:
# Local centroid relative to portal.
cx = sum(p[0] for p in local.values()) / len(local)
cy = sum(p[1] for p in local.values()) / len(local)
ldx, ldy = cx - lx0, cy - ly0
tdx = prefer_center[0] - wx0
tdy = prefer_center[1] - wy0
if (ldx * ldx + ldy * ldy) > 1e-8 and (tdx * tdx + tdy * tdy) > 1e-8:
angle = math.atan2(tdy, tdx) - math.atan2(ldy, ldx)
cos_a, sin_a = math.cos(angle), math.sin(angle)
out: dict[str, tuple[float, float]] = {}
for nid, (lx, ly) in local.items():
dx, dy = lx - lx0, ly - ly0
rx = dx * cos_a - dy * sin_a
ry = dx * sin_a + dy * cos_a
out[nid] = (wx0 + rx, wy0 + ry)
if len(shared) < 2:
return out
# Dual-portal flip: same chord, opposite bank.
shared_set = set(shared)
new_ids = {n for n in local if n not in shared_set}
link_list = list(links or [])
def _score(cand: dict[str, tuple[float, float]]) -> tuple[int, float, float]:
trial = dict(world)
for nid, xy in cand.items():
if nid in shared_set:
continue
trial[nid] = xy
xcount = (
count_edge_crossings(trial, link_list) if link_list else 0
)
bridge = _external_bridge_cost(cand, world, link_list, new_ids)
if prefer_center is None:
aim = 0.0
else:
excl = [cand[n] for n in new_ids if n in cand]
if not excl:
aim = 0.0
else:
cx = sum(p[0] for p in excl) / len(excl)
cy = sum(p[1] for p in excl) / len(excl)
aim = (cx - prefer_center[0]) ** 2 + (cy - prefer_center[1]) ** 2
return (int(xcount), bridge, aim)
flipped = _reflect_about_axis(
out, list(local.keys()), world[shared[0]], world[shared[1]]
)
for p in shared:
if p in world:
flipped[p] = world[p]
if _score(flipped) < _score(out):
return flipped
return out
def strip_pack_blocks(
blocks: list[ComposeBlock],
*,
pad: float = DEFAULT_SLOT_PAD,
merge_shared: bool = True,
links: list[tuple[str, str]] | None = None,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Pack blocks; return merged positions + meta.
With ``merge_shared`` (default): later blocks that share node ids with
already-placed nodes are rigidly aligned (portal merge). First owner wins
for shared coordinates. Blocks without shared anchors use strip-pack slots.
Pack order prefers portal connectivity growth so dual units that share
hubs stay glued; large orphan/misc blocks are placed last. Optional
``links`` + ``fabric_bridges`` add fabric-bridge weight to the spring and
guide dual-portal flip by partial-graph crossings (then bridge length).
MCP knobs: ``pad``, ``bridge_boost``, ``ideal_scale``, ``spring_iters``,
``fabric_bridges``.
"""
if not blocks:
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
slot_meta: dict[str, dict[str, float]] = {}
valid: list[ComposeBlock] = []
for b in blocks:
if not b.positions:
continue
xs = [p[0] for p in b.positions.values()]
ys = [p[1] for p in b.positions.values()]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
w = max(max_x - min_x, 1.0) + float(pad)
h = max(max_y - min_y, 1.0) + float(pad)
slot_meta[b.key] = {
"min_x": min_x,
"min_y": min_y,
"w": w,
"h": h,
"area": w * h,
"n": float(len(b.positions)),
}
valid.append(b)
if not valid:
return {}, {"slots": 0, "nodes": 0, "pad": pad, "merge_shared": merge_shared}
membership = _membership_counts(valid)
link_list = list(links or [])
do_fabric = bool(fabric_bridges)
boost = max(0.0, float(bridge_boost))
if merge_shared:
key_order = _portal_grow_order(
valid,
slot_meta,
membership,
links=link_list,
fabric_bridges=do_fabric,
)
order_mode = "portal_grow"
graph = _block_graph(
valid,
membership,
links=link_list,
fabric_bridges=do_fabric,
bridge_boost=boost,
)
origins = _portal_centroid_origins(
key_order,
slot_meta,
graph,
pad=float(pad),
ideal_scale=float(ideal_scale),
spring_iters=int(spring_iters),
)
pack_mode = "portal_centroid"
cols = 0
else:
key_order = [
b.key
for b in sorted(valid, key=lambda b: (-slot_meta[b.key]["area"], b.key))
]
order_mode = "area"
pack_mode = "strip"
n = max(1, len(key_order))
cols = max(1, int(math.ceil(math.sqrt(n))))
col_widths: list[float] = [0.0] * cols
row_heights: dict[int, float] = defaultdict(float)
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
meta = slot_meta[key]
col_widths[c] = max(col_widths[c], meta["w"])
row_heights[r] = max(row_heights[r], meta["h"])
col_origin = [0.0]
for c in range(cols - 1):
col_origin.append(col_origin[-1] + col_widths[c])
row_origin: dict[int, float] = {0: 0.0}
max_row = max((i // cols for i in range(n)), default=0)
for r in range(1, max_row + 2):
row_origin[r] = row_origin.get(r - 1, 0.0) + row_heights.get(r - 1, 0.0)
origins = {}
for i, key in enumerate(key_order):
c, r = i % cols, i // cols
origins[key] = (col_origin[c], row_origin[r])
by_key = {b.key: b for b in valid}
out: dict[str, tuple[float, float]] = {}
owner: dict[str, str] = {}
merged_via: dict[str, str] = {}
align_meta: list[dict[str, Any]] = []
# Per-block members as placed (whole sub-region = rigid body).
group_nodes: dict[str, list[str]] = {k: [] for k in key_order}
group_pivots: dict[str, list[str]] = {k: [] for k in key_order}
for key in key_order:
block = by_key[key]
meta = slot_meta[key]
shared_raw = [nid for nid in block.positions if nid in out]
shared = _rank_shared_pivots(shared_raw, membership)
if merge_shared and shared:
ox, oy = origins[key]
prefer = (
ox + 0.5 * meta["w"],
oy + 0.5 * meta["h"],
)
aligned = _rigid_align_to_world(
block.positions,
out,
shared,
prefer_center=prefer,
links=link_list,
)
placed_new = 0
for nid, xy in aligned.items():
group_nodes[key].append(nid)
if nid in out:
continue
out[nid] = xy
owner[nid] = key
placed_new += 1
# Freeze pivots = high-membership shared (true portals), not every
# overlapping corridor id — soft polish needs corridors movable.
portal_pivots = [n for n in shared if membership.get(n, 1) >= 2][:8]
group_pivots[key] = portal_pivots or list(shared[:2])
merged_via[key] = "rigid_shared"
align_meta.append(
{
"key": key,
"shared": shared[:6],
"shared_n": len(shared),
"placed_new": placed_new,
"pivot_n": len(group_pivots[key]),
}
)
continue
ox, oy = origins[key]
for nid, (lx, ly) in block.positions.items():
group_nodes[key].append(nid)
if nid in out:
group_pivots[key].append(nid)
continue
out[nid] = (ox + (lx - meta["min_x"]), oy + (ly - meta["min_y"]))
owner[nid] = key
merged_via[key] = "strip_pack"
rigid_groups = [
{
"key": k,
"node_ids": sorted(set(group_nodes[k])),
"pivots": sorted(set(group_pivots.get(k) or [])),
"via": merged_via.get(k),
"soft": True,
}
for k in key_order
if group_nodes.get(k)
]
mass_groups = [
{**g, "cores": list(g.get("pivots") or []), "soft": True}
for g in rigid_groups
]
return out, {
"slots": len(key_order),
"nodes": len(out),
"pad": float(pad),
"merge_shared": bool(merge_shared),
"fabric_bridges": do_fabric if merge_shared else False,
"bridge_boost": boost if merge_shared else 0.0,
"ideal_scale": float(ideal_scale) if merge_shared else None,
"spring_iters": int(spring_iters) if merge_shared else None,
"order_mode": order_mode,
"pack_mode": pack_mode,
"cols": cols,
"order": key_order,
"origins": {k: [round(v[0], 2), round(v[1], 2)] for k, v in origins.items()},
"owner": {k: owner[k] for k in list(owner)[:40]},
"merged_via": merged_via,
"alignments": align_meta[:20],
"rigid_groups": rigid_groups,
"mass_groups": mass_groups,
"soft": True,
"tip": (
"Compose: portal+fabric spring seed; prefer mass_merge "
"(core/ring/chain attract) then polish_crossings."
),
"slot_meta": {
k: {
"w": round(v["w"], 1),
"h": round(v["h"], 1),
"area": round(v["area"], 1),
"n": int(v["n"]),
}
for k, v in slot_meta.items()
},
}
def blocks_from_position_maps(
maps: list[tuple[str, dict[str, tuple[float, float]]]],
) -> list[ComposeBlock]:
return [ComposeBlock(key=k, positions=dict(pos)) for k, pos in maps if pos]
def compose_into_state(
state: LayoutState,
blocks: list[ComposeBlock],
params: LayoutParams | None = None,
*,
pad: float = DEFAULT_SLOT_PAD,
merge_shared: bool = True,
fabric_bridges: bool = True,
bridge_boost: float = 8.0,
ideal_scale: float = 0.55,
spring_iters: int = 80,
) -> OpResult:
"""Write strip-packed / merge-aligned coords onto ``state``."""
del params
merged, meta = strip_pack_blocks(
blocks,
pad=pad,
merge_shared=merge_shared,
links=list(state.links or []),
fabric_bridges=fabric_bridges,
bridge_boost=bridge_boost,
ideal_scale=ideal_scale,
spring_iters=spring_iters,
)
if not merged:
return OpResult(
state=state,
moved=set(),
op="compose_views",
params=meta,
note="no_blocks",
)
out = state.copy()
moved: set[str] = set()
for nid, xy in merged.items():
if nid not in out.positions:
out.positions[nid] = xy
moved.add(nid)
continue
if out.positions[nid] != xy:
out.positions[nid] = xy
moved.add(nid)
out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta
try:
from netx_topology_mcp.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
mass = build_mass_field(out, groups=meta.get("mass_groups") or [])
out.meta["mass_field"] = mass
out.meta["compose_views"] = attach_mass_to_compose_meta(meta, mass)
except Exception:
pass
out.last_moved = moved
return OpResult(
state=out,
moved=moved,
op="compose_views",
params={**meta, "moved_n": len(moved)},
note=(
f"compose_views:slots={meta.get('slots')} nodes={meta.get('nodes')} "
f"merge_shared={meta.get('merge_shared')} order={meta.get('order_mode')}"
),
)
def compose_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("pad") is not None:
try:
out["pad"] = float(overrides["pad"])
except (TypeError, ValueError):
pass
for flag in ("merge_shared", "fabric_bridges"):
if flag not in overrides:
continue
v = overrides[flag]
if isinstance(v, bool):
out[flag] = v
else:
out[flag] = str(v).strip().lower() in {"1", "true", "yes", "on"}
if overrides.get("bridge_boost") is not None:
try:
out["bridge_boost"] = max(0.0, float(overrides["bridge_boost"]))
except (TypeError, ValueError):
pass
if overrides.get("ideal_scale") is not None:
try:
out["ideal_scale"] = max(0.25, min(1.2, float(overrides["ideal_scale"])))
except (TypeError, ValueError):
pass
if overrides.get("spring_iters") is not None:
try:
out["spring_iters"] = max(20, min(240, int(overrides["spring_iters"])))
except (TypeError, ValueError):
pass
src = overrides.get("source_view_ids")
if isinstance(src, list):
out["source_view_ids"] = [str(x).strip() for x in src if str(x).strip()]
elif isinstance(src, str) and src.strip():
out["source_view_ids"] = [s.strip() for s in src.split(",") if s.strip()]
return out

View file

@ -0,0 +1,781 @@
"""Inward densify sweep: top-3 single-node pulls + corridor_cap scan.
Agent workflow: preview → pick rank 1..3 → apply; round=true auto-applies #1
when global crossings do not rise and stretch falls; phase=corridor / corridor=true
sweeps shrink_long_corridors params for util gain.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossings_involving_node,
)
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.orbit_sweep import (
_diversify_top,
_eval_candidate,
_incident_stretch,
)
from netx_topology_mcp.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
shrink_long_corridors,
)
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_PULL = 900.0
_MAX_PULL_CAP = 2200.0
_PULL_FRACS = (0.12, 0.22, 0.35, 0.5, 0.7)
_ANGLE_JITTERS = (-30.0, -15.0, 0.0, 15.0, 30.0)
_DEFAULT_CORRIDOR_CAPS = (1200.0, 1600.0, 2200.0, 3200.0)
_DEFAULT_PULLS = (0.35, 0.5, 0.65)
def _protect_is_off(protect_rigid: bool | str) -> bool:
return protect_rigid in (False, "false", "off", "none", "0")
def _resolve_frozen(
st: LayoutState,
protect_rigid: bool | str,
frozen_ids: set[str] | None,
) -> set[str]:
"""Portal/rigid freeze only when protect is on; off ignores portal_ids inject."""
if _protect_is_off(protect_rigid):
return set()
frozen: set[str] = set(frozen_ids or ())
if not frozen:
frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen
def _groups_from_state(state: LayoutState) -> list[dict[str, Any]]:
return groups_from_compose_meta((state.meta or {}).get("compose_views"))
def _membership_maps(
groups: list[dict[str, Any]],
valid: set[str],
) -> tuple[dict[str, list[str]], set[str], dict[str, tuple[float, float]]]:
"""Return node→group_keys, shared portals, group_key→exclusive centroid xy."""
counts: dict[str, int] = {}
g_members: dict[str, list[str]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
g_members[key] = members
for n in members:
counts[n] = counts.get(n, 0) + 1
shared = {n for n, c in counts.items() if c > 1}
# Need positions later — centroids filled by caller.
return (
{n: [k for k, ms in g_members.items() if n in ms] for n in counts},
shared,
{},
)
def _exclusive_centroids(
groups: list[dict[str, Any]],
pos: dict[str, tuple[float, float]],
shared: set[str],
) -> dict[str, tuple[float, float]]:
out: dict[str, tuple[float, float]] = {}
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in pos]
exclusive = [n for n in members if n not in shared]
use = exclusive if len(exclusive) >= 1 else members
if not use:
continue
cx = sum(pos[n][0] for n in use) / len(use)
cy = sum(pos[n][1] for n in use) / len(use)
out[key] = (cx, cy)
return out
def _anchor_for_node(
nid: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
*,
node_groups: dict[str, list[str]],
centroids: dict[str, tuple[float, float]],
global_cx: float,
global_cy: float,
) -> tuple[float, float, str]:
nbs = [pos[v] for v in adj.get(nid, ()) if v in pos]
if nbs:
ax = sum(p[0] for p in nbs) / len(nbs)
ay = sum(p[1] for p in nbs) / len(nbs)
return ax, ay, "neighbors"
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
return cx, cy, "unit"
return global_cx, global_cy, "global"
def _inward_samples(
x0: float,
y0: float,
ax: float,
ay: float,
*,
max_pull: float,
) -> list[tuple[float, float, float, float]]:
"""Samples on ray toward anchor; never past the anchor; capped by max_pull."""
dx, dy = ax - x0, ay - y0
dist = math.hypot(dx, dy)
if dist < 8.0:
return []
ux, uy = dx / dist, dy / dist
px, py = -uy, ux
out: list[tuple[float, float, float, float]] = []
base_ang = math.degrees(math.atan2(uy, ux)) % 360.0
for t in _PULL_FRACS:
pull = min(dist * t, max_pull)
if pull < 12.0:
continue
# Stay short of the anchor (leave 4px slack).
pull = min(pull, dist - 4.0)
if pull < 12.0:
continue
for dang in _ANGLE_JITTERS:
if dang == 0.0:
nx = x0 + ux * pull
ny = y0 + uy * pull
ang = base_ang
else:
rad = math.radians(base_ang + dang)
c, s = math.cos(rad), math.sin(rad)
nx = x0 + c * pull
ny = y0 + s * pull
# Reject if farther from anchor than start.
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
ang = (base_ang + dang) % 360.0
r = math.hypot(nx - x0, ny - y0)
if r < 12.0 or r > max_pull + 1:
continue
out.append((nx, ny, r, ang))
# Mild perpendicular nudges at mid pull (still closer to anchor).
mid = min(dist * 0.35, max_pull)
if mid >= 20.0:
for sign in (-1.0, 1.0):
nx = x0 + ux * mid + px * sign * mid * 0.25
ny = y0 + uy * mid + py * sign * mid * 0.25
if math.hypot(nx - ax, ny - ay) >= dist - 1e-6:
continue
r = math.hypot(nx - x0, ny - y0)
if 12.0 <= r <= max_pull + 1:
ang = math.degrees(math.atan2(ny - y0, nx - x0)) % 360.0
out.append((nx, ny, r, ang))
return out
def _densify_score_key(c: dict[str, Any]) -> tuple:
# Crossings first; then shorter incident stretch; prefer larger inward pull.
return (
int(c["crossings"]["global"]),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
-float(c.get("r") or 0.0),
)
def densify_sweep_node(
state: LayoutState,
node_id: str,
*,
params: LayoutParams | None = None,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
cand_cap: int = 180,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
top_k: int = 3,
groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Inward pull candidates for one node; return diversified top-k.
Default ``protect_rigid=off`` so multi-round densify may move portals;
other layout actions keep portal freeze. Pass ``protect_rigid=portals``
to opt back into rigid protection.
"""
params = params or LayoutParams()
st = state
nid = str(node_id).strip()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
if nid not in pos:
return {"ok": False, "error": "node_not_on_view", "node_id": nid}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
if nid in frozen:
return {
"ok": False,
"error": "frozen",
"node_id": nid,
"hint": "portal/rigid frozen; protect_rigid=off or pick a corridor node",
}
pull = float(max_pull if max_pull is not None else _MAX_PULL)
pull = max(80.0, min(pull, _MAX_PULL_CAP))
nbs_cap = pull * 2.5
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
# With groups + protect on: shared portals stay frozen.
if groups and nid in shared and not _protect_is_off(protect_rigid):
return {
"ok": False,
"error": "shared_portal",
"node_id": nid,
"hint": "shared portals frozen; densify defaults protect_rigid=off",
}
centroids = _exclusive_centroids(groups, pos, shared)
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
ax, ay, anchor_kind = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
stretch0 = _incident_stretch(nid, pos, adj, target_nn)
samples = _inward_samples(x0, y0, ax, ay, max_pull=pull)
# Also try unit centroid if neighbor anchor was used.
if anchor_kind == "neighbors":
for gk in node_groups.get(nid, ()):
if gk in centroids:
cx, cy = centroids[gk]
samples.extend(_inward_samples(x0, y0, cx, cy, max_pull=pull))
seen: set[tuple[int, int]] = set()
uniq: list[tuple[float, float, float, float]] = []
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
uniq.append((sx, sy, r, ang))
if len(uniq) >= cand_cap:
break
scored: list[dict[str, Any]] = []
for sx, sy, r, ang in uniq:
c = _eval_candidate(
nid,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=nn_floor,
nbs_cap=nbs_cap,
)
if c is None:
continue
# Densify: reject moves that raise global crossings.
if int(c["delta"]["global"]) > 0:
continue
scored.append(c)
scored.sort(key=_densify_score_key)
# Prefer stretch drop among non-rising-x candidates.
improving = [
c
for c in scored
if c["delta"]["global"] <= 0 and float(c.get("stretch") or 99) < stretch0 - 1e-4
]
pool = improving if improving else scored
top = _diversify_top(pool, k=max(1, int(top_k)), min_angle_sep=min_angle_sep)
return {
"ok": True,
"node_id": nid,
"name": names.get(nid, nid),
"x0": round(x0, 1),
"y0": round(y0, 1),
"degree": len(adj.get(nid, ())),
"anchor": {"x": round(ax, 1), "y": round(ay, 1), "kind": anchor_kind},
"stretch_before": round(stretch0, 3),
"crossings_before": {"global": int(global0), "incident": int(local0)},
"candidates": top,
"sampled": len(seen),
"improving_n": len(improving),
"max_pull": pull,
"nn_floor": nn_floor,
"hint": (
"prefer rank1 (inward, x not up, stretch down); pick 2/3 if label/util. "
"apply with params.pick=1|2|3."
),
}
def apply_densify_pick(
state: LayoutState,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
st = state.copy()
nid = str(sweep.get("node_id") or "")
cands = list(sweep.get("candidates") or [])
if not nid or nid not in st.positions or not cands:
return OpResult(
state=st,
moved=set(),
op="densify_sweep",
note="densify_sweep:noop",
params={"error": "no_candidates"},
)
idx = max(1, min(int(pick), len(cands))) - 1
chosen = cands[idx]
st.positions[nid] = (float(chosen["x"]), float(chosen["y"]))
st.last_moved = {nid}
st.meta["densify_sweep"] = {
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
}
return OpResult(
state=st,
moved={nid},
op="densify_sweep",
params={
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
},
note=(
f"densify_sweep pick={idx + 1} "
f"g{sweep.get('crossings_before', {}).get('global')}->"
f"{chosen['crossings']['global']}"
),
)
def densify_sweep_round(
state: LayoutState,
*,
params: LayoutParams | None = None,
top_n: int = 16,
max_degree: int = 8,
max_pull: float | None = None,
nn_floor: float = 60.0,
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
groups: list[dict[str, Any]] | None = None,
) -> OpResult:
"""Scan sparse/high-stretch nodes; auto-apply #1 if x not up and stretch drops.
Default ``protect_rigid=off`` (may move portals). Opt in with portals/all.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
groups = groups if groups is not None else _groups_from_state(st)
valid = set(pos)
node_groups, shared, _ = _membership_maps(groups, valid)
centroids = _exclusive_centroids(groups, pos, shared)
if groups and not _protect_is_off(protect_rigid):
frozen |= shared
gcx = sum(p[0] for p in pos.values()) / max(len(pos), 1)
gcy = sum(p[1] for p in pos.values()) / max(len(pos), 1)
global0 = count_edge_crossings(pos, links)
focus = {str(x) for x in (focus_ids or []) if str(x)}
scored_nodes: list[tuple[float, str]] = []
for nid in pos:
if nid in frozen:
continue
deg = len(adj.get(nid, ()))
if deg >= max_degree or deg == 0:
continue
if groups and nid not in node_groups and not focus:
continue
stretch = _incident_stretch(nid, pos, adj, target_nn)
ax, ay, _ = _anchor_for_node(
nid,
pos,
adj,
node_groups=node_groups,
centroids=centroids,
global_cx=gcx,
global_cy=gcy,
)
dist = math.hypot(pos[nid][0] - ax, pos[nid][1] - ay)
if stretch < 1.15 and dist < 180 and nid not in focus:
continue
# Higher stretch / farther from anchor first; focus boost.
pri = 0.0 if nid in focus else 1.0
scored_nodes.append((-stretch * 10 - dist / 500.0 + pri * 100, nid))
scored_nodes.sort()
movable = [nid for _, nid in scored_nodes[: max(1, int(top_n))]]
moved: set[str] = set()
trace: list[dict[str, Any]] = []
for nid in movable:
st.positions = pos
sweep = densify_sweep_node(
st,
nid,
params=params,
max_pull=max_pull,
nn_floor=nn_floor,
min_angle_sep=min_angle_sep,
protect_rigid="off",
frozen_ids=frozen,
groups=groups,
)
if not sweep.get("ok"):
trace.append({"node_id": nid, "skipped": sweep.get("error")})
continue
cands = list(sweep.get("candidates") or [])
if not cands:
trace.append({"node_id": nid, "skipped": "no_candidates"})
continue
best = cands[0]
stretch_b = float(sweep.get("stretch_before") or 0)
stretch_a = float(best.get("stretch") or 0)
if int(best["delta"]["global"]) > 0:
trace.append(
{
"node_id": nid,
"skipped": "crossing_up",
"best_delta": best["delta"],
}
)
continue
if stretch_a >= stretch_b - 1e-4:
# Allow pure global drop even if stretch flat.
if int(best["delta"]["global"]) >= 0:
trace.append(
{
"node_id": nid,
"skipped": "no_stretch_gain",
"stretch_before": stretch_b,
"stretch_after": stretch_a,
}
)
continue
pos[nid] = (float(best["x"]), float(best["y"]))
moved.add(nid)
trace.append(
{
"node_id": nid,
"name": names.get(nid, nid),
"applied": True,
"pick": 1,
"xy": [best["x"], best["y"]],
"delta": best["delta"],
"stretch": {"before": stretch_b, "after": stretch_a},
}
)
st.positions = pos
st.last_moved = moved
end_g = count_edge_crossings(pos, links)
meta = {
"start_crossings": global0,
"end_crossings": end_g,
"moved_n": len(moved),
"scanned_n": len(movable),
"trace": trace,
"top_n": top_n,
"max_degree": max_degree,
"nn_floor": nn_floor,
}
st.meta["densify_sweep"] = meta
return OpResult(
state=st,
moved=moved,
op="densify_sweep_round",
params=meta,
note=f"densify_sweep_round {global0}->{end_g} moved={len(moved)}/{len(movable)}",
)
def densify_corridor_scan(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
corridor_caps: list[float] | None = None,
pulls: list[float] | None = None,
iters: int = 6,
x_slack: int | None = None,
) -> OpResult:
"""Sweep corridor_cap×pull; keep best util with ov=0 and tight crossing slack."""
st0 = state.copy()
groups = groups if groups is not None else _groups_from_state(st0)
before = score_state(st0)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = int((before.get("summary") or {}).get("overlaps") or 0)
if before_ov == 0:
before_ov = len(overlapping_nodes(st0))
xs = [p[0] for p in st0.positions.values()]
ys = [p[1] for p in st0.positions.values()]
area0 = max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
caps = list(corridor_caps) if corridor_caps else list(_DEFAULT_CORRIDOR_CAPS)
pull_list = list(pulls) if pulls else list(_DEFAULT_PULLS)
slack = (
max(5, int(before_x * 0.05))
if x_slack is None
else max(0, int(x_slack))
)
trials: list[dict[str, Any]] = []
best: dict[str, Any] | None = None
for cap in caps:
for pull in pull_list:
op = shrink_long_corridors(
st0,
edge_len_cap=float(cap),
pull=float(pull),
iters=max(1, int(iters)),
max_bridges=8 if groups else 12,
min_island=2 if groups else 6,
groups=groups or None,
accept_crossings=True,
)
fin = score_state(op.state)
util = float((fin.get("summary") or {}).get("util") or 0.0)
x1 = int((fin.get("summary") or {}).get("crossings") or 0)
ov = len(overlapping_nodes(op.state))
xs1 = [p[0] for p in op.state.positions.values()]
ys1 = [p[1] for p in op.state.positions.values()]
area1 = max(max(xs1) - min(xs1), 1e-6) * max(max(ys1) - min(ys1), 1e-6)
area_ratio = area0 / area1
row = {
"corridor_cap": float(cap),
"pull": float(pull),
"util": util,
"crossings": x1,
"overlaps": ov,
"moved_n": len(op.moved),
"bbox_area_ratio": round(area_ratio, 4),
"note": op.note,
}
trials.append(row)
if ov > 0:
continue
if x1 > before_x + slack:
continue
# Accept if util rises at all, or bbox shrinks ≥0.8% (metro
# corridors often move area×1.01 before util clears 1%).
util_up = util > before_util + 1e-6
area_up = area_ratio >= 1.008
if not util_up and not area_up:
continue
rank = (util, area_ratio, -x1)
if best is None or rank > best["rank"]:
best = {
"rank": rank,
"state": op.state,
"moved": set(op.moved),
"row": row,
}
if best is None:
meta = {
"start_crossings": before_x,
"end_crossings": before_x,
"start_util": before_util,
"end_util": before_util,
"reverted": True,
"reason": "no_util_gain",
"trials": trials,
"x_slack": slack,
}
st0.meta["densify_corridor"] = meta
return OpResult(
state=st0,
moved=set(),
op="densify_corridor_scan",
params=meta,
note="densify_corridor_scan:reverted no_util_gain",
)
st = best["state"]
fin = score_state(st)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
meta = {
"start_crossings": before_x,
"end_crossings": end_x,
"start_util": before_util,
"end_util": end_util,
"reverted": False,
"chosen": best["row"],
"trials": trials,
"x_slack": slack,
"moved_n": len(best["moved"]),
}
st.meta = dict(st.meta or {})
st.meta["densify_corridor"] = meta
st.last_moved = best["moved"]
return OpResult(
state=st,
moved=best["moved"],
op="densify_corridor_scan",
params=meta,
note=(
f"densify_corridor_scan util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} "
f"cap={best['row']['corridor_cap']:.0f} pull={best['row']['pull']}"
),
)
def densify_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("node_id") is not None:
out["node_id"] = str(o.get("node_id") or "").strip()
elif o.get("fabric_node_id") is not None:
out["node_id"] = str(o.get("fabric_node_id") or "").strip()
if o.get("pick") is not None:
try:
out["pick"] = max(1, min(3, int(o["pick"])))
except (TypeError, ValueError):
out["pick"] = 1
for flag in ("round", "corridor"):
if o.get(flag) is not None:
out[flag] = str(o.get(flag)).lower() not in {
"0",
"false",
"no",
"off",
"",
} or o.get(flag) is True
phase = str(o.get("phase") or "").strip().lower()
if phase in {"corridor", "corridors", "shrink"}:
out["corridor"] = True
elif phase in {"intra", "node", "round"}:
out["round"] = out.get("round", True)
for key, cast, default in (
("top_n", int, 16),
("max_degree", int, 8),
("cand_cap", int, 180),
("top_k", int, 3),
("iters", int, 6),
):
if key not in o or o[key] is None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
out[key] = default
if o.get("max_pull") is not None:
try:
out["max_pull"] = float(o["max_pull"])
except (TypeError, ValueError):
pass
if o.get("nn_floor") is not None:
try:
out["nn_floor"] = float(o["nn_floor"])
except (TypeError, ValueError):
out["nn_floor"] = 60.0
else:
out["nn_floor"] = 60.0
if o.get("min_angle_sep") is not None:
try:
out["min_angle_sep"] = float(o["min_angle_sep"])
except (TypeError, ValueError):
out["min_angle_sep"] = 35.0
else:
out["min_angle_sep"] = 35.0
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
else:
# densify breaks rigid by default (opt in with protect_rigid=portals).
out["protect_rigid"] = "off"
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
raw_p = o.get("portal_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
caps = o.get("corridor_caps") or o.get("corridor_cap_list")
if isinstance(caps, list) and caps:
try:
out["corridor_caps"] = [float(x) for x in caps]
except (TypeError, ValueError):
pass
elif o.get("corridor_cap") is not None:
try:
out["corridor_caps"] = [float(o["corridor_cap"])]
except (TypeError, ValueError):
pass
pulls = o.get("pulls") or o.get("pull_list")
if isinstance(pulls, list) and pulls:
try:
out["pulls"] = [float(x) for x in pulls]
except (TypeError, ValueError):
pass
elif o.get("pull") is not None:
try:
out["pulls"] = [float(o["pull"])]
except (TypeError, ValueError):
pass
if o.get("x_slack") is not None:
try:
out["x_slack"] = int(o["x_slack"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,451 @@
"""Same-canvas dual-unit beautify: parallel lanes + straight chains.
Role: after gravity (`mass_merge`) has clustered the canvas, reshape each
dual-unit neighborhood in place — multi-corridor → H/V lanes; chains →
straight (no 回字). Does **not** redistribute the whole canvas by default.
Modes:
- refine (default / auto when spread): beautify onto current world portals
- full / repark: optional orbit_pack (legacy redistribute) then beautify
Typical agent flow:
mass_merge (gravity) → polish_crossings → dual_mass ↔ polish
"""
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.compose_orbit import orbit_pack_blocks
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
from netx_topology_mcp.layout_ops.dual_units import (
DualUnit,
beautify_dual_unit_positions,
classify_dual_unit,
find_dual_portal_units,
)
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.mass_field import (
attach_mass_to_compose_meta,
build_mass_field,
)
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _unit_links(
unit: DualUnit,
adj: dict[str, tuple[str, ...]],
) -> list[tuple[str, str]]:
members = unit.member_ids()
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for n in members:
for v in adj.get(n, ()):
if v not in members:
continue
pair = (n, v) if n <= v else (v, n)
if pair in seen:
continue
seen.add(pair)
out.append(pair)
return out
def _misc_grid_positions(
node_ids: list[str],
*,
pitch: float = 200.0,
) -> dict[str, tuple[float, float]]:
if not node_ids:
return {}
cols = max(1, int(math.ceil(math.sqrt(len(node_ids)))))
pos: dict[str, tuple[float, float]] = {}
for i, nid in enumerate(sorted(node_ids)):
r, c = divmod(i, cols)
pos[nid] = (c * pitch, r * pitch)
return pos
def _unit_centroids(
positions: dict[str, tuple[float, float]],
units: list[DualUnit],
) -> dict[int, tuple[float, float]]:
out: dict[int, tuple[float, float]] = {}
for u in units:
ids = [n for n in u.member_ids() if n in positions]
if not ids:
continue
xs = [positions[n][0] for n in ids]
ys = [positions[n][1] for n in ids]
out[int(u.unit_id)] = (sum(xs) / len(xs), sum(ys) / len(ys))
return out
def _centroid_drift(
before: dict[int, tuple[float, float]],
after: dict[int, tuple[float, float]],
) -> dict[str, float]:
keys = sorted(set(before) & set(after))
if not keys:
return {"mean": 0.0, "max": 0.0, "n": 0.0}
dists = [
math.hypot(after[k][0] - before[k][0], after[k][1] - before[k][1])
for k in keys
]
return {
"mean": round(sum(dists) / len(dists), 2),
"max": round(max(dists), 2),
"n": float(len(dists)),
}
def _knobs_from_meta(state: LayoutState) -> dict[str, Any]:
raw = (state.meta or {}).get("_dual_mass") or {}
return dict(raw) if isinstance(raw, dict) else {}
def _resolve_mode(state: LayoutState, mode: str) -> str:
m = str(mode or "auto").strip().lower() or "auto"
if m in {"full", "seed", "pack", "repark"}:
return "full"
if m in {"refine", "sweep", "stabilize", "beautify"}:
return "refine"
# auto: beautify in place once the canvas is already spread / has meta.
if (state.meta or {}).get("dual_mass"):
return "refine"
xs = [p[0] for p in state.positions.values()]
ys = [p[1] for p in state.positions.values()]
if not xs:
return "refine"
span = max(max(xs) - min(xs), max(ys) - min(ys))
# Collapsed dump → optional repark; otherwise in-place beautify.
return "full" if span < 800.0 else "refine"
def _beautify_blocks(
st: LayoutState,
units: list[DualUnit],
params: LayoutParams,
) -> tuple[list[ComposeBlock], int, int, set[str], dict[str, int]]:
blocks: list[ComposeBlock] = []
unit_ok = 0
unit_fail = 0
covered: set[str] = set()
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
for u in units:
kind = classify_dual_unit(u)
kinds[kind] = kinds.get(kind, 0) + 1
local = beautify_dual_unit_positions(st, u, params)
links_u = _unit_links(u, st.adj)
x_u = count_edge_crossings(local, links_u) if links_u else 0
if x_u != 0:
unit_fail += 1
else:
unit_ok += 1
covered |= set(local.keys())
blocks.append(ComposeBlock(key=f"u{u.unit_id}", positions=local))
return blocks, unit_ok, unit_fail, covered, kinds
def _refine_beautify_onto_world(
st: LayoutState,
units: list[DualUnit],
params: LayoutParams,
) -> tuple[LayoutState, set[str], int, int, dict[str, int]]:
"""Beautify each unit rigidly onto current portal world coords (no re-orbit)."""
world = dict(st.positions)
moved: set[str] = set()
unit_ok = 0
unit_fail = 0
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
for u in units:
kind = classify_dual_unit(u)
kinds[kind] = kinds.get(kind, 0) + 1
local = beautify_dual_unit_positions(st, u, params)
links_u = _unit_links(u, st.adj)
x_u = count_edge_crossings(local, links_u) if links_u else 0
if x_u != 0:
unit_fail += 1
else:
unit_ok += 1
shared = [p for p in (u.portal_a, u.portal_b) if p in world and p in local]
if not shared:
continue
prefer = None
if len(shared) == 1 and len(local) >= 2:
cx = sum(p[0] for p in world.values()) / max(len(world), 1)
cy = sum(p[1] for p in world.values()) / max(len(world), 1)
prefer = (cx, cy)
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=list(st.links or []),
)
for nid, xy in aligned.items():
if nid in shared:
continue
if world.get(nid) != xy:
moved.add(nid)
world[nid] = xy
out = st.copy()
out.positions = world
return out, moved, unit_ok, unit_fail, kinds
def _ensure_mass_meta(
st: LayoutState,
units: list[DualUnit],
groups: list[dict[str, Any]] | None = None,
) -> tuple[LayoutState, dict[str, Any], list[dict[str, Any]]]:
grp = list(groups or [])
if not grp:
for u in units:
ids = sorted(u.member_ids())
grp.append(
{
"key": f"u{u.unit_id}",
"node_ids": ids,
"pivots": [u.portal_a, u.portal_b],
"cores": [u.portal_a, u.portal_b],
"soft": True,
}
)
mass = build_mass_field(st, units=units, groups=grp)
st = st.copy()
st.meta = dict(st.meta or {})
st.meta["mass_field"] = mass
packish = {
"mode": "dual_mass",
"mass_groups": mass.get("groups") or grp,
"rigid_groups": grp,
"soft": True,
}
st.meta["compose_views"] = attach_mass_to_compose_meta(packish, mass)
return st, mass, grp
def layout_dual_mass(
state: LayoutState,
params: LayoutParams | None = None,
*,
pad: float = 500.0,
fabric_bridges: bool = True,
angle_step: int = 30,
mass_merge: bool = False,
mass_iters: int = 12,
mode: str = "auto",
rounds: int = 1,
gravity_first: bool = False,
stable_drift: float = 120.0,
) -> OpResult:
"""Beautify dual-portal neighborhoods (petal / 回 / straight) in place."""
params = params or LayoutParams()
knobs = _knobs_from_meta(state)
if "mode" in knobs:
mode = str(knobs["mode"])
if "rounds" in knobs:
rounds = int(knobs["rounds"] or 1)
if "mass_iters" in knobs:
mass_iters = int(knobs["mass_iters"] or mass_iters)
if "pad" in knobs:
pad = float(knobs["pad"] or pad)
if "angle_step" in knobs:
angle_step = int(knobs["angle_step"] or angle_step)
if "mass_merge" in knobs:
v = knobs["mass_merge"]
mass_merge = bool(v) if not isinstance(v, str) else v.strip().lower() not in {
"0",
"false",
"no",
"off",
}
if "gravity_first" in knobs:
v = knobs["gravity_first"]
gravity_first = (
bool(v)
if not isinstance(v, str)
else v.strip().lower() in {"1", "true", "yes", "on"}
)
if "stable_drift" in knobs:
stable_drift = float(knobs["stable_drift"] or stable_drift)
st = state.copy()
units = find_dual_portal_units(st)
if not units:
return OpResult(
state=st,
moved=set(),
op="layout_dual_mass",
params={"units_n": 0},
note="no_dual_units",
)
resolved = _resolve_mode(st, mode)
rounds = max(1, min(int(rounds), 8))
moved: set[str] = set()
unit_ok = 0
unit_fail = 0
leftovers: list[str] = []
pack_meta: dict[str, Any] = {}
mass_meta: dict[str, Any] | None = None
kinds: dict[str, int] = {"petal": 0, "hui": 0, "straight": 0}
drifts: list[dict[str, float]] = []
stable = False
if gravity_first:
st, mass, groups = _ensure_mass_meta(st, units)
mop = mass_merge_round(
st,
groups=mass.get("groups") or groups,
iters=max(4, int(mass_iters) // 2),
protect_rigid="portals",
gravity_k=0.45,
core_pull_k=1.2,
)
st = mop.state
moved |= mop.moved
mass_meta = mop.params if isinstance(mop.params, dict) else None
for ri in range(rounds):
cens_before = _unit_centroids(st.positions, units)
pass_mode = resolved if ri == 0 else "refine"
if pass_mode == "full":
blocks, unit_ok, unit_fail, covered, kinds = _beautify_blocks(
st, units, params
)
leftovers = [
n
for n in st.positions
if n not in covered and not str(n).startswith("region:")
]
if leftovers:
blocks.append(
ComposeBlock(
key="misc",
positions=_misc_grid_positions(
leftovers, pitch=max(float(params.pitch), 180.0)
),
)
)
merged, pack_meta = orbit_pack_blocks(
blocks,
pad=float(pad),
links=list(st.links or []),
fabric_bridges=bool(fabric_bridges),
angle_step=int(angle_step),
)
if not merged:
return OpResult(
state=st,
moved=moved,
op="layout_dual_mass",
params={
"units_n": len(units),
"pack": pack_meta,
"mode": pass_mode,
"kinds": kinds,
},
note="pack_empty",
)
for nid, xy in merged.items():
if st.positions.get(nid) != xy:
moved.add(nid)
st.positions[nid] = xy
for nid in state.positions:
if nid not in st.positions:
st.positions[nid] = state.positions[nid]
groups = pack_meta.get("mass_groups") or pack_meta.get("rigid_groups") or []
st, mass, groups = _ensure_mass_meta(st, units, groups)
else:
st, eye_moved, unit_ok, unit_fail, kinds = _refine_beautify_onto_world(
st, units, params
)
moved |= eye_moved
st, mass, groups = _ensure_mass_meta(st, units)
if mass_merge and len(st.positions) >= 3:
mop = mass_merge_round(
st,
groups=mass.get("groups") or groups,
iters=max(1, int(mass_iters)),
protect_rigid="portals",
)
st = mop.state
moved |= mop.moved
mass_meta = mop.params if isinstance(mop.params, dict) else None
cens_after = _unit_centroids(st.positions, units)
drift = _centroid_drift(cens_before, cens_after)
drifts.append(drift)
if drift["n"] > 0 and drift["mean"] <= float(stable_drift) and ri > 0:
stable = True
break
resolved = "refine"
fop = fix_overlaps_local(st, params)
st = fop.state
moved |= fop.moved
report = {
"units_n": len(units),
"unit_ok": unit_ok,
"unit_fail_cross": unit_fail,
"kinds": kinds,
"mode": resolved if rounds == 1 else "multi",
"mode_first": _resolve_mode(state, mode),
"rounds_ran": len(drifts),
"rounds_requested": rounds,
"centroid_drift": drifts,
"stable": stable,
"stable_drift": float(stable_drift),
"misc_n": len(leftovers),
"pack_slots": pack_meta.get("slots"),
"pack_final_crossings": pack_meta.get("final_crossings"),
"mass_merge": mass_meta,
"mass_groups_n": len(
(st.meta.get("compose_views") or {}).get("mass_groups") or []
),
"gravity_first": bool(gravity_first),
"role": "beautify",
}
st.meta = dict(st.meta or {})
st.meta["dual_mass"] = report
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="layout_dual_mass",
params=report,
note=(
f"layout_dual_mass units={len(units)} kinds={kinds} "
f"mode={report['mode_first']} rounds={report['rounds_ran']} "
f"stable={stable} moved={len(moved)}"
),
)
def dual_mass_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pick dual_mass knobs from layout params."""
if not overrides:
return {}
out: dict[str, Any] = {}
for k in (
"mode",
"rounds",
"mass_iters",
"pad",
"angle_step",
"mass_merge",
"gravity_first",
"stable_drift",
"fabric_bridges",
):
if k in overrides:
out[k] = overrides[k]
return out

View file

@ -0,0 +1,627 @@
"""Dual-portal basic units: parallel lanes + 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.
"""
from __future__ import annotations
import math
from collections import deque
from dataclasses import dataclass, field
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.sugiyama import _find_two_portal_ring_groups
@dataclass
class DualUnit:
portal_a: str
portal_b: str
paths: list[list[str]]
tails: list[list[str]] = field(default_factory=list)
unit_id: int = 0
@property
def portals(self) -> tuple[str, str]:
return self.portal_a, self.portal_b
def member_ids(self) -> set[str]:
out = {self.portal_a, self.portal_b}
for p in self.paths:
out.update(p)
for t in self.tails:
out.update(t)
return out
def as_dict(self, names: dict[str, str] | None = None) -> dict[str, Any]:
names = names or {}
nest = len(self.paths)
return {
"unit_id": self.unit_id,
"portal_a": self.portal_a,
"portal_b": self.portal_b,
"portal_a_name": names.get(self.portal_a, self.portal_a),
"portal_b_name": names.get(self.portal_b, self.portal_b),
"path_count": nest,
"nest_depth": nest,
"tail_count": len(self.tails),
"node_count": len(self.member_ids()),
"node_ids": sorted(self.member_ids()),
"paths_len": [len(p) for p in self.paths],
"tail_lens": [len(t) for t in self.tails],
}
def _collect_tails(
state: LayoutState,
core: set[str],
*,
max_tail: int = 24,
) -> list[list[str]]:
"""deg≤2 chains hanging off unit core; stop at foreign high-degree nodes."""
adj, names = state.adj, state.names
claimed = set(core)
tails: list[list[str]] = []
seeds = []
for n in core:
for v in adj.get(n, ()):
if v in claimed:
continue
deg = len(adj.get(v, ()))
if deg <= 2 and state.layers.get(v) not in ("core", "agg"):
seeds.append((n, v))
for attach, stub in sorted(seeds, key=lambda x: names.get(x[1], x[1])):
if stub in claimed:
continue
chain = [stub]
claimed.add(stub)
prev, cur = attach, stub
while len(chain) < max_tail:
nbs = [v for v in adj.get(cur, ()) if v != prev and v not in claimed]
# Prefer continuing along deg≤2 corridor
forward = [
v
for v in nbs
if len(adj.get(v, ())) <= 2 and state.layers.get(v) not in ("core", "agg")
]
if len(forward) != 1:
break
prev, cur = cur, forward[0]
chain.append(cur)
claimed.add(cur)
tails.append(chain)
return tails
def find_dual_portal_units(
state: LayoutState,
*,
max_units: int = 120,
) -> list[DualUnit]:
"""Detect dual-portal eye units; interiors exclusive, portals may overlap."""
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"}
core_set = {n for n, ly in layers.items() if ly == "core"}
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],
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:
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)
)
for i, u in enumerate(units):
u.unit_id = i
return units
def _normalize_paths(
unit: DualUnit,
) -> list[list[str]]:
a, b = unit.portal_a, unit.portal_b
paths: list[list[str]] = []
for p in unit.paths:
pp = list(p)
if pp and pp[0] == b and pp[-1] == a:
pp = list(reversed(pp))
if len(pp) >= 2 and pp[0] == a and pp[-1] == b:
paths.append(pp)
paths.sort(key=len)
return paths
def classify_dual_unit(unit: DualUnit) -> str:
"""petal = multi-corridor (parallel lanes); else straight (no 回字)."""
paths = _normalize_paths(unit)
corridors = [p for p in paths if len(p) >= 3]
if len(corridors) >= 2:
return "petal"
return "straight"
def _place_chain_straight(
nodes: list[str],
*,
origin: tuple[float, float],
direction: tuple[float, float],
pitch: float,
pos: dict[str, tuple[float, float]],
) -> None:
dx, dy = direction
nrm = math.hypot(dx, dy) or 1.0
ux, uy = dx / nrm, dy / nrm
ox, oy = origin
for i, nid in enumerate(nodes):
if nid in pos:
continue
pos[nid] = (ox + ux * pitch * (i + 1), oy + uy * pitch * (i + 1))
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 回字).
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.
"""
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)
else:
# Single corridor / chain body — always straight between portals.
body: list[str] = []
if paths:
body = list(paths[0][1:-1])
if not body and unit.tails:
longest = max(unit.tails, key=len)
body = list(longest)
if body:
_place_chain_straight(
body,
origin=(-half, 0.0),
direction=(1.0, 0.0),
pitch=max(pitch, (2.0 * half) / (len(body) + 1)),
pos=pos,
)
# Tails: always straight H/V spurs (stack parallel if many).
used = set(pos)
for ti, chain in enumerate(unit.tails):
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
attach = None
for n in chain:
for v in state.adj.get(n, ()):
if v in pos and v not in chain:
attach = v
break
if attach is not None:
break
if attach is None:
attach = a if ti % 2 == 0 else b
ax, ay = pos[attach]
y_off = (ti % 3 - 1) * pitch * 0.45
if attach == a:
origin = (ax, ay + y_off)
direc = (-1.0, 0.0)
elif attach == b:
origin = (ax, ay + y_off)
direc = (1.0, 0.0)
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
)
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)
return pos
def layout_dual_unit_positions(
state: LayoutState,
unit: DualUnit,
params: LayoutParams | None = None,
) -> dict[str, tuple[float, float]]:
"""Unit local layout — lanes / 回 / straight beautify (zero-cross target)."""
return beautify_dual_unit_positions(state, unit, params)
def _uncross_unit(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
pinned: set[str],
*,
max_rounds: int = 80,
) -> dict[str, tuple[float, float]]:
"""Greedy: move lower-degree free endpoint vertically to kill crossings."""
from netx_topology_mcp.layout_metrics import segments_properly_intersect
out = dict(pos)
deg: dict[str, int] = {}
for u, v in links:
deg[u] = deg.get(u, 0) + 1
deg[v] = deg.get(v, 0) + 1
def crossings() -> list[tuple[int, int]]:
segs = []
for u, v in links:
if u in out and v in out:
segs.append((u, v, out[u], out[v]))
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]
c, d, pc, pd = segs[j]
if len({a, b, c, d}) < 4:
continue
if segments_properly_intersect(pa, pb, pc, pd):
bad.append((i, j))
return bad
for _ in range(max_rounds):
bad = crossings()
if not bad:
break
segs = [(u, v) for u, v in links if u in out and v in out]
progressed = False
for i, j in bad[:12]:
if i >= len(segs) or j >= len(segs):
continue
ends = list(segs[i]) + list(segs[j])
free = [n for n in ends if n not in pinned]
if not free:
continue
free.sort(key=lambda n: (deg.get(n, 0), n))
n = free[0]
x, y = out[n]
best = None
for dy in (
80.0,
-80.0,
160.0,
-160.0,
240.0,
-240.0,
320.0,
-320.0,
480.0,
-480.0,
):
for dx in (0.0, 40.0, -40.0, 80.0, -80.0):
trial = dict(out)
trial[n] = (x + dx, y + dy)
c0 = count_edge_crossings(out, links)
c1 = count_edge_crossings(trial, links)
if c1 < c0 and (best is None or c1 < best[0]):
best = (c1, x + dx, y + dy)
if best is not None:
out[n] = (best[1], best[2])
progressed = True
break
if not progressed:
break
return out
def layout_dual_unit(
state: LayoutState,
params: LayoutParams | None = None,
*,
unit: DualUnit | None = None,
unit_id: int | None = None,
portal_a: str | None = None,
portal_b: str | None = None,
) -> OpResult:
"""Layout the (single) dual-portal unit on this canvas; require crossings=0."""
params = params or LayoutParams()
units = find_dual_portal_units(state) if unit is None else [unit]
if unit_id is not None:
units = [u for u in units if u.unit_id == unit_id]
if portal_a and portal_b:
matched = [
u
for u in units
if {u.portal_a, u.portal_b} == {portal_a, portal_b}
]
if matched:
units = matched
else:
# Rebuild unit for the requested portals from this subgraph.
# Explicit portals: only forbid *other cores* (do NOT ban all agg —
# LPG-style canvases label almost every NE as agg, which yielded 0 paths).
forbid = {
n
for n, ly in state.layers.items()
if ly == "core" and n not in (portal_a, portal_b)
}
paths = cover_hub_paths(
portal_a, portal_b, state.adj, state.names, forbid=forbid
)
if len(paths) < 2:
paths = cover_hub_paths(
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,
)
]
if not units:
# Whole canvas as one unit attempt: pick best hub pair cover
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]
forbid = {
n
for n, ly in state.layers.items()
if ly == "core" and n not in (a, b)
}
paths = cover_hub_paths(a, b, state.adj, state.names, forbid=forbid)
if len(paths) < 2:
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 not units:
return OpResult(
state=state,
moved=set(),
op="layout_dual_unit",
params={},
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()))
pos = layout_dual_unit_positions(state, u, params)
out = state.copy()
for n, xy in pos.items():
if n in out.positions:
out.positions[n] = xy
parked = 0
extras = [n for n in out.positions if n not in pos]
if extras:
base_y = max((xy[1] for xy in pos.values()), default=0.0) + max(
params.lane, 300.0
)
for i, n in enumerate(sorted(extras, key=lambda x: state.names.get(x, x))):
out.positions[n] = (
-len(extras) * 0.5 * params.pitch + i * params.pitch,
base_y,
)
parked += 1
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).
pinned = {u.portal_a, u.portal_b}
out.positions = _uncross_unit(out.positions, unit_links, pinned)
x_unit = count_edge_crossings(out.positions, unit_links)
# Gentle overlap resolve only if still zero-cross; else skip.
stf = out
if x_unit == 0:
cand = fix_overlaps_local(out, params).state
cand.positions[u.portal_a] = pos[u.portal_a]
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
x_unit = count_edge_crossings(stf.positions, unit_links)
x = count_edge_crossings(stf.positions, stf.links)
moved = {
n
for n, p in stf.positions.items()
if n in state.positions and p != state.positions[n]
}
stf.meta = dict(stf.meta or {})
stf.meta["layout_dual_unit"] = {
"unit": u.as_dict(state.names),
"unit_crossings": x_unit,
"parked": parked,
}
accepted = x_unit == 0
return OpResult(
state=stf,
moved=moved,
op="layout_dual_unit",
params={
"unit": u.as_dict(state.names),
"unit_crossings": x_unit,
"global_crossings": x,
"accepted": accepted,
"parked": parked,
},
note=(
f"layout_dual_unit:paths={len(u.paths)} x=0"
if accepted
else f"dual_unit_crossings={x_unit}"
),
)
def dual_units_report(
state: LayoutState,
*,
max_units: int = 120,
) -> dict[str, Any]:
units = find_dual_portal_units(state, max_units=max_units)
covered: set[str] = set()
for u in units:
covered |= u.member_ids()
graph_n = len(state.positions) or len(state.names) or len(state.adj)
return {
"unit_count": len(units),
"max_units": max_units,
"covered_nodes": len(covered),
"graph_nodes": graph_n,
"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."
),
}
def dual_unit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("unit_id") is not None:
try:
out["unit_id"] = int(overrides["unit_id"])
except (TypeError, ValueError):
pass
for key in ("portal_a", "portal_b"):
v = overrides.get(key)
if v is not None and str(v).strip():
out[key] = str(v).strip()
return out

View file

@ -0,0 +1,295 @@
"""Fold a deg≤2 tentacle into the emptiest cheap sector around a hub.
Agent workflow (stage 2): nearest-ring angular sweep → push low-degree blockers
radially out → place the whole chain on an arc in that sector.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _deg(st: LayoutState) -> dict[str, int]:
return {nid: len(st.adj.get(nid, ())) for nid in st.positions}
def _chain_from(st: LayoutState, hub: str, stub: str, deg: dict[str, int]) -> list[str]:
path = [stub]
prev, cur = hub, stub
while True:
nxt = [x for x in st.adj.get(cur, ()) if x != prev and deg.get(x, 0) <= 2]
if len(nxt) != 1:
break
path.append(nxt[0])
prev, cur = cur, nxt[0]
return path
def _pick_hub(st: LayoutState, deg: dict[str, int], hub_id: str | None) -> str:
if hub_id and hub_id in st.positions:
return hub_id
# Prefer highest degree; break ties by name for stability.
ranked = sorted(
st.positions.keys(),
key=lambda n: (-deg.get(n, 0), st.names.get(n, ""), n),
)
if not ranked:
raise ValueError("fold_chain:empty_graph")
return ranked[0]
def _first_hop_toward(
st: LayoutState, hub: str, target: str
) -> str | None:
"""BFS: first neighbor of hub on a path to target."""
if target in st.adj.get(hub, ()):
return target
from collections import deque
q = deque([hub])
prev: dict[str, str | None] = {hub: None}
while q:
u = q.popleft()
for v in st.adj.get(u, ()):
if v in prev:
continue
prev[v] = u
if v == target:
# Walk back to hop after hub.
cur = target
while prev[cur] is not None and prev[cur] != hub:
cur = prev[cur]
return cur
q.append(v)
return None
def _pick_stub(
st: LayoutState,
hub: str,
deg: dict[str, int],
stub_id: str | None,
*,
min_len: int,
) -> tuple[str, list[str]]:
if stub_id and stub_id in st.positions:
hop = _first_hop_toward(st, hub, stub_id)
if hop is None:
raise ValueError(f"fold_chain:stub_unreachable:{stub_id}")
chain = _chain_from(st, hub, hop, deg)
return hop, chain
best: list[str] = []
best_stub = ""
for nb in sorted(st.adj.get(hub, ()), key=lambda n: (st.names.get(n, ""), n)):
if deg.get(nb, 0) > 2:
continue
chain = _chain_from(st, hub, nb, deg)
if len(chain) > len(best):
best = chain
best_stub = nb
if len(best) < min_len:
raise ValueError(
f"fold_chain:no_tentacle_from_hub:{hub}:need>={min_len},got={len(best)}"
)
return best_stub, best
def _ang_delta(a: float, b: float) -> float:
return abs(((a - b + 180.0) % 360.0) - 180.0)
def fold_chain_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pull fold_chain knobs from layout params overrides."""
o = overrides or {}
out: dict[str, Any] = {}
def _f(key: str) -> float | None:
if key not in o or o[key] is None:
return None
try:
return float(o[key])
except (TypeError, ValueError):
return None
def _i(key: str) -> int | None:
if key not in o or o[key] is None:
return None
try:
return int(o[key])
except (TypeError, ValueError):
return None
for key in ("hub_id", "stub_id", "chain_start"):
if key in o and o[key] is not None and str(o[key]).strip():
out[key] = str(o[key]).strip()
for key in ("prefer_mid_deg", "r_arc", "chord", "push_r", "min_gap_deg"):
v = _f(key)
if v is not None:
out[key] = v
for key in ("ring_k", "min_chain_len", "protect_deg_ge"):
v = _i(key)
if v is not None:
out[key] = v
if "clear_only" in o and o["clear_only"] is not None:
out["clear_only"] = str(o["clear_only"]).strip().lower() in {
"1",
"true",
"yes",
} or o["clear_only"] is True
if isinstance(o.get("protected_ids"), list):
out["protected_ids"] = [str(x).strip() for x in o["protected_ids"] if str(x).strip()]
return out
def fold_chain_into_sector(
st: LayoutState,
params: LayoutParams,
*,
hub_id: str | None = None,
stub_id: str | None = None,
chain_start: str | None = None,
prefer_mid_deg: float | None = None,
r_arc: float | None = None,
chord: float | None = None,
push_r: float | None = None,
ring_k: int = 14,
min_gap_deg: float = 26.0,
min_chain_len: int = 3,
clear_only: bool = False,
protected_ids: list[str] | None = None,
protect_deg_ge: int = 4,
) -> OpResult:
"""Sweep nearest ring, clear cheapest large gap, fold deg≤2 chain onto arc."""
st = st.copy()
deg = _deg(st)
hub = _pick_hub(st, deg, hub_id)
stub = stub_id or chain_start
stub, chain = _pick_stub(st, hub, deg, stub, min_len=int(min_chain_len))
chain_set = set(chain)
hx, hy = st.positions[hub]
protected = {hub, *chain_set}
for pid in protected_ids or []:
if str(pid).strip():
protected.add(str(pid).strip())
# Auto-protect high-degree nodes near hub
for nid, d in deg.items():
if d >= int(protect_deg_ge):
protected.add(nid)
others: list[tuple[float, float, str]] = []
for nid, (x, y) in st.positions.items():
if nid == hub or nid in chain_set:
continue
r = math.hypot(x - hx, y - hy)
ang = math.degrees(math.atan2(y - hy, x - hx)) % 360.0
others.append((r, ang, nid))
others.sort()
k = max(4, min(int(ring_k), len(others)))
ring = sorted(others[:k], key=lambda t: t[1]) if others else []
gaps: list[tuple[float, float, float, float, str, str]] = []
if len(ring) >= 2:
for i in range(len(ring)):
a0 = ring[i][1]
a1 = ring[(i + 1) % len(ring)][1]
gap = (a1 - a0) % 360.0
mid = (a0 + gap / 2.0) % 360.0
lid, rid = ring[i][2], ring[(i + 1) % len(ring)][2]
cost = 0.0
for bid in (lid, rid):
if bid in protected or deg.get(bid, 0) >= int(protect_deg_ge):
cost += 4.0
elif bid in st.adj.get(hub, ()):
cost += 1.0
gaps.append((cost, -gap, mid, gap, lid, rid))
gaps.sort()
else:
# Empty / sparse: open southish default sector.
mid = float(prefer_mid_deg) if prefer_mid_deg is not None else 90.0
gaps = [(0.0, -60.0, mid % 360.0, 60.0, "", "")]
def mid_penalty(mid: float) -> float:
if prefer_mid_deg is None:
return 0.0
return _ang_delta(mid, float(prefer_mid_deg)) / 30.0
cand = [g for g in gaps if g[3] >= float(min_gap_deg)] or gaps[:3]
cand.sort(key=lambda g: (g[0], mid_penalty(g[2]), -g[3]))
cost, _, sector_mid, best_gap, lid, rid = cand[0]
sector_half = max(min(best_gap / 2.0 - 2.0, 38.0), 28.0)
target_nn = float(params.target_nn or 155.0)
r_push = float(push_r) if push_r is not None else max(480.0, target_nn * 3.0)
arc_r = float(r_arc) if r_arc is not None else max(280.0, target_nn * 2.0)
chord_len = float(chord) if chord is not None else max(170.0, target_nn * 1.15)
moved: set[str] = set()
pushed: list[str] = []
def in_sector(ang: float) -> bool:
return _ang_delta(ang, sector_mid) <= sector_half
for r, ang, nid in others:
if not in_sector(ang) or nid in protected or deg.get(nid, 0) >= 3:
continue
if r >= r_push:
continue
nr = max(r_push, r + 200.0)
rad = math.radians(ang)
st.positions[nid] = (hx + nr * math.cos(rad), hy + nr * math.sin(rad))
moved.add(nid)
pushed.append(nid)
folded: list[str] = []
if not clear_only:
n = len(chain)
d_ang = math.degrees(2 * math.asin(min(0.95, chord_len / (2.0 * arc_r))))
span = d_ang * (n - 1)
a0 = sector_mid - span / 2.0
for i, nid in enumerate(chain):
ang = math.radians((a0 + i * d_ang) % 360.0)
st.positions[nid] = (hx + arc_r * math.cos(ang), hy + arc_r * math.sin(ang))
moved.add(nid)
folded.append(nid)
st.last_moved = set(moved)
st.meta["fold_chain"] = {
"hub_id": hub,
"stub_id": stub,
"chain": chain,
"sector_mid": round(sector_mid, 2),
"sector_half": round(sector_half, 2),
"gap_deg": round(best_gap, 2),
"gap_cost": cost,
"gap_bounds": [lid, rid],
"prefer_mid_deg": prefer_mid_deg,
"r_arc": arc_r,
"chord": chord_len,
"push_r": r_push,
"pushed": pushed,
"folded": folded,
"clear_only": bool(clear_only),
"ring_k": k,
}
note = (
f"fold_chain hub={hub[:8]} chain={len(chain)} "
f"mid={sector_mid:.0f} gap={best_gap:.0f} pushed={len(pushed)}"
)
return OpResult(
state=st,
moved=moved,
op="fold_chain_into_sector",
params={
"hub_id": hub,
"stub_id": stub,
"sector_mid": sector_mid,
"gap_deg": best_gap,
"clear_only": bool(clear_only),
},
note=note,
)

View file

@ -0,0 +1,599 @@
"""Semi-rigid force densify: bidirectional edge springs + local repulsion.
Metro util is killed by long bridges; uniform pack crushes nn into overlaps,
rigid shrink spikes crossings. This runs a few damped force iterations:
- **Edge spring (双向)**: Hookean toward ``ideal_len`` — too long → pull
together, too short → push apart (push can also densify by expanding
crushed corridors into readable spacing while gravity/long edges shrink
the global bbox).
- **Node repulse**: spatial-hash neighbors push apart below ~1.35×nn_floor.
- **Gravity**: free nodes gently pull toward free-centroid (bbox extremities).
- **Semi-rigid**: dual-unit exclusive bodies share a group translation
(``rigid_strength``) plus optional per-node deform residual.
- **Mass / weights**: hubs move less / repulse stronger; leaves follow springs.
Gates: util or bbox gain within soft crossing budget; then polish.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import (
frozen_ids_for_protect,
groups_from_compose_meta,
)
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6
_DEFAULT_ITERS = 12
_DEFAULT_STEP = 0.28
_DEFAULT_MAX_STEP = 100.0
_DEFAULT_RIGID = 0.9
_DEFAULT_DEFORM = 0.12
_DEFAULT_ATTRACT = 0.85
_DEFAULT_REPULSE = 1.35
_DEFAULT_NN_FLOOR = 90.0
# Only pull edges longer than this multiple of nn_floor (metro bridges).
_IDEAL_NN_MUL = 6.0
_MAX_EDGE_PULL = 90.0
def _bbox_area(pos: dict[str, tuple[float, float]]) -> float:
pts = [
p
for p in pos.values()
if abs(p[0]) <= _COORD_ABS_MAX
and abs(p[1]) <= _COORD_ABS_MAX
and math.isfinite(p[0])
and math.isfinite(p[1])
]
if len(pts) < 2:
return 1.0
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
def _parse_groups(
groups: list[dict[str, Any]],
valid: set[str],
) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
raw: list[tuple[str, list[str], list[str]]] = []
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
pivots = [str(p) for p in (g.get("pivots") or []) if str(p) in valid]
for n in members:
counts[n] = counts.get(n, 0) + 1
raw.append((key, members, pivots))
shared = {n for n, c in counts.items() if c > 1}
out: list[dict[str, Any]] = []
for key, members, pivots in raw:
exclusive = [n for n in members if n not in shared]
out.append(
{
"key": key,
"members": members,
"pivots": pivots or [n for n in members if n in shared],
"exclusive": exclusive,
"shared": [n for n in members if n in shared],
}
)
return out
def _mass_and_weights(
st: LayoutState,
ids: list[str],
) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
"""Return mass, attract_w, repulse_w per node."""
mass: dict[str, float] = {}
att: dict[str, float] = {}
rep: dict[str, float] = {}
for n in ids:
deg = len(st.adj.get(n, ()))
layer = str(st.layers.get(n) or "")
# Hubs = heavy + strong territory; leaves = light + strong spring follow.
m = 1.0 + 0.28 * float(deg)
if layer in ("core", "agg") or deg >= 8:
m *= 1.35
a_w = 0.65
r_w = 1.55
elif deg <= 2:
m *= 0.75
a_w = 1.35
r_w = 0.85
else:
a_w = 1.0
r_w = 1.0
mass[n] = m
att[n] = a_w
rep[n] = r_w
return mass, att, rep
def _spatial_bins(
pos: dict[str, tuple[float, float]],
ids: list[str],
cell: float,
) -> dict[tuple[int, int], list[str]]:
bins: dict[tuple[int, int], list[str]] = {}
inv = 1.0 / max(cell, 1e-6)
for n in ids:
x, y = pos[n]
key = (int(math.floor(x * inv)), int(math.floor(y * inv)))
bins.setdefault(key, []).append(n)
return bins
def _accumulate_forces(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
ids: list[str],
*,
ideal_len: float,
nn_floor: float,
attract_k: float,
repulse_k: float,
gravity_k: float,
att_w: dict[str, float],
rep_w: dict[str, float],
frozen: set[str],
) -> dict[str, tuple[float, float]]:
fx = {n: 0.0 for n in ids}
fy = {n: 0.0 for n in ids}
id_set = set(ids)
# Weak gravity toward free-node centroid (shrinks bbox extremities).
free = [n for n in ids if n not in frozen and n in pos]
if gravity_k > 1e-9 and len(free) >= 2:
gcx = sum(pos[n][0] for n in free) / len(free)
gcy = sum(pos[n][1] for n in free) / len(free)
for n in free:
dx, dy = gcx - pos[n][0], gcy - pos[n][1]
dist = math.hypot(dx, dy)
if dist < nn_floor * 2.0:
continue
# Stronger pull for outliers far from center.
mag = gravity_k * min(dist * 0.02, _MAX_EDGE_PULL * 0.8)
fx[n] += (dx / dist) * mag
fy[n] += (dy / dist) * mag
# Bidirectional edge spring: L>ideal pull; L<ideal push (Hookean).
# Dead-band around ideal avoids jitter; push uses same attract_k scale
# (repulse_k still handles non-adjacent crowding).
dead = max(ideal_len * 0.06, nn_floor * 0.25)
for a, b in links:
if a not in pos or b not in pos:
continue
if a not in id_set and b not in id_set:
continue
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
if L < 1e-6:
# Coincident endpoints: invent a tiny axis so push can separate.
dx, dy, L = 1.0, 0.0, 1.0
delta = L - ideal_len
if abs(delta) <= dead:
continue
ux, uy = dx / L, dy / L
# Cap |δ| so ultra-long bridges / crushed stubs don't explode.
capped = max(-_MAX_EDGE_PULL * 8.0, min(delta, _MAX_EDGE_PULL * 12.0))
# Sign: positive δ → pull a toward b (force on a along +u);
# negative δ → push a away from b (force on a along -u).
mag = attract_k * min(abs(capped) * 0.045, _MAX_EDGE_PULL)
if capped < 0:
# Slightly softer push than pull (avoid blasting clusters open).
mag *= 0.75
sign = 1.0 if capped > 0 else -1.0
wa = att_w.get(a, 1.0) if a not in frozen else 0.0
wb = att_w.get(b, 1.0) if b not in frozen else 0.0
wsum = wa + wb
if wsum <= 1e-12:
continue
fa = mag * (wa / wsum)
fb = mag * (wb / wsum)
# On a: toward b when pulling (sign>0); away when pushing (sign<0).
if a in fx and a not in frozen:
fx[a] += ux * fa * sign
fy[a] += uy * fa * sign
if b in fx and b not in frozen:
fx[b] -= ux * fb * sign
fy[b] -= uy * fb * sign
# Local repulsion — tight floor only (avoid pushing bbox outward).
r0 = max(nn_floor * 1.35, 100.0)
cell = max(r0, 1.0)
bins = _spatial_bins(pos, ids, cell)
for (cx, cy), bucket in bins.items():
neighbors: list[str] = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
neighbors.extend(bins.get((cx + dx, cy + dy), ()))
seen: set[tuple[str, str]] = set()
for a in bucket:
if a in frozen:
continue
ax, ay = pos[a]
for b in neighbors:
if b == a or b in frozen:
continue
pair = (a, b) if a < b else (b, a)
if pair in seen:
continue
seen.add(pair)
bx, by = pos[b]
dx, dy = ax - bx, ay - by
d = math.hypot(dx, dy)
if d >= r0 or d < 1e-6:
continue
ux, uy = dx / d, dy / d
mag = repulse_k * min(r0 - d, r0) * 0.4 * (
(rep_w.get(a, 1.0) + rep_w.get(b, 1.0)) * 0.5
)
mag = min(mag, _MAX_EDGE_PULL * 0.9)
if a not in frozen:
fx[a] += ux * mag
fy[a] += uy * mag
if b not in frozen:
fx[b] -= ux * mag
fy[b] -= uy * mag
return {n: (fx[n], fy[n]) for n in ids}
def _apply_semi_rigid(
deltas: dict[str, tuple[float, float]],
groups: list[dict[str, Any]],
frozen: set[str],
*,
rigid_strength: float,
deform: float,
) -> dict[str, tuple[float, float]]:
"""Blend group mean translation with per-node residual."""
out = dict(deltas)
rs = max(0.0, min(1.0, float(rigid_strength)))
df = max(0.0, min(1.0, float(deform)))
# When rigid=1 and deform=0 → pure mean translate for exclusive.
for g in groups:
excl = [n for n in g.get("exclusive") or [] if n in out and n not in frozen]
if len(excl) < 2:
continue
mx = sum(out[n][0] for n in excl) / len(excl)
my = sum(out[n][1] for n in excl) / len(excl)
for n in excl:
ix, iy = out[n]
# rs=1,df=0 → pure group translate; df>0 allows limited shape stretch.
out[n] = (
rs * mx + (1.0 - rs) * ix + df * rs * (ix - mx),
rs * my + (1.0 - rs) * iy + df * rs * (iy - my),
)
return out
def force_densify_round(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
iters: int = _DEFAULT_ITERS,
step: float = _DEFAULT_STEP,
max_step: float = _DEFAULT_MAX_STEP,
ideal_len: float | None = None,
nn_floor: float = _DEFAULT_NN_FLOOR,
attract_k: float = _DEFAULT_ATTRACT,
repulse_k: float = _DEFAULT_REPULSE,
gravity_k: float = 0.55,
rigid_strength: float = _DEFAULT_RIGID,
deform: float = _DEFAULT_DEFORM,
protect_rigid: bool | str = "portals",
x_slack: int | None = None,
damping: float = 0.85,
) -> OpResult:
"""Run damped attract/repulse densify with semi-rigid dual-unit bodies."""
from netx_topology_mcp.layout_jobs import (
raise_if_cancelled,
report_progress,
touch_heartbeat,
)
st = state.copy()
groups = groups if groups is not None else groups_from_compose_meta(
(st.meta or {}).get("compose_views")
)
valid = {
n
for n, (x, y) in st.positions.items()
if abs(x) <= _COORD_ABS_MAX
and abs(y) <= _COORD_ABS_MAX
and math.isfinite(x)
and math.isfinite(y)
}
ids = sorted(valid)
if len(ids) < 3:
return OpResult(
state=st, moved=set(), op="force_densify", note="force_densify:too_few"
)
parsed = _parse_groups(groups or [], valid)
frozen = frozen_ids_for_protect(st, protect_rigid) if protect_rigid not in (
False,
"false",
"off",
"none",
"0",
) else set()
# Pin region phantoms.
for n in list(ids):
if str(n).startswith("region:"):
frozen.add(n)
before = score_state(st, fast=True)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = len(overlapping_nodes(st))
area0 = _bbox_area(st.positions)
slack = (
max(12, int(before_x * 0.1))
if x_slack is None
else max(0, int(x_slack))
)
# Default ideal targets long corridors only (not near-nn edges).
ideal = (
float(ideal_len)
if ideal_len is not None
else max(float(nn_floor) * _IDEAL_NN_MUL, 520.0)
)
mass, att_w, rep_w = _mass_and_weights(st, ids)
pos = {n: (float(st.positions[n][0]), float(st.positions[n][1])) for n in ids}
links = list(st.links)
n_iters = max(1, min(40, int(iters)))
step_k = max(0.05, min(1.0, float(step)))
cap = max(20.0, float(max_step))
damp = max(0.2, min(0.98, float(damping)))
report_progress(
"force_densify",
pct=48.0,
message=(
f"iters={n_iters} ideal={ideal:.0f} "
f"rigid={rigid_strength:.2f} deform={deform:.2f}"
),
nodes=len(ids),
groups=len(parsed),
)
vel = {n: (0.0, 0.0) for n in ids}
moved: set[str] = set()
# Keep best checkpoint within x_slack (smaller bbox / higher util proxy).
_cell = 90.0 * 90.0
util_proxy0 = len(ids) * _cell / max(area0, 1e-6)
best_pos = dict(pos)
# tier=2 within slack, tier=1 soft densify, tier=0 baseline
best_key = (0, util_proxy0, -area0, -before_x)
best_meta_x = before_x
best_meta_util = util_proxy0
best_iter = 0
from netx_topology_mcp.layout_metrics import count_edge_crossings
for it in range(n_iters):
raise_if_cancelled()
touch_heartbeat()
if it % 2 == 0:
report_progress(
"force_densify",
pct=48.0 + 24.0 * (it / max(n_iters, 1)),
message=f"iter {it + 1}/{n_iters}",
iter=it + 1,
)
forces = _accumulate_forces(
pos,
links,
ids,
ideal_len=ideal,
nn_floor=float(nn_floor),
attract_k=float(attract_k),
repulse_k=float(repulse_k),
gravity_k=float(gravity_k),
att_w=att_w,
rep_w=rep_w,
frozen=frozen,
)
deltas: dict[str, tuple[float, float]] = {}
for n in ids:
if n in frozen:
deltas[n] = (0.0, 0.0)
continue
fx, fy = forces[n]
m = max(mass.get(n, 1.0), 0.2)
dx, dy = (fx / m) * step_k, (fy / m) * step_k
vx = damp * vel[n][0] + dx
vy = damp * vel[n][1] + dy
spd = math.hypot(vx, vy)
if spd > cap:
s = cap / spd
vx, vy = vx * s, vy * s
vel[n] = (vx, vy)
deltas[n] = (vx, vy)
deltas = _apply_semi_rigid(
deltas,
parsed,
frozen,
rigid_strength=rigid_strength,
deform=deform,
)
for n, (dx, dy) in deltas.items():
if n in frozen:
continue
spd = math.hypot(dx, dy)
if spd > cap:
s = cap / spd
dx, dy = dx * s, dy * s
if abs(dx) + abs(dy) < 1e-4:
continue
x, y = pos[n]
pos[n] = (x + dx, y + dy)
moved.add(n)
# Checkpoint every 2 iters (and last). Prefer within x_slack; else
# still keep densifying states (metro: short x rise → polish after).
if it % 2 == 1 or it == n_iters - 1:
raise_if_cancelled()
touch_heartbeat()
x_now = count_edge_crossings(pos, links)
area_now = _bbox_area(pos)
util_proxy = len(ids) * _cell / max(area_now, 1e-6)
densified = util_proxy > util_proxy0 * 1.002 or area_now < area0 * 0.998
if not densified:
continue
soft_cap = before_x + max(slack * 3, int(before_x * 0.45) + 40)
if x_now > soft_cap:
continue
tier = 2 if x_now <= before_x + slack else 1
key = (tier, util_proxy, -area_now, -x_now)
if key > best_key:
best_key = key
best_pos = dict(pos)
best_meta_x = x_now
best_meta_util = util_proxy
best_iter = it + 1
pos = best_pos
st.positions = {**st.positions, **pos}
fin = score_state(st, fast=True)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
end_ov = len(overlapping_nodes(st))
area1 = _bbox_area(st.positions)
area_ratio = area0 / max(area1, 1e-6)
util_up = end_util > before_util + 1e-6
area_up = area_ratio >= 1.004
x_down = end_x < before_x
# Soft final gate: densify may spend up to soft_cap crossings; polish next.
soft_final = before_x + max(slack * 3, int(before_x * 0.45) + 40)
x_ok = end_x <= soft_final
gain = util_up or area_up or x_down
meta = {
"reverted": False,
"start_util": before_util,
"end_util": end_util,
"start_crossings": before_x,
"end_crossings": end_x,
"start_overlaps": before_ov,
"end_overlaps": end_ov,
"bbox_area_ratio": round(area_ratio, 4),
"moved_n": len(moved),
"iters": n_iters,
"best_iter": best_iter,
"ideal_len": round(ideal, 1),
"nn_floor": float(nn_floor),
"attract_k": float(attract_k),
"repulse_k": float(repulse_k),
"gravity_k": float(gravity_k),
"rigid_strength": float(rigid_strength),
"deform": float(deform),
"x_slack": slack,
"x_soft_cap": soft_final,
"checkpoint_x": best_meta_x,
"checkpoint_util_proxy": round(best_meta_util, 6),
"protect_rigid": (
protect_rigid
if isinstance(protect_rigid, str)
else ("portals" if protect_rigid else "off")
),
"groups_n": len(parsed),
"frozen_n": len(frozen),
}
def _revert(reason: str) -> OpResult:
meta["reverted"] = True
meta["reason"] = reason
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["force_densify"] = meta
return OpResult(
state=st0,
moved=set(),
op="force_densify",
params=meta,
note=f"force_densify:reverted {reason}",
)
if best_iter == 0 and not gain:
return _revert("no_gain")
if not x_ok:
return _revert("crossing_rise")
if not gain:
return _revert("no_gain")
# Residual overlaps OK if densified — layout_tool ensure_zero_overlap repairs.
st.meta = dict(st.meta or {})
st.meta["force_densify"] = meta
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="force_densify",
params=meta,
note=(
f"force_densify util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} area×{area_ratio:.3f} moved={len(moved)}"
),
)
def force_densify_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
for key, lo, hi, cast in (
("iters", 1, 40, int),
("step", 0.05, 1.0, float),
("max_step", 20.0, 800.0, float),
("ideal_len", 40.0, 4000.0, float),
("nn_floor", 40.0, 400.0, float),
("attract_k", 0.05, 4.0, float),
("repulse_k", 0.05, 4.0, float),
("gravity_k", 0.0, 3.0, float),
("rigid_strength", 0.0, 1.0, float),
("deform", 0.0, 1.0, float),
("damping", 0.2, 0.98, float),
("x_slack", 0, 5000, int),
):
if o.get(key) is None:
continue
try:
v = cast(o[key])
out[key] = max(lo, min(hi, v))
except (TypeError, ValueError):
pass
if "protect_rigid" in o:
v = o.get("protect_rigid")
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v or "portals").strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = "portals"
groups = o.get("rigid_groups") or o.get("_rigid_groups")
if isinstance(groups, list):
out["groups"] = groups
return out

View file

@ -0,0 +1,209 @@
"""Graph helpers for layout ops."""
from __future__ import annotations
import re
from collections import deque
_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()]
def bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
return min(xs), min(ys), max(xs), max(ys)
def connected_components(nodes: set[str], g: dict[str, set[str]]) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for s in sorted(nodes):
if s in seen:
continue
q = deque([s])
seen.add(s)
comp: list[str] = []
while q:
u = q.popleft()
comp.append(u)
for v in g.get(u, ()):
if v in nodes and v not in seen:
seen.add(v)
q.append(v)
out.append(comp)
out.sort(key=lambda c: -len(c))
return out
def order_ans(
ans: list[str], ens: set[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
if len(ans) <= 1:
return list(ans)
def dist(a: str, b: str) -> int:
q = deque([(a, 0)])
seen = {a}
while q:
u, d = q.popleft()
for v in g.get(u, ()):
if v == b:
return d + 1
if v in seen:
continue
if v in ens or v in ans:
seen.add(v)
q.append((v, d + 1))
return 10**6
start = max(ans, key=lambda a: (len([x for x in g.get(a, ()) if x in ens]), names[a]))
seq = [start]
rest = set(ans) - {start}
while rest:
last = seq[-1]
nxt = min(rest, key=lambda a: (dist(last, a), names[a]))
seq.append(nxt)
rest.remove(nxt)
return seq
def chain_order(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Covering walk (may include all nodes). Prefer spine_backbone for true spine."""
s = set(nodes)
if not s:
return []
sub = {n: [x for x in g.get(n, ()) if x in s] for n in nodes}
ends = [n for n in nodes if len(sub[n]) <= 1] or list(nodes)
start = sorted(ends, key=lambda n: (len(sub[n]), names.get(n, n)))[0]
ordered = [start]
prev = None
cur = start
seen = {start}
while len(ordered) < len(nodes):
nxts = [x for x in sub[cur] if x != prev and x not in seen]
if not nxts:
cand = [v for u in ordered for v in sub[u] if v not in seen]
if not cand:
rest = [n for n in nodes if n not in seen]
if not rest:
break
nxts = [sorted(rest, key=lambda n: names.get(n, n))[0]]
else:
nxts = [sorted(cand, key=lambda n: names.get(n, n))[0]]
prev = None
nxts.sort(key=lambda n: names.get(n, n))
prev, cur = cur, nxts[0]
ordered.append(cur)
seen.add(cur)
return ordered
def spine_backbone(
nodes: list[str], g: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
"""Longest shortest-path among `nodes` (approx diameter). Side branches stay off-spine."""
s = set(nodes)
if not s:
return []
if len(s) == 1:
return list(s)
def bfs(src: str) -> tuple[dict[str, int], dict[str, str | None]]:
dist = {src: 0}
parent: dict[str, str | None] = {src: None}
q = deque([src])
while q:
u = q.popleft()
for v in g.get(u, ()):
if v not in s or v in dist:
continue
dist[v] = dist[u] + 1
parent[v] = u
q.append(v)
return dist, parent
# eccentricity from an arbitrary end, then from farthest
seed = sorted(nodes, key=lambda n: (len([x for x in g.get(n, ()) if x in s]), names.get(n, n)))[0]
d1, _ = bfs(seed)
a = max(d1, key=lambda n: (d1[n], names.get(n, n)))
d2, parent = bfs(a)
b = max(d2, key=lambda n: (d2[n], names.get(n, n)))
path = [b]
while parent.get(path[-1]) is not None:
path.append(parent[path[-1]] or "")
path.reverse()
return [n for n in path if n]
def build_state_from_nodes_edges(
nodes: list[dict], edges: list[dict]
) -> "LayoutState": # noqa: F821
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links
names: dict[str, str] = {}
layers: dict[str, str] = {}
ids: list[str] = []
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
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"))
adj: dict[str, set[str]] = {i: set() for i in ids}
links = collapse_links(edges)
for a, b in links:
if a in adj and b in adj:
adj[a].add(b)
adj[b].add(a)
positions: dict[str, tuple[float, float]] = {}
for n in nodes:
if not isinstance(n, dict):
continue
fid = str(n.get("fabric_node_id") or n.get("id") or "").strip()
if not fid:
continue
if n.get("x") is not None and n.get("y") is not None:
try:
positions[fid] = (float(n["x"]), float(n["y"]))
except (TypeError, ValueError):
pass
return LayoutState(
positions=positions,
names=names,
layers=layers,
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,591 @@
"""Top-down hierarchy layout: contract → order sectors → expand.
At each hub level we only score crossings on the *contracted* graph
(parent + one representative per child territory). Internal edges of
children are ignored until that child is expanded. Accept/reject is
driven by contracted crossings only — global leaf crossings may rise
temporarily; that is intentional (fix lower levels later).
Expand is a *rigid* polar remapping of each stub territory onto the new
sector angle so local corridor geometry survives the reorder.
"""
from __future__ import annotations
import math
from collections import deque
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import pick_hub_seeds
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:
hx, hy = pos[hub]
x, y = pos[nid]
return math.atan2(y - hy, x - hx)
def _stub_territories(
hub: str,
members: set[str],
adj: dict[str, set[str]],
pinned: set[str],
) -> tuple[list[str], dict[str, str]]:
stubs = [
n
for n in adj.get(hub, ())
if n in members and n not in pinned
]
owner: dict[str, str] = {}
q: deque[str] = deque()
for stub in stubs:
owner[stub] = stub
q.append(stub)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v not in members or v == hub or v in owner or v in pinned:
continue
owner[v] = owner[u]
q.append(v)
return stubs, owner
def _owner_hub_map(
adj: dict[str, set[str]],
hubs: set[str],
) -> dict[str, str]:
"""BFS Voronoi: every node maps to nearest hub (ties: first reached)."""
owner: dict[str, str] = {h: h for h in hubs}
q: deque[str] = deque(hubs)
while q:
u = q.popleft()
ou = owner[u]
for v in adj.get(u, ()):
if v in owner:
continue
owner[v] = ou
q.append(v)
return owner
def _contracted_links(
hub: str,
stubs: list[str],
owner: dict[str, str],
adj: dict[str, set[str]],
pinned: set[str],
*,
hub_of: dict[str, str] | None = None,
) -> list[tuple[str, str]]:
"""Edges among {hub} ∪ stubs after contracting each stub territory.
Foreign endpoints outside this hub's territories collapse to their
owning hub (Voronoi), so cross-block edges are not dropped from the
contracted objective.
"""
terr: dict[str, set[str]] = {s: {s} for s in stubs}
for n, o in owner.items():
terr.setdefault(o, set()).add(n)
node_to_rep: dict[str, str] = {hub: hub}
for s, nodes in terr.items():
for n in nodes:
node_to_rep[n] = s
links: set[tuple[str, str]] = set()
for s in stubs:
links.add(tuple(sorted((hub, s))))
for s, nodes in terr.items():
for n in nodes:
for v in adj.get(n, ()):
if v == hub:
continue
if v in node_to_rep:
r = node_to_rep[v]
if r != s:
links.add(tuple(sorted((s, r))))
elif v in pinned:
links.add(tuple(sorted((s, v))))
elif hub_of is not None and v in hub_of:
fh = hub_of[v]
if fh != hub:
links.add(tuple(sorted((s, fh))))
return sorted(links)
def _contracted_crossings(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]] | None = None,
a0: float | None = None,
a1: float | None = None,
) -> int:
"""Place stub reps on an arc; count crossings of contracted links only."""
hx, hy = hub_xy
n = len(stubs_ord)
if n == 0:
return 0
if a0 is None or a1 is None:
a0, a1 = -math.pi * 0.85, math.pi * 0.85
pos: dict[str, tuple[float, float]] = {hub: (hx, hy)}
if foreign_pos:
pos.update(foreign_pos)
for i, s in enumerate(stubs_ord):
mid = 0.5 * (a0 + a1) if n == 1 else a0 + (a1 - a0) * i / (n - 1)
pos[s] = (hx + math.cos(mid) * radius, hy + math.sin(mid) * radius)
segs = [(a, b) for a, b in links if a in pos and b in pos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = pos[a], pos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
c += 1
return c
def _score_order(
hub: str,
stubs_ord: list[str],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> int:
return _contracted_crossings(
hub,
stubs_ord,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
def _candidate_orders(stubs: list[str], pos: dict[str, tuple[float, float]], hub: str) -> list[list[str]]:
if not stubs:
return []
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
orders: list[list[str]] = []
seen: set[tuple[str, ...]] = set()
def _add(ord_: list[str]) -> None:
key = tuple(ord_)
if key not in seen:
seen.add(key)
orders.append(ord_)
for base in (geo, list(reversed(geo))):
for k in range(len(base)):
_add(base[k:] + base[:k])
# Adjacent swaps from geo.
for i in range(len(geo) - 1):
trial = list(geo)
trial[i], trial[i + 1] = trial[i + 1], trial[i]
_add(trial)
return orders
def _best_order_2opt(
hub: str,
stubs: list[str],
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
radius: float,
hub_xy: tuple[float, float],
foreign_pos: dict[str, tuple[float, float]],
a0: float,
a1: float,
) -> tuple[list[str], int]:
"""Greedy 2-opt / insert bakeoff seeded from candidate rotations."""
seeds = _candidate_orders(stubs, pos, hub)
best_ord = sorted(stubs, key=lambda s: _ang(pos, hub, s))
best_cc = _score_order(
hub, best_ord, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
for seed in seeds:
cur = list(seed)
cur_cc = _score_order(
hub, cur, links, radius=radius, hub_xy=hub_xy, foreign_pos=foreign_pos, a0=a0, a1=a1
)
improved = True
rounds = 0
while improved and rounds < 12:
improved = False
rounds += 1
n = len(cur)
for i in range(n):
for j in range(i + 2, n + (0 if i == 0 else 1)):
# reverse segment [i:j]
jj = j if j <= n else n
if jj - i < 2:
continue
trial = cur[:i] + list(reversed(cur[i:jj])) + cur[jj:]
if len(trial) != n:
continue
cc = _score_order(
hub,
trial,
links,
radius=radius,
hub_xy=hub_xy,
foreign_pos=foreign_pos,
a0=a0,
a1=a1,
)
if cc < cur_cc:
cur, cur_cc = trial, cc
improved = True
break
if improved:
break
if cur_cc < best_cc:
best_cc = cur_cc
best_ord = cur
return best_ord, best_cc
def _contracted_crossings_at_pos(
hub: str,
stubs: list[str],
links: list[tuple[str, str]],
pos: dict[str, tuple[float, float]],
foreign_pos: dict[str, tuple[float, float]],
) -> int:
"""Contracted crossings using *actual* stub coordinates as representatives."""
cpos: dict[str, tuple[float, float]] = {hub: pos[hub]}
cpos.update(foreign_pos)
for s in stubs:
if s in pos:
cpos[s] = pos[s]
segs = [(a, b) for a, b in links if a in cpos and b in cpos]
c = 0
for i, (a, b) in enumerate(segs):
p1, p2 = cpos[a], cpos[b]
for u, v in segs[i + 1 :]:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, cpos[u], cpos[v]):
c += 1
return c
def _swap_territories(
hub: str,
a: str,
b: str,
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
) -> dict[str, tuple[float, float]]:
"""Exchange polar angles of two stub territories around hub (rigid)."""
hx, hy = pos[hub]
aa, ab = _ang(pos, hub, a), _ang(pos, hub, b)
delta = ab - aa
trial = dict(pos)
ta = {n for n, o in owner.items() if o == a} | {a}
tb = {n for n, o in owner.items() if o == b} | {b}
for n in ta:
if n not in pos or (n in pinned and n != a):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) + delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
for n in tb:
if n not in pos or (n in pinned and n != b):
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) - delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
trial[hub] = (hx, hy)
return trial
def _realize_order_by_swaps(
hub: str,
stubs: list[str],
owner: dict[str, str],
pinned: set[str],
pos: dict[str, tuple[float, float]],
clinks: list[tuple[str, str]],
foreign: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
*,
target_ord: list[str] | None = None,
allow_global_rise: bool = False,
) -> tuple[dict[str, tuple[float, float]], int, int]:
"""Greedy adjacent territory swaps; primary score = contracted@pos.
Search is driven by contracted crossings (lower-level edges ignored in
the objective). On an already-laid canvas (stage-2 polish) we also refuse
swaps that raise *global* crossings — otherwise leaf noise swamps the
map before lower levels can be re-expanded. Set allow_global_rise for
true build-from-skeleton top-down.
"""
trial = dict(pos)
before = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g_base = count_edge_crossings(trial, links)
target_idx = {s: i for i, s in enumerate(target_ord)} if target_ord else {}
for _ in range(max(2 * len(stubs), 8)):
geo = sorted(stubs, key=lambda s: _ang(trial, hub, s))
pair_idxs = list(range(len(geo) - 1))
if target_idx:
inv = [
i
for i in pair_idxs
if target_idx.get(geo[i], 0) > target_idx.get(geo[i + 1], 0)
]
pair_idxs = inv + [i for i in pair_idxs if i not in inv]
progressed = False
c0 = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
g0 = count_edge_crossings(trial, links)
# Pick the adjacent swap with best contracted drop; tie-break by global.
best_cand = None
best_key = None
for i in pair_idxs:
cand = _swap_territories(hub, geo[i], geo[i + 1], owner, pinned, trial)
for h in pinned:
if h in pos:
cand[h] = pos[h]
cand[hub] = pos[hub]
c1 = _contracted_crossings_at_pos(hub, stubs, clinks, cand, foreign)
if c1 >= c0:
continue
g1 = count_edge_crossings(cand, links)
if not allow_global_rise and g1 > g0:
continue
key = (c0 - c1, g0 - g1)
if best_key is None or key > best_key:
best_key = key
best_cand = cand
if best_cand is not None:
trial = best_cand
progressed = True
if not progressed:
break
after = _contracted_crossings_at_pos(hub, stubs, clinks, trial, foreign)
del g_base
return trial, before, after
def hierarchy_sectors_greedy(
state: LayoutState,
params: LayoutParams | None = None,
*,
step: float | None = None,
max_hubs: int = 20,
allow_global_rise: bool = False,
) -> OpResult:
"""Top-down: contract → order by contracted crossings → rigid expand.
Role beam offsets (pin_beam) belong *after* this pass, not inside it.
Default stage-2 polish keeps global from rising; pass allow_global_rise
for skeleton-time top-down where leaf crossings are deferred.
"""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch * 0.95, 170.0))
pos = dict(state.positions)
links = list(state.links)
adj = state.adj
global0 = count_edge_crossings(pos, links)
hubs = pick_hub_seeds(state, max_hubs=max_hubs)
cores = [h for h in hubs if state.layers.get(h) == "core"]
aggs = [h for h in hubs if state.layers.get(h) == "agg"]
# Top-down: cores first, then aggs, then remaining high-degree hubs.
order_hubs = list(cores) + [h for h in aggs if h not in cores]
for h in hubs:
if h not in order_hubs:
order_hubs.append(h)
pinned = set(hubs)
hub_of = _owner_hub_map(adj, pinned)
level_notes: list[dict[str, Any]] = []
any_level = False
contracted_gain = 0
for hub in order_hubs:
if hub not in pos:
continue
members = {hub}
q: deque[str] = deque([hub])
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v in members:
continue
if v in pinned and v != hub:
continue
members.add(v)
q.append(v)
for v in adj.get(hub, ()):
if v not in pinned:
members.add(v)
stubs, owner = _stub_territories(hub, members, adj, pinned)
if len(stubs) < 2:
continue
clinks = _contracted_links(
hub, stubs, owner, adj, pinned, hub_of=hub_of
)
foreign = {
n: pos[n]
for n in pinned
if n != hub and n in pos and any(n in e for e in clinks)
}
angs = [_ang(pos, hub, s) for s in stubs]
a0, a1 = min(angs) - 0.15, max(angs) + 0.15
if a1 - a0 < 0.8:
mid = sum(angs) / len(angs)
a0, a1 = mid - 1.1, mid + 1.1
geo = sorted(stubs, key=lambda s: _ang(pos, hub, s))
base_cc = _score_order(
hub,
geo,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
best_ord, best_cc = _best_order_2opt(
hub,
stubs,
pos,
clinks,
radius=step_px,
hub_xy=pos[hub],
foreign_pos=foreign,
a0=a0,
a1=a1,
)
# Abstract arc bakeoff only proposes a target cyclic order.
if best_cc >= base_cc:
continue
# Realize via adjacent territory swaps; accept on contracted@pos only.
trial, c_before, c_after = _realize_order_by_swaps(
hub,
stubs,
owner,
pinned,
pos,
clinks,
foreign,
links,
target_ord=best_ord,
allow_global_rise=allow_global_rise,
)
if c_after >= c_before:
continue
for h in pinned:
if h in pos:
trial[h] = pos[h]
trial[hub] = pos[hub]
# No per-level overlap fix — it fights sector moves and is slow on
# large canvases. Zero-overlap is ensured by the layout_tool wrapper.
g1 = count_edge_crossings(trial, links)
pos = trial
any_level = True
contracted_gain += c_before - c_after
level_notes.append(
{
"hub_id": hub,
"hub_name": state.names.get(hub, hub),
"stubs": len(stubs),
"contracted_before": c_before,
"contracted_after": c_after,
"contracted_arc_before": base_cc,
"contracted_arc_after": best_cc,
"global_after": g1,
}
)
if not any_level:
return OpResult(
state=state,
moved=set(),
op="hierarchy_sectors",
params={"accepted_n": 0, "contracted_gain": 0},
note="no_level_improved",
)
# One end-of-pass overlap polish (hubs stay pinned).
st_end = state.copy()
st_end.positions = pos
st_end = fix_overlaps_local(st_end, params).state
for h in pinned:
if h in pos:
st_end.positions[h] = pos[h]
pos = {k: (float(v[0]), float(v[1])) for k, v in st_end.positions.items()}
out = state.copy()
out.positions = pos
moved = {
n for n, p in pos.items() if n in state.positions and p != state.positions[n]
}
g_end = count_edge_crossings(pos, links)
out.meta = dict(out.meta or {})
out.meta["hierarchy_sectors"] = {
"levels": level_notes,
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
}
return OpResult(
state=out,
moved=moved,
op="hierarchy_sectors",
params={
"accepted_n": len(level_notes),
"levels": level_notes[:12],
"crossings_before": global0,
"crossings_after": g_end,
"contracted_gain": contracted_gain,
},
note=(
f"hierarchy_sectors:{len(level_notes)} levels "
f"contractedΔ={contracted_gain} x:{global0}->{g_end}"
),
)
def hierarchy_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("max_hubs") is not None:
try:
out["max_hubs"] = int(overrides["max_hubs"])
except (TypeError, ValueError):
pass
if "allow_global_rise" in overrides:
out["allow_global_rise"] = bool(overrides.get("allow_global_rise"))
return out

View file

@ -0,0 +1,380 @@
"""Local hotspots: overlaps / dense cells — scope without touching whole canvas."""
from __future__ import annotations
import math
from collections import defaultdict
from dataclasses import replace
from typing import Any
from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
from netx_topology_mcp.layout_ops.scope import select_scope
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import (
explode_clusters,
pack_utilization,
resolve_overlaps,
)
def _aabb_overlap(
a: tuple[float, float, float, float], b: tuple[float, float, float, float]
) -> bool:
return a[0] < b[2] and a[2] > b[0] and a[1] < b[3] and a[3] > b[1]
def overlapping_nodes(state: LayoutState) -> set[str]:
"""Nodes whose icon+label footprints collide."""
ids = list(state.positions)
fps: dict[str, tuple[float, float, float, float]] = {}
for n in ids:
x, y = state.positions[n]
fx0, fy0, fx1, fy1 = node_footprint(state.names.get(n, n))
fps[n] = (x + fx0, y + fy0, x + fx1, y + fy1)
hit: set[str] = set()
for i, a in enumerate(ids):
for b in ids[i + 1 :]:
if _aabb_overlap(fps[a], fps[b]):
hit.add(a)
hit.add(b)
return hit
def expand_hops(
seeds: set[str], adj: dict[str, set[str]], *, hops: int = 1
) -> set[str]:
out = set(seeds)
frontier = set(seeds)
for _ in range(max(0, hops)):
nxt: set[str] = set()
for u in frontier:
nxt |= set(adj.get(u, ()))
nxt -= out
out |= nxt
frontier = nxt
if not frontier:
break
return out
def dense_blocks(
state: LayoutState,
*,
cell_w: float = REC_CENTER_DX,
cell_h: float = REC_CENTER_DY,
min_count: int = 5,
) -> list[set[str]]:
"""Grid cells with too many centers — local overcrowding."""
if len(state.positions) < min_count:
return []
buckets: dict[tuple[int, int], set[str]] = defaultdict(set)
for n, (x, y) in state.positions.items():
buckets[(int(math.floor(x / cell_w)), int(math.floor(y / cell_h)))].add(n)
return [nodes for nodes in buckets.values() if len(nodes) >= min_count]
def close_clusters(
state: LayoutState, *, thr: float = 100.0
) -> list[set[str]]:
"""Union-find clusters of centers closer than thr (each cluster = one local scope)."""
ids = list(state.positions)
parent = {n: n for n in ids}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def uni(a: str, b: str) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
cell = max(thr, 40.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n, (x, y) in state.positions.items():
buckets[(int(x // cell), int(y // cell))].append(n)
for n, (x, y) in state.positions.items():
cx, cy = int(x // cell), int(y // cell)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m <= n:
continue
mx, my = state.positions[m]
if math.hypot(x - mx, y - my) < thr:
uni(n, m)
groups: dict[str, set[str]] = defaultdict(set)
for n in ids:
groups[find(n)].add(n)
return [g for g in groups.values() if len(g) >= 2]
def sprawled_component_scopes(
state: LayoutState,
*,
target_util: float,
min_size: int = 8,
max_size: int = 80,
) -> list[set[str]]:
"""Graph components with low util — skip huge ones (those need layout, not local pack)."""
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components
comps = connected_components(set(state.positions), state.adj)
out: list[set[str]] = []
tile = REC_CENTER_DX * REC_CENTER_DY
for c in comps:
if len(c) < min_size or len(c) > max_size:
continue
sub = {n: state.positions[n] for n in c}
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
util = len(c) * tile / area
if util < target_util:
out.append(set(c))
return out
def _merge_scopes(scopes: list[set[str]], *, max_size: int = 64) -> list[set[str]]:
"""Merge intersecting scopes but never grow past max_size."""
merged: list[set[str]] = []
for s in scopes:
if not s or len(s) < 2:
continue
if len(s) > max_size:
continue
hit = None
for m in merged:
if s & m and len(m | s) <= max_size:
hit = m
break
if hit is None:
merged.append(set(s))
else:
hit |= s
return [m for m in merged if len(m) >= 2]
def hotspot_scopes(
state: LayoutState,
*,
hops: int = 1,
dense_min: int = 5,
close_thr: float = 100.0,
target_util: float | None = None,
max_scope: int = 64,
) -> list[set[str]]:
"""Local scopes only: overlap / close clusters / dense cells / small sprawled comps."""
seeds_list: list[set[str]] = []
ov = overlapping_nodes(state)
if ov and len(ov) <= max_scope:
seeds_list.append(ov)
seeds_list.extend(close_clusters(state, thr=close_thr))
seeds_list.extend(dense_blocks(state, min_count=dense_min))
if target_util is not None:
seeds_list.extend(
sprawled_component_scopes(
state, target_util=target_util, max_size=max_scope
)
)
if not seeds_list:
return []
expanded: list[set[str]] = []
for s in seeds_list:
if len(s) < 2 or len(s) > max_scope:
continue
e = expand_hops(s, state.adj, hops=hops) & set(state.positions)
if len(e) > max_scope:
e = set(s) # drop hop expansion if it balloons
expanded.append(e)
return _merge_scopes(expanded, max_size=max_scope)
def fix_overlaps_local(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
from netx_topology_mcp.layout_metrics import count_edge_crossings
params = params or LayoutParams()
st = state.copy()
seeds = overlapping_nodes(st)
if not seeds:
return OpResult(
state=st,
moved=set(),
op="fix_overlaps_local",
params={"seeds": 0},
note="no overlaps",
)
c0 = count_edge_crossings(st.positions, st.links)
baseline = st.copy()
scope = expand_hops(seeds, st.adj, hops=1) & set(st.positions)
# Prefer moving smaller set; keep rest as obstacles
st = select_scope(st, mode="ids", node_ids=scope).state
p = replace(
params,
overlap_iters=max(220, int(params.overlap_iters)),
overlap_step=max(4.0, float(params.overlap_step)),
)
moved: set[str] = set()
r1 = resolve_overlaps(st, p, mode="free")
st, moved = r1.state, moved | r1.moved
r2 = resolve_overlaps(st, p, mode="lateral")
st, moved = r2.state, moved | r2.moved
# Still stuck? explode tiny coincidences inside scope, then resolve again
still = overlapping_nodes(st) & scope
if still:
st = select_scope(st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)).state
r3 = explode_clusters(st, p, thr=12.0, gap=max(40.0, p.cluster_gap), axis="along")
st, moved = r3.state, moved | r3.moved
r4 = resolve_overlaps(st, p, mode="free")
st, moved = r4.state, moved | r4.moved
r5 = resolve_overlaps(st, p, mode="lateral")
st, moved = r5.state, moved | r5.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
# Free/explode can shred metro geometry — fall back to lateral-only unstick.
if c1 > max(c0 * 1.35, c0 + 60):
st = baseline
st = select_scope(st, mode="ids", node_ids=scope).state
moved = set()
p2 = replace(p, overlap_iters=max(320, int(p.overlap_iters)))
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, r.moved
still = overlapping_nodes(st) & scope
if still:
st = select_scope(
st, mode="ids", node_ids=expand_hops(still, st.adj, hops=1)
).state
r = resolve_overlaps(st, p2, mode="lateral")
st, moved = r.state, moved | r.moved
st = select_scope(st, mode="all").state
c1 = count_edge_crossings(st.positions, st.links)
left = overlapping_nodes(st)
return OpResult(
state=st,
moved=moved,
op="fix_overlaps_local",
params={
"seeds": len(seeds),
"scope_n": len(scope),
"moved_n": len(moved),
"overlaps_left": len(left),
"crossings_before": c0,
"crossings_after": c1,
},
note=f"local unstick seeds={len(seeds)} left={len(left)} cross={c0}->{c1}",
)
def _block_util(state: LayoutState, block: set[str]) -> float:
from netx_topology_mcp.layout_ops.graph_util import bbox
if len(block) < 2:
return 1.0
sub = {n: state.positions[n] for n in block if n in state.positions}
if len(sub) < 2:
return 1.0
x0, y0, x1, y1 = bbox(sub)
area = max((x1 - x0) * (y1 - y0), 1.0)
return len(sub) * REC_CENTER_DX * REC_CENTER_DY / area
def _block_has_close(state: LayoutState, block: set[str], thr: float) -> bool:
ids = [n for n in block if n in state.positions]
for i, a in enumerate(ids):
ax, ay = state.positions[a]
for b in ids[i + 1 :]:
bx, by = state.positions[b]
if math.hypot(ax - bx, ay - by) < thr:
return True
return False
def relax_hotspots(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Per-hotspot local polish — never global compress.
- Sprawled block (low util): pack + resolve only
- Crowded block (close pairs / overlaps): mild explode + resolve (no pack)
"""
params = params or LayoutParams()
st = state.copy()
scopes = hotspot_scopes(
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
)
if not scopes:
from netx_topology_mcp.layout_ops.scope import list_blocks
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]
scopes = sorted(scopes, key=len, reverse=True)[:24]
total_moved: set[str] = set()
block_notes: list[dict[str, Any]] = []
close_thr = 55.0 # only true local crowding, not soft nn band
for i, block in enumerate(scopes):
if len(block) < 2:
continue
st = select_scope(st, mode="ids", node_ids=block).state
before_ov = len(overlapping_nodes(st) & block)
util = _block_util(st, block)
crowded = before_ov > 0 or _block_has_close(st, block, close_thr)
sprawled = util < params.target_util
mode = "pack" if sprawled and not crowded else ("unstick" if crowded else "skip")
moved_here: set[str] = set()
if mode == "unstick":
r1 = explode_clusters(
st, params, thr=close_thr, gap=max(40.0, params.cluster_gap), axis="along"
)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
r3 = resolve_overlaps(st, params, mode="free")
st = r3.state
moved_here |= r3.moved
elif mode == "pack":
r1 = pack_utilization(st, params)
st = r1.state
moved_here |= r1.moved
r2 = resolve_overlaps(st, params, mode="lateral")
st = r2.state
moved_here |= r2.moved
total_moved |= moved_here
after_ov = len(overlapping_nodes(st) & block)
block_notes.append(
{
"i": i,
"n": len(block),
"mode": mode,
"util": round(util, 4),
"ov_before": before_ov,
"ov_after": after_ov,
"moved": len(moved_here),
}
)
st = select_scope(st, mode="all").state
fin = fix_overlaps_local(st, params)
st = fin.state
total_moved |= fin.moved
return OpResult(
state=st,
moved=total_moved,
op="relax_hotspots",
params={"blocks": block_notes, "blocks_n": len(scopes), "moved_n": len(total_moved)},
note=f"relax blocks={len(scopes)} moved={len(total_moved)}",
)

View file

@ -0,0 +1,591 @@
"""Per-node / per-edge attract+repulse mass field for soft compose merge.
Replaces uniform exclusive-island rigidity: cores pull hard, rings hold shape,
chains are tearable and may evolve into rings (raising mass).
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_topology_mcp.layout_ops.state import LayoutState
# role -> (attract, repulse, mass)
_ROLE_NODE: dict[str, tuple[float, float, float]] = {
"core": (4.0, 1.8, 3.0),
"ring": (2.4, 1.2, 1.8),
"chain": (0.7, 0.6, 0.5),
"free": (1.0, 1.0, 1.0),
}
_ROLE_EDGE: dict[str, tuple[float, float]] = {
"ring": (2.6, 1.0),
"chain": (0.5, 0.4),
"bridge": (1.2, 0.8),
"plain": (1.0, 1.0),
}
_ROLE_RANK = {"free": 0, "chain": 1, "ring": 2, "core": 3}
_STAB = {"chain": 0.35, "ring": 1.2, "core": 3.0, "free": 0.7}
def _edge_key(a: str, b: str) -> str:
return f"{a}|{b}" if a <= b else f"{b}|{a}"
def _nest_boost(path_count: int) -> float:
extra = max(0, int(path_count) - 2)
return 1.0 + 0.15 * float(extra)
def annotate_dual_unit(unit: DualUnit) -> dict[str, Any]:
"""Role tags for one DualUnit (portals→core, paths→ring, tails→chain)."""
nest = max(0, len(unit.paths))
boost = _nest_boost(nest)
nodes: dict[str, dict[str, Any]] = {}
edges: dict[str, dict[str, Any]] = {}
def _set_node(nid: str, role: str) -> None:
cur = nodes.get(nid)
if cur is None or _ROLE_RANK.get(role, 0) > _ROLE_RANK.get(
str(cur.get("role") or "free"), 0
):
a, r, m = _ROLE_NODE[role]
if role == "ring":
a *= boost
m *= 1.0 + 0.08 * max(0, nest - 2)
nodes[nid] = {
"role": role,
"attract": round(a, 4),
"repulse": round(r, 4),
"mass": round(m, 4),
"unit_id": unit.unit_id,
}
def _set_edge(u: str, v: str, role: str) -> None:
if u == v:
return
key = _edge_key(u, v)
cur = edges.get(key)
if cur is None or _ROLE_RANK.get(role, 0) > _ROLE_RANK.get(
str(cur.get("role") or "plain"), 0
):
ae, re = _ROLE_EDGE[role]
if role == "ring":
ae *= boost
edges[key] = {
"role": role,
"attract": round(ae, 4),
"repulse": round(re, 4),
"a": u if u <= v else v,
"b": v if u <= v else u,
}
for p in (unit.portal_a, unit.portal_b):
_set_node(p, "core")
for path in unit.paths:
for i, n in enumerate(path):
if n in (unit.portal_a, unit.portal_b):
_set_node(n, "core")
else:
_set_node(n, "ring")
if i + 1 < len(path):
_set_edge(path[i], path[i + 1], "ring")
for chain in unit.tails:
prev = None
# Attach edge from nearest portal/core if chain starts at neighbor — handled
# when full graph links are known; here tag chain nodes + consecutive edges.
for n in chain:
_set_node(n, "chain")
if prev is not None:
_set_edge(prev, n, "chain")
prev = n
return {
"unit_id": unit.unit_id,
"nest_depth": nest,
"portal_a": unit.portal_a,
"portal_b": unit.portal_b,
"nodes": nodes,
"edges": edges,
}
def merge_mass_dicts(
*parts: dict[str, dict[str, Any]],
rank_key: str = "role",
rank_map: dict[str, int] | None = None,
) -> dict[str, dict[str, Any]]:
ranks = rank_map or _ROLE_RANK
out: dict[str, dict[str, Any]] = {}
for part in parts:
for k, row in part.items():
cur = out.get(k)
if cur is None or ranks.get(str(row.get(rank_key) or ""), 0) > ranks.get(
str(cur.get(rank_key) or ""), 0
):
out[k] = dict(row)
return out
def _degree_tweak(
state: LayoutState,
nodes: dict[str, dict[str, Any]],
) -> None:
for n, row in nodes.items():
deg = len(state.adj.get(n, ()))
layer = str(state.layers.get(n) or "")
m = float(row.get("mass") or 1.0)
a = float(row.get("attract") or 1.0)
if layer in ("core", "agg") or deg >= 8:
m *= 1.15
a *= 1.08
elif deg <= 2 and str(row.get("role")) == "chain":
m *= 0.92
a *= 0.95
row["mass"] = round(m, 4)
row["attract"] = round(a, 4)
def build_mass_field(
state: LayoutState,
*,
units: list[DualUnit] | None = None,
groups: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Build mass_field from dual units (+ optional compose groups).
Pass ``units=[]`` to skip dual-unit detection (plain FA2 mass tags only).
"""
found = units if units is not None else find_dual_portal_units(state)
node_parts: list[dict[str, dict[str, Any]]] = []
edge_parts: list[dict[str, dict[str, Any]]] = []
unit_rows: list[dict[str, Any]] = []
for u in found:
ann = annotate_dual_unit(u)
unit_rows.append(
{
"unit_id": ann["unit_id"],
"nest_depth": ann["nest_depth"],
"portal_a": ann["portal_a"],
"portal_b": ann["portal_b"],
}
)
node_parts.append(ann["nodes"])
edge_parts.append(ann["edges"])
nodes = merge_mass_dicts(*node_parts) if node_parts else {}
edges = merge_mass_dicts(
*edge_parts, rank_key="role", rank_map={"plain": 0, "bridge": 1, "chain": 2, "ring": 3}
) if edge_parts else {}
# Tag remaining nodes + mark fabric bridges between different group homes.
home = home_map_from_groups(groups or [])
for n in state.positions:
if n not in nodes:
nodes[n] = {
"role": "free",
"attract": 1.0,
"repulse": 1.0,
"mass": 1.0,
}
for a, b in state.links:
key = _edge_key(a, b)
if key in edges:
continue
ha, hb = home.get(a), home.get(b)
role = "bridge" if ha and hb and ha != hb else "plain"
ae, re = _ROLE_EDGE[role]
edges[key] = {
"role": role,
"attract": ae,
"repulse": re,
"a": a if a <= b else b,
"b": b if a <= b else a,
}
_degree_tweak(state, nodes)
soft_groups = []
for g in groups or []:
key = str(g.get("key") or "")
members = [str(x) for x in (g.get("node_ids") or g.get("members") or [])]
pivots = [str(x) for x in (g.get("pivots") or [])]
cores = [
n
for n in members
if str((nodes.get(n) or {}).get("role")) == "core" or n in pivots
]
if not cores:
cores = list(pivots)
soft_groups.append(
{
"key": key,
"node_ids": members,
"pivots": pivots,
"cores": cores,
"soft": True,
}
)
return {
"nodes": nodes,
"edges": edges,
"units": unit_rows,
"groups": soft_groups,
"tip": (
"mass_field: core/ring/chain attract+repulse; use mass_merge "
"(not exclusive rigid islands) after compose"
),
}
def home_map_from_groups(groups: list[dict[str, Any]]) -> dict[str, str]:
"""First group wins for multi-membership; portals often multi-home."""
home: dict[str, str] = {}
counts: dict[str, int] = {}
for g in groups:
key = str(g.get("key") or "")
for n in g.get("node_ids") or g.get("members") or []:
nid = str(n)
counts[nid] = counts.get(nid, 0) + 1
if nid not in home:
home[nid] = key
# Shared portals: keep first home but mark multi via absence of exclusive.
return home
def group_effective_mass(
group: dict[str, Any],
nodes: dict[str, dict[str, Any]],
) -> float:
cores = list(group.get("cores") or group.get("pivots") or [])
if not cores:
# Fall back to top attract members.
members = [str(x) for x in (group.get("node_ids") or [])]
members.sort(
key=lambda n: -float((nodes.get(n) or {}).get("attract") or 0.0)
)
cores = members[:2]
total = 0.0
for c in cores:
row = nodes.get(c) or {}
total += float(row.get("mass") or 1.0) * float(row.get("attract") or 1.0)
return max(total, 0.1)
def core_centroid(
group: dict[str, Any],
pos: dict[str, tuple[float, float]],
nodes: dict[str, dict[str, Any]],
) -> tuple[float, float] | None:
cores = [c for c in (group.get("cores") or group.get("pivots") or []) if c in pos]
if not cores:
members = [n for n in (group.get("node_ids") or []) if n in pos]
if not members:
return None
members.sort(
key=lambda n: -float((nodes.get(n) or {}).get("attract") or 0.0)
)
cores = members[:3]
if not cores:
return None
cx = sum(pos[c][0] for c in cores) / len(cores)
cy = sum(pos[c][1] for c in cores) / len(cores)
return (cx, cy)
def evolve_chains_to_rings(
state: LayoutState,
mass: dict[str, Any],
) -> dict[str, Any]:
"""Promote chain nodes that sit on newly detected dual-portal corridors."""
units = find_dual_portal_units(state)
nodes = dict(mass.get("nodes") or {})
edges = dict(mass.get("edges") or {})
promoted_n = 0
promoted_e = 0
for u in units:
ann = annotate_dual_unit(u)
for nid, row in ann["nodes"].items():
if str(row.get("role")) != "ring":
continue
cur = nodes.get(nid) or {}
if str(cur.get("role")) == "chain":
nodes[nid] = row
promoted_n += 1
elif nid not in nodes or _ROLE_RANK.get(
str(row.get("role")), 0
) > _ROLE_RANK.get(str(cur.get("role") or "free"), 0):
nodes[nid] = row
for key, row in ann["edges"].items():
if str(row.get("role")) != "ring":
continue
cur = edges.get(key) or {}
if str(cur.get("role")) == "chain":
edges[key] = row
promoted_e += 1
elif key not in edges:
edges[key] = row
_degree_tweak(state, nodes)
out = dict(mass)
out["nodes"] = nodes
out["edges"] = edges
out["evolve"] = {
"promoted_nodes": promoted_n,
"promoted_edges": promoted_e,
"units_n": len(units),
}
return out
def capture_pass(
state: LayoutState,
mass: dict[str, Any],
*,
kappa_node: float = 1.25,
kappa_block: float = 2.0,
rho_ideal: float = 6.0,
ideal_len: float = 540.0,
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Rewrite soft group membership: steal nodes / whole weak blocks."""
groups = [dict(g) for g in (mass.get("groups") or [])]
if len(groups) < 2:
return mass, {"stolen_nodes": 0, "stolen_blocks": 0}
nodes = mass.get("nodes") or {}
pos = state.positions
# Rebuild member lists as sets
by_key: dict[str, dict[str, Any]] = {}
for g in groups:
key = str(g.get("key") or "")
g["node_ids"] = [str(x) for x in (g.get("node_ids") or [])]
g["_set"] = set(g["node_ids"])
by_key[key] = g
masses = {k: group_effective_mass(g, nodes) for k, g in by_key.items()}
cents = {k: core_centroid(g, pos, nodes) for k, g in by_key.items()}
# Home: exclusive preferred (membership count==1)
count: dict[str, int] = {}
for g in groups:
for n in g["node_ids"]:
count[n] = count.get(n, 0) + 1
home: dict[str, str] = {}
for g in groups:
k = str(g.get("key") or "")
for n in g["node_ids"]:
if count.get(n, 0) == 1:
home[n] = k
elif n not in home:
home[n] = k
stolen_nodes = 0
for n, gkey in list(home.items()):
if count.get(n, 0) > 1:
continue # shared portals stay
role = str((nodes.get(n) or {}).get("role") or "free")
if role == "core":
continue
if n not in pos:
continue
nx, ny = pos[n]
hold = masses.get(gkey, 0.1) * _STAB.get(role, 0.7)
best_h = None
best_pull = 0.0
for hk, hc in cents.items():
if hk == gkey or hc is None:
continue
d = math.hypot(nx - hc[0], ny - hc[1])
pull = masses.get(hk, 0.1) / (d + 1.0)
if pull > best_pull:
best_pull = pull
best_h = hk
if best_h is None:
continue
if best_pull > kappa_node * max(hold, 1e-6):
# Move membership
by_key[gkey]["_set"].discard(n)
by_key[best_h]["_set"].add(n)
home[n] = best_h
stolen_nodes += 1
stolen_blocks = 0
# Block capture: weak whole group re-parented (merge members into strong).
keys = list(by_key.keys())
for i, wk in enumerate(keys):
for sk in keys[i + 1 :]:
for weak, strong in ((wk, sk), (sk, wk)):
mw, ms = masses.get(weak, 0.1), masses.get(strong, 0.1)
if ms < kappa_block * mw:
continue
cw, cs = cents.get(weak), cents.get(strong)
if cw is None or cs is None:
continue
dist = math.hypot(cw[0] - cs[0], cw[1] - cs[1])
if dist > rho_ideal * ideal_len:
continue
# Contact: any bridge edge or shared portal
contact = False
wset = by_key[weak]["_set"]
sset = by_key[strong]["_set"]
if wset & sset:
contact = True
else:
for a, b in state.links:
if (a in wset and b in sset) or (b in wset and a in sset):
contact = True
break
if not contact:
continue
# Absorb exclusive weak members into strong (keep soft parent key
# on strong; clear weak exclusive into strong set).
moved = [n for n in list(wset) if count.get(n, 0) == 1]
if len(moved) < 2:
continue
for n in moved:
by_key[weak]["_set"].discard(n)
by_key[strong]["_set"].add(n)
home[n] = strong
stolen_blocks += 1
masses[strong] = group_effective_mass(by_key[strong], nodes)
masses[weak] = group_effective_mass(by_key[weak], nodes)
cents[strong] = core_centroid(by_key[strong], pos, nodes)
cents[weak] = core_centroid(by_key[weak], pos, nodes)
break
new_groups = []
for g in groups:
key = str(g.get("key") or "")
members = sorted(by_key[key]["_set"])
pivots = [p for p in (g.get("pivots") or []) if p in by_key[key]["_set"]]
cores = [
n
for n in members
if str((nodes.get(n) or {}).get("role")) == "core" or n in pivots
]
new_groups.append(
{
"key": key,
"node_ids": members,
"pivots": pivots,
"cores": cores or pivots,
"soft": True,
}
)
out = dict(mass)
out["groups"] = new_groups
report = {
"stolen_nodes": stolen_nodes,
"stolen_blocks": stolen_blocks,
"kappa_node": kappa_node,
"kappa_block": kappa_block,
}
return out, report
def geo_score(
state: LayoutState,
mass: dict[str, Any],
) -> float:
"""Higher is better: ring edge length consistency + core compactness."""
nodes = mass.get("nodes") or {}
edges = mass.get("edges") or {}
pos = state.positions
ring_lens: list[float] = []
chain_lens: list[float] = []
for key, row in edges.items():
a, b = str(row.get("a") or ""), str(row.get("b") or "")
if a not in pos or b not in pos:
# key form
if "|" in key:
a, b = key.split("|", 1)
else:
continue
if a not in pos or b not in pos:
continue
L = math.hypot(pos[a][0] - pos[b][0], pos[a][1] - pos[b][1])
role = str(row.get("role") or "plain")
if role == "ring":
ring_lens.append(L)
elif role == "chain":
chain_lens.append(L)
score = 0.0
if len(ring_lens) >= 2:
mean = sum(ring_lens) / len(ring_lens)
var = sum((x - mean) ** 2 for x in ring_lens) / len(ring_lens)
score += 100.0 / (1.0 + math.sqrt(var) / max(mean, 1.0))
elif ring_lens:
score += 40.0
# Penalize very long chains (should be tearable / reattached).
if chain_lens:
mean_c = sum(chain_lens) / len(chain_lens)
score -= min(40.0, mean_c / 80.0)
# Core neighborhood compactness
cores = [n for n, r in nodes.items() if str(r.get("role")) == "core" and n in pos]
if len(cores) >= 2:
cx = sum(pos[n][0] for n in cores) / len(cores)
cy = sum(pos[n][1] for n in cores) / len(cores)
spread = sum(math.hypot(pos[n][0] - cx, pos[n][1] - cy) for n in cores) / len(
cores
)
score += 80.0 / (1.0 + spread / 400.0)
return float(score)
def attach_mass_to_compose_meta(
meta: dict[str, Any],
mass: dict[str, Any],
) -> dict[str, Any]:
"""Write mass_groups + mass_field onto compose meta (keep rigid_groups)."""
out = dict(meta)
groups = mass.get("groups") or []
if not groups and out.get("rigid_groups"):
groups = [
{**g, "soft": True, "cores": list(g.get("pivots") or [])}
for g in out["rigid_groups"]
if isinstance(g, dict)
]
mass = dict(mass)
mass["groups"] = groups
out["mass_groups"] = groups
out["mass_field"] = {
"nodes_n": len(mass.get("nodes") or {}),
"edges_n": len(mass.get("edges") or {}),
"units": mass.get("units") or [],
"tip": mass.get("tip"),
}
# Full field stored at state.meta["mass_field"] by caller.
out["soft"] = True
return out
def groups_from_mass_or_rigid(meta: dict[str, Any] | None) -> list[dict[str, Any]]:
"""Prefer soft mass_groups, fall back to rigid_groups."""
if not meta:
return []
raw = meta.get("mass_groups") or meta.get("rigid_groups") or []
out: list[dict[str, Any]] = []
for g in raw:
if not isinstance(g, dict):
continue
nodes = [str(x) for x in (g.get("node_ids") or []) if str(x)]
if len(nodes) < 2:
continue
pivots = [str(x) for x in (g.get("pivots") or []) if str(x)]
out.append(
{
"key": str(g.get("key") or ""),
"node_ids": nodes,
"pivots": pivots,
"cores": [str(x) for x in (g.get("cores") or pivots) if str(x)],
"soft": bool(g.get("soft", True)),
}
)
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,372 @@
"""Dual-hub minimal-ring layering (PLAU↔ATP style).
When two aggregation hubs share ≥2 nearly interior-disjoint corridors,
nest those paths on alternating ellipse bands and park leftover side
chains in free left/right sectors. Preferable to per-AN petal pack for
agg_bar metro rings (0-crossing when the graph is path-planar).
"""
from __future__ import annotations
import math
from collections import defaultdict, deque
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _all_simple_paths(
src: str,
dst: str,
adj: dict[str, set[str]],
*,
cutoff: int = 16,
forbid: set[str] | None = None,
) -> list[list[str]]:
forbid = set(forbid or ())
paths: list[list[str]] = []
stack: list[tuple[str, list[str]]] = [(src, [src])]
while stack:
u, path = stack.pop()
if len(path) > cutoff:
continue
for v in adj.get(u, ()):
if v in path or v in forbid:
continue
np = path + [v]
if v == dst:
paths.append(np)
else:
stack.append((v, np))
return paths
def _path_core(path: list[str]) -> set[str]:
"""Exclusive midpoints (drop portal-adjacent hops that rings often share)."""
mid = path[1:-1]
if not mid:
return set()
if len(mid) <= 2:
return set(mid)
return set(mid[1:-1])
def cover_hub_paths(
hub_a: str,
hub_b: str,
adj: dict[str, set[str]],
names: dict[str, str],
*,
cutoff: int = 16,
forbid: set[str] | None = None,
) -> list[list[str]]:
"""Greedy path cover between hubs; cores disjoint (portal-adjacent may share)."""
paths = _all_simple_paths(hub_a, hub_b, adj, cutoff=cutoff, forbid=forbid)
paths = [p for p in paths if p[1:-1]] # skip direct hub-hub
paths.sort(key=lambda p: (len(p), "".join(names.get(x, x) for x in p)))
picked: list[list[str]] = []
used_core: set[str] = set()
for p in paths:
core = _path_core(p)
if not core or core & used_core:
continue
picked.append(p)
used_core |= core
picked.sort(key=len)
return picked
def pick_dual_hubs(state: LayoutState) -> tuple[str, str] | None:
"""Top two agg (else core) hubs by degree; need both present in graph."""
cand = [
n
for n, ly in state.layers.items()
if ly == "agg" and n in state.adj
]
if len(cand) < 2:
cand = [
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n in state.adj
]
if len(cand) < 2:
return None
cand.sort(
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n))
)
a, b = cand[0], cand[1]
if a == b:
return None
# Prefer name-stable left/right
if state.names.get(a, a) > state.names.get(b, b):
a, b = b, a
return a, b
def min_rings_eligible(
state: LayoutState,
*,
min_paths: int = 2,
min_cover_frac: float = 0.35,
) -> dict[str, Any] | None:
"""Return plan dict if dual-hub path cover is worth running."""
hubs = pick_dual_hubs(state)
if not hubs:
return None
hub_a, hub_b = hubs
# Ignore other high-layer nodes as path interiors (orphan ANs park later).
other_hubs = {
n
for n, ly in state.layers.items()
if ly in ("agg", "core") and n not in (hub_a, hub_b)
}
paths = cover_hub_paths(
hub_a, hub_b, state.adj, state.names, forbid=other_hubs
)
if len(paths) < min_paths:
return None
used = {n for p in paths for n in p[1:-1]}
access = {n for n, ly in state.layers.items() if ly == "access"}
if not access:
return None
cover = len(used & access) / max(1, len(access))
# Low cover with many paths is still a star/hub petal job (e.g. BTM),
# not dual-hub corridor nesting — fall through to ume_petals.
if cover < min_cover_frac:
return None
return {
"hub_a": hub_a,
"hub_b": hub_b,
"paths": paths,
"cover_frac": cover,
"path_count": len(paths),
}
def _comps(nodes: set[str], adj: dict[str, set[str]], names: dict[str, str]) -> list[list[str]]:
seen: set[str] = set()
out: list[list[str]] = []
for n in sorted(nodes, key=lambda x: names.get(x, x)):
if n in seen:
continue
q = deque([n])
seen.add(n)
c: list[str] = []
while q:
u = q.popleft()
c.append(u)
for v in adj.get(u, ()):
if v in nodes and v not in seen:
seen.add(v)
q.append(v)
out.append(c)
return out
def _attach_of(
comp: list[str], on: set[str], adj: dict[str, set[str]], fallback: str
) -> tuple[str, str]:
for n in comp:
for v in adj.get(n, ()):
if v in on:
return v, n
return fallback, comp[0]
def _order_chain(
comp: list[str], start: str, adj: dict[str, set[str]], names: dict[str, str]
) -> list[str]:
s = set(comp)
if start not in s:
start = sorted(comp, key=lambda n: names.get(n, n))[0]
ordered = [start]
seen = {start}
prev = None
cur = start
while len(ordered) < len(comp):
nbs = [v for v in adj.get(cur, ()) if v in s and v not in seen]
if not nbs:
rest = [n for n in comp if n not in seen]
if not rest:
break
cur = sorted(rest, key=lambda n: names.get(n, n))[0]
ordered.append(cur)
seen.add(cur)
prev = None
continue
nbs.sort(
key=lambda v: (
len([x for x in adj.get(v, ()) if x in s]),
names.get(v, v),
)
)
if prev in nbs and len(nbs) > 1:
nbs = [v for v in nbs if v != prev] + [prev]
prev, cur = cur, nbs[0]
ordered.append(cur)
seen.add(cur)
return ordered
def layout_min_rings_positions(
state: LayoutState,
params: LayoutParams,
plan: dict[str, Any] | None = None,
) -> dict[str, tuple[float, float]] | None:
"""Compute positions; None if plan ineligible."""
plan = plan or min_rings_eligible(state)
if not plan:
return None
hub_a = str(plan["hub_a"])
hub_b = str(plan["hub_b"])
paths: list[list[str]] = list(plan["paths"]) # type: ignore[arg-type]
adj, names, layers = state.adj, state.names, state.layers
rx = max(params.an_gap * 2.5, params.pitch * 7.0, 1100.0)
ry_step = max(params.side * 1.6, params.lane * 0.9, 280.0)
pitch = max(params.pitch * 0.8, 160.0)
base_r = max(params.side * 1.3, 220.0)
# Prefer denser bands when graph is small.
n_access = sum(1 for ly in layers.values() if ly == "access")
if n_access <= 55:
ry_step = min(ry_step, 320.0)
pitch = min(pitch, 170.0)
pos: dict[str, tuple[float, float]] = {
hub_a: (-rx, 0.0),
hub_b: (rx, 0.0),
}
# Nodes that appear as portal-adjacent on ≥2 paths sit on the hub chord.
hop_count: dict[str, int] = defaultdict(int)
for p in paths:
mid = p[1:-1]
if not mid:
continue
hop_count[mid[0]] += 1
if len(mid) > 1:
hop_count[mid[-1]] += 1
shared_hops = {n for n, c in hop_count.items() if c >= 2}
used: set[str] = set()
for i, p in enumerate(paths):
side = 1 if i % 2 == 0 else -1
ry = ry_step * ((i // 2) + 1)
m = len(p)
for j, n in enumerate(p):
if n in (hub_a, hub_b):
continue
t = j / (m - 1) if m > 1 else 0.5
if n in shared_hops:
# Keep on axis so corridor arcs do not cross the hub chord.
if n not in pos:
pos[n] = (-rx + 2 * rx * t, 0.0)
used.add(n)
continue
ang = math.pi * (1 - t)
if side < 0:
ang = -ang
pos[n] = (rx * math.cos(ang), ry * math.sin(ang))
used.add(n)
on = used | {hub_a, hub_b}
# Orphan hubs (other agg/core) + leftover access comps → free sectors
leftovers = {n for n in names if n not in on}
orphan_hubs = [
n
for n in leftovers
if layers.get(n) in ("agg", "core")
]
left_access = leftovers - set(orphan_hubs)
lcomps = _comps(left_access, adj, names)
by_hub: dict[str, list[tuple[list[str], str]]] = defaultdict(list)
for c in lcomps:
att, seed = _attach_of(c, on, adj, hub_a)
by_hub[att].append((c, seed))
left_angs = [math.pi, 2.55, 3.55, -2.55]
right_angs = [0.0, 0.5, -0.5, 0.95]
for hub, groups in by_hub.items():
groups = sorted(groups, key=lambda g: -len(g[0]))
if hub == hub_a:
angs = left_angs
elif hub == hub_b:
angs = right_angs
else:
angs = None
hx, hy = pos.get(hub, (0.0, 0.0))
for gi, (comp, seed) in enumerate(groups):
if angs is not None:
ang = angs[gi % len(angs)]
else:
ang = math.atan2(hy, hx or 1e-9) + (gi - (len(groups) - 1) / 2) * 0.25
for i, n in enumerate(_order_chain(comp, seed, adj, names)):
r = base_r + i * pitch
pos[n] = (hx + r * math.cos(ang), hy + r * math.sin(ang))
# Park orphan hubs above/below clear of bands
ymin = min((y for _, y in pos.values()), default=0.0)
ymax = max((y for _, y in pos.values()), default=0.0)
for i, n in enumerate(sorted(orphan_hubs, key=lambda x: names.get(x, x))):
pos[n] = (80.0 * (i - (len(orphan_hubs) - 1) / 2), ymin - 280.0 - i * 40)
for n in names:
if n not in pos:
pos[n] = (0.0, ymax + 200.0)
# Normalize to positive margin
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
x0, y0 = min(xs), min(ys)
pad = params.margin
return {n: (x - x0 + pad, y - y0 + pad) for n, (x, y) in pos.items()}
def build_min_ring_skeleton(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult | None:
"""Return OpResult if dual-hub min-rings applies; else None (caller falls back)."""
params = params or LayoutParams()
plan = min_rings_eligible(state)
if not plan:
return None
pos = layout_min_rings_positions(state, params, plan)
if not pos:
return None
st = state.copy()
st.positions = pos
st.pinned = {plan["hub_a"], plan["hub_b"]}
# Also pin path portals' immediate corridor order lightly via spine
spine: set[str] = {plan["hub_a"], plan["hub_b"]}
for p in plan["paths"]:
spine.update(p)
st.spine = spine
st.last_moved = set(pos.keys())
cross = count_edge_crossings(pos, st.links)
st.meta["rings_mode"] = "min_rings"
st.meta["min_rings"] = {
"hub_a": plan["hub_a"],
"hub_b": plan["hub_b"],
"path_count": plan["path_count"],
"cover_frac": round(float(plan["cover_frac"]), 3),
"edge_crossings": cross,
}
st.meta["components"] = 1
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_min_ring_skeleton",
params={
"mode": "min_rings",
"path_count": plan["path_count"],
"cover_frac": plan["cover_frac"],
"hubs": [plan["hub_a"], plan["hub_b"]],
},
note=(
f"min-rings dual-hub paths={plan['path_count']} "
f"cover={plan['cover_frac']:.2f} cross={cross}"
),
)

View file

@ -0,0 +1,812 @@
"""Polar orbit sweep: suggest top-3 single-node drags by crossing score.
Agent workflow: preview → pick rank 1..3 → apply; or round=true to auto-apply
#1 for each hot node when global crossings drop.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossing_participation,
crossings_involving_node,
node_footprint,
top_crossing_nodes,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_JUMP = 900.0
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for
# max_jump≈8k–12k orbit rounds (still clamped per-call via params).
_MAX_JUMP_CAP = 12000.0
_MAX_FROM_NBS = 1100.0
def _protect_is_off(protect_rigid: bool | str) -> bool:
return protect_rigid in (False, "false", "off", "none", "0")
def _resolve_frozen(
st: LayoutState,
protect_rigid: bool | str,
frozen_ids: set[str] | None,
) -> set[str]:
"""Portal/rigid freeze only when protect is on; off ignores portal_ids inject."""
if _protect_is_off(protect_rigid):
return set()
frozen: set[str] = set(frozen_ids or ())
if not frozen:
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
x, y = pos[nid]
minx, miny, maxx, maxy = node_footprint(names.get(nid, ""))
return (x + minx, y + miny, x + maxx, y + maxy)
def _node_overlaps_any(
node: str, pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> bool:
ax0, ay0, ax1, ay1 = _box(node, pos, names)
for b, (x, y) in pos.items():
if b == node:
continue
if abs(x - pos[node][0]) > 80 and abs(y - pos[node][1]) > 60:
continue
bx0, by0, bx1, by1 = _box(b, pos, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _nn_ok(
node: str,
pos: dict[str, tuple[float, float]],
nn_floor: float,
) -> bool:
if nn_floor <= 0:
return True
x0, y0 = pos[node]
floor2 = nn_floor * nn_floor
for b, (x, y) in pos.items():
if b == node:
continue
dx, dy = x - x0, y - y0
if dx * dx + dy * dy < floor2:
return False
return True
def _incident_stretch(
node: str,
pos: dict[str, tuple[float, float]],
adj: dict[str, set[str]],
target_nn: float,
) -> float:
nbs = [v for v in adj.get(node, ()) if v in pos]
if not nbs:
return 1.0
tn = max(40.0, float(target_nn))
x0, y0 = pos[node]
lengths = [math.hypot(pos[v][0] - x0, pos[v][1] - y0) for v in nbs]
mean_l = sum(lengths) / len(lengths)
return mean_l / tn
def _angle_diff_deg(a: float, b: float) -> float:
d = abs(a - b) % 360.0
return d if d <= 180.0 else 360.0 - d
def _radii_for_jump(jump: float) -> list[float]:
radii = [80.0, 120.0, 180.0, 260.0, 360.0, 480.0]
if jump > 520:
radii = radii + [640.0, 800.0]
if jump > 1200:
radii = radii + [1200.0, 1600.0, 2200.0, min(jump, 3200.0)]
return [r for r in radii if r <= jump + 1]
def _polar_grid(
x0: float,
y0: float,
*,
jump: float,
angle_step: int,
radii: list[float] | None = None,
) -> list[tuple[float, float, float, float]]:
"""Return (x, y, r, angle_deg) samples on polar rings about (x0,y0)."""
step = max(10, int(angle_step))
rs = radii if radii is not None else _radii_for_jump(jump)
out: list[tuple[float, float, float, float]] = []
for ang in range(0, 360, step):
rad = math.radians(ang)
c, s = math.cos(rad), math.sin(rad)
for r in rs:
if r > jump + 1:
continue
out.append((x0 + r * c, y0 + r * s, float(r), float(ang)))
return out
def _neighbor_guides(
pos: dict[str, tuple[float, float]],
node: str,
adj: dict[str, set[str]],
jump: float,
) -> list[tuple[float, float, float, float]]:
x, y = pos[node]
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if not nbs:
return []
cx = sum(p[0] for p in nbs) / len(nbs)
cy = sum(p[1] for p in nbs) / len(nbs)
dx, dy = x - cx, y - cy
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
px, py = -uy, ux
out: list[tuple[float, float, float, float]] = []
for s in (-360.0, -220.0, -120.0, 120.0, 220.0, 360.0):
if abs(s) > jump:
continue
for nx, ny in (
(x + ux * s, y + uy * s),
(cx + ux * abs(s), cy + uy * abs(s)),
):
r = math.hypot(nx - x, ny - y)
if r > jump or r < 1.0:
continue
ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0
out.append((nx, ny, r, ang))
for s in (-240.0, -160.0, -80.0, 80.0, 160.0, 240.0):
if abs(s) > jump:
continue
nx, ny = x + px * s, y + py * s
r = abs(s)
ang = math.degrees(math.atan2(ny - y, nx - x)) % 360.0
out.append((nx, ny, r, ang))
return out
def _score_key(c: dict[str, Any]) -> tuple:
return (
int(c["crossings"]["global"]),
int(c["crossings"]["incident"]),
float(c.get("stretch") or 1.0),
float(c.get("r") or 0.0),
)
def _diversify_top(
ranked: list[dict[str, Any]],
*,
k: int = 3,
min_angle_sep: float = 35.0,
) -> list[dict[str, Any]]:
picked: list[dict[str, Any]] = []
for c in ranked:
ok = True
for p in picked:
if _angle_diff_deg(float(c["angle_deg"]), float(p["angle_deg"])) < min_angle_sep:
r0 = max(float(p.get("r") or 1.0), 1.0)
r1 = max(float(c.get("r") or 1.0), 1.0)
ratio = max(r0, r1) / min(r0, r1)
if ratio < 1.3:
ok = False
break
if ok:
picked.append(c)
if len(picked) >= k:
break
# Fill if diversity filtered too hard.
if len(picked) < k:
ids = {id(p) for p in picked}
for c in ranked:
if id(c) in ids:
continue
picked.append(c)
if len(picked) >= k:
break
for i, c in enumerate(picked):
c["rank"] = i + 1
return picked
def _eval_candidate(
node: str,
cand_xy: tuple[float, float],
r: float,
angle_deg: float,
*,
pos: dict[str, tuple[float, float]],
names: dict[str, str],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
global0: int,
local0: int,
target_nn: float,
nn_floor: float,
nbs_cap: float,
) -> dict[str, Any] | None:
x0, y0 = pos[node]
nx, ny = cand_xy
if math.hypot(nx - x0, ny - y0) > r + 1e-6 and r > 0:
# keep r as reported displacement
pass
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if nbs:
mx = sum(p[0] for p in nbs) / len(nbs)
my = sum(p[1] for p in nbs) / len(nbs)
if math.hypot(nx - mx, ny - my) > nbs_cap:
return None
trial = dict(pos)
trial[node] = (nx, ny)
if _node_overlaps_any(node, trial, names):
return None
nn_ok = _nn_ok(node, trial, nn_floor)
if not nn_ok:
return None
local1 = crossings_involving_node(node, trial, links, adj)
g1 = int(global0) - int(local0) + int(local1)
stretch = _incident_stretch(node, trial, adj, target_nn)
disp = math.hypot(nx - x0, ny - y0)
return {
"x": round(nx, 1),
"y": round(ny, 1),
"r": round(disp, 1),
"angle_deg": round(angle_deg % 360.0, 1),
"crossings": {"global": g1, "incident": int(local1)},
"delta": {
"global": g1 - int(global0),
"incident": int(local1) - int(local0),
},
"ov": False,
"nn_ok": True,
"stretch": round(stretch, 3),
}
def orbit_sweep_node(
state: LayoutState,
node_id: str,
*,
params: LayoutParams | None = None,
max_jump: float | None = None,
angle_step: int | None = None,
nn_floor: float = 36.0,
min_angle_sep: float = 35.0,
cand_cap: int = 280,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
top_k: int = 3,
) -> dict[str, Any]:
"""Sweep polar candidates for one node; return diversified top-k.
Default ``protect_rigid=off`` so multi-round orbit may move portals to cut
crossings; other layout actions keep portal freeze. Opt in with portals/all.
"""
params = params or LayoutParams()
st = state
nid = str(node_id).strip()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
if nid not in pos:
return {
"ok": False,
"error": "node_not_on_view",
"node_id": nid,
}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
if nid in frozen:
return {
"ok": False,
"error": "frozen",
"node_id": nid,
"hint": "portal/rigid frozen; protect_rigid=off or pick a corridor node",
}
n_links = len(links)
jump = float(max_jump if max_jump is not None else _MAX_JUMP)
jump = max(200.0, min(jump, _MAX_JUMP_CAP))
if angle_step is None:
angle_step = 24 if n_links >= 400 else (18 if n_links >= 200 else 15)
angle_step = max(10, int(angle_step))
# Local untangle-style jumps stay near neighbor centroid; metro bridges
# (max_jump≫1k) must be allowed to leave the unit blob.
if jump > 1200:
nbs_cap = jump * 2.5
else:
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
target_nn = float(getattr(params, "target_nn", 155.0) or 155.0)
x0, y0 = pos[nid]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(nid, pos, links, adj)
# Coarse grid.
coarse_step = max(angle_step, 24 if n_links >= 200 else angle_step)
samples = _polar_grid(x0, y0, jump=jump, angle_step=coarse_step)
samples.extend(_neighbor_guides(pos, nid, adj, jump))
# Explicit samples toward each neighbor (incl. long bridges).
for nb in adj.get(nid, ()):
if nb not in pos:
continue
bx, by = pos[nb]
dx, dy = bx - x0, by - y0
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
ang = math.degrees(math.atan2(uy, ux)) % 360.0
for frac in (0.15, 0.35, 0.55, 0.75):
r = min(jump, L * frac)
if r < 40:
continue
samples.append((x0 + ux * r, y0 + uy * r, r, ang))
# Perpendicular escapes at mid-chord fractions.
px, py = -uy, ux
for r in (180.0, 360.0, 640.0, 1200.0):
if r > jump:
continue
samples.append((x0 + px * r, y0 + py * r, r, (ang + 90) % 360))
samples.append((x0 - px * r, y0 - py * r, r, (ang + 270) % 360))
# Dedup by rounded xy.
seen: set[tuple[int, int]] = set()
uniq: list[tuple[float, float, float, float]] = []
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
uniq.append((sx, sy, r, ang))
if len(uniq) >= cand_cap:
break
scored: list[dict[str, Any]] = []
for sx, sy, r, ang in uniq:
c = _eval_candidate(
nid,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=nn_floor,
nbs_cap=nbs_cap,
)
if c is not None:
scored.append(c)
scored.sort(key=_score_key)
# Refine around top-8 coarse winners.
refine_budget = max(0, cand_cap - len(uniq))
fine: list[tuple[float, float, float, float]] = []
half = max(5.0, coarse_step / 2.0)
radii = _radii_for_jump(jump)
for base in scored[:8]:
ang0 = float(base["angle_deg"])
r0 = float(base["r"])
# nearest radius indices
near_r = sorted(radii, key=lambda rr: abs(rr - r0))[:3]
for dang in (-half, 0.0, half):
ang = (ang0 + dang) % 360.0
rad = math.radians(ang)
c_, s_ = math.cos(rad), math.sin(rad)
for rr in near_r:
if rr > jump + 1:
continue
fine.append((x0 + rr * c_, y0 + rr * s_, rr, ang))
if len(fine) >= refine_budget:
break
if len(fine) >= refine_budget:
break
if len(fine) >= refine_budget:
break
for sx, sy, r, ang in fine:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
c = _eval_candidate(
nid,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=nn_floor,
nbs_cap=nbs_cap,
)
if c is not None:
scored.append(c)
scored.sort(key=_score_key)
# Prefer improving moves; still return best even if none improve.
improving = [c for c in scored if c["delta"]["global"] < 0]
pool = improving if improving else scored
top = _diversify_top(pool, k=max(1, int(top_k)), min_angle_sep=min_angle_sep)
return {
"ok": True,
"node_id": nid,
"name": names.get(nid, nid),
"x0": round(x0, 1),
"y0": round(y0, 1),
"degree": len(adj.get(nid, ())),
"crossings_before": {"global": int(global0), "incident": int(local0)},
"candidates": top,
"sampled": len(seen),
"improving_n": len(improving),
"max_jump": jump,
"angle_step": angle_step,
"hint": (
"prefer rank1 unless util/label concern; then pick 2/3. "
"apply with params.pick=1|2|3 or updateTopologyViewPositions."
),
}
def apply_orbit_pick(
state: LayoutState,
sweep: dict[str, Any],
*,
pick: int = 1,
) -> OpResult:
"""Move node to chosen candidate (1-based rank)."""
st = state.copy()
nid = str(sweep.get("node_id") or "")
cands = list(sweep.get("candidates") or [])
if not nid or nid not in st.positions or not cands:
return OpResult(
state=st,
moved=set(),
op="orbit_sweep",
note="orbit_sweep:noop",
params={"error": "no_candidates"},
)
idx = max(1, min(int(pick), len(cands))) - 1
chosen = cands[idx]
st.positions[nid] = (float(chosen["x"]), float(chosen["y"]))
st.last_moved = {nid}
st.meta["orbit_sweep"] = {
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
}
return OpResult(
state=st,
moved={nid},
op="orbit_sweep",
params={
"node_id": nid,
"pick": idx + 1,
"candidate": chosen,
"crossings_before": sweep.get("crossings_before"),
},
note=(
f"orbit_sweep pick={idx + 1} "
f"g{sweep.get('crossings_before', {}).get('global')}->"
f"{chosen['crossings']['global']}"
),
)
def orbit_sweep_round(
state: LayoutState,
*,
params: LayoutParams | None = None,
top_n: int = 12,
max_degree: int = 9,
max_jump: float | None = None,
angle_step: int | None = None,
nn_floor: float = 36.0,
min_angle_sep: float = 35.0,
protect_rigid: bool | str = "off",
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
) -> OpResult:
"""Scan hot nodes; auto-apply each node's rank-1 if global crossings drop.
Default ``protect_rigid=off`` (may move portals). Opt in with portals/all.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
frozen = _resolve_frozen(st, protect_rigid, frozen_ids)
global0 = count_edge_crossings(pos, links)
hit = crossing_participation(pos, links)[1]
focus = {str(x) for x in (focus_ids or []) if str(x)}
if not focus:
focus = {
str(r["fabric_node_id"])
for r in top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
)
}
# Expand hub focus to low-deg neighbors.
movable: list[str] = []
seen_m: set[str] = set()
prefer: list[str] = []
for nid in focus:
if nid in hit and len(adj.get(nid, ())) < max_degree and nid not in frozen:
prefer.append(nid)
for nb in adj.get(nid, ()):
if nb in hit and len(adj.get(nb, ())) < max_degree and nb not in frozen:
prefer.append(nb)
ranked = sorted(
hit.keys(),
key=lambda n: (
0 if n in prefer or n in focus else 1,
-hit[n] / max(len(adj.get(n, ())), 1),
len(adj.get(n, ())),
-hit[n],
),
)
for nid in ranked:
if nid in frozen or nid in seen_m:
continue
if len(adj.get(nid, ())) >= max_degree:
continue
if hit.get(nid, 0) <= 0:
continue
movable.append(nid)
seen_m.add(nid)
if len(movable) >= max(1, int(top_n)):
break
cur_g = global0
moved: set[str] = set()
trace: list[dict[str, Any]] = []
for nid in movable:
# Refresh state positions into a temp LayoutState for sweep.
st.positions = pos
sweep = orbit_sweep_node(
st,
nid,
params=params,
max_jump=max_jump,
angle_step=angle_step,
nn_floor=nn_floor,
min_angle_sep=min_angle_sep,
protect_rigid="off", # already applied frozen set
frozen_ids=frozen,
)
if not sweep.get("ok"):
trace.append({"node_id": nid, "skipped": sweep.get("error")})
continue
cands = list(sweep.get("candidates") or [])
if not cands:
trace.append({"node_id": nid, "skipped": "no_candidates"})
continue
best = cands[0]
if int(best["delta"]["global"]) >= 0:
trace.append(
{
"node_id": nid,
"skipped": "no_global_gain",
"best_delta": best["delta"],
}
)
continue
# Apply #1
pos[nid] = (float(best["x"]), float(best["y"]))
moved.add(nid)
cur_g = int(best["crossings"]["global"])
# Refresh hit lightly for ranking continuity.
local = crossings_involving_node(nid, pos, links, adj)
if local > 0:
hit[nid] = local
else:
hit.pop(nid, None)
trace.append(
{
"node_id": nid,
"name": names.get(nid, nid),
"applied": True,
"pick": 1,
"xy": [best["x"], best["y"]],
"delta": best["delta"],
"crossings": best["crossings"],
}
)
st.positions = pos
st.last_moved = moved
end_g = count_edge_crossings(pos, links)
meta = {
"start_crossings": global0,
"end_crossings": end_g,
"moved_n": len(moved),
"scanned_n": len(movable),
"trace": trace,
"top_n": top_n,
"max_degree": max_degree,
}
st.meta["orbit_sweep"] = meta
return OpResult(
state=st,
moved=moved,
op="orbit_sweep_round",
params=meta,
note=f"orbit_sweep_round {global0}->{end_g} moved={len(moved)}/{len(movable)}",
)
def orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("node_id") is not None:
out["node_id"] = str(o.get("node_id") or "").strip()
elif o.get("fabric_node_id") is not None:
out["node_id"] = str(o.get("fabric_node_id") or "").strip()
if o.get("pick") is not None:
try:
out["pick"] = max(1, min(3, int(o["pick"])))
except (TypeError, ValueError):
out["pick"] = 1
if o.get("round") is not None:
out["round"] = str(o.get("round")).lower() not in {
"0",
"false",
"no",
"off",
"",
} or o.get("round") is True
for key, cast, default in (
("top_n", int, 12),
("max_degree", int, 9),
("angle_step", int, None),
("cand_cap", int, 280),
("top_k", int, 3),
):
if key not in o or o[key] is None:
if default is not None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
if default is not None:
out[key] = default
if o.get("max_jump") is not None:
try:
out["max_jump"] = float(o["max_jump"])
except (TypeError, ValueError):
pass
if o.get("nn_floor") is not None:
try:
out["nn_floor"] = float(o["nn_floor"])
except (TypeError, ValueError):
out["nn_floor"] = 36.0
else:
out["nn_floor"] = 36.0
if o.get("min_angle_sep") is not None:
try:
out["min_angle_sep"] = float(o["min_angle_sep"])
except (TypeError, ValueError):
out["min_angle_sep"] = 35.0
else:
out["min_angle_sep"] = 35.0
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
else:
# orbit breaks rigid by default (opt in with protect_rigid=portals).
out["protect_rigid"] = "off"
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
# portal freeze from polish path
raw_p = o.get("portal_ids")
if isinstance(raw_p, list):
out["frozen_ids"] = {str(x) for x in raw_p if str(x)}
return out
def orbit_lite_suggest(
node: str,
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
names: dict[str, str],
*,
max_jump: float = 360.0,
angle_step: int = 45,
top_k: int = 3,
target_nn: float = 155.0,
) -> list[dict[str, Any]]:
"""Lightweight polar suggest for analyze sight (fewer samples)."""
if node not in pos:
return []
# Build a tiny state-like eval without LayoutState.
x0, y0 = pos[node]
global0 = count_edge_crossings(pos, links)
local0 = crossings_involving_node(node, pos, links, adj)
jump = max(120.0, min(float(max_jump), 800.0))
samples = _polar_grid(
x0,
y0,
jump=jump,
angle_step=angle_step,
radii=[120.0, 200.0, 320.0],
)
samples.extend(_neighbor_guides(pos, node, adj, jump)[:12])
scored: list[dict[str, Any]] = []
seen: set[tuple[int, int]] = set()
for sx, sy, r, ang in samples:
key = (int(round(sx)), int(round(sy)))
if key in seen:
continue
seen.add(key)
c = _eval_candidate(
node,
(sx, sy),
r,
ang,
pos=pos,
names=names,
links=links,
adj=adj,
global0=global0,
local0=local0,
target_nn=target_nn,
nn_floor=36.0,
nbs_cap=jump * 2.5 if jump > 1200 else max(_MAX_FROM_NBS, jump * 1.25),
)
if c is not None:
scored.append(c)
scored.sort(key=_score_key)
improving = [c for c in scored if c["delta"]["global"] < 0]
pool = improving if improving else scored
top = _diversify_top(pool, k=top_k, min_angle_sep=35.0)
out: list[dict[str, Any]] = []
for c in top:
out.append(
{
"kind": f"orbit_r{int(c['r'])}_a{int(c['angle_deg'])}",
"x": c["x"],
"y": c["y"],
"r": c["r"],
"angle_deg": c["angle_deg"],
"delta_crossings_est": c["delta"]["global"],
"delta_incident": c["delta"]["incident"],
"global_after": c["crossings"]["global"],
}
)
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,523 @@
"""Soft partitioning for layout init — own algorithms first, igraph optional.
Design (partial igraph use):
1. Hard blocks = connected components (existing ``list_blocks(component)``).
2. Soft blocks inside a giant CC = **hub-seeded territory** (our BFS; default).
3. Optional igraph:
- ``leiden``: only refine leftover / oversized mesh pockets (hubs pinned).
- ``fr_pack``: place soft-block *centers* on a supergraph (not final metro coords).
4. Per-block geometry stays ours: beam / stub petals / chain spine / ume_petals.
Never use force-directed as the final access layout.
"""
from __future__ import annotations
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import Any, Iterable
from netx_topology_mcp.layout_ops.graph_util import connected_components
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
# Preferred soft-block step when packing centers (px).
_BLOCK_GAP = 420.0
def resolve_block_mode(state: LayoutState, mode: str | None = None) -> str:
"""Resolve ``auto`` → hub_territory when one giant CC has multiple hubs."""
m = (mode or "component").strip().lower()
if m != "auto":
return m
active = {
n
for n in state.positions
if state.layers.get(n) in ("core", "agg", "access")
}
comps = connected_components(active, state.adj) if active else []
hubs = pick_hub_seeds(state)
big = max((len(c) for c in comps), default=0)
if len(comps) <= 2 and big >= 40 and len(hubs) >= 2:
return "hub_territory"
return "component"
def igraph_available() -> bool:
try:
import igraph # noqa: F401
return True
except ImportError:
return False
@dataclass(frozen=True)
class SoftBlock:
block_id: int
hub_id: str | None
method: str
node_ids: tuple[str, ...]
def as_dict(self) -> dict[str, Any]:
return {
"block_id": self.block_id,
"hub_id": self.hub_id,
"method": self.method,
"size": len(self.node_ids),
"node_ids": list(self.node_ids)[:80],
"node_count": len(self.node_ids),
}
def pick_hub_seeds(
state: LayoutState,
*,
min_degree: int = 2,
max_hubs: int = 24,
) -> list[str]:
"""core/agg hubs by degree; fall back to high-degree nodes."""
ids = list(state.positions.keys())
layered = [
n
for n in ids
if state.layers.get(n) in ("core", "agg")
and len(state.adj.get(n, ())) >= min_degree
]
if layered:
layered.sort(
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n))
)
return layered[:max_hubs]
ranked = sorted(
ids,
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
return [n for n in ranked if len(state.adj.get(n, ())) >= max(3, min_degree)][
:max_hubs
]
def hub_territory_partition(
state: LayoutState,
*,
hubs: Iterable[str] | None = None,
) -> list[SoftBlock]:
"""Multi-source BFS from each hub's 1-hop stubs — one soft block per hub.
Hubs themselves are included in their block. Nodes claimed by the nearest
stub (first visit). Leftovers become CC soft-blocks (``method=leftover``).
"""
hub_list = [h for h in (hubs or pick_hub_seeds(state)) if h in state.positions]
if not hub_list:
comps = connected_components(set(state.positions), state.adj)
return [
SoftBlock(i, None, "component", tuple(sorted(c)))
for i, c in enumerate(comps)
if c
]
pinned = set(hub_list)
owner: dict[str, str] = {}
q: deque[str] = deque()
for h in hub_list:
for stub in sorted(state.adj.get(h, ()), key=lambda i: state.names.get(i, i)):
if stub in pinned or stub in owner:
continue
owner[stub] = h
q.append(stub)
while q:
u = q.popleft()
h = owner[u]
for v in state.adj.get(u, ()):
if v in pinned or v in owner:
continue
owner[v] = h
q.append(v)
by_hub: dict[str, list[str]] = {h: [h] for h in hub_list}
for nid, h in owner.items():
by_hub.setdefault(h, [h]).append(nid)
blocks: list[SoftBlock] = []
claimed = set(hub_list) | set(owner)
for h in hub_list:
nodes = tuple(sorted(set(by_hub.get(h, [h])), key=lambda i: state.names.get(i, i)))
blocks.append(SoftBlock(len(blocks), h, "hub_territory", nodes))
rest = set(state.positions) - claimed
if rest:
for comp in connected_components(rest, state.adj):
if not comp:
continue
blocks.append(
SoftBlock(
len(blocks),
None,
"leftover",
tuple(sorted(comp, key=lambda i: state.names.get(i, i))),
)
)
return blocks
def _leiden_membership(
node_ids: list[str],
adj: dict[str, set[str]],
*,
resolution: float = 1.0,
) -> list[int] | None:
if not igraph_available() or len(node_ids) < 6:
return None
import igraph as ig
idx = {n: i for i, n in enumerate(node_ids)}
edges: list[tuple[int, int]] = []
seen: set[tuple[int, int]] = set()
for a in node_ids:
for b in adj.get(a, ()):
if b not in idx or a >= b:
continue
ea, eb = idx[a], idx[b]
key = (ea, eb) if ea < eb else (eb, ea)
if key in seen:
continue
seen.add(key)
edges.append(key)
if not edges:
return None
g = ig.Graph(n=len(node_ids), edges=edges, directed=False)
try:
part = g.community_leiden(
objective_function="modularity",
resolution=resolution,
n_iterations=2,
)
except Exception:
try:
part = g.community_multilevel()
except Exception:
return None
return list(part.membership)
def leiden_refine_leftovers(
state: LayoutState,
blocks: list[SoftBlock],
*,
resolution: float = 0.8,
min_split_size: int = 12,
) -> list[SoftBlock]:
"""Optionally split leftover/mesh pockets with Leiden; hubs stay untouched.
If igraph is missing, returns ``blocks`` unchanged.
"""
if not igraph_available():
return blocks
out: list[SoftBlock] = []
for b in blocks:
if b.method != "leftover" or len(b.node_ids) < min_split_size:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
nodes = list(b.node_ids)
memb = _leiden_membership(nodes, state.adj, resolution=resolution)
if memb is None:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
groups: dict[int, list[str]] = defaultdict(list)
for n, m in zip(nodes, memb):
groups[int(m)].append(n)
if len(groups) <= 1:
out.append(SoftBlock(len(out), b.hub_id, b.method, b.node_ids))
continue
for gid in sorted(groups.keys()):
ids = tuple(sorted(groups[gid], key=lambda i: state.names.get(i, i)))
out.append(SoftBlock(len(out), None, "leiden", ids))
return out
def partition_soft_blocks(
state: LayoutState,
*,
mode: str = "hub_territory",
hubs: Iterable[str] | None = None,
leiden_resolution: float = 0.8,
) -> list[SoftBlock]:
"""Public entry: ``hub_territory`` (default) | ``leiden`` | ``soft``.
- hub_territory: our seeded BFS only
- leiden: hub territories + Leiden on leftovers (needs igraph)
- soft: alias of leiden if igraph else hub_territory
"""
m = (mode or "hub_territory").strip().lower()
base = hub_territory_partition(state, hubs=hubs)
if m in {"leiden", "soft"} and igraph_available():
return leiden_refine_leftovers(
state, base, resolution=leiden_resolution
)
return base
def soft_blocks_as_sets(blocks: list[SoftBlock]) -> list[set[str]]:
return [set(b.node_ids) for b in blocks if b.node_ids]
def _block_centroids(
state: LayoutState, blocks: list[SoftBlock]
) -> dict[int, tuple[float, float]]:
out: dict[int, tuple[float, float]] = {}
for b in blocks:
pts = [state.positions[n] for n in b.node_ids if n in state.positions]
if not pts:
continue
out[b.block_id] = (
sum(p[0] for p in pts) / len(pts),
sum(p[1] for p in pts) / len(pts),
)
return out
def _centroid_spread(centroids: dict[int, tuple[float, float]]) -> float:
"""Max pairwise distance among centroids (0 if <2)."""
ids = list(centroids)
if len(ids) < 2:
return 0.0
best = 0.0
for i, a in enumerate(ids):
ax, ay = centroids[a]
for b in ids[i + 1 :]:
bx, by = centroids[b]
d = ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
if d > best:
best = d
return best
def _median_nn(centroids: dict[int, tuple[float, float]]) -> float:
ids = list(centroids)
if len(ids) < 2:
return 0.0
nns: list[float] = []
for a in ids:
ax, ay = centroids[a]
best = None
for b in ids:
if a == b:
continue
bx, by = centroids[b]
d = ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
if best is None or d < best:
best = d
if best is not None:
nns.append(best)
if not nns:
return 0.0
nns.sort()
return nns[len(nns) // 2]
def _pack_centers_preserve(
centroids: dict[int, tuple[float, float]],
*,
gap: float,
) -> dict[int, tuple[float, float]]:
"""Keep relative constellation; scale about COM so median NN ≈ gap."""
if len(centroids) < 2:
return dict(centroids)
cx = sum(p[0] for p in centroids.values()) / len(centroids)
cy = sum(p[1] for p in centroids.values()) / len(centroids)
med = _median_nn(centroids)
if med < 1e-6:
return dict(centroids)
# Only densify when already too sparse; never explode a tight pack here.
scale = min(1.0, gap / med)
if abs(scale - 1.0) < 1e-3:
return dict(centroids)
return {
bid: (cx + (x - cx) * scale, cy + (y - cy) * scale)
for bid, (x, y) in centroids.items()
}
def _pack_centers_fr_or_grid(
state: LayoutState,
blocks: list[SoftBlock],
*,
gap: float,
) -> dict[int, tuple[float, float]]:
"""Fresh placement when centroids are collapsed (init / stacked)."""
id_of = {}
for b in blocks:
for n in b.node_ids:
id_of[n] = b.block_id
cut: dict[tuple[int, int], int] = defaultdict(int)
for a, b in state.links:
ia, ib = id_of.get(a), id_of.get(b)
if ia is None or ib is None or ia == ib:
continue
key = (ia, ib) if ia < ib else (ib, ia)
cut[key] += 1
n = len(blocks)
if igraph_available() and n >= 2 and cut:
import igraph as ig
edges = list(cut.keys())
weights = [float(cut[e]) for e in edges]
g = ig.Graph(n=n, edges=edges, directed=False)
try:
layout = g.layout_fruchterman_reingold(weights=weights, niter=200)
except Exception:
layout = g.layout_fruchterman_reingold(niter=200)
xs = [float(p[0]) for p in layout]
ys = [float(p[1]) for p in layout]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
span = max(maxx - minx, maxy - miny, 1e-6)
target = gap * max(2.0, n**0.5)
scale = target / span
return {
blocks[i].block_id: ((xs[i] - minx) * scale, (ys[i] - miny) * scale)
for i in range(n)
}
cols = max(1, int(n**0.5 + 0.999))
out: dict[int, tuple[float, float]] = {}
for i, b in enumerate(blocks):
r, c = divmod(i, cols)
out[b.block_id] = (c * gap, r * gap)
return out
def pack_block_centers(
state: LayoutState,
blocks: list[SoftBlock],
*,
gap: float = _BLOCK_GAP,
strategy: str = "auto",
) -> dict[int, tuple[float, float]]:
"""Suggest soft-block centroids (init only; does not move nodes).
``strategy``:
- ``preserve``: keep current relative centroids; densify so median NN≈gap
- ``grid`` / ``fr``: ignore current world placement (FR if igraph else grid)
- ``auto``: if centroids already spread (≥0.75·gap), preserve; else FR/grid
"""
if not blocks:
return {}
centroids = _block_centroids(state, blocks)
if len(centroids) < 2:
return centroids
strat = (strategy or "auto").strip().lower()
spread = _centroid_spread(centroids)
if strat == "preserve" or (strat == "auto" and spread >= gap * 0.75):
return _pack_centers_preserve(centroids, gap=gap)
return _pack_centers_fr_or_grid(state, blocks, gap=gap)
def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any]:
"""Structure-facing summary for analyzeTopologyViewLayout."""
blocks = partition_soft_blocks(state, mode=mode)
centers = pack_block_centers(state, blocks)
return {
"mode": mode if mode != "soft" else ("leiden" if igraph_available() else "hub_territory"),
"igraph": igraph_available(),
"block_count": len(blocks),
"blocks": [
{
**b.as_dict(),
"center_hint": (
[round(centers[b.block_id][0], 1), round(centers[b.block_id][1], 1)]
if b.block_id in centers
else None
),
}
for b in blocks[:40]
],
"tip": (
"Soft blocks for init only: lay each hub territory / leftover with our "
"petal/spine algorithms; use center_hint to pack blocks. "
"igraph Leiden/FR are optional — install netx-topology-mcp[layout]."
),
}
def pack_soft_blocks(
state: LayoutState,
params: LayoutParams | None = None,
*,
mode: str = "hub_territory",
gap: float | None = None,
strategy: str = "auto",
) -> OpResult:
"""Rigid-translate each soft block so centroids match ``pack_block_centers``.
Init packing only — does not reshape petals inside a block. No-op if <2 blocks.
``strategy=auto`` preserves an already-spread constellation (densify only);
collapsed stacks still get FR/grid separation.
"""
del params # reserved for future spacing knobs
st = state.copy()
raw = (mode or "hub_territory").strip().lower()
resolved = resolve_block_mode(st, raw) if raw == "auto" else raw
if resolved == "component":
resolved = "hub_territory"
blocks = partition_soft_blocks(st, mode=resolved)
if len(blocks) < 2:
return OpResult(
state=st,
moved=set(),
op="pack_soft_blocks",
params={"mode": resolved, "blocks_n": len(blocks)},
note="skip soft pack (<2 blocks)",
)
g = float(gap or _BLOCK_GAP)
strat = (strategy or "auto").strip().lower()
before = _block_centroids(st, blocks)
targets = pack_block_centers(st, blocks, gap=g, strategy=strat)
used = (
"preserve"
if strat == "preserve"
or (strat == "auto" and _centroid_spread(before) >= g * 0.75)
else ("fr" if igraph_available() else "grid")
)
moved: set[str] = set()
for b in blocks:
tid = b.block_id
if tid not in targets or not b.node_ids:
continue
pts = [st.positions[n] for n in b.node_ids if n in st.positions]
if not pts:
continue
cx = sum(p[0] for p in pts) / len(pts)
cy = sum(p[1] for p in pts) / len(pts)
tx, ty = targets[tid]
dx, dy = tx - cx, ty - cy
if abs(dx) < 1e-6 and abs(dy) < 1e-6:
continue
for n in b.node_ids:
if n not in st.positions:
continue
x, y = st.positions[n]
st.positions[n] = (x + dx, y + dy)
moved.add(n)
st.meta["soft_pack"] = {
"mode": resolved,
"strategy": used,
"blocks_n": len(blocks),
"moved_n": len(moved),
"igraph_centers": igraph_available(),
}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="pack_soft_blocks",
params={
"mode": resolved,
"strategy": used,
"blocks_n": len(blocks),
"moved_n": len(moved),
},
note=f"packed soft blocks={len(blocks)} strategy={used} moved={len(moved)}",
)

View file

@ -0,0 +1,252 @@
"""Stage-2: pin core horizontal beam + agg row via rigid soft-block translates."""
from __future__ import annotations
from collections import deque
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import normalize_origin
def _hops_to_cores(
src: str, cores: list[str], adj: dict[str, set[str]]
) -> tuple[int, str]:
if not cores:
return 99, ""
core_set = set(cores)
if src in core_set:
return 0, src
seen = {src}
q: deque[tuple[str, int]] = deque([(src, 0)])
while q:
u, d = q.popleft()
for v in adj.get(u, ()):
if v in seen:
continue
if v in core_set:
return d + 1, v
seen.add(v)
q.append((v, d + 1))
return 99, cores[0]
def _core_order(state: LayoutState) -> list[str]:
cores = [
n
for n in state.positions
if state.layers.get(n) == "core"
]
if len(cores) < 2:
# fall back: high-degree hubs that look like CN
for n, nm in state.names.items():
if "-CN" in (nm or "") and n not in cores:
cores.append(n)
cores = sorted(
set(cores),
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
return cores
def _agg_hubs(state: LayoutState, *, min_degree: int = 4) -> list[str]:
aggs = [
n
for n in state.positions
if state.layers.get(n) == "agg"
and len(state.adj.get(n, ())) >= min_degree
]
return sorted(
aggs,
key=lambda n: (-len(state.adj.get(n, ())), state.names.get(n, n)),
)
def _apply_targets(
state: LayoutState,
targets: dict[str, tuple[float, float]],
by_hub: dict[str, Any],
) -> LayoutState:
pos = dict(state.positions)
for hid, (tx, ty) in targets.items():
block = by_hub.get(hid)
if not block or hid not in pos:
if hid in pos:
pos[hid] = (tx, ty)
continue
hx, hy = pos[hid]
dx, dy = tx - hx, ty - hy
for n in block.node_ids:
if n in pos:
x, y = pos[n]
pos[n] = (x + dx, y + dy)
st = state.copy()
st.positions = pos
st = fix_overlaps_local(st, LayoutParams()).state
st = normalize_origin(st, LayoutParams()).state
return st
def pin_beam_rigid(
state: LayoutState,
params: LayoutParams | None = None,
*,
step: float | None = None,
gap_scales: tuple[float, ...] = (1.0, 1.3, 1.6, 0.85),
) -> OpResult:
"""Rigid-translate soft blocks so cores form a beam and aggs sit on a row.
Picks the trial with lowest (overlaps, crossings). Does not worsen crossings
vs the input when a better trial exists; otherwise returns input unchanged.
"""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch, 180.0))
cores = _core_order(state)
if len(cores) < 2:
return OpResult(
state=state,
moved=set(),
op="pin_beam",
params={"cores": cores},
note="need_ge_2_cores",
)
aggs = _agg_hubs(state)
aggs = sorted(
aggs,
key=lambda a: (
_hops_to_cores(a, cores, state.adj)[0],
state.names.get(a, a),
),
)
blocks = partition_soft_blocks(state, mode="hub_territory")
by_hub = {b.hub_id: b for b in blocks if b.hub_id}
links = list(state.links)
base_cross = count_edge_crossings(state.positions, links)
base_score = score_state(state)
base_ov = int(base_score.get("footprint_overlap_pairs") or 0) + int(
base_score.get("label_overlap_pairs") or 0
)
gap0 = step_px * 2.4
beam_y = 0.0
trials: list[tuple[str, LayoutState, int, int]] = []
for gs in gap_scales:
gap = gap0 * gs
for petal_mul in (4.0, 4.5, 5.5):
petal_dy = step_px * petal_mul
targets: dict[str, tuple[float, float]] = {}
for i, c in enumerate(cores):
targets[c] = (i * gap, beam_y)
if aggs:
lo = targets[cores[0]][0] - gap * 0.5
hi = targets[cores[-1]][0] + gap * 0.5
for i, a in enumerate(aggs):
_, pref = _hops_to_cores(a, cores, state.adj)
prefer_x = targets.get(pref, targets[cores[0]])[0]
if len(aggs) == 1:
x = prefer_x
else:
x = lo + (hi - lo) * i / (len(aggs) - 1)
x = 0.55 * x + 0.45 * prefer_x
targets[a] = (x, beam_y + petal_dy)
st2 = _apply_targets(state, targets, by_hub)
sc = score_state(st2)
ov = int(sc.get("footprint_overlap_pairs") or 0) + int(
sc.get("label_overlap_pairs") or 0
)
cross = int(sc.get("edge_crossings") or 0)
trials.append((f"g{gs}_dy{petal_dy:.0f}", st2, ov, cross))
# Y-align only (preserve x): often safer on already-readable canvases
cy = sum(state.positions[c][1] for c in cores if c in state.positions) / len(
cores
)
targets_y: dict[str, tuple[float, float]] = {}
for c in cores:
if c in state.positions:
targets_y[c] = (state.positions[c][0], cy)
ay = cy + step_px * 4.0
for a in aggs:
if a in state.positions:
targets_y[a] = (state.positions[a][0], ay)
if targets_y:
st_y = _apply_targets(state, targets_y, by_hub)
sc = score_state(st_y)
ov = int(sc.get("footprint_overlap_pairs") or 0) + int(
sc.get("label_overlap_pairs") or 0
)
cross = int(sc.get("edge_crossings") or 0)
trials.append(("y_align", st_y, ov, cross))
if not trials:
return OpResult(
state=state, moved=set(), op="pin_beam", note="no_trials"
)
best_name, best_st, best_ov, best_cross = min(
trials, key=lambda t: (t[2], t[3])
)
# Refuse internal accept if worse than input on both axes we care about
if best_ov > base_ov or best_cross > base_cross + max(20, int(base_cross * 0.05)):
return OpResult(
state=state,
moved=set(),
op="pin_beam",
params={
"cores": cores,
"aggs": aggs,
"best_trial": best_name,
"best_crossings": best_cross,
"base_crossings": base_cross,
"accepted": False,
},
note="no_improvement",
)
moved = {
n
for n, p in best_st.positions.items()
if n in state.positions and p != state.positions[n]
}
best_st.meta = dict(best_st.meta or {})
best_st.meta["pin_beam"] = {
"trial": best_name,
"cores": cores,
"aggs": aggs,
"crossings_before": base_cross,
"crossings_after": best_cross,
"overlaps_after": best_ov,
}
return OpResult(
state=best_st,
moved=moved,
op="pin_beam",
params={
"cores": cores,
"aggs": aggs,
"best_trial": best_name,
"accepted": True,
"crossings_before": base_cross,
"crossings_after": best_cross,
},
note=f"pin_beam:{best_name} {base_cross}->{best_cross}",
)
def pin_beam_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,760 @@
"""Stage-2 crossing pressure without temp scripts.
Actions used by agents via layoutTopologyView:
- press_hot_edges: rotate non-portal ends of top crossing edges about the other end
- press_crossers: move nodes that participate in crossings against those hot edges
- polish_crossings: straighten → hot_edges → crossers → untangle(portals)
Hot path uses incremental crossing deltas (O(deg·E) per trial) so KND-scale
graphs (~500 links) stay interactive under MCP stdio timeouts.
"""
from __future__ import annotations
import math
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossings_after_node_move,
crossings_involving_node,
crossing_participation_full,
node_footprint,
segments_properly_intersect,
top_crossing_edges,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.untangle import untangle_crossings
def park_phantom_nodes(state: LayoutState) -> set[str]:
"""Park region: markers / absurd coords so they do not blow bbox/util."""
moved: set[str] = set()
for nid, (x, y) in list(state.positions.items()):
if str(nid).startswith("region:") or abs(x) > 1e5 or abs(y) > 1e5:
if not math.isfinite(x) or not math.isfinite(y) or abs(x) > 1e5 or abs(y) > 1e5:
state.positions[nid] = (160.0, 160.0)
moved.add(nid)
elif str(nid).startswith("region:"):
# keep finite but park markers to origin corner
if abs(x) > 1e4 or abs(y) > 1e4:
state.positions[nid] = (160.0, 160.0)
moved.add(nid)
return moved
def _overlaps_any(
node: str,
pos: dict[str, tuple[float, float]],
names: dict[str, str],
) -> bool:
x, y = pos[node]
fa = node_footprint(names.get(node, ""))
ax0, ay0, ax1, ay1 = x + fa[0], y + fa[1], x + fa[2], y + fa[3]
for b, (bx, by) in pos.items():
if b == node:
continue
if abs(bx - x) > 90 and abs(by - y) > 70:
continue
fb = node_footprint(names.get(b, ""))
bx0, by0, bx1, by1 = bx + fb[0], by + fb[1], bx + fb[2], by + fb[3]
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _rotate_about(
hub: tuple[float, float], leaf: tuple[float, float], ang: float
) -> tuple[float, float]:
hx, hy = hub
lx, ly = leaf
dx, dy = lx - hx, ly - hy
c, s = math.cos(ang), math.sin(ang)
return (hx + dx * c - dy * s, hy + dx * s + dy * c)
def frozen_portals_from_state(
state: LayoutState, portal_ids: list[str] | None = None
) -> set[str]:
from netx_topology_mcp.layout_ops.rigid_units import (
_hub_portals,
frozen_ids_for_protect,
)
if portal_ids:
raw = {str(x) for x in portal_ids if str(x)}
# Explicit dual-unit portals (~20) must stay frozen; only shrink if
# caller passed the inflated multi-membership set.
return _hub_portals(state, raw, cap=32)
return frozen_ids_for_protect(state, "portals")
def _large_graph_budget(n_links: int) -> dict[str, Any]:
"""Shrink search when E is large so MCP stdio does not time out.
Target: full polish_crossings on ~500-link graphs finishes in ~15–25s.
"""
e = max(0, int(n_links))
if e >= 1200:
return {
"hot_top_n": 6,
"hot_max_moves": 10,
"hot_max_sweeps": 1,
"hot_angle_step": math.pi / 6,
"hot_radii": [0.95, 1.2],
"cross_top_n": 4,
"cross_max_moves": 12,
"cross_max_sweeps": 1,
"cross_cand_cap": 48,
"straighten": False,
"skip_dual_full_x": True,
"untangle_rounds": 18,
"untangle_moves": 2,
"untangle_rank_cap": 36,
"untangle_angle_step": 30,
}
if e >= 400:
return {
"hot_top_n": 8,
"hot_max_moves": 14,
"hot_max_sweeps": 2,
"hot_angle_step": math.pi / 8,
"hot_radii": [0.9, 1.15, 1.35],
"cross_top_n": 5,
"cross_max_moves": 16,
"cross_max_sweeps": 2,
"cross_cand_cap": 72,
# straighten_channels is O(channels×modes×crossings); on 1k+ link
# metros it can stall for minutes with no cancel checkpoints.
"straighten": False,
"skip_dual_full_x": e >= 800,
"untangle_rounds": 28,
"untangle_moves": 3,
"untangle_rank_cap": 48,
"untangle_angle_step": 24,
}
if e >= 200:
return {
"hot_top_n": 8,
"hot_max_moves": 14,
"hot_max_sweeps": 2,
"hot_angle_step": math.pi / 9,
"hot_radii": [0.9, 1.1, 1.25],
"cross_top_n": 4,
"cross_max_moves": 14,
"cross_max_sweeps": 2,
"cross_cand_cap": 80,
"straighten": False,
"skip_dual_full_x": False,
"untangle_rounds": 24,
"untangle_moves": 3,
"untangle_rank_cap": 60,
"untangle_angle_step": 20,
}
return {
"hot_top_n": 10,
"hot_max_moves": 24,
"hot_max_sweeps": 4,
"hot_angle_step": math.pi / 12,
"hot_radii": [0.85, 1.0, 1.15, 1.35],
"cross_top_n": 6,
"cross_max_moves": 40,
"cross_max_sweeps": 6,
"cross_cand_cap": 220,
"straighten": True,
"skip_dual_full_x": False,
"untangle_rounds": 120,
"untangle_moves": 5,
"untangle_rank_cap": 140,
"untangle_angle_step": 15,
}
def press_hot_edges(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
) -> OpResult:
"""Rotate non-portal ends of top crossing edges about the other endpoint."""
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
del params
st = state.copy()
park_phantom_nodes(st)
frozen = frozen_portals_from_state(st, portal_ids)
pos = dict(st.positions)
names = st.names
links = st.links
adj = st.adj
budget = _large_graph_budget(len(links))
# Cap overrides — giant metros must not explode search via params.top_n.
top_n = min(
int(top_n if top_n is not None else budget["hot_top_n"]),
int(budget["hot_top_n"]) + (0 if len(links) >= 800 else 4),
)
max_moves = min(
int(max_moves if max_moves is not None else budget["hot_max_moves"]),
int(budget["hot_max_moves"]) + (0 if len(links) >= 800 else 6),
)
max_sweeps = min(
int(max_sweeps if max_sweeps is not None else budget["hot_max_sweeps"]),
int(budget["hot_max_sweeps"]),
)
angle_step = float(budget["hot_angle_step"])
radii_scale = list(budget["hot_radii"])
angles = [i * angle_step for i in range(1, max(2, int(round(2 * math.pi / angle_step))))]
skip_dual = bool(budget.get("skip_dual_full_x"))
touch_heartbeat()
start = count_edge_crossings(pos, links)
cur = start
moved: set[str] = set()
accepted = 0
for _sweep in range(max(1, int(max_sweeps))):
raise_if_cancelled()
touch_heartbeat()
_n, _nh, edge_hit = crossing_participation_full(pos, links)
touch_heartbeat()
tops = top_crossing_edges(
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
)
sweep_moves = 0
for ei, edge in enumerate(tops):
if ei % 2 == 0:
raise_if_cancelled()
touch_heartbeat()
a, b = str(edge["a_node_id"]), str(edge["b_node_id"])
if a not in pos or b not in pos:
continue
cand_pairs: list[tuple[str, str]] = []
if a not in frozen:
cand_pairs.append((a, b))
if b not in frozen:
cand_pairs.append((b, a))
if not cand_pairs:
continue
cand_pairs.sort(
key=lambda pair: (
0 if pair[1] in frozen else 1,
len(adj.get(pair[0], ())),
)
)
leaf, hub = cand_pairs[0]
hub_xy, leaf_xy = pos[hub], pos[leaf]
r0 = math.hypot(leaf_xy[0] - hub_xy[0], leaf_xy[1] - hub_xy[1]) or 200.0
# Cap orbit radius so long spokes cannot fling leaves across a giant bbox.
# Giant metros still need a higher reel target than 900 (bridges ≫5k).
r_cap = 2200.0 if len(links) >= 400 else 1400.0
r_use = min(r0, r_cap)
max_disp = 1100.0 if len(links) >= 400 else 1100.0
# Inward reel on long metro bridges needs a larger displacement budget
# (otherwise max_disp rejects the only moves that cut crossings).
if r0 > max_disp * 1.5:
max_disp = max(max_disp, min(r0 * 0.55, r0 - 0.35 * r_use))
local_before = crossings_involving_node(leaf, pos, links, adj)
best: tuple[int, tuple[float, float]] | None = None
best_dual: (
tuple[int, tuple[float, float], tuple[float, float]] | None
) = None
def _try_cand(cand: tuple[float, float]) -> None:
nonlocal best
if math.hypot(cand[0] - leaf_xy[0], cand[1] - leaf_xy[1]) > max_disp:
return
trial_pos = {**pos, leaf: cand}
if _overlaps_any(leaf, trial_pos, names):
return
c1 = crossings_after_node_move(
pos,
links,
adj,
leaf,
cand,
current_total=cur,
local_before=local_before,
)
if c1 < cur and (best is None or c1 < best[0]):
best = (c1, cand)
# Reel-in along the spoke first (shorten without hunting angles).
if r0 > r_use + 50.0:
ux = (leaf_xy[0] - hub_xy[0]) / r0
uy = (leaf_xy[1] - hub_xy[1]) / r0
for rt in (
r_use,
r_use * 1.15,
max(220.0, r0 * 0.25),
max(220.0, r0 * 0.4),
max(220.0, r0 * 0.55),
max(220.0, r0 * 0.7),
):
rt = min(rt, r0 - 1.0)
if rt < 120.0:
continue
_try_cand((hub_xy[0] + ux * rt, hub_xy[1] + uy * rt))
# Both ends walk toward the midpoint (metro bridges often need this).
# Skip on giant E: each trial is a full O(E²) count and stalls MCP.
if (not skip_dual) and r0 > 1500.0 and hub not in frozen:
mx = 0.5 * (hub_xy[0] + leaf_xy[0])
my = 0.5 * (hub_xy[1] + leaf_xy[1])
ux = (leaf_xy[0] - hub_xy[0]) / r0
uy = (leaf_xy[1] - hub_xy[1]) / r0
px, py = -uy, ux
dual_plans: list[tuple[tuple[float, float], tuple[float, float]]] = []
for t in (0.15, 0.25, 0.35, 0.45, 0.55):
dual_plans.append(
(
(
leaf_xy[0] + t * (mx - leaf_xy[0]),
leaf_xy[1] + t * (my - leaf_xy[1]),
),
(
hub_xy[0] + t * (mx - hub_xy[0]),
hub_xy[1] + t * (my - hub_xy[1]),
),
)
)
# Asymmetric: reel the free leaf harder than the hub.
for tl, th in ((0.45, 0.15), (0.6, 0.2), (0.7, 0.25)):
dual_plans.append(
(
(
leaf_xy[0] + tl * (mx - leaf_xy[0]),
leaf_xy[1] + tl * (my - leaf_xy[1]),
),
(
hub_xy[0] + th * (mx - hub_xy[0]),
hub_xy[1] + th * (my - hub_xy[1]),
),
)
)
for s in (-900.0, -520.0, -280.0, 280.0, 520.0, 900.0):
dual_plans.append(
(
(leaf_xy[0] + px * s, leaf_xy[1] + py * s),
(hub_xy[0] + px * s, hub_xy[1] + py * s),
)
)
for leaf_c, hub_c in dual_plans:
trial = {**pos, leaf: leaf_c, hub: hub_c}
if _overlaps_any(leaf, trial, names) or _overlaps_any(
hub, trial, names
):
continue
c1 = count_edge_crossings(trial, links)
if c1 < cur and (best_dual is None or c1 < best_dual[0]):
best_dual = (c1, leaf_c, hub_c)
for ang in angles:
for rs in radii_scale:
rx, ry = _rotate_about(hub_xy, leaf_xy, ang)
dx, dy = rx - hub_xy[0], ry - hub_xy[1]
L = math.hypot(dx, dy) or 1.0
cand = (
hub_xy[0] + dx / L * r_use * min(rs, 1.25),
hub_xy[1] + dy / L * r_use * min(rs, 1.25),
)
_try_cand(cand)
if best is None:
hx, hy = hub_xy
lx, ly = leaf_xy
dx, dy = lx - hx, ly - hy
L = math.hypot(dx, dy) or 1.0
px, py = -dy / L, dx / L
for s in (-720.0, -480.0, -320.0, -200.0, -120.0, 120.0, 200.0, 320.0, 480.0, 720.0):
_try_cand((lx + px * s, ly + py * s))
# Prefer dual mid-walk when it beats single-end moves.
if best_dual is not None and (
best is None or best_dual[0] < best[0]
):
pos[leaf] = best_dual[1]
pos[hub] = best_dual[2]
cur = best_dual[0]
moved.add(leaf)
moved.add(hub)
accepted += 1
sweep_moves += 1
if accepted >= max_moves:
break
continue
if best is None:
continue
pos[leaf] = best[1]
cur = best[0]
moved.add(leaf)
accepted += 1
sweep_moves += 1
if accepted >= max_moves:
break
if sweep_moves == 0 or accepted >= max_moves:
break
st.positions = pos
st.meta = dict(st.meta or {})
st.meta["press_hot_edges"] = {
"start_crossings": start,
"end_crossings": cur,
"accepted_moves": accepted,
"frozen_n": len(frozen),
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
}
return OpResult(
state=st,
moved=moved,
op="press_hot_edges",
params=st.meta["press_hot_edges"],
note=f"press_hot_edges {start}->{cur} moves={accepted}",
)
def _edges_crossing(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
a: str,
b: str,
) -> list[tuple[str, str]]:
if a not in pos or b not in pos:
return []
p1, p2 = pos[a], pos[b]
out: list[tuple[str, str]] = []
for c, d in links:
if len({a, b, c, d}) < 4 or c not in pos or d not in pos:
continue
if segments_properly_intersect(p1, p2, pos[c], pos[d]):
out.append((c, d))
return out
def _try_move_node(
pos: dict[str, tuple[float, float]],
names: dict[str, str],
links: list[tuple[str, str]],
adj: dict[str, set[str]],
node: str,
*,
jump: float,
cur: int,
cand_cap: int = 220,
) -> tuple[int, tuple[float, float]] | None:
x0, y0 = pos[node]
before_local = crossings_involving_node(node, pos, links, adj)
if before_local <= 0:
return None
ang_step = 30 if cand_cap <= 60 else (20 if cand_cap <= 100 else 10)
# Keep jumps local — long radii explode util on metro canvases.
# Large graphs still need mid-range radii to clear hot-edge crossers.
radii = (
(120, 220, 360, 520)
if cand_cap <= 80
else (80, 140, 220, 320, 450, 600, 800, 1100)
)
cands: list[tuple[float, float]] = []
for ang in range(0, 360, ang_step):
for r in radii:
if r > jump:
continue
rad = math.radians(ang)
cands.append((x0 + r * math.cos(rad), y0 + r * math.sin(rad)))
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if nbs:
cx = sum(p[0] for p in nbs) / len(nbs)
cy = sum(p[1] for p in nbs) / len(nbs)
dx, dy = x0 - cx, y0 - cy
L = math.hypot(dx, dy) or 1.0
ux, uy = dx / L, dy / L
px, py = -uy, ux
for s in (-800.0, -500.0, -350.0, -200.0, 200.0, 350.0, 500.0, 800.0):
cands.append((x0 + ux * s, y0 + uy * s))
cands.append((x0 + px * s, y0 + py * s))
if len(cands) > cand_cap:
cands = cands[:: max(1, len(cands) // cand_cap)]
best: tuple[int, tuple[float, float]] | None = None
for cand in cands:
if math.hypot(cand[0] - x0, cand[1] - y0) > jump + 1:
continue
trial_pos = {**pos, node: cand}
if _overlaps_any(node, trial_pos, names):
continue
after_local = crossings_involving_node(node, trial_pos, links, adj)
if after_local >= before_local:
continue
c1 = cur - before_local + after_local
if c1 < cur and (best is None or c1 < best[0]):
best = (c1, cand)
return best
def press_crossers(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
) -> OpResult:
"""Move non-portal nodes involved in crossings against top hot edges."""
del params
st = state.copy()
park_phantom_nodes(st)
frozen = frozen_portals_from_state(st, portal_ids)
pos = dict(st.positions)
names = st.names
links = st.links
adj = st.adj
budget = _large_graph_budget(len(links))
top_n = int(top_n if top_n is not None else budget["cross_top_n"])
max_moves = int(max_moves if max_moves is not None else budget["cross_max_moves"])
max_sweeps = int(max_sweeps if max_sweeps is not None else budget["cross_max_sweeps"])
cand_cap = int(budget["cross_cand_cap"])
start = count_edge_crossings(pos, links)
cur = start
moved: set[str] = set()
accepted = 0
for _sweep in range(max(1, int(max_sweeps))):
_n, _nh, edge_hit = crossing_participation_full(pos, links)
tops = top_crossing_edges(
pos, links, names=names, top_n=top_n, edge_participation=edge_hit
)
scores: dict[str, int] = {}
for he in tops:
a, b = str(he["a_node_id"]), str(he["b_node_id"])
for c, d in _edges_crossing(pos, links, a, b):
for nid in (c, d, a, b):
if nid in frozen or nid not in pos:
continue
deg = len(adj.get(nid, ()))
if deg >= 16:
continue
scores[nid] = scores.get(nid, 0) + int(he["crossing_hits"]) + max(
0, 10 - deg
)
ranked = sorted(scores, key=lambda n: (-scores[n], len(adj.get(n, ())), n))
node_cap = 16 if cand_cap <= 60 else (24 if cand_cap <= 100 else 40)
improved = False
for node in ranked[:node_cap]:
jump = 1400.0 if len(adj.get(node, ())) <= 4 else 1000.0
best = _try_move_node(
pos, names, links, adj, node, jump=jump, cur=cur, cand_cap=cand_cap
)
if best is None:
continue
pos[node] = best[1]
cur = best[0]
moved.add(node)
accepted += 1
improved = True
if accepted >= max_moves:
break
if not improved or accepted >= max_moves:
break
st.positions = pos
st.meta = dict(st.meta or {})
st.meta["press_crossers"] = {
"start_crossings": start,
"end_crossings": cur,
"accepted_moves": accepted,
"frozen_n": len(frozen),
"budget": {"top_n": top_n, "max_moves": max_moves, "max_sweeps": max_sweeps},
}
return OpResult(
state=st,
moved=moved,
op="press_crossers",
params=st.meta["press_crossers"],
note=f"press_crossers {start}->{cur} moves={accepted}",
)
def polish_crossings(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | None = None,
straighten: bool | None = None,
max_degree: int = 9,
untangle_rounds: int | None = None,
top_n: int | None = None,
max_moves: int | None = None,
max_sweeps: int | None = None,
) -> OpResult:
"""Pipeline: park phantoms → straighten → hot_edges → crossers → untangle."""
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))
# Giant graphs: never allow straighten even if caller asks (stalls for minutes).
if len(st.links) >= 800:
do_straighten = False
else:
do_straighten = (
bool(budget["straighten"]) if straighten is None else bool(straighten)
)
untangle_rounds = int(
untangle_rounds if untangle_rounds is not None else budget["untangle_rounds"]
)
if len(st.links) >= 800:
untangle_rounds = min(untangle_rounds, int(budget["untangle_rounds"]))
top_n = (
min(int(top_n), int(budget["hot_top_n"]))
if top_n is not None
else top_n
)
max_moves = (
min(int(max_moves), int(budget["hot_max_moves"]))
if max_moves is not None
else max_moves
)
report_progress(
"polish_start",
pct=42.0,
message=f"links={len(st.links)} rounds={untangle_rounds}",
links=len(st.links),
untangle_rounds=untangle_rounds,
)
start = count_edge_crossings(st.positions, st.links)
trace: list[dict[str, Any]] = []
moved: set[str] = set()
if do_straighten:
from netx_topology_mcp.layout_ops.channels import straighten_channels_greedy
raise_if_cancelled()
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
op = straighten_channels_greedy(st, params)
st = op.state
moved |= op.moved
trace.append({"op": "straighten_channels", "note": op.note, **(op.params or {})})
raise_if_cancelled()
report_progress("polish_hot_edges", pct=55.0, message="press_hot_edges")
op_h = press_hot_edges(
st,
params,
portal_ids=portal_ids,
top_n=top_n,
max_moves=max_moves,
max_sweeps=max_sweeps,
)
st = op_h.state
moved |= op_h.moved
trace.append({"op": "press_hot_edges", "note": op_h.note, **(op_h.params or {})})
raise_if_cancelled()
report_progress("polish_crossers", pct=62.0, message="press_crossers")
op_c = press_crossers(
st,
params,
portal_ids=portal_ids,
top_n=top_n,
max_moves=max_moves,
max_sweeps=max_sweeps,
)
st = op_c.state
moved |= op_c.moved
trace.append({"op": "press_crossers", "note": op_c.note, **(op_c.params or {})})
focus: list[str] = []
_n, node_hit, edge_hit = crossing_participation_full(st.positions, st.links)
tops_e = top_crossing_edges(
st.positions, st.links, names=st.names, top_n=5, edge_participation=edge_hit
)
for row in tops_e:
focus.append(str(row["a_node_id"]))
focus.append(str(row["b_node_id"]))
ranked_nodes = sorted(node_hit.items(), key=lambda kv: -kv[1])[:5]
focus.extend(nid for nid, _ in ranked_nodes)
focus = list(dict.fromkeys(focus))
raise_if_cancelled()
report_progress(
"polish_untangle",
pct=70.0,
message=f"untangle rounds={untangle_rounds} focus={len(focus)}",
untangle_rounds=untangle_rounds,
focus_n=len(focus),
)
op_u = untangle_crossings(
st,
params,
protect_rigid="portals",
focus_ids=focus,
max_rounds=untangle_rounds,
max_degree=max_degree,
target_crossings=40,
max_jump=1000.0,
moves_per_round=int(budget["untangle_moves"]),
frozen_ids=frozen_portals_from_state(st, portal_ids) if portal_ids else None,
rank_cap=int(budget["untangle_rank_cap"]),
angle_step=int(budget["untangle_angle_step"]),
refresh_every=3 if len(st.links) >= 200 else 1,
)
st = op_u.state
moved |= op_u.moved
trace.append({"op": "untangle", "note": op_u.note, **(op_u.params or {})})
end = count_edge_crossings(st.positions, st.links)
report_progress(
"polish_done",
pct=74.0,
message=f"crossings {start}->{end}",
crossings_before=start,
crossings_after=end,
)
st.meta = dict(st.meta or {})
st.meta["polish_crossings"] = {
"start_crossings": start,
"end_crossings": end,
"trace": trace,
"focus_n": len(focus),
"budget": budget,
"straighten": do_straighten,
"untangle_rounds": untangle_rounds,
}
return OpResult(
state=st,
moved=moved,
op="polish_crossings",
params=st.meta["polish_crossings"],
note=f"polish_crossings {start}->{end}",
)
def press_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
for key, cast in (
("top_n", int),
("max_moves", int),
("max_sweeps", int),
("max_degree", int),
("untangle_rounds", int),
):
if key in o and o[key] is not None:
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
pass
if "straighten" in o:
v = o["straighten"]
out["straighten"] = (
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):
out["source_view_ids"] = [str(x) for x in o["source_view_ids"] if str(x)]
return out

View file

@ -0,0 +1,352 @@
"""Compose atomic ops into multipass recipes (supports per-block scope)."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any, Callable
from netx_topology_mcp.layout_ops.score import score_op, score_state
from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope
from netx_topology_mcp.layout_ops.sides import place_side_branches
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.partition import pack_soft_blocks
from netx_topology_mcp.layout_ops.transforms import (
enforce_min_gap,
explode_clusters,
normalize_origin,
pack_utilization,
resolve_overlaps,
scale_edge_axes,
scale_region,
select_pins,
soft_nn_scale,
)
OpFn = Callable[..., OpResult]
REGISTRY: dict[str, OpFn] = {
"build_skeleton": build_skeleton,
"build_ring_skeleton": build_ring_skeleton,
"place_side_branches": place_side_branches,
"select_pins": select_pins,
"select_scope": select_scope,
"map_blocks": map_blocks,
"scale_region": scale_region,
"scale_edge_axes": scale_edge_axes,
"pack_utilization": pack_utilization,
"pack_soft_blocks": pack_soft_blocks,
"resolve_overlaps": resolve_overlaps,
"explode_clusters": explode_clusters,
"enforce_min_gap": enforce_min_gap,
"soft_nn_scale": soft_nn_scale,
"normalize_origin": normalize_origin,
"fix_overlaps_local": fix_overlaps_local,
"relax_hotspots": relax_hotspots,
"score": score_op,
}
def smd_corridor_v1_passes() -> list[dict[str, Any]]:
"""Global structure, then per-component explode+resolve (scoped)."""
return [
{"op": "build_skeleton"},
{"op": "place_side_branches"},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "scale_edge_axes"},
{"op": "soft_nn_scale"},
{"op": "map_blocks", "kwargs": {"mode": "component"}},
# Expanded at runtime by foreach_blocks
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"passes": [
{
"op": "explode_clusters",
"kwargs": {"thr": 20.0, "gap": 80.0, "axis": "perp"},
},
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "normalize_origin"},
{"op": "score"},
]
def smd_corridor_unstick_v1_passes() -> list[dict[str, Any]]:
"""v1 + second per-block free resolve for stubborn overlaps."""
return [
*smd_corridor_v1_passes()[:-2],
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"passes": [
{"op": "resolve_overlaps", "kwargs": {"mode": "free"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "normalize_origin"},
{"op": "score"},
]
def agg_rings_v1_passes() -> list[dict[str, Any]]:
"""Rings recipe: dual-hub min-rings when eligible, else UME petals.
Light overlap fix only — avoid explode that destroys ring geometry.
"""
return [
{"op": "build_ring_skeleton"},
{"op": "select_pins", "kwargs": {"mode": "agg"}},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "fix_overlaps_local"},
{"op": "normalize_origin"},
{"op": "score"},
]
def smd_corridor_compact_v1_passes() -> list[dict[str, Any]]:
"""Corridor skeleton, then pack/unstick **per component** — no global crush.
Global isotropic pack destroys crossings; islands are compacted in-place.
Ends with surgical local overlap fix.
"""
return [
{"op": "build_skeleton"},
{"op": "place_side_branches"},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "scale_edge_axes"},
{"op": "soft_nn_scale"},
{"op": "map_blocks", "kwargs": {"mode": "component"}},
{
"op": "foreach_blocks",
"kwargs": {
"mode": "component",
"min_size": 3,
"passes": [
{"op": "pack_utilization"},
{
"op": "explode_clusters",
"kwargs": {"thr": 8.0, "gap": 35.0, "axis": "along"},
},
{"op": "resolve_overlaps", "kwargs": {"mode": "lateral"}},
],
},
},
{"op": "select_scope", "kwargs": {"mode": "all"}},
{"op": "fix_overlaps_local"},
{"op": "normalize_origin"},
{"op": "score"},
]
RECIPES: dict[str, Callable[[], list[dict[str, Any]]]] = {
"smd_corridor_v1": smd_corridor_v1_passes,
"smd_corridor_unstick_v1": smd_corridor_unstick_v1_passes,
"smd_corridor_compact_v1": smd_corridor_compact_v1_passes,
"agg_rings_v1": agg_rings_v1_passes,
}
def _apply_op(
st: LayoutState,
name: str,
params: LayoutParams,
kwargs: dict[str, Any],
*,
ume_reference: bool,
) -> OpResult:
if name == "score":
return score_op(st, ume_reference=ume_reference)
if name == "select_pins":
return select_pins(st, **kwargs)
if name == "select_scope":
return select_scope(st, **kwargs)
if name == "map_blocks":
return map_blocks(st, **kwargs)
if name == "pack_soft_blocks":
return pack_soft_blocks(st, params, **kwargs)
if name == "scale_region":
return scale_region(st, params, **kwargs)
if name == "resolve_overlaps":
return resolve_overlaps(st, params, **kwargs)
if name == "explode_clusters":
return explode_clusters(st, params, **kwargs)
if name == "enforce_min_gap":
return enforce_min_gap(st, params, **kwargs)
if name == "fix_overlaps_local":
return fix_overlaps_local(st, params)
if name == "relax_hotspots":
return relax_hotspots(st, params)
if name == "build_ring_skeleton":
return build_ring_skeleton(st, params)
fn = REGISTRY.get(name)
if fn is None:
raise KeyError(f"unknown op: {name}")
return fn(st, params)
def _trace_entry(
i: int,
op: str,
note: str,
params: dict[str, Any],
moved_n: int,
pinned_n: int,
metrics: dict[str, Any] | None = None,
) -> dict[str, Any]:
m = metrics or {}
return {
"i": i,
"op": op,
"note": note,
"params": params,
"moved_n": moved_n,
"pinned_n": pinned_n,
"edge_crossings": m.get("edge_crossings"),
"nn_p50": m.get("nn_p50"),
"footprint_overlap_pairs": m.get("footprint_overlap_pairs"),
"label_overlap_pairs": m.get("label_overlap_pairs"),
"space_utilization": m.get("space_utilization"),
"bbox": m.get("bbox"),
"grade": (m.get("grade") or {}).get("overall") if m else None,
}
def _expand_foreach(
st: LayoutState,
kwargs: dict[str, Any],
params: LayoutParams,
*,
ume_reference: bool,
base_i: int,
) -> tuple[LayoutState, list[dict[str, Any]]]:
"""Run sub-passes once per block. Score only once after all blocks (fast)."""
mode = str(kwargs.get("mode") or "component")
sub_passes: list[dict[str, Any]] = list(kwargs.get("passes") or [])
min_size = int(kwargs.get("min_size") or 2)
blocks = list_blocks(st, mode=mode)
# Record resolved mode (auto → hub_territory|component) for agents/trace.
from netx_topology_mcp.layout_ops.partition import resolve_block_mode
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
st = st.copy()
st.meta["block_mode"] = resolved_mode
st.meta["blocks"] = [sorted(b) for b in blocks]
trace: list[dict[str, Any]] = []
step_i = base_i
total_moved = 0
for bi, block in enumerate(blocks):
if len(block) < min_size:
continue
st = select_scope(st, mode="ids", node_ids=block).state
block_moved = 0
for step in sub_passes:
name = str(step.get("op") or "")
kw = dict(step.get("kwargs") or {})
result = _apply_op(st, name, params, kw, ume_reference=ume_reference)
st = result.state
block_moved += len(result.moved)
total_moved += len(result.moved)
trace.append(
_trace_entry(
step_i,
"foreach_block",
f"block[{bi}] n={len(block)} moved≈{block_moved}",
{"block_index": bi, "scope_n": len(block), "mode": mode},
block_moved,
len(st.pinned),
)
)
step_i += 1
st = select_scope(st, mode="all").state
metrics = score_state(st, ume_reference=ume_reference)
trace.append(
_trace_entry(
step_i,
"foreach_blocks",
f"done blocks={len(blocks)} moved≈{total_moved}",
{"mode": mode, "blocks_n": len(blocks), "moved_n": total_moved},
total_moved,
len(st.pinned),
metrics,
)
)
return st, trace
def run_recipe(
state: LayoutState,
recipe: str | list[dict[str, Any]] = "smd_corridor_v1",
params: LayoutParams | None = None,
*,
ume_reference: bool = False,
) -> tuple[LayoutState, list[dict[str, Any]], dict[str, Any]]:
"""Run passes; return final state, per-pass trace, final score."""
params = params or LayoutParams()
passes = RECIPES[recipe]() if isinstance(recipe, str) else recipe
st = state.copy()
trace: list[dict[str, Any]] = []
i = 0
for step in passes:
name = str(step.get("op") or "")
kwargs = dict(step.get("kwargs") or {})
if name == "foreach_blocks":
st, sub = _expand_foreach(
st, kwargs, params, ume_reference=ume_reference, base_i=i
)
trace.extend(sub)
i = (trace[-1]["i"] + 1) if trace else i + 1
continue
result = _apply_op(st, name, params, kwargs, ume_reference=ume_reference)
st = result.state
# Lightweight steps skip full score; score at structural checkpoints + end.
want_score = name in {
"build_skeleton",
"place_side_branches",
"soft_nn_scale",
"normalize_origin",
"score",
"pack_utilization",
}
metrics = score_state(st, ume_reference=ume_reference) if want_score else None
trace.append(
_trace_entry(
i,
result.op,
result.note,
result.params,
len(result.moved),
len(st.pinned),
metrics,
)
)
i += 1
final = score_state(st, ume_reference=ume_reference)
final["recipe"] = recipe if isinstance(recipe, str) else "custom"
final["params"] = asdict(params)
final["passes"] = len(trace)
return st, trace, final
def positions_for_api(state: LayoutState) -> list[dict[str, float | str]]:
return [
{
"fabric_node_id": n,
"x": round(state.positions[n][0], 1),
"y": round(state.positions[n][1], 1),
}
for n in state.positions
]

View file

@ -0,0 +1,566 @@
"""Rigid-body densify: drag a dual-unit by an external corridor (bridge orbit).
For each staging rigid group, pick an external bridge tip as pivot and sweep the
exclusive members on concentric circles (angle × radius_scale). Prefer inward
radii to raise util / shrink bbox while keeping overlaps=0 and crossings within
``x_slack``.
This reuses the polar spirit of ``compose_orbit`` attach, but runs *after*
compose as a densify/util pass (portals stay frozen when shared).
"""
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.compose_orbit import crossings_touching
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta
from netx_topology_mcp.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6
_DEFAULT_ANGLE_STEP = 30
# Bias inward: pull units toward external tips to densify.
_DEFAULT_RADII = (0.55, 0.7, 0.85, 1.0, 1.15, 1.35)
# Large canvases: coarser polar grid (full E² count is too expensive).
_LARGE_N = 600
_LARGE_ANGLE_STEP = 45
_LARGE_RADII = (0.55, 0.75, 0.9, 1.1)
_MIN_BRIDGE = 180.0
_FOOTPRINT_MIN = 90.0
def _apply_polar(
base: dict[str, tuple[float, float]],
members: list[str],
pivot: tuple[float, float],
*,
angle: float,
radius_scale: float,
) -> dict[str, tuple[float, float]]:
cos_a, sin_a = math.cos(angle), math.sin(angle)
cx, cy = pivot
out = dict(base)
for n in members:
if n not in out:
continue
x, y = out[n]
dx, dy = x - cx, y - cy
rx = (dx * cos_a - dy * sin_a) * radius_scale
ry = (dx * sin_a + dy * cos_a) * radius_scale
out[n] = (cx + rx, cy + ry)
return out
def _bbox_area(pos: dict[str, tuple[float, float]], ids: set[str] | None = None) -> float:
pts = [
pos[n]
for n in (ids or pos.keys())
if n in pos
and abs(pos[n][0]) <= _COORD_ABS_MAX
and abs(pos[n][1]) <= _COORD_ABS_MAX
]
if len(pts) < 2:
return 1.0
xs = [p[0] for p in pts]
ys = [p[1] for p in pts]
return max(max(xs) - min(xs), 1e-6) * max(max(ys) - min(ys), 1e-6)
def _footprint_hits(
pos: dict[str, tuple[float, float]],
moved: set[str],
frozen: set[str],
*,
min_dist: float = _FOOTPRINT_MIN,
) -> int:
"""Cheap center collisions between moved nodes and the rest."""
hits = 0
md2 = min_dist * min_dist
others = [
(pos[n][0], pos[n][1])
for n in pos
if n not in moved and n not in frozen
and abs(pos[n][0]) <= _COORD_ABS_MAX
]
for n in moved:
if n not in pos:
continue
x, y = pos[n]
for ox, oy in others:
dx, dy = x - ox, y - oy
if dx * dx + dy * dy < md2:
hits += 1
break
return hits
def _parse_groups(
groups: list[dict[str, Any]],
valid: set[str],
) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
parsed_raw: list[tuple[str, list[str], list[str]]] = []
for g in groups:
key = str(g.get("key") or "")
members = [str(n) for n in (g.get("node_ids") or []) if str(n) in valid]
if len(members) < 2:
continue
pivots = [str(p) for p in (g.get("pivots") or []) if str(p) in valid]
for n in members:
counts[n] = counts.get(n, 0) + 1
parsed_raw.append((key, members, pivots))
shared = {n for n, c in counts.items() if c > 1}
out: list[dict[str, Any]] = []
for key, members, pivots in parsed_raw:
piv = pivots or [n for n in members if n in shared]
exclusive = [n for n in members if n not in shared]
out.append(
{
"key": key,
"members": members,
"pivots": piv,
"exclusive": exclusive,
"shared": [n for n in members if n in shared],
}
)
return out
def _external_bridges(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
members: set[str],
exclusive: set[str],
) -> list[tuple[float, str, str]]:
"""Return (length, inner_id, outer_id) sorted longest-first."""
bridges: list[tuple[float, str, str]] = []
for a, b in links:
if a not in pos or b not in pos:
continue
a_in, b_in = a in members, b in members
if a_in == b_in:
continue
inner, outer = (a, b) if a_in else (b, a)
# Prefer tips that are exclusive (true corridor leaf), not shared portals.
if exclusive and inner not in exclusive and inner not in members:
continue
L = math.hypot(pos[inner][0] - pos[outer][0], pos[inner][1] - pos[outer][1])
if L < _MIN_BRIDGE:
continue
bridges.append((L, inner, outer))
bridges.sort(reverse=True)
return bridges
def _movers_for_group(g: dict[str, Any]) -> list[str]:
"""Nodes that move under bridge orbit (exclusive; whole group if free)."""
if g["exclusive"]:
return list(g["exclusive"])
# No shared membership → whole body can orbit.
return list(g["members"])
def rigid_orbit_candidates_for_group(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
group: dict[str, Any],
*,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] = _DEFAULT_RADII,
bridges_per_group: int = 4,
cand_cap: int = 120,
x0: int | None = None,
area0: float | None = None,
x_slack: int = 40,
) -> list[dict[str, Any]]:
"""Ranked candidates for one rigid group (best first).
Ranking uses *partial* crossings (edges touching movers) — O(focus×E) —
not full global E². Callers verify top picks with ``count_edge_crossings``.
"""
del x0 # reserved; global verify happens in round
members = set(group["members"])
exclusive = set(group["exclusive"])
movers = _movers_for_group(group)
if len(movers) < 2:
return []
frozen_portals = set(group["shared"] or group["pivots"])
bridges = _external_bridges(pos, links, members, exclusive)[
: max(1, bridges_per_group)
]
if not bridges:
return []
area_base = _bbox_area(pos) if area0 is None else float(area0)
step = max(15, min(60, int(angle_step)))
rads = tuple(float(r) for r in radii) or _DEFAULT_RADII
touch_slack = max(4, int(x_slack))
cands: list[dict[str, Any]] = []
for L, inner, outer in bridges:
if outer not in pos or inner not in pos:
continue
pivot = pos[outer]
focus0 = set(movers) | {inner, outer}
x_touch0 = crossings_touching(pos, links, focus0)
for deg in range(0, 360, step):
ang = math.radians(deg)
for rs in rads:
if deg == 0 and abs(rs - 1.0) < 1e-9:
continue
trial = _apply_polar(pos, movers, pivot, angle=ang, radius_scale=rs)
for p in frozen_portals:
if p in pos:
trial[p] = pos[p]
trial[outer] = pos[outer]
ov_cheap = _footprint_hits(trial, set(movers), frozen_portals)
if ov_cheap > 0:
continue
x_touch = crossings_touching(trial, links, focus0)
if x_touch > x_touch0 + touch_slack:
continue
area1 = _bbox_area(trial)
area_ratio = area_base / max(area1, 1e-6)
# Prefer denser bbox, then fewer partial crossings.
rank = (area_ratio, -(x_touch - x_touch0), -x_touch, -L)
cands.append(
{
"group_key": group["key"],
"inner": inner,
"outer": outer,
"bridge_len": round(L, 1),
"angle_deg": float(deg),
"radius_scale": float(rs),
"crossings_touch": x_touch,
"crossings_touch0": x_touch0,
"area_ratio": round(area_ratio, 4),
"delta_touch": x_touch - x_touch0,
"movers_n": len(movers),
"rank": rank,
"positions": {n: trial[n] for n in movers if n in trial},
}
)
if len(cands) >= cand_cap * 2:
break
if len(cands) >= cand_cap * 2:
break
if len(cands) >= cand_cap * 2:
break
cands.sort(key=lambda r: r["rank"], reverse=True)
useful = [
c
for c in cands
if c["area_ratio"] >= 1.004 or c["delta_touch"] < 0
]
out = useful[:cand_cap] if useful else cands[: min(3, len(cands))]
for i, c in enumerate(out, start=1):
c["rank_i"] = i
c.pop("rank", None)
return out
def rigid_orbit_round(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
top_n: int = 24,
bridges_per_group: int = 3,
angle_step: int = _DEFAULT_ANGLE_STEP,
radii: tuple[float, ...] | list[float] | None = None,
cand_cap: int = 80,
x_slack: int | None = None,
max_accepts: int = 16,
) -> OpResult:
"""Greedy bridge-orbit densify over top rigid groups."""
from netx_topology_mcp.layout_jobs import (
raise_if_cancelled,
report_progress,
touch_heartbeat,
)
st = state.copy()
groups = groups if groups is not None else groups_from_compose_meta(
(st.meta or {}).get("compose_views")
)
valid = {
n
for n, (x, y) in st.positions.items()
if abs(x) <= _COORD_ABS_MAX
and abs(y) <= _COORD_ABS_MAX
and math.isfinite(x)
and math.isfinite(y)
}
parsed = _parse_groups(groups or [], valid)
if not parsed:
return OpResult(
state=st,
moved=set(),
op="rigid_orbit",
params={"groups": 0},
note="rigid_orbit:no_groups",
)
large = len(st.positions) >= _LARGE_N or len(st.links) >= _LARGE_N
before = score_state(st, fast=True)
before_util = float((before.get("summary") or {}).get("util") or 0.0)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_ov = len(overlapping_nodes(st))
area0 = _bbox_area(st.positions)
slack = (
max(8, int(before_x * 0.08))
if x_slack is None
else max(0, int(x_slack))
)
# Auto-coarsen polar grid on large canvases unless caller overrode radii.
use_angle = int(angle_step)
if radii is None and large:
rads = _LARGE_RADII
use_angle = max(use_angle, _LARGE_ANGLE_STEP)
bridges_per_group = min(int(bridges_per_group), 2)
cand_cap = min(int(cand_cap), 48)
top_n = min(int(top_n), 20)
max_accepts = min(int(max_accepts), 10)
else:
rads = tuple(float(r) for r in (radii or _DEFAULT_RADII))
# Order groups by longest external bridge (sparsity / stretch drivers).
scored_g: list[tuple[float, dict[str, Any]]] = []
for g in parsed:
br = _external_bridges(
st.positions, st.links, set(g["members"]), set(g["exclusive"])
)
if not br:
continue
scored_g.append((br[0][0], g))
scored_g.sort(key=lambda t: t[0], reverse=True)
ordered = [g for _L, g in scored_g[: max(1, int(top_n))]]
pos = {n: (float(p[0]), float(p[1])) for n, p in st.positions.items()}
links = list(st.links)
cur_x = before_x
cur_area = area0
moved: set[str] = set()
trace: list[dict[str, Any]] = []
accepts = 0
verify_k = 3 if large else 5
report_progress(
"rigid_orbit",
pct=48.0,
message=f"scan {len(ordered)} groups large={large}",
groups=len(ordered),
)
for gi, g in enumerate(ordered):
if accepts >= max(1, int(max_accepts)):
break
raise_if_cancelled()
touch_heartbeat()
if gi == 0 or gi % 2 == 0 or accepts > 0:
pct = 48.0 + 25.0 * (gi / max(len(ordered), 1))
report_progress(
"rigid_orbit",
pct=min(72.0, pct),
message=f"group {gi + 1}/{len(ordered)} accepts={accepts}",
group_key=g["key"],
accepts=accepts,
)
cands = rigid_orbit_candidates_for_group(
pos,
links,
g,
angle_step=use_angle,
radii=rads,
bridges_per_group=bridges_per_group,
cand_cap=cand_cap,
x0=cur_x,
area0=cur_area,
x_slack=slack,
)
if not cands:
trace.append({"group_key": g["key"], "skipped": "no_candidates"})
continue
applied = False
for cand in cands[:verify_k]:
trial = dict(pos)
movers_ids = set(cand.get("positions") or {})
for n, xy in (cand.get("positions") or {}).items():
trial[n] = xy
frozen = set(g.get("shared") or g.get("pivots") or [])
if _footprint_hits(trial, movers_ids, frozen) > 0:
continue
raise_if_cancelled()
touch_heartbeat()
x1 = count_edge_crossings(trial, links)
if x1 > cur_x + slack:
continue
area1 = _bbox_area(trial)
area_ratio = cur_area / max(area1, 1e-6)
util_proxy_up = area_ratio >= 1.004
x_down = x1 < cur_x
if not util_proxy_up and not x_down:
continue
# Accept (footprint-ok). Residual AABB → ensure_zero_overlap in layout_tool.
pos = trial
cur_x = x1
cur_area = area1
moved.update(movers_ids)
accepts += 1
applied = True
trace.append(
{
"group_key": g["key"],
"applied": True,
"inner": cand.get("inner"),
"outer": cand.get("outer"),
"angle_deg": cand.get("angle_deg"),
"radius_scale": cand.get("radius_scale"),
"bridge_len": cand.get("bridge_len"),
"crossings": x1,
"area_ratio": round(area_ratio, 4),
"movers_n": len(movers_ids),
}
)
break
if not applied:
best = cands[0]
trace.append(
{
"group_key": g["key"],
"skipped": "no_accept",
"best_area_ratio": best.get("area_ratio"),
"best_delta_touch": best.get("delta_touch"),
}
)
st.positions = pos
fin = score_state(st, fast=True)
end_util = float((fin.get("summary") or {}).get("util") or 0.0)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
end_ov = len(overlapping_nodes(st))
# Residual overlaps: keep accepted moves; layout_tool ensure_zero_overlap repairs.
# Only hard-revert when we never accepted and somehow got worse.
if end_ov > before_ov and accepts == 0:
return OpResult(
state=state.copy(),
moved=set(),
op="rigid_orbit",
params={
"reverted": True,
"reason": "overlaps",
"start_util": before_util,
"start_crossings": before_x,
"trace": trace,
},
note="rigid_orbit:reverted overlaps",
)
util_up = end_util > before_util + 1e-6
area_up = (area0 / max(_bbox_area(st.positions), 1e-6)) >= 1.004
x_ok = end_x <= before_x + slack
# Crossing-only gains count (util may stay flat when bbox is hull-dominated).
if not ((util_up or area_up or (accepts > 0 and end_x < before_x)) and x_ok) and accepts == 0:
meta = {
"reverted": True,
"reason": "no_gain",
"start_util": before_util,
"end_util": before_util,
"start_crossings": before_x,
"end_crossings": before_x,
"x_slack": slack,
"scanned_groups": len(ordered),
"trace": trace[:40],
}
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["rigid_orbit"] = meta
return OpResult(
state=st0,
moved=set(),
op="rigid_orbit",
params=meta,
note="rigid_orbit:reverted no_gain",
)
meta = {
"reverted": False,
"start_util": before_util,
"end_util": end_util,
"start_crossings": before_x,
"end_crossings": end_x,
"accepted": accepts,
"moved_n": len(moved),
"x_slack": slack,
"scanned_groups": len(ordered),
"bbox_area_ratio": round(area0 / max(_bbox_area(st.positions), 1e-6), 4),
"trace": trace[:60],
}
st.meta = dict(st.meta or {})
st.meta["rigid_orbit"] = meta
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="rigid_orbit",
params=meta,
note=(
f"rigid_orbit util {before_util:.4f}->{end_util:.4f} "
f"x {before_x}->{end_x} accepted={accepts}/{len(ordered)}"
),
)
def rigid_orbit_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
o = overrides or {}
out: dict[str, Any] = {}
if o.get("top_n") is not None:
try:
out["top_n"] = max(1, min(80, int(o["top_n"])))
except (TypeError, ValueError):
pass
if o.get("bridges_per_group") is not None:
try:
out["bridges_per_group"] = max(1, min(12, int(o["bridges_per_group"])))
except (TypeError, ValueError):
pass
if o.get("angle_step") is not None:
try:
out["angle_step"] = max(10, min(90, int(o["angle_step"])))
except (TypeError, ValueError):
pass
if o.get("cand_cap") is not None:
try:
out["cand_cap"] = max(20, min(400, int(o["cand_cap"])))
except (TypeError, ValueError):
pass
if o.get("max_accepts") is not None:
try:
out["max_accepts"] = max(1, min(64, int(o["max_accepts"])))
except (TypeError, ValueError):
pass
if o.get("x_slack") is not None:
try:
out["x_slack"] = max(0, int(o["x_slack"]))
except (TypeError, ValueError):
pass
if isinstance(o.get("radii"), (list, tuple)) and o["radii"]:
try:
out["radii"] = tuple(float(r) for r in o["radii"])
except (TypeError, ValueError):
pass
groups = o.get("rigid_groups") or o.get("_rigid_groups")
if isinstance(groups, list):
out["groups"] = groups
# round defaults true; allow explicit false for preview-only catalog
if "round" in o:
v = o.get("round")
out["round"] = v in (True, 1, "1", "true", "yes", "on") or v is True
else:
out["round"] = True
if o.get("group_key") is not None:
out["group_key"] = str(o.get("group_key") or "").strip()
return out

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,181 @@
"""Minimal ring faces: detect chordless cycles and piercing by foreign edges.
Stage-1/2 use this to keep ring interiors hollow (metro face integrity).
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Any
from netx_topology_mcp.layout_metrics import segments_properly_intersect
from netx_topology_mcp.layout_ops.state import LayoutState
from netx_topology_mcp.layout_topology_quality import (
RING_MAX_LEN,
find_short_chordless_cycles,
)
@dataclass(frozen=True)
class RingFace:
node_ids: tuple[str, ...]
def as_dict(self) -> dict[str, Any]:
return {"n": len(self.node_ids), "node_ids": list(self.node_ids)}
def extract_ring_faces(
state: LayoutState,
*,
max_len: int = min(6, RING_MAX_LEN),
max_cycles: int = 80,
) -> list[RingFace]:
cycles = find_short_chordless_cycles(
state.adj, max_len=max_len, max_cycles=max_cycles
)
faces = [RingFace(tuple(c)) for c in cycles]
faces.sort(key=lambda f: (len(f.node_ids), f.node_ids[0] if f.node_ids else ""))
return faces
def _point_in_poly(
x: float, y: float, poly: list[tuple[float, float]]
) -> bool:
"""Ray casting; boundary counts as outside (conservative)."""
n = len(poly)
if n < 3:
return False
inside = False
j = n - 1
for i in range(n):
xi, yi = poly[i]
xj, yj = poly[j]
if ((yi > y) != (yj > y)) and (
x < (xj - xi) * (y - yi) / (yj - yi + 1e-15) + xi
):
inside = not inside
j = i
return inside
def ring_polygon(
face: RingFace, pos: dict[str, tuple[float, float]]
) -> list[tuple[float, float]] | None:
pts = [pos[n] for n in face.node_ids if n in pos]
if len(pts) < 3:
return None
return pts
def count_ring_pierces(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
faces: list[RingFace],
) -> dict[str, Any]:
"""Foreign edges that properly cross a ring boundary edge."""
pierced = 0
pierce_x = 0
for face in faces:
rset = set(face.node_ids)
poly = ring_polygon(face, pos)
if not poly:
continue
n = len(face.node_ids)
ring_edges = []
for i in range(n):
a, b = face.node_ids[i], face.node_ids[(i + 1) % n]
if a in pos and b in pos:
ring_edges.append((a, b))
hit = False
for a, b in links:
if a not in pos or b not in pos:
continue
# foreign if at least one endpoint outside ring
if a in rset and b in rset:
continue
p1, p2 = pos[a], pos[b]
for u, v in ring_edges:
if len({a, b, u, v}) < 4:
continue
if segments_properly_intersect(p1, p2, pos[u], pos[v]):
pierce_x += 1
hit = True
if hit:
pierced += 1
return {
"ring_count": len(faces),
"rings_pierced": pierced,
"pierce_crossings": pierce_x,
}
def place_ring_rectangle(
face: RingFace,
*,
center: tuple[float, float],
width: float,
height: float,
start_angle: float = -math.pi / 2,
) -> dict[str, tuple[float, float]]:
"""Place ring nodes on an axis-aligned rectangle (hollow face)."""
nodes = list(face.node_ids)
n = len(nodes)
if n < 3:
return {}
cx, cy = center
hw, hh = width / 2, height / 2
# Perimeter parametrization 0..1 around rectangle
perim = 2 * (width + height)
out: dict[str, tuple[float, float]] = {}
for i, nid in enumerate(nodes):
t = (i / n + (start_angle + math.pi / 2) / (2 * math.pi)) % 1.0
d = t * perim
if d <= width:
x, y = cx - hw + d, cy - hh
elif d <= width + height:
x, y = cx + hw, cy - hh + (d - width)
elif d <= 2 * width + height:
x, y = cx + hw - (d - width - height), cy + hh
else:
x, y = cx - hw, cy + hh - (d - 2 * width - height)
out[nid] = (x, y)
return out
def eject_intruders(
pos: dict[str, tuple[float, float]],
faces: list[RingFace],
*,
push: float = 40.0,
protected: set[str] | None = None,
) -> dict[str, tuple[float, float]]:
"""Push non-ring nodes out of ring polygons."""
protected = protected or set()
out = dict(pos)
for face in faces:
rset = set(face.node_ids)
poly = ring_polygon(face, out)
if not poly:
continue
cx = sum(p[0] for p in poly) / len(poly)
cy = sum(p[1] for p in poly) / len(poly)
for nid, (x, y) in list(out.items()):
if nid in rset or nid in protected:
continue
if not _point_in_poly(x, y, poly):
continue
dx, dy = x - cx, y - cy
dist = math.hypot(dx, dy) or 1.0
out[nid] = (cx + dx / dist * (dist + push), cy + dy / dist * (dist + push))
return out
def ring_faces_report(state: LayoutState) -> dict[str, Any]:
faces = extract_ring_faces(state)
pierces = count_ring_pierces(state.positions, state.links, faces)
return {
**pierces,
"sample": [f.as_dict() for f in faces[:10]],
"tip": "最小环应中空;channel_metro / protect_rings 优先降 pierced。",
}

View file

@ -0,0 +1,35 @@
"""Ring/chain layout entry: dual-hub min-rings first, else UME petals.
- ``min_rings``: two agg hubs + ≥2 parallel corridors → nested ellipse bands
(A1-ULU / PLAU↔ATP style). Prefer when structure says ``agg_bar``.
- ``ume_petals``: rect-perimeter petals around AN anchors (legacy Sugiyama path).
"""
from __future__ import annotations
from netx_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.sugiyama import build_sugiyama_layout
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
params = params or LayoutParams()
dual = build_min_ring_skeleton(state, params)
if dual is not None:
return OpResult(
state=dual.state,
moved=dual.moved,
op="build_ring_skeleton",
params={**(dual.params or {}), "mode": "min_rings"},
note=dual.note,
)
out = build_sugiyama_layout(state, params)
st = out.state
st.meta["rings_mode"] = st.meta.get("rings_mode") or "ume_petals"
return OpResult(
state=st,
moved=out.moved,
op="build_ring_skeleton",
params={**(out.params or {}), "mode": st.meta.get("rings_mode", "ume_petals")},
note=out.note or "UME petal rect-perimeter skeleton",
)

View file

@ -0,0 +1,153 @@
"""Block / scope selection — ops act only on active nodes."""
from __future__ import annotations
from typing import Any, Iterable
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
def active_nodes(state: LayoutState) -> set[str]:
"""Nodes currently in scope (all if scope is None/empty meaning unrestricted)."""
if state.scope is None:
return set(state.positions.keys())
return {n for n in state.scope if n in state.positions}
def movable_nodes(state: LayoutState, *, respect_pins: bool = True) -> set[str]:
"""Nodes an op may move: in scope, not hard agg/core, optionally not soft-pinned."""
act = active_nodes(state)
hard = {n for n in act if state.layers.get(n) in ("agg", "core")}
out = act - hard
if respect_pins:
out -= set(state.pinned)
return out
def list_blocks(
state: LayoutState,
*,
mode: str = "component",
) -> list[set[str]]:
"""Partition graph into blocks for per-block recipes.
Modes:
- component: hard CC (default; existing recipes)
- layer / bbox_quad: coarse buckets
- hub_territory: soft blocks by hub-seeded BFS (our algo)
- leiden / soft: hub territories + optional igraph Leiden on leftovers
"""
ids = set(state.positions.keys())
m = (mode or "component").strip().lower()
if m == "auto":
from netx_topology_mcp.layout_ops.partition import resolve_block_mode
m = resolve_block_mode(state, "auto")
if m == "component":
active = {
n
for n in ids
if state.layers.get(n) in ("core", "agg", "access")
}
comps = connected_components(active, state.adj)
blocks = [set(c) for c in comps if c]
# orphans / other
rest = ids - {n for b in blocks for n in b}
if rest:
blocks.append(rest)
return blocks
if m in {"hub_territory", "leiden", "soft"}:
from netx_topology_mcp.layout_ops.partition import (
partition_soft_blocks,
soft_blocks_as_sets,
)
return soft_blocks_as_sets(partition_soft_blocks(state, mode=m))
if m == "layer":
by: dict[str, set[str]] = {}
for n in ids:
by.setdefault(state.layers.get(n, "other"), set()).add(n)
return [by[k] for k in sorted(by.keys()) if by[k]]
if m == "bbox_quad":
if not ids:
return []
x0, y0, x1, y1 = bbox(state.positions)
mx, my = (x0 + x1) / 2, (y0 + y1) / 2
quads: list[set[str]] = [set() for _ in range(4)]
for n, (x, y) in state.positions.items():
qi = (0 if x < mx else 1) + (0 if y < my else 2)
quads[qi].add(n)
return [q for q in quads if q]
# single full block
return [ids] if ids else []
def select_scope(
state: LayoutState,
*,
mode: str = "all",
node_ids: Iterable[str] | None = None,
component_index: int | None = None,
layer: str | None = None,
bbox_region: tuple[float, float, float, float] | None = None,
) -> OpResult:
"""Set state.scope. mode=all clears scope (full graph)."""
st = state.copy()
pos = st.positions
if mode == "all" or (mode == "ids" and not node_ids):
st.scope = None
return OpResult(
state=st, moved=set(), op="select_scope", params={"mode": "all"}, note="scope=all"
)
if mode == "ids" and node_ids is not None:
scope = {n for n in node_ids if n in pos}
elif mode == "component":
blocks = list_blocks(st, mode="component")
idx = 0 if component_index is None else int(component_index)
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
elif mode == "layer":
lyr = layer or "access"
scope = {n for n in pos if st.layers.get(n) == lyr}
elif mode == "bbox" and bbox_region is not None:
x0, y0, x1, y1 = bbox_region
scope = {
n
for n, (x, y) in pos.items()
if x0 <= x <= x1 and y0 <= y <= y1
}
elif mode == "bbox_quad":
blocks = list_blocks(st, mode="bbox_quad")
idx = 0 if component_index is None else int(component_index)
scope = blocks[idx] if 0 <= idx < len(blocks) else set()
else:
scope = set(pos.keys())
st.scope = scope
return OpResult(
state=st,
moved=set(),
op="select_scope",
params={"mode": mode, "scope_n": len(scope), "component_index": component_index},
note=f"scope={mode} n={len(scope)}",
)
def map_blocks(
state: LayoutState,
*,
mode: str = "component",
) -> OpResult:
"""Record block partition in meta (for agents / foreach). Does not change scope."""
st = state.copy()
blocks = list_blocks(st, mode=mode)
st.meta["blocks"] = [sorted(b) for b in blocks]
st.meta["block_mode"] = mode
return OpResult(
state=st,
moved=set(),
op="map_blocks",
params={"mode": mode, "blocks_n": len(blocks), "sizes": [len(b) for b in blocks]},
note=f"blocks={len(blocks)} mode={mode}",
)

View file

@ -0,0 +1,57 @@
"""Score a LayoutState with layout_metrics + layout_stats composite."""
from __future__ import annotations
from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
from netx_topology_mcp.layout_stats import analyze_layout_stats
def score_state(
state: LayoutState,
*,
ume_reference: bool = False,
fast: bool | None = None,
) -> dict[str, Any]:
nodes = [
{
"fabric_node_id": n,
"name": state.names.get(n, n),
"x": state.positions[n][0],
"y": state.positions[n][1],
}
for n in state.positions
]
edges = [{"a_node_id": a, "b_node_id": b} for a, b in state.links]
# Giant canvases: skip ring pierce (O(rings·E²)-ish) unless forced full.
if fast is None:
fast = len(nodes) >= 600 or len(state.links) >= 700
return analyze_layout_stats(
nodes, edges, ume_reference=ume_reference, fast=bool(fast)
)
def score_op(state: LayoutState, *, ume_reference: bool = False) -> OpResult:
st = state.copy()
m = score_state(st, ume_reference=ume_reference)
st.meta["last_score"] = m
report = m.get("report") or {}
verdict = report.get("verdict") or m.get("summary") or {}
return OpResult(
state=st,
moved=set(),
op="score",
params={
"total": verdict.get("total"),
"overall": verdict.get("overall"),
"headline": verdict.get("headline"),
"overlap": (report.get("overlap") or {}).get("status"),
"crossing": (report.get("crossing") or {}).get("status"),
"sparsity": (report.get("sparsity") or {}).get("status"),
"edge_crossings": m.get("edge_crossings"),
"nn_p50": m.get("nn_p50"),
"space_utilization": m.get("space_utilization"),
},
note=f"score={verdict.get('total')} {verdict.get('headline')}",
)

View file

@ -0,0 +1,87 @@
"""Atomic op: place side-branch NEs on both sides of spine."""
from __future__ import annotations
import math
from netx_topology_mcp.layout_ops.graph_util import spine_backbone
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def place_side_branches(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""X-gain + lane push for non-pinned / off-spine access nodes."""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
if not pos:
return OpResult(state=st, moved=set(), op="place_side_branches", note="empty")
# Amplify X about global center (borrow left/right).
xs = [pos[n][0] for n in pos]
cx = sum(xs) / len(xs)
pos = {n: (cx + (x - cx) * params.x_gain, y) for n, (x, y) in pos.items()}
moved = set(pos.keys())
ens = [n for n in pos if st.layers.get(n) == "access"]
if ens:
if st.spine:
spine = spine_backbone([n for n in ens if n in st.spine], st.adj, st.names)
if len(spine) < 2:
spine = spine_backbone(ens, st.adj, st.names)
else:
spine = spine_backbone(ens, st.adj, st.names)
spine_set = set(spine)
st.spine = spine_set
tang: dict[str, tuple[float, float]] = {}
for i, n in enumerate(spine):
if i + 1 < len(spine):
x0, y0 = pos[n]
x1, y1 = pos[spine[i + 1]]
elif i > 0:
x1, y1 = pos[n]
x0, y0 = pos[spine[i - 1]]
else:
tang[n] = (1.0, 0.0)
continue
dx, dy = x1 - x0, y1 - y0
L = math.hypot(dx, dy) or 1.0
tang[n] = (dx / L, dy / L)
for n in ens:
if n in spine_set or n in st.pinned:
continue
sx, sy = pos[n]
nearest = min(
spine, key=lambda s: math.hypot(pos[s][0] - sx, pos[s][1] - sy)
)
tx, ty = tang.get(nearest, (1.0, 0.0))
nx, ny = -ty, tx
px, py = pos[nearest]
cross = tx * (sy - py) - ty * (sx - px)
sign = 1.0 if cross >= 0 else -1.0
cur = math.hypot(sx - px, sy - py)
target = max(params.lane, cur)
if cur < 1e-6:
pos[n] = (px + nx * sign * target, py + ny * sign * target)
elif cur < target:
sc = target / cur
pos[n] = (px + (sx - px) * sc, py + (sy - py) * sc)
# Pin skeleton after sides placed
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in st.spine
}
st.positions = pos
st.pinned = pin
st.last_moved = moved - pin
return OpResult(
state=st,
moved=st.last_moved,
op="place_side_branches",
params={"x_gain": params.x_gain, "lane": params.lane, "pinned_n": len(pin)},
note="side lanes from spine; pinned=agg+core+spine",
)

View file

@ -0,0 +1,427 @@
"""Select dual-unit batches to sink from a root canvas onto a child region.
Pure selection / park / per-unit layout helpers — HTTP moves live in http_tools.
"""
from __future__ import annotations
from typing import Any
from netx_topology_mcp.layout_ops.dual_units import DualUnit, layout_dual_unit
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState
def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
counts: dict[str, int] = {}
for u in units:
for p in (u.portal_a, u.portal_b):
counts[p] = counts.get(p, 0) + 1
return counts
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."""
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))
def select_dual_unit_batch(
units: list[DualUnit],
*,
max_units: int = 3,
min_nodes: int = 8,
max_nodes: int = 80,
max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None,
) -> list[DualUnit]:
"""Greedy pick detachable dual-units within size / batch caps."""
exclude_ids = exclude_ids or set()
share = _portal_share_counts(units)
candidates: list[DualUnit] = []
for u in units:
members = u.member_ids()
if not members:
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):
continue
candidates.append(u)
candidates.sort(key=lambda u: unit_detach_score(u, share))
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}
if interior & claimed:
continue
next_ids = claimed | members
if len(next_ids) > int(max_batch_nodes):
continue
picked.append(u)
claimed |= members
return picked
def batch_node_ids(units: list[DualUnit]) -> list[str]:
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)
return out
def leftover_batch_ids(
source_ids: list[str],
*,
max_batch_nodes: int = 120,
exclude_ids: set[str] | None = None,
) -> list[str]:
"""When dual_units are exhausted, take a plain leftover chunk."""
exclude_ids = exclude_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:
continue
out.append(sid)
if len(out) >= int(max_batch_nodes):
break
return out
def park_positions(
src_pos: dict[str, tuple[float, float]],
node_ids: list[str],
*,
sink_pos: dict[str, tuple[float, float]] | None = None,
pad: float = 280.0,
links: list[tuple[str, str]] | None = None,
) -> list[dict[str, Any]]:
"""Park batch via compose_orbit-style block sweep (not fixed right)."""
pts = {nid: src_pos[nid] for nid in node_ids if nid in src_pos}
if not pts:
out: list[dict[str, Any]] = []
cols = max(4, int(len(node_ids) ** 0.5) or 1)
for i, nid in enumerate(node_ids):
out.append(
{
"fabric_node_id": nid,
"x": float((i % cols) * 160),
"y": float((i // cols) * 120),
}
)
return out
# Normalize local bbox to origin before orbit attach.
xs = [p[0] for p in pts.values()]
ys = [p[1] for p in pts.values()]
min_x, min_y = min(xs), min(ys)
local = {nid: (x - min_x, y - min_y) for nid, (x, y) in pts.items()}
world, _meta = orbit_attach_to_sink(
local,
sink_pos or {},
links=links or [],
pad=pad,
)
return positions_to_patch(world)
def units_as_batch_rows(
units: list[DualUnit],
names: dict[str, str] | None = None,
) -> list[dict[str, Any]]:
names = names or {}
rows: list[dict[str, Any]] = []
for u in units:
d = u.as_dict(names)
rows.append(
{
"unit_id": d.get("unit_id"),
"node_count": d.get("node_count"),
"portals": [d.get("portal_a"), d.get("portal_b")],
"names": [d.get("portal_a_name"), d.get("portal_b_name")],
"node_ids": d.get("node_ids") or [],
}
)
return rows
def subgraph_state(state: LayoutState, members: set[str]) -> LayoutState:
"""Induced subgraph for one dual-unit (local layout_dual_unit)."""
ids = {m for m in members if m}
adj = {n: {v for v in state.adj.get(n, ()) if v in ids} for n in ids}
links = [(a, b) for a, b in state.links if a in ids and b in ids]
return LayoutState(
positions={n: state.positions[n] for n in ids if n in state.positions},
names={n: state.names.get(n, n) for n in ids},
layers={n: state.layers.get(n, "access") for n in ids},
links=links,
adj=adj,
meta={"ids": sorted(ids)},
)
def _bbox(pos: dict[str, tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in pos.values()]
ys = [p[1] for p in pos.values()]
return min(xs), min(ys), max(xs), max(ys)
def _translate(
pos: dict[str, tuple[float, float]], dx: float, dy: float
) -> dict[str, tuple[float, float]]:
return {n: (x + dx, y + dy) for n, (x, y) in pos.items()}
def orbit_attach_to_sink(
local: dict[str, tuple[float, float]],
sink_pos: dict[str, tuple[float, float]],
*,
links: list[tuple[str, str]] | None = None,
pad: float = 280.0,
angle_step: int = 30,
radii: tuple[float, ...] = (0.75, 1.0, 1.25, 1.55, 1.95),
cand_cap: int = 96,
) -> tuple[dict[str, tuple[float, float]], dict[str, Any]]:
"""Block-sweep attach: reuse compose_orbit orphan/portal orbit pick.
Scores candidates by (partial crossings, footprint hits, bridge length)
against the existing sink world — same spirit as compose_orbit hang-block.
"""
from netx_topology_mcp.layout_ops.compose_orbit import (
_orbit_candidates_one_portal,
_orbit_candidates_orphan,
_orbit_candidates_two_portal,
_pick_best,
_thin_candidates,
)
from netx_topology_mcp.layout_ops.compose_views import (
_rank_shared_pivots,
_rigid_align_to_world,
)
link_list = list(links or [])
if not local:
return {}, {"via": "empty", "cands": 0}
if not sink_pos:
xs = [p[0] for p in local.values()]
ys = [p[1] for p in local.values()]
min_x, min_y = min(xs), min(ys)
seeded = {
n: (x - min_x + 40.0, y - min_y + 40.0) for n, (x, y) in local.items()
}
return seeded, {"via": "seed", "cands": 1, "score": [0, 0, 0.0]}
world = dict(sink_pos)
shared_raw = [nid for nid in local if nid in world]
# membership hint: portals already on sink count as glue
membership = {n: 2 for n in shared_raw}
shared = _rank_shared_pivots(shared_raw, membership)
exclusive = [n for n in local if n not in set(shared)]
old_ids = set(world)
if shared:
prefer = None
wxs = [p[0] for p in world.values()]
wys = [p[1] for p in world.values()]
if wxs and wys:
prefer = (0.5 * (min(wxs) + max(wxs)), 0.5 * (min(wys) + max(wys)))
aligned = _rigid_align_to_world(
local,
world,
shared,
prefer_center=prefer,
links=link_list,
)
if len(shared) >= 2:
cands = _orbit_candidates_two_portal(
world,
aligned,
shared[:2],
exclusive,
local,
angle_step=angle_step,
)
via = "orbit_dual"
else:
cands = _orbit_candidates_one_portal(
world,
aligned,
shared[0],
exclusive,
angle_step=angle_step,
radii=radii,
)
via = "orbit_portal"
else:
cands = _orbit_candidates_orphan(
world,
local,
pad=pad,
angle_step=angle_step,
radii=radii,
links=link_list,
)
via = "orbit_orphan"
cands = _thin_candidates(cands, cand_cap)
new_ids = set(exclusive) if exclusive else set(local) - old_ids
# Fresh rescore every call — never sticky pick.
best, best_sc, best_i = _pick_best(cands, link_list, new_ids, old_ids)
out = {n: best[n] for n in local if n in best}
meta = {
"via": via,
"shared_n": len(shared),
"shared": shared[:4],
"cands": len(cands),
"best_i": best_i,
"score": [best_sc[0], best_sc[1], round(best_sc[2], 1)],
"exclusive_n": len(exclusive),
}
return out, meta
def layout_and_pack_batch(
state: LayoutState,
units: list[DualUnit],
*,
sink_pos: dict[str, tuple[float, float]] | None = None,
pad: float = 280.0,
unit_gap: float = 220.0,
params: LayoutParams | None = None,
links: list[tuple[str, str]] | None = None,
) -> tuple[dict[str, tuple[float, float]], list[dict[str, Any]], dict[str, Any]]:
"""layout_dual_unit each unit, strip-pack, then orbit-attach onto sink.
Attach uses compose_orbit block sweep (partial crossings / overlap /
bridge length) — not a fixed right-side park.
"""
params = params or LayoutParams()
reports: list[dict[str, Any]] = []
packed: dict[str, tuple[float, float]] = {}
cursor_x = 0.0
row_max_h = 0.0
for u in units:
members = u.member_ids()
if not members:
continue
sub = subgraph_state(state, members)
# Seed missing coords so layout_dual_unit has a canvas.
for n in members:
if n not in sub.positions:
sub.positions[n] = (0.0, 0.0)
op = layout_dual_unit(sub, params, unit=u)
local = {
n: op.state.positions[n]
for n in members
if n in op.state.positions
}
if not local:
reports.append(
{
"unit_id": u.unit_id,
"accepted": False,
"note": "no_positions",
"unit_crossings": None,
}
)
continue
# Align shared portals already placed by an earlier unit in this batch.
shared = [p for p in (u.portal_a, u.portal_b) if p in packed and p in local]
if shared:
# Translate so first shared portal matches world.
piv = shared[0]
dx = packed[piv][0] - local[piv][0]
dy = packed[piv][1] - local[piv][1]
local = _translate(local, dx, dy)
# Exclusive members only — keep prior portal coords.
for n, xy in local.items():
if n not in packed:
packed[n] = xy
min_x, min_y, max_x, max_y = _bbox(
{n: packed[n] for n in members if n in packed}
)
cursor_x = max(cursor_x, max_x + float(unit_gap))
row_max_h = max(row_max_h, max_y - min_y)
else:
min_x, min_y, max_x, max_y = _bbox(local)
local = _translate(local, cursor_x - min_x, -min_y)
for n, xy in local.items():
if n not in packed:
packed[n] = xy
w = max_x - min_x
h = max_y - min_y
cursor_x += w + float(unit_gap)
row_max_h = max(row_max_h, h)
reports.append(
{
"unit_id": u.unit_id,
"accepted": bool(op.params.get("accepted")),
"unit_crossings": op.params.get("unit_crossings"),
"note": op.note,
"node_count": len(members),
"portals": [u.portal_a, u.portal_b],
}
)
if not packed:
return {}, reports, {"via": "empty"}
# Prefer fabric links from caller (sink∪source); fall back to state links.
attach_links = list(links) if links is not None else list(state.links)
world, attach_meta = orbit_attach_to_sink(
packed,
sink_pos or {},
links=attach_links,
pad=pad,
)
return world, reports, attach_meta
def positions_to_patch(
pos: dict[str, tuple[float, float]],
) -> list[dict[str, Any]]:
return [
{"fabric_node_id": nid, "x": float(xy[0]), "y": float(xy[1])}
for nid, xy in sorted(pos.items())
]
def merge_view_links(
*payloads: dict[str, Any],
) -> list[tuple[str, str]]:
"""Undirected fabric pairs from one or more view GET payloads."""
seen: set[tuple[str, str]] = set()
out: list[tuple[str, str]] = []
for payload in payloads:
for e in payload.get("edges") or []:
if not isinstance(e, dict):
continue
a = str(e.get("a_node_id") or e.get("source") or "").strip()
b = str(e.get("b_node_id") or e.get("target") or "").strip()
if not a or not b or a == b:
continue
key = (a, b) if a < b else (b, a)
if key in seen:
continue
seen.add(key)
out.append(key)
return out

View file

@ -0,0 +1,231 @@
"""Atomic op: build corridor skeleton (Tutte + island pack)."""
from __future__ import annotations
from collections import defaultdict, deque
from itertools import product
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import (
bbox,
connected_components,
order_ans,
spine_backbone,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _place_component_tutte(
comp: list[str],
state: LayoutState,
params: LayoutParams,
) -> tuple[dict[str, tuple[float, float]], list[str]]:
"""Tutte embedding for one component. Returns positions + spine order (EN)."""
g, layers, names = state.adj, state.layers, state.names
edge_pairs = state.links
ans = sorted([n for n in comp if layers[n] == "agg"], key=lambda n: names[n])
ens = [n for n in comp if layers[n] == "access"]
cores = [n for n in comp if layers[n] == "core"]
ens_set = set(ens)
an_order = order_ans(ans, ens_set, g, names)
pinned: dict[str, tuple[float, float]] = {}
for i, a in enumerate(an_order):
pinned[a] = (i * params.an_gap, params.an_y)
for i, c in enumerate(sorted(cores, key=lambda n: names[n])):
pinned[c] = (
(len(an_order) - 1) * params.an_gap / 2 + i * params.an_gap,
params.an_y - params.an_gap,
)
pos: dict[str, list[float]] = {
n: [float(pinned[n][0]), float(pinned[n][1])] for n in pinned
}
if ens:
dist: dict[str, int] = {}
nearest: dict[str, str] = {}
q: deque[str] = deque()
for a in an_order:
for nb in g.get(a, ()):
if nb in ens_set and nb not in dist:
dist[nb] = 1
nearest[nb] = a
q.append(nb)
while q:
u = q.popleft()
for v in g.get(u, ()):
if v in ens_set and v not in dist:
dist[v] = dist[u] + 1
nearest[v] = nearest[u]
q.append(v)
for n in ens:
if n not in dist:
dist[n] = max(dist.values(), default=1) + 1
nearest[n] = an_order[0] if an_order else n
buckets: dict[tuple[str, int], list[str]] = defaultdict(list)
for n in ens:
buckets[(nearest[n], dist[n])].append(n)
for (an, d), group in buckets.items():
group.sort(key=lambda n: names[n])
ax = pinned[an][0] if an in pinned else 0.0
for k, n in enumerate(group):
pos[n] = [ax + (k - (len(group) - 1) / 2) * 0.35, float(d)]
for _ in range(80):
for n in ens:
neigh = [v for v in g.get(n, ()) if v in pos]
if not neigh:
continue
pos[n][0] = sum(pos[v][0] for v in neigh) / len(neigh)
pos[n][1] = sum(pos[v][1] for v in neigh) / len(neigh)
for n, xy in pinned.items():
pos[n][0], pos[n][1] = float(xy[0]), float(xy[1])
for n in ens:
if pos[n][1] < params.an_y + 0.4:
pos[n][1] = params.an_y + 0.4
raw = {n: (pos[n][0], pos[n][1]) for n in pos}
x0, y0, x1, y1 = bbox(raw)
bw, bh = max(x1 - x0, 1e-6), max(y1 - y0, 1e-6)
unit = {n: ((x - x0) / bw, (y - y0) / bh) for n, (x, y) in raw.items()}
w = max(params.an_gap * max(len(an_order), 1), 1120.0) * params.width_mul
h = max(140.0 * (1 + len(ens) ** 0.5), 840.0) * params.height_mul
node_set = set(comp)
internal = [(a, b) for a, b in edge_pairs if a in node_set and b in node_set]
best, best_c = None, 10**9
for fx, fy in product([False, True], repeat=2):
p = {
n: (((1 - x) if fx else x) * w, ((1 - y) if fy else y) * h)
for n, (x, y) in unit.items()
}
if an_order and ens:
if sum(p[a][1] for a in an_order) / len(an_order) > sum(
p[e][1] for e in ens
) / len(ens):
continue
c = count_edge_crossings(p, internal)
if c < best_c:
best_c, best = c, p
assert best is not None
x0, y0, _, _ = bbox(best)
placed = {n: (x - x0, y - y0) for n, (x, y) in best.items()}
spine = spine_backbone(ens, g, names) if ens else []
return placed, spine
def _pack_islands(
islands: list[dict[str, tuple[float, float]]],
edge_pairs: list[tuple[str, str]],
params: LayoutParams,
) -> dict[str, tuple[float, float]]:
islands = sorted(islands, key=lambda p: -len(p))
cols = 2 if len(islands) >= 3 else 1
n = len(islands)
def build(flips: list[bool]) -> dict[str, tuple[float, float]]:
cells: list[list[dict]] = [[] for _ in range(cols)]
for i, isl in enumerate(islands):
loc = dict(isl)
if flips[i]:
x0, _, x1, _ = bbox(loc)
mid = (x0 + x1) / 2
loc = {nid: (2 * mid - x, y) for nid, (x, y) in loc.items()}
cells[i % cols].append(loc)
col_w = []
for c in range(cols):
w = 0.0
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
w = max(w, x1 - x0 + params.island_pad_x)
col_w.append(max(w, 1.0))
out: dict[str, tuple[float, float]] = {}
for c in range(cols):
oy = 0.0
ox = sum(col_w[:c])
for loc in cells[c]:
x0, y0, x1, y1 = bbox(loc)
pad = (col_w[c] - params.island_pad_x - (x1 - x0)) / 2
for nid, (x, y) in loc.items():
out[nid] = (ox + pad + (x - x0), oy + (y - y0))
oy += (y1 - y0) + params.island_pad_y
return out
k = min(n, 5)
best, best_c = None, 10**9
for bits in product([False, True], repeat=k):
flips = list(bits) + [False] * (n - k)
p = build(flips)
c = count_edge_crossings(p, edge_pairs)
if c < best_c:
best_c, best = c, p
assert best is not None
return best
def build_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
"""Place Tutte corridor skeleton; pin agg+core+spine; leave sides for next op."""
params = params or LayoutParams()
st = state.copy()
ids = list(st.meta.get("ids") or st.names.keys())
active = {i for i in ids if st.layers.get(i) in ("core", "agg", "access")}
others = sorted(
[i for i in ids if st.layers.get(i) == "other"], key=lambda n: st.names[n]
)
comps = connected_components(active, st.adj)
islands: list[dict[str, tuple[float, float]]] = []
spine_all: set[str] = set()
for c in comps:
placed, spine = _place_component_tutte(c, st, params)
if placed:
islands.append(placed)
spine_all.update(spine)
pos = _pack_islands(islands, st.links, params) if islands else {}
if others:
if pos:
x0, y0, x1, y1 = bbox(pos)
fallback_x, fallback_y = x1 + params.island_pad_x * 0.4, y0
else:
fallback_x, fallback_y = 0.0, 0.0
for i, n in enumerate(others):
neigh = [v for v in st.adj.get(n, ()) if v in pos]
if neigh:
mx = sum(pos[v][0] for v in neigh) / len(neigh)
my = sum(pos[v][1] for v in neigh) / len(neigh)
pos[n] = (
mx + params.pitch * (1.0 + (i % 3) * 0.35),
my + params.side * ((i % 5) - 2) * 0.25,
)
else:
pos[n] = (
fallback_x + (i % 3) * params.pitch,
fallback_y + (i // 3) * params.side,
)
for n in ids:
if n not in pos:
pos[n] = (0.0, 0.0)
st.positions = pos
st.spine = spine_all
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in spine_all
}
st.pinned = pin
st.last_moved = set(pos.keys())
st.meta["components"] = len(comps)
return OpResult(
state=st,
moved=set(pos.keys()),
op="build_skeleton",
params={
"width_mul": params.width_mul,
"height_mul": params.height_mul,
"an_gap": params.an_gap,
"spine_n": len(spine_all),
"pinned_n": len(pin),
},
note="Tutte islands packed; pinned=agg+core+spine",
)

View file

@ -0,0 +1,323 @@
"""Stage-2: per soft-block stub fan + spine; accept only if global crossings drop."""
from __future__ import annotations
import math
from collections import deque
from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _longest_path(
comp: set[str], adj: dict[str, set[str]], prefer: str | None = None
) -> list[str]:
if not comp:
return []
if len(comp) == 1:
return [next(iter(comp))]
def far(src: str) -> tuple[str, list[str]]:
prev: dict[str, str | None] = {src: None}
q: deque[str] = deque([src])
last = src
while q:
u = q.popleft()
last = u
for v in adj.get(u, ()):
if v in comp and v not in prev:
prev[v] = u
q.append(v)
path = [last]
while prev[path[-1]] is not None:
path.append(prev[path[-1]]) # type: ignore[index]
path.reverse()
return last, path
start = prefer if prefer in comp else next(iter(comp))
a, _ = far(start)
_, path = far(a)
return path
def soft_petals_greedy(
state: LayoutState,
params: LayoutParams | None = None,
*,
hub_id: str | None = None,
step: float | None = None,
min_block_size: int = 3,
) -> OpResult:
"""Fan stubs around each soft-block hub; keep a block only if crossings fall."""
params = params or LayoutParams()
step_px = float(step if step is not None else max(params.pitch * 0.92, 160.0))
pos = dict(state.positions)
links = list(state.links)
adj = state.adj
cross = count_edge_crossings(pos, links)
blocks = partition_soft_blocks(state, mode="hub_territory")
hubs = set(pick_hub_seeds(state))
for n, ly in state.layers.items():
if ly == "core":
hubs.add(n)
if hub_id:
hub_id = str(hub_id).strip()
blocks = [b for b in blocks if b.hub_id == hub_id]
if not blocks:
return OpResult(
state=state,
moved=set(),
op="soft_petals",
params={"hub_id": hub_id},
note="hub_not_found",
)
def _commit_trial(
trial: dict[str, tuple[float, float]],
*,
hub: str,
hx: float,
hy: float,
best_c: int,
) -> tuple[dict[str, tuple[float, float]] | None, int]:
c1 = count_edge_crossings(trial, links)
if c1 >= best_c:
return None, best_c
st_try = state.copy()
st_try.positions = trial
stf = fix_overlaps_local(st_try, params).state
for hid in hubs:
if hid != hub and hid in pos:
stf.positions[hid] = pos[hid]
stf.positions[hub] = (hx, hy)
trial2 = {k: (float(v[0]), float(v[1])) for k, v in stf.positions.items()}
c2 = count_edge_crossings(trial2, links)
if c2 < best_c:
return trial2, c2
return None, best_c
accepted: list[dict[str, Any]] = []
for b in sorted(blocks, key=lambda x: -len(x.node_ids)):
hub = b.hub_id
members = [n for n in b.node_ids if n in pos]
if not hub or hub not in pos or len(members) < min_block_size:
continue
pinned = set(hubs)
hx, hy = pos[hub]
member_set = set(members)
stubs = [
n
for n in adj.get(hub, ())
if n in member_set and n not in pinned
]
if len(stubs) < 1:
continue
owner: dict[str, str] = {}
q: deque[str] = deque()
for stub in stubs:
owner[stub] = stub
q.append(stub)
while q:
u = q.popleft()
for v in adj.get(u, ()):
if v not in member_set or v == hub or v in owner or v in pinned:
continue
owner[v] = owner[u]
q.append(v)
def ang(s: str) -> float:
x, y = pos[s]
return math.atan2(y - hy, x - hx)
best_trial = None
best_c = cross
kind = None
# 1) Rigid rotate existing petal around hub (keeps local structure).
movable = [n for n in members if n != hub and n not in pinned]
for deg in (-90, -60, -45, -30, -15, 15, 30, 45, 60, 90):
rad = math.radians(deg)
ca, sa = math.cos(rad), math.sin(rad)
trial = dict(pos)
for n in movable:
x, y = pos[n]
dx, dy = x - hx, y - hy
trial[n] = (hx + dx * ca - dy * sa, hy + dx * sa + dy * ca)
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = f"rotate:{deg}"
# 2) Swap adjacent stub territories by exchanging polar angles of nodes.
stubs_geo = sorted(stubs, key=ang)
if len(stubs_geo) >= 2:
for i in range(len(stubs_geo) - 1):
a, bstub = stubs_geo[i], stubs_geo[i + 1]
ta = {n for n, o in owner.items() if o == a} | {a}
tb = {n for n, o in owner.items() if o == bstub} | {bstub}
aa, ab = ang(a), ang(bstub)
delta = ab - aa
trial = dict(pos)
for n in ta:
if n in pinned:
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) + delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
for n in tb:
if n in pinned:
continue
x, y = pos[n]
dx, dy = x - hx, y - hy
r = math.hypot(dx, dy)
th = math.atan2(dy, dx) - delta
trial[n] = (hx + r * math.cos(th), hy + r * math.sin(th))
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = f"swap:{a[:6]}-{bstub[:6]}"
# 3) Full re-fan with geo / crossing-aware orders + cyclic rotations.
stubs_x = order_stubs_crossing_aware(hub, stubs, adj, member_set, owner)
orders: list[list[str]] = []
for base in (stubs_geo, stubs_x):
for k in range(len(base)):
orders.append(base[k:] + base[:k])
orders.append(list(reversed(base)))
seen_ord: set[tuple[str, ...]] = set()
for stubs_ord in orders:
key = tuple(stubs_ord)
if key in seen_ord:
continue
seen_ord.add(key)
angs = [ang(s) for s in stubs_ord]
a0, a1 = min(angs) - 0.1, max(angs) + 0.1
if a1 - a0 < 0.7:
mid = sum(angs) / len(angs)
a0, a1 = mid - 1.0, mid + 1.0
# Also try a wider fan spanning more of the circle when crowded.
spans = [(a0, a1)]
if len(stubs_ord) >= 3:
mid = 0.5 * (a0 + a1)
half = max(0.9, 0.55 * (a1 - a0) + 0.4)
spans.append((mid - half, mid + half))
for sa0, sa1 in spans:
trial = dict(pos)
for i, stub in enumerate(stubs_ord):
mid = (
0.5 * (sa0 + sa1)
if len(stubs_ord) == 1
else sa0 + (sa1 - sa0) * i / (len(stubs_ord) - 1)
)
ux, uy = math.cos(mid), math.sin(mid)
trial[stub] = (hx + ux * step_px, hy + uy * step_px)
terr = {n for n, o in owner.items() if o == stub}
spine = _longest_path(terr, adj, prefer=stub)
if stub in spine:
si = spine.index(stub)
fwd, bwd = spine[si:], list(reversed(spine[: si + 1]))
spine = fwd if len(fwd) >= len(bwd) else bwd
sx, sy = trial[stub]
for k, n in enumerate(spine):
if n in pinned or n == stub:
continue
trial[n] = (
sx + ux * step_px * max(k, 1),
sy + uy * step_px * max(k, 1),
)
for n in terr:
if n in pinned or n == stub or n in spine:
continue
portals = [v for v in adj.get(n, ()) if v in trial]
if not portals:
continue
px, py = trial[portals[0]]
trial[n] = (px - uy * step_px * 0.7, py + ux * step_px * 0.7)
trial[hub] = (hx, hy)
got, best_c = _commit_trial(trial, hub=hub, hx=hx, hy=hy, best_c=best_c)
if got is not None:
best_trial = got
kind = "refan"
if best_trial is None:
continue
pos = best_trial
cross = best_c
accepted.append(
{
"hub_id": hub,
"hub_name": state.names.get(hub, hub),
"crossings": cross,
"n": len(members),
"kind": kind,
}
)
if not accepted:
return OpResult(
state=state,
moved=set(),
op="soft_petals",
params={"accepted_n": 0, "hub_id": hub_id},
note="no_block_improved",
)
out = state.copy()
out.positions = pos
moved = {
n
for n, p in pos.items()
if n in state.positions and p != state.positions[n]
}
out.meta = dict(out.meta or {})
out.meta["soft_petals"] = {
"accepted": accepted,
"crossings_after": cross,
}
return OpResult(
state=out,
moved=moved,
op="soft_petals",
params={
"accepted_n": len(accepted),
"accepted": accepted,
"hub_id": hub_id,
"crossings_after": cross,
},
note=f"soft_petals:{len(accepted)} blocks",
)
def soft_petals_params_from_overrides(
overrides: dict[str, Any] | None,
) -> dict[str, Any]:
out: dict[str, Any] = {}
if not overrides:
return out
if overrides.get("hub_id"):
out["hub_id"] = str(overrides["hub_id"]).strip()
if overrides.get("step") is not None:
try:
out["step"] = float(overrides["step"])
except (TypeError, ValueError):
pass
if overrides.get("min_block_size") is not None:
try:
out["min_block_size"] = int(overrides["min_block_size"])
except (TypeError, ValueError):
pass
return out

View file

@ -0,0 +1,75 @@
"""Layout state shared by atomic ops."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class LayoutParams:
"""Tunable knobs for recipe / ops (agent edits these, not ad-hoc scripts)."""
pitch: float = 200.0
side: float = 170.0
an_gap: float = 420.0
an_y: float = 0.0
island_pad_x: float = 220.0
island_pad_y: float = 200.0
width_mul: float = 3.5
height_mul: float = 2.0
x_gain: float = 1.8
lane: float = 260.0
target_nn: float = 150.0
scale_cap: float = 2.2
target_util: float = 0.08
min_util: float = 0.03
pack_min_scale: float = 0.55
pack_iters: int = 4
# Cap uniform pack so nn_p50 does not fall below this (keeps icons readable).
pack_nn_floor: float = 140.0
overlap_iters: int = 160
overlap_step: float = 3.0
margin: float = 160.0
# explode_clusters / enforce_min_gap
cluster_thr: float = 8.0
cluster_gap: float = 35.0
min_center_gap: float = 160.0
@dataclass
class LayoutState:
positions: dict[str, tuple[float, float]] = field(default_factory=dict)
pinned: set[str] = field(default_factory=set)
names: dict[str, str] = field(default_factory=dict)
layers: dict[str, str] = 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)
# None = whole graph active; else only these ids may be moved by scoped ops.
scope: set[str] | None = None
meta: dict[str, Any] = field(default_factory=dict)
last_moved: set[str] = field(default_factory=set)
def copy(self) -> LayoutState:
return LayoutState(
positions=dict(self.positions),
pinned=set(self.pinned),
names=dict(self.names),
layers=dict(self.layers),
links=list(self.links),
adj={k: set(v) for k, v in self.adj.items()},
spine=set(self.spine),
scope=None if self.scope is None else set(self.scope),
meta=dict(self.meta),
last_moved=set(self.last_moved),
)
@dataclass
class OpResult:
state: LayoutState
moved: set[str]
op: str
params: dict[str, Any] = field(default_factory=dict)
note: str = ""

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,844 @@
"""Atomic transforms: pin select, regional scale, pack util, resolve overlaps."""
from __future__ import annotations
import math
from collections import defaultdict
from typing import Any
from netx_topology_mcp.layout_metrics import (
ICON_SIZE,
REC_CENTER_DX,
REC_CENTER_DY,
node_footprint,
)
from netx_topology_mcp.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.scope import active_nodes, movable_nodes
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def select_pins(
state: LayoutState,
*,
mode: str = "spine",
complement_of_last: bool = False,
) -> OpResult:
"""Set pinned set by rule. Does not move positions."""
st = state.copy()
pos = st.positions
if mode == "spine":
pin = {
n
for n in pos
if st.layers.get(n) in ("agg", "core") or n in st.spine
}
elif mode == "agg":
pin = {n for n in pos if st.layers.get(n) in ("agg", "core")}
elif mode == "high_degree":
deg = sorted(pos.keys(), key=lambda n: (-len(st.adj.get(n, ())), st.names.get(n, n)))
k = max(3, len(pos) // 10)
pin = set(deg[:k])
elif mode == "bbox_quartile":
if not pos:
pin = set()
else:
x0, y0, x1, y1 = bbox(pos)
mid_x = (x0 + x1) / 2
# pin left half (already "stable" after lateral stretch)
pin = {n for n, (x, _) in pos.items() if x <= mid_x}
elif mode == "prev_moved":
pin = set(st.last_moved)
else:
pin = set(st.pinned)
if complement_of_last and st.last_moved:
# pin everyone except last_moved (stabilize what we just placed)
pin = set(pos.keys()) - set(st.last_moved)
st.pinned = pin
return OpResult(
state=st,
moved=set(),
op="select_pins",
params={"mode": mode, "complement_of_last": complement_of_last, "pinned_n": len(pin)},
note=f"pins={len(pin)} mode={mode}",
)
def scale_region(
state: LayoutState,
params: LayoutParams | None = None,
*,
sx: float | None = None,
sy: float | None = None,
factor: float | None = None,
node_ids: set[str] | None = None,
only_unpinned: bool = True,
anchor: str = "centroid",
) -> OpResult:
"""Scale a subset of nodes about an anchor. Pinned stay fixed when only_unpinned."""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
if factor is not None:
sx = sy = float(factor)
sx = 1.0 if sx is None else float(sx)
sy = 1.0 if sy is None else float(sy)
if abs(sx - 1.0) < 1e-12 and abs(sy - 1.0) < 1e-12:
return OpResult(state=st, moved=set(), op="scale_region", note="noop")
if node_ids is not None:
targets = {n for n in node_ids if n in pos}
else:
targets = active_nodes(st)
if only_unpinned:
targets = {n for n in targets if n not in st.pinned}
# hard anchors never move
targets = {n for n in targets if st.layers.get(n) not in ("agg", "core")}
if not targets:
return OpResult(state=st, moved=set(), op="scale_region", note="no targets")
if anchor == "origin":
ax, ay = 0.0, 0.0
else:
ax = sum(pos[n][0] for n in targets) / len(targets)
ay = sum(pos[n][1] for n in targets) / len(targets)
moved: set[str] = set()
for n in targets:
x, y = pos[n]
pos[n] = (ax + (x - ax) * sx, ay + (y - ay) * sy)
moved.add(n)
st.positions = pos
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="scale_region",
params={"sx": sx, "sy": sy, "moved_n": len(moved), "only_unpinned": only_unpinned},
note=f"scaled {len(moved)} nodes sx={sx} sy={sy}",
)
def scale_edge_axes(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Anisotropic stretch so median |dx|/|dy| of edges approach pitch/side."""
params = params or LayoutParams()
st = state.copy()
pos = st.positions
dxs = [
abs(pos[a][0] - pos[b][0])
for a, b in st.links
if a in pos and b in pos and abs(pos[a][0] - pos[b][0]) > 1e-6
]
dys = [
abs(pos[a][1] - pos[b][1])
for a, b in st.links
if a in pos and b in pos and abs(pos[a][1] - pos[b][1]) > 1e-6
]
def _med(vals: list[float]) -> float:
vals = sorted(vals)
return vals[len(vals) // 2] if vals else 0.0
med_dx, med_dy = _med(dxs), _med(dys)
sx = sy = 1.0
if 1e-6 < med_dx < params.pitch:
sx = min(params.pitch / med_dx, 1.8)
if 1e-6 < med_dy < params.side:
sy = min(params.side / med_dy, 1.8)
out = scale_region(
st,
params,
sx=sx,
sy=sy,
node_ids=active_nodes(st),
only_unpinned=False,
anchor="centroid",
)
out.op = "scale_edge_axes"
out.params = {**out.params, "med_dx": round(med_dx, 2), "med_dy": round(med_dy, 2)}
out.note = f"edge-axis stretch sx={sx:.3f} sy={sy:.3f}"
return out
def compress_long_edges(
state: LayoutState,
params: LayoutParams | None = None,
*,
edge_len_cap: float | None = None,
pull: float = 0.45,
iters: int = 4,
frozen: set[str] | None = None,
) -> OpResult:
"""Shorten edges longer than ``edge_len_cap`` by walking ends toward mid.
This is the densify that works when nn is already sweet but bbox is huge
from metro bridges (uniform pack cannot raise util without crushing nn).
Frozen nodes (e.g. a small portal set) stay put; the other end takes the
full pull.
"""
params = params or LayoutParams()
st = state.copy()
pos = {n: (float(p[0]), float(p[1])) for n, p in st.positions.items()}
freeze = set(frozen or ())
cap = float(edge_len_cap) if edge_len_cap is not None else max(
700.0, float(params.target_nn) * 4.5
)
pull = max(0.05, min(float(pull), 0.9))
iters = max(1, int(iters))
moved: set[str] = set()
shortened = 0
def _util() -> float:
ids = list(active_nodes(st))
if len(ids) < 2:
return 0.0
sub = {n: pos[n] for n in ids if n in pos}
x0, y0, x1, y1 = bbox(sub)
return len(ids) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
util_before = _util()
for _ in range(iters):
progress = 0
for a, b in list(st.links):
if a not in pos or b not in pos:
continue
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
if L <= cap + 1e-6:
continue
mx, my = 0.5 * (ax + bx), 0.5 * (ay + by)
# Desired length after this step.
target = L - pull * (L - cap)
target = max(cap, target)
if target >= L - 1e-6:
continue
half = 0.5 * target
ux, uy = dx / L, dy / L
new_a = (mx - ux * half, my - uy * half)
new_b = (mx + ux * half, my + uy * half)
a_fr, b_fr = a in freeze, b in freeze
if a_fr and b_fr:
continue
trial = dict(pos)
if a_fr and not b_fr:
# Keep a; place b on the ray at distance target.
trial[b] = (ax + ux * target, ay + uy * target)
elif b_fr and not a_fr:
trial[a] = (bx - ux * target, by - uy * target)
else:
trial[a], trial[b] = new_a, new_b
movers = {n for n in (a, b) if trial[n] != pos[n]}
# Reject if a mover lands too close to another center (avoid ov).
min_gap = max(80.0, float(params.min_center_gap) * 0.55)
too_close = False
for n in movers:
nx, ny = trial[n]
for m, (mx2, my2) in trial.items():
if m == n:
continue
if math.hypot(nx - mx2, ny - my2) < min_gap:
too_close = True
break
if too_close:
break
if too_close:
continue
for n in movers:
pos[n] = trial[n]
moved.add(n)
shortened += 1
progress += 1
if progress == 0:
break
st.positions = pos
st.last_moved = moved
util_after = _util()
return OpResult(
state=st,
moved=moved,
op="compress_long_edges",
params={
"edge_len_cap": round(cap, 1),
"pull": round(pull, 3),
"iters": iters,
"shortened": shortened,
"moved_n": len(moved),
"util_before": round(util_before, 4),
"util_after": round(util_after, 4),
"frozen_n": len(freeze),
},
note=(
f"compress_long cap={cap:.0f} shortened={shortened} "
f"util {util_before:.4f}→{util_after:.4f}"
),
)
def pack_toward_portals(
state: LayoutState,
params: LayoutParams | None = None,
*,
portal_ids: list[str] | set[str] | None = None,
scale: float | None = None,
) -> OpResult:
"""Pull non-portal nodes toward nearest portal (shorten long metro spokes).
Uniform bbox pack cannot raise util when nn is already in the sweet band —
empty area lives in long corridors. This compresses leaves toward portals
while leaving portal coordinates fixed (semi-rigid dual-unit style).
"""
params = params or LayoutParams()
st = state.copy()
portals = {str(p) for p in (portal_ids or []) if str(p) and str(p) in st.positions}
if len(portals) < 1:
return OpResult(
state=st, moved=set(), op="pack_toward_portals", note="no portals"
)
# Mild default: 0.82 keeps local clusters readable; caller may tighten.
s = 0.82 if scale is None else float(scale)
s = max(0.35, min(s, 0.98))
targets = [n for n in active_nodes(st) if n not in portals]
if not targets:
return OpResult(
state=st, moved=set(), op="pack_toward_portals", note="no leaves"
)
pos = dict(st.positions)
moved: set[str] = set()
for n in targets:
x, y = pos[n]
best_p = None
best_d = None
for p in portals:
px, py = pos[p]
d = math.hypot(x - px, y - py)
if best_d is None or d < best_d:
best_d = d
best_p = p
if best_p is None or best_d is None or best_d < 1e-6:
continue
px, py = pos[best_p]
pos[n] = (px + (x - px) * s, py + (y - py) * s)
moved.add(n)
st.positions = pos
st.last_moved = moved
ids = list(active_nodes(st))
sub = {n: pos[n] for n in ids}
x0, y0, x1, y1 = bbox(sub)
util = len(ids) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
return OpResult(
state=st,
moved=moved,
op="pack_toward_portals",
params={
"scale": round(s, 4),
"portal_n": len(portals),
"moved_n": len(moved),
"util_after": round(util, 4),
"nn_p50_after": round(_nn_p50(pos, ids), 2),
},
note=f"portal-pack s={s:.2f} moved={len(moved)} util→{util:.4f}",
)
def _nn_p50(pos: dict[str, tuple[float, float]], ids: list[str]) -> float:
nns: list[float] = []
for a in ids:
ax, ay = pos[a]
best = min(
(math.hypot(ax - pos[b][0], ay - pos[b][1]) for b in ids if b != a),
default=None,
)
if best is not None:
nns.append(best)
if not nns:
return 0.0
nns.sort()
return float(nns[len(nns) // 2])
def pack_utilization(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Shrink active scope toward centroid until util≈target (multi-iter).
Each iter clamps scale to [pack_min_scale, 1] and also refuses to push
nn_p50 below ``pack_nn_floor`` (default 140) — otherwise a sparse-but-
well-spaced canvas gets crushed and fix_overlaps blows crossings.
Use with resolve_overlaps afterwards when pack still leaves footprint hits.
"""
params = params or LayoutParams()
st = state.copy()
targets = list(active_nodes(st))
if len(targets) < 2:
return OpResult(state=st, moved=set(), op="pack_utilization", note="too few")
pos = dict(st.positions)
moved: set[str] = set()
util_before = None
scales: list[float] = []
iters = max(1, int(params.pack_iters))
# Allow aggressive pack when caller sets pack_min_scale < 0.2 (MCP tune).
floor = max(0.05, min(float(params.pack_min_scale), 0.95))
nn_floor = max(40.0, float(getattr(params, "pack_nn_floor", 140.0) or 140.0))
nn_capped = False
for _ in range(iters):
sub = {n: pos[n] for n in targets}
x0, y0, x1, y1 = bbox(sub)
bw, bh = max(x1 - x0, 1e-6), max(y1 - y0, 1e-6)
util = len(targets) * REC_CENTER_DX * REC_CENTER_DY / (bw * bh)
if util_before is None:
util_before = util
if util >= params.target_util:
break
nn = _nn_p50(pos, targets)
if nn <= nn_floor + 1e-6:
# Already at/below floor — do not shrink further.
nn_capped = True
break
s = math.sqrt(util / max(params.target_util, 1e-6))
# Hard cap: never project nn_p50 below floor (similarity shrink).
s_nn = nn_floor / nn
if s < s_nn:
nn_capped = True
s = s_nn
s = max(floor, min(s, 1.0))
if s >= 0.999:
break
# Guard: refuse a step that would still undershoot the floor.
if nn * s < nn_floor - 1e-3:
nn_capped = True
break
cx = (x0 + x1) / 2
cy = (y0 + y1) / 2
snap = {n: pos[n] for n in targets}
for n in targets:
x, y = pos[n]
pos[n] = (cx + (x - cx) * s, cy + (y - cy) * s)
moved.add(n)
nn_step = _nn_p50(pos, targets)
if nn_step + 1e-6 < nn_floor:
# Revert step — nn metric can drop faster than uniform scale predicts.
for n, p in snap.items():
pos[n] = p
nn_capped = True
break
scales.append(round(s, 4))
if nn_capped:
break
st.positions = pos
st.last_moved = moved
sub = {n: pos[n] for n in targets}
x0, y0, x1, y1 = bbox(sub)
util_after = len(targets) * REC_CENTER_DX * REC_CENTER_DY / max((x1 - x0) * (y1 - y0), 1e-6)
nn_after = _nn_p50(pos, targets)
return OpResult(
state=st,
moved=moved,
op="pack_utilization",
params={
"scales": scales,
"util_before": round(float(util_before or 0.0), 4),
"util_after": round(util_after, 4),
"target_util": params.target_util,
"nn_p50_after": round(nn_after, 2),
"pack_nn_floor": nn_floor,
"nn_capped": nn_capped,
"scope_n": len(targets),
},
note=(
(
f"pack iters={len(scales)} util {util_before:.4f}→{util_after:.4f}"
+ (" (nn floor)" if nn_capped else "")
)
if scales
else (
f"nn floor blocks pack (nn≈{nn_after:.1f})"
if nn_capped
else f"util already ok ({util_before:.4f})"
)
),
)
def _footprints(
pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> dict[str, tuple[float, float, float, float]]:
out = {}
for n, (x, y) in pos.items():
fx0, fy0, fx1, fy1 = node_footprint(names.get(n, n))
out[n] = (x + fx0, y + fy0, x + fx1, y + fy1)
return out
def resolve_overlaps(
state: LayoutState,
params: LayoutParams | None = None,
*,
mode: str = "lateral",
) -> OpResult:
"""Push movable nodes to clear footprint overlaps.
mode=lateral (default): project pushes onto ±X (corridor sides) to preserve
Tutte chain crossings. mode=free: isotropic push (can raise crossings a lot).
Hard-pinned = agg/core.
"""
params = params or LayoutParams()
st = state.copy()
pos = {n: [float(p[0]), float(p[1])] for n, p in st.positions.items()}
ids = list(pos)
# Move only scoped non-agg/core; outsiders are frozen obstacles.
can_move = movable_nodes(st, respect_pins=False)
# Spine = accepted pure-chain units; keep them atomic during unstick.
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")} | set(st.spine)
pinned_soft = set(st.pinned) - hard
moved: set[str] = set()
for _ in range(params.overlap_iters):
fps = _footprints({n: (pos[n][0], pos[n][1]) for n in ids}, st.names)
cell = max(REC_CENTER_DX, 80.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n in ids:
ax0, ay0, ax1, ay1 = fps[n]
cx = int(((ax0 + ax1) / 2) // cell)
cy = int(((ay0 + ay1) / 2) // cell)
buckets[(cx, cy)].append(n)
hits = 0
for n in ids:
if n not in can_move:
continue
# Prefer moving non-spine; spine only if still overlapping
ax0, ay0, ax1, ay1 = fps[n]
cx = int(((ax0 + ax1) / 2) // cell)
cy = int(((ay0 + ay1) / 2) // cell)
fx = fy = 0.0
local_hits = 0
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m == n:
continue
bx0, by0, bx1, by1 = fps[m]
if not (ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0):
continue
local_hits += 1
mx = (ax0 + ax1) / 2 - (bx0 + bx1) / 2
my = (ay0 + ay1) / 2 - (by0 + by1) / 2
if abs(mx) + abs(my) < 1e-9:
mx = 1.0 if (hash(n) & 1) else -1.0
my = 0.0
ox = min(ax1, bx1) - max(ax0, bx0)
oy = min(ay1, by1) - max(ay0, by0)
push = 0.5 * max(ox, oy, ICON_SIZE)
if m in hard:
push *= 2.0
L = math.hypot(mx, my) or 1.0
ux, uy = mx / L, my / L
if mode == "lateral":
# keep chain Y; separate in X (+ small Y for label)
fx += math.copysign(push, ux if abs(ux) > 1e-9 else 1.0)
fy += 0.25 * push if uy >= 0 else -0.15 * push
else:
fx += ux * push
fy += uy * push
if local_hits == 0:
continue
if n in pinned_soft and mode == "lateral" and local_hits < 2:
# spine lightly held unless multi-overlap
continue
hits += local_hits
L = math.hypot(fx, fy)
if L > params.overlap_step:
fx *= params.overlap_step / L
fy *= params.overlap_step / L
if L > 1e-9:
pos[n][0] += fx
pos[n][1] += fy
moved.add(n)
if hits == 0:
break
st.positions = {n: (pos[n][0], pos[n][1]) for n in ids}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="resolve_overlaps",
params={"iters": params.overlap_iters, "moved_n": len(moved), "mode": mode},
note=f"unstick mode={mode} moved={len(moved)} scope={len(can_move)}",
)
def explode_clusters(
state: LayoutState,
params: LayoutParams | None = None,
*,
thr: float | None = None,
gap: float | None = None,
axis: str = "along",
) -> OpResult:
"""Spread near-coincident clusters (generic atom).
Union nodes with center dist ≤ thr; lay cluster on a short line.
axis=along: parallel to average neighbor edge (usually fewer new crossings).
axis=perp: side-lane / normal direction.
Only moves nodes in current scope (non agg/core).
"""
params = params or LayoutParams()
thr = params.cluster_thr if thr is None else float(thr)
gap = params.cluster_gap if gap is None else float(gap)
st = state.copy()
pos = dict(st.positions)
ids = list(pos)
if len(ids) < 2:
return OpResult(state=st, moved=set(), op="explode_clusters", note="too few")
can_move = movable_nodes(st, respect_pins=False)
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")}
parent = {n: n for n in ids}
def find(x: str) -> str:
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def uni(a: str, b: str) -> None:
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
cell = max(thr, 1.0)
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n, (x, y) in pos.items():
buckets[(int(x // cell), int(y // cell))].append(n)
for n, (x, y) in pos.items():
cx, cy = int(x // cell), int(y // cell)
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m <= n:
continue
if math.hypot(x - pos[m][0], y - pos[m][1]) <= thr:
uni(n, m)
groups: dict[str, list[str]] = defaultdict(list)
for n in ids:
groups[find(n)].append(n)
moved: set[str] = set()
for grp in groups.values():
if len(grp) < 2:
continue
grp = sorted(grp, key=lambda n: st.names.get(n, n))
vx = vy = 0.0
for n in grp:
for m in st.adj.get(n, ()):
if m not in pos:
continue
dx = pos[m][0] - pos[n][0]
dy = pos[m][1] - pos[n][1]
if abs(dx) + abs(dy) < 1e-9:
continue
if dx < 0:
dx, dy = -dx, -dy
vx += dx
vy += dy
L = math.hypot(vx, vy)
if L < 1e-9:
ax, ay = 1.0, 0.0
else:
ax, ay = vx / L, vy / L
if axis == "perp":
tx, ty = -ay, ax
else:
tx, ty = ax, ay
cx = sum(pos[n][0] for n in grp) / len(grp)
cy = sum(pos[n][1] for n in grp) / len(grp)
# Only explode clusters that touch the active scope; only move can_move.
movable = [n for n in grp if n in can_move]
if not movable:
continue
for i, n in enumerate(movable):
off = (i - (len(movable) - 1) / 2) * gap
pos[n] = (cx + tx * off, cy + ty * off)
moved.add(n)
st.positions = pos
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="explode_clusters",
params={
"thr": thr,
"gap": gap,
"axis": axis,
"moved_n": len(moved),
"scope_n": len(can_move),
},
note=f"exploded axis={axis} moved={len(moved)} scope={len(can_move)}",
)
def enforce_min_gap(
state: LayoutState,
params: LayoutParams | None = None,
*,
min_gap: float | None = None,
iters: int | None = None,
) -> OpResult:
"""Push centers apart until euclidean nearest-neighbor ≥ min_gap (unpinned)."""
params = params or LayoutParams()
min_gap = params.min_center_gap if min_gap is None else float(min_gap)
iters = params.overlap_iters if iters is None else int(iters)
st = state.copy()
pos = {n: [float(p[0]), float(p[1])] for n, p in st.positions.items()}
ids = list(pos)
can_move = movable_nodes(st, respect_pins=False)
hard = {n for n in ids if st.layers.get(n) in ("agg", "core")}
moved: set[str] = set()
cell = max(min_gap, 1.0)
step = params.overlap_step
for _ in range(iters):
buckets: dict[tuple[int, int], list[str]] = defaultdict(list)
for n in ids:
buckets[(int(pos[n][0] // cell), int(pos[n][1] // cell))].append(n)
hits = 0
for n in ids:
if n not in can_move:
continue
cx, cy = int(pos[n][0] // cell), int(pos[n][1] // cell)
fx = fy = 0.0
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
for m in buckets[(cx + dx, cy + dy)]:
if m == n:
continue
vx = pos[n][0] - pos[m][0]
vy = pos[n][1] - pos[m][1]
d = math.hypot(vx, vy)
if d >= min_gap:
continue
hits += 1
if d < 1e-6:
ang = (hash(n) ^ hash(m)) % 360
rad = math.radians(float(ang))
vx, vy = math.cos(rad), math.sin(rad)
d = 1e-6
else:
vx /= d
vy /= d
push = (min_gap - d) * (1.0 if m in hard else 0.5)
fx += vx * push
fy += vy * push
L = math.hypot(fx, fy)
if L > step:
fx *= step / L
fy *= step / L
if L > 1e-9:
pos[n][0] += fx
pos[n][1] += fy
moved.add(n)
if hits == 0:
break
st.positions = {n: (pos[n][0], pos[n][1]) for n in ids}
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="enforce_min_gap",
params={"min_gap": min_gap, "moved_n": len(moved)},
note=f"min_gap={min_gap} moved={len(moved)}",
)
def soft_nn_scale(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Capped isotropic scale by nn_p50 toward target_nn (active scope)."""
params = params or LayoutParams()
st = state.copy()
ids = list(active_nodes(st))
pos = st.positions
if len(ids) < 2:
return OpResult(state=st, moved=set(), op="soft_nn_scale", note="too few")
nns = []
for a in ids:
ax, ay = pos[a]
best = min(
(math.hypot(ax - pos[b][0], ay - pos[b][1]) for b in ids if b != a),
default=None,
)
if best is not None:
nns.append(best)
nns.sort()
nn = nns[len(nns) // 2] if nns else 0.0
if nn <= 1e-9 or nn >= params.target_nn:
return OpResult(
state=st,
moved=set(),
op="soft_nn_scale",
params={"nn_p50": round(nn, 2), "scope_n": len(ids)},
note="no scale",
)
s = min(params.target_nn / nn, params.scale_cap)
out = scale_region(
st, params, factor=s, node_ids=set(ids), only_unpinned=False, anchor="centroid"
)
out.op = "soft_nn_scale"
out.params = {
**out.params,
"nn_p50_before": round(nn, 2),
"scale": round(s, 3),
"scope_n": len(ids),
}
out.note = f"nn soft scale s={s:.3f} scope={len(ids)}"
return out
def normalize_origin(
state: LayoutState, params: LayoutParams | None = None
) -> OpResult:
"""Translate so min corner is at margin."""
params = params or LayoutParams()
st = state.copy()
if not st.positions:
return OpResult(state=st, moved=set(), op="normalize_origin", note="empty")
x0, y0, _, _ = bbox(st.positions)
m = params.margin
pos = {
n: (x - x0 + m, y - y0 + m) for n, (x, y) in st.positions.items()
}
st.positions = pos
st.last_moved = set(pos.keys())
return OpResult(
state=st,
moved=st.last_moved,
op="normalize_origin",
params={"margin": m},
note="origin+margin",
)
OPS: dict[str, Any] = {
"select_pins": select_pins,
"scale_region": scale_region,
"scale_edge_axes": scale_edge_axes,
"pack_utilization": pack_utilization,
"resolve_overlaps": resolve_overlaps,
"explode_clusters": explode_clusters,
"enforce_min_gap": enforce_min_gap,
"soft_nn_scale": soft_nn_scale,
"normalize_origin": normalize_origin,
}

View file

@ -0,0 +1,395 @@
"""Greedy local untangle: move low-degree nodes to cut crossings (keep ov=0)."""
from __future__ import annotations
import math
import random
from typing import Any
from netx_topology_mcp.layout_metrics import (
count_edge_crossings,
crossing_participation,
crossings_involving_node,
node_footprint,
top_crossing_nodes,
)
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
x, y = pos[nid]
minx, miny, maxx, maxy = node_footprint(names.get(nid, ""))
return (x + minx, y + miny, x + maxx, y + maxy)
def _node_overlaps_any(
node: str, pos: dict[str, tuple[float, float]], names: dict[str, str]
) -> bool:
ax0, ay0, ax1, ay1 = _box(node, pos, names)
for b, (x, y) in pos.items():
if b == node:
continue
if abs(x - pos[node][0]) > 80 and abs(y - pos[node][1]) > 60:
continue
bx0, by0, bx1, by1 = _box(b, pos, names)
if ax0 < bx1 and ax1 > bx0 and ay0 < by1 and ay1 > by0:
return True
return False
def _participation(
pos: dict[str, tuple[float, float]], links: list[tuple[str, str]]
) -> dict[str, int]:
_n, hit = crossing_participation(pos, links)
return hit
# Max jump from current position — prevents radial *scale* candidates from
# flinging nodes across a giant star and exploding util/bbox.
# Default raised: BTM-scale stars need ~900px jumps to escape petal local minima.
_MAX_JUMP = 900.0
_MAX_FROM_NBS = 1100.0
# Sparse metro compose can have spokes of several thousand px; a hard 1200
# cap makes untangle a no-op on the worst bridges.
_MAX_JUMP_CAP = 4800.0
def _candidates(
pos: dict[str, tuple[float, float]],
node: str,
adj: dict[str, set[str]],
rng: random.Random,
*,
max_jump: float = _MAX_JUMP,
angle_step: int = 15,
random_n: int = 12,
) -> list[tuple[float, float]]:
x, y = pos[node]
jump = max(200.0, min(float(max_jump), _MAX_JUMP_CAP))
nbs_cap = max(_MAX_FROM_NBS, jump * 1.25)
step = max(10, int(angle_step))
cands: list[tuple[float, float]] = []
radii = [80, 120, 180, 260, 360, 480]
if jump > 520:
radii = radii + [640, 800]
if jump > 1200:
radii = radii + [1200, 1600, 2200, min(jump, 3200)]
if step >= 25:
radii = [r for r in radii if r >= 120]
for ang in range(0, 360, step):
for r in radii:
if r > jump + 1:
continue
rad = math.radians(ang)
cands.append((x + r * math.cos(rad), y + r * math.sin(rad)))
nbs = [pos[v] for v in adj.get(node, ()) if v in pos]
if nbs:
cx = sum(p[0] for p in nbs) / len(nbs)
cy = sum(p[1] for p in nbs) / len(nbs)
dx, dy = x - cx, y - cy
L = math.hypot(dx, dy) or 1.0
# Unit steps along/away from neighbor centroid — NOT unbounded *s* of L.
ux, uy = dx / L, dy / L
for s in (-360.0, -220.0, -120.0, 120.0, 220.0, 360.0):
if abs(s) <= jump:
cands.append((x + ux * s, y + uy * s))
cands.append((cx + ux * abs(s), cy + uy * abs(s)))
px, py = -uy, ux
for s in (-240, -160, -80, 80, 160, 240):
if abs(s) <= jump:
cands.append((x + px * s, y + py * s))
span = min(350.0, jump * 0.7)
for _ in range(max(0, int(random_n))):
cands.append((x + rng.uniform(-span, span), y + rng.uniform(-span, span)))
# Drop absurd jumps (legacy radial scale could send nodes 10k+ px away).
out: list[tuple[float, float]] = []
for cx_, cy_ in cands:
if math.hypot(cx_ - x, cy_ - y) > jump:
continue
if nbs:
mx = sum(p[0] for p in nbs) / len(nbs)
my = sum(p[1] for p in nbs) / len(nbs)
if math.hypot(cx_ - mx, cy_ - my) > nbs_cap:
continue
out.append((cx_, cy_))
return out
def untangle_crossings(
state: LayoutState,
params: LayoutParams | None = None,
*,
max_rounds: int = 200,
max_degree: int = 7,
target_crossings: int = 60,
seed: int = 7,
protect_rings: bool = False,
protect_rigid: bool | str = "portals",
moves_per_round: int = 3,
max_jump: float | None = None,
frozen_ids: set[str] | None = None,
focus_ids: list[str] | None = None,
rank_cap: int | None = None,
angle_step: int | None = None,
refresh_every: int | None = None,
) -> OpResult:
"""Move low-degree nodes greedily to reduce global edge crossings.
``protect_rigid`` (default ``portals``): freeze shared dual-unit pivots
only — corridors/tails may still move. Use ``all`` to freeze every
compose-group member, or ``false`` to freeze nothing.
"""
params = params or LayoutParams()
st = state.copy()
pos = dict(st.positions)
names = dict(st.names)
links = list(st.links)
adj = {n: set(st.adj.get(n, ())) for n in pos}
rng = random.Random(seed)
jump = float(max_jump if max_jump is not None else _MAX_JUMP)
jump = max(200.0, min(jump, _MAX_JUMP_CAP))
n_links = len(links)
# Auto-throttle on large E so MCP stdio stays interactive.
if rank_cap is None:
rank_cap = 36 if n_links >= 400 else (60 if n_links >= 200 else 140)
if angle_step is None:
angle_step = 30 if n_links >= 400 else (20 if n_links >= 200 else 15)
if refresh_every is None:
refresh_every = 3 if n_links >= 200 else 1
rank_cap = max(8, int(rank_cap))
angle_step = max(10, int(angle_step))
refresh_every = max(1, int(refresh_every))
random_n = 4 if n_links >= 400 else (8 if n_links >= 200 else 12)
cur_c = count_edge_crossings(pos, links)
start_c = cur_c
moved: set[str] = set()
frozen: set[str] = set(frozen_ids or ())
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid)
faces = None
pierce0 = 0
if protect_rings:
from netx_topology_mcp.layout_ops.ring_faces import (
count_ring_pierces,
extract_ring_faces,
)
faces = extract_ring_faces(st)
pierce0 = int(count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0)
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
hit: dict[str, int] = {}
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
rounds_total = max(1, int(max_rounds))
for _round in range(rounds_total):
if _round % max(1, refresh_every) == 0:
raise_if_cancelled()
pct = 70.0 + 4.0 * (_round / rounds_total)
report_progress(
"untangle",
pct=min(74.0, pct),
message=f"round {_round + 1}/{rounds_total} x={cur_c}",
step=_round + 1,
total_steps=rounds_total,
crossings=cur_c,
)
if _round % refresh_every == 0 or not hit:
hit = _participation(pos, links)
if not hit:
break
# Prefer analyze.top_crossing_nodes / explicit focus_ids first.
if not focus:
focus = {
str(r["fabric_node_id"])
for r in top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
)
}
# High-degree hubs in top_nodes cannot move under max_degree — pull in
# their low-degree neighbors that still participate in crossings.
movable_focus: set[str] = set()
for nid in focus:
if len(adj.get(nid, ())) < max_degree and nid in hit:
movable_focus.add(nid)
for nb in adj.get(nid, ()):
if nb in hit and len(adj.get(nb, ())) < max_degree:
movable_focus.add(nb)
prefer = movable_focus or focus
ranked = sorted(
hit.keys(),
key=lambda n: (
0 if n in prefer else 1,
-hit[n] / max(len(adj.get(n, ())), 1),
len(adj.get(n, ())),
-hit[n],
),
)
improved = False
moves_left = max(1, int(moves_per_round))
for node in ranked[:rank_cap]:
if moves_left <= 0:
break
if node in frozen:
continue
deg = len(adj.get(node, ()))
if deg >= max_degree:
continue
before = crossings_involving_node(node, pos, links, adj)
if before <= 0:
continue
best: tuple[int, tuple[float, float]] | None = None
x0, y0 = pos[node]
for cand in _candidates(
pos,
node,
adj,
rng,
max_jump=jump,
angle_step=angle_step,
random_n=random_n,
):
if math.hypot(cand[0] - x0, cand[1] - y0) > jump:
continue
trial = dict(pos)
trial[node] = cand
if _node_overlaps_any(node, trial, names):
continue
after = crossings_involving_node(node, trial, links, adj)
if after >= before:
continue
g2 = cur_c - before + after
if g2 >= cur_c:
continue
if faces is not None:
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
pierce1 = int(
count_ring_pierces(trial, links, faces).get("pierce_crossings")
or 0
)
if pierce1 > pierce0:
continue
if best is None or g2 < best[0]:
best = (g2, cand)
if best is None:
continue
after_local = best[0] - cur_c + before
pos[node] = best[1]
moved.add(node)
cur_c = best[0]
# Stale hit is ok between refreshes; keep local estimate for ranking.
if after_local > 0:
hit[node] = after_local
else:
hit.pop(node, None)
if faces is not None:
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
pierce0 = int(
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
)
improved = True
moves_left -= 1
if not improved:
break
if cur_c <= target_crossings:
break
protect_mode = (
protect_rigid
if isinstance(protect_rigid, str)
else ("portals" if protect_rigid else "off")
)
st.positions = pos
# Reuse last hit for meta (avoid an extra O(E²) pass on large graphs).
if not hit:
hit = _participation(pos, links)
st.meta["untangle"] = {
"start_crossings": start_c,
"end_crossings": cur_c,
"moved_n": len(moved),
"max_degree": max_degree,
"target_crossings": target_crossings,
"max_jump": jump,
"protect_rings": bool(protect_rings),
"protect_rigid": protect_mode,
"frozen_n": len(frozen),
"moves_per_round": int(moves_per_round),
"focus_n": len(focus),
"rank_cap": rank_cap,
"angle_step": angle_step,
"refresh_every": refresh_every,
"top_crossing_nodes": top_crossing_nodes(
pos, links, names=names, adj=adj, top_n=5, participation=hit
),
}
return OpResult(
state=st,
moved=moved,
op="untangle_crossings",
params={
"max_rounds": max_rounds,
"max_degree": max_degree,
"target_crossings": target_crossings,
"seed": seed,
"protect_rings": bool(protect_rings),
"protect_rigid": protect_mode,
"frozen_n": len(frozen),
"moves_per_round": int(moves_per_round),
"max_jump": jump,
"focus_n": len(focus),
},
note=f"untangle {start_c}->{cur_c} moved={len(moved)} frozen={len(frozen)}",
)
def untangle_params_from_overrides(overrides: dict[str, Any] | None) -> dict[str, Any]:
"""Pull untangle knobs from layout params overrides (optional)."""
o = overrides or {}
out: dict[str, Any] = {}
for key, cast, default in (
("max_rounds", int, 200),
("max_degree", int, 7),
("target_crossings", int, 60),
("seed", int, 7),
("moves_per_round", int, 3),
):
if key not in o or o[key] is None:
out[key] = default
continue
try:
out[key] = cast(o[key])
except (TypeError, ValueError):
out[key] = default
if "protect_rings" in o:
out["protect_rings"] = bool(o["protect_rings"])
if "protect_rigid" in o:
v = o["protect_rigid"]
if isinstance(v, bool):
# true → portals (semi-rigid); false → off
out["protect_rigid"] = "portals" if v else "off"
else:
key = str(v).strip().lower()
if key in {"0", "false", "no", "off", "none"}:
out["protect_rigid"] = "off"
elif key in {"1", "true", "yes", "on", "portals", "skeleton"}:
out["protect_rigid"] = "portals"
elif key in {"all", "full", "rigid"}:
out["protect_rigid"] = "all"
else:
out["protect_rigid"] = key
if o.get("max_jump") is not None:
try:
out["max_jump"] = float(o["max_jump"])
except (TypeError, ValueError):
pass
focus = o.get("focus_ids") or o.get("focus_node_ids")
if isinstance(focus, list):
out["focus_ids"] = [str(x).strip() for x in focus if str(x).strip()]
elif isinstance(focus, str) and focus.strip():
out["focus_ids"] = [s.strip() for s in focus.split(",") if s.strip()]
return out