From 8d72de596a0bf2464a21a99263fa57668b2f94d3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 30 Sep 2026 03:41:41 +0300 Subject: [PATCH] fix: chain table reads the stored propositions, not the derived canvas image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's live test surfaced it: after importing notes, the chains view listed implication nodes as bogus singleton "chains" — and each carried the same arg_ id as the real stored chain, tripping React's duplicate-key error (chain-table.tsx:372). Root cause: graph-view passed snapshot.nodes — the derived canvas view (propositions + implication nodes) — into ChainTableOverlay, whose chainsForSelection turns every unchained node into a singleton chain. An implication node is never a chain member (members are proposition ids), so every imp node minted a bogus singleton with a colliding id; its row was even selectable, leaking arg_ ids into practice input. Fix (owner-approved shape): GraphSnapshot gains `propositions` — the stored-truth node list riding beside `chains` for exactly this class of consumer. The chain table now receives snapshot.propositions; the canvas keeps snapshot.nodes (imp nodes are meant to be drawn). Practice pipeline unaffected (it already read getGraph, which is propositions-only). Test: the snapshot split pinned as an invariant in graph.test.ts — image = truth + one imp node per fully-live stored chain; truth never carries an arg_ id. --- components/graph-view.tsx | 2 +- src/core/contracts/graph.ts | 4 ++++ src/core/graph.ts | 1 + tests/graph.test.ts | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 0011642..b8f370f 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -1656,7 +1656,7 @@ export function GraphView() { here.) */} { assert.ok(mine, "the trio is one cluster"); assert.equal(mine!.id, "src_clusterfixture"); assert.equal(mine!.label, "/vault/cluster notes.md", "a notes file is a birth-session — the label is its path"); + + // Chains-primary split (owner, 2026-09-30): `nodes` is the canvas + // image (propositions + derived implication nodes), `propositions` + // is the stored truth alone — the chain table's input. The store is + // shared across this suite, so assert the INVARIANT, not counts: + // image = truth + one imp node per fully-live stored chain, and the + // truth never carries an arg_ id. + const stored = graphStore.readChains(); + const live = new Set(snap.propositions.map((n) => n.id)); + const liveChains = stored.filter((c) => live.has(c.conclusion) && c.premises.every((p) => live.has(p))); + assert.ok(snap.propositions.every((n) => !n.id.startsWith("arg_")), "the stored-truth list never carries imp nodes"); + assert.equal(snap.nodes.length, snap.propositions.length + liveChains.length, "image = truth + one imp node per live chain"); + const imp = snap.nodes.filter((n) => n.id.startsWith("arg_")); + assert.ok(imp.length === liveChains.length && imp.every((n) => snap.propositions.every((p) => p.id !== n.id)), "the extra nodes are exactly the derived implication nodes"); }); it("derives the sessions index in graphSnapshot", () => {