Option A: incremental anchored force layout, persisted server-side

The layout now EVOLVES instead of reshuffling on every extraction. A pure
core module (src/core/layout.ts) warm-starts forceAtlas2 from the positions
ledger (data/graph/positions.json, read-modify-write like sources.json,
updatedAt stamp for future live-physics conflict handling), re-blends every
previously-positioned node toward its anchor each iteration (retention 0.85
- old placements stay recognizable, strong forces still win), seeds new
nodes near their positioned neighbors, and seeds orphans on the rim outside
the core so they read as unconnected knowledge.

- Port/store: GraphStore.readPositions/writePositions; snapshot carries
  positions when the ledger exists (optional - fresh graphs unchanged).
- Extraction pipeline settles synchronously after every merge (chat and
  notes paths), before usage persist - the next /api/graph fetch already
  reflects new nodes.
- View: the ledger IS the layout; the local one-shot FA2 pass survives
  only as a fallback when no ledger exists.
- scripts/replay-layout.ts: simulates organic growth by replaying the
  current 338-node graph one imported file at a time (real import order,
  edges activate when both endpoints exist). Real run: 338 positions in
  0.3s, 90-degree-0 orphans collecting at mean r=338 vs connected r=194.
- Also restores the cluster region fills (hull background colors) that
  6692b19 had removed on a misreading of "no glow".

Live-verified: fit zoom derives from the ledger bbox, pickAt aligns with
ledger positions at every probe, hover/click/detail-card work, 408/408
tests, typecheck/lint/build green.

Future live physics stays cheap by design: the same pure function is the
merge-time settler and the future client simulator; a pinned node is just
an anchor with full retention.
This commit is contained in:
amosblomqvist
2026-09-13 08:35:42 +03:00
parent 11bca055dd
commit 0c14ea425d
12 changed files with 565 additions and 14 deletions
+7
View File
@@ -66,6 +66,7 @@ function memoryLedgers() {
const checkpoints: SessionCheckpoint[] = [];
const sources = new Map<string, NotesSource>();
const clusterLabels: ClusterLabeling[] = [];
const positions = new Map<string, { x: number; y: number }>();
return {
appendNode: (n: GraphNode) => void nodes.push(n),
readNodes: () => nodes.map((n) => ({ ...n })),
@@ -95,6 +96,12 @@ function memoryLedgers() {
if (!clusterLabels.some((x) => x.id === l.id)) clusterLabels.push(l);
},
readClusterLabels: () => clusterLabels.map((l) => ({ ...l, core: [...l.core] })),
// The layout ledger — settleGraphLayout writes it after each merge.
readPositions: () => Object.fromEntries([...positions].map(([id, p]) => [id, { ...p }])),
writePositions: (next) => {
positions.clear();
for (const [id, p] of Object.entries(next)) positions.set(id, { x: p.x, y: p.y });
},
} satisfies GraphStore;
}
+128
View File
@@ -0,0 +1,128 @@
/**
* Option A — incremental anchored force layout (core/layout.ts). The
* properties the user asked for: the layout EVOLVES (old placements stay
* recognizable), new nodes land near their cluster, orphans collect on
* the rim, and everything is deterministic.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { incrementalLayout, settleGraphLayout } from "../src/core/layout.ts";
import type { GraphNode, GraphEdge, Position } from "../src/core/contracts.ts";
const node = (id: string): GraphNode => ({
id,
name: id,
statement: id,
aliases: [],
createdAt: "2026-09-13T00:00:00.000Z",
provenance: [],
});
const edge = (from: string, to: string): GraphEdge => ({ from, to, type: "supports", provenance: [] });
const mean = (positions: Map<string, Position>, ids: string[]): { x: number; y: number } => ({
x: ids.reduce((s, id) => s + positions.get(id)!.x, 0) / ids.length,
y: ids.reduce((s, id) => s + positions.get(id)!.y, 0) / ids.length,
});
describe("incrementalLayout", () => {
it("bootstraps deterministically from an empty ledger", () => {
const nodes = ["a", "b", "c", "d"].map(node);
const edges = [edge("a", "b"), edge("b", "c"), edge("c", "d")];
const first = incrementalLayout(nodes, edges, new Map());
const second = incrementalLayout(nodes, edges, new Map());
assert.equal(first.size, 4);
for (const [id, p] of first) {
assert.ok(Number.isFinite(p.x) && Number.isFinite(p.y), `finite coords for ${id}`);
assert.deepEqual(p, second.get(id), `deterministic position for ${id}`);
}
});
it("rest state is stable: a re-settle with nothing new barely moves nodes", () => {
const nodes = ["a", "b", "c", "d", "e"].map(node);
const edges = [edge("a", "b"), edge("b", "c"), edge("c", "d"), edge("d", "e"), edge("a", "e")];
const rest = incrementalLayout(nodes, edges, new Map());
const span =
Math.max(...[...rest.values()].map((p) => p.x)) - Math.min(...[...rest.values()].map((p) => p.x));
const again = incrementalLayout(nodes, edges, rest);
let maxShift = 0;
for (const [id, p] of rest) {
const q = again.get(id)!;
maxShift = Math.max(maxShift, Math.hypot(p.x - q.x, p.y - q.y));
}
assert.ok(maxShift < span * 0.1, `re-settle drift ${maxShift.toFixed(2)} should be < 10% of span ${span.toFixed(2)}`);
});
it("evolves: an anchored node stays near its anchor while a new neighbor joins it", () => {
const core = ["a", "b", "c"].map(node);
const coreEdges = [edge("a", "b"), edge("b", "c")];
const previous = incrementalLayout(core, coreEdges, new Map());
const anchor = previous.get("b")!;
const dist = Math.hypot;
const nearby = ["d", "e"].map(node);
const next = incrementalLayout(
[...core, ...nearby],
[...coreEdges, edge("b", "d"), edge("d", "e")],
previous,
);
// The anchor keeps its neighborhood (bounded drift), and the new
// nodes landed near it rather than being flung elsewhere.
const coreSpanBefore =
Math.max(...[...previous.values()].map((p) => p.x)) - Math.min(...[...previous.values()].map((p) => p.x));
assert.ok(
dist(next.get("b")!.x - anchor.x, next.get("b")!.y - anchor.y) < coreSpanBefore,
"anchored node b stays near its previous position",
);
const centroid = mean(next, ["b", "d", "e"]);
assert.ok(
dist(next.get("d")!.x - centroid.x, next.get("d")!.y - centroid.y) < coreSpanBefore * 2,
"new neighbor d landed near the cluster it arrived with",
);
});
it("rim: an orphan ends up outside the anchored core", () => {
const core = ["a", "b", "c", "d", "e"].map(node);
const coreEdges = [
edge("a", "b"),
edge("b", "c"),
edge("c", "d"),
edge("d", "e"),
edge("a", "e"),
edge("b", "d"),
];
const previous = incrementalLayout(core, coreEdges, new Map());
const next = incrementalLayout([...core, node("orphan")], coreEdges, previous);
const c = mean(previous, ["a", "b", "c", "d", "e"]);
const coreMax = Math.max(
...[...next.entries()].filter(([id]) => id !== "orphan").map(([, p]) => Math.hypot(p.x - c.x, p.y - c.y)),
);
const orphanR = Math.hypot(next.get("orphan")!.x - c.x, next.get("orphan")!.y - c.y);
assert.ok(orphanR > coreMax, `orphan at r=${orphanR.toFixed(1)} should sit outside core max r=${coreMax.toFixed(1)}`);
});
});
describe("settleGraphLayout", () => {
it("dedupes repeated node lines, runs, and writes the whole ledger", () => {
const written: Record<string, Position>[] = [];
const lines = [node("a"), node("b"), node("a")]; // "a" appended twice
const store = {
readNodes: () => lines.map((l) => ({ ...l })),
readEdges: () => [edge("a", "b")],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p: Record<string, Position>) => void written.push(p),
};
settleGraphLayout(store);
assert.equal(written.length, 1);
assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "b"], "one position per UNIQUE node");
});
it("does nothing on an empty graph", () => {
const written: Record<string, Position>[] = [];
settleGraphLayout({
readNodes: () => [],
readEdges: () => [],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p) => void written.push(p),
});
assert.equal(written.length, 0);
});
});