chains-primary: stored chains replace typed edges; edges become a derived view

Owner rulings 2026-09-30: reasoning chains are the stored truth; the graph
canvas renders a lossy derived image. No migration, no backcompat — old
graph data was wiped.

- contracts: GraphChain (arg_-minted id, premises, conclusion) replaces
  typed edges; GraphEdge survives only as the untyped derived arrow;
  GraphSnapshot carries stored chains; supports edges retired entirely
- store: chains.jsonl ledger (edges.jsonl purged at load alongside legacy
  events.jsonl); readChains/appendChain seam; merge dedupes by
  (sorted premises, conclusion), re-assertions append under the same arg_ id
- core: deriveView mints implication nodes (name ⟹, statement via
  formatChainLine) plus premise→imp→conclusion arrows; clusters stay
  proposition-only (birthSessionPartition); chainsForSelection now groups
  STORED chains (chain rides iff every member selected) — independent
  arguments for one conclusion never union; chainsToGraph/chainsToEdges die
- extraction: extractor submits chains natively (expandFindings deleted);
  auditor receives propositions only; supports fan-out gone
- deletion: full death of a proposition kills every referencing chain;
  arg_-prefixed node deletes kill that chain; chainsRemoved stats
- UI: every arrow renders the ⟹ double shaft; supports toggle + thickness
  slider and detail-card edge sections deleted (card keeps name +
  proposition); chain table + practice pipeline read stored chains
- tests: 1188 green across the new seams
This commit is contained in:
amosblomqvist
2026-09-30 03:22:52 +03:00
parent a3aabb3a57
commit 4985b6f9fa
28 changed files with 669 additions and 726 deletions
+56 -51
View File
@@ -243,11 +243,10 @@ function fixtureFindings(): SessionFindings {
aliases: [],
},
],
edges: [
chains: [
{
from: "p1",
to: "p2",
type: "follows",
premises: ["p1"],
conclusion: "p2",
},
],
};
@@ -274,14 +273,14 @@ function scopedGraph(chatIds: string[]) {
const graph = getGraph(graphStore);
return {
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))),
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
chains: graph.chains.filter((c) => c.provenance.some((pt) => chatIds.includes(pt.chatId))),
};
}
describe("the merger (mergeFindings)", () => {
it("creates nodes from the verdicts with chat-level provenance", () => {
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id));
assert.deepEqual(stats, { nodes: 3, edges: 1 });
assert.deepEqual(stats, { nodes: 3, chains: 1 });
const graph = scopedGraph([chat1.id]);
assert.equal(graph.nodes.length, 3);
@@ -298,13 +297,13 @@ describe("the merger (mergeFindings)", () => {
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
// The follows edge, provenance attached.
assert.equal(graph.edges.length, 1);
const edge = graph.edges[0]!;
assert.equal(edge.from, idByStatement.get("Alpha intuition"));
assert.equal(edge.to, idByStatement.get("Beta rule"));
assert.equal(edge.type, "follows");
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
// The stored chain, provenance attached.
assert.equal(graph.chains.length, 1);
const chain = graph.chains[0]!;
assert.match(chain.id, /^arg_/, "store-minted argument id");
assert.deepEqual(chain.premises, [idByStatement.get("Alpha intuition")]);
assert.equal(chain.conclusion, idByStatement.get("Beta rule"));
assert.deepEqual(chain.provenance, [{ chatId: chat1.id, messageId: "" }]);
});
@@ -321,15 +320,15 @@ describe("the merger (mergeFindings)", () => {
{ id: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
{ id: "p2", name: "Rep two", statement: "Independence two statement", aliases: [] },
],
edges: [{ from: "p1", to: "p2", type: "follows" }],
chains: [{ premises: ["p1"], conclusion: "p2" }],
};
const first = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
assert.deepEqual(first, { nodes: 2, edges: 1 });
assert.deepEqual(first, { nodes: 2, chains: 1 });
const second = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
assert.deepEqual(second, { nodes: 2, edges: 1 }, "nothing remembered — everything re-creates");
assert.deepEqual(second, { nodes: 2, chains: 1 }, "nothing remembered — everything re-creates");
const graph = scopedGraph([rep.id]);
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes");
assert.equal(graph.edges.length, 2, "fresh endpoints — fresh edge assertions");
assert.equal(graph.chains.length, 2, "fresh endpoints — fresh chain assertions");
// exactly one checkpoint line per merge, though — checkpoints are a log
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2);
});
@@ -341,10 +340,10 @@ describe("the merger (mergeFindings)", () => {
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
],
edges: [],
chains: [],
};
const stats = mergeFindings(dup.id, findings, { links: [] }, graphDeps, sessionSource(dup.id));
assert.deepEqual(stats, { nodes: 1, edges: 0 }, "the duplicate id is skipped — first resolution stands");
assert.deepEqual(stats, { nodes: 1, chains: 0 }, "the duplicate id is skipped — first resolution stands");
});
it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
@@ -373,7 +372,7 @@ describe("the merger (mergeFindings)", () => {
aliases: ["alpha intuition"],
},
],
edges: [],
chains: [],
},
// VERDICTS-ONLY: the identity call is a link entry, not a field
// on the proposition (owner, 2026-09-28).
@@ -381,7 +380,7 @@ describe("the merger (mergeFindings)", () => {
graphDeps,
sessionSource(chat2.id),
);
assert.deepEqual(stats, { nodes: 0, edges: 0 });
assert.deepEqual(stats, { nodes: 0, chains: 0 });
const after = scopedGraph([chat1.id, chat2.id]);
assert.equal(after.nodes.length, 3, "no duplicate node");
@@ -410,7 +409,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
{ links: [] },
graphDeps,
@@ -439,10 +438,9 @@ describe("the merger (mergeFindings)", () => {
statement,
aliases: [],
})),
edges: [
{ from: "p1", to: "p2", type: "follows", },
{ from: "p2", to: "p3", type: "follows", },
{ from: "p1", to: "p3", type: "follows", },
chains: [
{ premises: ["p1"], conclusion: "p2", },
{ premises: ["p2"], conclusion: "p3", },
],
},
{ links: [] },
@@ -494,7 +492,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
// empty verdicts — identity is the auditor's call alone
{ links: [] },
@@ -519,13 +517,13 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
{ links: [] },
graphDeps,
{ foldedAt: "2026-09-13T00:00:00.000Z" },
);
assert.deepEqual(stats, { nodes: 1, edges: 0 });
assert.deepEqual(stats, { nodes: 1, chains: 0 });
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
@@ -617,7 +615,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], },
],
edges: [{ from: "p1", to: "p2", type: "follows", }],
chains: [{ premises: ["p1"], conclusion: "p2", }],
};
mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
// Resolve through THIS session's provenance — the shared ledger
@@ -642,7 +640,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"] },
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], },
],
edges: [{ from: "q1", to: "q2", type: "follows", }],
chains: [{ premises: ["q1"], conclusion: "q2", }],
};
mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
const zId = bornOf(dB.id, "B extra statement");
@@ -665,7 +663,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
const stats = deleteChatTraces(graphStore, dA.id, "delete-nodes");
assert.deepEqual(stats, {
nodesRemoved: 1,
edgesRemoved: 1,
chainsRemoved: 1,
coverageRemoved: 2,
checkpointsRemoved: 1,
} satisfies ChatDeletionStats);
@@ -684,9 +682,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
// Z (dB-only) is untouched, byte-identical.
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore);
// Edges: dA's assertion X→Y died; dB's X→Z survived.
// Chains: dA's argument X⇒Y died (Y died); dB's X⇒Z survived.
const scoped = scopedGraph([dA.id, dB.id]);
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]);
assert.equal(scoped.chains.length, 1);
assert.deepEqual(scoped.chains[0]!.premises, [xId]);
assert.equal(scoped.chains[0]!.conclusion, zId);
assert.deepEqual(scoped.chains[0]!.provenance, [{ chatId: dB.id, messageId: "" }]);
// Positions: the orphan pruned.
const positions = graphStore.readPositions();
assert.equal(positions[yId], undefined);
@@ -703,25 +704,25 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
// Idempotent: a second run finds nothing.
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, chainsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
});
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
const before = {
nodes: graphStore.readNodes(),
edges: graphStore.readEdges(),
chains: graphStore.readChains(),
positions: graphStore.readPositions(),
};
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
assert.deepEqual(stats, {
nodesRemoved: 0,
edgesRemoved: 0,
chainsRemoved: 0,
coverageRemoved: 2,
checkpointsRemoved: 1,
} satisfies ChatDeletionStats);
assert.deepEqual(graphStore.readNodes(), before.nodes);
assert.deepEqual(graphStore.readEdges(), before.edges);
assert.deepEqual(graphStore.readChains(), before.chains);
assert.deepEqual(graphStore.readPositions(), before.positions);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true);
@@ -769,12 +770,12 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
/** The full-death scenario: node X is born in a notes file (EMPTY
* provenance — a legacy line; the mappings anchor is gone, owner
* 2026-09-29), re-encountered by a chat (link line: alias +
* provenance), and asserted in edges by two sessions. */
* provenance), and asserted in chains by two sessions. */
function buildNodeScenario() {
// Clean slate per test: each test in this describe builds its own
// scenario from empty ledgers (the suite's isolation covers the
// whole file; this covers describe-internal stacking).
graphStore.rewriteLedgers({ nodes: [], edges: [] });
graphStore.rewriteLedgers({ nodes: [], chains: [] });
graphStore.writePositions({});
graphStore.appendNode({
id: "n_dead",
@@ -802,18 +803,20 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
createdAt: "2026-09-23T00:00:00.000Z",
provenance: [{ chatId: "chat_node_a", messageId: "m2" }],
});
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// Resting positions for both.
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
graphStore.appendChain({ id: "arg_dead_keep", premises: ["n_dead"], conclusion: "n_keep", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
graphStore.appendChain({ id: "arg_keep_dead", premises: ["n_keep"], conclusion: "n_dead", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// A FOREIGN chain the deletion must not touch (destructive rewriting
// may not exceed the blast radius — foreign data rides untouched).
graphStore.appendChain({ id: "arg_keep_other", premises: ["n_keep"], conclusion: "n_other", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// Resting positions for both nodes and both dying chains' imp nodes.
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 }, arg_dead_keep: { x: 1.5, y: 1.5 }, arg_keep_dead: { x: 2.5, y: 2.5 } });
}
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
buildNodeScenario();
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
const stats = deleteNodeTraces(graphStore, "n_dead");
assert.deepEqual(stats, { edgesRemoved: 2 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { chainsRemoved: 2 } satisfies NodeDeletionStats);
// The node's EVERY line is gone; the neighbor is byte-identical
// (full death is node-scoped — other nodes' sessions keep their
@@ -824,15 +827,17 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
keepLineBefore,
"the untouched node is byte-identical",
);
// Only the edge to nowhere rode along; the foreign edge X→Z stays.
// Only the chains referencing X died; the foreign chain stays.
assert.deepEqual(
graphStore.readEdges().map((e) => [e.from, e.to]),
[["n_keep", "n_other"]],
graphStore.readChains().map((c) => [c.premises, c.conclusion]),
[[["n_keep"], "n_other"]],
);
// The resting position died with the node.
const positions = graphStore.readPositions();
assert.equal(positions["n_dead"], undefined);
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 });
assert.equal(positions["arg_dead_keep"], undefined, "the dying chains' imp-node positions die too");
assert.equal(positions["arg_keep_dead"], undefined);
// Atomic rewrites left no temp files.
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
// Idempotent: a second run finds no node — null.
@@ -849,7 +854,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
});
const stats = deleteNodeTraces(graphStore, "n_solo");
assert.deepEqual(stats, { edgesRemoved: 0 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { chainsRemoved: 0 } satisfies NodeDeletionStats);
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
});
@@ -899,7 +904,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
const revisionBefore = graphRevision();
const outcome = deleteGraphNode("n_orch");
assert.ok(outcome.ok);
assert.deepEqual(outcome.stats, { edgesRemoved: 0 });
assert.deepEqual(outcome.stats, { chainsRemoved: 0 });
assert.ok(outcome.graphRevision > 0);
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);