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, XIcon,
} from "lucide-react"; } from "lucide-react";
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core"; 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 { TopBar } from "@/components/top-bar";
import { PracticeDialog } from "@/components/practice-dialog"; import { PracticeDialog } from "@/components/practice-dialog";
import { DeleteNodeDialog } from "@/components/delete-node-dialog"; import { DeleteNodeDialog } from "@/components/delete-node-dialog";
@@ -99,7 +99,7 @@ interface ClusterGroup {
* via props; the overlay wrapper below owns it. */ * via props; the overlay wrapper below owns it. */
export function ChainTableBody({ export function ChainTableBody({
nodes, nodes,
edges, chains: storedChains,
sessions, sessions,
fileSources, fileSources,
query, query,
@@ -118,7 +118,7 @@ export function ChainTableBody({
onClose, onClose,
}: { }: {
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; chains: GraphChain[];
sessions: Record<string, string>; sessions: Record<string, string>;
fileSources: Record<string, string>; fileSources: Record<string, string>;
query: string; query: string;
@@ -140,7 +140,7 @@ export function ChainTableBody({
onClose: () => void; onClose: () => void;
}) { }) {
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); 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() !== ""; const searching = query.trim() !== "";
// The fuzzy filter: a chain is visible when ANY member matches on // 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). // The derived shape under the floating bar (the agents' ACTUAL input).
const derived = useMemo(() => { const derived = useMemo(() => {
const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined); const selectedNodes = [...selection].map((id) => byId.get(id)).filter((n): n is GraphNode => n !== undefined);
return chainsForSelection(selectedNodes, edges); return chainsForSelection(selectedNodes, storedChains);
}, [selection, byId, edges]); }, [selection, byId, storedChains]);
const nameOf = (id: string) => byId.get(id)?.name ?? id; const nameOf = (id: string) => byId.get(id)?.name ?? id;
const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? ""; const dateOf = (id: string) => byId.get(id)?.createdAt.slice(0, 10) ?? "";
@@ -583,14 +583,14 @@ function RowCheckbox({
export function ChainTableOverlay({ export function ChainTableOverlay({
open, open,
nodes, nodes,
edges, chains: storedChains,
sessions, sessions,
fileSources, fileSources,
onClose, onClose,
}: { }: {
open: boolean; open: boolean;
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; chains: GraphChain[];
sessions: Record<string, string>; sessions: Record<string, string>;
fileSources: Record<string, string>; fileSources: Record<string, string>;
onClose: () => void; onClose: () => void;
@@ -618,7 +618,7 @@ export function ChainTableOverlay({
const byId = new Map(nodes.map((n) => [n.id, n])); 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 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 nodeIds = [...selection].sort();
const toggleNodes = (ids: string[], next: boolean) => const toggleNodes = (ids: string[], next: boolean) =>
@@ -645,7 +645,7 @@ export function ChainTableOverlay({
> >
<ChainTableBody <ChainTableBody
nodes={nodes} nodes={nodes}
edges={edges} chains={storedChains}
sessions={sessions} sessions={sessions}
fileSources={fileSources} fileSources={fileSources}
query={query} query={query}
+8 -24
View File
@@ -65,7 +65,6 @@ import {
sanitizeRegionTuning, sanitizeRegionTuning,
type PhysicsTuning, type PhysicsTuning,
type RegionTuning, type RegionTuning,
type EdgeType,
INCREMENTAL_GRAVITY, INCREMENTAL_GRAVITY,
LINK_DISTANCE, LINK_DISTANCE,
type DepthAnchorMode, type DepthAnchorMode,
@@ -86,14 +85,13 @@ export interface CanvasNode {
label: string; 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 { export interface CanvasEdge {
from: string; from: string;
to: 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). */ /** 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 builtVersion = -1; // version the cache was built at
private nodesPath: Path2D | null = null; private nodesPath: Path2D | null = null;
private shaftsPath: Path2D = new Path2D(); 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 headsPath: Path2D = new Path2D();
private regionCanvas: HTMLCanvasElement | null = null; private regionCanvas: HTMLCanvasElement | null = null;
/** World rect the bitmap covers (drawImage target coords). */ /** 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 // relation types draw the same arrow for now — the double-shaft
// glyph was rejected (2026-09-15); differentiation is pending a new // glyph was rejected (2026-09-15); differentiation is pending a new
// approach. Two owner rulings 2026-09-29: the supports draw toggle // approach. Two owner rulings 2026-09-29: the supports draw toggle
// (off skips the paths entirely — a draw-only hide; the sim still // CHAINS-PRIMARY (2026-09-30): one path family — every derived arrow
// feels them) and the supports SHAFT thickness split (supports get // is an implication, double-shafted + arrowheaded uniformly.
// their own path stroked at edgeWidth × supportsWidth; heads stay
// full-geometry in the one shared path).
const arrowLen = settings.arrowLength; const arrowLen = settings.arrowLength;
const shafts = new Path2D(); const shafts = new Path2D();
const supportsShafts = new Path2D();
const heads = new Path2D(); const heads = new Path2D();
for (const e of data.edges) { for (const e of data.edges) {
if (!settings.showSupports && e.type === "supports") continue;
if (!isVisible(e.from) || !isVisible(e.to)) continue; if (!isVisible(e.from) || !isVisible(e.to)) continue;
const a = this.nodeById.get(e.from); const a = this.nodeById.get(e.from);
const b = this.nodeById.get(e.to); const b = this.nodeById.get(e.to);
if (!a || !b) continue; if (!a || !b) continue;
const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen); const g = arrowGeometry(a, b, nodeR, nodeR, arrowLen);
if (!g) continue; if (!g) continue;
const path = e.type === "supports" ? supportsShafts : shafts; shafts.moveTo(g.shaftFrom.x, g.shaftFrom.y);
path.moveTo(g.shaftFrom.x, g.shaftFrom.y); shafts.lineTo(g.shaftTo.x, g.shaftTo.y);
path.lineTo(g.shaftTo.x, g.shaftTo.y);
heads.moveTo(g.tip.x, g.tip.y); heads.moveTo(g.tip.x, g.tip.y);
heads.lineTo(g.left.x, g.left.y); heads.lineTo(g.left.x, g.left.y);
heads.lineTo(g.right.x, g.right.y); heads.lineTo(g.right.x, g.right.y);
heads.closePath(); heads.closePath();
} }
this.shaftsPath = shafts; this.shaftsPath = shafts;
this.supportsShaftsPath = supportsShafts;
this.headsPath = heads; this.headsPath = heads;
// Cluster regions are NOT built here: they rasterize into their own // Cluster regions are NOT built here: they rasterize into their own
@@ -1639,10 +1627,6 @@ export class GraphCanvasRenderer {
ctx.lineWidth = settings.edgeWidth / k; ctx.lineWidth = settings.edgeWidth / k;
ctx.lineCap = "round"; ctx.lineCap = "round";
ctx.stroke(this.shaftsPath); 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); ctx.fill(this.headsPath);
} }
+6 -83
View File
@@ -51,10 +51,9 @@
* *
* Rendering settings (2026-09-13): the gear in the search panel reveals * Rendering settings (2026-09-13): the gear in the search panel reveals
* five sliders (node size, edge width, arrowhead length, label size, the * five sliders (node size, edge width, arrowhead length, label size, the
* zoom threshold), the supports-edges draw toggle (2026-09-29: off hides * zoom threshold), and the cluster-labels checkbox. (The supports-edge
* supports edges from the canvas — draw-only; physics and detail cards * draw toggle + thickness slider died with supports edges themselves —
* still see them) with its shaft-thickness slider (0–1 × Edge width, * chains-primary, 2026-09-30.) SERVER-persisted
* default 0.5; heads stay full), and the cluster-labels checkbox. SERVER-persisted
* (data/render-settings.json via /api/graph/render-settings): loaded * (data/render-settings.json via /api/graph/render-settings): loaded
* once after mount, saved debounced on every user change — one source * once after mount, saved debounced on every user change — one source
* of truth shared by every browser. The label-zoom slider is log-scaled * of truth shared by every browser. The label-zoom slider is log-scaled
@@ -532,36 +531,7 @@ function GraphSettingsControl({
onChange={(p) => onChange={(p) =>
changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))} changeSettings((cur) => ({ ...cur, labelZoom: fromLadderPosition(p, LABEL_ZOOM_LADDER) }))}
/> />
{/* Supports draw toggle (owner, 2026-09-29): hides the single-line {/* Regions: the blurred cluster tints. Sliders + a fixed-set
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
dropdown for the bitmap resolution (it's a discrete dropdown for the bitmap resolution (it's a discrete
choice, not a free number). Server-persisted like the choice, not a free number). Server-persisted like the
sizes; every range end was sweep-validated on the real sizes; every range end was sweep-validated on the real
@@ -1411,7 +1381,7 @@ export function GraphView() {
y: positions.get(n.id)?.y ?? 0, y: positions.get(n.id)?.y ?? 0,
label: n.name, 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 })), clusters: snapshot.clusters.map((c) => ({ id: c.id, label: c.label, members: c.members })),
colors: { colors: {
node: tokenHex(NODE_COLOR_TOKEN), 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="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> <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 ? ( {selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1"> <div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div> <div className="text-xs text-muted-foreground">Sessions</div>
@@ -1734,7 +1657,7 @@ export function GraphView() {
<ChainTableOverlay <ChainTableOverlay
open={chainsOpen && !isEmpty} open={chainsOpen && !isEmpty}
nodes={snapshot?.nodes ?? []} nodes={snapshot?.nodes ?? []}
edges={snapshot?.edges ?? []} chains={snapshot?.chains ?? []}
sessions={snapshot?.sessions ?? {}} sessions={snapshot?.sessions ?? {}}
fileSources={snapshot?.fileSources ?? {}} fileSources={snapshot?.fileSources ?? {}}
onClose={() => setChainsOpen(false)} 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 * 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 = { export const LEDGER_FILES = {
nodes: path.join(PATHS.graph, "nodes.jsonl"), 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"), sessions: path.join(PATHS.graph, "sessions.jsonl"),
/** Per-message extraction coverage (contracts/graph.ts CoverageEntry; /** Per-message extraction coverage (contracts/graph.ts CoverageEntry;
* the checkpoint's replacement for chat extractions — notes keep * 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); const stats = await pipeline.runExtraction(chat.id);
console.log( console.log(
`[backfill] ${chat.id} (${chat.title.slice(0, 40)}): ` + `[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) { } catch (err) {
console.error(`[backfill] ${chat.id} FAILED:`, err); console.error(`[backfill] ${chat.id} FAILED:`, err);
@@ -35,7 +35,7 @@ async function main() {
} }
const graph = getGraph(graphStore); const graph = getGraph(graphStore);
console.log( 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, getChunkTokens: () => effectiveSettings().graph.extractionChunkTokens,
}); });
const queue = [...files]; 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 () => { const workers = Array.from({ length: Math.min(limit, files.length) }, async () => {
for (;;) { for (;;) {
const file = queue.shift(); const file = queue.shift();
@@ -43,9 +43,9 @@ async function main() {
try { try {
const stats = await pipeline.importNotes({ filePath, text: fs.readFileSync(filePath, "utf-8") }); const stats = await pipeline.importNotes({ filePath, text: fs.readFileSync(filePath, "utf-8") });
totals.nodes += stats.nodes; totals.nodes += stats.nodes;
totals.edges += stats.edges; totals.chains += stats.chains ?? 0;
console.log( 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) { } catch (err) {
totals.failed++; totals.failed++;
@@ -57,8 +57,8 @@ async function main() {
const graph = getGraph(stores.graphStore); const graph = getGraph(stores.graphStore);
console.log( console.log(
`[import-notes] done: +${totals.nodes} nodes, +${totals.edges} edges, ${totals.failed} failed — ` + `[import-notes] done: +${totals.nodes} nodes, +${totals.chains} chains, ${totals.failed} failed — ` +
`graph now ${graph.nodes.length} nodes, ${graph.edges.length} edges`, `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"; depthArg === "--depth=vertical" ? "vertical" : depthArg === "--depth=radial" ? "radial" : "off";
import { incrementalLayout } from "../src/core/layout.ts"; import { incrementalLayout } from "../src/core/layout.ts";
import { birthSessionPartition } from "../src/core/session-clusters.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"; import type { GraphNode, Position } from "../src/core/contracts.ts";
function main() { function main() {
const t0 = Date.now(); const t0 = Date.now();
// Unique nodes (lines may repeat an id — first defines). // CHAINS-PRIMARY (2026-09-30): the stored truth is nodes + chains; the
const seen = new Set<string>(); // replay lays out the DERIVED view (implication nodes included, untyped
const nodes: GraphNode[] = []; // arrows) exactly like the real settle.
for (const line of graphStore.readNodes()) { const graph = getGraph(graphStore);
if (seen.has(line.id)) continue; const nodes = graph.nodes; // getGraph already merges lines by id
seen.add(line.id); const view = deriveView(graph);
nodes.push(line); const edges = view.edges;
} const viewNodeById = new Map(view.nodes.map((n) => [n.id, n]));
const edges = graphStore.readEdges(); 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 // The cluster source (wave 7): the birth-session partition, derived on
// read — same as the real settle (core/layout-settle.ts). // read — same as the real settle (core/layout-settle.ts).
const birthClusters = birthSessionPartition(nodes); const birthClusters = birthSessionPartition(nodes);
@@ -58,7 +66,7 @@ function main() {
const byId = new Map(nodes.map((n) => [n.id, n])); const byId = new Map(nodes.map((n) => [n.id, n]));
const unmappedFallback = sources[sources.length - 1]?.id ?? ""; 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 maxOldShift = 0;
let shiftReport = ""; let shiftReport = "";
for (const source of sources) { for (const source of sources) {
@@ -74,13 +82,18 @@ function main() {
} }
} }
for (const n of batch) delivered.add(n.id); 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 // Edges activate once BOTH endpoints exist (a later file can claim
// one endpoint — the edge lights up then). // one endpoint — the edge lights up then).
const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to)); const activeEdges = edges.filter((e) => delivered.has(e.from) && delivered.has(e.to));
const before = new Map(positions); 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, { positions = incrementalLayout(batchNodes, activeEdges, positions, {
// The same partition the real settle would see for this batch: // The same partition the real settle would see for this batch:
// the birth-session clusters, filtered to delivered nodes. // 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 { settleGraphLayout } from "../../core/layout-settle.ts";
import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts"; import { batchRuns, coverageOf, messageViewLines } from "../../core/coverage.ts";
import { teacherProseBlocks } 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 type { LhMessage } from "../../core/native-messages.ts";
import { auditorGraphTools } from "../tools/graph.ts"; import { auditorGraphTools } from "../tools/graph.ts";
import { runSubmitAgent } from "./submit-agent.ts"; import { runSubmitAgent } from "./submit-agent.ts";
@@ -266,17 +265,15 @@ function validateFindingsChains(value: FindingsSubmit): string | null {
return null; return null;
} }
/** The translation seam (extractor redesign, 2026-09-30): the wire /** (chains-primary, owner 2026-09-30) the wire IS the pipeline shape
* speaks CHAINS, the pipeline speaks follows-EDGES — SessionFindings * now: propositions + chains, no translation — the stores keep the
* and everything downstream (auditor, resolution, merge, coverage, * chains and the derived edge view is recomputed on read. The fill
* store) is UNCHANGED. One math, one place: chainsToEdges sits next * already ran inside runSubmitAgent; this only re-asserts the
* to chainsToGraph in core/practice.ts. */ * aliases-filled type. */
function expandFindings(raw: FindingsSubmit): SessionFindings { const asSessionFindings = (raw: FindingsSubmit): SessionFindings => ({
return { propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })),
propositions: raw.propositions.map((p) => ({ ...p, aliases: p.aliases ?? [] })), chains: raw.chains,
edges: chainsToEdges(raw.chains), });
};
}
function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts { function fillAudit(raw: Static<typeof auditSchema>): AuditVerdicts {
return { return {
@@ -346,7 +343,7 @@ function runExtractor(
onStep, onStep,
neverSubmitted: "the extractor never submitted findings", neverSubmitted: "the extractor never submitted findings",
prepareArguments: healStringifiedArrays, prepareArguments: healStringifiedArrays,
}).then(expandFindings); }).then(asSessionFindings);
} }
/** The extractor over ONE source of raw personal notes (the format-agnostic /** The extractor over ONE source of raw personal notes (the format-agnostic
@@ -371,7 +368,7 @@ function runNotesExtractor(
onStep, onStep,
neverSubmitted: "the extractor never submitted findings", neverSubmitted: "the extractor never submitted findings",
prepareArguments: healStringifiedArrays, prepareArguments: healStringifiedArrays,
}).then(expandFindings); }).then(asSessionFindings);
} }
// ─── the auditor ─────────────────────────────────────────────────────────── // ─── 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 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 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. 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 + 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 propositions — different claims about the world — stay apart. You are NOT guarding against wrong merges; you are
hunting for right ones. hunting for right ones.
Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions or the extractor's edges — Your deliverable is ONLY the identity verdicts: "links". NEVER restate the propositions —
the merger already has them. You NEVER create edges — relations between the new propositions come from the 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 extractor alone, and the batch connects to the existing graph ONLY through the equivalences you identify. Your
entire deliverable is ONE submit_review call. 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 signal?.throwIfAborted(); // an abort during the extract→audit gap ends here
const graph = getGraph(graphStore); const graph = getGraph(graphStore);
console.info( 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)`, `${graph.nodes.length} graph nodes)`,
); );
const t0 = performance.now(); const t0 = performance.now();
@@ -758,8 +755,8 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// exploration tools instead of an injected dump. // exploration tools instead of an injected dump.
const auditResult = await runAuditor( const auditResult = await runAuditor(
stageEngine("auditor", deps.auditor), stageEngine("auditor", deps.auditor),
`Proposed findings from the extractor (the source itself is not available to you — the extractor is fully trusted):\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(findings, null, 2)}\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.`, `The current knowledge graph has ${graph.nodes.length} node(s). Explore it with your tools and link what links.`,
signal, signal,
(usage, model, step, finishReason, tools) => { (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 * per job and never throw — the source's run returns what actually
* merged. */ * merged. */
function aggregateJobs(sourceId: string, settled: PromiseSettledResult<EmissionStats>[]): EmissionStats { 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 aborted = false;
let failed = 0; let failed = 0;
for (const result of settled) { for (const result of settled) {
if (result.status === "fulfilled") { if (result.status === "fulfilled") {
stats.nodes += result.value.nodes; stats.nodes += result.value.nodes;
stats.edges += result.value.edges; stats.chains! += result.value.chains ?? 0;
continue; continue;
} }
const err = result.reason; const err = result.reason;
@@ -874,7 +871,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
} }
async function runExtraction(chatId: string): Promise<EmissionStats> { 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); inFlight.add(chatId);
const t0 = performance.now(); const t0 = performance.now();
// COVERAGE (vision-extraction-coverage.md, 2026-09-15): extract ONLY // 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); const covered = coverageOf(record.messages, graphStore.readCoverage(), chatId);
if (covered.uncovered.length === 0) { if (covered.uncovered.length === 0) {
console.info(`[extraction ${chatId}] nothing uncovered — no-op`); 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; const chunkTokens = deps.getChunkTokens?.() ?? FACTORY_SETTINGS.graph.extractionChunkTokens;
// The batcher's one rule (owner, Wave D): a tool-call message and // 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). // next snapshot fetch must already reflect the new nodes).
settleGraphLayout(graphStore); settleGraphLayout(graphStore);
console.info( console.info(
`[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.edges} edges`, `[extraction ${jobKey}] merged: ${batchStats.nodes} nodes, ${batchStats.chains ?? 0} chains`,
); );
return batchStats; return batchStats;
}); });
@@ -969,7 +966,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
); );
const stats = aggregateJobs(chatId, settled); const stats = aggregateJobs(chatId, settled);
console.info( 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))`, `(${Math.round(performance.now() - t0)}ms total, ${batches.length} batch job(s))`,
); );
return stats; 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> { async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> {
const { id } = input; const { id } = input;
if (inFlight.has(id)) return { nodes: 0, edges: 0 }; if (inFlight.has(id)) return { nodes: 0, chains: 0 };
inFlight.add(id); inFlight.add(id);
const t0 = performance.now(); const t0 = performance.now();
try { try {
@@ -1098,7 +1095,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// The layout ledger evolves with each merge — the next // The layout ledger evolves with each merge — the next
// snapshot fetch reflects every finished chunk. // snapshot fetch reflects every finished chunk.
settleGraphLayout(graphStore); 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; return merged;
}); });
}); });
@@ -1106,7 +1103,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
); );
const stats = aggregateJobs(id, settled); const stats = aggregateJobs(id, settled);
console.info( 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))`, `(${Math.round(performance.now() - t0)}ms total, ${chunks.length} chunk job(s))`,
); );
return stats; 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); const contentFp = crypto.createHash("sha256").update(input.text).digest("hex").slice(0, 16);
if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) { if (graphStore.readSources().find((s) => s.id === id)?.contentFp === contentFp) {
console.info(`[extraction ${id}] re-import with unchanged content (fp match) — skipping`); 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(); const importedAt = new Date().toISOString();
graphStore.upsertSource({ id, filePath: input.filePath, importedAt, contentFp }); 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 { GraphStore, PracticeStore } from "../../core/ports.ts";
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts"; import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
import { chainsForSelection } from "../../core/practice.ts"; import { chainsForSelection } from "../../core/practice.ts";
import { getGraph } from "../../core/graph.ts";
import { import {
createPracticeAgents, createPracticeAgents,
type PracticeModels, type PracticeModels,
@@ -444,10 +445,10 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
const wanted = new Set(nodeIds); const wanted = new Set(nodeIds);
const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id)); const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
const selectedIds = new Set(selected.map((n) => n.id)); const selectedIds = new Set(selected.map((n) => n.id));
const edges = deps // The STORED chains (chains-primary, owner 2026-09-30): chainsForSelection
.graphStore.readEdges() // keeps a chain iff every member is selected — the same inclusion law as
.filter((e) => e.type === "follows" && selectedIds.has(e.from) && selectedIds.has(e.to)); // the pre-chains edge grouping; never the derived fan-out.
const chains = chainsForSelection(selected, edges); const chains = chainsForSelection(selected, getGraph(deps.graphStore).chains);
if (chains.length === 0) throw new EmptySelectionError(); if (chains.length === 0) throw new EmptySelectionError();
const propositions: PropositionSnapshot = {}; const propositions: PropositionSnapshot = {};
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement }; 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) ──────────────────── // ─── 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"; 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 /** 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 { export interface EmissionStats {
/** New graph nodes appended. */ /** New graph nodes appended. */
nodes: number; nodes: number;
/** New edge assertions appended. */ /** New reasoning chains appended. */
edges: number; chains?: number;
} }
/** One provenance point — message-granular (DECIDED 2026-09-02): nodes and /** One provenance point — message-granular (DECIDED 2026-09-02): nodes and
@@ -107,14 +138,6 @@ export interface GraphNode {
provenance: ProvenancePoint[]; 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 /** sessions.jsonl line — the emission checkpoint for one chat (which chats
* have been folded, and through which updatedAt — never fold twice). */ * have been folded, and through which updatedAt — never fold twice). */
export interface SessionCheckpoint { export interface SessionCheckpoint {
@@ -143,7 +166,9 @@ export interface CoverageEntry {
/** /**
* Store format (src/store/graph.ts): JSONL ledgers at config.LEDGER_FILES * 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 * nodes.jsonl one line per node — the FIRST line for an id defines
* name/statement/createdAt; later lines for the * name/statement/createdAt; later lines for the
@@ -151,8 +176,9 @@ export interface CoverageEntry {
* their statements/aliases join `aliases`, their * their statements/aliases join `aliases`, their
* provenance unions; the name stays the first line's. * provenance unions; the name stays the first line's.
* getGraph merges by id. * getGraph merges by id.
* edges.jsonl one line per edge ASSERTION; readers merge by * chains.jsonl one line per chain ASSERTION; readers merge by
* (from,to,type), unioning provenance. * (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, * The derived graph is rebuilt by reading the ledgers — store events,
* derive state. sessions.jsonl is the extraction checkpoint: which chats * derive state. sessions.jsonl is the extraction checkpoint: which chats
@@ -202,21 +228,20 @@ export interface ExtractedProposition {
* + structure, nothing else. */ * + structure, nothing else. */
} }
/** One proposed relation. Endpoints are extractor-local proposition ids /** One proposed argument (extractor redesign, 2026-09-30). Endpoints are
* ("p1", …) — the extractor is the ONLY stage that ever proposes edges * extractor-local proposition ids ("p1", …) — the extractor is the ONLY
* (the auditor's edges argument is gone, owner 2026-09-28 evening). */ * stage that ever proposes chains; the merger resolves them to node ids. */
export interface ExtractedEdge { export interface ExtractedChain {
/** Proposition id that is followed / supported. */ /** Proposition ids that JOINTLY entail the conclusion. */
from: string; premises: string[];
/** Proposition id that follows / is supported. */ /** The proposition id the premises jointly imply. */
to: string; conclusion: string;
type: EdgeType;
} }
/** The extractor's output shape: the raw reasoning chains. */ /** The extractor's output shape: the raw reasoning chains. */
export interface SessionFindings { export interface SessionFindings {
propositions: ExtractedProposition[]; propositions: ExtractedProposition[];
edges: ExtractedEdge[]; chains: ExtractedChain[];
} }
/** One identity verdict: the extractor's local proposition id → the /** One identity verdict: the extractor's local proposition id → the
@@ -279,8 +304,16 @@ export interface GraphClusterGroup {
} }
export interface GraphSnapshot { export interface GraphSnapshot {
/** Proposition nodes + the DERIVED implication nodes (one per chain,
* id = chain id) — what the canvas renders. */
nodes: GraphNode[]; nodes: GraphNode[];
/** DERIVED untyped arrows: premise → imp → conclusion. A lossy image
* of the stored chains — rebuilt after every change, never stored. */
edges: GraphEdge[]; 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. */ /** chatId → title, for every chat that touched the graph. */
sessions: Record<string, string>; sessions: Record<string, string>;
/** srcId → file path, for every IMPORTED NOTES file that touched the /** 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, * orchestration) owns the cross-store choreography (chat record, jobs,
* extraction wind-down); this module owns the LEDGERS. * 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): * Two modes (owner-locked semantics):
* *
* preserve-graph the chat's extraction bookkeeping dies — its coverage * preserve-graph the chat's extraction bookkeeping dies — its coverage
* rows and its legacy checkpoint lines — and NOTHING else: * 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 * The graph keeps the session's contributions; the
* snapshot simply no longer resolves the dead chatId to * snapshot simply no longer resolves the dead chatId to
* a title (nodeSessions filters unknown ids at read * a title (nodeSessions filters unknown ids at read
@@ -21,17 +27,16 @@
* *
* delete-nodes "this session never happened": every trace of the * delete-nodes "this session never happened": every trace of the
* chat leaves the ledgers — its provenance points on * 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 * phrasings die with them), its coverage/checkpoint
* rows — and orphans die: a node * rows — and orphans die: a node
* whose references all came from this chat disappears * whose references all came from this chat disappears
* entirely, pruned from the positions and embedding * entirely, pruned from the positions ledger too (dead
* ledgers too (dead members would break cluster hulls). * members would break cluster hulls).
* *
* Shared nodes survive automatically (the ruling): a node lives iff ANY * Shared nodes survive automatically (the ruling): a node lives iff ANY
* surviving reference remains — a provenance point from another chat or * surviving reference remains — a provenance point from another chat or
* notes source, or a surviving event (another session's quiz outcome / * notes source. The create line of a surviving node is special:
* observation) naming it. The create line of a surviving node is special:
* name/statement/createdAt are the node's IDENTITY — the shared anchor * name/statement/createdAt are the node's IDENTITY — the shared anchor
* other sessions' link verdicts recognized — so they stay even when the * other sessions' link verdicts recognized — so they stay even when the
* deleted chat created the node; but that session's phrasings (the line's * deleted chat created the node; but that session's phrasings (the line's
@@ -39,9 +44,6 @@
* 2026-09-23: all traces, identity survives). * 2026-09-23: all traces, identity survives).
* *
* Not touched, by design: * 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 * - imported-notes sources (src_…) — a different front door with its
* own lifecycle (re-import dedupes by path hash); this wave deletes * own lifecycle (re-import dedupes by path hash); this wave deletes
* CHATS. * CHATS.
@@ -55,7 +57,7 @@
import type { import type {
CoverageEntry, CoverageEntry,
GraphEdge, GraphChain,
GraphNode, GraphNode,
SessionCheckpoint, SessionCheckpoint,
} from "./contracts.ts"; } from "./contracts.ts";
@@ -71,8 +73,8 @@ export type ChatDeleteMode = "preserve-graph" | "delete-nodes";
export interface ChatDeletionStats { export interface ChatDeletionStats {
/** Distinct nodes that died entirely (all their lines). */ /** Distinct nodes that died entirely (all their lines). */
nodesRemoved: number; nodesRemoved: number;
/** Edge assertion LINES removed (empty-provenance or dead endpoint). */ /** Chain assertion LINES removed (empty-provenance or dead member). */
edgesRemoved: number; chainsRemoved: number;
/** Coverage rows removed. */ /** Coverage rows removed. */
coverageRemoved: number; coverageRemoved: number;
/** Checkpoint lines removed. */ /** Checkpoint lines removed. */
@@ -81,7 +83,7 @@ export interface ChatDeletionStats {
const EMPTY_STATS: ChatDeletionStats = { const EMPTY_STATS: ChatDeletionStats = {
nodesRemoved: 0, nodesRemoved: 0,
edgesRemoved: 0, chainsRemoved: 0,
coverageRemoved: 0, coverageRemoved: 0,
checkpointsRemoved: 0, checkpointsRemoved: 0,
}; };
@@ -116,7 +118,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
// ── delete-nodes: the full purge ────────────────────────────────────── // ── delete-nodes: the full purge ──────────────────────────────────────
const nodeLines = graphStore.readNodes(); 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 // Does the chat touch the graph at all? (A never-extracted chat still
// needs its coverage rows dropped above, but no ledger rewrite — keeps // 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.) // changed.)
const touched = const touched =
nodeLines.some((line) => line.provenance.some((pt) => pt.chatId === chatId)) || 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 (!touched) {
if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) { if (stats.coverageRemoved > 0 || stats.checkpointsRemoved > 0) {
graphStore.rewriteLedgers({ coverage, checkpoints }); 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 // 1. Survival: a node lives iff a reference from ANY surviving source
// remains — another chat/src's provenance point on any of its lines, // remains — another chat/src's provenance point on any of its lines.
// a surviving mapping line (notes-front-door nodes carry EMPTY // (Chain members are deliberately NOT survival references: a
// provenance: the file IS the anchor, recorded through the mapping // chain's asserting session always left provenance on its endpoints,
// line). (Edge endpoints are deliberately // so endpoint-only liveness cannot occur in a consistent ledger —
// NOT survival references: an edge's asserting session always left // and counting it would keep nodes alive whose lines we would then
// provenance on its endpoints, so endpoint-only liveness cannot // drop, dangling the chain.)
// occur in a consistent ledger — and counting it would keep nodes
// alive whose lines we would then drop, dangling the edge.)
const survives = (points: ReadonlyArray<{ chatId: string }>): boolean => const survives = (points: ReadonlyArray<{ chatId: string }>): boolean =>
points.some((pt) => pt.chatId !== chatId); points.some((pt) => pt.chatId !== chatId);
const live = new Set<string>(); 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. // else: the deleted chat's link line — dies with its phrasings.
} }
// 3. Edges: strip the chat's points; an assertion dies when its // 3. Chains: strip the chat's points; an assertion dies when its
// provenance empties or either endpoint died IN THIS DELETION. // provenance empties or ANY member node (premise or conclusion)
// (Only nodes that existed and died count — an edge whose endpoints // died IN THIS DELETION. (Only nodes that existed and died count —
// never resolved as nodes is foreign data the deletion leaves alone: // a chain whose members never resolved as nodes is foreign data the
// destructive rewriting may not exceed the deleted chat's blast // deletion leaves alone: destructive rewriting may not exceed the
// radius.) // deleted chat's blast radius.)
const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id))); const deadNodes = new Set([...new Set(nodeLines.map((line) => line.id))].filter((id) => !live.has(id)));
const outEdges: GraphEdge[] = []; const outChains: GraphChain[] = [];
for (const line of edgeLines) { for (const line of chainLines) {
if (deadNodes.has(line.from) || deadNodes.has(line.to)) continue; if (deadNodes.has(line.conclusion) || line.premises.some((premise) => deadNodes.has(premise))) continue;
const points = line.provenance.filter((pt) => pt.chatId !== chatId); const points = line.provenance.filter((pt) => pt.chatId !== chatId);
// An assertion dies when THIS deletion empties its provenance — a // An assertion dies when THIS deletion empties its provenance — a
// line that already had no points is foreign/legacy data, not this // line that already had no points is foreign/legacy data, not this
// chat's trace; it rides untouched. // chat's trace; it rides untouched.
if (line.provenance.length > 0 && points.length === 0) continue; 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.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({ graphStore.rewriteLedgers({
nodes: outNodes, nodes: outNodes,
edges: outEdges, chains: outChains,
coverage, coverage,
checkpoints, checkpoints,
}); });
// 5. Positions: prune dead nodes (stale resting spots would pin // 4. Positions: prune dead nodes AND dead chains (an argument's derived
// nothing — but pruning keeps the ledger honest and the revision // implication node carries the chain's arg_ id — stale resting spots
// moving for exactly the right reason). // 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 positions = graphStore.readPositions();
const knownNodeIds = new Set(nodeLines.map((line) => line.id)); const knownNodeIds = new Set(nodeLines.map((line) => line.id));
const prunedPositions = Object.fromEntries( 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) { if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
graphStore.writePositions(prunedPositions); graphStore.writePositions(prunedPositions);
@@ -224,6 +232,7 @@ export function deleteChatTraces(graphStore: GraphStore, chatId: string, mode: C
return stats; return stats;
} }
// ─── single-node deletion (node interlude, owner-locked full-death) ─────────── // ─── 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 — * one deletes a NODE and takes its every trace —
* *
* - every line of the node (create + link lines) * - every line of the node (create + link lines)
* - every EDGE touching it (from or to), whatever session asserted it * - every CHAIN referencing it (as premise or conclusion), whatever
* - every mapping line that anchored it (the audit trail; for a * session asserted it — a chain with a dead member is not a chain
* notes-born node the file IS the anchor — its mapping line is the * - its resting position (and every dead chain's)
* only one)
* - its resting position
* *
* 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 * provenance, aliases, and sessions are untouched (full death is
* NODE-scoped, not session-scoped), and the source records of notes * NODE-scoped, not session-scoped), and the source records of notes
* imports stay (the import's bookkeeping; deleting one proposition is not * 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. * second run finds no lines and changes nothing.
*/ */
export interface NodeDeletionStats { export interface NodeDeletionStats {
/** Edge assertion LINES removed (every edge touching the node). */ /** Chain assertion LINES removed (every chain referencing the node). */
edgesRemoved: number; chainsRemoved: number;
} }
function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] { function nodeLinesOf(graphStore: GraphStore, nodeId: string): GraphNode[] {
return graphStore.readNodes().filter((line) => line.id === nodeId); 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 * Full-death one node (or one argument, by its arg_ id). Returns null
* orchestration maps that to 404); rewrites only the ledgers that * when no such node/chain exists (the orchestration maps that to 404);
* changed (unchanged files keep their mtimes — the version stamp moves * rewrites only the ledgers that changed (unchanged files keep their
* only when something really did). * mtimes — the version stamp moves only when something really did).
*/ */
export function deleteNodeTraces(graphStore: GraphStore, nodeId: string): NodeDeletionStats | null { 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); const nodeLines = nodeLinesOf(graphStore, nodeId);
if (nodeLines.length === 0) return null; if (nodeLines.length === 0) return null;
const edgeLines = graphStore.readEdges(); const outChains = chainLines.filter(
const outEdges = edgeLines.filter((line) => line.from !== nodeId && line.to !== nodeId); (line) => line.conclusion !== nodeId && !line.premises.includes(nodeId),
);
// Only the touched ledgers: the node's own lines always die (its file // Only the touched ledgers: the node's own lines always die (its file
// must move), everything else rewrites only when rows actually drop. // must move), everything else rewrites only when rows actually drop.
const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId); const outNodes = graphStore.readNodes().filter((line) => line.id !== nodeId);
graphStore.rewriteLedgers({ graphStore.rewriteLedgers({
nodes: outNodes, 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(); const positions = graphStore.readPositions();
if (positions[nodeId] !== undefined) { const prunedPositions = Object.fromEntries(
const { [nodeId]: _dead, ...rest } = positions; Object.entries(positions).filter(([id]) => id !== nodeId && !deadChainIds.has(id)),
graphStore.writePositions(rest); );
if (Object.keys(prunedPositions).length !== Object.keys(positions).length) {
graphStore.writePositions(prunedPositions);
} }
return { 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 { CoverageEntry } from "./contracts/graph.ts";
import type { import type {
AuditVerdicts, AuditVerdicts,
ExtractedEdge, ExtractedChain,
GraphChain,
SessionFindings, SessionFindings,
Cluster, Cluster,
EmissionStats, EmissionStats,
@@ -56,6 +57,7 @@ import type {
} from "./contracts.ts"; } from "./contracts.ts";
import { MIN_CLUSTER_SIZE } from "./clusters.ts"; import { MIN_CLUSTER_SIZE } from "./clusters.ts";
import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts"; import { birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
import { formatChainLine } from "./practice.ts";
import type { ChatStore, GraphStore } from "./ports.ts"; import type { ChatStore, GraphStore } from "./ports.ts";
/** The storage ports every graph operation needs, passed explicitly. */ /** The storage ports every graph operation needs, passed explicitly. */
@@ -69,6 +71,18 @@ function newGraphNodeId(): string {
return `n_${randomBytes(6).toString("hex")}`; 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 /** 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 — * 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 * for the format-agnostic notes front door (2026-09-13) — no coverage at
@@ -127,7 +141,7 @@ export function mergeFindings(
source: MergeSource, source: MergeSource,
): EmissionStats { ): EmissionStats {
const { graphStore: ledgers } = deps; 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 // 1. Resolution: THIS job's propositions → graph node ids, built
// in-memory as the propositions resolve (owner, 2026-09-29: the // in-memory as the propositions resolve (owner, 2026-09-29: the
@@ -180,31 +194,41 @@ export function mergeFindings(
mapping.set(p.id, graphNodeId); mapping.set(p.id, graphNodeId);
} }
// 2. Edges — the extractor's own, the ONLY edge source (the auditor's // 2. Chains — the extractor's own, the ONLY chain source; the stored
// edges argument is gone, owner 2026-09-28 evening). Endpoints are // truth (chains-primary, owner 2026-09-30). Endpoints are this
// this job's local proposition ids and resolve through the job-local // job's local proposition ids and resolve through the job-local
// map; edges carry the run's chat-level provenance point and dedupe // map; chains carry the run's chat-level provenance point and
// by (from,to,type) already asserted by THIS chat — an assertion by // dedupe by (premises, conclusion) already asserted by THIS chat —
// another chat stays untouched (provenance unions at read time). // 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>(); const assertedByChat = new Set<string>();
for (const line of ledgers.readEdges()) { const idOfKey = new Map<string, string>();
const key = `${line.from}\u0000${line.to}\u0000${line.type}`; 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); if (line.provenance.some((pt) => pt.chatId === chatId)) assertedByChat.add(key);
} }
const emitEdge = (edge: ExtractedEdge): void => { const emitChain = (chain: ExtractedChain): 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 resolve = (endpoint: string): string | undefined => mapping.get(endpoint); const resolve = (endpoint: string): string | undefined => mapping.get(endpoint);
const from = resolve(edge.from); const conclusion = resolve(chain.conclusion);
const to = resolve(edge.to); if (conclusion === undefined) return; // unresolvable endpoint — skip
if (!from || !to || from === to) return; // unresolvable endpoint — skip // Duplicate local ids resolving to one node (two phrasings of one
const key = `${from}\u0000${to}\u0000${edge.type}`; // premise) collapse; a premise equal to the conclusion is dropped.
if (assertedByChat.has(key)) return; const premises = [...new Set(chain.premises.map(resolve))].filter(
ledgers.appendEdge({ from, to, type: edge.type, provenance: pointOf(chatId) }); (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); 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): // 3. Checkpoint — THE COVERAGE DISCIPLINE (vision-extraction-coverage.md):
// only now, after every append succeeded, is the batch covered. The // 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) ───────────── // ─── 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 { export interface DerivedGraph {
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; chains: GraphChain[];
} }
/** /**
* The whole graph from the ledgers. Node LINES may repeat an id (link- * The whole graph from the ledgers. Node LINES may repeat an id (link-
* appends): the FIRST line defines name/statement/createdAt; later * appends): the FIRST line defines name/statement/createdAt; later
* lines' statements and aliases join `aliases`, provenance unions — the * lines' statements and aliases join `aliases`, provenance unions — the
* name never changes after creation. Edge * name never changes after creation. Chain LINES are assertions, merged
* LINES are assertions, merged by (from,to,type) with unioned provenance, * by (premises, conclusion) with unioned provenance; the first line's
* first-seen order. `store events, derive state` — this is the only reader * arg_ id / premises / createdAt are the chain's identity.
* that matters. * `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). // Merge node lines by id (first line defines; later lines add phrasings).
const byId = new Map<string, GraphNode>(); const byId = new Map<string, GraphNode>();
for (const line of graphStore.readNodes()) { for (const line of graphStore.readNodes()) {
@@ -254,29 +283,68 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
} }
} }
const merged: GraphEdge[] = []; // Merge chain assertion lines by (premises, conclusion): the first
const byKey = new Map<string, GraphEdge>(); // line's id/premises/createdAt stand, provenance unions.
for (const line of graphStore.readEdges()) { const chains: GraphChain[] = [];
const key = `${line.from}\u0000${line.to}\u0000${line.type}`; const byKey = new Map<string, GraphChain>();
for (const line of graphStore.readChains()) {
const key = chainKey(line.premises, line.conclusion);
const existing = byKey.get(key); const existing = byKey.get(key);
if (existing) { if (existing) {
for (const pt of line.provenance) { for (const pt of line.provenance) {
if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt); if (!existing.provenance.some((q) => samePoint(q, pt))) existing.provenance.push(pt);
} }
} else { } else {
const edge: GraphEdge = { ...line, provenance: [...line.provenance] }; const chain: GraphChain = { ...line, premises: [...line.premises], provenance: [...line.provenance] };
byKey.set(key, edge); byKey.set(key, chain);
merged.push(edge); 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 ────────────────────────────────────────────────── // ─── the /api/graph payload ──────────────────────────────────────────────────
/** Everything the home graph view needs, in one fetch (shape: contracts). */ /** Everything the home graph view needs, in one fetch (shape: contracts). */
export function graphSnapshot(deps: GraphDeps): GraphSnapshot { 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 nodeChats = new Map<string, Set<string>>();
const touch = (nodeId: string, chatId: string): void => { const touch = (nodeId: string, chatId: string): void => {
@@ -290,13 +358,6 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
for (const node of nodes) { for (const node of nodes) {
for (const pt of node.provenance) touch(node.id, pt.chatId); 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 // Labels for every source the graph can point back at: real chats
// (title, deep-linkable) and imported notes files (path, static — the // (title, deep-linkable) and imported notes files (path, static — the
// format-agnostic front door's provenance). // 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 // presentation rule (a region needs >= 3 propositions for a hull). A
// birth session whose record is gone (deleted chat) keeps its members // birth session whose record is gone (deleted chat) keeps its members
// but renders unlabeled — honest, never guessed. // 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 const clusters: Cluster[] = communities
.filter((community) => community.members.length >= MIN_CLUSTER_SIZE) .filter((community) => community.members.length >= MIN_CLUSTER_SIZE)
.map((community) => ({ .map((community) => ({
@@ -346,7 +409,8 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
return { return {
nodes, nodes,
edges, edges: view.edges,
chains: graph.chains,
sessions, sessions,
fileSources, fileSources,
nodeSessions, 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 { incrementalLayout } from "./layout.ts";
import { birthSessionPartition } from "./session-clusters.ts"; import { birthSessionPartition } from "./session-clusters.ts";
import { deriveView, getGraph } from "./graph.ts";
/** /**
* The merge-time settle: recompute the resting layout from the CURRENT * 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 * commit (client positions become the anchor set). Synchronous — it must
* finish before the next /api/graph fetch reflects the change. * 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 * SPLIT FROM layout.ts deliberately (2026-09-13): layout.ts is in the
* browser barrel, and the settle needs the cluster partition for the * browser barrel, and the settle needs the cluster partition for the
* cluster centroid pull — this module must not ship in the client bundle. * 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: { export function settleGraphLayout(graphStore: {
readNodes(): GraphNode[]; readNodes(): GraphNode[];
readEdges(): GraphEdge[]; readChains(): GraphChain[];
readPositions(): Record<string, Position>; readPositions(): Record<string, Position>;
writePositions(positions: Record<string, Position>): void; writePositions(positions: Record<string, Position>): void;
}): void { }): void {
const nodes = graphStore.readNodes(); const graph = getGraph(graphStore);
if (nodes.length === 0) return; if (graph.nodes.length === 0) return;
// Node LINES may repeat an id (link-appends) — dedupe, first defines. const view = deriveView(graph);
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 previous = new Map(Object.entries(graphStore.readPositions())); const previous = new Map(Object.entries(graphStore.readPositions()));
// The cluster partition for the centroid pull: the SAME birth-session // The cluster partition for the centroid pull: the SAME birth-session
// partition the snapshot renders (derived from provenance on read — // partition the snapshot renders (derived from provenance on read —
// core/session-clusters.ts, wave 7) — one cluster concept everywhere. // core/session-clusters.ts, wave 7) — one cluster concept everywhere.
// The unique list is the deduped set the partition needs (lines may // PROPOSITIONS ONLY (owner, 2026-09-30): implication nodes never
// repeat an id — first defines). // partition; the stored world clusters, its image follows.
const communities = birthSessionPartition(unique); const communities = birthSessionPartition(graph.nodes);
const next = incrementalLayout(unique, edges, previous, { clusters: communities }); const next = incrementalLayout(view.nodes, view.edges, previous, { clusters: communities });
graphStore.writePositions(Object.fromEntries(next)); graphStore.writePositions(Object.fromEntries(next));
} }
+12 -13
View File
@@ -14,7 +14,7 @@ import type {
ChatRecord, ChatRecord,
ChatSettings, ChatSettings,
ChatSummary, ChatSummary,
GraphEdge, GraphChain,
GraphNode, GraphNode,
JobKind, JobKind,
JobRecord, JobRecord,
@@ -65,11 +65,12 @@ export interface ChatStore {
} }
// ─── GraphStore — the append-only evidence ledgers (data/graph) ────────────── // ─── GraphStore — the append-only evidence ledgers (data/graph) ──────────────
// v1 surface = the five JSONL ledgers (nodes / edges / events / mappings / // Chains are the stored truth (owner, 2026-09-30): nodes + reasoning
// sessions). Reads return the raw append-order lines; merging edge assertions // chains; edges are a DERIVED view (core/graph.ts) and never touch this
// by (from, to, type) is DERIVED in core/graph.ts — the store never decides. // seam. Reads return the raw append-order lines; merging chain assertions
// The extraction pipeline (next stage) becomes the sole writer; this read // by (premises, conclusion) is DERIVED in core/graph.ts — the store never
// surface stays. // decides. The extraction pipeline is the sole writer; this read surface
// stays.
export interface GraphStore { export interface GraphStore {
/** Append one GraphNode (ids are unique — no merging needed). */ /** Append one GraphNode (ids are unique — no merging needed). */
@@ -77,12 +78,10 @@ export interface GraphStore {
/** Raw node lines in append order. */ /** Raw node lines in append order. */
readNodes(): GraphNode[]; readNodes(): GraphNode[];
/** Append one edge ASSERTION (a second session asserting the same /** Append one chain ASSERTION (a second session asserting the same
* (from, to, type) appends another line; readers merge). */ * (premises, conclusion) appends another line; readers merge). */
appendEdge(assertion: GraphEdge): void; appendChain(assertion: GraphChain): void;
readEdges(): GraphEdge[]; readChains(): GraphChain[];
/** Append one quiz outcome / understanding observation. */
/** Append one emission checkpoint. */ /** Append one emission checkpoint. */
appendCheckpoint(checkpoint: SessionCheckpoint): void; appendCheckpoint(checkpoint: SessionCheckpoint): void;
@@ -122,7 +121,7 @@ export interface GraphStore {
* replacement atomic. */ * replacement atomic. */
rewriteLedgers(next: { rewriteLedgers(next: {
nodes?: GraphNode[]; nodes?: GraphNode[];
edges?: GraphEdge[]; chains?: GraphChain[];
coverage?: CoverageEntry[]; coverage?: CoverageEntry[];
checkpoints?: SessionCheckpoint[]; checkpoints?: SessionCheckpoint[];
}): void; }): void;
+26 -74
View File
@@ -2,16 +2,15 @@
* Practice-module core — the chain bijection + coverage (slice 1 of the * Practice-module core — the chain bijection + coverage (slice 1 of the
* 2026-09-29 plan). Pure, no I/O. * 2026-09-29 plan). Pure, no I/O.
* *
* chainsForSelection(): the graph → chains translation — group by * chainsForSelection(): the stored CHAINS (chains-primary, owner
* conclusion over FOLLOWS edges only (supports are invisible to practice, * 2026-09-30) filtered to a selection — a chain rides along iff ALL of
* owner 2026-09-29: "no edges"). chainsToGraph(): the exact inverse — the * its members are selected; nodes in no surviving chain become singleton
* bijection the chain notation promises (renderable back to the graph * chains. formatChainLine(): one world-facing line per chain.
* with no information loss; the round-trip is pinned in * chainCoverage(): the set's completeness law — the union of the
* tests/practice.test.ts). chainCoverage(): the set's completeness law — * problems' chains must equal the set's chains; the partitioner's submit
* the union of the problems' chains must equal the set's chains; the * is validated against it.
* 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 /** The named levels (owner-approved 2026-09-29) — definitions verbatim
* from the ladder doctrine / the contract's tables. The UI's stepped * 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." }, 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 /** The stored chains filtered to a selection (chains-primary, owner
* that node's (one) chain; a node with no in- AND no out-follows-edge in * 2026-09-30): a chain rides along iff its conclusion AND every premise
* the selection is a singleton chain. Premise-only nodes are NOT separate * are selected; a node in no surviving chain is a singleton chain
* chains — they are covered inside the chains they feed. Deterministic: * (premises: []). Premise-only nodes whose chain was excluded are
* chains sorted by id, premises sorted. */ * 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( export function chainsForSelection(
nodes: ReadonlyArray<Pick<GraphNode, "id">>, nodes: ReadonlyArray<Pick<GraphNode, "id">>,
edges: ReadonlyArray<Pick<GraphEdge, "from" | "to" | "type">>, chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
): ReasoningChain[] { ): ReasoningChain[] {
const selected = new Set(nodes.map((n) => n.id)); 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 chained = new Set<string>();
const chains: ReasoningChain[] = []; const included: ReasoningChain[] = [];
for (const [conclusion, premises] of premisesByConclusion) { for (const chain of chains) {
chains.push({ id: conclusion, premises: [...premises].sort(), conclusion }); if (!selected.has(chain.conclusion)) continue;
chained.add(conclusion); if (!chain.premises.every((premise) => selected.has(premise))) continue;
for (const premise of premises) chained.add(premise); 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) { for (const node of nodes) {
if (!chained.has(node.id)) { 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)); return included.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;
} }
/** A chain as one world-facing line — the propositions' NAMES joined /** A chain as one world-facing line — the propositions' NAMES joined
-23
View File
@@ -54,19 +54,6 @@ export interface GraphRenderSettings {
labelZoom: number; labelZoom: number;
/** Node + hover label font size, WORLD units (scales with camera). */ /** Node + hover label font size, WORLD units (scales with camera). */
labelSize: number; 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). */ /** Toggle for the cluster region labels (hulls always draw). */
clusterLabels: boolean; clusterLabels: boolean;
/** Cluster label font size, px on screen (camera-compensated — /** 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), 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), 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), 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: clusterLabels:
typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels, typeof r.clusterLabels === "boolean" ? r.clusterLabels : DEFAULT_RENDER_SETTINGS.clusterLabels,
clusterLabelSize: ladd( clusterLabelSize: ladd(
@@ -283,8 +262,6 @@ export const DEFAULT_RENDER_SETTINGS: GraphRenderSettings = {
arrowLength: 2, arrowLength: 2,
labelZoom: 4, labelZoom: 4,
labelSize: 3, labelSize: 3,
showSupports: true,
supportsWidth: 0.5,
clusterLabels: true, clusterLabels: true,
clusterLabelSize: 14, clusterLabelSize: 14,
regions: DEFAULT_REGION_TUNING, regions: DEFAULT_REGION_TUNING,
+1 -1
View File
@@ -75,7 +75,7 @@ export function graphRevision(): number {
let revision = 0; let revision = 0;
for (const file of [ for (const file of [
LEDGER_FILES.nodes, LEDGER_FILES.nodes,
LEDGER_FILES.edges, LEDGER_FILES.chains,
LEDGER_FILES.sessions, LEDGER_FILES.sessions,
LEDGER_FILES.coverage, LEDGER_FILES.coverage,
POSITIONS_FILE, 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"). * config.LEDGER_FILES (contracts.ts "v1 emission").
* nodes.jsonl GraphNode — one line per node write: the FIRST * nodes.jsonl GraphNode — one line per node write: the FIRST
* line for an id defines it; a later * line for an id defines it; a later
@@ -7,10 +7,18 @@
* link-append (session phrasings join * link-append (session phrasings join
* aliases, provenance unions); reads * aliases, provenance unions); reads
* merge by id (core/graph.ts getGraph) * merge by id (core/graph.ts getGraph)
* edges.jsonl GraphEdge — one line per edge ASSERTION (a second * chains.jsonl GraphChain — one line per chain ASSERTION (a second
* session asserting the same (from,to,type) * session asserting the same
* appends another line; reads merge) * (premises, conclusion) appends
* another line; readers merge)
* sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt) * 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 * Plus config.SOURCES_FILE (sources.json): the imported-notes bookkeeping
* (NOT a ledger) — one JSON map, src-id → NotesSource, read-modify-write * (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 * as path from "node:path";
import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts"; import { LEDGER_FILES, POSITIONS_FILE, SOURCES_FILE } from "../../config.ts";
import type { import type {
GraphEdge, GraphChain,
GraphNode, GraphNode,
ExtractionRunUsage, ExtractionRunUsage,
NotesSource, NotesSource,
@@ -94,15 +102,20 @@ const isGraphNode = (v: unknown): v is GraphNode =>
Array.isArray(v.provenance) && Array.isArray(v.provenance) &&
v.provenance.every(isProvenancePoint); v.provenance.every(isProvenancePoint);
const EDGE_TYPES = ["follows", "supports"] as const; /** A chain line: premises ≥ 1 (a chain IS an argument — bare propositions
* ride the node ledger alone), every member a string, conclusion a string.
const isGraphEdge = (v: unknown): v is GraphEdge => * Endpoint EXISTENCE is the derivation's concern (it drops dangling
* chains); the store only shapes. */
const isGraphChain = (v: unknown): v is GraphChain =>
isRecord(v) && isRecord(v) &&
isString(v.from) && isString(v.id) &&
isString(v.to) && Array.isArray(v.premises) &&
(EDGE_TYPES as readonly string[]).includes(v.type as string) && v.premises.length > 0 &&
v.premises.every(isString) &&
isString(v.conclusion) &&
Array.isArray(v.provenance) && Array.isArray(v.provenance) &&
v.provenance.every(isProvenancePoint); v.provenance.every(isProvenancePoint) &&
isString(v.createdAt);
const isCheckpoint = (v: unknown): v is SessionCheckpoint => const isCheckpoint = (v: unknown): v is SessionCheckpoint =>
isRecord(v) && isString(v.chatId) && isString(v.foldedAt); isRecord(v) && isString(v.chatId) && isString(v.foldedAt);
@@ -123,12 +136,13 @@ export const graphStore: GraphStore = {
return readValid(LEDGER_FILES.nodes, isGraphNode); return readValid(LEDGER_FILES.nodes, isGraphNode);
}, },
/** Append one edge ASSERTION; readers merge lines by (from,to,type). */ /** Append one chain ASSERTION; readers merge lines by (premises,
appendEdge(assertion: GraphEdge): void { * conclusion). */
appendLine(LEDGER_FILES.edges, assertion); appendChain(assertion: GraphChain): void {
appendLine(LEDGER_FILES.chains, assertion);
}, },
readEdges(): GraphEdge[] { readChains(): GraphChain[] {
return readValid(LEDGER_FILES.edges, isGraphEdge); return readValid(LEDGER_FILES.chains, isGraphChain);
}, },
appendCheckpoint(checkpoint: SessionCheckpoint): void { appendCheckpoint(checkpoint: SessionCheckpoint): void {
@@ -184,13 +198,13 @@ export const graphStore: GraphStore = {
* as "nothing yet", same as never-emitted. */ * as "nothing yet", same as never-emitted. */
rewriteLedgers(next: { rewriteLedgers(next: {
nodes?: GraphNode[]; nodes?: GraphNode[];
edges?: GraphEdge[]; chains?: GraphChain[];
coverage?: CoverageEntry[]; coverage?: CoverageEntry[];
checkpoints?: SessionCheckpoint[]; checkpoints?: SessionCheckpoint[];
}): void { }): void {
const files = { const files = {
nodes: LEDGER_FILES.nodes, nodes: LEDGER_FILES.nodes,
edges: LEDGER_FILES.edges, chains: LEDGER_FILES.chains,
coverage: LEDGER_FILES.coverage, coverage: LEDGER_FILES.coverage,
checkpoints: LEDGER_FILES.sessions, checkpoints: LEDGER_FILES.sessions,
} as const; } as const;
@@ -290,20 +304,21 @@ function writeSourceMap(map: Record<string, NotesSource>): void {
fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8"); fs.writeFileSync(SOURCES_FILE, JSON.stringify(map, null, "\t"), "utf-8");
} }
// ─── one-way migration: the events ledger is gone (2026-09-27) ──────────────── // ─── one-way purges at adapter load: dead ledgers are exactly the confusion
// Quiz outcomes and understanding observations are no longer formalized in // the cleanup removes (owner doctrine — nothing reads them, nothing writes
// the graph — the pipeline writes nodes + edges only. A stale events.jsonl // them, a stale file lying around is a lie). ────────────────────────────────
// left by an earlier build is deleted once, here, at adapter load: dead
// ledgers are exactly the confusion the cleanup removes (owner ruling).
{ {
const legacyEvents = path.join(path.dirname(LEDGER_FILES.nodes), "events.jsonl"); const graphDir = path.dirname(LEDGER_FILES.nodes);
try { for (const legacy of ["edges.jsonl", "events.jsonl"]) {
if (fs.existsSync(legacyEvents)) { const file = path.join(graphDir, legacy);
fs.rmSync(legacyEvents); try {
console.info(`[graph-store] purged legacy events ledger: ${legacyEvents}`); 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, AuditVerdicts,
CoverageEntry, CoverageEntry,
ExtractionRunUsage, ExtractionRunUsage,
GraphEdge, GraphChain,
GraphNode, GraphNode,
NotesSource, NotesSource,
@@ -74,7 +74,7 @@ function fauxEngine(steps: FauxResponseStep[]): PiEngine {
/** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */ /** An in-memory GraphStore (core/ports.ts) — this suite never touches data/graph. */
function memoryLedgers() { function memoryLedgers() {
const nodes: GraphNode[] = []; const nodes: GraphNode[] = [];
const edges: GraphEdge[] = []; const chains: GraphChain[] = [];
const checkpoints: SessionCheckpoint[] = []; const checkpoints: SessionCheckpoint[] = [];
const coverage: CoverageEntry[] = []; const coverage: CoverageEntry[] = [];
const sources = new Map<string, NotesSource>(); const sources = new Map<string, NotesSource>();
@@ -82,8 +82,8 @@ function memoryLedgers() {
return { return {
appendNode: (n: GraphNode) => void nodes.push(n), appendNode: (n: GraphNode) => void nodes.push(n),
readNodes: () => nodes.map((n) => ({ ...n })), readNodes: () => nodes.map((n) => ({ ...n })),
appendEdge: (e: GraphEdge) => void edges.push(e), appendChain: (c: GraphChain) => void chains.push(c),
readEdges: () => edges.map((e) => ({ ...e })), readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c), appendCheckpoint: (c: SessionCheckpoint) => void checkpoints.push(c),
readCheckpoints: () => checkpoints.map((c) => ({ ...c })), readCheckpoints: () => checkpoints.map((c) => ({ ...c })),
// Extraction coverage (vision-extraction-coverage.md) — append-only; // Extraction coverage (vision-extraction-coverage.md) — append-only;
@@ -114,7 +114,7 @@ function memoryLedgers() {
// store: replace the provided ledgers' full contents. // store: replace the provided ledgers' full contents.
rewriteLedgers: (next) => { rewriteLedgers: (next) => {
if (next.nodes) nodes.splice(0, nodes.length, ...next.nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] }))); 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.coverage) coverage.splice(0, coverage.length, ...next.coverage.map((c) => ({ ...c })));
if (next.checkpoints) checkpoints.splice(0, checkpoints.length, ...next.checkpoints.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 pipeline = createExtractionPipeline({ chatStore, graphStore: ledgers, extractor, auditor: submitModel("submit_review", verdict) });
const stats = await pipeline.runExtraction(chatId); 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 () => { 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, extractor,
auditor: submitModel("submit_review", { links: [] }), 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 () => { 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 // p1 creates no node, p2 creates one, and the extractor's edge
// resolves through them. (The old echo form could silently drop // resolves through them. (The old echo form could silently drop
// content by omitting it; that accidental drop channel is gone.) // 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 // Link-append: the phrasing joined the existing node's aliases and
// the provenance unioned; p2's create line is its own node. // 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), auditor: submitModel("submit_review", verdict),
}); });
const stats = await pipeline.runExtraction(chatId); 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, // Wave 7 (950ddfd): birth-session clusters carry session-name labels,
// so the post-merge naming garnish is gone — no model spend, no // so the post-merge naming garnish is gone — no model spend, no
// naming-run records, no label writes. // 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 old run-level throw is gone: one failed job is absorbed at
// the fan-out — the run resolves with what actually merged (zeros). // 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.readNodes().length, 0);
assert.equal(ledgers.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs"); assert.equal(ledgers.readCheckpoints().length, 0, "no checkpoint — the next trigger re-runs");
assert.equal(ledgers.readCoverage().length, 0); assert.equal(ledgers.readCoverage().length, 0);
@@ -620,7 +620,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
extractor: silentModel(), extractor: silentModel(),
auditor: submitModel("submit_review", { links: [] }), 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); assert.equal(ledgers.readCheckpoints().length, 0);
// THE isolation pin: a two-batch chat whose SECOND extractor call // THE isolation pin: a two-batch chat whose SECOND extractor call
@@ -642,7 +642,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
engineFor: (role) => engineFor: (role) =>
role === "extractor" ? fauxEngine([countingStep]) : submitModel("submit_review", { links: [] }), 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(invocations, 2);
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages"); assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
assert.equal( 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"); assert.equal(extractionStatuses().find((r) => r.chatId === a.chatId)?.phase, "auditing", "A still audits while B waits");
gateOpen(); gateOpen();
assert.deepEqual(await runA, { nodes: 1, edges: 0 }); assert.deepEqual(await runA, { nodes: 1, chains: 0 });
assert.deepEqual(await runB, { nodes: 1, edges: 0 }); assert.deepEqual(await runB, { nodes: 1, chains: 0 });
assert.ok(bAuditorCalled); assert.ok(bAuditorCalled);
assert.ok(bSawAlphasMerge, "B's auditor saw A's merged node — audit serialized after merge"); 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"); 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. // The dock's abort button, pressed while B queues.
assert.equal(abortExtraction(b.chatId), true); 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(!bAuditorCalled, "B's auditor was never invoked");
assert.ok(!extractionStatuses().some((r) => r.chatId === b.chatId), "B left the registry"); 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"); 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 // The chain link B abandoned still resolved: A finishes, and a
// THIRD run audits normally — no deadlock residue. // THIRD run audits normally — no deadlock residue.
gateOpen(); gateOpen();
assert.deepEqual(await runA, { nodes: 1, edges: 0 }); assert.deepEqual(await runA, { nodes: 1, chains: 0 });
const pipelineC = createExtractionPipeline({ const pipelineC = createExtractionPipeline({
chatStore, chatStore,
graphStore: ledgers, graphStore: ledgers,
extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")), extractor: submitModel("submit_findings", single("pc", "Gamma claim", "Gamma statement")),
auditor: submitModel("submit_review", { links: [] }), 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 () => { 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; const stats = await runPromise;
// A deliberate stop, not a failure: zero emissions, no throw. // 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(extractionStatuses().length, 0, "the registry clears");
assert.equal(ledgers.readNodes().length, 0, "nothing merged"); assert.equal(ledgers.readNodes().length, 0, "nothing merged");
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed — the next trigger re-runs"); 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 first = pipeline.runExtraction(chatId);
const second = await pipeline.runExtraction(chatId); // in-flight → zeros const second = await pipeline.runExtraction(chatId); // in-flight → zeros
assert.deepEqual(second, { nodes: 0, edges: 0 }); assert.deepEqual(second, { nodes: 0, chains: 0 });
release(); release();
// The gated extractor never submits — an ISOLATED job failure now // The gated extractor never submits — an ISOLATED job failure now
// (per-chunk jobs, owner 2026-09-28): the run resolves with zeros // (per-chunk jobs, owner 2026-09-28): the run resolves with zeros
// instead of throwing. // 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 () => { 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> } }; 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.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(); 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(!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"); 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 { chatId } = fixtureChat();
const ledgers = memoryLedgers(); const ledgers = memoryLedgers();
const extractor = submitModel("submit_findings", { const extractor = submitModel("submit_findings", {
@@ -1044,16 +1044,16 @@ describe("the extractor redesign (owner, 2026-09-30): chains are the deliverable
extractor, extractor,
auditor: submitModel("submit_review", { links: [] }), auditor: submitModel("submit_review", { links: [] }),
}); });
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, edges: 2 }); assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 3, chains: 1 });
// Both edges point INTO the conclusion's resolved node; the two // The stored chain's premises are exactly the two premises; the
// from-ends are exactly the two premises (node ids are random — // conclusion is C's resolved node (ids are random — assert
// assert set-wise, never by ledger order). // set-wise, never by ledger order).
const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id])); const byName = new Map(ledgers.readNodes().map((n) => [n.name, n.id]));
const conclusion = byName.get("C")!; const conclusion = byName.get("C")!;
const edges = ledgers.readEdges(); const chains = ledgers.readChains();
assert.ok(edges.every((e) => e.to === conclusion), "both edges point INTO the conclusion"); assert.ok(chains.every((c) => c.conclusion === conclusion), "chains point INTO the conclusion");
assert.deepEqual( assert.deepEqual(
edges.map((e) => e.from).sort(), chains.flatMap((c) => c.premises).sort(),
[byName.get("A")!, byName.get("B")!].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, extractor,
auditor: submitModel("submit_review", { links: [] }), auditor: submitModel("submit_review", { links: [] }),
}); });
// Independent arguments for the same conclusion are separate chains // Independent arguments for the same conclusion are separate chains;
// → two edges into C; the bare p4 rides the propositions list alone. // the bare p4 rides the propositions list alone.
assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, edges: 2 }); assert.deepEqual(await pipeline.runExtraction(chatId), { nodes: 4, chains: 2 });
const lonely = ledgers.readNodes().find((n) => n.name === "Lonely")!; 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 () => { 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, extractor,
auditor: submitModel("submit_review", { links: [] }), 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 () => { 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, graphStore: ledgers,
engineFor: (role) => (role === "extractor" ? oldShape() : submitModel("submit_review", { links: [] })), 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 () => { 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 first submit was schema-VALID — only the chain law caught it;
// the retry merges in full. // 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 () => { 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), extractor: retryEngine(bad, FINDINGS),
auditor: submitModel("submit_review", { links: [] }), 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 () => { 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), extractor: retryEngine(bad, FINDINGS),
auditor: submitModel("submit_review", { links: [] }), 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", () => { 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 }); 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. // The run surfaced as a NOTES run labeled with the file path.
assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]); assert.deepEqual(seen, [{ kind: "notes", label: "/vault/learn vim.md" }]);
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends"); 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); // independently (owner, 2026-09-29 — the mappings ledger is gone);
// cross-chunk identity is the auditor's linkTo verdict, and these // cross-chunk identity is the auditor's linkTo verdict, and these
// fixtures submit none. // fixtures submit none.
assert.deepEqual(stats, { nodes: calls, edges: 0 }); assert.deepEqual(stats, { nodes: calls, chains: 0 });
assert.equal(ledgers.readNodes().length, calls); 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 // Isolated job failure (per-chunk jobs, owner 2026-09-28): the run
// resolves zeros — no throw — and still nothing merged. // 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.readNodes().length, 0);
assert.equal(ledgers.readCoverage().length, 0, "nothing checkpointed — the messages stay re-runnable"); 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), auditor: submitModel("submit_review", NOTES_VERDICT),
}); });
const first = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); 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 // Unchanged text: the content fingerprint matches — no extraction
// at all (no model calls, no usage record; the mappings ledger // at all (no model calls, no usage record; the mappings ledger
// used to give this no-op at merge time by accident). // used to give this no-op at merge time by accident).
const second = await mk().importNotes({ filePath: "/vault/learn vim.md", text: NOTES_TEXT }); 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().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"); 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 // whole file re-runs (fresh merge; the auditor's links would dedupe
// identity on a real model). // identity on a real model).
const third = await mk().importNotes({ filePath: "/vault/learn vim.md", text: `${NOTES_TEXT}\nA new closing line.` }); 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.readSources()[0]!.extractions?.length, 2, "the re-run bills its usage");
assert.equal(ledgers.readNodes().length, 2); 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; const srcId = extractionStatuses().find((r) => r.chatId.startsWith("src_"))!.chatId;
assert.equal(abortExtraction(srcId), true); 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.readNodes().length, 0, "nothing merged");
assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed"); assert.equal(ledgers.readCheckpoints().length, 0, "nothing checkpointed");
assert.equal(ledgers.readSources()[0]!.extractions?.length, 1, "partial usage still persisted"); 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) // SEPARATE job's namespace (owner, 2026-09-29 — no mapping table)
// — its propositions merge again as fresh nodes. Cross-batch // — its propositions merge again as fresh nodes. Cross-batch
// identity is the auditor's linkTo verdict, never the merger's. // 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. // Per-batch coverage: every message checkpointed, nothing legacy.
const coverage = ledgers.readCoverage(); const coverage = ledgers.readCoverage();
@@ -1474,7 +1474,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
// new usage entry (no money spent on covered ground). // new usage entry (no money spent on covered ground).
const before = views.length; const before = views.length;
const again = await pipeline.runExtraction(chatId); 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); assert.equal(views.length, before);
// PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job // PER-CHUNK JOBS (owner 2026-09-28): one usage record per batch job
// (two), and the no-op run adds none. // (two), and the no-op run adds none.
@@ -1521,7 +1521,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
const stats = await runPromise; const stats = await runPromise;
// Batch 1's emissions are kept and its messages stay covered. // 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"); assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId); const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered"); 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. // SOURCE-WIDE abort: every chunk job of the fan-out dies together.
assert.equal(abortExtraction(srcId), true); 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(extractionStatuses().filter((r) => r.chatId === srcId).length, 0, "all rows leave the registry");
assert.equal(ledgers.readNodes().length, 0, "nothing merged"); assert.equal(ledgers.readNodes().length, 0, "nothing merged");
// Per-job usage: all 8 chunk jobs persisted their (failed) spend. // 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 // Identical findings in every chunk, one merge PER chunk (jobs are
// independent — owner, 2026-09-29): 8 chunks × 2 propositions, // independent — owner, 2026-09-29): 8 chunks × 2 propositions,
// 8 × 1 edge. The auditor's links, not the merger, dedupe identity. // 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); 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). // emissions (stats present but empty — nothing merged).
assert.ok(stats); assert.ok(stats);
assert.equal(stats.nodes, 0); assert.equal(stats.nodes, 0);
assert.equal(stats.edges, 0); assert.equal(stats.chains, 0);
// Nothing merged, nothing checkpointed — the deleted chat leaves no // Nothing merged, nothing checkpointed — the deleted chat leaves no
// trace for a later snapshot to resurrect. // trace for a later snapshot to resurrect.
assert.equal(ledgers.readNodes().length, 0); 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.readCoverage().length, 0);
assert.equal(ledgers.readCheckpoints().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 { auditorGraphTools } from "../src/agent/tools/graph.ts";
import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts"; import type { ChatStore, GraphStore, JobStore } from "../src/core/ports.ts";
import type { ChatSummary } from "../src/core/contracts/chat.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 ──────────────────────────────────────────────────────────────── // ─── fixtures ────────────────────────────────────────────────────────────────
@@ -39,10 +39,10 @@ function fakeStores(
chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>; chats?: Array<Pick<ChatSummary, "id" | "title" | "createdAt" | "updatedAt">>;
} = {}, } = {},
): { chatStore: ChatStore; graphStore: GraphStore } { ): { chatStore: ChatStore; graphStore: GraphStore } {
const edges: GraphEdge[] = []; const chains: GraphChain[] = [];
const graphStore: GraphStore = { const graphStore: GraphStore = {
readNodes: () => nodes, readNodes: () => nodes,
readEdges: () => edges, readChains: () => chains,
readCheckpoints: () => [], readCheckpoints: () => [],
readCoverage: () => [], readCoverage: () => [],
readSources: () => [], readSources: () => [],
+56 -51
View File
@@ -243,11 +243,10 @@ function fixtureFindings(): SessionFindings {
aliases: [], aliases: [],
}, },
], ],
edges: [ chains: [
{ {
from: "p1", premises: ["p1"],
to: "p2", conclusion: "p2",
type: "follows",
}, },
], ],
}; };
@@ -274,14 +273,14 @@ function scopedGraph(chatIds: string[]) {
const graph = getGraph(graphStore); const graph = getGraph(graphStore);
return { return {
nodes: graph.nodes.filter((n) => n.provenance.some((pt) => chatIds.includes(pt.chatId))), 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)", () => { describe("the merger (mergeFindings)", () => {
it("creates nodes from the verdicts with chat-level provenance", () => { it("creates nodes from the verdicts with chat-level provenance", () => {
const stats = mergeFindings(chat1.id, fixtureFindings(), fixtureVerdicts(), graphDeps, sessionSource(chat1.id)); 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]); const graph = scopedGraph([chat1.id]);
assert.equal(graph.nodes.length, 3); assert.equal(graph.nodes.length, 3);
@@ -298,13 +297,13 @@ describe("the merger (mergeFindings)", () => {
const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!; const alpha = graph.nodes.find((n) => n.statement === "Alpha intuition")!;
assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]); assert.deepEqual(alpha.provenance, [{ chatId: chat1.id, messageId: "" }]);
// The follows edge, provenance attached. // The stored chain, provenance attached.
assert.equal(graph.edges.length, 1); assert.equal(graph.chains.length, 1);
const edge = graph.edges[0]!; const chain = graph.chains[0]!;
assert.equal(edge.from, idByStatement.get("Alpha intuition")); assert.match(chain.id, /^arg_/, "store-minted argument id");
assert.equal(edge.to, idByStatement.get("Beta rule")); assert.deepEqual(chain.premises, [idByStatement.get("Alpha intuition")]);
assert.equal(edge.type, "follows"); assert.equal(chain.conclusion, idByStatement.get("Beta rule"));
assert.deepEqual(edge.provenance, [{ chatId: chat1.id, messageId: "" }]); 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: "p1", name: "Rep one", statement: "Independence one statement", aliases: [] },
{ id: "p2", name: "Rep two", statement: "Independence two 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)); 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)); 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]); const graph = scopedGraph([rep.id]);
assert.equal(graph.nodes.length, 4, "the propositions merge again as fresh nodes"); 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 // exactly one checkpoint line per merge, though — checkpoints are a log
assert.equal(graphStore.readCheckpoints().filter((c) => c.chatId === rep.id).length, 2); 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: [] },
{ 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)); 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", () => { it("applies a linkTo verdict: phrasings join aliases, provenance unions, no duplicate node", () => {
@@ -373,7 +372,7 @@ describe("the merger (mergeFindings)", () => {
aliases: ["alpha intuition"], aliases: ["alpha intuition"],
}, },
], ],
edges: [], chains: [],
}, },
// VERDICTS-ONLY: the identity call is a link entry, not a field // VERDICTS-ONLY: the identity call is a link entry, not a field
// on the proposition (owner, 2026-09-28). // on the proposition (owner, 2026-09-28).
@@ -381,7 +380,7 @@ describe("the merger (mergeFindings)", () => {
graphDeps, graphDeps,
sessionSource(chat2.id), sessionSource(chat2.id),
); );
assert.deepEqual(stats, { nodes: 0, edges: 0 }); assert.deepEqual(stats, { nodes: 0, chains: 0 });
const after = scopedGraph([chat1.id, chat2.id]); const after = scopedGraph([chat1.id, chat2.id]);
assert.equal(after.nodes.length, 3, "no duplicate node"); assert.equal(after.nodes.length, 3, "no duplicate node");
@@ -410,7 +409,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [], aliases: [],
}, },
], ],
edges: [], chains: [],
}, },
{ links: [] }, { links: [] },
graphDeps, graphDeps,
@@ -439,10 +438,9 @@ describe("the merger (mergeFindings)", () => {
statement, statement,
aliases: [], aliases: [],
})), })),
edges: [ chains: [
{ from: "p1", to: "p2", type: "follows", }, { premises: ["p1"], conclusion: "p2", },
{ from: "p2", to: "p3", type: "follows", }, { premises: ["p2"], conclusion: "p3", },
{ from: "p1", to: "p3", type: "follows", },
], ],
}, },
{ links: [] }, { links: [] },
@@ -494,7 +492,7 @@ describe("the merger (mergeFindings)", () => {
aliases: [], aliases: [],
}, },
], ],
edges: [], chains: [],
}, },
// empty verdicts — identity is the auditor's call alone // empty verdicts — identity is the auditor's call alone
{ links: [] }, { links: [] },
@@ -519,13 +517,13 @@ describe("the merger (mergeFindings)", () => {
aliases: [], aliases: [],
}, },
], ],
edges: [], chains: [],
}, },
{ links: [] }, { links: [] },
graphDeps, graphDeps,
{ foldedAt: "2026-09-13T00:00:00.000Z" }, { 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"))!; 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"); 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: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], },
{ id: "p2", name: "Solo fact", statement: "Solo fact statement", aliases: ["solo 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)); mergeFindings(dA.id, findingsA, { links: [] }, graphDeps, sessionSource(dA.id));
// Resolve through THIS session's provenance — the shared ledger // 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: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"] },
{ id: "q2", name: "B extra", statement: "B extra statement", aliases: ["b extra feel"], }, { 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)); mergeFindings(dB.id, findingsB, { links: [{ id: "q1", linkTo: xId }] }, graphDeps, sessionSource(dB.id));
const zId = bornOf(dB.id, "B extra statement"); 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"); const stats = deleteChatTraces(graphStore, dA.id, "delete-nodes");
assert.deepEqual(stats, { assert.deepEqual(stats, {
nodesRemoved: 1, nodesRemoved: 1,
edgesRemoved: 1, chainsRemoved: 1,
coverageRemoved: 2, coverageRemoved: 2,
checkpointsRemoved: 1, checkpointsRemoved: 1,
} satisfies ChatDeletionStats); } satisfies ChatDeletionStats);
@@ -684,9 +682,12 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
assert.equal(graphStore.readNodes().some((n) => n.id === yId), false); assert.equal(graphStore.readNodes().some((n) => n.id === yId), false);
// Z (dB-only) is untouched, byte-identical. // Z (dB-only) is untouched, byte-identical.
assert.equal(JSON.stringify(graphStore.readNodes().find((n) => n.id === zId)), zLineBefore); 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]); 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. // Positions: the orphan pruned.
const positions = graphStore.readPositions(); const positions = graphStore.readPositions();
assert.equal(positions[yId], undefined); 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: "" }] }); 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); assert.equal(graphStore.readNodes().length, nodesBefore + 1);
// Idempotent: a second run finds nothing. // 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", () => { it("preserve-graph: only the extraction bookkeeping dies — the graph ledgers are byte-identical", () => {
const { dA, dB, xId, yId, zId } = buildTwoSessionScenario(); const { dA, dB, xId, yId, zId } = buildTwoSessionScenario();
const before = { const before = {
nodes: graphStore.readNodes(), nodes: graphStore.readNodes(),
edges: graphStore.readEdges(), chains: graphStore.readChains(),
positions: graphStore.readPositions(), positions: graphStore.readPositions(),
}; };
const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph"); const stats = deleteChatTraces(graphStore, dA.id, "preserve-graph");
assert.deepEqual(stats, { assert.deepEqual(stats, {
nodesRemoved: 0, nodesRemoved: 0,
edgesRemoved: 0, chainsRemoved: 0,
coverageRemoved: 2, coverageRemoved: 2,
checkpointsRemoved: 1, checkpointsRemoved: 1,
} satisfies ChatDeletionStats); } satisfies ChatDeletionStats);
assert.deepEqual(graphStore.readNodes(), before.nodes); 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.deepEqual(graphStore.readPositions(), before.positions);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false); assert.equal(graphStore.readCoverage().some((row) => row.chatId === dA.id), false);
assert.equal(graphStore.readCoverage().some((row) => row.chatId === dB.id), true); 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 /** The full-death scenario: node X is born in a notes file (EMPTY
* provenance — a legacy line; the mappings anchor is gone, owner * provenance — a legacy line; the mappings anchor is gone, owner
* 2026-09-29), re-encountered by a chat (link line: alias + * 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() { function buildNodeScenario() {
// Clean slate per test: each test in this describe builds its own // Clean slate per test: each test in this describe builds its own
// scenario from empty ledgers (the suite's isolation covers the // scenario from empty ledgers (the suite's isolation covers the
// whole file; this covers describe-internal stacking). // whole file; this covers describe-internal stacking).
graphStore.rewriteLedgers({ nodes: [], edges: [] }); graphStore.rewriteLedgers({ nodes: [], chains: [] });
graphStore.writePositions({}); graphStore.writePositions({});
graphStore.appendNode({ graphStore.appendNode({
id: "n_dead", id: "n_dead",
@@ -802,18 +803,20 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
createdAt: "2026-09-23T00:00:00.000Z", createdAt: "2026-09-23T00:00:00.000Z",
provenance: [{ chatId: "chat_node_a", messageId: "m2" }], 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.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.appendEdge({ from: "n_keep", to: "n_dead", type: "supports", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] }); 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" }] });
graphStore.appendEdge({ from: "n_keep", to: "n_other", type: "follows", provenance: [{ chatId: "chat_node_b", messageId: "m3" }] }); // A FOREIGN chain the deletion must not touch (destructive rewriting
// Resting positions for both. // may not exceed the blast radius — foreign data rides untouched).
graphStore.writePositions({ n_dead: { x: 1, y: 1 }, n_keep: { x: 2, y: 2 } }); 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", () => { it("deleteNodeTraces: FULL DEATH — the node, its edges, its evidence, its anchors, its position", () => {
buildNodeScenario(); buildNodeScenario();
const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep")); const keepLineBefore = JSON.stringify(graphStore.readNodes().find((n) => n.id === "n_keep"));
const stats = deleteNodeTraces(graphStore, "n_dead"); 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 // The node's EVERY line is gone; the neighbor is byte-identical
// (full death is node-scoped — other nodes' sessions keep their // (full death is node-scoped — other nodes' sessions keep their
@@ -824,15 +827,17 @@ describe("single-node deletion (deleteNodeTraces + deleteGraphNode, node interlu
keepLineBefore, keepLineBefore,
"the untouched node is byte-identical", "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( assert.deepEqual(
graphStore.readEdges().map((e) => [e.from, e.to]), graphStore.readChains().map((c) => [c.premises, c.conclusion]),
[["n_keep", "n_other"]], [[["n_keep"], "n_other"]],
); );
// The resting position died with the node. // The resting position died with the node.
const positions = graphStore.readPositions(); const positions = graphStore.readPositions();
assert.equal(positions["n_dead"], undefined); assert.equal(positions["n_dead"], undefined);
assert.deepEqual(positions["n_keep"], { x: 2, y: 2 }); 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. // Atomic rewrites left no temp files.
assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0); assert.equal(fs.readdirSync(PATHS.graph).filter((f) => f.includes(".tmp-")).length, 0);
// Idempotent: a second run finds no node — null. // 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" }], provenance: [{ chatId: "chat_node_solo", messageId: "m1" }],
}); });
const stats = deleteNodeTraces(graphStore, "n_solo"); 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); 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 revisionBefore = graphRevision();
const outcome = deleteGraphNode("n_orch"); const outcome = deleteGraphNode("n_orch");
assert.ok(outcome.ok); 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 > 0);
assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved"); assert.ok(outcome.graphRevision >= revisionBefore, "the version stamp moved");
assert.equal(graphStore.readNodes().some((n) => n.id === "n_orch"), false); 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 { describe, it } from "node:test";
import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts"; import { createGraphSimulation, incrementalLayout, inferenceDepths } from "../src/core/layout.ts";
import { settleGraphLayout } from "../src/core/layout-settle.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 => ({ const node = (id: string): GraphNode => ({
id, id,
@@ -18,7 +18,9 @@ const node = (id: string): GraphNode => ({
createdAt: "2026-09-13T00:00:00.000Z", createdAt: "2026-09-13T00:00:00.000Z",
provenance: [], 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 } => ({ 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, 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 lines = [node("a"), node("b"), node("a")]; // "a" appended twice
const store = { const store = {
readNodes: () => lines.map((l) => ({ ...l })), readNodes: () => lines.map((l) => ({ ...l })),
readEdges: () => [edge("a", "b")], readChains: () => [chain("arg_ab", ["a"], "b")],
readPositions: () => ({} as Record<string, Position>), readPositions: () => ({} as Record<string, Position>),
writePositions: (p: Record<string, Position>) => void written.push(p), writePositions: (p: Record<string, Position>) => void written.push(p),
}; };
settleGraphLayout(store); settleGraphLayout(store);
assert.equal(written.length, 1); 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", () => { it("does nothing on an empty graph", () => {
const written: Record<string, Position>[] = []; const written: Record<string, Position>[] = [];
settleGraphLayout({ settleGraphLayout({
readNodes: () => [], readNodes: () => [],
readEdges: () => [], readChains: () => [],
readPositions: () => ({} as Record<string, Position>), readPositions: () => ({} as Record<string, Position>),
writePositions: (p) => void written.push(p), writePositions: (p) => void written.push(p),
}); });
+11 -10
View File
@@ -27,18 +27,18 @@ import {
type RunningPractice, type RunningPractice,
} from "../src/agent/subagents/practice-pipeline.ts"; } from "../src/agent/subagents/practice-pipeline.ts";
import { chainCoverage } from "../src/core/practice.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 type { ChatStore, GraphStore, PracticeStore } from "../src/core/ports.ts";
import { ModelsNotConfiguredError } from "../src/core/settings.ts"; import { ModelsNotConfiguredError } from "../src/core/settings.ts";
// ─── in-memory stores ──────────────────────────────────────────────────────── // ─── in-memory stores ────────────────────────────────────────────────────────
function memoryGraph(nodes: GraphNode[], edges: GraphEdge[]): GraphStore { function memoryGraph(nodes: GraphNode[], chains: GraphChain[]): GraphStore {
return { return {
appendNode: () => {}, appendNode: () => {},
readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })), readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })),
appendEdge: () => {}, appendChain: () => {},
readEdges: () => edges.map((e) => ({ ...e, provenance: [...e.provenance] })), readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
appendCheckpoint: () => {}, appendCheckpoint: () => {},
readCheckpoints: () => [], readCheckpoints: () => [],
appendCoverage: () => {}, appendCoverage: () => {},
@@ -284,15 +284,16 @@ function diamondGraph(): GraphStore {
createdAt: NOW, createdAt: NOW,
provenance: pt("chat_1"), provenance: pt("chat_1"),
}); });
const edge = (from: string, to: string, type: "follows" | "supports" = "follows"): GraphEdge => ({ const chain = (from: string, to: string): GraphChain => ({
from, id: to, // fixture id = conclusion id (the form the generator prompt renders)
to, premises: [from],
type, conclusion: to,
provenance: pt("chat_1"), provenance: pt("chat_1"),
createdAt: NOW,
}); });
return memoryGraph( return memoryGraph(
[node("a", "A"), node("b", "B"), node("c", "C"), node("d", "D"), node("e", "E")], [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.match(set.id, /^ps_/);
assert.equal(set.status, "partitioning"); 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. // Stubs appear the moment the partition lands — numbered, generating.
await until(() => f.practice.store.getSet(set.id)?.status === "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 — * PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
* chainsForSelection (group by conclusion, FOLLOWS ONLY — supports are * (chains-primary, owner 2026-09-30: a chain rides along iff every member
* invisible to practice, owner ruling) / chainsToGraph (the exact inverse, * is selected; nodes in no surviving chain are singletons) and
* round-trip pinned) — and chainCoverage (the set's completeness law: * chainCoverage (the set's completeness law: union of the problems' chains
* union of the problems' chains must equal the set's chains). * must equal the set's chains).
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import type { EdgeType } from "../src/core/contracts.ts"; import type { GraphChain } from "../src/core/contracts.ts";
import { chainCoverage, chainsForSelection, chainsToEdges, chainsToGraph, formatChainLine } from "../src/core/practice.ts"; import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
// ─── fixtures ──────────────────────────────────────────────────────────────── // ─── fixtures ────────────────────────────────────────────────────────────────
const n = (id: string) => ({ id }); 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. */ /** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
const diamond = { * arguments (each its own arg_ id, conclusion ids may repeat). */
nodes: [n("a"), n("b"), n("c"), n("d")], const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
edges: [e("a", "b"), e("a", "c"), e("b", "d"), e("c", "d")], const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
};
const diamondChains = [
{ id: "b", premises: ["a"], conclusion: "b" },
{ id: "c", premises: ["a"], conclusion: "c" },
{ id: "d", premises: ["b", "c"], conclusion: "d" },
];
// ─── chainsForSelection ────────────────────────────────────────────────────── // ─── chainsForSelection ──────────────────────────────────────────────────────
describe("chainsForSelection", () => { describe("chainsForSelection", () => {
it("groups by conclusion: the diamond a→b, a→c, b→d, c→d is three chains", () => { it("keeps every fully-selected stored argument, each under its own id", () => {
assert.deepEqual(chainsForSelection(diamond.nodes, diamond.edges), diamondChains); 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", () => { it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
const ids = chainsForSelection(diamond.nodes, diamond.edges).map((c) => c.id); const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
assert.ok(!ids.includes("a")); // a is only ever a premise assert.ok(!ids.includes("a")); // a is only ever a premise
}); });
it("an isolated node becomes a singleton chain", () => { 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" }); assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
}); });
it("supports edges are NO edges — nodes joined only by supports are singletons", () => { it("two independent arguments for one conclusion stay DISTINCT chains", () => {
const chains = chainsForSelection([n("x"), n("y")], [e("x", "y", "supports")]); // 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, [ assert.deepEqual(chains, [
{ id: "x", premises: [], conclusion: "x" }, { id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
{ id: "y", premises: [], conclusion: "y" }, { id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
]); ]);
}); });
it("an edge with an endpoint outside the selection is excluded entirely", () => { it("an argument with ANY member outside the selection is excluded entirely", () => {
// a→out would chain a; out is not selected, so the edge is gone and // arg_ab needs b; b is not selected, so the argument is gone and
// a (nothing else left) falls back to a singleton. // 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" }]); assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
}); });
it("duplicate edge assertions collapse into one premise", () => { it("a chain referencing unknown node ids simply never applies", () => {
const chains = chainsForSelection([n("a"), n("b")], [e("a", "b"), e("a", "b")]); const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]);
assert.deepEqual(chains, [{ id: "b", premises: ["a"], conclusion: "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( const shuffled = chainsForSelection(
[n("d"), n("c"), n("b"), n("a")], [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); 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" },
// ─── 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" },
]); ]);
}); });
}); });
// ─── chainCoverage ─────────────────────────────────────────────────────────── // ─── 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", () => { describe("chainCoverage", () => {
it("complete cover: every chain exercised, no missing", () => { it("complete cover: every chain exercised, no missing", () => {
const set = { const set = {
chains: diamondChains, chains: diamondChains,
problems: [{ chains: ["b", "c"] }, { chains: ["d"] }], problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
}; };
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
}); });
@@ -117,23 +109,23 @@ describe("chainCoverage", () => {
it("missing: a chain no problem exercises", () => { it("missing: a chain no problem exercises", () => {
const set = { const set = {
chains: diamondChains, 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", () => { it("duplicated is informational — a chain exercised twice is allowed", () => {
const set = { const set = {
chains: diamondChains, 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", () => { it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
const set = { const set = {
chains: [{ id: "b" }] as Array<{ id: string }>, chains: [{ id: "arg_b" }] as Array<{ id: string }>,
problems: [{ chains: ["b", "ghost"] }], problems: [{ chains: ["arg_b", "ghost"] }],
}; };
assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] }); 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"); 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 { describe, it } from "node:test";
import { ChainTableBody } from "../components/chain-table.tsx"; import { ChainTableBody } from "../components/chain-table.tsx";
import type { ChainSort } 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: /** The fixture graph — two follows-chains + one standalone:
* a → c1 ← b (chain one, conclusion born chat1), d → e (chain two, * 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), node("orphan", "Orphan", "Statement O.", null),
]; ];
const EDGES: GraphEdge[] = [ const CHAINS: GraphChain[] = [
{ from: "a", to: "c1", type: "follows" }, { id: "c1", premises: ["a", "b"], conclusion: "c1", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
{ from: "b", to: "c1", type: "follows" }, { id: "e", premises: ["d"], conclusion: "e", provenance: [], createdAt: "2026-09-20T10:00:00.000Z" },
{ from: "d", to: "e", type: "follows" }, ];
] as GraphEdge[];
const BASE = { const BASE = {
nodes: NODES, nodes: NODES,
edges: EDGES, chains: CHAINS,
sessions: { chat1: "Ethics lesson", chat2: "Second chat" }, sessions: { chat1: "Ethics lesson", chat2: "Second chat" },
fileSources: { notes1: "notes/raw/ethics.md" }, fileSources: { notes1: "notes/raw/ethics.md" },
query: "", query: "",
+7 -23
View File
@@ -118,8 +118,6 @@ describe("sanitizeRenderSettings", () => {
arrowLength: 3, arrowLength: 3,
labelZoom: 2, labelZoom: 2,
labelSize: 4, labelSize: 4,
showSupports: false,
supportsWidth: 0.3,
clusterLabels: false, clusterLabels: false,
clusterLabelSize: 20, clusterLabelSize: 20,
regions: DEFAULT_REGION_TUNING, regions: DEFAULT_REGION_TUNING,
@@ -128,27 +126,13 @@ describe("sanitizeRenderSettings", () => {
assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member assert.deepEqual(sanitizeRenderSettings(valid), valid); // every value a ladder member
}); });
test("showSupports (2026-09-29): boolean-only, default on — the draw toggle", () => { test("supports settings are GONE (chains-primary, 2026-09-30): stale keys silently drop", () => {
// A render-only hide for supports edges: anything but a real boolean // Supports edges were retired; showSupports/supportsWidth no longer
// (missing key, legacy file, garbage) loads as ON (today's look); a // exist. A legacy file carrying them loads without the keys — the
// real false round-trips untouched. // sanitizer drops unknown fields by construction.
assert.equal(sanitizeRenderSettings({}).showSupports, true); const cleaned = sanitizeRenderSettings({ showSupports: false, supportsWidth: 0.9 } as never);
assert.equal(sanitizeRenderSettings({ showSupports: false }).showSupports, false); assert.equal("showSupports" in cleaned, false);
assert.equal(sanitizeRenderSettings({ showSupports: "no" }).showSupports, true); assert.equal("supportsWidth" in cleaned, false);
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("the pre-2026-09-13 stored shape (flat, no regions/physics) still loads", () => { 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 assert from "node:assert/strict";
import { describe, it } from "node:test"; 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 type { ChatStore, GraphStore } from "../src/core/ports.ts";
import { import {
graphSessionIndex, graphSessionIndex,
@@ -40,10 +40,10 @@ function fakeStores(
sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>; sources?: Array<Pick<NotesSource, "id" | "filePath" | "importedAt">>;
} = {}, } = {},
): GraphDeps { ): GraphDeps {
const edges: GraphEdge[] = []; const chains: GraphChain[] = [];
const graphStore: GraphStore = { const graphStore: GraphStore = {
readNodes: () => nodes, readNodes: () => nodes,
readEdges: () => edges, readChains: () => chains,
readCheckpoints: () => [], readCheckpoints: () => [],
readCoverage: () => [], readCoverage: () => [],
readSources: () => readSources: () =>
+2 -2
View File
@@ -244,7 +244,7 @@ test("appendExtractionUsage: appends, idempotent per startedAt, not in list payl
startedAt, startedAt,
at: "2026-09-09T00:00:00Z", at: "2026-09-09T00:00:00Z",
durationMs: 42_000, durationMs: 42_000,
stats: { nodes: 1, edges: 2 }, stats: { nodes: 1, chains: 2 },
stages: { stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, 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 })] }, 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, startedAt,
at: "2026-09-13T00:00:00Z", at: "2026-09-13T00:00:00Z",
durationMs: 42_000, durationMs: 42_000,
stats: { nodes: 1, edges: 2 }, stats: { nodes: 1, chains: 2 },
stages: { stages: {
extractor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 100, outputTokens: 10 })] }, 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 })] }, auditor: { model: "openrouter:z-ai/glm-5.3-flash", steps: [call({ inputTokens: 50, outputTokens: 5 })] },