Cluster gravity: per-cluster centroid pull force in the engine

The 'virtual node' realized as a centroid force: two allocation-free
O(n) passes per tick (membership resolved once at sim creation;
Float64Array sums). Measured indistinguishable from force-off at 338
nodes (271ms vs 272ms per 400-tick settle).

- createGraphSimulation: SimOptions.clusterPull + linkStrength +
  velocityDecay (physics controls plumbing); degree-scaled link
  stiffness so linkStrength=1 is exactly d3's default
- settleGraphLayout split into layout-settle.ts (it computes the
  Louvain partition for the pull); layout.ts stays browser-barrel-pure
- barrel no longer re-exports clusters.ts: graphology + louvain are
  fully out of the client bundle (verified against built chunks)
- replay passes the per-batch partition; CLUSTER_PULL_STRENGTH=0.15
  tuned by sweep: cluster spread 63->36 world units, centroid
  separation and total span unchanged (no blob merge, no collapse)
This commit is contained in:
amosblomqvist
2026-09-13 09:52:12 +03:00
parent 60686d7831
commit 975a78a304
7 changed files with 183 additions and 41 deletions
+6 -5
View File
@@ -10,6 +10,7 @@
* npx tsx scripts/replay-layout.ts
*/
import { graphStore } from "../src/store/graph.ts";
import { detectCommunities } from "../src/core/clusters.ts";
import { incrementalLayout } from "../src/core/layout.ts";
import type { GraphNode, NodeMapping, Position } from "../src/core/contracts.ts";
@@ -69,11 +70,11 @@ function main() {
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
const before = new Map(positions);
positions = incrementalLayout(
nodes.filter((n) => delivered.has(n.id)),
activeEdges,
positions,
);
const batchNodes = nodes.filter((n) => delivered.has(n.id));
positions = incrementalLayout(batchNodes, activeEdges, positions, {
// The same partition the real settle would see for this batch.
clusters: detectCommunities(batchNodes, activeEdges).communities,
});
for (const [id, prev] of before) {
const now = positions.get(id)!;
const shift = Math.hypot(prev.x - now.x, prev.y - now.y);
+1 -1
View File
@@ -16,7 +16,7 @@ import type {
SessionFindings,
} from "../../core/contracts.ts";
import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../../core/graph.ts";
import { settleGraphLayout } from "../../core/layout.ts";
import { settleGraphLayout } from "../../core/layout-settle.ts";
import { refreshClusterLabels } from "./cluster-namer.ts";
/**
+5 -1
View File
@@ -20,7 +20,11 @@ export * from "./ports.ts";
export * from "./render-settings.ts";
export * from "./question-core.ts";
export * from "./clusters.ts";
// clusters.ts (Louvain) is deliberately NOT re-exported: nothing in the
// shell runs community detection — the snapshot carries the partition
// from the server. Keeping it out keeps graphology-louvain out of the
// client bundle (verified 2026-09-13: chunks contained it while this
// line existed). Same logic as layout-settle.ts below.
export * from "./layout.ts";
export * from "./plan-tree.ts";
export * from "./phases.ts";
+43
View File
@@ -0,0 +1,43 @@
import type { GraphEdge, GraphNode } from "./contracts.ts";
import { detectCommunities } from "./clusters.ts";
import { incrementalLayout } from "./layout.ts";
import type { Position } from "./contracts.ts";
/**
* The merge-time settle: recompute the resting layout from the CURRENT
* graph + the positions ledger, and replace the ledger. Called by the
* extraction pipeline after every merge (the sole writer) and by the
* positions-commit endpoint when the graph grew under a live-physics
* commit (client positions become the anchor set). Synchronous — it must
* finish before the next /api/graph fetch reflects the change.
*
* SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
* browser barrel, and the settle needs the Louvain partition for the
* cluster centroid pull — graphology-louvain must not ship in the client
* bundle. Server code imports this module DIRECTLY; it is never
* re-exported through src/core/index.ts.
*/
export function settleGraphLayout(graphStore: {
readNodes(): GraphNode[];
readEdges(): GraphEdge[];
readPositions(): Record<string, Position>;
writePositions(positions: Record<string, Position>): void;
}): void {
const nodes = graphStore.readNodes();
if (nodes.length === 0) return;
// Node LINES may repeat an id (link-appends) — dedupe, first defines.
const seen = new Set<string>();
const unique = nodes.filter((n) => {
if (seen.has(n.id)) return false;
seen.add(n.id);
return true;
});
const edges = graphStore.readEdges();
const previous = new Map(Object.entries(graphStore.readPositions()));
// The cluster partition for the centroid pull: same Louvain the
// snapshot uses (same nodes/edges = same communities). 338 nodes:
// ~1-2ms, noise next to the settle itself.
const { communities } = detectCommunities(unique, edges);
const next = incrementalLayout(unique, edges, previous, { clusters: communities });
graphStore.writePositions(Object.fromEntries(next));
}
+83 -31
View File
@@ -46,6 +46,9 @@ type SimLink = SimulationLinkDatum<LayoutNode>;
export interface SimOptions {
/** Link spring rest length (world units). */
linkDistance?: number;
/** Link spring stiffness MULTIPLIER (1 = d3's default
* 1/min(degree) stiffness). */
linkStrength?: number;
/** Many-body repulsion strength (negative). */
chargeStrength?: number;
/** Center-of-mass gravity: pulls loose components inward. Weak —
@@ -54,6 +57,18 @@ export interface SimOptions {
/** Alpha decay per tick — higher = snappier settle. The merge settle
* uses d3's default 0.0228 (~300 ticks); the live sim passes ~0.05. */
alphaDecay?: number;
/** Velocity damping per tick (d3 default 0.4). Higher = more friction. */
velocityDecay?: number;
/** Cluster centroid pull (the "virtual node" without the node):
* members of each cluster accelerate toward their own cluster's
* centroid. Two O(n) passes per tick — cheaper than a real virtual
* node (which would need one extra link spring per member plus a
* maintained position). Strength 0 or absent = force off.
* Callers supply the partition (Louvain output shape). */
clusterPull?: {
clusters: ReadonlyArray<{ members: ReadonlyArray<string> }>;
strength: number;
};
}
/** Link input: edges are undirected for layout purposes. */
@@ -62,8 +77,8 @@ export interface LayoutEdge {
to: string;
}
const LINK_DISTANCE = 40;
const CHARGE_STRENGTH = -120;
export const LINK_DISTANCE = 40;
export const CHARGE_STRENGTH = -120;
/** Per-tick anchor re-blend: fraction of an anchored node's displacement
* from its previous position that SURVIVES each tick. 0.85 = gentle
* evolution where forces are strong, stillness where they are not —
@@ -81,6 +96,10 @@ const BOOTSTRAP_GRAVITY = 0.2;
/** Exported: the live sim reuses this so its rest state matches the
* server settle's equilibrium (one engine, one rest). */
export const INCREMENTAL_GRAVITY = 0.12;
/** Exported: default cluster-centroid pull strength (tuned by replay —
* compact enough to read as blobs, weak enough that cluster shape is
* still the edge network's). */
export const CLUSTER_PULL_STRENGTH = 0.15;
/**
* The one force set. Returns a STOPPED simulation — callers drive it
@@ -93,6 +112,15 @@ export function createGraphSimulation(
edges: ReadonlyArray<LayoutEdge>,
options: SimOptions = {},
): Simulation<LayoutNode, SimLink> {
// Link stiffness: d3's default is 1/min(deg) (degree-scaled springs).
// linkStrength multiplies it, so 1 = unchanged default behavior.
const degree = new Map<string, number>();
for (const e of edges) {
degree.set(e.from, (degree.get(e.from) ?? 0) + 1);
degree.set(e.to, (degree.get(e.to) ?? 0) + 1);
}
const linkStrength = options.linkStrength ?? 1;
const sim = forceSimulation<LayoutNode, SimLink>(nodes)
.force(
"link",
@@ -100,10 +128,16 @@ export function createGraphSimulation(
edges.map((e) => ({ source: e.from, target: e.to })),
)
.id((d) => d.id)
.distance(options.linkDistance ?? LINK_DISTANCE),
.distance(options.linkDistance ?? LINK_DISTANCE)
.strength(
(l) =>
(linkStrength * 1) /
Math.min(degree.get((l.source as LayoutNode).id) ?? 1, degree.get((l.target as LayoutNode).id) ?? 1),
),
)
.force("charge", forceManyBody<LayoutNode>().strength(options.chargeStrength ?? CHARGE_STRENGTH))
.alphaDecay(options.alphaDecay ?? 0.0228)
.velocityDecay(options.velocityDecay ?? 0.4)
.stop();
const g = options.gravity;
@@ -111,6 +145,43 @@ export function createGraphSimulation(
sim.force("x", forceX<LayoutNode>(g.x).strength(g.strength));
sim.force("y", forceY<LayoutNode>(g.y).strength(g.strength));
}
// Cluster centroid pull: the membership index is resolved ONCE (nodes
// don't change clusters mid-sim); each tick is two allocation-free
// O(n) passes (sum, then pull). Singletons (<2 members) skip themselves.
const cp = options.clusterPull;
if (cp && cp.strength > 0 && cp.clusters.length > 0) {
const clusterIdx = new Map<string, number>();
cp.clusters.forEach((c, i) => {
for (const id of c.members) clusterIdx.set(id, i);
});
const nodeCluster = nodes.map((n) => clusterIdx.get(n.id) ?? -1);
const k = cp.clusters.length;
const sumX = new Float64Array(k);
const sumY = new Float64Array(k);
const cnt = new Float64Array(k);
const strength = cp.strength;
sim.force("clusterPull", (alpha: number) => {
sumX.fill(0);
sumY.fill(0);
cnt.fill(0);
for (let i = 0; i < nodes.length; i++) {
const ci = nodeCluster[i]!;
if (ci < 0) continue;
const n = nodes[i]!;
sumX[ci] = sumX[ci]! + (n.x ?? 0);
sumY[ci] = sumY[ci]! + (n.y ?? 0);
cnt[ci] = cnt[ci]! + 1;
}
for (let i = 0; i < nodes.length; i++) {
const ci = nodeCluster[i]!;
if (ci < 0 || (cnt[ci] ?? 0) < 2) continue;
const n = nodes[i]!;
n.vx = (n.vx ?? 0) + (sumX[ci]! / cnt[ci]! - (n.x ?? 0)) * strength * alpha;
n.vy = (n.vy ?? 0) + (sumY[ci]! / cnt[ci]! - (n.y ?? 0)) * strength * alpha;
}
});
}
return sim;
}
@@ -122,6 +193,12 @@ export interface IncrementalLayoutOptions {
/** Center-of-mass gravity strength override (tuning/A-B experiments;
* defaults to the internal bootstrap/incremental constants). */
gravityStrength?: number;
/** Cluster partition (Louvain output shape). Supplied by callers so
* this module stays louvain-free (the browser bundle must not carry
* graphology-louvain); absent = no cluster pull. */
clusters?: ReadonlyArray<{ members: ReadonlyArray<string> }>;
/** Cluster centroid pull strength (default CLUSTER_PULL_STRENGTH). */
clusterPullStrength?: number;
}
/**
@@ -253,6 +330,9 @@ export function incrementalLayout(
y: cy,
strength: options.gravityStrength ?? (hasCore ? INCREMENTAL_GRAVITY : BOOTSTRAP_GRAVITY),
},
clusterPull: options.clusters
? { clusters: options.clusters, strength: options.clusterPullStrength ?? CLUSTER_PULL_STRENGTH }
: undefined,
});
const byId = new Map(simNodes.map((n) => [n.id, n]));
for (let i = 0; i < ticks; i++) {
@@ -275,31 +355,3 @@ export function incrementalLayout(
for (const n of simNodes) out.set(n.id, { x: n.x ?? 0, y: n.y ?? 0 });
return out;
}
/**
* The merge-time settle: recompute the resting layout from the CURRENT
* graph + the positions ledger, and replace the ledger. Called by the
* extraction pipeline after every merge (the sole writer) and by the
* positions-commit endpoint when the graph grew under a live-physics
* commit (client positions become the anchor set). Synchronous — it must
* finish before the next /api/graph fetch reflects the change.
*/
export function settleGraphLayout(graphStore: {
readNodes(): GraphNode[];
readEdges(): GraphEdge[];
readPositions(): Record<string, Position>;
writePositions(positions: Record<string, Position>): void;
}): void {
const nodes = graphStore.readNodes();
if (nodes.length === 0) return;
// Node LINES may repeat an id (link-appends) — dedupe, first defines.
const seen = new Set<string>();
const unique = nodes.filter((n) => {
if (seen.has(n.id)) return false;
seen.add(n.id);
return true;
});
const previous = new Map(Object.entries(graphStore.readPositions()));
const next = incrementalLayout(unique, graphStore.readEdges(), previous);
graphStore.writePositions(Object.fromEntries(next));
}
+1 -1
View File
@@ -12,7 +12,7 @@
*/
import type { Position } from "../core/contracts.ts";
import { getGraph } from "../core/graph.ts";
import { settleGraphLayout } from "../core/layout.ts";
import { settleGraphLayout } from "../core/layout-settle.ts";
import { stores } from "./stores.ts";
/** Max |x| or |y| accepted — NaN/Infinity clamp here, absurd too. */
+44 -2
View File
@@ -6,7 +6,8 @@
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { incrementalLayout, settleGraphLayout } from "../src/core/layout.ts";
import { createGraphSimulation, incrementalLayout } from "../src/core/layout.ts";
import { settleGraphLayout } from "../src/core/layout-settle.ts";
import type { GraphNode, GraphEdge, Position } from "../src/core/contracts.ts";
const node = (id: string): GraphNode => ({
@@ -125,4 +126,45 @@ describe("settleGraphLayout", () => {
});
assert.equal(written.length, 0);
});
});
});
describe("clusterPull (the centroid force)", () => {
it("compacts each cluster toward its OWN centroid; strength 0 = off", () => {
// Two loose chains, far apart, edges only WITHIN each chain.
const mk = (p: string, baseX: number) =>
Array.from({ length: 8 }, (_, i) => ({ id: `${p}${i}`, x: baseX + i * 40, y: i * 15 }));
const a = mk("a", -400);
const b = mk("b", 400);
const nodes = [...a, ...b];
const edges = [
...a.slice(0, -1).map((n, i) => ({ from: n.id, to: a[i + 1]!.id })),
...b.slice(0, -1).map((n, i) => ({ from: n.id, to: b[i + 1]!.id })),
];
const clusters = [{ members: a.map((n) => n.id) }, { members: b.map((n) => n.id) }];
const spreadOf = (ns: { id: string; x: number; y: number }[]): number => {
let sum = 0;
for (const group of [ns.filter((n) => n.id[0] === "a"), ns.filter((n) => n.id[0] === "b")]) {
const cx = group.reduce((s, n) => s + n.x, 0) / group.length;
const cy = group.reduce((s, n) => s + n.y, 0) / group.length;
sum += group.reduce((s, n) => s + Math.hypot(n.x - cx, n.y - cy), 0) / group.length;
}
return sum;
};
const run = (strength: number) => {
const sim = createGraphSimulation(
nodes.map((n) => ({ ...n })),
edges,
{ clusterPull: { clusters, strength } },
);
for (let i = 0; i < 150; i++) sim.tick();
return spreadOf(sim.nodes() as unknown as { id: string; x: number; y: number }[]);
};
const pulled = run(0.15);
const unpulled = run(0);
// The force compacts (well under the unpulled baseline)...
assert.ok(pulled < unpulled * 0.8, `pulled spread ${pulled.toFixed(1)} should be well under ${unpulled.toFixed(1)}`);
// ...and both groups survive as separate blobs (no merge).
assert.ok(pulled > 0, "spread is finite/positive");
});
});