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:
@@ -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);
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user