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
+10 -10
View File
@@ -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<string, string>;
fileSources: Record<string, string>;
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<string, string>;
fileSources: Record<string, string>;
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({
>
<ChainTableBody
nodes={nodes}
edges={edges}
chains={storedChains}
sessions={sessions}
fileSources={fileSources}
query={query}
+8 -24
View File
@@ -65,7 +65,6 @@ import {
sanitizeRegionTuning,
type PhysicsTuning,
type RegionTuning,
type EdgeType,
INCREMENTAL_GRAVITY,
LINK_DISTANCE,
type DepthAnchorMode,
@@ -86,14 +85,13 @@ export interface CanvasNode {
label: string;
}
/** One directed edge (follows | supports); arrow points prerequisite → dependent. */
/** One directed edge of the DERIVED view (chains-primary, 2026-09-30):
* premise → implication node → conclusion, UNTYPED — every arrow is an
* implication, so every arrow renders the double-shaft glyph (⟹) +
* arrowhead (DECIDED 2026-09-14, docs/vision-edge-glyph.md). */
export interface CanvasEdge {
from: string;
to: string;
/** Relation type: `follows` renders the double-shaft glyph (⟹),
* `supports` the plain single line (DECIDED 2026-09-14,
* docs/vision-edge-glyph.md). */
type: EdgeType;
}
/** One cluster region: member node ids + the LLM-coined label (or null). */
@@ -623,10 +621,6 @@ export class GraphCanvasRenderer {
private builtVersion = -1; // version the cache was built at
private nodesPath: Path2D | null = null;
private shaftsPath: Path2D = new Path2D();
/** Supports shafts draw THINNER (owner, 2026-09-29): their own path +
* stroke pass at edgeWidth × supportsWidth. Arrowheads share the one
* heads path at full geometry (owner pick: shaft only). */
private supportsShaftsPath: Path2D = new Path2D();
private headsPath: Path2D = new Path2D();
private regionCanvas: HTMLCanvasElement | null = null;
/** World rect the bitmap covers (drawImage target coords). */
@@ -1237,32 +1231,26 @@ export class GraphCanvasRenderer {
// relation types draw the same arrow for now — the double-shaft
// glyph was rejected (2026-09-15); differentiation is pending a new
// approach. Two owner rulings 2026-09-29: the supports draw toggle
// (off skips the paths entirely — a draw-only hide; the sim still
// feels them) and the supports SHAFT thickness split (supports get
// their own path stroked at edgeWidth × supportsWidth; heads stay
// full-geometry in the one shared path).
// CHAINS-PRIMARY (2026-09-30): one path family — every derived arrow
// is an implication, double-shafted + arrowheaded uniformly.
const arrowLen = settings.arrowLength;
const shafts = new Path2D();
const supportsShafts = new Path2D();
const heads = new Path2D();
for (const e of data.edges) {
if (!settings.showSupports && e.type === "supports") continue;
if (!isVisible(e.from) || !isVisible(e.to)) continue;
const a = this.nodeById.get(e.from);
const b = this.nodeById.get(e.to);
if (!a || !b) continue;
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
if (!g) continue;
const path = e.type === "supports" ? supportsShafts : shafts;
path.moveTo(g.shaftFrom.x, g.shaftFrom.y);
path.lineTo(g.shaftTo.x, g.shaftTo.y);
shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
heads.moveTo(g.tip.x, g.tip.y);
heads.lineTo(g.left.x, g.left.y);
heads.lineTo(g.right.x, g.right.y);
heads.closePath();
}
this.shaftsPath = shafts;
this.supportsShaftsPath = supportsShafts;
this.headsPath = heads;
// Cluster regions are NOT built here: they rasterize into their own
@@ -1639,10 +1627,6 @@ export class GraphCanvasRenderer {
ctx.lineWidth = settings.edgeWidth / k;
ctx.lineCap = "round";
ctx.stroke(this.shaftsPath);
if (settings.showSupports) {
ctx.lineWidth = Math.max(settings.edgeWidth * settings.supportsWidth, 0.5) / k;
ctx.stroke(this.supportsShaftsPath);
}
ctx.fill(this.headsPath);
}
+6 -83
View File
@@ -51,10 +51,9 @@
*
* Rendering settings (2026-09-13): the gear in the search panel reveals
* five sliders (node size, edge width, arrowhead length, label size, the
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides
* supports edges from the canvas — draw-only; physics and detail cards
* still see them) with its shaft-thickness slider (0–1 × Edge width,
* default 0.5; heads stay full), and the cluster-labels checkbox. SERVER-persisted
* zoom threshold), and the cluster-labels checkbox. (The supports-edge
* draw toggle + thickness slider died with supports edges themselves —
* chains-primary, 2026-09-30.) SERVER-persisted
* (data/render-settings.json via /api/graph/render-settings): loaded
* once after mount, saved debounced on every user change — one source
* of truth shared by every browser. The label-zoom slider is log-scaled
@@ -532,36 +531,7 @@ function GraphSettingsControl({
onChange={(p) =>
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. */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Supports edges</span>
<input
type="checkbox"
checked={settings.showSupports}
onChange={(e) =>
changeSettings((cur) => ({ ...cur, showSupports: e.target.checked }))}
className="accent-primary"
/>
</label>
{/* 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). */}
<SettingSlider
label="Supports thickness"
disabled={!settings.showSupports}
min={0}
max={1}
step={0.1}
value={settings.supportsWidth}
display={`${Math.round(settings.supportsWidth * 100) / 100}×`}
onChange={(v) => 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() {
>
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{(() => {
// 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 (
<div className="mt-3 flex flex-col gap-1">
{groups.map((g) => (
<div key={g.title} className="flex flex-col gap-0.5">
<div className="text-xs text-muted-foreground">{g.title}</div>
<div className="flex flex-wrap gap-1">
{g.names.map((name, i) => (
<span key={`${name}-${i}`} className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
{name}
</span>
))}
</div>
</div>
))}
</div>
);
})()}
{selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
@@ -1734,7 +1657,7 @@ export function GraphView() {
<ChainTableOverlay
open={chainsOpen && !isEmpty}
nodes={snapshot?.nodes ?? []}
edges={snapshot?.edges ?? []}
chains={snapshot?.chains ?? []}
sessions={snapshot?.sessions ?? {}}
fileSources={snapshot?.fileSources ?? {}}
onClose={() => setChainsOpen(false)}
+3 -2
View File
@@ -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
+2 -2
View File
@@ -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`,
);
}
+5 -5
View File
@@ -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`,
);
}
+24 -11
View File
@@ -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<string>();
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.
+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);
}
}
+53 -53
View File
@@ -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<string, NotesSource>();
@@ -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<string, unknown> } };
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);
+3 -3
View File
@@ -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<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
} = {},
): { chatStore: ChatStore; graphStore: GraphStore } {
const edges: GraphEdge[] = [];
const chains: GraphChain[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readChains: () => chains,
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () => [],
+56 -51
View File
@@ -243,11 +243,10 @@ function fixtureFindings(): SessionFindings {
aliases: [],
},
],
edges: [
chains: [
{
from: "p1",
to: "p2",
type: "follows",
premises: ["p1"],
conclusion: "p2",
},
],
};
@@ -274,14 +273,14 @@ function scopedGraph(chatIds: string[]) {
const graph = getGraph(graphStore);
return {
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))),
edges: graph.edges.filter((e) => e.provenance.some((pt) => chatIds.includes(pt.chatId))),
chains: graph.chains.filter((c) => c.provenance.some((pt) => chatIds.includes(pt.chatId))),
};
}
describe("the merger (mergeFindings)", () => {
it("creates nodes from the verdicts with chat-level provenance", () => {
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id));
assert.deepEqual(stats, { nodes: 3, edges: 1 });
assert.deepEqual(stats, { nodes: 3, chains: 1 });
const graph = scopedGraph([chat1.id]);
assert.equal(graph.nodes.length, 3);
@@ -298,13 +297,13 @@ describe("the merger (mergeFindings)", () => {
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
// The follows edge, provenance attached.
assert.equal(graph.edges.length, 1);
const edge = graph.edges[0]!;
assert.equal(edge.from, idByStatement.get("Alpha intuition"));
assert.equal(edge.to, idByStatement.get("Beta rule"));
assert.equal(edge.type, "follows");
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]);
// The stored chain, provenance attached.
assert.equal(graph.chains.length, 1);
const chain = graph.chains[0]!;
assert.match(chain.id, /^arg_/, "store-minted argument id");
assert.deepEqual(chain.premises, [idByStatement.get("Alpha intuition")]);
assert.equal(chain.conclusion, idByStatement.get("Beta rule"));
assert.deepEqual(chain.provenance, [{ chatId: chat1.id, messageId: "" }]);
});
@@ -321,15 +320,15 @@ describe("the merger (mergeFindings)", () => {
{ id: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
{ id: "p2", name: "Rep two", statement: "Independence two statement", aliases: [] },
],
edges: [{ from: "p1", to: "p2", type: "follows" }],
chains: [{ premises: ["p1"], conclusion: "p2" }],
};
const first = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
assert.deepEqual(first, { nodes: 2, edges: 1 });
assert.deepEqual(first, { nodes: 2, chains: 1 });
const second = mergeFindings(rep.id, findings, { links: [] }, graphDeps, sessionSource(rep.id));
assert.deepEqual(second, { nodes: 2, edges: 1 }, "nothing remembered — everything re-creates");
assert.deepEqual(second, { nodes: 2, chains: 1 }, "nothing remembered — everything re-creates");
const graph = scopedGraph([rep.id]);
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes");
assert.equal(graph.edges.length, 2, "fresh endpoints — fresh edge assertions");
assert.equal(graph.chains.length, 2, "fresh endpoints — fresh chain assertions");
// exactly one checkpoint line per merge, though — checkpoints are a log
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2);
});
@@ -341,10 +340,10 @@ describe("the merger (mergeFindings)", () => {
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
{ id: "p1", name: "Dup", statement: "Dup statement", aliases: [] },
],
edges: [],
chains: [],
};
const stats = mergeFindings(dup.id, findings, { links: [] }, graphDeps, sessionSource(dup.id));
assert.deepEqual(stats, { nodes: 1, edges: 0 }, "the duplicate id is skipped — first resolution stands");
assert.deepEqual(stats, { nodes: 1, chains: 0 }, "the duplicate id is skipped — first resolution stands");
});
it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
@@ -373,7 +372,7 @@ describe("the merger (mergeFindings)", () => {
aliases: ["alpha intuition"],
},
],
edges: [],
chains: [],
},
// VERDICTS-ONLY: the identity call is a link entry, not a field
// on the proposition (owner, 2026-09-28).
@@ -381,7 +380,7 @@ describe("the merger (mergeFindings)", () => {
graphDeps,
sessionSource(chat2.id),
);
assert.deepEqual(stats, { nodes: 0, edges: 0 });
assert.deepEqual(stats, { nodes: 0, chains: 0 });
const after = scopedGraph([chat1.id, chat2.id]);
assert.equal(after.nodes.length, 3, "no duplicate node");
@@ -410,7 +409,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
{ links: [] },
graphDeps,
@@ -439,10 +438,9 @@ describe("the merger (mergeFindings)", () => {
statement,
aliases: [],
})),
edges: [
{ from: "p1", to: "p2", type: "follows", },
{ from: "p2", to: "p3", type: "follows", },
{ from: "p1", to: "p3", type: "follows", },
chains: [
{ premises: ["p1"], conclusion: "p2", },
{ premises: ["p2"], conclusion: "p3", },
],
},
{ links: [] },
@@ -494,7 +492,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
// empty verdicts — identity is the auditor's call alone
{ links: [] },
@@ -519,13 +517,13 @@ describe("the merger (mergeFindings)", () => {
aliases: [],
},
],
edges: [],
chains: [],
},
{ links: [] },
graphDeps,
{ foldedAt: "2026-09-13T00:00:00.000Z" },
);
assert.deepEqual(stats, { nodes: 1, edges: 0 });
assert.deepEqual(stats, { nodes: 1, chains: 0 });
const node = getGraph(graphStore).nodes.find((n) => n.statement.startsWith("Vim starts"))!;
assert.deepEqual(node.provenance, [{ chatId: "src_notesfixture", messageId: "" }], "one chat-level point — the source key, no message anchors");
@@ -617,7 +615,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo feel"], },
],
edges: [{ from: "p1", to: "p2", type: "follows", }],
chains: [{ premises: ["p1"], conclusion: "p2", }],
};
mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
// Resolve through THIS session's provenance — the shared ledger
@@ -642,7 +640,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"] },
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], },
],
edges: [{ from: "q1", to: "q2", type: "follows", }],
chains: [{ premises: ["q1"], conclusion: "q2", }],
};
mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
const zId = bornOf(dB.id, "B extra statement");
@@ -665,7 +663,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
const stats = deleteChatTraces(graphStore, dA.id, "delete-nodes");
assert.deepEqual(stats, {
nodesRemoved: 1,
edgesRemoved: 1,
chainsRemoved: 1,
coverageRemoved: 2,
checkpointsRemoved: 1,
} satisfies ChatDeletionStats);
@@ -684,9 +682,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
// Z (dB-only) is untouched, byte-identical.
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore);
// Edges: dA's assertion X→Y died; dB's X→Z survived.
// Chains: dA's argument X⇒Y died (Y died); dB's X⇒Z survived.
const scoped = scopedGraph([dA.id, dB.id]);
assert.deepEqual(scoped.edges, [{ from: xId, to: zId, type: "follows", provenance: [{ chatId: dB.id, messageId: "" }] }]);
assert.equal(scoped.chains.length, 1);
assert.deepEqual(scoped.chains[0]!.premises, [xId]);
assert.equal(scoped.chains[0]!.conclusion, zId);
assert.deepEqual(scoped.chains[0]!.provenance, [{ chatId: dB.id, messageId: "" }]);
// Positions: the orphan pruned.
const positions = graphStore.readPositions();
assert.equal(positions[yId], undefined);
@@ -703,25 +704,25 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
graphStore.appendNode({ id: "n_del_append_probe", name: "Probe", statement: "Probe statement", aliases: [], createdAt: "2026-09-23T00:04:00.000Z", provenance: [{ chatId: "chat_foreign_probe", messageId: "" }] });
assert.equal(graphStore.readNodes().length, nodesBefore + 1);
// Idempotent: a second run finds nothing.
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, edgesRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
assert.deepEqual(deleteChatTraces(graphStore, dA.id, "delete-nodes"), { ...stats, nodesRemoved: 0, chainsRemoved: 0, coverageRemoved: 0, checkpointsRemoved: 0 } satisfies ChatDeletionStats);
});
it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
const before = {
nodes: graphStore.readNodes(),
edges: graphStore.readEdges(),
chains: graphStore.readChains(),
positions: graphStore.readPositions(),
};
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
assert.deepEqual(stats, {
nodesRemoved: 0,
edgesRemoved: 0,
chainsRemoved: 0,
coverageRemoved: 2,
checkpointsRemoved: 1,
} satisfies ChatDeletionStats);
assert.deepEqual(graphStore.readNodes(), before.nodes);
assert.deepEqual(graphStore.readEdges(), before.edges);
assert.deepEqual(graphStore.readChains(), before.chains);
assert.deepEqual(graphStore.readPositions(), before.positions);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true);
@@ -769,12 +770,12 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
/** The full-death scenario: node X is born in a notes file (EMPTY
* provenance — a legacy line; the mappings anchor is gone, owner
* 2026-09-29), re-encountered by a chat (link line: alias +
* provenance), and asserted in edges by two sessions. */
* provenance), and asserted in chains by two sessions. */
function buildNodeScenario() {
// Clean slate per test: each test in this describe builds its own
// scenario from empty ledgers (the suite's isolation covers the
// whole file; this covers describe-internal stacking).
graphStore.rewriteLedgers({ nodes: [], edges: [] });
graphStore.rewriteLedgers({ nodes: [], chains: [] });
graphStore.writePositions({});
graphStore.appendNode({
id: "n_dead",
@@ -802,18 +803,20 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
createdAt: "2026-09-23T00:00:00.000Z",
provenance: [{ chatId: "chat_node_a", messageId: "m2" }],
});
graphStore.appendEdge({ from: "n_dead", to: "n_keep", type: "follows", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// Resting positions for both.
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } });
graphStore.appendChain({ id: "arg_dead_keep", premises: ["n_dead"], conclusion: "n_keep", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_a", messageId: "m1" }] });
graphStore.appendChain({ id: "arg_keep_dead", premises: ["n_keep"], conclusion: "n_dead", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// A FOREIGN chain the deletion must not touch (destructive rewriting
// may not exceed the blast radius — foreign data rides untouched).
graphStore.appendChain({ id: "arg_keep_other", premises: ["n_keep"], conclusion: "n_other", createdAt: "2026-09-23T00:00:00.000Z", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] });
// Resting positions for both nodes and both dying chains' imp nodes.
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 }, arg_dead_keep: { x: 1.5, y: 1.5 }, arg_keep_dead: { x: 2.5, y: 2.5 } });
}
it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
buildNodeScenario();
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
const stats = deleteNodeTraces(graphStore, "n_dead");
assert.deepEqual(stats, { edgesRemoved: 2 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { chainsRemoved: 2 } satisfies NodeDeletionStats);
// The node's EVERY line is gone; the neighbor is byte-identical
// (full death is node-scoped — other nodes' sessions keep their
@@ -824,15 +827,17 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
keepLineBefore,
"the untouched node is byte-identical",
);
// Only the edge to nowhere rode along; the foreign edge X→Z stays.
// Only the chains referencing X died; the foreign chain stays.
assert.deepEqual(
graphStore.readEdges().map((e) => [e.from, e.to]),
[["n_keep", "n_other"]],
graphStore.readChains().map((c) => [c.premises, c.conclusion]),
[[["n_keep"], "n_other"]],
);
// The resting position died with the node.
const positions = graphStore.readPositions();
assert.equal(positions["n_dead"], undefined);
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 });
assert.equal(positions["arg_dead_keep"], undefined, "the dying chains' imp-node positions die too");
assert.equal(positions["arg_keep_dead"], undefined);
// Atomic rewrites left no temp files.
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
// Idempotent: a second run finds no node — null.
@@ -849,7 +854,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
});
const stats = deleteNodeTraces(graphStore, "n_solo");
assert.deepEqual(stats, { edgesRemoved: 0 } satisfies NodeDeletionStats);
assert.deepEqual(stats, { chainsRemoved: 0 } satisfies NodeDeletionStats);
assert.equal(graphStore.readNodes().some((n) => n.id === "n_solo"), false);
});
@@ -899,7 +904,7 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
const revisionBefore = graphRevision();
const outcome = deleteGraphNode("n_orch");
assert.ok(outcome.ok);
assert.deepEqual(outcome.stats, { edgesRemoved: 0 });
assert.deepEqual(outcome.stats, { chainsRemoved: 0 });
assert.ok(outcome.graphRevision > 0);
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false);
+7 -5
View File
@@ -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<string, Position>, 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<string, Position>),
writePositions: (p: Record<string, Position>) => 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<string, Position>[] = [];
settleGraphLayout({
readNodes: () => [],
readEdges: () => [],
readChains: () => [],
readPositions: () => ({} as Record<string, Position>),
writePositions: (p) => void written.push(p),
});
+11 -10
View File
@@ -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");
+64 -107
View File
@@ -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([]), []);
});
});
+6 -7
View File
@@ -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: "",
+7 -23
View File
@@ -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", () => {
+3 -3
View File
@@ -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<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
} = {},
): GraphDeps {
const edges: GraphEdge[] = [];
const chains: GraphChain[] = [];
const graphStore: GraphStore = {
readNodes: () => nodes,
readEdges: () => edges,
readChains: () => chains,
readCheckpoints: () => [],
readCoverage: () => [],
readSources: () =>
+2 -2
View File
@@ -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 })] },