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"); + }); +});