diff --git a/components/graph-canvas.ts b/components/graph-canvas.ts index 9692c1f..e2373bb 100644 --- a/components/graph-canvas.ts +++ b/components/graph-canvas.ts @@ -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); diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 6e58b61..b283aee 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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(); + 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 diff --git a/config.ts b/config.ts index d6039bf..8985557 100644 --- a/config.ts +++ b/config.ts @@ -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, diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts new file mode 100644 index 0000000..23a8242 --- /dev/null +++ b/scripts/replay-layout.ts @@ -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(); + 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(); // nodeId → srcId + for (const m of mappings) { + if (!batchOf.has(m.graphNodeId)) batchOf.set(m.graphNodeId, m.chatId); + } + + const delivered = new Set(); + let positions = new Map(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(); \ No newline at end of file diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index e06bb97..ce388a7 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -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 ` + diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 77aac84..cb56644 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -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; +} + +/** 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). diff --git a/src/core/graph.ts b/src/core/graph.ts index bebde35..c1aa7a5 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -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. diff --git a/src/core/layout.ts b/src/core/layout.ts new file mode 100644 index 0000000..af072c0 --- /dev/null +++ b/src/core/layout.ts @@ -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>, + edges: ReadonlyArray, + previous: ReadonlyMap, + options: IncrementalLayoutOptions = {}, +): Map { + const iterations = options.iterations ?? 100; + const retention = options.anchorRetention ?? 0.85; + + // Anchors: the warm-start set (the previous rest state). + const anchors = new Map(); + 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(); + 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(); + 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; + 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/core/ports.ts b/src/core/ports.ts index 3f96871..307280c 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -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; + /** Replace the whole ledger (the settle is a full-graph computation). */ + writePositions(positions: Record): void; } // ─── JobStore — background sub-agent jobs (data/jobs) ───────────────────────── diff --git a/src/store/graph.ts b/src/store/graph.ts index ea050bd..d7a9545 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -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 { + return readPositionMap(); + }, + writePositions(positions: Record): 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 { + 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 = {}; + 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): 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 { diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 4ccc072..02ec278 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -66,6 +66,7 @@ function memoryLedgers() { const checkpoints: SessionCheckpoint[] = []; const sources = new Map(); const clusterLabels: ClusterLabeling[] = []; + const positions = new Map(); 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; } diff --git a/tests/layout.test.ts b/tests/layout.test.ts new file mode 100644 index 0000000..adabea6 --- /dev/null +++ b/tests/layout.test.ts @@ -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, 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[] = []; + 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), + writePositions: (p: Record) => 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[] = []; + settleGraphLayout({ + readNodes: () => [], + readEdges: () => [], + readPositions: () => ({} as Record), + writePositions: (p) => void written.push(p), + }); + assert.equal(written.length, 0); + }); +}); \ No newline at end of file