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

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

- contracts: GraphChain (arg_-minted id, premises, conclusion) replaces
  typed edges; GraphEdge survives only as the untyped derived arrow;
  GraphSnapshot carries stored chains; supports edges retired entirely
- store: chains.jsonl ledger (edges.jsonl purged at load alongside legacy
  events.jsonl); readChains/appendChain seam; merge dedupes by
  (sorted premises, conclusion), re-assertions append under the same arg_ id
- core: deriveView mints implication nodes (name ⟹, statement via
  formatChainLine) plus premise→imp→conclusion arrows; clusters stay
  proposition-only (birthSessionPartition); chainsForSelection now groups
  STORED chains (chain rides iff every member selected) — independent
  arguments for one conclusion never union; chainsToGraph/chainsToEdges die
- extraction: extractor submits chains natively (expandFindings deleted);
  auditor receives propositions only; supports fan-out gone
- deletion: full death of a proposition kills every referencing chain;
  arg_-prefixed node deletes kill that chain; chainsRemoved stats
- UI: every arrow renders the ⟹ double shaft; supports toggle + thickness
  slider and detail-card edge sections deleted (card keeps name +
  proposition); chain table + practice pipeline read stored chains
- tests: 1188 green across the new seams
This commit is contained in:
amosblomqvist
2026-09-30 03:22:52 +03:00
parent a3aabb3a57
commit 4985b6f9fa
28 changed files with 669 additions and 726 deletions
+27 -30
View File
@@ -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<typeof auditSchema>): 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>[]): 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<EmissionStats> {
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<EmissionStats> {
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 });
+5 -4
View File
@@ -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 };
+61 -28
View File
@@ -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<string, string>;
/** srcId → file path, for every IMPORTED NOTES file that touched the
+95 -59
View File
@@ -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<string>();
@@ -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,
};
}
+109 -45
View File
@@ -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<string>, 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<string>();
for (const line of ledgers.readEdges()) {
const key = `${line.from}\u0000${line.to}\u0000${line.type}`;
const idOfKey = new Map<string, string>();
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<GraphStore, "readNodes" | "readChains">): DerivedGraph {
// Merge node lines by id (first line defines; later lines add phrasings).
const byId = new Map<string, GraphNode>();
for (const line of graphStore.readNodes()) {
@@ -254,29 +283,68 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
}
}
const merged: GraphEdge[] = [];
const byKey = new Map<string, GraphEdge>();
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<string, GraphChain>();
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<string, { name: string }> = {};
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<string, Set<string>>();
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,
+17 -17
View File
@@ -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<string, Position>;
writePositions(positions: Record<string, Position>): 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<string>();
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));
}
}
+12 -13
View File
@@ -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;
+26 -74
View File
@@ -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<Presentation, { name: string; definition: str
application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
};
/** The selection's follows-edges as chains: every edge into a node joins
* that node's (one) chain; a node with no in- AND no out-follows-edge in
* the selection is a singleton chain. Premise-only nodes are NOT separate
* chains — they are covered inside the chains they feed. Deterministic:
* chains sorted by id, premises sorted. */
/** The stored chains filtered to a selection (chains-primary, owner
* 2026-09-30): a chain rides along iff its conclusion AND every premise
* are selected; a node in no surviving chain is a singleton chain
* (premises: []). Premise-only nodes whose chain was excluded are
* singletons — the same inclusion law as the pre-chains edge grouping.
* Every stored chain keeps its OWN id (arg_…) — two independent
* arguments for one conclusion stay distinct. Deterministic: chains
* sorted by id, premises sorted. */
export function chainsForSelection(
nodes: ReadonlyArray<Pick<GraphNode, "id">>,
edges: ReadonlyArray<Pick<GraphEdge, "from" | "to" | "type">>,
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
): ReasoningChain[] {
const selected = new Set(nodes.map((n) => n.id));
const premisesByConclusion = new Map<string, Set<string>>();
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<string>();
premises.add(edge.from);
premisesByConclusion.set(edge.to, premises);
}
const chained = new Set<string>();
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<Pick<ReasoningChain, "premises" | "conclusion">>,
): {
nodeIds: string[];
edges: Array<{ from: string; to: string; type: "follows" }>;
} {
const nodeIds = new Set<string>();
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<Pick<ReasoningChain, "premises" | "conclusion">>,
): Array<{ from: string; to: string; type: "follows" }> {
const seen = new Set<string>();
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
-23
View File
@@ -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,
+1 -1
View File
@@ -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,
+46 -31
View File
@@ -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<string, NotesSource>): 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);
}
}