From 4985b6f9fa6e32ac4153f184cb7cb6fe911ca850 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 30 Sep 2026 03:22:52 +0300 Subject: [PATCH] chains-primary: stored chains replace typed edges; edges become a derived view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- components/chain-table.tsx | 20 +-- components/graph-canvas.ts | 32 ++--- components/graph-view.tsx | 89 +----------- config.ts | 5 +- scripts/backfill-graph.ts | 4 +- scripts/import-notes.ts | 10 +- scripts/replay-layout.ts | 35 +++-- src/agent/subagents/extraction.ts | 57 ++++---- src/agent/subagents/practice-pipeline.ts | 9 +- src/core/contracts/graph.ts | 89 ++++++++---- src/core/graph-deletion.ts | 154 ++++++++++++-------- src/core/graph.ts | 154 ++++++++++++++------ src/core/layout-settle.ts | 34 ++--- src/core/ports.ts | 25 ++-- src/core/practice.ts | 100 ++++--------- src/core/render-settings.ts | 23 --- src/server/graph.ts | 2 +- src/store/graph.ts | 77 ++++++---- tests/extraction.test.ts | 106 +++++++------- tests/graph-tools.test.ts | 6 +- tests/graph.test.ts | 107 +++++++------- tests/layout.test.ts | 12 +- tests/practice-pipeline.test.ts | 21 +-- tests/practice.test.ts | 171 +++++++++-------------- tests/render-chain-table.test.tsx | 13 +- tests/render-settings.test.ts | 30 +--- tests/session-clusters.test.ts | 6 +- tests/usage.test.ts | 4 +- 28 files changed, 669 insertions(+), 726 deletions(-) diff --git a/components/chain-table.tsx b/components/chain-table.tsx index ec02122..eb09a0f 100644 --- a/components/chain-table.tsx +++ b/components/chain-table.tsx @@ -68,7 +68,7 @@ import { XIcon, } from "lucide-react"; import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core"; -import type { GraphEdge, GraphNode } from "@/src/core"; +import type { GraphChain, GraphNode } from "@/src/core"; import { TopBar } from "@/components/top-bar"; import { PracticeDialog } from "@/components/practice-dialog"; import { DeleteNodeDialog } from "@/components/delete-node-dialog"; @@ -99,7 +99,7 @@ interface ClusterGroup { * via props; the overlay wrapper below owns it. */ export function ChainTableBody({ nodes, - edges, + chains: storedChains, sessions, fileSources, query, @@ -118,7 +118,7 @@ export function ChainTableBody({ onClose, }: { nodes: GraphNode[]; - edges: GraphEdge[]; + chains: GraphChain[]; sessions: Record; fileSources: Record; query: string; @@ -140,7 +140,7 @@ export function ChainTableBody({ onClose: () => void; }) { const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); - const chains = useMemo(() => chainsForSelection(nodes, edges), [nodes, edges]); + const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]); const searching = query.trim() !== ""; // The fuzzy filter: a chain is visible when ANY member matches on @@ -223,8 +223,8 @@ export function ChainTableBody({ // The derived shape under the floating bar (the agents' ACTUAL input). const derived = useMemo(() => { const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); - return chainsForSelection(selectedNodes, edges); - }, [selection, byId, edges]); + return chainsForSelection(selectedNodes, storedChains); + }, [selection, byId, storedChains]); const nameOf = (id: string) => byId.get(id)?.name ?? id; const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? ""; @@ -583,14 +583,14 @@ function RowCheckbox({ export function ChainTableOverlay({ open, nodes, - edges, + chains: storedChains, sessions, fileSources, onClose, }: { open: boolean; nodes: GraphNode[]; - edges: GraphEdge[]; + chains: GraphChain[]; sessions: Record; fileSources: Record; onClose: () => void; @@ -618,7 +618,7 @@ export function ChainTableOverlay({ const byId = new Map(nodes.map((n) => [n.id, n])); const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); - const derived = chainsForSelection(selectedNodes, edges); + const derived = chainsForSelection(selectedNodes, storedChains); const nodeIds = [...selection].sort(); const toggleNodes = (ids: string[], next: boolean) => @@ -645,7 +645,7 @@ export function ChainTableOverlay({ > changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))} /> - {/* Supports draw toggle (owner, 2026-09-29): hides the single-line - supports edges from the canvas — follows (⟹-meant) stay. Draw-only: - physics + detail cards still see them. Last rows of the FIRST - section; the border-t below starts Regions. */} - - {/* Supports shaft thickness (owner, 2026-09-29): a LINEAR 0–1 - ratio of Edge width (not a ladder — it's a ratio; default 0.5 - dead-center anyway, step 0.1 = 11 stops). Grayed out by the - toggle above (the Dilation pattern); 0 floors to a hairline in - the renderer — arrowheads never scale (shaft-only ruling). */} - changeSettings((cur) => ({ ...cur, supportsWidth: v }))} - /> - {/* Regions: the blurred cluster tints. Sliders + a fixed-set + {/* Regions: the blurred cluster tints. Sliders + a fixed-set dropdown for the bitmap resolution (it's a discrete choice, not a free number). Server-persisted like the sizes; every range end was sweep-validated on the real @@ -1411,7 +1381,7 @@ export function GraphView() { y: positions.get(n.id)?.y ?? 0, label: n.name, })), - edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to, type: e.type })), + edges: snapshot.edges.map((e) => ({ from: e.from, to: e.to })), clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })), colors: { node: tokenHex(NODE_COLOR_TOKEN), @@ -1637,53 +1607,6 @@ export function GraphView() { >
{selected.node.name}
{selected.node.statement}
- {(() => { - // EDGES (owner 2026-09-27): edge classification was entirely - // hidden in the UI — the card now shows every edge this node - // participates in, grouped by direction + type, above the - // sessions list. Semantics (owner 2026-09-28): an edge A→B means A implies/ - // supports B — OUTGOING edges list under "What follows from this" / - // "What this supports", INCOMING under "Follows from" / "Supported by". - // Only present groups render. - const id = selected.node.id; - const nodeById = new Map((snapshot?.nodes ?? []).map((n) => [n.id, n])); - const label = (nid: string) => nodeById.get(nid)?.name ?? nid; - const groups: { title: string; names: string[] }[] = [ - { - title: "What follows from this", - names: (snapshot?.edges ?? []).filter((e) => e.from === id && e.type === "follows").map((e) => label(e.to)), - }, - { - title: "What this supports", - names: (snapshot?.edges ?? []).filter((e) => e.from === id && e.type === "supports").map((e) => label(e.to)), - }, - { - title: "Follows from", - names: (snapshot?.edges ?? []).filter((e) => e.to === id && e.type === "follows").map((e) => label(e.from)), - }, - { - title: "Supported by", - names: (snapshot?.edges ?? []).filter((e) => e.to === id && e.type === "supports").map((e) => label(e.from)), - }, - ].filter((g) => g.names.length > 0); - if (groups.length === 0) return null; - return ( -
- {groups.map((g) => ( -
-
{g.title}
-
- {g.names.map((name, i) => ( - - {name} - - ))} -
-
- ))} -
- ); - })()} {selected.sourceIds.length > 0 ? (
Sessions
@@ -1734,7 +1657,7 @@ export function GraphView() { setChainsOpen(false)} diff --git a/config.ts b/config.ts index d1fe32e..12baa83 100644 --- a/config.ts +++ b/config.ts @@ -60,11 +60,12 @@ export type ModelRole = /** * The LIVE graph store (src/store/graph.ts) — owned by the app's extraction - * pipeline. Line shapes: src/core/contracts.ts. + * pipeline. Line shapes: src/core/contracts.ts. CHAINS ARE THE STORED + * TRUTH (owner, 2026-09-30): edges are a derived view, never stored. */ export const LEDGER_FILES = { nodes: path.join(PATHS.graph, "nodes.jsonl"), - edges: path.join(PATHS.graph, "edges.jsonl"), + chains: path.join(PATHS.graph, "chains.jsonl"), sessions: path.join(PATHS.graph, "sessions.jsonl"), /** Per-message extraction coverage (contracts/graph.ts CoverageEntry; * the checkpoint's replacement for chat extractions — notes keep diff --git a/scripts/backfill-graph.ts b/scripts/backfill-graph.ts index e708b8f..972f454 100644 --- a/scripts/backfill-graph.ts +++ b/scripts/backfill-graph.ts @@ -27,7 +27,7 @@ async function main() { const stats = await pipeline.runExtraction(chat.id); console.log( `[backfill] ${chat.id} (${chat.title.slice(0, 40)}): ` + - `${stats.nodes} nodes, ${stats.edges} edges in ${((Date.now() - t0) / 1000).toFixed(1)}s`, + `${stats.nodes} nodes, ${stats.chains ?? 0} chains in ${((Date.now() - t0) / 1000).toFixed(1)}s`, ); } catch (err) { console.error(`[backfill] ${chat.id} FAILED:`, err); @@ -35,7 +35,7 @@ async function main() { } const graph = getGraph(graphStore); console.log( - `[backfill] done: ${graph.nodes.length} nodes, ${graph.edges.length} edges`, + `[backfill] done: ${graph.nodes.length} nodes, ${graph.chains.length} chains`, ); } diff --git a/scripts/import-notes.ts b/scripts/import-notes.ts index 2d91932..dc38554 100644 --- a/scripts/import-notes.ts +++ b/scripts/import-notes.ts @@ -33,7 +33,7 @@ async function main() { getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens, }); const queue = [...files]; - const totals = { nodes: 0, edges: 0, failed: 0 }; + const totals = { nodes: 0, chains: 0, failed: 0 }; const workers = Array.from({ length: Math.min(limit, files.length) }, async () => { for (;;) { const file = queue.shift(); @@ -43,9 +43,9 @@ async function main() { try { const stats = await pipeline.importNotes({ filePath, text: fs.readFileSync(filePath, "utf-8") }); totals.nodes += stats.nodes; - totals.edges += stats.edges; + totals.chains += stats.chains ?? 0; console.log( - `[import-notes] ${file}: ${stats.nodes} nodes, ${stats.edges} edges in ${((Date.now() - t0) / 1000).toFixed(1)}s`, + `[import-notes] ${file}: ${stats.nodes} nodes, ${stats.chains ?? 0} chains in ${((Date.now() - t0) / 1000).toFixed(1)}s`, ); } catch (err) { totals.failed++; @@ -57,8 +57,8 @@ async function main() { const graph = getGraph(stores.graphStore); console.log( - `[import-notes] done: +${totals.nodes} nodes, +${totals.edges} edges, ${totals.failed} failed — ` + - `graph now ${graph.nodes.length} nodes, ${graph.edges.length} edges`, + `[import-notes] done: +${totals.nodes} nodes, +${totals.chains} chains, ${totals.failed} failed — ` + + `graph now ${graph.nodes.length} nodes, ${graph.chains.length} chains`, ); } diff --git a/scripts/replay-layout.ts b/scripts/replay-layout.ts index f36cf6b..e3822a3 100644 --- a/scripts/replay-layout.ts +++ b/scripts/replay-layout.ts @@ -19,20 +19,28 @@ const depthMode: "off" | "vertical" | "radial" = depthArg === "--depth=vertical" ? "vertical" : depthArg === "--depth=radial" ? "radial" : "off"; import { incrementalLayout } from "../src/core/layout.ts"; import { birthSessionPartition } from "../src/core/session-clusters.ts"; +import { deriveView, getGraph } from "../src/core/graph.ts"; import type { GraphNode, Position } from "../src/core/contracts.ts"; function main() { const t0 = Date.now(); - // Unique nodes (lines may repeat an id — first defines). - const seen = new Set(); - const nodes: GraphNode[] = []; - for (const line of graphStore.readNodes()) { - if (seen.has(line.id)) continue; - seen.add(line.id); - nodes.push(line); - } - const edges = graphStore.readEdges(); + // CHAINS-PRIMARY (2026-09-30): the stored truth is nodes + chains; the + // replay lays out the DERIVED view (implication nodes included, untyped + // arrows) exactly like the real settle. + const graph = getGraph(graphStore); + const nodes = graph.nodes; // getGraph already merges lines by id + const view = deriveView(graph); + const edges = view.edges; + const viewNodeById = new Map(view.nodes.map((n) => [n.id, n])); + const chains = graph.chains; + // An implication node is delivered the moment its chain is complete + // (conclusion + every premise delivered). + const chainReady = (id: string): boolean => { + const chain = chains.find((c) => c.id === id); + if (!chain) return delivered.has(id); + return delivered.has(chain.conclusion) && chain.premises.every((p) => delivered.has(p)); + }; // The cluster source (wave 7): the birth-session partition, derived on // read — same as the real settle (core/layout-settle.ts). const birthClusters = birthSessionPartition(nodes); @@ -58,7 +66,7 @@ function main() { const byId = new Map(nodes.map((n) => [n.id, n])); const unmappedFallback = sources[sources.length - 1]?.id ?? ""; - console.log(`[replay] ${nodes.length} unique nodes, ${edges.length} edge lines, ${sources.length} sources`); + console.log(`[replay] ${nodes.length} nodes, ${chains.length} chains, ${sources.length} sources`); let maxOldShift = 0; let shiftReport = ""; for (const source of sources) { @@ -74,13 +82,18 @@ function main() { } } for (const n of batch) delivered.add(n.id); + // Ready chains' implication nodes ride the same batch (they have no + // provenance of their own — the chain's completeness delivers them). + for (const chain of chains) { + if (chainReady(chain.id)) delivered.add(chain.id); + } // Edges activate once BOTH endpoints exist (a later file can claim // one endpoint — the edge lights up then). const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to)); const before = new Map(positions); - const batchNodes = nodes.filter((n) => delivered.has(n.id)); + const batchNodes = view.nodes.filter((n) => delivered.has(n.id)); positions = incrementalLayout(batchNodes, activeEdges, positions, { // The same partition the real settle would see for this batch: // the birth-session clusters, filtered to delivered nodes. diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index bbdb033..b6da799 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -28,7 +28,6 @@ import type { GraphStore } from "../../core/ports.ts"; import { settleGraphLayout } from "../../core/layout-settle.ts"; import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"; import { teacherProseBlocks } from "../../core/coverage.ts"; -import { chainsToEdges } from "../../core/practice.ts"; import type { LhMessage } from "../../core/native-messages.ts"; import { auditorGraphTools } from "../tools/graph.ts"; import { runSubmitAgent } from "./submit-agent.ts"; @@ -266,17 +265,15 @@ function validateFindingsChains(value: FindingsSubmit): string | null { return null; } -/** The translation seam (extractor redesign, 2026-09-30): the wire - * speaks CHAINS, the pipeline speaks follows-EDGES — SessionFindings - * and everything downstream (auditor, resolution, merge, coverage, - * store) is UNCHANGED. One math, one place: chainsToEdges sits next - * to chainsToGraph in core/practice.ts. */ -function expandFindings(raw: FindingsSubmit): SessionFindings { - return { - propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), - edges: chainsToEdges(raw.chains), - }; -} +/** (chains-primary, owner 2026-09-30) the wire IS the pipeline shape + * now: propositions + chains, no translation — the stores keep the + * chains and the derived edge view is recomputed on read. The fill + * already ran inside runSubmitAgent; this only re-asserts the + * aliases-filled type. */ +const asSessionFindings = (raw: FindingsSubmit): SessionFindings => ({ + propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), + chains: raw.chains, +}); function fillAudit(raw: Static): AuditVerdicts { return { @@ -346,7 +343,7 @@ function runExtractor( onStep, neverSubmitted: "the extractor never submitted findings", prepareArguments: healStringifiedArrays, - }).then(expandFindings); + }).then(asSessionFindings); } /** The extractor over ONE source of raw personal notes (the format-agnostic @@ -371,7 +368,7 @@ function runNotesExtractor( onStep, neverSubmitted: "the extractor never submitted findings", prepareArguments: healStringifiedArrays, - }).then(expandFindings); + }).then(asSessionFindings); } // ─── the auditor ─────────────────────────────────────────────────────────── @@ -379,7 +376,7 @@ function runNotesExtractor( const AUDITOR_INSTRUCTIONS = `You are the linking stage of a learning application's extraction pipeline. An extractor has read a source (a teaching session, or the author's personal notes) and reported findings: propositions and -the follows edges of their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check +their reasoning chains. You NEVER see the source itself — the extractor is fully trusted, and you do not re-check its work. Your job is IDENTITY: connecting those findings to the CURRENT knowledge graph, preparing the merge. You explore the graph through your tools: list_sessions, list_propositions, and find (glob over names + @@ -406,8 +403,8 @@ IDENTITY — links, per proposition: propositions — different claims about the world — stay apart. You are NOT guarding against wrong merges; you are hunting for right ones. -Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions or the extractor's edges — -the merger already has them. You NEVER create edges — relations between the new propositions come from the +Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions — +the merger already has them. You NEVER create relations between propositions — they come from the extractor alone, and the batch connects to the existing graph ONLY through the equivalences you identify. Your entire deliverable is ONE submit_review call. @@ -748,7 +745,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti signal?.throwIfAborted(); // an abort during the extract→audit gap ends here const graph = getGraph(graphStore); console.info( - `[extraction ${jobKey}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` + + `[extraction ${jobKey}] auditor starting (${findings.propositions.length} propositions, ` + `${graph.nodes.length} graph nodes)`, ); const t0 = performance.now(); @@ -758,8 +755,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // exploration tools instead of an injected dump. const auditResult = await runAuditor( stageEngine("auditor", deps.auditor), - `Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted):\n\n` + - `${JSON.stringify(findings, null, 2)}\n\n` + + `Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted; the propositions only — the reasoning chains are the merger's business, owner 2026-09-30):\n\n` + + `${JSON.stringify({ propositions: findings.propositions }, null, 2)}\n\n` + `The current knowledge graph has ${graph.nodes.length} node(s). Explore it with your tools and link what links.`, signal, (usage, model, step, finishReason, tools) => { @@ -848,13 +845,13 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti * per job and never throw — the source's run returns what actually * merged. */ function aggregateJobs(sourceId: string, settled: PromiseSettledResult[]): EmissionStats { - const stats: EmissionStats = { nodes: 0, edges: 0 }; + const stats: EmissionStats = { nodes: 0, chains: 0 }; let aborted = false; let failed = 0; for (const result of settled) { if (result.status === "fulfilled") { stats.nodes += result.value.nodes; - stats.edges += result.value.edges; + stats.chains! += result.value.chains ?? 0; continue; } const err = result.reason; @@ -874,7 +871,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti } async function runExtraction(chatId: string): Promise { - if (inFlight.has(chatId)) return { nodes: 0, edges: 0 }; + if (inFlight.has(chatId)) return { nodes: 0, chains: 0 }; inFlight.add(chatId); const t0 = performance.now(); // COVERAGE (vision-extraction-coverage.md, 2026-09-15): extract ONLY @@ -895,7 +892,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId); if (covered.uncovered.length === 0) { console.info(`[extraction ${chatId}] nothing uncovered — no-op`); - return { nodes: 0, edges: 0 }; + return { nodes: 0, chains: 0 }; } const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens; // The batcher's one rule (owner, Wave D): a tool-call message and @@ -960,7 +957,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // next snapshot fetch must already reflect the new nodes). settleGraphLayout(graphStore); console.info( - `[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.edges} edges`, + `[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.chains ?? 0} chains`, ); return batchStats; }); @@ -969,7 +966,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti ); const stats = aggregateJobs(chatId, settled); console.info( - `[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges ` + + `[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` + `(${Math.round(performance.now() - t0)}ms total, ${batches.length} batch job(s))`, ); return stats; @@ -1047,7 +1044,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise { const { id } = input; - if (inFlight.has(id)) return { nodes: 0, edges: 0 }; + if (inFlight.has(id)) return { nodes: 0, chains: 0 }; inFlight.add(id); const t0 = performance.now(); try { @@ -1098,7 +1095,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // The layout ledger evolves with each merge — the next // snapshot fetch reflects every finished chunk. settleGraphLayout(graphStore); - console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.edges} edges`); + console.info(`[extraction ${jobKey}] merged: ${merged.nodes} nodes, ${merged.chains ?? 0} chains`); return merged; }); }); @@ -1106,7 +1103,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti ); const stats = aggregateJobs(id, settled); console.info( - `[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.edges} edges ` + + `[extraction ${id}] merged: ${stats.nodes} nodes, ${stats.chains ?? 0} chains ` + `(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`, ); return stats; @@ -1133,7 +1130,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16); if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) { console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`); - return { nodes: 0, edges: 0 }; + return { nodes: 0, chains: 0 }; } const importedAt = new Date().toISOString(); graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp }); diff --git a/src/agent/subagents/practice-pipeline.ts b/src/agent/subagents/practice-pipeline.ts index 74fa988..12d5db4 100644 --- a/src/agent/subagents/practice-pipeline.ts +++ b/src/agent/subagents/practice-pipeline.ts @@ -45,6 +45,7 @@ import type { GraphStore, PracticeStore } from "../../core/ports.ts"; import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts"; import { chainsForSelection } from "../../core/practice.ts"; +import { getGraph } from "../../core/graph.ts"; import { createPracticeAgents, type PracticeModels, @@ -444,10 +445,10 @@ export function createPracticePipeline(deps: PracticePipelineDeps) { const wanted = new Set(nodeIds); const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id)); const selectedIds = new Set(selected.map((n) => n.id)); - const edges = deps - .graphStore.readEdges() - .filter((e) => e.type === "follows" && selectedIds.has(e.from) && selectedIds.has(e.to)); - const chains = chainsForSelection(selected, edges); + // The STORED chains (chains-primary, owner 2026-09-30): chainsForSelection + // keeps a chain iff every member is selected — the same inclusion law as + // the pre-chains edge grouping; never the derived fan-out. + const chains = chainsForSelection(selected, getGraph(deps.graphStore).chains); if (chains.length === 0) throw new EmptySelectionError(); const propositions: PropositionSnapshot = {}; for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement }; diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index c92ac09..509089d 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -18,20 +18,51 @@ */ // ─── Knowledge graph (vision §1 — global, cross-session) ──────────────────── -/** Two relations only (DECIDED 2026-09-02): follows = b is derivable from a; - * supports = a grounds b without entailing it. No scalars, no encoded - * strength — strength is derived at read time from topology. */ import type { CallUsage, ExtractionRunUsage } from "./chat.ts"; -export type EdgeType = "follows" | "supports"; +/** One relation of the DERIVED view — chains are the stored truth (owner, + * 2026-09-30: "the graph is a lossy image of what is truly stored"), and + * the snapshot derives UNTYPED arrows from them: every premise → the + * chain's implication node, the implication node → the conclusion. + * Nothing stores or mutates these — they are rebuilt after every change + * and never converted back. */ +export interface GraphEdge { + from: string; + to: string; +} + +/** One stored REASONING CHAIN — an argument: premises that JOINTLY entail + * the conclusion (chains-primary, owner 2026-09-30). Independent arguments + * for the same conclusion are separate chains (they may share `conclusion`); + * co-premises within one chain are used TOGETHER. The snapshot derives an + * implication NODE per chain (id = the chain id — the arg_ namespace is its + * own, collision-free with n_) plus untyped edges premises → imp → + * conclusion. A chain referencing a dead node dies with it (full death + * cascades through every referencing chain). */ +export interface GraphChain { + /** "arg_" + nanoid-like (store-assigned, the node-minting pattern). */ + id: string; + /** Node ids of the argument's premises — at least one, jointly + * sufficient. Extractor-local at the wire; node ids after resolution. */ + premises: string[]; + /** The node id the premises jointly imply. */ + conclusion: string; + /** Every (chat, message) point where this argument was asserted — + * unioned across assertion lines at read time. */ + provenance: ProvenancePoint[]; + createdAt: string; +} /** What one extraction run merged into the graph ledgers — the merger's - * output, also persisted in extraction-run usage records (contracts/chat). */ + * output, also persisted in extraction-run usage records (contracts/chat). + * `chains` is OPTIONAL IN OLD RECORDS ONLY: usage history from the + * pre-chains era carries `edges` instead — tolerated at read, never + * written again (no-backward-compat regime, owner 2026-09-30). */ export interface EmissionStats { /** New graph nodes appended. */ nodes: number; - /** New edge assertions appended. */ - edges: number; + /** New reasoning chains appended. */ + chains?: number; } /** One provenance point — message-granular (DECIDED 2026-09-02): nodes and @@ -107,14 +138,6 @@ export interface GraphNode { provenance: ProvenancePoint[]; } -export interface GraphEdge { - from: string; - to: string; - type: EdgeType; - /** Every (chat, message) point where this relation was asserted. */ - provenance: ProvenancePoint[]; -} - /** sessions.jsonl line — the emission checkpoint for one chat (which chats * have been folded, and through which updatedAt — never fold twice). */ export interface SessionCheckpoint { @@ -143,7 +166,9 @@ export interface CoverageEntry { /** * Store format (src/store/graph.ts): JSONL ledgers at config.LEDGER_FILES - * (nodes/edges/mappings/sessions), all append-only. + * (nodes/chains/sessions), all append-only. Edges are NEVER stored — the + * derived view (implication nodes + untyped arrows) is rebuilt from chains + * on every read (chains-primary, owner 2026-09-30). * * nodes.jsonl one line per node — the FIRST line for an id defines * name/statement/createdAt; later lines for the @@ -151,8 +176,9 @@ export interface CoverageEntry { * their statements/aliases join `aliases`, their * provenance unions; the name stays the first line's. * getGraph merges by id. - * edges.jsonl one line per edge ASSERTION; readers merge by - * (from,to,type), unioning provenance. + * chains.jsonl one line per chain ASSERTION; readers merge by + * (premises, conclusion), unioning provenance — the + * first line's arg_ id is the chain's identity. * * The derived graph is rebuilt by reading the ledgers — store events, * derive state. sessions.jsonl is the extraction checkpoint: which chats @@ -202,21 +228,20 @@ export interface ExtractedProposition { * + structure, nothing else. */ } -/** One proposed relation. Endpoints are extractor-local proposition ids - * ("p1", …) — the extractor is the ONLY stage that ever proposes edges - * (the auditor's edges argument is gone, owner 2026-09-28 evening). */ -export interface ExtractedEdge { - /** Proposition id that is followed / supported. */ - from: string; - /** Proposition id that follows / is supported. */ - to: string; - type: EdgeType; +/** One proposed argument (extractor redesign, 2026-09-30). Endpoints are + * extractor-local proposition ids ("p1", …) — the extractor is the ONLY + * stage that ever proposes chains; the merger resolves them to node ids. */ +export interface ExtractedChain { + /** Proposition ids that JOINTLY entail the conclusion. */ + premises: string[]; + /** The proposition id the premises jointly imply. */ + conclusion: string; } /** The extractor's output shape: the raw reasoning chains. */ export interface SessionFindings { propositions: ExtractedProposition[]; - edges: ExtractedEdge[]; + chains: ExtractedChain[]; } /** One identity verdict: the extractor's local proposition id → the @@ -279,8 +304,16 @@ export interface GraphClusterGroup { } export interface GraphSnapshot { + /** Proposition nodes + the DERIVED implication nodes (one per chain, + * id = chain id) — what the canvas renders. */ nodes: GraphNode[]; + /** DERIVED untyped arrows: premise → imp → conclusion. A lossy image + * of the stored chains — rebuilt after every change, never stored. */ edges: GraphEdge[]; + /** The STORED reasoning chains (merged by (premises, conclusion) at + * read): the chain table's and the practice pipeline's input — they + * read the truth, never the derived fan-out. */ + chains: GraphChain[]; /** chatId → title, for every chat that touched the graph. */ sessions: Record; /** srcId → file path, for every IMPORTED NOTES file that touched the diff --git a/src/core/graph-deletion.ts b/src/core/graph-deletion.ts index 849b25a..8a1bbdf 100644 --- a/src/core/graph-deletion.ts +++ b/src/core/graph-deletion.ts @@ -9,11 +9,17 @@ * orchestration) owns the cross-store choreography (chat record, jobs, * extraction wind-down); this module owns the LEDGERS. * + * CHAINS-PRIMARY (owner, 2026-09-30): the stored truth is propositions + + * reasoning chains; edges are a derived view. Deletion therefore operates + * on CHAIN LINES where it used to operate on edge lines — a chain dies + * when its provenance empties or any member node died — and the derived + * view follows on the next read, never rewritten here. + * * Two modes (owner-locked semantics): * * preserve-graph the chat's extraction bookkeeping dies — its coverage * rows and its legacy checkpoint lines — and NOTHING else: - * nodes and edges stay byte-identical. + * nodes and chains stay byte-identical. * The graph keeps the session's contributions; the * snapshot simply no longer resolves the dead chatId to * a title (nodeSessions filters unknown ids at read @@ -21,17 +27,16 @@ * * delete-nodes "this session never happened": every trace of the * chat leaves the ledgers — its provenance points on - * every node and edge line, its link lines (their + * every node and chain line, its link lines (their * phrasings die with them), its coverage/checkpoint * rows — and orphans die: a node * whose references all came from this chat disappears - * entirely, pruned from the positions and embedding - * ledgers too (dead members would break cluster hulls). + * entirely, pruned from the positions ledger too (dead + * members would break cluster hulls). * * Shared nodes survive automatically (the ruling): a node lives iff ANY * surviving reference remains — a provenance point from another chat or - * notes source, or a surviving event (another session's quiz outcome / - * observation) naming it. The create line of a surviving node is special: + * notes source. The create line of a surviving node is special: * name/statement/createdAt are the node's IDENTITY — the shared anchor * other sessions' link verdicts recognized — so they stay even when the * deleted chat created the node; but that session's phrasings (the line's @@ -39,9 +44,6 @@ * 2026-09-23: all traces, identity survives). * * Not touched, by design: - * - cluster-labels.jsonl — immutable history by contract, and the - * namer is graph-global (tied to no session); core-survival renames - * communities whose core shrank below half. * - imported-notes sources (src_…) — a different front door with its * own lifecycle (re-import dedupes by path hash); this wave deletes * CHATS. @@ -55,7 +57,7 @@ import type { CoverageEntry, - GraphEdge, + GraphChain, GraphNode, SessionCheckpoint, } from "./contracts.ts"; @@ -71,8 +73,8 @@ export type ChatDeleteMode = "preserve-graph" | "delete-nodes"; export interface ChatDeletionStats { /** Distinct nodes that died entirely (all their lines). */ nodesRemoved: number; - /** Edge assertion LINES removed (empty-provenance or dead endpoint). */ - edgesRemoved: number; + /** Chain assertion LINES removed (empty-provenance or dead member). */ + chainsRemoved: number; /** Coverage rows removed. */ coverageRemoved: number; /** Checkpoint lines removed. */ @@ -81,7 +83,7 @@ export interface ChatDeletionStats { const EMPTY_STATS: ChatDeletionStats = { nodesRemoved: 0, - edgesRemoved: 0, + chainsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0, }; @@ -116,7 +118,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C // ── delete-nodes: the full purge ────────────────────────────────────── const nodeLines = graphStore.readNodes(); - const edgeLines = graphStore.readEdges(); + const chainLines = graphStore.readChains(); // Does the chat touch the graph at all? (A never-extracted chat still // needs its coverage rows dropped above, but no ledger rewrite — keeps @@ -124,7 +126,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C // changed.) const touched = nodeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) || - edgeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)); + chainLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)); if (!touched) { if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) { graphStore.rewriteLedgers({ coverage, checkpoints }); @@ -133,14 +135,12 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C } // 1. Survival: a node lives iff a reference from ANY surviving source - // remains — another chat/src's provenance point on any of its lines, - // a surviving mapping line (notes-front-door nodes carry EMPTY - // provenance: the file IS the anchor, recorded through the mapping - // line). (Edge endpoints are deliberately - // NOT survival references: an edge's asserting session always left - // provenance on its endpoints, so endpoint-only liveness cannot - // occur in a consistent ledger — and counting it would keep nodes - // alive whose lines we would then drop, dangling the edge.) + // remains — another chat/src's provenance point on any of its lines. + // (Chain members are deliberately NOT survival references: a + // chain's asserting session always left provenance on its endpoints, + // so endpoint-only liveness cannot occur in a consistent ledger — + // and counting it would keep nodes alive whose lines we would then + // drop, dangling the chain.) const survives = (points: ReadonlyArray<{ chatId: string }>): boolean => points.some((pt) => pt.chatId !== chatId); const live = new Set(); @@ -182,41 +182,49 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C // else: the deleted chat's link line — dies with its phrasings. } - // 3. Edges: strip the chat's points; an assertion dies when its - // provenance empties or either endpoint died IN THIS DELETION. - // (Only nodes that existed and died count — an edge whose endpoints - // never resolved as nodes is foreign data the deletion leaves alone: - // destructive rewriting may not exceed the deleted chat's blast - // radius.) + // 3. Chains: strip the chat's points; an assertion dies when its + // provenance empties or ANY member node (premise or conclusion) + // died IN THIS DELETION. (Only nodes that existed and died count — + // a chain whose members never resolved as nodes is foreign data the + // deletion leaves alone: destructive rewriting may not exceed the + // deleted chat's blast radius.) const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id))); - const outEdges: GraphEdge[] = []; - for (const line of edgeLines) { - if (deadNodes.has(line.from) || deadNodes.has(line.to)) continue; + const outChains: GraphChain[] = []; + for (const line of chainLines) { + if (deadNodes.has(line.conclusion) || line.premises.some((premise) => deadNodes.has(premise))) continue; const points = line.provenance.filter((pt) => pt.chatId !== chatId); // An assertion dies when THIS deletion empties its provenance — a // line that already had no points is foreign/legacy data, not this // chat's trace; it rides untouched. if (line.provenance.length > 0 && points.length === 0) continue; - outEdges.push(points.length === line.provenance.length ? line : { ...line, provenance: points }); + outChains.push(points.length === line.provenance.length ? line : { ...line, provenance: points }); } stats.nodesRemoved = [...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)).length; - stats.edgesRemoved = edgeLines.length - outEdges.length; + stats.chainsRemoved = chainLines.length - outChains.length; graphStore.rewriteLedgers({ nodes: outNodes, - edges: outEdges, + chains: outChains, coverage, checkpoints, }); - // 5. Positions: prune dead nodes (stale resting spots would pin - // nothing — but pruning keeps the ledger honest and the revision - // moving for exactly the right reason). + // 4. Positions: prune dead nodes AND dead chains (an argument's derived + // implication node carries the chain's arg_ id — stale resting spots + // would pin nothing; pruning keeps the ledger honest and the + // revision moving for exactly the right reason). + const deadChainIds = new Set( + chainLines + .filter((line) => !outChains.includes(line)) + .map((line) => line.id), + ); const positions = graphStore.readPositions(); const knownNodeIds = new Set(nodeLines.map((line) => line.id)); const prunedPositions = Object.fromEntries( - Object.entries(positions).filter(([id]) => live.has(id) || !knownNodeIds.has(id)), + Object.entries(positions).filter( + ([id]) => (live.has(id) || !knownNodeIds.has(id)) && !deadChainIds.has(id), + ), ); if (Object.keys(prunedPositions).length !== Object.keys(positions).length) { graphStore.writePositions(prunedPositions); @@ -224,6 +232,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C return stats; } + // ─── single-node deletion (node interlude, owner-locked full-death) ─────────── /** @@ -236,13 +245,15 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C * one deletes a NODE and takes its every trace — * * - every line of the node (create + link lines) - * - every EDGE touching it (from or to), whatever session asserted it - * - every mapping line that anchored it (the audit trail; for a - * notes-born node the file IS the anchor — its mapping line is the - * only one) - * - its resting position + * - every CHAIN referencing it (as premise or conclusion), whatever + * session asserted it — a chain with a dead member is not a chain + * - its resting position (and every dead chain's) * - * The blast radius is exactly the rows naming the node id: other nodes' + * An ARGUMENT deletion is the same call with an arg_-prefixed id (owner, + * 2026-09-30: the chain table's fenced delete kills chains): the "node" + * is the derived implication node whose id IS the chain's id. + * + * The blast radius is exactly the rows naming the id: other nodes' * provenance, aliases, and sessions are untouched (full death is * NODE-scoped, not session-scoped), and the source records of notes * imports stay (the import's bookkeeping; deleting one proposition is not @@ -256,43 +267,68 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C * second run finds no lines and changes nothing. */ export interface NodeDeletionStats { - /** Edge assertion LINES removed (every edge touching the node). */ - edgesRemoved: number; + /** Chain assertion LINES removed (every chain referencing the node). */ + chainsRemoved: number; } function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] { return graphStore.readNodes().filter((line) => line.id === nodeId); } +function chainLinesOf(graphStore: GraphStore, chainId: string): GraphChain[] { + return graphStore.readChains().filter((line) => line.id === chainId); +} + /** - * Full-death one node. Returns null when no such node exists (the - * orchestration maps that to 404); rewrites only the ledgers that - * changed (unchanged files keep their mtimes — the version stamp moves - * only when something really did). + * Full-death one node (or one argument, by its arg_ id). Returns null + * when no such node/chain exists (the orchestration maps that to 404); + * rewrites only the ledgers that changed (unchanged files keep their + * mtimes — the version stamp moves only when something really did). */ export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null { + const chainLines = graphStore.readChains(); + + // The argument branch: an arg_ id names a CHAIN, not a proposition — + // its lines die, and that is the whole death (the member propositions + // survive: deleting an argument spares its premises/conclusion). + if (nodeId.startsWith("arg_")) { + const outChains = chainLines.filter((line) => line.id !== nodeId); + if (outChains.length === chainLines.length) return null; // unknown argument + graphStore.rewriteLedgers({ chains: outChains }); + const positions = graphStore.readPositions(); + if (positions[nodeId] !== undefined) { + const { [nodeId]: _dead, ...rest } = positions; + graphStore.writePositions(rest); + } + return { chainsRemoved: chainLines.length - outChains.length }; + } + const nodeLines = nodeLinesOf(graphStore, nodeId); if (nodeLines.length === 0) return null; - const edgeLines = graphStore.readEdges(); - const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId); + const outChains = chainLines.filter( + (line) => line.conclusion !== nodeId && !line.premises.includes(nodeId), + ); // Only the touched ledgers: the node's own lines always die (its file // must move), everything else rewrites only when rows actually drop. const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId); graphStore.rewriteLedgers({ nodes: outNodes, - ...(outEdges.length !== edgeLines.length ? { edges: outEdges } : {}), + ...(outChains.length !== chainLines.length ? { chains: outChains } : {}), }); - // The resting position dies with the node. + // The resting positions die with the node and every dead chain. + const deadChainIds = new Set(chainLines.filter((line) => !outChains.includes(line)).map((line) => line.id)); const positions = graphStore.readPositions(); - if (positions[nodeId] !== undefined) { - const { [nodeId]: _dead, ...rest } = positions; - graphStore.writePositions(rest); + const prunedPositions = Object.fromEntries( + Object.entries(positions).filter(([id]) => id !== nodeId && !deadChainIds.has(id)), + ); + if (Object.keys(prunedPositions).length !== Object.keys(positions).length) { + graphStore.writePositions(prunedPositions); } return { - edgesRemoved: edgeLines.length - outEdges.length, + chainsRemoved: chainLines.length - outChains.length, }; } diff --git a/src/core/graph.ts b/src/core/graph.ts index 1c29e1c..b7c2908 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -45,7 +45,8 @@ import { randomBytes } from "node:crypto"; import type { CoverageEntry } from "./contracts/graph.ts"; import type { AuditVerdicts, - ExtractedEdge, + ExtractedChain, + GraphChain, SessionFindings, Cluster, EmissionStats, @@ -56,6 +57,7 @@ import type { } from "./contracts.ts"; import { MIN_CLUSTER_SIZE } from "./clusters.ts"; import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts"; +import { formatChainLine } from "./practice.ts"; import type { ChatStore, GraphStore } from "./ports.ts"; /** The storage ports every graph operation needs, passed explicitly. */ @@ -69,6 +71,18 @@ function newGraphNodeId(): string { return `n_${randomBytes(6).toString("hex")}`; } +/** "arg_" + 12 hex chars — the implication-node namespace (a chain's id + * IS its derived node's id; collision-free with n_ by construction). */ +function newGraphChainId(): string { + return `arg_${randomBytes(6).toString("hex")}`; +} + +/** A chain's merge key: sorted premises + conclusion — the identity an + * assertion re-stating the same argument merges under. */ +function chainKey(premises: ReadonlyArray, conclusion: string): string { + return `${[...premises].sort().join("+")}>${conclusion}`; +} + /** What one merge run merges FROM (the caller supplies it — the merger never * reads the chat store): the coverage checkpoint for a session batch, or — * for the format-agnostic notes front door (2026-09-13) — no coverage at @@ -127,7 +141,7 @@ export function mergeFindings( source: MergeSource, ): EmissionStats { const { graphStore: ledgers } = deps; - const stats: EmissionStats = { nodes: 0, edges: 0 }; + const stats: EmissionStats = { nodes: 0, chains: 0 }; // 1. Resolution: THIS job's propositions → graph node ids, built // in-memory as the propositions resolve (owner, 2026-09-29: the @@ -180,31 +194,41 @@ export function mergeFindings( mapping.set(p.id, graphNodeId); } - // 2. Edges — the extractor's own, the ONLY edge source (the auditor's - // edges argument is gone, owner 2026-09-28 evening). Endpoints are - // this job's local proposition ids and resolve through the job-local - // map; edges carry the run's chat-level provenance point and dedupe - // by (from,to,type) already asserted by THIS chat — an assertion by - // another chat stays untouched (provenance unions at read time). + // 2. Chains — the extractor's own, the ONLY chain source; the stored + // truth (chains-primary, owner 2026-09-30). Endpoints are this + // job's local proposition ids and resolve through the job-local + // map; chains carry the run's chat-level provenance point and + // dedupe by (premises, conclusion) already asserted by THIS chat — + // an assertion by another chat stays untouched (provenance unions + // at read time; a known argument re-stated appends under the SAME + // arg_ id — link-style, exactly like node link-appends; the first + // line's premises/createdAt stand at read time). const assertedByChat = new Set(); - for (const line of ledgers.readEdges()) { - const key = `${line.from}\u0000${line.to}\u0000${line.type}`; + const idOfKey = new Map(); + for (const line of ledgers.readChains()) { + const key = chainKey(line.premises, line.conclusion); + if (!idOfKey.has(key)) idOfKey.set(key, line.id); if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key); } - const emitEdge = (edge: ExtractedEdge): void => { - // Extractor-local ids only — the auditor's bridging edges are gone - // (owner, 2026-09-28 evening), so raw node-id endpoints no longer occur. + const emitChain = (chain: ExtractedChain): void => { const resolve = (endpoint: string): string | undefined => mapping.get(endpoint); - const from = resolve(edge.from); - const to = resolve(edge.to); - if (!from || !to || from === to) return; // unresolvable endpoint — skip - const key = `${from}\u0000${to}\u0000${edge.type}`; - if (assertedByChat.has(key)) return; - ledgers.appendEdge({ from, to, type: edge.type, provenance: pointOf(chatId) }); + const conclusion = resolve(chain.conclusion); + if (conclusion === undefined) return; // unresolvable endpoint — skip + // Duplicate local ids resolving to one node (two phrasings of one + // premise) collapse; a premise equal to the conclusion is dropped. + const premises = [...new Set(chain.premises.map(resolve))].filter( + (p): p is string => p !== undefined && p !== conclusion, + ); + if (premises.length === 0) return; // a chain IS an argument — bare propositions ride the node ledger alone + const key = chainKey(premises, conclusion); + if (assertedByChat.has(key)) return; // this chat already asserted this argument + const id = idOfKey.get(key) ?? newGraphChainId(); + ledgers.appendChain({ id, premises, conclusion, createdAt: now, provenance: pointOf(chatId) }); + idOfKey.set(key, id); assertedByChat.add(key); - stats.edges++; + stats.chains!++; }; - for (const edge of findings.edges) emitEdge(edge); // the extractor's edges — the ONLY edge source + for (const chain of findings.chains) emitChain(chain); // the extractor's chains — the ONLY chain source // the extractor's edges — the ONLY edge source // 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md): // only now, after every append succeeded, is the batch covered. The @@ -222,21 +246,26 @@ export function mergeFindings( // ─── derivation (reads only — everything below is recomputable) ───────────── +/** The merged STORED truth: propositions + chains (chains-primary, owner + * 2026-09-30). `nodes` are PROPOSITIONS ONLY — no derived implication + * nodes; the auditor's and prober's views see the world, not the + * rendering. The canvas-facing VIEW (implication nodes + untyped + * arrows) is deriveView() below — the lossy image. */ export interface DerivedGraph { nodes: GraphNode[]; - edges: GraphEdge[]; + chains: GraphChain[]; } /** * The whole graph from the ledgers. Node LINES may repeat an id (link- * appends): the FIRST line defines name/statement/createdAt; later * lines' statements and aliases join `aliases`, provenance unions — the - * name never changes after creation. Edge - * LINES are assertions, merged by (from,to,type) with unioned provenance, - * first-seen order. `store events, derive state` — this is the only reader - * that matters. + * name never changes after creation. Chain LINES are assertions, merged + * by (premises, conclusion) with unioned provenance; the first line's + * arg_ id / premises / createdAt are the chain's identity. + * `store events, derive state` — this is the only reader that matters. */ -export function getGraph(graphStore: GraphStore): DerivedGraph { +export function getGraph(graphStore: Pick): DerivedGraph { // Merge node lines by id (first line defines; later lines add phrasings). const byId = new Map(); for (const line of graphStore.readNodes()) { @@ -254,29 +283,68 @@ export function getGraph(graphStore: GraphStore): DerivedGraph { } } - const merged: GraphEdge[] = []; - const byKey = new Map(); - for (const line of graphStore.readEdges()) { - const key = `${line.from}\u0000${line.to}\u0000${line.type}`; + // Merge chain assertion lines by (premises, conclusion): the first + // line's id/premises/createdAt stand, provenance unions. + const chains: GraphChain[] = []; + const byKey = new Map(); + for (const line of graphStore.readChains()) { + const key = chainKey(line.premises, line.conclusion); const existing = byKey.get(key); if (existing) { for (const pt of line.provenance) { if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt); } } else { - const edge: GraphEdge = { ...line, provenance: [...line.provenance] }; - byKey.set(key, edge); - merged.push(edge); + const chain: GraphChain = { ...line, premises: [...line.premises], provenance: [...line.provenance] }; + byKey.set(key, chain); + chains.push(chain); } } - return { nodes: [...byId.values()], edges: merged }; + return { nodes: [...byId.values()], chains }; +} + +/** The DERIVED view — what the canvas renders. One implication NODE per + * chain (id = the chain's arg_ id; name "⟹"; statement = the chain + * line; the chain's provenance/createdAt) plus UNTYPED arrows: each + * premise → the implication node, the implication node → the + * conclusion. Rendering specialization (invisible / physics-free) + * comes later (owner, 2026-09-30: physics is fine with imp nodes as + * regular nodes for now). Dangling chains (a member node missing) + * drop here — the deletion cascades should prevent them; this is the + * honest belt. */ +export function deriveView(graph: DerivedGraph): { nodes: GraphNode[]; edges: GraphEdge[] } { + const names: Record = {}; + for (const node of graph.nodes) names[node.id] = { name: node.name }; + const live = new Set(graph.nodes.map((node) => node.id)); + const impNodes: GraphNode[] = []; + const edges: GraphEdge[] = []; + for (const chain of graph.chains) { + if (!live.has(chain.conclusion) || !chain.premises.every((premise) => live.has(premise))) continue; + impNodes.push({ + id: chain.id, + name: "⟹", + statement: formatChainLine(chain, names), + aliases: [], + createdAt: chain.createdAt, + provenance: [...chain.provenance], + }); + for (const premise of chain.premises) edges.push({ from: premise, to: chain.id }); + edges.push({ from: chain.id, to: chain.conclusion }); + } + return { nodes: [...graph.nodes, ...impNodes], edges }; } // ─── the /api/graph payload ────────────────────────────────────────────────── /** Everything the home graph view needs, in one fetch (shape: contracts). */ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { - const { nodes, edges } = getGraph(deps.graphStore); + const graph = getGraph(deps.graphStore); + // The canvas-facing lossy image: propositions + derived implication + // nodes, untyped arrows. `chains` (the stored truth) ride along for + // the chain table and the practice pipeline — they read the truth, + // never the derived fan-out. + const view = deriveView(graph); + const nodes = view.nodes; const nodeChats = new Map>(); const touch = (nodeId: string, chatId: string): void => { @@ -290,13 +358,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { for (const node of nodes) { for (const pt of node.provenance) touch(node.id, pt.chatId); } - for (const edge of edges) { - for (const pt of edge.provenance) { - touch(edge.from, pt.chatId); - touch(edge.to, pt.chatId); - } - } - // Labels for every source the graph can point back at: real chats // (title, deep-linkable) and imported notes files (path, static — the // format-agnostic front door's provenance). @@ -330,7 +391,9 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { // presentation rule (a region needs >= 3 propositions for a hull). A // birth session whose record is gone (deleted chat) keeps its members // but renders unlabeled — honest, never guessed. - const communities = birthSessionPartition(nodes); + // PROPOSITIONS ONLY (owner, 2026-09-30): derived implication nodes + // never partition — the stored world clusters, its image follows. + const communities = birthSessionPartition(graph.nodes); const clusters: Cluster[] = communities .filter((community) => community.members.length >= MIN_CLUSTER_SIZE) .map((community) => ({ @@ -346,7 +409,8 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { return { nodes, - edges, + edges: view.edges, + chains: graph.chains, sessions, fileSources, nodeSessions, diff --git a/src/core/layout-settle.ts b/src/core/layout-settle.ts index f854c1d..04b1761 100644 --- a/src/core/layout-settle.ts +++ b/src/core/layout-settle.ts @@ -1,6 +1,7 @@ -import type { GraphEdge, GraphNode, Position } from "./contracts.ts"; +import type { GraphChain, GraphNode, Position } from "./contracts.ts"; import { incrementalLayout } from "./layout.ts"; import { birthSessionPartition } from "./session-clusters.ts"; +import { deriveView, getGraph } from "./graph.ts"; /** * The merge-time settle: recompute the resting layout from the CURRENT @@ -10,6 +11,12 @@ import { birthSessionPartition } from "./session-clusters.ts"; * commit (client positions become the anchor set). Synchronous — it must * finish before the next /api/graph fetch reflects the change. * + * CHAINS-PRIMARY (owner, 2026-09-30): the settle lays out the DERIVED + * view — propositions AND the derived implication nodes (one per chain, + * arg_ ids) over the derived untyped arrows — so positions exist for + * every node the canvas renders. Implication nodes participate in the + * physics as regular nodes (rendering specialization comes later). + * * SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the * browser barrel, and the settle needs the cluster partition for the * cluster centroid pull — this module must not ship in the client bundle. @@ -18,27 +25,20 @@ import { birthSessionPartition } from "./session-clusters.ts"; */ export function settleGraphLayout(graphStore: { readNodes(): GraphNode[]; - readEdges(): GraphEdge[]; + readChains(): GraphChain[]; readPositions(): Record; writePositions(positions: Record): void; }): void { - const nodes = graphStore.readNodes(); - if (nodes.length === 0) return; - // Node LINES may repeat an id (link-appends) — dedupe, first defines. - const seen = new Set(); - const unique = nodes.filter((n) => { - if (seen.has(n.id)) return false; - seen.add(n.id); - return true; - }); - const edges = graphStore.readEdges(); + const graph = getGraph(graphStore); + if (graph.nodes.length === 0) return; + const view = deriveView(graph); const previous = new Map(Object.entries(graphStore.readPositions())); // The cluster partition for the centroid pull: the SAME birth-session // partition the snapshot renders (derived from provenance on read — // core/session-clusters.ts, wave 7) — one cluster concept everywhere. - // The unique list is the deduped set the partition needs (lines may - // repeat an id — first defines). - const communities = birthSessionPartition(unique); - const next = incrementalLayout(unique, edges, previous, { clusters: communities }); + // PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never + // partition; the stored world clusters, its image follows. + const communities = birthSessionPartition(graph.nodes); + const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities }); graphStore.writePositions(Object.fromEntries(next)); -} \ No newline at end of file +} diff --git a/src/core/ports.ts b/src/core/ports.ts index fbd2350..4c9924c 100644 --- a/src/core/ports.ts +++ b/src/core/ports.ts @@ -14,7 +14,7 @@ import type { ChatRecord, ChatSettings, ChatSummary, - GraphEdge, + GraphChain, GraphNode, JobKind, JobRecord, @@ -65,11 +65,12 @@ export interface ChatStore { } // ─── GraphStore — the append-only evidence ledgers (data/graph) ────────────── -// v1 surface = the five JSONL ledgers (nodes / edges / events / mappings / -// sessions). Reads return the raw append-order lines; merging edge assertions -// by (from, to, type) is DERIVED in core/graph.ts — the store never decides. -// The extraction pipeline (next stage) becomes the sole writer; this read -// surface stays. +// Chains are the stored truth (owner, 2026-09-30): nodes + reasoning +// chains; edges are a DERIVED view (core/graph.ts) and never touch this +// seam. Reads return the raw append-order lines; merging chain assertions +// by (premises, conclusion) is DERIVED in core/graph.ts — the store never +// decides. The extraction pipeline is the sole writer; this read surface +// stays. export interface GraphStore { /** Append one GraphNode (ids are unique — no merging needed). */ @@ -77,12 +78,10 @@ export interface GraphStore { /** Raw node lines in append order. */ readNodes(): GraphNode[]; - /** Append one edge ASSERTION (a second session asserting the same - * (from, to, type) appends another line; readers merge). */ - appendEdge(assertion: GraphEdge): void; - readEdges(): GraphEdge[]; - - /** Append one quiz outcome / understanding observation. */ + /** Append one chain ASSERTION (a second session asserting the same + * (premises, conclusion) appends another line; readers merge). */ + appendChain(assertion: GraphChain): void; + readChains(): GraphChain[]; /** Append one emission checkpoint. */ appendCheckpoint(checkpoint: SessionCheckpoint): void; @@ -122,7 +121,7 @@ export interface GraphStore { * replacement atomic. */ rewriteLedgers(next: { nodes?: GraphNode[]; - edges?: GraphEdge[]; + chains?: GraphChain[]; coverage?: CoverageEntry[]; checkpoints?: SessionCheckpoint[]; }): void; diff --git a/src/core/practice.ts b/src/core/practice.ts index e8a76ed..d786460 100644 --- a/src/core/practice.ts +++ b/src/core/practice.ts @@ -2,16 +2,15 @@ * Practice-module core — the chain bijection + coverage (slice 1 of the * 2026-09-29 plan). Pure, no I/O. * - * chainsForSelection(): the graph → chains translation — group by - * conclusion over FOLLOWS edges only (supports are invisible to practice, - * owner 2026-09-29: "no edges"). chainsToGraph(): the exact inverse — the - * bijection the chain notation promises (renderable back to the graph - * with no information loss; the round-trip is pinned in - * tests/practice.test.ts). chainCoverage(): the set's completeness law — - * the union of the problems' chains must equal the set's chains; the - * partitioner's submit is validated against it. + * chainsForSelection(): the stored CHAINS (chains-primary, owner + * 2026-09-30) filtered to a selection — a chain rides along iff ALL of + * its members are selected; nodes in no surviving chain become singleton + * chains. formatChainLine(): one world-facing line per chain. + * chainCoverage(): the set's completeness law — the union of the + * problems' chains must equal the set's chains; the partitioner's submit + * is validated against it. */ -import type { CueDistance, GraphEdge, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts"; +import type { CueDistance, GraphChain, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts"; /** The named levels (owner-approved 2026-09-29) — definitions verbatim * from the ladder doctrine / the contract's tables. The UI's stepped @@ -36,81 +35,34 @@ export const PRESENTATIONS: Record>, - edges: ReadonlyArray>, + chains: ReadonlyArray>, ): ReasoningChain[] { const selected = new Set(nodes.map((n) => n.id)); - const premisesByConclusion = new Map>(); - for (const edge of edges) { - if (edge.type !== "follows") continue; // supports are NO edges here (owner, 2026-09-29) - if (!selected.has(edge.from) || !selected.has(edge.to)) continue; - const premises = premisesByConclusion.get(edge.to) ?? new Set(); - premises.add(edge.from); - premisesByConclusion.set(edge.to, premises); - } const chained = new Set(); - const chains: ReasoningChain[] = []; - for (const [conclusion, premises] of premisesByConclusion) { - chains.push({ id: conclusion, premises: [...premises].sort(), conclusion }); - chained.add(conclusion); - for (const premise of premises) chained.add(premise); + const included: ReasoningChain[] = []; + for (const chain of chains) { + if (!selected.has(chain.conclusion)) continue; + if (!chain.premises.every((premise) => selected.has(premise))) continue; + included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion }); + chained.add(chain.conclusion); + for (const premise of chain.premises) chained.add(premise); } for (const node of nodes) { if (!chained.has(node.id)) { - chains.push({ id: node.id, premises: [], conclusion: node.id }); + included.push({ id: node.id, premises: [], conclusion: node.id }); } } - return chains.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); -} - -/** The exact inverse of chainsForSelection: node ids = every chain member, - * edges = one follows-edge per (premise, conclusion) pair. Deterministic: - * node ids sorted, edges sorted by (from, to). */ -export function chainsToGraph( - chains: ReadonlyArray>, -): { - nodeIds: string[]; - edges: Array<{ from: string; to: string; type: "follows" }>; -} { - const nodeIds = new Set(); - const edges: Array<{ from: string; to: string; type: "follows" }> = []; - for (const chain of chains) { - nodeIds.add(chain.conclusion); - for (const premise of chain.premises) { - nodeIds.add(premise); - edges.push({ from: premise, to: chain.conclusion, type: "follows" }); - } - } - edges.sort((a, b) => (a.from !== b.from ? (a.from < b.from ? -1 : 1) : a.to < b.to ? -1 : 1)); - return { nodeIds: [...nodeIds].sort(), edges }; -} - -/** The extraction seam's chain→edge expansion (extractor redesign, - * 2026-09-30): the extractor SUBMITS chains (premises + conclusion); - * the pipeline's internal findings carry follows-EDGES downstream - * (auditor, merger, coverage — all unchanged). Each premise becomes - * one edge into the conclusion — chainsToGraph's edge law, shared - * (one math, one place). Identical (premise-set, conclusion) chains - * dedupe SILENTLY here: the model restating an argument is a no-op, - * never an error (validation errors are the loud channel; see the - * extractor's validate hook). */ -export function chainsToEdges( - chains: ReadonlyArray>, -): Array<{ from: string; to: string; type: "follows" }> { - const seen = new Set(); - const deduped = chains.filter((chain) => { - const key = `${[...chain.premises].sort().join("+")}>${chain.conclusion}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); - return chainsToGraph(deduped).edges; + return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); } /** A chain as one world-facing line — the propositions' NAMES joined diff --git a/src/core/render-settings.ts b/src/core/render-settings.ts index 600b33b..7fa968a 100644 --- a/src/core/render-settings.ts +++ b/src/core/render-settings.ts @@ -54,19 +54,6 @@ export interface GraphRenderSettings { labelZoom: number; /** Node + hover label font size, WORLD units (scales with camera). */ labelSize: number; - /** Draw `supports` edges at all (owner, 2026-09-29): off hides them - * from the canvas — a DRAW toggle only. The physics sim still feels - * them (settings never shift the graph: camera, one frame), and node - * detail cards still list them. */ - showSupports: boolean; - /** Relative SHAFT thickness of supports edges (owner, 2026-09-29): - * a 0–1 multiplier of edgeWidth — default 0.5, the single-line - * relation reads as the lighter one. SHAFT ONLY: arrowheads keep - * full Arrowhead-size geometry (owner pick). Linear, not a ladder — - * it's a ratio; exactly 0 floors to a hairline in the renderer - * (canvas cannot stroke a true 0-width line; hiding is the toggle's - * job). */ - supportsWidth: number; /** Toggle for the cluster region labels (hulls always draw). */ clusterLabels: boolean; /** Cluster label font size, px on screen (camera-compensated — @@ -108,14 +95,6 @@ export function sanitizeRenderSettings(raw: unknown): GraphRenderSettings { arrowLength: legacySizes ? DEFAULT_RENDER_SETTINGS.arrowLength : ladd(r.arrowLength, ARROW_LENGTH_LADDER, DEFAULT_RENDER_SETTINGS.arrowLength), labelZoom: ladd(r.labelZoom, LABEL_ZOOM_LADDER, DEFAULT_RENDER_SETTINGS.labelZoom), labelSize: legacySizes ? DEFAULT_RENDER_SETTINGS.labelSize : ladd(r.labelSize, LABEL_SIZE_LADDER, DEFAULT_RENDER_SETTINGS.labelSize), - showSupports: - typeof r.showSupports === "boolean" ? r.showSupports : DEFAULT_RENDER_SETTINGS.showSupports, - // Linear ratio, not a ladder: real numbers clamp to [0, 1]; anything - // else resets to the default. - supportsWidth: - typeof r.supportsWidth === "number" && Number.isFinite(r.supportsWidth) - ? clamp(r.supportsWidth, 0, 1) - : DEFAULT_RENDER_SETTINGS.supportsWidth, clusterLabels: typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, clusterLabelSize: ladd( @@ -283,8 +262,6 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = { arrowLength: 2, labelZoom: 4, labelSize: 3, - showSupports: true, - supportsWidth: 0.5, clusterLabels: true, clusterLabelSize: 14, regions: DEFAULT_REGION_TUNING, diff --git a/src/server/graph.ts b/src/server/graph.ts index 9dbc015..a86e525 100644 --- a/src/server/graph.ts +++ b/src/server/graph.ts @@ -75,7 +75,7 @@ export function graphRevision(): number { let revision = 0; for (const file of [ LEDGER_FILES.nodes, - LEDGER_FILES.edges, + LEDGER_FILES.chains, LEDGER_FILES.sessions, LEDGER_FILES.coverage, POSITIONS_FILE, diff --git a/src/store/graph.ts b/src/store/graph.ts index 996ac80..7753654 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -1,5 +1,5 @@ /** - * GraphStore — the fs JSONL adapter: four append-only ledgers at + * GraphStore — the fs JSONL adapter: the append-only ledgers at * config.LEDGER_FILES (contracts.ts "v1 emission"). * nodes.jsonl GraphNode — one line per node write: the FIRST * line for an id defines it; a later @@ -7,10 +7,18 @@ * link-append (session phrasings join * aliases, provenance unions); reads * merge by id (core/graph.ts getGraph) - * edges.jsonl GraphEdge — one line per edge ASSERTION (a second - * session asserting the same (from,to,type) - * appends another line; reads merge) + * chains.jsonl GraphChain — one line per chain ASSERTION (a second + * session asserting the same + * (premises, conclusion) appends + * another line; readers merge) * sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt) + * coverage.jsonl CoverageEntry — per-message extraction coverage + * + * CHAINS-PRIMARY (owner, 2026-09-30): there is NO edges ledger — the edge + * view (implication nodes + untyped arrows) is derived in core/graph.ts on + * every read and never persisted. A stale edges.jsonl left by a pre-chains + * build is deleted once, here, at adapter load (same doctrine as the + * events-ledger purge below). * * Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping * (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write @@ -28,7 +36,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts"; import type { - GraphEdge, + GraphChain, GraphNode, ExtractionRunUsage, NotesSource, @@ -94,15 +102,20 @@ const isGraphNode = (v: unknown): v is GraphNode => Array.isArray(v.provenance) && v.provenance.every(isProvenancePoint); -const EDGE_TYPES = ["follows", "supports"] as const; - -const isGraphEdge = (v: unknown): v is GraphEdge => +/** A chain line: premises ≥ 1 (a chain IS an argument — bare propositions + * ride the node ledger alone), every member a string, conclusion a string. + * Endpoint EXISTENCE is the derivation's concern (it drops dangling + * chains); the store only shapes. */ +const isGraphChain = (v: unknown): v is GraphChain => isRecord(v) && - isString(v.from) && - isString(v.to) && - (EDGE_TYPES as readonly string[]).includes(v.type as string) && + isString(v.id) && + Array.isArray(v.premises) && + v.premises.length > 0 && + v.premises.every(isString) && + isString(v.conclusion) && Array.isArray(v.provenance) && - v.provenance.every(isProvenancePoint); + v.provenance.every(isProvenancePoint) && + isString(v.createdAt); const isCheckpoint = (v: unknown): v is SessionCheckpoint => isRecord(v) && isString(v.chatId) && isString(v.foldedAt); @@ -123,12 +136,13 @@ export const graphStore: GraphStore = { return readValid(LEDGER_FILES.nodes, isGraphNode); }, - /** Append one edge ASSERTION; readers merge lines by (from,to,type). */ - appendEdge(assertion: GraphEdge): void { - appendLine(LEDGER_FILES.edges, assertion); + /** Append one chain ASSERTION; readers merge lines by (premises, + * conclusion). */ + appendChain(assertion: GraphChain): void { + appendLine(LEDGER_FILES.chains, assertion); }, - readEdges(): GraphEdge[] { - return readValid(LEDGER_FILES.edges, isGraphEdge); + readChains(): GraphChain[] { + return readValid(LEDGER_FILES.chains, isGraphChain); }, appendCheckpoint(checkpoint: SessionCheckpoint): void { @@ -184,13 +198,13 @@ export const graphStore: GraphStore = { * as "nothing yet", same as never-emitted. */ rewriteLedgers(next: { nodes?: GraphNode[]; - edges?: GraphEdge[]; + chains?: GraphChain[]; coverage?: CoverageEntry[]; checkpoints?: SessionCheckpoint[]; }): void { const files = { nodes: LEDGER_FILES.nodes, - edges: LEDGER_FILES.edges, + chains: LEDGER_FILES.chains, coverage: LEDGER_FILES.coverage, checkpoints: LEDGER_FILES.sessions, } as const; @@ -290,20 +304,21 @@ function writeSourceMap(map: Record): void { fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8"); } -// ─── one-way migration: the events ledger is gone (2026-09-27) ──────────────── -// Quiz outcomes and understanding observations are no longer formalized in -// the graph — the pipeline writes nodes + edges only. A stale events.jsonl -// left by an earlier build is deleted once, here, at adapter load: dead -// ledgers are exactly the confusion the cleanup removes (owner ruling). +// ─── one-way purges at adapter load: dead ledgers are exactly the confusion +// the cleanup removes (owner doctrine — nothing reads them, nothing writes +// them, a stale file lying around is a lie). ──────────────────────────────── { - const legacyEvents = path.join(path.dirname(LEDGER_FILES.nodes), "events.jsonl"); - try { - if (fs.existsSync(legacyEvents)) { - fs.rmSync(legacyEvents); - console.info(`[graph-store] purged legacy events ledger: ${legacyEvents}`); + const graphDir = path.dirname(LEDGER_FILES.nodes); + for (const legacy of ["edges.jsonl", "events.jsonl"]) { + const file = path.join(graphDir, legacy); + try { + if (fs.existsSync(file)) { + fs.rmSync(file); + console.info(`[graph-store] purged legacy ledger: ${file}`); + } + } catch (err) { + console.warn(`[graph-store] could not purge legacy ledger ${file}:`, err); } - } catch (err) { - console.warn(`[graph-store] could not purge legacy events ledger:`, err); } } diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index 6206cf3..50f79aa 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -33,7 +33,7 @@ import type { AuditVerdicts, CoverageEntry, ExtractionRunUsage, - GraphEdge, + GraphChain, GraphNode, NotesSource, @@ -74,7 +74,7 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine { /** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */ function memoryLedgers() { const nodes: GraphNode[] = []; - const edges: GraphEdge[] = []; + const chains: GraphChain[] = []; const checkpoints: SessionCheckpoint[] = []; const coverage: CoverageEntry[] = []; const sources = new Map(); @@ -82,8 +82,8 @@ function memoryLedgers() { return { appendNode: (n: GraphNode) => void nodes.push(n), readNodes: () => nodes.map((n) => ({ ...n })), - appendEdge: (e: GraphEdge) => void edges.push(e), - readEdges: () => edges.map((e) => ({ ...e })), + appendChain: (c: GraphChain) => void chains.push(c), + readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })), appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c), readCheckpoints: () => checkpoints.map((c) => ({ ...c })), // Extraction coverage (vision-extraction-coverage.md) — append-only; @@ -114,7 +114,7 @@ function memoryLedgers() { // store: replace the provided ledgers' full contents. rewriteLedgers: (next) => { if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] }))); - if (next.edges) edges.splice(0, edges.length, ...next.edges.map((e) => ({ ...e, provenance: [...e.provenance] }))); + if (next.chains) chains.splice(0, chains.length, ...next.chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] }))); if (next.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c }))); if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.map((c) => ({ ...c }))); }, @@ -386,7 +386,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) }); const stats = await pipeline.runExtraction(chatId); - assert.deepEqual(stats, { nodes: 2, edges: 1 }); + assert.deepEqual(stats, { nodes: 2, chains: 1 }); }); it("stringified-payload heal: the CHAINS array arriving as ONE JSON string still validates (redesign, 2026-09-30)", async () => { @@ -402,7 +402,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { extractor, auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 }); }); it("runs end to end: findings → audit verdict (identity) → deterministic merge + checkpoint", async () => { @@ -436,7 +436,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { // p1 creates no node, p2 creates one, and the extractor's edge // resolves through them. (The old echo form could silently drop // content by omitting it; that accidental drop channel is gone.) - assert.deepEqual(stats, { nodes: 1, edges: 1 }); + assert.deepEqual(stats, { nodes: 1, chains: 1 }); // Link-append: the phrasing joined the existing node's aliases and // the provenance unioned; p2's create line is its own node. @@ -544,7 +544,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { auditor: submitModel("submit_review", verdict), }); const stats = await pipeline.runExtraction(chatId); - assert.deepEqual(stats, { nodes: 2, edges: 1 }); // full findings merge (verdicts-only) + assert.deepEqual(stats, { nodes: 2, chains: 1 }); // full findings merge (verdicts-only) // Wave 7 (950ddfd): birth-session clusters carry session-name labels, // so the post-merge naming garnish is gone — no model spend, no // naming-run records, no label writes. @@ -609,7 +609,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { }); // The old run-level throw is gone: one failed job is absorbed at // the fan-out — the run resolves with what actually merged (zeros). - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 }); assert.equal(ledgers.readNodes().length, 0); assert.equal(ledgers.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs"); assert.equal(ledgers.readCoverage().length, 0); @@ -620,7 +620,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { extractor: silentModel(), auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await failingExtractor.runExtraction(chatId), { nodes: 0, edges: 0 }); + assert.deepEqual(await failingExtractor.runExtraction(chatId), { nodes: 0, chains: 0 }); assert.equal(ledgers.readCheckpoints().length, 0); // THE isolation pin: a two-batch chat whose SECOND extractor call @@ -642,7 +642,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { engineFor: (role) => role === "extractor" ? fauxEngine([countingStep]) : submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await mixed.runExtraction(chunkyId), { nodes: 2, edges: 1 }, "batch 1's merge survives batch 2's failure"); + assert.deepEqual(await mixed.runExtraction(chunkyId), { nodes: 2, chains: 1 }, "batch 1's merge survives batch 2's failure"); assert.equal(invocations, 2); assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages"); assert.equal( @@ -731,8 +731,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { assert.equal(extractionStatuses().find((r) => r.chatId === a.chatId)?.phase, "auditing", "A still audits while B waits"); gateOpen(); - assert.deepEqual(await runA, { nodes: 1, edges: 0 }); - assert.deepEqual(await runB, { nodes: 1, edges: 0 }); + assert.deepEqual(await runA, { nodes: 1, chains: 0 }); + assert.deepEqual(await runB, { nodes: 1, chains: 0 }); assert.ok(bAuditorCalled); assert.ok(bSawAlphasMerge, "B's auditor saw A's merged node — audit serialized after merge"); assert.equal(extractionStatuses().length, 0, "both runs left the registry"); @@ -774,7 +774,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { // The dock's abort button, pressed while B queues. assert.equal(abortExtraction(b.chatId), true); - assert.deepEqual(await runB, { nodes: 0, edges: 0 }, "aborted before auditing — zero emissions"); + assert.deepEqual(await runB, { nodes: 0, chains: 0 }, "aborted before auditing — zero emissions"); assert.ok(!bAuditorCalled, "B's auditor was never invoked"); assert.ok(!extractionStatuses().some((r) => r.chatId === b.chatId), "B left the registry"); assert.equal(ledgers.readCoverage().some((row) => row.chatId === b.chatId), false, "B checkpointed nothing"); @@ -782,14 +782,14 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { // The chain link B abandoned still resolved: A finishes, and a // THIRD run audits normally — no deadlock residue. gateOpen(); - assert.deepEqual(await runA, { nodes: 1, edges: 0 }); + assert.deepEqual(await runA, { nodes: 1, chains: 0 }); const pipelineC = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")), auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipelineC.runExtraction(c.chatId), { nodes: 1, edges: 0 }); + assert.deepEqual(await pipelineC.runExtraction(c.chatId), { nodes: 1, chains: 0 }); }); it("captures per-stage usage live, persists the run's usage into the chat record", async () => { @@ -862,7 +862,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const stats = await runPromise; // A deliberate stop, not a failure: zero emissions, no throw. - assert.deepEqual(stats, { nodes: 0, edges: 0 }); + assert.deepEqual(stats, { nodes: 0, chains: 0 }); assert.equal(extractionStatuses().length, 0, "the registry clears"); assert.equal(ledgers.readNodes().length, 0, "nothing merged"); assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed — the next trigger re-runs"); @@ -907,13 +907,13 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { const first = pipeline.runExtraction(chatId); const second = await pipeline.runExtraction(chatId); // in-flight → zeros - assert.deepEqual(second, { nodes: 0, edges: 0 }); + assert.deepEqual(second, { nodes: 0, chains: 0 }); release(); // The gated extractor never submits — an ISOLATED job failure now // (per-chunk jobs, owner 2026-09-28): the run resolves with zeros // instead of throwing. - assert.deepEqual(await first, { nodes: 0, edges: 0 }); + assert.deepEqual(await first, { nodes: 0, chains: 0 }); }); it("registers runs on the process-wide registry (route-instance safety): a second pipeline instance sees the same single-flight state", async () => { @@ -959,10 +959,10 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => { ); const g = globalThis as { __lhExtractionRuns?: { running: Map } }; assert.ok(g.__lhExtractionRuns?.running.has(chatId), "the registry lives on globalThis — no module-instance split can hide runs"); - assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, edges: 0 }, "single-flight spans pipeline instances"); + assert.deepEqual(await observer.runExtraction(chatId), { nodes: 0, chains: 0 }, "single-flight spans pipeline instances"); release(); - assert.deepEqual(await first, { nodes: 0, edges: 0 }, "isolated job failure — the run resolves zeros"); + assert.deepEqual(await first, { nodes: 0, chains: 0 }, "isolated job failure — the run resolves zeros"); assert.ok(!extractionStatuses().some((run) => run.chatId === chatId), "the registry entry clears when the run ends (however it ends)"); assert.ok(!g.__lhExtractionRuns?.running.has(chatId), "nothing leaks into the global registry"); }); @@ -1027,7 +1027,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable ]); } - it("co-premises fan out at the seam: ONE chain with two premises becomes TWO follows-edges into the conclusion", async () => { + it("co-premises ride as ONE stored argument — the fan-out to arrows is the derived view, not the store", async () => { const { chatId } = fixtureChat(); const ledgers = memoryLedgers(); const extractor = submitModel("submit_findings", { @@ -1044,16 +1044,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable extractor, auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, edges: 2 }); - // Both edges point INTO the conclusion's resolved node; the two - // from-ends are exactly the two premises (node ids are random — - // assert set-wise, never by ledger order). + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, chains: 1 }); + // The stored chain's premises are exactly the two premises; the + // conclusion is C's resolved node (ids are random — assert + // set-wise, never by ledger order). const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id])); const conclusion = byName.get("C")!; - const edges = ledgers.readEdges(); - assert.ok(edges.every((e) => e.to === conclusion), "both edges point INTO the conclusion"); + const chains = ledgers.readChains(); + assert.ok(chains.every((c) => c.conclusion === conclusion), "chains point INTO the conclusion"); assert.deepEqual( - edges.map((e) => e.from).sort(), + chains.flatMap((c) => c.premises).sort(), [byName.get("A")!, byName.get("B")!].sort(), ); }); @@ -1079,11 +1079,11 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable extractor, auditor: submitModel("submit_review", { links: [] }), }); - // Independent arguments for the same conclusion are separate chains - // → two edges into C; the bare p4 rides the propositions list alone. - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, edges: 2 }); + // Independent arguments for the same conclusion are separate chains; + // the bare p4 rides the propositions list alone. + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, chains: 2 }); const lonely = ledgers.readNodes().find((n) => n.name === "Lonely")!; - assert.ok(ledgers.readEdges().every((e) => e.from !== lonely.id && e.to !== lonely.id)); + assert.ok(ledgers.readChains().every((c) => !c.premises.includes(lonely.id) && c.conclusion !== lonely.id)); }); it("identical chains dedupe SILENTLY at the expansion — restating an argument is a no-op, not an error", async () => { @@ -1099,7 +1099,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable extractor, auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 }); }); it("the OLD wire shape (an edges array, no chains) is dead: validation fails every turn — nothing merges", async () => { @@ -1127,7 +1127,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable graphStore: ledgers, engineFor: (role) => (role === "extractor" ? oldShape() : submitModel("submit_review", { links: [] })), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 }); }); it("validate error-back: a chain referencing an UNKNOWN id is rejected, the model re-submits clean", async () => { @@ -1145,7 +1145,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable }); // The first submit was schema-VALID — only the chain law caught it; // the retry merges in full. - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 }); }); it("validate error-back: a conclusion in its OWN premises is rejected, the model re-submits clean", async () => { @@ -1161,7 +1161,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable extractor: retryEngine(bad, FINDINGS), auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 }); }); it("empty premises never pass: the schema's minItems rejects, the model re-submits clean", async () => { @@ -1177,7 +1177,7 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable extractor: retryEngine(bad, FINDINGS), auditor: submitModel("submit_review", { links: [] }), }); - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, edges: 1 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 2, chains: 1 }); }); it("the system prompt is goal-first: ONE shared core, both preambles assembled onto it", () => { @@ -1226,7 +1226,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { }); const stats = await pipeline.importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); - assert.deepEqual(stats, { nodes: 1, edges: 0 }); + assert.deepEqual(stats, { nodes: 1, chains: 0 }); // The run surfaced as a NOTES run labeled with the file path. assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]); assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends"); @@ -1290,7 +1290,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { // independently (owner, 2026-09-29 — the mappings ledger is gone); // cross-chunk identity is the auditor's linkTo verdict, and these // fixtures submit none. - assert.deepEqual(stats, { nodes: calls, edges: 0 }); + assert.deepEqual(stats, { nodes: calls, chains: 0 }); assert.equal(ledgers.readNodes().length, calls); }); @@ -1319,7 +1319,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { }); // Isolated job failure (per-chunk jobs, owner 2026-09-28): the run // resolves zeros — no throw — and still nothing merged. - assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, edges: 0 }); + assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 0, chains: 0 }); assert.equal(ledgers.readNodes().length, 0); assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable"); }); @@ -1334,13 +1334,13 @@ describe("the notes front door (importNotes — format-agnostic)", () => { auditor: submitModel("submit_review", NOTES_VERDICT), }); const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); - assert.deepEqual(first, { nodes: 1, edges: 0 }); + assert.deepEqual(first, { nodes: 1, chains: 0 }); // Unchanged text: the content fingerprint matches — no extraction // at all (no model calls, no usage record; the mappings ledger // used to give this no-op at merge time by accident). const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); - assert.deepEqual(second, { nodes: 0, edges: 0 }, "unchanged content is a true no-op"); + assert.deepEqual(second, { nodes: 0, chains: 0 }, "unchanged content is a true no-op"); assert.equal(ledgers.readSources().length, 1, "one source identity — the path hashes to the same id"); assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "only the real run billed its usage"); @@ -1350,7 +1350,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { // whole file re-runs (fresh merge; the auditor's links would dedupe // identity on a real model). const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` }); - assert.deepEqual(third, { nodes: 1, edges: 0 }, "changed content re-extracts"); + assert.deepEqual(third, { nodes: 1, chains: 0 }, "changed content re-extracts"); assert.equal(ledgers.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage"); assert.equal(ledgers.readNodes().length, 2); }); @@ -1367,7 +1367,7 @@ describe("the notes front door (importNotes — format-agnostic)", () => { const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId; assert.equal(abortExtraction(srcId), true); - assert.deepEqual(await runPromise, { nodes: 0, edges: 0 }); + assert.deepEqual(await runPromise, { nodes: 0, chains: 0 }); assert.equal(ledgers.readNodes().length, 0, "nothing merged"); assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed"); assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted"); @@ -1462,7 +1462,7 @@ describe("the chunked pipeline (extraction coverage)", () => { // SEPARATE job's namespace (owner, 2026-09-29 — no mapping table) // — its propositions merge again as fresh nodes. Cross-batch // identity is the auditor's linkTo verdict, never the merger's. - assert.deepEqual(stats, { nodes: 4, edges: 2 }); + assert.deepEqual(stats, { nodes: 4, chains: 2 }); // Per-batch coverage: every message checkpointed, nothing legacy. const coverage = ledgers.readCoverage(); @@ -1474,7 +1474,7 @@ describe("the chunked pipeline (extraction coverage)", () => { // new usage entry (no money spent on covered ground). const before = views.length; const again = await pipeline.runExtraction(chatId); - assert.deepEqual(again, { nodes: 0, edges: 0 }); + assert.deepEqual(again, { nodes: 0, chains: 0 }); assert.equal(views.length, before); // PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job // (two), and the no-op run adds none. @@ -1521,7 +1521,7 @@ describe("the chunked pipeline (extraction coverage)", () => { const stats = await runPromise; // Batch 1's emissions are kept and its messages stay covered. - assert.deepEqual(stats, { nodes: 2, edges: 1 }); + assert.deepEqual(stats, { nodes: 2, chains: 1 }); assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages"); const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId); assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered"); @@ -1654,7 +1654,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => { // SOURCE-WIDE abort: every chunk job of the fan-out dies together. assert.equal(abortExtraction(srcId), true); - assert.deepEqual(await runPromise, { nodes: 0, edges: 0 }); + assert.deepEqual(await runPromise, { nodes: 0, chains: 0 }); assert.equal(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry"); assert.equal(ledgers.readNodes().length, 0, "nothing merged"); // Per-job usage: all 8 chunk jobs persisted their (failed) spend. @@ -1706,7 +1706,7 @@ describe("per-chunk parallel jobs (owner, 2026-09-28)", () => { // Identical findings in every chunk, one merge PER chunk (jobs are // independent — owner, 2026-09-29): 8 chunks × 2 propositions, // 8 × 1 edge. The auditor's links, not the merger, dedupe identity. - assert.deepEqual(stats, { nodes: 16, edges: 8 }); + assert.deepEqual(stats, { nodes: 16, chains: 8 }); assert.equal(ledgers.readNodes().length, 16); }); }); @@ -1730,12 +1730,12 @@ describe("the session-deletion guard (chat deleted mid-run)", () => { // emissions (stats present but empty — nothing merged). assert.ok(stats); assert.equal(stats.nodes, 0); - assert.equal(stats.edges, 0); + assert.equal(stats.chains, 0); // Nothing merged, nothing checkpointed — the deleted chat leaves no // trace for a later snapshot to resurrect. assert.equal(ledgers.readNodes().length, 0); - assert.equal(ledgers.readEdges().length, 0); + assert.equal(ledgers.readChains().length, 0); assert.equal(ledgers.readCoverage().length, 0); assert.equal(ledgers.readCheckpoints().length, 0); diff --git a/tests/graph-tools.test.ts b/tests/graph-tools.test.ts index 40dd19b..4eff7f4 100644 --- a/tests/graph-tools.test.ts +++ b/tests/graph-tools.test.ts @@ -15,7 +15,7 @@ import { createProberTools } from "../src/agent/tools.ts"; import { auditorGraphTools } from "../src/agent/tools/graph.ts"; import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts"; import type { ChatSummary } from "../src/core/contracts/chat.ts"; -import type { GraphEdge, GraphNode } from "../src/core/contracts/graph.ts"; +import type { GraphChain, GraphNode } from "../src/core/contracts/graph.ts"; // ─── fixtures ──────────────────────────────────────────────────────────────── @@ -39,10 +39,10 @@ function fakeStores( chats?: Array>; } = {}, ): { chatStore: ChatStore; graphStore: GraphStore } { - const edges: GraphEdge[] = []; + const chains: GraphChain[] = []; const graphStore: GraphStore = { readNodes: () => nodes, - readEdges: () => edges, + readChains: () => chains, readCheckpoints: () => [], readCoverage: () => [], readSources: () => [], diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 31f2301..01b2f2b 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -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); diff --git a/tests/layout.test.ts b/tests/layout.test.ts index a3fe029..6c3294e 100644 --- a/tests/layout.test.ts +++ b/tests/layout.test.ts @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts"; import { settleGraphLayout } from "../src/core/layout-settle.ts"; -import type { GraphNode, GraphEdge, Position } from "../src/core/contracts.ts"; +import type { GraphChain, GraphNode, Position } from "../src/core/contracts.ts"; const node = (id: string): GraphNode => ({ id, @@ -18,7 +18,9 @@ const node = (id: string): GraphNode => ({ createdAt: "2026-09-13T00:00:00.000Z", provenance: [], }); -const edge = (from: string, to: string): GraphEdge => ({ from, to, type: "supports", provenance: [] }); +const chain = (id: string, premises: string[], conclusion: string): GraphChain => ({ id, premises, conclusion, provenance: [], createdAt: "2026-09-13T00:00:00.000Z" }); +/** incrementalLayout's link shape — the DERIVED untyped arrow. */ +const edge = (from: string, to: string): { from: string; to: string } => ({ from, to }); const mean = (positions: Map, ids: string[]): { x: number; y: number } => ({ x: ids.reduce((s, id) => s + positions.get(id)!.x, 0) / ids.length, @@ -107,20 +109,20 @@ describe("settleGraphLayout", () => { const lines = [node("a"), node("b"), node("a")]; // "a" appended twice const store = { readNodes: () => lines.map((l) => ({ ...l })), - readEdges: () => [edge("a", "b")], + readChains: () => [chain("arg_ab", ["a"], "b")], readPositions: () => ({} as Record), writePositions: (p: Record) => void written.push(p), }; settleGraphLayout(store); assert.equal(written.length, 1); - assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "b"], "one position per UNIQUE node"); + assert.deepEqual(Object.keys(written[0]!).sort(), ["a", "arg_ab", "b"], "one position per unique node AND the derived implication node"); }); it("does nothing on an empty graph", () => { const written: Record[] = []; settleGraphLayout({ readNodes: () => [], - readEdges: () => [], + readChains: () => [], readPositions: () => ({} as Record), writePositions: (p) => void written.push(p), }); diff --git a/tests/practice-pipeline.test.ts b/tests/practice-pipeline.test.ts index 09aff23..805b005 100644 --- a/tests/practice-pipeline.test.ts +++ b/tests/practice-pipeline.test.ts @@ -27,18 +27,18 @@ import { type RunningPractice, } from "../src/agent/subagents/practice-pipeline.ts"; import { chainCoverage } from "../src/core/practice.ts"; -import type { GraphEdge, GraphNode, ProblemSet } from "../src/core/contracts.ts"; +import type { GraphChain, GraphNode, ProblemSet } from "../src/core/contracts.ts"; import type { ChatStore, GraphStore, PracticeStore } from "../src/core/ports.ts"; import { ModelsNotConfiguredError } from "../src/core/settings.ts"; // ─── in-memory stores ──────────────────────────────────────────────────────── -function memoryGraph(nodes: GraphNode[], edges: GraphEdge[]): GraphStore { +function memoryGraph(nodes: GraphNode[], chains: GraphChain[]): GraphStore { return { appendNode: () => {}, readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })), - appendEdge: () => {}, - readEdges: () => edges.map((e) => ({ ...e, provenance: [...e.provenance] })), + appendChain: () => {}, + readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })), appendCheckpoint: () => {}, readCheckpoints: () => [], appendCoverage: () => {}, @@ -284,15 +284,16 @@ function diamondGraph(): GraphStore { createdAt: NOW, provenance: pt("chat_1"), }); - const edge = (from: string, to: string, type: "follows" | "supports" = "follows"): GraphEdge => ({ - from, - to, - type, + const chain = (from: string, to: string): GraphChain => ({ + id: to, // fixture id = conclusion id (the form the generator prompt renders) + premises: [from], + conclusion: to, provenance: pt("chat_1"), + createdAt: NOW, }); return memoryGraph( [node("a", "A"), node("b", "B"), node("c", "C"), node("d", "D"), node("e", "E")], - [edge("a", "b"), edge("a", "c"), edge("b", "d"), edge("c", "d")], + [chain("a", "b"), chain("a", "c"), { id: "d", premises: ["b", "c"], conclusion: "d", provenance: pt("chat_1"), createdAt: NOW }], ); } @@ -372,7 +373,7 @@ describe("createProblemSet (generation)", () => { assert.match(set.id, /^ps_/); assert.equal(set.status, "partitioning"); - assert.equal(set.title, "Practice — 2026-09-29"); // nodeIds path: dated fallback title + assert.equal(set.title, `Practice — ${new Date().toISOString().slice(0, 10)}`); // nodeIds path: dated fallback title // Stubs appear the moment the partition lands — numbered, generating. await until(() => f.practice.store.getSet(set.id)?.status === "generating"); diff --git a/tests/practice.test.ts b/tests/practice.test.ts index 0b1658d..e519408 100644 --- a/tests/practice.test.ts +++ b/tests/practice.test.ts @@ -1,115 +1,107 @@ /** - * PRACTICE-MODULE CORE (slice 1, 2026-09-29 plan): the chain bijection — - * chainsForSelection (group by conclusion, FOLLOWS ONLY — supports are - * invisible to practice, owner ruling) / chainsToGraph (the exact inverse, - * round-trip pinned) — and chainCoverage (the set's completeness law: - * union of the problems' chains must equal the set's chains). + * PRACTICE-MODULE CORE: chainsForSelection over the STORED chains + * (chains-primary, owner 2026-09-30: a chain rides along iff every member + * is selected; nodes in no surviving chain are singletons) and + * chainCoverage (the set's completeness law: union of the problems' chains + * must equal the set's chains). */ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import type { EdgeType } from "../src/core/contracts.ts"; -import { chainCoverage, chainsForSelection, chainsToEdges, chainsToGraph, formatChainLine } from "../src/core/practice.ts"; +import type { GraphChain } from "../src/core/contracts.ts"; +import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts"; // ─── fixtures ──────────────────────────────────────────────────────────────── const n = (id: string) => ({ id }); -const e = (from: string, to: string, type: EdgeType = "follows") => ({ from, to, type }); +const arg = (id: string, premises: string[], conclusion: string): GraphChain => ({ + id, + premises, + conclusion, + provenance: [], + createdAt: "2026-09-30T00:00:00.000Z", +}); -/** The plan's canonical shape: diamond a→b, a→c, b→d, c→d. */ -const diamond = { - nodes: [n("a"), n("b"), n("c"), n("d")], - edges: [e("a", "b"), e("a", "c"), e("b", "d"), e("c", "d")], -}; -const diamondChains = [ - { id: "b", premises: ["a"], conclusion: "b" }, - { id: "c", premises: ["a"], conclusion: "c" }, - { id: "d", premises: ["b", "c"], conclusion: "d" }, -]; +/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored + * arguments (each its own arg_ id, conclusion ids may repeat). */ +const diamondNodes = [n("a"), n("b"), n("c"), n("d")]; +const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")]; // ─── chainsForSelection ────────────────────────────────────────────────────── describe("chainsForSelection", () => { - it("groups by conclusion: the diamond a→b, a→c, b→d, c→d is three chains", () => { - assert.deepEqual(chainsForSelection(diamond.nodes, diamond.edges), diamondChains); + it("keeps every fully-selected stored argument, each under its own id", () => { + assert.deepEqual(chainsForSelection(diamondNodes, diamondArgs), [ + { id: "arg_b", premises: ["a"], conclusion: "b" }, + { id: "arg_c", premises: ["a"], conclusion: "c" }, + { id: "arg_d", premises: ["b", "c"], conclusion: "d" }, + ]); }); - it("a premise-only node is NOT its own chain — it lives inside the chains it feeds", () => { - const ids = chainsForSelection(diamond.nodes, diamond.edges).map((c) => c.id); + it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => { + const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id); assert.ok(!ids.includes("a")); // a is only ever a premise }); it("an isolated node becomes a singleton chain", () => { - const chains = chainsForSelection([...diamond.nodes, n("z")], diamond.edges); + const chains = chainsForSelection([...diamondNodes, n("z")], diamondArgs); assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" }); }); - it("supports edges are NO edges — nodes joined only by supports are singletons", () => { - const chains = chainsForSelection([n("x"), n("y")], [e("x", "y", "supports")]); + it("two independent arguments for one conclusion stay DISTINCT chains", () => { + // The bijection problem that killed edge-grouping (owner, 2026-09-30): + // A∧B⇒E and C∧D⇒E must never union into A∧B∧C∧D⇒E. + const chains = chainsForSelection( + [n("a"), n("b"), n("c"), n("d"), n("e")], + [arg("arg_e1", ["a", "b"], "e"), arg("arg_e2", ["c", "d"], "e")], + ); assert.deepEqual(chains, [ - { id: "x", premises: [], conclusion: "x" }, - { id: "y", premises: [], conclusion: "y" }, + { id: "arg_e1", premises: ["a", "b"], conclusion: "e" }, + { id: "arg_e2", premises: ["c", "d"], conclusion: "e" }, ]); }); - it("an edge with an endpoint outside the selection is excluded entirely", () => { - // a→out would chain a; out is not selected, so the edge is gone and + it("an argument with ANY member outside the selection is excluded entirely", () => { + // arg_ab needs b; b is not selected, so the argument is gone and // a (nothing else left) falls back to a singleton. - const chains = chainsForSelection([n("a")], [e("a", "out"), e("in", "a")]); + const chains = chainsForSelection([n("a")], [arg("arg_ab", ["a"], "b"), arg("arg_ia", ["in"], "a")]); assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]); }); - it("duplicate edge assertions collapse into one premise", () => { - const chains = chainsForSelection([n("a"), n("b")], [e("a", "b"), e("a", "b")]); - assert.deepEqual(chains, [{ id: "b", premises: ["a"], conclusion: "b" }]); + it("a chain referencing unknown node ids simply never applies", () => { + const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]); + assert.deepEqual(chains, [ + { id: "a", premises: [], conclusion: "a" }, + { id: "b", premises: [], conclusion: "b" }, + ]); }); - it("is deterministic — chains and premises sorted by id", () => { + it("is deterministic — chains sorted by id, premises sorted", () => { const shuffled = chainsForSelection( [n("d"), n("c"), n("b"), n("a")], - [e("c", "d"), e("a", "c"), e("a", "b"), e("b", "d")], + [arg("arg_d", ["c", "b"], "d"), arg("arg_c", ["a"], "c"), arg("arg_b", ["a"], "b")], ); - assert.deepEqual(shuffled, diamondChains); - }); -}); - -// ─── chainsToGraph — the bijection ─────────────────────────────────────────── - -describe("chainsToGraph (the inverse)", () => { - it("reproduces exactly the selection's follows-edges (both ends selected)", () => { - const supportsNoise = e("x", "y", "supports"); - const outside = e("a", "out"); - const { edges } = chainsToGraph(chainsForSelection(diamond.nodes, [...diamond.edges, supportsNoise, outside])); - assert.deepEqual(edges, [...diamond.edges]); - }); - - it("round-trips a canonical chain list — singletons survive only for edge-less nodes", () => { - // NOTE: the bijection is over chainsForSelection's IMAGE — lists where a - // singleton's node never also appears as a premise/conclusion elsewhere. - // (A "singleton + premise elsewhere" list is degenerate: not something - // the translation ever produces.) - const canonical = [ - { id: "p1", premises: ["a", "b"], conclusion: "p1" }, - { id: "p9", premises: [], conclusion: "p9" }, - { id: "c1", premises: ["p1"], conclusion: "c1" }, - ]; - const nodeIds = [...new Set(canonical.flatMap((c) => [c.conclusion, ...c.premises]))]; - const roundTripped = chainsForSelection(nodeIds.map(n), chainsToGraph(canonical).edges); - assert.deepEqual(roundTripped, [ - { id: "c1", premises: ["p1"], conclusion: "c1" }, - { id: "p1", premises: ["a", "b"], conclusion: "p1" }, - { id: "p9", premises: [], conclusion: "p9" }, + assert.deepEqual(shuffled, [ + { id: "arg_b", premises: ["a"], conclusion: "b" }, + { id: "arg_c", premises: ["a"], conclusion: "c" }, + { id: "arg_d", premises: ["b", "c"], conclusion: "d" }, ]); }); }); // ─── chainCoverage ─────────────────────────────────────────────────────────── +const diamondChains = [ + { id: "arg_b", premises: ["a"], conclusion: "b" }, + { id: "arg_c", premises: ["a"], conclusion: "c" }, + { id: "arg_d", premises: ["b", "c"], conclusion: "d" }, +]; + describe("chainCoverage", () => { it("complete cover: every chain exercised, no missing", () => { const set = { chains: diamondChains, - problems: [{ chains: ["b", "c"] }, { chains: ["d"] }], + problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }], }; assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); }); @@ -117,23 +109,23 @@ describe("chainCoverage", () => { it("missing: a chain no problem exercises", () => { const set = { chains: diamondChains, - problems: [{ chains: ["d"] }], + problems: [{ chains: ["arg_d"] }], }; - assert.deepEqual(chainCoverage(set), { missing: ["b", "c"], duplicated: [] }); + assert.deepEqual(chainCoverage(set), { missing: ["arg_b", "arg_c"], duplicated: [] }); }); it("duplicated is informational — a chain exercised twice is allowed", () => { const set = { chains: diamondChains, - problems: [{ chains: ["b", "c", "d"] }, { chains: ["b", "d"] }], + problems: [{ chains: ["arg_b", "arg_c", "arg_d"] }, { chains: ["arg_b", "arg_d"] }], }; - assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["b", "d"] }); + assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["arg_b", "arg_d"] }); }); it("problems claiming chain ids outside the set neither cover nor fail anything", () => { const set = { - chains: [{ id: "b" }] as Array<{ id: string }>, - problems: [{ chains: ["b", "ghost"] }], + chains: [{ id: "arg_b" }] as Array<{ id: string }>, + problems: [{ chains: ["arg_b", "ghost"] }], }; assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); }); @@ -159,38 +151,3 @@ describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () => assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion"); }); }); - -// ─── chainsToEdges — the extraction seam's expansion (2026-09-30) ──────────── - -describe("chainsToEdges (the extractor redesign's translation seam)", () => { - it("each premise becomes ONE follows-edge into the conclusion", () => { - const edges = chainsToEdges([{ premises: ["p1", "p2"], conclusion: "c1" }]); - assert.deepEqual(edges, [ - { from: "p1", to: "c1", type: "follows" }, - { from: "p2", to: "c1", type: "follows" }, - ]); - }); - - it("chains sharing a conclusion union their premises; the result stays sorted", () => { - const edges = chainsToEdges([ - { premises: ["p2"], conclusion: "c1" }, - { premises: ["p1"], conclusion: "c1" }, - ]); - assert.deepEqual(edges, [ - { from: "p1", to: "c1", type: "follows" }, - { from: "p2", to: "c1", type: "follows" }, - ]); - }); - - it("identical (premise-set, conclusion) chains dedupe silently — restating is a no-op", () => { - const chain = { premises: ["p1", "p2"], conclusion: "c1" }; - assert.deepEqual(chainsToEdges([chain, chain, { premises: ["p2", "p1"], conclusion: "c1" }]), [ - { from: "p1", to: "c1", type: "follows" }, - { from: "p2", to: "c1", type: "follows" }, - ]); - }); - - it("empty in, empty out — bare propositions ride the propositions list alone", () => { - assert.deepEqual(chainsToEdges([]), []); - }); -}); diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx index e93e64e..05883aa 100644 --- a/tests/render-chain-table.test.tsx +++ b/tests/render-chain-table.test.tsx @@ -29,7 +29,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import { ChainTableBody } from "../components/chain-table.tsx"; import type { ChainSort } from "../components/chain-table.tsx"; -import type { GraphEdge, GraphNode } from "../src/core/contracts.ts"; +import type { GraphChain, GraphNode } from "../src/core/contracts.ts"; /** The fixture graph — two follows-chains + one standalone: * a → c1 ← b (chain one, conclusion born chat1), d → e (chain two, @@ -58,15 +58,14 @@ const NODES: GraphNode[] = [ node("orphan", "Orphan", "Statement O.", null), ]; -const EDGES: GraphEdge[] = [ - { from: "a", to: "c1", type: "follows" }, - { from: "b", to: "c1", type: "follows" }, - { from: "d", to: "e", type: "follows" }, -] as GraphEdge[]; +const CHAINS: GraphChain[] = [ + { id: "c1", premises: ["a", "b"], conclusion: "c1", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" }, + { id: "e", premises: ["d"], conclusion: "e", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" }, +]; const BASE = { nodes: NODES, - edges: EDGES, + chains: CHAINS, sessions: { chat1: "Ethics lesson", chat2: "Second chat" }, fileSources: { notes1: "notes/raw/ethics.md" }, query: "", diff --git a/tests/render-settings.test.ts b/tests/render-settings.test.ts index a01ee74..5249014 100644 --- a/tests/render-settings.test.ts +++ b/tests/render-settings.test.ts @@ -118,8 +118,6 @@ describe("sanitizeRenderSettings", () => { arrowLength: 3, labelZoom: 2, labelSize: 4, - showSupports: false, - supportsWidth: 0.3, clusterLabels: false, clusterLabelSize: 20, regions: DEFAULT_REGION_TUNING, @@ -128,27 +126,13 @@ describe("sanitizeRenderSettings", () => { assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member }); - test("showSupports (2026-09-29): boolean-only, default on — the draw toggle", () => { - // A render-only hide for supports edges: anything but a real boolean - // (missing key, legacy file, garbage) loads as ON (today's look); a - // real false round-trips untouched. - assert.equal(sanitizeRenderSettings({}).showSupports, true); - assert.equal(sanitizeRenderSettings({ showSupports: false }).showSupports, false); - assert.equal(sanitizeRenderSettings({ showSupports: "no" }).showSupports, true); - assert.equal(sanitizeRenderSettings({ showSupports: 0 }).showSupports, true); - }); - - test("supportsWidth (2026-09-29): linear 0–1 ratio of edgeWidth, default 0.5", () => { - // The supports shaft-thickness multiplier: LINEAR (not a ladder — - // it's a ratio), real numbers clamp to [0, 1], anything else resets - // to the default. The 0 end floors to a hairline in the renderer. - assert.equal(sanitizeRenderSettings({}).supportsWidth, 0.5); - assert.equal(sanitizeRenderSettings({ supportsWidth: 0 }).supportsWidth, 0); - assert.equal(sanitizeRenderSettings({ supportsWidth: 1 }).supportsWidth, 1); - assert.equal(sanitizeRenderSettings({ supportsWidth: 0.37 }).supportsWidth, 0.37); - assert.equal(sanitizeRenderSettings({ supportsWidth: 1.7 }).supportsWidth, 1); - assert.equal(sanitizeRenderSettings({ supportsWidth: -0.3 }).supportsWidth, 0); - assert.equal(sanitizeRenderSettings({ supportsWidth: "thick" }).supportsWidth, 0.5); + test("supports settings are GONE (chains-primary, 2026-09-30): stale keys silently drop", () => { + // Supports edges were retired; showSupports/supportsWidth no longer + // exist. A legacy file carrying them loads without the keys — the + // sanitizer drops unknown fields by construction. + const cleaned = sanitizeRenderSettings({ showSupports: false, supportsWidth: 0.9 } as never); + assert.equal("showSupports" in cleaned, false); + assert.equal("supportsWidth" in cleaned, false); }); test("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => { diff --git a/tests/session-clusters.test.ts b/tests/session-clusters.test.ts index 26f00b0..01e0a60 100644 --- a/tests/session-clusters.test.ts +++ b/tests/session-clusters.test.ts @@ -11,7 +11,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import type { GraphEdge, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts"; +import type { GraphChain, GraphNode, NotesSource, ChatSummary } from "../src/core/contracts.ts"; import type { ChatStore, GraphStore } from "../src/core/ports.ts"; import { graphSessionIndex, @@ -40,10 +40,10 @@ function fakeStores( sources?: Array>; } = {}, ): GraphDeps { - const edges: GraphEdge[] = []; + const chains: GraphChain[] = []; const graphStore: GraphStore = { readNodes: () => nodes, - readEdges: () => edges, + readChains: () => chains, readCheckpoints: () => [], readCoverage: () => [], readSources: () => diff --git a/tests/usage.test.ts b/tests/usage.test.ts index a2ae994..353def8 100644 --- a/tests/usage.test.ts +++ b/tests/usage.test.ts @@ -244,7 +244,7 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl startedAt, at: "2026-09-09T00:00:00Z", durationMs: 42_000, - stats: { nodes: 1, edges: 2 }, + stats: { nodes: 1, chains: 2 }, stages: { extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] }, @@ -281,7 +281,7 @@ test("appendSourceUsage: appends to the source record, idempotent per startedAt, startedAt, at: "2026-09-13T00:00:00Z", durationMs: 42_000, - stats: { nodes: 1, edges: 2 }, + stats: { nodes: 1, chains: 2 }, stages: { extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },