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
+25 -8
View File
@@ -129,10 +129,16 @@ const PICKING_PAD = 10;
/** Click vs drag threshold: pointer travel under this many px is a click. */
const CLICK_SLOP = 4;
/** Cluster hull styling: a SOLID viewer-relative border only. The layered
* low-alpha fills (the "glow") were removed on user request — they read
* as jagged up close (straight-line hull approximation) and are better
* revisited once the layout settles. */
/** Cluster hull styling: layered low-alpha fills tint each region, plus
* a SOLID viewer-relative border. (The fills were briefly removed on a
* misreading of "no glow": they are the REGION BACKGROUNDS, not the
* glow. The jagged-when-zoomed look is the straight-line hull
* approximation, polishable later once the layout settles.) */
const HULL_FILL_LAYERS = [
[24, 0.025],
[19, 0.04],
[14, 0.06],
] as const;
const HULL_STROKE_PAD = 14;
const HULL_CORNER_RADIUS = 9;
const REGION_LABEL_SIZE = 14;
@@ -356,6 +362,7 @@ export class GraphCanvasRenderer {
private shaftsPath: Path2D = new Path2D();
private headsPath: Path2D = new Path2D();
private hullShapes: {
fill: Path2D[];
stroke: Path2D;
label: string | null;
cx: number; // world centroid
@@ -704,7 +711,8 @@ export class GraphCanvasRenderer {
this.headsPath = heads;
// Cluster hulls: convex hull of member positions (world = raw data
// coordinates — no projection at all) and a SOLID boundary path.
// coordinates, no projection at all), three offset fill layers,
// and a SOLID boundary path.
this.hullShapes = [];
for (const cluster of data.clusters) {
const pts: Pt[] = [];
@@ -715,11 +723,16 @@ export class GraphCanvasRenderer {
}
const hull = convexHull(pts);
if (hull.length < 3) continue;
const fills = HULL_FILL_LAYERS.map(([pad]) => {
const path = new Path2D();
traceRoundedPath(path, offsetPolygon(hull, pad * s), HULL_CORNER_RADIUS * s);
return path;
});
const stroke = new Path2D();
traceRoundedPath(stroke, offsetPolygon(hull, HULL_STROKE_PAD * s), HULL_CORNER_RADIUS * s);
const cx = pts.reduce((sum, p) => sum + p.x, 0) / pts.length;
const cy = pts.reduce((sum, p) => sum + p.y, 0) / pts.length;
this.hullShapes.push({ stroke, label: cluster.label || null, cx, cy, hue: clusterHue(cluster.id) });
this.hullShapes.push({ fill: fills, stroke, label: cluster.label || null, cx, cy, hue: clusterHue(cluster.id) });
}
// Label wrapping: measureText once per node per geometry build,
@@ -764,9 +777,13 @@ export class GraphCanvasRenderer {
const regionAlpha = 1 - nodeAlpha;
const { nodeR, glowOuter } = this.world;
// 1. Cluster hulls: cached world paths — SOLID viewer-relative
// hairline border only (no fill glow, removed for now).
// 1-2. Cluster hulls: cached world paths — soft layered region
// fills, then a SOLID viewer-relative hairline border.
for (const hs of this.hullShapes) {
for (let i = 0; i < hs.fill.length; i++) {
ctx.fillStyle = `hsla(${hs.hue}, 65%, 60%, ${HULL_FILL_LAYERS[i]![1]})`;
ctx.fill(hs.fill[i]!);
}
ctx.strokeStyle = `hsla(${hs.hue}, 65%, 62%, 0.5)`;
ctx.lineWidth = 1.5 / k; // constant screen px
ctx.stroke(hs.stroke);
+22 -4
View File
@@ -327,12 +327,30 @@ export function GraphView() {
};
}, []);
// Build the graphology graph and lay it out ONCE per snapshot: seeded
// start positions, one synchronous forceAtlas2 pass, then freeze. The
// renderer only ever receives the frozen positions as data — layout is
// never recomputed for a settings tweak or a refetch of identical data.
// Layout (Option A): the positions ledger (written by the server's
// incremental settle after every merge) IS the layout — the renderer
// receives it as frozen data. The local one-shot forceAtlas2 pass runs
// ONLY as a fallback when the ledger is absent (a graph never merged
// through the pipeline, e.g. tests or a fresh install).
useEffect(() => {
if (!snapshot || snapshot.nodes.length === 0) return;
// The ledger path: every positioned node uses its resting position;
// nodes the ledger doesn't know yet (raced mid-settle) fall through
// to seeded scatter below.
const ledger = snapshot.positions;
if (ledger) {
const positions = new Map<string, { x: number; y: number }>();
for (const node of snapshot.nodes) {
const p = ledger[node.id];
if (p) positions.set(node.id, { x: p.x, y: p.y });
}
if (positions.size > 0) {
posRef.current = positions;
return () => {
posRef.current = null;
};
}
}
const graph = new Graph();
for (const node of snapshot.nodes) {
// Seeded start positions (FA2 requires non-zero x/y) keep the
+4
View File
@@ -84,6 +84,10 @@ export const LEDGER_FILES = {
* log. Read-modify-write like a chat file, not append-only like the ledgers.
*/
export const SOURCES_FILE = path.join(PATHS.graph, "sources.json");
/** The layout ledger (read-modify-write like sources.json): nodeId →
* {x,y} resting positions from the incremental settle (core/layout.ts).
* Carries an updatedAt stamp for future live-physics conflict handling. */
export const POSITIONS_FILE = path.join(PATHS.graph, "positions.json");
/**
* Graph render settings (the settings-panel controls): one small JSON blob,
+94
View File
@@ -0,0 +1,94 @@
/**
* Option A simulation: replay the CURRENT graph through the incremental
* layout as if it had arrived one imported-notes file at a time — the
* organic growth path the user described (one dependency structure /
* cluster at a time, orphans collecting on the rim).
*
* Reads ONLY the ledgers (nodes / edges / mappings / sources); writes ONLY
* data/graph/positions.json — no LLM calls, no extraction, no chat records.
*
* npx tsx scripts/replay-layout.ts
*/
import { graphStore } from "../src/store/graph.ts";
import { incrementalLayout } from "../src/core/layout.ts";
import type { GraphNode, NodeMapping, Position } from "../src/core/contracts.ts";
function main() {
const t0 = Date.now();
// Unique nodes (lines may repeat an id — first defines).
const seen = new Set<string>();
const nodes: GraphNode[] = [];
for (const line of graphStore.readNodes()) {
if (seen.has(line.id)) continue;
seen.add(line.id);
nodes.push(line);
}
const edges = graphStore.readEdges();
// Batches: each imported file, in real import order (sources.json
// importedAt). A node belongs to the file whose mapping first claims it
// (mapping line order = arrival); nodes no mapping covers (legacy) ride
// the final batch.
const sources = [...graphStore.readSources()].sort((a, b) => a.importedAt.localeCompare(b.importedAt));
const mappings: NodeMapping[] = graphStore.readMappings();
const batchOf = new Map<string, string>(); // nodeId → srcId
for (const m of mappings) {
if (!batchOf.has(m.graphNodeId)) batchOf.set(m.graphNodeId, m.chatId);
}
const delivered = new Set<string>();
let positions = new Map<string, Position>(Object.entries(graphStore.readPositions()));
if (positions.size > 0) {
console.log("[replay] discarding existing positions ledger — full replay from empty");
positions = new Map();
}
const byId = new Map(nodes.map((n) => [n.id, n]));
const unmappedFallback = sources[sources.length - 1]?.id ?? "";
console.log(`[replay] ${nodes.length} unique nodes, ${edges.length} edge lines, ${sources.length} sources`);
let maxOldShift = 0;
for (const source of sources) {
const batch = nodes.filter((n) => batchOf.get(n.id) === source.id);
const isLast = source.id === sources[sources.length - 1]!.id;
if (isLast) {
// Sweep in any never-mapped nodes with the last batch.
for (const n of nodes) {
if (!batchOf.has(n.id) && !delivered.has(n.id)) {
batch.push(n);
batchOf.set(n.id, unmappedFallback);
}
}
}
for (const n of batch) delivered.add(n.id);
// Edges activate once BOTH endpoints exist (a later file can claim
// one endpoint — the edge lights up then).
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,
);
for (const [id, prev] of before) {
const now = positions.get(id)!;
const shift = Math.hypot(prev.x - now.x, prev.y - now.y);
if (shift > maxOldShift) maxOldShift = shift;
}
const label = source.filePath.split("/").pop() ?? source.id;
console.log(
`[replay] +${String(batch.length).padStart(3)} nodes ${String(activeEdges.length).padStart(4)} edges active (${label})`,
);
}
graphStore.writePositions(Object.fromEntries(positions));
console.log(
`[replay] done: ${positions.size} positions written in ${((Date.now() - t0) / 1000).toFixed(1)}s` +
` (max old-node shift within a batch: ${maxOldShift.toFixed(1)})`,
);
}
main();
+6
View File
@@ -16,6 +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 { refreshClusterLabels } from "./cluster-namer.ts";
/**
@@ -714,6 +715,9 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
messages: record.messages as UIMessage[],
foldedAt: record.updatedAt,
});
// Option A: the layout ledger evolves with the merge (sync — the
// next snapshot fetch must already reflect the new nodes).
settleGraphLayout(graphStore);
persistRunUsage(chatId, Math.round(performance.now() - t0), stats);
console.info(
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
@@ -824,6 +828,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// (messages: null) — nothing to attribute, nothing to build.
const auditResult: AuditResult = { ...notesResult, attribution: { quiz: {} } };
const stats = mergeFindings(id, auditResult, { chatStore, graphStore }, { messages: null, foldedAt: input.importedAt });
// Option A: same post-merge settle as the chat path.
settleGraphLayout(graphStore);
persistRunUsage(id, Math.round(performance.now() - t0), stats);
console.info(
`[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
+10
View File
@@ -321,6 +321,16 @@ export interface GraphSnapshot {
* joined with persisted LLM labelings. Purely structural — never
* depends on layout. */
clusters: Cluster[];
/** Resting layout positions per node id, when a positions ledger
* exists (core/layout.ts settle writes it). Absent on a fresh graph —
* the view then falls back to its own one-shot forceAtlas2 pass. */
positions?: Record<string, Position>;
}
/** A node's resting position in layout space (the positions ledger). */
export interface Position {
x: number;
y: number;
}
/** Derive a short chat title from the learner's first message (pure).
+15 -1
View File
@@ -513,7 +513,21 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
members: community.members,
}));
return { nodes, edges, sessions, fileSources, nodeSessions, nodeObservations, clusters };
// Resting layout (Option A): the positions ledger, when the settle has
// ever run. Absent on a fresh graph — the view falls back to its own
// one-shot pass until the first merge writes the ledger.
const positions = deps.graphStore.readPositions();
return {
nodes,
edges,
sessions,
fileSources,
nodeSessions,
nodeObservations,
clusters,
...(Object.keys(positions).length > 0 ? { positions } : {}),
};
}
// Re-export: the contract owns the shape; legacy importers still reach it.
+199
View File
@@ -0,0 +1,199 @@
/**
* Option A — incremental anchored force layout (DECIDED 2026-09-13).
*
* The layout EVOLVES instead of reshuffling: each settle warm-starts
* forceAtlas2 from the positions ledger, ties every previously-positioned
* node back toward its anchor (a per-iteration re-blend), and seeds new
* nodes near their positioned neighbors — so old placements stay
* recognizable while the graph grows, exactly the way a mind map accretes
* around what came before.
*
* Design constraints (kept deliberately, for future live physics):
* - PURE: no fs, no server types — the same function is the merge-time
* server settler AND the future client-side live simulator (a pinned
* node is just an anchor with full retention).
* - DETERMINISTIC: seeded by node id, so the same graph + ledger state
* always settles identically.
*
* Orphans (degree-0 nodes) seed on the rim — outside the anchored core's
* bounding box — so they read as "not yet connected knowledge", the organic
* outer ring the user asked for.
*/
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import type { GraphNode, GraphEdge, Position } from "./contracts.ts";
import { seededRandom } from "./question-core.ts";
export interface IncrementalLayoutOptions {
/** FA2 iterations per settle (default 100 — same depth as the classic
* one-shot pass, so a fresh graph settles exactly like the old code). */
iterations?: number;
/** Per-iteration anchor retention: how much of a previously-positioned
* node's displacement from its anchor SURVIVES each iteration.
* 1 = fully pinned (never moves), 0.85 = gentle drift where forces
* are strong (near new nodes), stillness where they are not. */
anchorRetention?: number;
}
/** Layout adjacency input: edges are undirected for layout purposes. */
export interface LayoutEdge {
from: string;
to: string;
}
/**
* One incremental settle. `nodes`/`edges` describe the CURRENT graph;
* `previous` is the positions ledger (empty on the very first settle —
* then this degrades to the classic seeded-scatter + 100-iteration FA2
* pass, i.e. what the browser used to do from scratch every time).
* Returns positions for EVERY node in `nodes`.
*/
export function incrementalLayout(
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
edges: ReadonlyArray<LayoutEdge>,
previous: ReadonlyMap<string, Position>,
options: IncrementalLayoutOptions = {},
): Map<string, Position> {
const iterations = options.iterations ?? 100;
const retention = options.anchorRetention ?? 0.85;
// Anchors: the warm-start set (the previous rest state).
const anchors = new Map<string, Position>();
for (const node of nodes) {
const prev = previous.get(node.id);
if (prev) anchors.set(node.id, { x: prev.x, y: prev.y });
}
// The anchored core's bounding box + centroid, for rim seeds.
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
let cx = 0;
let cy = 0;
for (const a of anchors.values()) {
minX = Math.min(minX, a.x);
maxX = Math.max(maxX, a.x);
minY = Math.min(minY, a.y);
maxY = Math.max(maxY, a.y);
cx += a.x;
cy += a.y;
}
const hasCore = anchors.size > 0;
if (hasCore) {
cx /= anchors.size;
cy /= anchors.size;
}
const coreRadius = hasCore
? Math.max(maxX - minX, maxY - minY) / 2
: 1;
// Adjacency for neighbor seeding (undirected: edges include assertions).
const adjacency = new Map<string, string[]>();
for (const e of edges) {
if (e.from === e.to) continue;
(adjacency.get(e.from) ?? adjacency.set(e.from, []).get(e.from)!).push(e.to);
(adjacency.get(e.to) ?? adjacency.set(e.to, []).get(e.to)!).push(e.from);
}
const graph = new Graph();
for (const node of nodes) {
const rand = seededRandom(node.id);
const anchor = anchors.get(node.id);
let x: number;
let y: number;
if (anchor) {
// Warm start: resume from the previous rest position.
x = anchor.x;
y = anchor.y;
} else {
// New node: seed near its positioned neighbors (the cluster it
// arrived with), else on the rim outside the core, else scatter.
let sx = 0;
let sy = 0;
let found = 0;
for (const nbr of adjacency.get(node.id) ?? []) {
const na = anchors.get(nbr);
if (na) {
sx += na.x;
sy += na.y;
found++;
}
}
if (found > 0) {
x = sx / found + (rand() - 0.5) * 8;
y = sy / found + (rand() - 0.5) * 8;
} else if (hasCore) {
const angle = rand() * Math.PI * 2;
const radius = coreRadius * 1.15 + 4 + rand() * 8;
x = cx + Math.cos(angle) * radius;
y = cy + Math.sin(angle) * radius;
} else {
// Bootstrap: the classic seeded scatter (FA2 requires
// non-zero coordinates; (rand()-0.5)*100 hits both axes).
x = (rand() - 0.5) * 100;
y = (rand() - 0.5) * 100;
}
}
// FA2 divides by distances — a coincident (0,0) seed is degenerate.
if (x === 0 && y === 0) x = 0.5;
graph.addNode(node.id, { x, y });
}
for (const e of edges) {
if (graph.hasNode(e.from) && graph.hasNode(e.to) && !graph.hasEdge(e.from, e.to)) {
graph.addEdge(e.from, e.to);
}
}
// Same escalation as the browser path: Barnes-Hut above ~500 nodes.
const settings = {
...forceAtlas2.inferSettings(graph),
...(graph.order > 500 ? { barnesHutOptimize: true } : {}),
};
for (let i = 0; i < iterations; i++) {
forceAtlas2.assign(graph, { iterations: 1, settings });
// Anchor re-blend: previously-positioned nodes are pulled back
// toward their rest positions, so the layout evolves locally
// instead of globally reshuffling. displacement * retention —
// strong forces still win out (equilibrium ≈ force/(1-retention)),
// weak ones fade to stillness.
if (hasCore && retention < 1) {
for (const [id, a] of anchors) {
const attrs = graph.getNodeAttributes(id);
graph.setNodeAttribute(id, "x", a.x + (attrs.x - a.x) * retention);
graph.setNodeAttribute(id, "y", a.y + (attrs.y - a.y) * retention);
}
}
}
const out = new Map<string, Position>();
graph.forEachNode((id, attrs) => out.set(id, { x: attrs.x, y: attrs.y }));
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). Synchronous —
* it must finish before the next /api/graph fetch reflects the merge.
*/
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));
}
+8
View File
@@ -22,6 +22,7 @@ import type {
ExtractionRunUsage,
NodeMapping,
PlanTree,
Position,
SessionCheckpoint,
TurnUsage,
NotesSource,
@@ -96,6 +97,13 @@ export interface GraphStore {
/** Append one run's usage to a source record — idempotent per
* startedAt, mirroring ChatRecord.extractions. */
appendSourceUsage(id: string, entry: ExtractionRunUsage): void;
/** The layout ledger (config.POSITIONS_FILE): nodeId → resting {x,y}.
* WRITTEN ONLY by the incremental settle after a merge (core/layout.ts
* settleGraphLayout) — read-only for everything else. */
readPositions(): Record<string, Position>;
/** Replace the whole ledger (the settle is a full-graph computation). */
writePositions(positions: Record<string, Position>): void;
}
// ─── JobStore — background sub-agent jobs (data/jobs) ─────────────────────────
+47 -1
View File
@@ -29,7 +29,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { LEDGER_FILES, SOURCES_FILE } from "../../config.ts";
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
import type {
ClusterLabeling,
GraphEdge,
@@ -39,6 +39,7 @@ import type {
LedgerEventKind,
NotesSource,
NodeMapping,
Position,
ProvenancePoint,
SessionCheckpoint,
} from "../core/contracts.ts";
@@ -207,8 +208,53 @@ export const graphStore: GraphStore = {
: [...(source.extractions ?? []), entry];
writeSourceMap(all);
},
// ── the layout ledger (config.POSITIONS_FILE — a JSON map) ──
readPositions(): Record<string, Position> {
return readPositionMap();
},
writePositions(positions: Record<string, Position>): void {
writePositionMap(positions);
},
};
/** positions.json — { positions, updatedAt }. Read-modify-write, NOT
* append-only: the settle is a full-graph computation, so it replaces the
* whole map. The updatedAt stamp exists for future live-physics conflict
* handling (a browser commit must be able to detect "the graph changed
* underneath me"); reads just return the map. */
function readPositionMap(): Record<string, Position> {
let raw: string;
try {
raw = fs.readFileSync(POSITIONS_FILE, "utf-8");
} catch {
return {}; // no ledger yet — nothing has ever settled
}
try {
const parsed: unknown = JSON.parse(raw);
if (!isRecord(parsed) || !isRecord(parsed.positions)) return {};
const out: Record<string, Position> = {};
for (const [id, value] of Object.entries(parsed.positions)) {
if (isPosition(value)) out[id] = { x: value.x, y: value.y };
}
return out;
} catch {
return {};
}
}
function writePositionMap(positions: Record<string, Position>): void {
fs.mkdirSync(path.dirname(POSITIONS_FILE), { recursive: true });
fs.writeFileSync(
POSITIONS_FILE,
JSON.stringify({ positions, updatedAt: new Date().toISOString() }, null, "\t"),
"utf-8",
);
}
const isPosition = (v: unknown): v is Position =>
isRecord(v) && typeof v.x === "number" && Number.isFinite(v.x) && typeof v.y === "number" && Number.isFinite(v.y);
/** sources.json — { id: NotesSource }. Read-modify-write, NOT append-only:
* the usage log lives inside the record (like a chat's JSON file). */
function readSourceMap(): Record<string, NotesSource> {
+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);
});
});