fix: chain table reads the stored propositions, not the derived canvas image
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.
This commit is contained in:
@@ -1656,7 +1656,7 @@ export function GraphView() {
|
|||||||
here.) */}
|
here.) */}
|
||||||
<ChainTableOverlay
|
<ChainTableOverlay
|
||||||
open={chainsOpen && !isEmpty}
|
open={chainsOpen && !isEmpty}
|
||||||
nodes={snapshot?.nodes ?? []}
|
nodes={snapshot?.propositions ?? []}
|
||||||
chains={snapshot?.chains ?? []}
|
chains={snapshot?.chains ?? []}
|
||||||
sessions={snapshot?.sessions ?? {}}
|
sessions={snapshot?.sessions ?? {}}
|
||||||
fileSources={snapshot?.fileSources ?? {}}
|
fileSources={snapshot?.fileSources ?? {}}
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ export interface GraphSnapshot {
|
|||||||
/** Proposition nodes + the DERIVED implication nodes (one per chain,
|
/** Proposition nodes + the DERIVED implication nodes (one per chain,
|
||||||
* id = chain id) — what the canvas renders. */
|
* id = chain id) — what the canvas renders. */
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
|
/** The STORED proposition nodes alone (no derived implication nodes)
|
||||||
|
* — the stored truth, like `chains`: consumers that read the world
|
||||||
|
* (the chain table) take this list, never the canvas image. */
|
||||||
|
propositions: GraphNode[];
|
||||||
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
|
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
|
||||||
* of the stored chains — rebuilt after every change, never stored. */
|
* of the stored chains — rebuilt after every change, never stored. */
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
|
|||||||
@@ -409,6 +409,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
nodes,
|
nodes,
|
||||||
|
propositions: graph.nodes,
|
||||||
edges: view.edges,
|
edges: view.edges,
|
||||||
chains: graph.chains,
|
chains: graph.chains,
|
||||||
sessions,
|
sessions,
|
||||||
|
|||||||
@@ -457,6 +457,20 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
assert.ok(mine, "the trio is one cluster");
|
assert.ok(mine, "the trio is one cluster");
|
||||||
assert.equal(mine!.id, "src_clusterfixture");
|
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");
|
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", () => {
|
it("derives the sessions index in graphSnapshot", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user