netx/netx_api/mcp_remote/topology/layout_ops/mass_merge.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

1049 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Mass-field merge: ForceAtlas2-style forces + dual-unit islands.
Gravity clustering (stage-1), Gephi-ForceAtlas2 inspired:
- Long-range node repulsion ∝ (m_i·m_j)/d (FA2 scaling).
- Lin-log edge attraction (short edges push apart, long edges log-pull).
- Gravity toward **home dual-unit core** (not canvas centroid).
- Inter-group centroid sep/pack so units stay separate islands.
- Global canvas gravity off by default (was collapsing the fabric).
Optional cross-block core_pull / capture remain for later merge polish.
"""
from __future__ import annotations
import math
from typing import Any
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_api.mcp_remote.topology.layout_ops.force_densify import (
_MAX_EDGE_PULL,
_bbox_area,
_spatial_bins,
)
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
build_mass_field,
capture_pass,
core_centroid,
evolve_chains_to_rings,
geo_score,
group_effective_mass,
groups_from_mass_or_rigid,
)
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6
_STEP_CAP = {
"core": 0.35,
"ring": 0.65,
"chain": 1.4,
"free": 1.0,
}
_FOLLOW = {
"chain": 1.4,
"ring": 0.5,
"core": 0.15,
"free": 1.0,
}
_L0_MUL = {
"ring": 1.0,
"chain": 0.85,
"bridge": 1.35, # bridges prefer longer — keep islands apart
"plain": 1.0,
}
def _edge_key(a: str, b: str) -> str:
return f"{a}|{b}" if a <= b else f"{b}|{a}"
def _group_records(
groups: list[dict[str, Any]],
pos: dict[str, tuple[float, float]],
nodes: dict[str, Any],
) -> list[tuple[str, float, tuple[float, float], set[str]]]:
"""(key, mass, centroid, members) for groups that have a usable centroid."""
out: list[tuple[str, float, tuple[float, float], set[str]]] = []
for g in groups:
if not isinstance(g, dict):
continue
key = str(g.get("key") or "")
members = {str(x) for x in (g.get("node_ids") or []) if str(x) in pos}
if len(members) < 1:
continue
c = core_centroid(g, pos, nodes)
if c is None:
xs = [pos[n][0] for n in members]
ys = [pos[n][1] for n in members]
c = (sum(xs) / len(xs), sum(ys) / len(ys))
M = group_effective_mass(g, nodes)
out.append((key, M, c, members))
return out
def _home_map(
gmeta: list[tuple[str, float, tuple[float, float], set[str]]],
) -> dict[str, str]:
home: dict[str, str] = {}
for key, _M, _c, members in gmeta:
for n in members:
if n not in home:
home[n] = key
return home
def _centroid_sep_stats(
gmeta: list[tuple[str, float, tuple[float, float], set[str]]],
) -> dict[str, float]:
"""Nearest-neighbor distances among group centroids."""
if len(gmeta) < 2:
return {"mean_nn": 0.0, "min_nn": 0.0, "n": float(len(gmeta))}
nns: list[float] = []
for i, (_k, _M, ci, _m) in enumerate(gmeta):
best = None
for j, (_k2, _M2, cj, _m2) in enumerate(gmeta):
if i == j:
continue
d = math.hypot(ci[0] - cj[0], ci[1] - cj[1])
if best is None or d < best:
best = d
if best is not None:
nns.append(best)
return {
"mean_nn": round(sum(nns) / len(nns), 2) if nns else 0.0,
"min_nn": round(min(nns), 2) if nns else 0.0,
"n": float(len(gmeta)),
}
def _seed_nodes_disk(
pos: dict[str, tuple[float, float]],
ids: list[str],
*,
pitch: float,
frozen: set[str],
) -> dict[str, tuple[float, float]]:
"""Pack free nodes into a round disk (golden-angle spiral) — FA2 circular seed."""
free = sorted(n for n in ids if n not in frozen and n in pos)
if len(free) < 3:
return pos
xs = [pos[n][0] for n in free]
ys = [pos[n][1] for n in free]
cx = sum(xs) / len(xs)
cy = sum(ys) / len(ys)
out = dict(pos)
p = max(float(pitch), 80.0)
for i, nid in enumerate(free):
ang = i * 2.399963229728653
rad = p * 0.55 * math.sqrt(i + 1.0)
out[nid] = (cx + math.cos(ang) * rad, cy + math.sin(ang) * rad)
return out
def _seed_group_circle(
pos: dict[str, tuple[float, float]],
gmeta: list[tuple[str, float, tuple[float, float], set[str]]],
*,
sep_ideal: float,
frozen: set[str],
) -> dict[str, tuple[float, float]]:
"""Place **cluster centroids** on a round golden-angle pack (keep islands).
Overall footprint is circular; each dual-unit stays a rigid translate of
its members (home map once — no stacked deltas).
"""
n = len(gmeta)
pitch = max(float(sep_ideal), 400.0)
ordered = sorted(gmeta, key=lambda t: (-t[1], t[0]))
home = _home_map(ordered)
targets: dict[str, tuple[float, float]] = {}
for i, (key, _M, _c, _m) in enumerate(ordered):
if i == 0:
targets[key] = (0.0, 0.0)
continue
ang = (i - 1) * 2.399963229728653
rad = pitch * 0.62 * math.sqrt(float(i))
targets[key] = (math.cos(ang) * rad, math.sin(ang) * rad)
gcx = sum(c[0] for _k, _M, c, _m in gmeta) / n
gcy = sum(c[1] for _k, _M, c, _m in gmeta) / n
tcx = sum(t[0] for t in targets.values()) / n
tcy = sum(t[1] for t in targets.values()) / n
delta: dict[str, tuple[float, float]] = {}
for key, _M, (cx, cy), _mem in ordered:
tx, ty = targets[key]
delta[key] = ((tx - tcx + gcx) - cx, (ty - tcy + gcy) - cy)
out = dict(pos)
for nid, hk in home.items():
if nid in frozen or hk not in delta:
continue
dx, dy = delta[hk]
x, y = out[nid]
out[nid] = (x + dx, y + dy)
# Unpack only crushed islands (local spiral); do not flatten the whole canvas.
by_home: dict[str, list[str]] = {}
for nid, hk in home.items():
if nid in frozen:
continue
by_home.setdefault(hk, []).append(nid)
local_pitch = max(float(sep_ideal) * 0.1, 130.0)
for _hk, members in by_home.items():
if len(members) < 2:
continue
members = sorted(members)
xs = [out[n][0] for n in members]
ys = [out[n][1] for n in members]
span = max(max(xs) - min(xs), max(ys) - min(ys))
nns: list[float] = []
for i, a in enumerate(members[:80]):
ax, ay = out[a]
best = None
for j, b in enumerate(members[:80]):
if i == j:
continue
d = math.hypot(ax - out[b][0], ay - out[b][1])
if best is None or d < best:
best = d
if best is not None:
nns.append(best)
med_nn = sorted(nns)[len(nns) // 2] if nns else span
if span >= local_pitch * 3.5 and med_nn >= local_pitch * 0.45:
continue
cx = sum(xs) / len(xs)
cy = sum(ys) / len(ys)
for i, nid in enumerate(members):
ang = i * 2.399963229728653
rad = local_pitch * 0.65 * math.sqrt(i + 1.0)
out[nid] = (cx + math.cos(ang) * rad, cy + math.sin(ang) * rad)
return out
def _seed_group_spread(
pos: dict[str, tuple[float, float]],
gmeta: list[tuple[str, float, tuple[float, float], set[str]]],
*,
sep_ideal: float,
frozen: set[str],
) -> tuple[dict[str, tuple[float, float]], bool, str]:
"""Repack cluster centroids into a round pack when stacked/sparse/collapsed."""
if len(gmeta) < 3:
return pos, False, "skip"
stats = _centroid_sep_stats(gmeta)
mean_nn = stats["mean_nn"]
min_nn = stats["min_nn"]
# Also reseat when the cloud of islands is a long strip.
cens = [c for _k, _M, c, _m in gmeta]
cw = max(c[0] for c in cens) - min(c[0] for c in cens)
ch = max(c[1] for c in cens) - min(c[1] for c in cens)
aspect = max(cw, ch) / max(min(cw, ch), 1.0)
if min_nn < sep_ideal * 0.35:
reason = "min_stacked"
elif mean_nn < sep_ideal * 0.45:
reason = "collapsed"
elif mean_nn > sep_ideal * 2.0:
reason = "too_sparse"
elif aspect > 1.45:
reason = "strip"
else:
return pos, False, "ok"
return _seed_group_circle(pos, gmeta, sep_ideal=sep_ideal, frozen=frozen), True, reason
def _accumulate_mass_forces(
pos: dict[str, tuple[float, float]],
links: list[tuple[str, str]],
ids: list[str],
*,
mass: dict[str, Any],
ideal_len: float,
nn_floor: float,
attract_k: float,
repulse_k: float,
gravity_k: float,
group_sep_k: float,
group_pack_k: float,
sep_ideal: float,
global_gravity_k: float,
core_pull_k: float,
lambda_core: float,
frozen: set[str],
fa2: bool = True,
scaling: float = 8.0,
linlog: bool = True,
) -> dict[str, tuple[float, float]]:
nodes = mass.get("nodes") or {}
edges = mass.get("edges") or {}
groups = mass.get("groups") or []
fx = {n: 0.0 for n in ids}
fy = {n: 0.0 for n in ids}
id_set = set(ids)
free = [n for n in ids if n not in frozen and n in pos]
gmeta = _group_records(groups, pos, nodes)
home = _home_map(gmeta)
by_key = {k: (M, c, mem) for k, M, c, mem in gmeta}
# Degree proxy for FA2 mass: use annotated mass (cores heavier).
def _m(n: str) -> float:
return max(float((nodes.get(n) or {}).get("mass") or 1.0), 0.2)
# FA2 global gravity → canvas barycenter (keeps overall shape round, not a strip).
if global_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 * 0.5:
continue
# Stronger than before: ∝ dist (FA2), scaled by node mass.
mag = (
global_gravity_k
* _m(n)
* min(dist * (0.045 if fa2 else 0.015), _MAX_EDGE_PULL * 1.1)
)
fx[n] += (dx / dist) * mag
fy[n] += (dy / dist) * mag
# Home-group gravity (FA2 gravity toward region center = dual-unit core).
if gravity_k > 1e-9 and by_key:
for n in free:
hk = home.get(n)
if not hk or hk not in by_key:
continue
_M, (cx, cy), _mem = by_key[hk]
dx, dy = cx - pos[n][0], cy - pos[n][1]
dist = math.hypot(dx, dy)
if dist < nn_floor * 0.35:
continue
role = str((nodes.get(n) or {}).get("role") or "free")
role_w = {"core": 0.15, "ring": 1.0, "chain": 1.2, "free": 0.95}.get(
role, 1.0
)
# FA2: gravity ∝ mass; stronger glue for leaves.
mag = (
gravity_k
* role_w
* _m(n)
* min(dist * (0.055 if fa2 else 0.04), _MAX_EDGE_PULL * 0.9)
)
fx[n] += (dx / dist) * mag
fy[n] += (dy / dist) * mag
# Inter-group: repulse if too close, pack if too far (keep islands compact).
if (group_sep_k > 1e-9 or group_pack_k > 1e-9) and len(gmeta) >= 2:
target = max(float(sep_ideal), nn_floor * 6.0)
pack_lo = target * 1.55
for i, (ki, Mi, ci, memi) in enumerate(gmeta):
for j, (kj, Mj, cj, memj) in enumerate(gmeta):
if j <= i:
continue
dx, dy = ci[0] - cj[0], ci[1] - cj[1]
dist = math.hypot(dx, dy)
if dist < 1e-6:
ang = hash((ki, kj)) % 360
rad = math.radians(float(ang))
dx, dy = math.cos(rad), math.sin(rad)
dist = 1.0
ux, uy = dx / dist, dy / dist
w_sum = max(Mi, 0.2) + max(Mj, 0.2)
if dist < target and group_sep_k > 1e-9:
gap = target - dist
push = group_sep_k * min(gap * 0.08, _MAX_EDGE_PULL * 1.4)
pi = push * (max(Mj, 0.2) / w_sum)
pj = push * (max(Mi, 0.2) / w_sum)
for n in memi:
if n in frozen or n not in fx or home.get(n) != ki:
continue
fx[n] += ux * pi
fy[n] += uy * pi
for n in memj:
if n in frozen or n not in fx or home.get(n) != kj:
continue
fx[n] -= ux * pj
fy[n] -= uy * pj
elif dist > pack_lo and group_pack_k > 1e-9:
gap = dist - target
pull = group_pack_k * min(gap * 0.035, _MAX_EDGE_PULL * 1.1)
pi = pull * (max(Mj, 0.2) / w_sum)
pj = pull * (max(Mi, 0.2) / w_sum)
for n in memi:
if n in frozen or n not in fx or home.get(n) != ki:
continue
fx[n] -= ux * pi
fy[n] -= uy * pi
for n in memj:
if n in frozen or n not in fx or home.get(n) != kj:
continue
fx[n] += ux * pj
fy[n] += uy * pj
# Edges: FA2 lin-log attraction, or legacy spring.
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
erow = edges.get(_edge_key(a, b)) or {}
role = str(erow.get("role") or "plain")
ae = float(erow.get("attract") or 1.0)
L0 = ideal_len * _L0_MUL.get(role, 1.0)
ax, ay = pos[a]
bx, by = pos[b]
dx, dy = bx - ax, by - ay
L = math.hypot(dx, dy)
if L < 1e-6:
dx, dy, L = 1.0, 0.0, 1.0
ux, uy = dx / L, dy / L
ma, mb = _m(a), _m(b)
inv = 1.0 / ma + 1.0 / mb
na = float((nodes.get(a) or {}).get("attract") or 1.0)
nb = float((nodes.get(b) or {}).get("attract") or 1.0)
w = ae * math.sqrt(max(na * nb, 1e-6))
if fa2:
# Lin-log (Gephi default for clustered graphs): pull ∝ log(1+d).
# Below L0: strong push so corridors don't collapse onto portals.
if L < L0:
gap = L0 - L
mag = attract_k * w * min(gap * 0.09, _MAX_EDGE_PULL * 1.2)
sign = -1.0 # push apart along edge
else:
if linlog:
mag = attract_k * w * math.log1p(L / max(L0, 1.0)) * 18.0
else:
mag = attract_k * w * min((L - L0) * 0.05, _MAX_EDGE_PULL)
mag = min(mag, _MAX_EDGE_PULL * 1.3)
sign = 1.0 # pull together
if role == "bridge" and sign > 0:
mag *= 0.4
else:
delta = L - L0
if abs(delta) <= dead:
continue
capped = max(-_MAX_EDGE_PULL * 8.0, min(delta, _MAX_EDGE_PULL * 12.0))
mag = attract_k * w * min(abs(capped) * 0.045, _MAX_EDGE_PULL)
if capped < 0:
mag *= 0.75
if role == "bridge" and capped > 0:
mag *= 0.55
sign = 1.0 if capped > 0 else -1.0
fa = mag * ((1.0 / ma) / inv)
fb = mag * ((1.0 / mb) / inv)
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
# Repulsion: FA2 long-range ∝ m_i·m_j / d ; legacy short nn bump otherwise.
if fa2:
# scaling ≈ Gephi scalingRatio; range grows with ideal island pitch.
r_max = max(float(sep_ideal) * 0.85, nn_floor * 10.0, ideal_len * 4.0)
cell = max(r_max * 0.45, nn_floor * 2.0)
bins = _spatial_bins(pos, ids, cell)
# 5×5 neighborhood ≈ long-range without full O(n²).
offsets = tuple((dx, dy) for dx in range(-2, 3) for dy in range(-2, 3))
scale = max(float(scaling), 0.2) * float(repulse_k)
for (cx, cy), bucket in bins.items():
neighbors: list[str] = []
for dx, dy in offsets:
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]
ma = _m(a)
ra = float((nodes.get(a) or {}).get("repulse") or 1.0)
for b in neighbors:
if b == a:
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 >= r_max:
continue
if d < 1e-6:
ang = hash(pair) % 360
rad = math.radians(float(ang))
dx, dy, d = math.cos(rad), math.sin(rad), 1.0
mb = _m(b)
rb = float((nodes.get(b) or {}).get("repulse") or 1.0)
# FA2: F = k * (m_i m_j) / d ; boost near-field to lift nn_p50.
mag = scale * ma * mb * math.sqrt(max(ra * rb, 1e-6)) / d
if d < nn_floor * 2.0:
mag *= 1.0 + (nn_floor * 2.0 - d) / max(nn_floor, 1.0)
mag = min(mag, _MAX_EDGE_PULL * 2.8)
ux, uy = dx / d, dy / d
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
else:
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 = []
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
neighbors.extend(bins.get((cx + dx, cy + dy), ()))
seen = set()
for a in bucket:
if a in frozen:
continue
ax, ay = pos[a]
ra = float((nodes.get(a) or {}).get("repulse") or 1.0)
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
rb = float((nodes.get(b) or {}).get("repulse") or 1.0)
ux, uy = dx / d, dy / d
mag = (
repulse_k
* math.sqrt(max(ra * rb, 1e-6))
* min(r0 - d, r0)
* 0.4
)
mag = min(mag, _MAX_EDGE_PULL * 0.9)
fx[a] += ux * mag
fy[a] += uy * mag
if b not in frozen:
fx[b] -= ux * mag
fy[b] -= uy * mag
# Weak foreign-core attract (optional steal); keep mild so islands survive.
if core_pull_k > 1e-9 and gmeta:
for n in free:
role = str((nodes.get(n) or {}).get("role") or "free")
follow = _FOLLOW.get(role, 1.0)
if follow < 1e-6:
continue
nx, ny = pos[n]
hk = home.get(n)
for key, M, (cx, cy), _members in gmeta:
if key == hk:
continue
dx, dy = cx - nx, cy - ny
d = math.hypot(dx, dy)
if d < nn_floor * 0.5 or d > lambda_core * 2.5:
continue
mag = (
core_pull_k
* M
* follow
* math.exp(-d / max(lambda_core, 1.0))
* 0.015
)
mag = min(mag, _MAX_EDGE_PULL * 0.7)
fx[n] += (dx / d) * mag
fy[n] += (dy / d) * mag
return {n: (fx[n], fy[n]) for n in ids}
def mass_merge_round(
state: LayoutState,
*,
groups: list[dict[str, Any]] | None = None,
iters: int = 16,
step: float = 0.32,
max_step: float = 160.0,
ideal_len: float | None = None,
nn_floor: float = 90.0,
attract_k: float = 1.0,
repulse_k: float = 1.15,
gravity_k: float = 0.85,
group_sep_k: float = 1.0,
group_pack_k: float = 1.1,
sep_ideal: float | None = None,
global_gravity_k: float = 0.0,
core_pull_k: float = 0.2,
lambda_core: float | None = None,
protect_rigid: bool | str = "off",
evolve_every: int = 8,
kappa_node: float = 1.25,
kappa_block: float = 2.0,
rho_ideal: float = 6.0,
x_slack: int | None = None,
damping: float = 0.85,
capture: bool = False,
cluster_seed: bool = True,
fa2: bool = True,
scaling: float = 8.0,
linlog: bool = True,
use_dual_units: bool = True,
) -> OpResult:
"""Run FA2-style mass merge with dual-unit clusters.
Islands = dual-unit groups; soft global gravity keeps the **cloud of
islands** roughly round (repulsion + edge traction), not a strip.
"""
from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled,
report_progress,
touch_heartbeat,
)
st = state.copy()
cv = (st.meta or {}).get("compose_views") or {}
if use_dual_units:
grp = groups if groups is not None else groups_from_mass_or_rigid(cv)
else:
# Temporarily ignore dual-unit / compose mass groups — pure FA2.
grp = []
mass = (st.meta or {}).get("mass_field") if use_dual_units else None
if not isinstance(mass, dict) or not mass.get("nodes"):
# units=[] skips dual-unit detection inside build_mass_field.
mass = build_mass_field(
st, units=[] if not use_dual_units else None, groups=grp
)
else:
mass = dict(mass)
if grp and not mass.get("groups"):
mass["groups"] = grp
if not use_dual_units:
mass["groups"] = []
mass["units"] = []
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="mass_merge", note="mass_merge:too_few"
)
frozen = (
frozen_ids_for_protect(st, protect_rigid)
if protect_rigid not in (False, "false", "off", "none", "0")
else set()
)
for n in list(ids):
if str(n).startswith("region:"):
frozen.add(n)
before = score_state(st, fast=True)
before_x = int((before.get("summary") or {}).get("crossings") or 0)
before_geo = geo_score(st, mass)
slack = (
max(20, int(before_x * 0.12))
if x_slack is None
else max(0, int(x_slack))
)
ideal = (
float(ideal_len)
if ideal_len is not None
else max(float(nn_floor) * 6.0, 520.0)
)
lam = float(lambda_core) if lambda_core is not None else 2.5 * ideal
g0 = _group_records(mass.get("groups") or [], st.positions, mass.get("nodes") or {})
# Keep island pitch moderate — do NOT scale with sqrt(n) (blew 91 units apart).
sep_tgt = (
float(sep_ideal)
if sep_ideal is not None
else max(ideal * 2.0, 700.0)
)
before_sep = _centroid_sep_stats(g0)
# Soft barycenter gravity → round envelope of the whole graph.
# With clusters: mild (islands stay apart via group_sep). Without: stronger.
if global_gravity_k <= 1e-12:
global_gravity_k = (0.45 if use_dual_units else 1.05) if fa2 else 0.4
if not use_dual_units:
gravity_k = 0.0
group_sep_k = 0.0
group_pack_k = 0.0
core_pull_k = 0.0
pos = {n: (float(st.positions[n][0]), float(st.positions[n][1])) for n in ids}
seeded = False
seed_reason = "skip"
if cluster_seed and g0:
pos, seeded, seed_reason = _seed_group_spread(
pos, g0, sep_ideal=sep_tgt, frozen=frozen
)
if seeded:
st.positions = {**st.positions, **pos}
g0 = _group_records(
mass.get("groups") or [], pos, mass.get("nodes") or {}
)
before_sep = _centroid_sep_stats(g0)
elif cluster_seed and not g0:
# Fallback only when there are no clusters: disk if strip/collapsed.
xs = [pos[n][0] for n in ids if n not in frozen]
ys = [pos[n][1] for n in ids if n not in frozen]
if len(xs) >= 8:
w = max(xs) - min(xs)
h = max(ys) - min(ys)
aspect = max(w, h) / max(min(w, h), 1.0)
sample = ids[: min(80, len(ids))]
nns: list[float] = []
for a in sample:
if a in frozen:
continue
ax, ay = pos[a]
best = None
for b in sample:
if a == b or b in frozen:
continue
d = math.hypot(ax - pos[b][0], ay - pos[b][1])
if best is None or d < best:
best = d
if best is not None:
nns.append(best)
med = sorted(nns)[len(nns) // 2] if nns else 0.0
if aspect > 1.55 or med < nn_floor * 0.7:
disk_pitch = max(float(nn_floor) * 1.15, ideal * 0.22, 100.0)
pos = _seed_nodes_disk(pos, ids, pitch=disk_pitch, frozen=frozen)
st.positions = {**st.positions, **pos}
seeded = True
seed_reason = "disk_aspect" if aspect > 1.55 else "disk_collapsed"
links = list(st.links)
n_iters = max(1, min(48, 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)))
every = max(2, int(evolve_every))
report_progress(
"mass_merge",
pct=48.0,
message=(
f"iters={n_iters} groups={len(mass.get('groups') or [])} "
f"sep_ideal={sep_tgt:.0f} seed={seed_reason}"
),
nodes=len(ids),
)
vel = {n: (0.0, 0.0) for n in ids}
moved: set[str] = set()
area0 = _bbox_area(pos)
best_pos = dict(pos)
best_mass = mass
# Prefer separation + geo; do NOT reward bbox crush.
best_key = (
before_sep["mean_nn"],
before_geo,
-before_x,
)
best_iter = 0
capture_reports: list[dict[str, Any]] = []
evolve_reports: list[dict[str, Any]] = []
nodes_m = mass.get("nodes") or {}
for it in range(n_iters):
raise_if_cancelled()
touch_heartbeat()
if it % 2 == 0:
report_progress(
"mass_merge",
pct=48.0 + 24.0 * (it / max(n_iters, 1)),
message=f"iter {it + 1}/{n_iters}",
iter=it + 1,
)
forces = _accumulate_mass_forces(
pos,
links,
ids,
mass=mass,
ideal_len=ideal,
nn_floor=float(nn_floor),
attract_k=float(attract_k),
repulse_k=float(repulse_k),
gravity_k=float(gravity_k),
group_sep_k=float(group_sep_k),
group_pack_k=float(group_pack_k),
sep_ideal=float(sep_tgt),
global_gravity_k=float(global_gravity_k),
core_pull_k=float(core_pull_k),
lambda_core=lam,
frozen=frozen,
fa2=bool(fa2),
scaling=float(scaling),
linlog=bool(linlog),
)
for n in ids:
if n in frozen:
continue
fx, fy = forces[n]
row = nodes_m.get(n) or {}
m = max(float(row.get("mass") or 1.0), 0.2)
role = str(row.get("role") or "free")
role_cap = cap * _STEP_CAP.get(role, 1.0)
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 > role_cap:
s = role_cap / spd
vx, vy = vx * s, vy * s
vel[n] = (vx, vy)
if abs(vx) + abs(vy) < 1e-4:
continue
x, y = pos[n]
pos[n] = (x + vx, y + vy)
moved.add(n)
if (it + 1) % every == 0 or it == n_iters - 1:
st.positions = {**st.positions, **pos}
if use_dual_units:
mass = evolve_chains_to_rings(st, mass)
ev = mass.get("evolve") or {}
evolve_reports.append(dict(ev))
if capture and use_dual_units:
mass, crep = capture_pass(
st,
mass,
kappa_node=kappa_node,
kappa_block=kappa_block,
rho_ideal=rho_ideal,
ideal_len=ideal,
)
capture_reports.append(crep)
nodes_m = mass.get("nodes") or {}
if it % 2 == 1 or it == n_iters - 1:
st.positions = {**st.positions, **pos}
g = geo_score(st, mass)
x_now = count_edge_crossings(pos, links)
soft_cap = before_x + max(slack * 4, int(before_x * 0.7) + 80)
# FA2 stage-1: crossings will spike while islands form — don't discard.
if (not fa2) and x_now > soft_cap:
continue
gnow = _group_records(
mass.get("groups") or [], pos, mass.get("nodes") or {}
)
sep = _centroid_sep_stats(gnow)
if fa2:
key = (sep["mean_nn"], g, -abs(sep["mean_nn"] - sep_tgt))
else:
key = (sep["mean_nn"], g, -x_now)
if key > best_key:
best_key = key
best_pos = dict(pos)
best_mass = mass
best_iter = it + 1
if best_iter == 0:
st.positions = {**st.positions, **pos}
last_geo = geo_score(st, mass)
last_x = count_edge_crossings(pos, links)
soft_cap = before_x + max(slack * 4, int(before_x * 0.7) + 80)
gnow = _group_records(mass.get("groups") or [], pos, mass.get("nodes") or {})
sep = _centroid_sep_stats(gnow)
if last_x <= soft_cap and (
sep["mean_nn"] >= before_sep["mean_nn"] * 0.95
or last_geo >= before_geo - 1.0
):
best_pos = dict(pos)
best_mass = mass
best_iter = n_iters
pos = best_pos
mass = best_mass
st.positions = {**st.positions, **pos}
fin = score_state(st, fast=True)
end_x = int((fin.get("summary") or {}).get("crossings") or 0)
end_geo = geo_score(st, mass)
end_gmeta = _group_records(
mass.get("groups") or [], pos, mass.get("nodes") or {}
)
end_sep = _centroid_sep_stats(end_gmeta)
area1 = _bbox_area(pos)
area_ratio = area0 / max(area1, 1e-6)
meta = {
"reverted": False,
"start_crossings": before_x,
"end_crossings": end_x,
"start_geo": round(before_geo, 3),
"end_geo": round(end_geo, 3),
"start_sep_nn": before_sep["mean_nn"],
"end_sep_nn": end_sep["mean_nn"],
"min_sep_nn": end_sep["min_nn"],
"sep_ideal": round(sep_tgt, 1),
"cluster_seeded": seeded,
"seed_reason": seed_reason,
"moved_n": len(moved),
"iters": n_iters,
"best_iter": best_iter,
"ideal_len": round(ideal, 1),
"gravity_k": float(gravity_k),
"group_sep_k": float(group_sep_k),
"group_pack_k": float(group_pack_k),
"global_gravity_k": float(global_gravity_k),
"core_pull_k": float(core_pull_k),
"capture": bool(capture),
"fa2": bool(fa2),
"scaling": float(scaling),
"linlog": bool(linlog),
"use_dual_units": bool(use_dual_units),
"kappa_node": float(kappa_node),
"kappa_block": float(kappa_block),
"evolve_every": every,
"capture_log": capture_reports[-3:] if capture_reports else [],
"evolve": evolve_reports[-3:] if evolve_reports else [],
"groups_n": len(mass.get("groups") or []),
"bbox_area_ratio": round(area_ratio, 4),
"x_slack": slack,
}
sep_up = end_sep["mean_nn"] > before_sep["mean_nn"] * 1.05 + 20.0
geo_up = end_geo > before_geo + 0.02
x_ok = end_x <= before_x + max(slack * 4, int(before_x * 0.7) + 80)
# Lattice/repack seed is itself the stage-1 gain — keep it even if x rises.
gain = sep_up or geo_up or seeded or (
len(moved) >= 2 and end_geo >= before_geo - 1.0
)
# FA2 clustering tolerates crossing spikes; only revert if nothing improved.
if (not fa2) and (not x_ok) and (not sep_up) and (not geo_up) and (not seeded):
meta["reverted"] = True
meta["reason"] = "crossing_rise"
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["mass_merge"] = meta
return OpResult(
state=st0,
moved=set(),
op="mass_merge",
params=meta,
note="mass_merge:reverted crossing_rise",
)
if best_iter == 0 and not gain:
meta["reverted"] = True
meta["reason"] = "no_sep_gain"
st0 = state.copy()
st0.meta = dict(st0.meta or {})
st0.meta["mass_merge"] = meta
return OpResult(
state=st0,
moved=set(),
op="mass_merge",
params=meta,
note="mass_merge:reverted no_sep_gain",
)
# Seeded but no checkpoint accepted: still keep last iterated / seeded pos.
if best_iter == 0 and seeded:
best_pos = dict(pos)
best_mass = mass
best_iter = max(1, n_iters)
pos = best_pos
mass = best_mass
st.positions = {**st.positions, **pos}
end_x = count_edge_crossings(pos, links)
end_geo = geo_score(st, mass)
end_gmeta = _group_records(
mass.get("groups") or [], pos, mass.get("nodes") or {}
)
end_sep = _centroid_sep_stats(end_gmeta)
meta["end_crossings"] = end_x
meta["end_geo"] = round(end_geo, 3)
meta["end_sep_nn"] = end_sep["mean_nn"]
meta["min_sep_nn"] = end_sep["min_nn"]
meta["best_iter"] = best_iter
meta["kept_seed"] = True
st.meta = dict(st.meta or {})
st.meta["mass_field"] = mass
st.meta["mass_merge"] = meta
cv2 = dict(st.meta.get("compose_views") or cv or {})
cv2["mass_groups"] = mass.get("groups") or []
cv2["soft"] = True
st.meta["compose_views"] = cv2
st.last_moved = moved
return OpResult(
state=st,
moved=moved,
op="mass_merge",
params=meta,
note=(
f"mass_merge sep {before_sep['mean_nn']:.0f}->{end_sep['mean_nn']:.0f} "
f"geo {before_geo:.1f}->{end_geo:.1f} x {before_x}->{end_x} "
f"moved={len(moved)}"
),
)
def mass_merge_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, 48, 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, 4.0, float),
("group_sep_k", 0.0, 4.0, float),
("group_pack_k", 0.0, 4.0, float),
("sep_ideal", 200.0, 20000.0, float),
("global_gravity_k", 0.0, 3.0, float),
("core_pull_k", 0.0, 4.0, float),
("lambda_core", 100.0, 8000.0, float),
("evolve_every", 2, 24, int),
("kappa_node", 0.5, 4.0, float),
("kappa_block", 1.1, 6.0, float),
("rho_ideal", 2.0, 20.0, float),
("damping", 0.2, 0.98, float),
("x_slack", 0, 5000, int),
("scaling", 0.2, 40.0, float),
):
if o.get(key) is None:
continue
try:
v = cast(o[key])
out[key] = max(lo, min(hi, v))
except (TypeError, ValueError):
pass
for flag in ("capture", "cluster_seed", "fa2", "linlog", "use_dual_units"):
if flag in o:
v = o.get(flag)
if isinstance(v, bool):
out[flag] = v
else:
out[flag] = str(v).strip().lower() in {"1", "true", "yes", "on"}
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 "off").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"
return out