graph: persist the arg_ implication nodes — the splatter fix

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.
This commit is contained in:
amosblomqvist
2026-10-06 04:46:26 +03:00
parent cbbe1da825
commit f82318c9e4
2 changed files with 135 additions and 9 deletions
+24 -9
View File
@@ -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<string, Position> {
if (typeof input !== "object" || input === null) return {};
const raw = input as Record<string, unknown>;
// Only ids the graph actually has (first line wins, like getGraph).
const known = new Map<string, boolean>();
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<string, Position> = {};
for (const [id, value] of Object.entries(raw)) {
@@ -50,8 +58,15 @@ export function commitPositions(input: unknown): Record<string, Position> {
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;
}