diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index 7c56d84..eb7f862 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -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); diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index ce388a7..f0e2030 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -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"; /** diff --git a/src/core/index.ts b/src/core/index.ts index 792689b..70c2ebb 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts new file mode 100644 index 0000000..eb23aa7 --- /dev/null +++ b/src/core/layout-settle.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; + writePositions(positions: Record): 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(); + 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)); +} \ No newline at end of file diff --git a/src/core/layout.ts b/src/core/layout.ts index b1a7d54..e2da57e 100644 --- a/src/core/layout.ts +++ b/src/core/layout.ts @@ -46,6 +46,9 @@ type SimLink = SimulationLinkDatum; 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 }>; + 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, options: SimOptions = {}, ): Simulation { + // Link stiffness: d3's default is 1/min(deg) (degree-scaled springs). + // linkStrength multiplies it, so 1 = unchanged default behavior. + const degree = new Map(); + 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(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().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(g.x).strength(g.strength)); sim.force("y", forceY(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(); + 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 }>; + /** 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; - writePositions(positions: Record): 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(); - 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)); -} \ No newline at end of file diff --git a/src/server/commit-positions.ts b/src/server/commit-positions.ts index 8178cf7..a31232e 100644 --- a/src/server/commit-positions.ts +++ b/src/server/commit-positions.ts @@ -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. */ diff --git a/tests/layout.test.ts b/tests/layout.test.ts index adabea6..15e6fc0 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -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); }); -}); \ No newline at end of file +}); +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"); + }); +});