From f82318c9e4ad46c5d6306ad5b2a294fffb6f4d33 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 6 Oct 2026 04:46:26 +0300 Subject: [PATCH] =?UTF-8?q?graph:=20persist=20the=20arg=5F=20implication?= =?UTF-8?q?=20nodes=20=E2=80=94=20the=20splatter=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnosed (owner report 2026-10-06, 'splattered from the middle on every navigation'): the canvas's world is 350 nodes — 262 stored propositions + 88 derived arg_ implication nodes the chain arrows bend through — but the drag-commit sanitizer accepted only stored proposition ids, and writePositions REPLACES the file: every drag-commit purged the arg_ entries, so every mount re-seeded them on a ~1306-unit ring (seedMissingNodePositions) — arrows streaking out of the middle, bbox inflated, fit zoomed out. A drag annealed them back (impLinks springs), the commit stripped them again. Fix: the sanitizer's known set = deriveView(getGraph()).nodes — propositions AND arg_ ids (stable: minted from the chain ledger). The grew-check becomes the honest test — some CURRENT view node absent from the payload (a merge landed mid-drag) → exactly one absorbing settle; full coverage → none (the old proposition-count comparison broke once args counted). Live ledger backfilled once: 88 arg_ positions seeded at their chains' centroids, propositions byte-identical (no settle, no re-anneal). Pinned in tests/commit-positions.test.ts (hermetic LH_DATA_DIR): args persist, unknown ids drop, no-settle on full coverage, absorbing settle on a stale payload, garbage sanitizing. --- src/server/commit-positions.ts | 33 +++++++--- tests/commit-positions.test.ts | 111 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 tests/commit-positions.test.ts diff --git a/src/server/commit-positions.ts b/src/server/commit-positions.ts index a31232e..0d1b050 100644 --- a/src/server/commit-positions.ts +++ b/src/server/commit-positions.ts @@ -9,9 +9,17 @@ * * Every body is sanitized: garbage coordinates clamp or drop, unknown * ids are discarded, nothing throws. + * + * KNOWN IDS = THE CANVAS'S WORLD (owner fix, 2026-10-06 — the splatter + * bug): the derived view's nodes — stored propositions AND the derived + * arg_ implication nodes. The canvas persists all of them; stripping + * the args here purged their ledger entries on every drag-commit, so + * each mount re-seeded them on a giant ring (the "splattered from the + * middle" report) until a drag annealed them back. arg_ ids are stable + * (minted from the chain ledger), so persisting them is sound. */ import type { Position } from "../core/contracts.ts"; -import { getGraph } from "../core/graph.ts"; +import { deriveView, getGraph } from "../core/graph.ts"; import { settleGraphLayout } from "../core/layout-settle.ts"; import { stores } from "./stores.ts"; @@ -27,11 +35,11 @@ export function commitPositions(input: unknown): Record { if (typeof input !== "object" || input === null) return {}; const raw = input as Record; - // Only ids the graph actually has (first line wins, like getGraph). - const known = new Map(); - for (const line of stores.graphStore.readNodes()) { - if (!known.has(line.id)) known.set(line.id, true); - } + // Only ids the canvas's world actually has: stored propositions plus + // the DERIVED arg_ implication nodes (deriveView of the current graph + // — the same derivation the snapshot serves, so ids agree by + // construction). + const known = new Set(deriveView(getGraph(stores.graphStore)).nodes.map((n) => n.id)); const positions: Record = {}; for (const [id, value] of Object.entries(raw)) { @@ -50,8 +58,15 @@ export function commitPositions(input: unknown): Record { stores.graphStore.writePositions(positions); // The graph grew past what the client knew (a merge landed mid-drag): - // absorb the new nodes with the client's rest state as anchors. - const unique = getGraph(stores.graphStore).nodes.length; - if (unique > Object.keys(positions).length) settleGraphLayout(stores.graphStore); + // absorb the new nodes with the client's rest state as anchors. The + // honest test: some node of the CURRENT view is absent from the + // client's payload — proposition or arg_ alike (the old count + // comparison broke once args counted as persistable). + for (const id of known) { + if (!(id in positions)) { + settleGraphLayout(stores.graphStore); + break; + } + } return positions; } \ No newline at end of file diff --git a/tests/commit-positions.test.ts b/tests/commit-positions.test.ts new file mode 100644 index 0000000..295d549 --- /dev/null +++ b/tests/commit-positions.test.ts @@ -0,0 +1,111 @@ +/** + * The positions-commit sanitizer (PUT /api/graph/positions) — the arg_ + * fix (owner, 2026-10-06): the canvas persists the derived view's whole + * world (stored propositions + derived arg_ implication nodes), and the + * commit must KEEP both. Stripping the args purged their ledger entries + * on every drag-commit, so each mount re-seeded them on a giant ring — + * the "splattered from the middle" report. Also pins the grew-check: + * a payload covering the whole view runs NO settle (the file equals the + * payload exactly); a payload missing a view node (a merge landed + * mid-drag) triggers exactly one absorbing settle. + * + * Hermetic: LH_DATA_DIR → a temp dir, set BEFORE the first import of + * the server modules (the seam is env-at-module-load, see + * tests/data-dir-seam.test.ts). + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { before, describe, it } from "node:test"; + +process.env.LH_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "lh-pos-commit-")); + +let commitPositions: (input: unknown) => Record; +let readPositions: () => Record; +let appendNode: (node: unknown) => void; +let appendChain: (assertion: unknown) => void; + +before(async () => { + ({ commitPositions } = await import("../src/server/commit-positions.ts")); + const { stores } = await import("../src/server/stores.ts"); + const positionsFile = path.join(process.env.LH_DATA_DIR!, "graph", "positions.json"); + readPositions = () => JSON.parse(fs.readFileSync(positionsFile, "utf8")).positions as Record; + appendNode = stores.graphStore.appendNode as (node: unknown) => void; + appendChain = stores.graphStore.appendChain as (assertion: unknown) => void; + + // Two propositions + one two-premise chain → the derived view is + // 2 props + 1 arg_ node. + const node = (id: string) => ({ + id, + name: id, + statement: `${id} statement`, + aliases: [], + createdAt: "2026-10-06T00:00:00.000Z", + provenance: [], + }); + appendNode(node("n_a")); + appendNode(node("n_b")); + appendNode(node("n_c")); + appendChain({ + id: "arg_chain1", + premises: ["n_a", "n_b"], + conclusion: "n_c", + provenance: [], + createdAt: "2026-10-06T00:00:00.000Z", + }); +}); + +describe("commitPositions (the arg_ fix)", () => { + it("persists arg_ implication ids alongside propositions — the canvas's whole world", () => { + const persisted = commitPositions({ + n_a: { x: 10, y: 20 }, + n_b: { x: 30, y: 40 }, + n_c: { x: 50, y: 60 }, + arg_chain1: { x: 34.5, y: 44.5 }, + garbage_id: { x: 1, y: 1 }, + }); + assert.deepEqual(Object.keys(persisted).sort(), ["arg_chain1", "n_a", "n_b", "n_c"]); + assert.deepEqual(readPositions().arg_chain1, { x: 34.5, y: 44.5 }); + assert.ok(!("garbage_id" in readPositions()), "unknown ids still discarded"); + }); + + it("a payload covering the whole view runs NO settle — the file equals the payload exactly", () => { + commitPositions({ + n_a: { x: 100, y: 200 }, + n_b: { x: 300, y: 400 }, + n_c: { x: 500, y: 600 }, + arg_chain1: { x: 350, y: 450 }, + }); + assert.deepEqual(readPositions(), { + n_a: { x: 100, y: 200 }, + n_b: { x: 300, y: 400 }, + n_c: { x: 500, y: 600 }, + arg_chain1: { x: 350, y: 450 }, + }, "no settle recomputation — the client's rest state is the ledger"); + }); + + it("a payload missing a view node (a merge landed mid-drag) triggers the absorbing settle", () => { + // The client never knew arg_chain1 (it merged after the drag began). + commitPositions({ + n_a: { x: 1, y: 2 }, + n_b: { x: 3, y: 4 }, + n_c: { x: 5, y: 6 }, + }); + const after = readPositions(); + assert.ok("arg_chain1" in after, "the settle laid out the node the client missed"); + // The client's anchors are honored (warm start), not clobbered to 0. + assert.ok(Math.abs(after.n_a!.x - 1) < 50, `anchor n_a near its committed rest (got ${after.n_a!.x})`); + }); + + it("garbage still sanitizes: non-finite drops, absurd clamps, nothing throws", () => { + const persisted = commitPositions({ + n_a: { x: Number.NaN, y: 1 }, + n_b: { x: 5, y: Number.POSITIVE_INFINITY }, + n_c: { x: 1e9, y: 0 }, + }); + assert.ok(!("n_a" in persisted), "NaN drops"); + assert.ok(!("n_b" in persisted), "Infinity drops"); + assert.equal(persisted.n_c!.x, 1e6, "absurd clamps to MAX_COORD"); + }); +});