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
+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> {