mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 06:10:58 +08:00
- 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>
1049 lines
37 KiB
Python
1049 lines
37 KiB
Python
"""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
|