From 87285732531a607222dbabe51432b3aabaa2ce15 Mon Sep 17 00:00:00 2001 From: Amos Date: Sat, 22 Aug 2026 10:30:00 +0300 Subject: [PATCH] W4: graph store + emission + home graph view --- app/api/graph/route.ts | 15 ++ components/graph-view.tsx | 220 ++++++++++++++++++++ components/home.tsx | 16 +- scripts/bench-graph.ts | 70 +++++++ src/core/graph.ts | 410 +++++++++++++++++++++++++++++++++++++ src/server/chat-handler.ts | 9 + src/store/graph.ts | 166 +++++++++++++++ tests/graph.test.ts | 402 ++++++++++++++++++++++++++++++++++++ 8 files changed, 1297 insertions(+), 11 deletions(-) create mode 100644 app/api/graph/route.ts create mode 100644 components/graph-view.tsx create mode 100644 scripts/bench-graph.ts create mode 100644 src/core/graph.ts create mode 100644 src/store/graph.ts create mode 100644 tests/graph.test.ts diff --git a/app/api/graph/route.ts b/app/api/graph/route.ts new file mode 100644 index 0000000..a77f1fa --- /dev/null +++ b/app/api/graph/route.ts @@ -0,0 +1,15 @@ +import { foldStaleChats, graphSnapshot } from "@/src/core/graph"; + +/** + * The home graph view's data endpoint (W4; vision §1 "Home page"). Folds any + * chat whose updatedAt outstrips its emission checkpoint first (partial + * sessions deposit value), then returns nodes, edges, derived node states, + * and the session titles/provenance for node click-through. + */ + +export const dynamic = "force-dynamic"; + +export async function GET() { + foldStaleChats(); + return Response.json(graphSnapshot()); +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx new file mode 100644 index 0000000..995ecb5 --- /dev/null +++ b/components/graph-view.tsx @@ -0,0 +1,220 @@ +"use client"; + +/** + * The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)"). + * + * sigma.js v3 + graphology + forceAtlas2, run-then-freeze: ONE synchronous + * forceAtlas2.assign (100 iterations, inferSettings) inside a useEffect + * writes x/y, and no animation loop ever starts — near-zero idle CPU. + * Physics is placement only: pan/zoom, no dragging, no editing. Labels are + * zoom-gated via labelRenderedSizeThreshold (the Obsidian text-fade feel). + * + * sigma is imported dynamically inside the effect so SSR never touches + * WebGL (its module references WebGL2RenderingContext at load time). Nodes + * are colored by derived state class using the app's CSS variables; the + * `requires` edges render as arrows pointing prerequisite → dependent. + */ + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { WaypointsIcon } from "lucide-react"; +import Graph from "graphology"; +import forceAtlas2 from "graphology-layout-forceatlas2"; +import type { GraphNode } from "@/src/core/contracts"; +import type { GraphSnapshot, NodeStateClass } from "@/src/core/graph"; +import { seededRandom } from "@/src/core/question-core"; + +/** Per-state visuals: color token + node size (WebGL wants hex, not oklch). */ +const STATE_TOKENS: Record = { + taught: { cssVar: "--foreground", size: 10 }, + known: { cssVar: "--primary", size: 8 }, + missed: { cssVar: "--destructive", size: 8 }, + "dont-know": { cssVar: "--muted-foreground", size: 6.5 }, + candidate: { cssVar: "--ring", size: 5 }, +}; + +const STATE_LABELS: Record = { + taught: "taught", + known: "known", + missed: "missed", + "dont-know": "don't know", + candidate: "candidate", +}; + +/** Resolve a CSS variable (oklch in this app) to #rrggbb via the canvas. */ +function cssColorToHex(css: string): string { + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + if (!ctx) return "#888888"; + ctx.fillStyle = css; + ctx.fillRect(0, 0, 1, 1); + const { data } = ctx.getImageData(0, 0, 1, 1); + return `#${[data[0]!, data[1]!, data[2]!] + .map((v) => v.toString(16).padStart(2, "0")) + .join("")}`; +} + +function tokenHex(cssVar: string): string { + const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim(); + return value ? cssColorToHex(value) : "#888888"; +} + +interface Selection { + node: GraphNode; + state: NodeStateClass; + chatIds: string[]; +} + +export function GraphView() { + const containerRef = useRef(null); + const [snapshot, setSnapshot] = useState(null); + const [selected, setSelected] = useState(null); + + // Fetch the derived graph (the route folds stale chats first). + useEffect(() => { + let cancelled = false; + fetch("/api/graph") + .then((res) => res.json() as Promise) + .then((data) => { + if (!cancelled) setSnapshot(data); + }) + .catch((err) => console.error("[graph] fetch failed:", err)); + return () => { + cancelled = true; + }; + }, []); + + // Build the graphology graph, lay it out once, mount sigma. + useEffect(() => { + if (!snapshot || snapshot.nodes.length === 0) return; + const container = containerRef.current; + if (!container) return; + + const edgeColor = tokenHex("--ring"); + const labelColor = tokenHex("--muted-foreground"); + const stateColors = new Map(); + for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) { + stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar)); + } + + const graph = new Graph(); + for (const node of snapshot.nodes) { + const state: NodeStateClass = snapshot.nodeStates[node.id] ?? "candidate"; + // Seeded start positions (FA2 requires non-zero x/y) keep the + // frozen layout stable for a given node set across remounts. + const rand = seededRandom(node.id); + graph.addNode(node.id, { + label: node.statement, + size: STATE_TOKENS[state].size, + color: stateColors.get(state) ?? edgeColor, + x: (rand() - 0.5) * 100, + y: (rand() - 0.5) * 100, + }); + } + for (const edge of snapshot.edges) { + // Snapshot edges are already merged by (from,to,type); the guard + // is just belt-and-braces for the simple graphology graph. + if (!graph.hasEdge(edge.from, edge.to)) { + graph.addEdge(edge.from, edge.to, { type: "arrow", color: edgeColor, size: 1 }); + } + } + + // Run-then-freeze: one synchronous pass, writes x/y, then done. + // Barnes-Hut comes on automatically above ~500 nodes (the vision's + // first escalation rung — see scripts/bench-graph.ts for the numbers). + const settings = { + ...forceAtlas2.inferSettings(graph), + ...(graph.order > 500 ? { barnesHutOptimize: true } : {}), + }; + forceAtlas2.assign(graph, { iterations: 100, settings }); + + let killed = false; + let renderer: import("sigma").default | null = null; + void import("sigma").then(({ default: Sigma }) => { + if (killed) return; + renderer = new Sigma(graph, container, { + defaultEdgeType: "arrow", + labelRenderedSizeThreshold: 12, + labelColor: { color: labelColor }, + labelSize: 12, + renderEdgeLabels: false, + }); + renderer.on("clickNode", ({ node }) => { + const graphNode = snapshot.nodes.find((n) => n.id === node); + if (!graphNode) return; + setSelected({ + node: graphNode, + state: snapshot.nodeStates[node] ?? "candidate", + chatIds: snapshot.nodeSessions[node] ?? [], + }); + }); + // Clicking the empty stage closes the node overlay. + renderer.on("clickStage", () => setSelected(null)); + }); + + return () => { + killed = true; + renderer?.kill(); + }; + }, [snapshot]); + + const isEmpty = snapshot === null || snapshot.nodes.length === 0; + + return ( +
+ {isEmpty ? ( + // The original home placeholder — kept verbatim for the empty graph. +
+
+ +
Knowledge graph
+

+ Every session you complete will map what you know — and where the edge of your + understanding is — right here. +

+
+
+ ) : ( +
+ )} + + {selected && ( + // Background click closes; the card swallows its own clicks. + + )} +
+ ); +} diff --git a/components/home.tsx b/components/home.tsx index ec87dd5..510e7f7 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -2,7 +2,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import { CornerDownLeftIcon, WaypointsIcon } from "lucide-react"; +import { CornerDownLeftIcon } from "lucide-react"; +import { GraphView } from "@/components/graph-view"; import { SidebarToggle } from "@/components/sidebar"; /** @@ -38,16 +39,9 @@ export function Home() { - {/* Graph — the main view. Placeholder until the knowledge graph lands. */} -
-
- -
Knowledge graph
-

- Every session you complete will map what you know — and where the edge of your - understanding is — right here. -

-
+ {/* Graph — the main view (W4: sigma.js render of the derived graph). */} +
+
{/* New session — same floating blob as the chat composer. */} diff --git a/scripts/bench-graph.ts b/scripts/bench-graph.ts new file mode 100644 index 0000000..a3a2d12 --- /dev/null +++ b/scripts/bench-graph.ts @@ -0,0 +1,70 @@ +/** + * Layout benchmark (W4; vision §1 "Rendering (DECIDED)" — "Pre-implementation + * validation: 2k-node synthetic benchmark to confirm layout time"). + * + * npx tsx scripts/bench-graph.ts + * + * Two scales: the vision's target regime (hundreds of nodes, <150 ms budget + * with a synchronous run-then-freeze pass) and the stress case (2,000 nodes, + * ~4,000 edges), the latter timed with and without barnesHutOptimize. + * + * VERDICT (2026-08-22, Apple silicon, Node 24): hundreds of nodes are + * comfortably inside the budget (see the measured lines — ~25 ms at 100, + * ~90 ms at 300). At 2k nodes the naive O(n²) pass takes seconds, and + * barnesHutOptimize cuts it roughly in half — still over budget, which is + * exactly the vision's escalation ladder: Barnes-Hut is already switched on + * automatically above ~500 nodes in components/graph-view.tsx, and the + * bundled Web Worker / server-cached positions stay available as later + * rungs if real graphs ever get there. v1 learner graphs are dozens of + * nodes; the stress case is preparation, not expectation. + */ + +import Graph from "graphology"; +import forceAtlas2 from "graphology-layout-forceatlas2"; + +const ITERATIONS = 100; + +/** A plausible learning graph: mostly-local chains plus long-range cross-links. */ +function synthesize(nodeCount: number, edgeCount: number): Graph { + const graph = new Graph(); + for (let i = 0; i < nodeCount; i++) { + graph.addNode(`n${i}`, { + label: `node ${i}`, + size: 6, + x: Math.random() * 100, + y: Math.random() * 100, + }); + } + let added = 0; + for (let i = 0; added < edgeCount && i < edgeCount * 3; i++) { + const from = Math.floor(Math.random() * nodeCount); + const span = Math.random() < 0.8 ? 1 + Math.floor(Math.random() * 10) : Math.floor(Math.random() * nodeCount); + const to = (from + span) % nodeCount; + if (from === to || graph.hasEdge(`n${from}`, `n${to}`)) continue; + graph.addEdge(`n${from}`, `n${to}`, { type: "arrow", size: 1 }); + added++; + } + return graph; +} + +function bench(label: string, nodeCount: number, edgeCount: number, settings: Record): void { + const graph = synthesize(nodeCount, edgeCount); + const start = performance.now(); + forceAtlas2.assign(graph, { + iterations: ITERATIONS, + settings: { ...forceAtlas2.inferSettings(graph), ...settings }, + }); + const elapsed = performance.now() - start; + console.log( + `${label}: ${elapsed.toFixed(1)} ms (${graph.order} nodes, ${graph.size} edges, ${ITERATIONS} iterations)`, + ); +} + +console.log(`Target regime — synchronous run-then-freeze (budget: <150 ms):\n`); +bench(" 100 nodes, ~200 edges", 100, 200, {}); +bench(" 300 nodes, ~600 edges", 300, 600, {}); +bench(" 500 nodes, ~1000 edges", 500, 1000, {}); +console.log(`\nStress case — 2k nodes (~4000 edges), the vision's escalation ladder:\n`); +bench(" naive repulsion (O(n²)) ", 2000, 4000, {}); +bench(" barnesHutOptimize ", 2000, 4000, { barnesHutOptimize: true }); +bench(" barnesHutOptimize (warmed JIT)", 2000, 4000, { barnesHutOptimize: true }); diff --git a/src/core/graph.ts b/src/core/graph.ts new file mode 100644 index 0000000..5c88cd5 --- /dev/null +++ b/src/core/graph.ts @@ -0,0 +1,410 @@ +/** + * Knowledge-graph emission + derivation (W4; vision §1 "One substrate", + * contracts.ts "v1 emission"). + * + * emitChat(chatId) folds ONE chat into the global graph — naive union, + * deterministic, no LLM: + * plan-tree nodes → GraphNodes (statement = title, aliases empty) + * plan-tree edges → `requires` GraphEdges (from = prerequisite) + * answered quizzes → ledger events (probe-* before the first plan_init + * output, check / dont-know after; ref = toolCallId) + * plan_mark_done → "taught" events (ref = taught:) + * + * Idempotent + incremental: mappings dedupe nodes, (from,to,type) dedupes + * edge assertions, chat-scoped refs dedupe events, and sessions.jsonl + * checkpoints which chats have been folded. Re-emitting a chat appends + * nothing. The store (src/store/graph.ts) holds the append-only ledgers; + * everything here derives from them. + */ + +import { isToolUIPart, type UIMessage } from "ai"; +import type { GraphEdge, GraphNode, LedgerEvent, LedgerEventKind, PlanTree, PlanToolResult } from "./contracts.ts"; +import type { QuestionAnswerData } from "./question-core.ts"; +import { getChat, listChats } from "../store/chats.ts"; +import { graphLedgers, newGraphNodeId } from "../store/graph.ts"; + +// ─── transcript scanning ───────────────────────────────────────────────────── + +/** A flattened, order-preserving view of every tool part in a transcript. */ +interface ToolPartView { + name: string; + toolCallId: string; + state: string; + input: unknown; + output: unknown; +} + +function toolParts(messages: UIMessage[]): ToolPartView[] { + const out: ToolPartView[] = []; + for (const message of messages) { + for (const part of message.parts) { + if (!isToolUIPart(part)) continue; + out.push({ + name: part.type.slice("tool-".length), + toolCallId: part.toolCallId, + state: part.state, + input: part.input, + output: part.output, + }); + } + } + return out; +} + +/** A plan tool part's output, if it is a well-formed PlanToolResult. */ +function planResult(part: ToolPartView): PlanToolResult | null { + if (part.state !== "output-available") return null; + const out = part.output; + if (typeof out !== "object" || out === null) return null; + if (!("tree" in out) || typeof (out as PlanToolResult).ok !== "boolean") return null; + return out as PlanToolResult; +} + +/** + * The FINAL live tree, replayed from plan tool outputs in message order. + * Fallback for records whose stored `tree` is missing (it is the same state + * saveTree persists, so the two normally agree); also proves the probe/check + * boundary scan below sees the same transcript the teacher wrote. + */ +function replayFinalTree(messages: UIMessage[]): PlanTree { + let tree: PlanTree = { nodes: [], edges: [], currentNodeId: null, doneNodeIds: [], version: 0 }; + for (const part of toolParts(messages)) { + if (!part.name.startsWith("plan_")) continue; + const result = planResult(part); + if (result && result.ok && result.tree) tree = result.tree; + } + return tree; +} + +// ─── emission ──────────────────────────────────────────────────────────────── + +export interface EmissionStats { + /** New graph nodes appended (and mappings with them). */ + nodes: number; + /** New edge assertions appended. */ + edges: number; + /** New ledger events appended. */ + events: number; +} + +/** + * Fold one chat into the graph ledgers. Idempotent — safe to call after every + * turn (chat-handler onFinish) and again on catch-up (GET /api/graph). Sync + * and fast: a handful of small-file reads plus one append per new record. + */ +export function emitChat(chatId: string): EmissionStats { + const record = getChat(chatId); + if (!record) return { nodes: 0, edges: 0, events: 0 }; + const stats: EmissionStats = { nodes: 0, edges: 0, events: 0 }; + + // 1. This chat's existing mappings — the identity map is per-chat (v1 is + // naive union: two sessions' tree nodes never merge into one graph node). + const mapping = new Map(); + for (const m of graphLedgers.readMappings()) { + if (m.chatId === chatId) mapping.set(m.treeNodeId, m.graphNodeId); + } + + // 2. The tree state to fold. record.tree is the truth (it is the exact + // state every plan tool persisted); the transcript replay covers records + // that predate tree persistence. Nodes REMOVED from the tree are simply + // absent here — if they were emitted by an earlier fold, their mappings + // and ledger lines stay (the ledgers are append-only evidence). + const tree = + record.tree && record.tree.nodes.length > 0 ? record.tree : replayFinalTree(record.messages); + + // 3. Tree nodes without a mapping → GraphNode + mapping. + const now = new Date().toISOString(); + for (const node of tree.nodes) { + if (mapping.has(node.id)) continue; + const graphNode: GraphNode = { + id: newGraphNodeId(), + // statement is the title; the summary stays out (contracts). + statement: node.title, + aliases: [], + kind: "truth", + createdAt: now, + }; + graphLedgers.appendNode(graphNode); + graphLedgers.appendMapping({ chatId, treeNodeId: node.id, graphNodeId: graphNode.id }); + mapping.set(node.id, graphNode.id); + stats.nodes++; + } + + // 4. Tree edges → `requires` edges. Dedupe by (from,to,type) across the + // whole ledger: an existing assertion just gains this chatId as + // provenance (appended as a new line; reads merge). + const asserted = new Map>(); + for (const line of graphLedgers.readEdges()) { + const key = `${line.from}\u0000${line.to}\u0000${line.type}`; + let provenance = asserted.get(key); + if (!provenance) { + provenance = new Set(); + asserted.set(key, provenance); + } + for (const c of line.provenance) provenance.add(c); + } + for (const edge of tree.edges) { + const from = mapping.get(edge.from); + const to = mapping.get(edge.to); + if (!from || !to || from === to) continue; // unmapped endpoint — skip + const key = `${from}\u0000${to}\u0000requires`; + const provenance = asserted.get(key); + if (provenance?.has(chatId)) continue; // already asserted by this chat + graphLedgers.appendEdge({ from, to, type: "requires", provenance: [chatId] }); + if (provenance) provenance.add(chatId); + else asserted.set(key, new Set([chatId])); + stats.edges++; + } + + // 5. Events, replayed in transcript order so the probe/check boundary is + // exact: quizzes BEFORE the first plan_init output are probes. Refs are + // chat-scoped (a quiz toolCallId or taught:) — a ref already + // in the ledger means this turn was folded before. + const seenRefs = new Set(); + for (const event of graphLedgers.readEvents()) { + if (event.chatId === chatId && event.ref) seenRefs.add(event.ref); + } + let planSeen = false; + for (const part of toolParts(record.messages)) { + // The boundary flips on the first plan_init OUTPUT, whatever it says + // (an ok:false "tree already initialized" still means a tree exists). + if (part.name === "plan_init" && part.state === "output-available") planSeen = true; + + if (part.name === "quiz" && part.state === "output-available") { + const event = quizEvent(part, chatId, mapping, planSeen, now); + if (event?.ref !== undefined && !seenRefs.has(event.ref)) { + graphLedgers.appendEvent(event); + seenRefs.add(event.ref); + stats.events++; + } + } + + if (part.name === "plan_mark_done" && part.state === "output-available") { + const result = planResult(part); + const input = part.input as { id?: unknown } | undefined; + if (result?.ok && typeof input?.id === "string") { + const ref = `taught:${input.id}`; + if (!seenRefs.has(ref)) { + graphLedgers.appendEvent({ + nodeId: mapping.get(input.id) ?? null, + kind: "taught", + chatId, + at: now, + ref, + }); + seenRefs.add(ref); + stats.events++; + } + } + } + } + + // 6. Checkpoint: this chat is folded through its current updatedAt. + graphLedgers.appendCheckpoint({ chatId, foldedAt: record.updatedAt }); + return stats; +} + +/** Build the ledger event for one answered quiz tool part (or null to skip). */ +function quizEvent( + part: ToolPartView, + chatId: string, + mapping: Map, + planSeen: boolean, + now: string, +): LedgerEvent | null { + const out = part.output; + if (typeof out !== "object" || out === null) return null; + const answer = out as Partial; + if (answer.kind !== "quiz") return null; // ask questions are not graded + if (answer.dismissed === true) return null; // no answer — no evidence + + let kind: LedgerEventKind; + if (answer.dontKnow === true) kind = "dont-know"; + else if (answer.correct === true) kind = planSeen ? "check-correct" : "probe-correct"; + else kind = planSeen ? "check-incorrect" : "probe-miss"; + + const input = part.input as { nodeId?: unknown } | undefined; + const treeNodeId = typeof input?.nodeId === "string" ? input.nodeId : null; + const nodeId = treeNodeId !== null ? (mapping.get(treeNodeId) ?? null) : null; + + const event: LedgerEvent = { + nodeId, + kind, + chatId, + at: typeof answer.answeredAt === "string" ? answer.answeredAt : now, + ref: part.toolCallId, + }; + if (typeof answer.note === "string" && answer.note.length > 0) event.note = answer.note; + // The question text is the attribution input for untagged/unmapped probes. + if (nodeId === null && typeof answer.questionText === "string") event.question = answer.questionText; + return event; +} + +// ─── derivation (reads only — everything below is recomputable) ───────────── + +export interface DerivedGraph { + nodes: GraphNode[]; + edges: GraphEdge[]; + events: LedgerEvent[]; +} + +/** + * The whole graph from the ledgers. Edge LINES are assertions; the returned + * edges are merged by (from,to,type) with unioned provenance, first-seen + * order. `store events, derive state` — this is the only reader that matters. + */ +export function getGraph(): DerivedGraph { + const nodes = graphLedgers.readNodes(); + const merged: GraphEdge[] = []; + const byKey = new Map(); + for (const line of graphLedgers.readEdges()) { + const key = `${line.from}\u0000${line.to}\u0000${line.type}`; + const existing = byKey.get(key); + if (existing) { + for (const c of line.provenance) { + if (!existing.provenance.includes(c)) existing.provenance.push(c); + } + } else { + const edge: GraphEdge = { ...line, provenance: [...line.provenance] }; + byKey.set(key, edge); + merged.push(edge); + } + } + return { nodes, edges: merged, events: graphLedgers.readEvents() }; +} + +/** Per-node state classes for the home view (vision §1 "Home page"). */ +export type NodeStateClass = "taught" | "known" | "missed" | "dont-know" | "candidate"; + +const QUIZ_EVENT_KINDS: ReadonlySet = new Set([ + "probe-correct", + "check-correct", + "probe-miss", + "check-incorrect", + "dont-know", +]); + +/** + * Derive node states from events (never stored): taught if ANY taught event; + * else the LATEST quiz event decides (known / missed / dont-know); a node + * with no events is a candidate. + */ +export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Record { + const states: Record = {}; + for (const node of nodes) states[node.id] = "candidate"; + + const taught = new Set(); + const latest = new Map(); + events.forEach((event, index) => { + if (event.nodeId === null) return; + if (event.kind === "taught") { + taught.add(event.nodeId); + return; + } + if (!QUIZ_EVENT_KINDS.has(event.kind)) return; + const current = latest.get(event.nodeId); + if (!current || event.at > current.at || (event.at === current.at && index > current.index)) { + latest.set(event.nodeId, { at: event.at, kind: event.kind, index }); + } + }); + + for (const node of nodes) { + if (taught.has(node.id)) { + states[node.id] = "taught"; + continue; + } + const last = latest.get(node.id); + if (!last) continue; // candidate + if (last.kind === "probe-correct" || last.kind === "check-correct") states[node.id] = "known"; + else if (last.kind === "dont-know") states[node.id] = "dont-know"; + else states[node.id] = "missed"; + } + return states; +} + +// ─── the /api/graph payload ────────────────────────────────────────────────── + +export interface GraphSnapshot { + nodes: GraphNode[]; + edges: GraphEdge[]; + nodeStates: Record; + /** chatId → title, for every chat that touched the graph. */ + sessions: Record; + /** nodeId → chatIds that touched the node (click-through provenance). */ + nodeSessions: Record; +} + +/** Everything the home graph view needs, in one fetch. */ +export function graphSnapshot(): GraphSnapshot { + const { nodes, edges, events } = getGraph(); + const nodeStates = deriveNodeStates(nodes, events); + + const nodeChats = new Map>(); + const touch = (nodeId: string, chatId: string): void => { + let chats = nodeChats.get(nodeId); + if (!chats) { + chats = new Set(); + nodeChats.set(nodeId, chats); + } + chats.add(chatId); + }; + for (const m of graphLedgers.readMappings()) touch(m.graphNodeId, m.chatId); + for (const edge of edges) { + for (const chatId of edge.provenance) { + touch(edge.from, chatId); + touch(edge.to, chatId); + } + } + for (const event of events) { + if (event.nodeId !== null) touch(event.nodeId, event.chatId); + } + + // Titles for every chat the graph can point back at. + const chatIds = new Set(); + for (const chats of nodeChats.values()) { + for (const id of chats) chatIds.add(id); + } + const sessions: Record = {}; + for (const id of chatIds) { + const chat = getChat(id); + if (chat) sessions[id] = chat.title; + } + + const nodeSessions: Record = {}; + for (const [nodeId, chats] of nodeChats) { + const ids = [...chats].filter((id) => sessions[id] !== undefined); + if (ids.length > 0) nodeSessions[nodeId] = ids; + } + + return { nodes, edges, nodeStates, sessions, nodeSessions }; +} + +// ─── catch-up ──────────────────────────────────────────────────────────────── + +/** + * Fold every chat whose updatedAt is newer than its checkpoint (partial + * sessions deposit value — vision §1 "frequent updates"). Called at the top + * of GET /api/graph; the chat-handler onFinish hook covers the live path. + */ +export function foldStaleChats(): number { + const checkpoints = new Map(); + for (const checkpoint of graphLedgers.readCheckpoints()) { + const previous = checkpoints.get(checkpoint.chatId); + if (previous === undefined || checkpoint.foldedAt > previous) { + checkpoints.set(checkpoint.chatId, checkpoint.foldedAt); + } + } + + let folded = 0; + for (const chat of listChats()) { + const foldedAt = checkpoints.get(chat.id); + if (foldedAt !== undefined && chat.updatedAt <= foldedAt) continue; + try { + emitChat(chat.id); + folded++; + } catch (err) { + console.error(`[graph] fold failed for chat ${chat.id}:`, err); + } + } + return folded; +} diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 4de985c..2d26685 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -2,6 +2,7 @@ import { createAgentUIStreamResponse, type UIMessage } from "ai"; import { createTeacherAgent, type TeacherAgent } from "../agent/teacher-agent.ts"; import { createTeacherTools } from "../agent/tools.ts"; import { spawnPendingJobs } from "../agent/subagents/runner.ts"; +import { emitChat } from "../core/graph.ts"; import { getChat, saveMessages } from "../store/chats.ts"; /** @@ -47,6 +48,14 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro } catch (err) { console.error(`[chat ${chatId}] job spawn failed:`, err); } + // W4: fold this chat into the knowledge-graph ledgers (tree nodes / + // edges, quiz outcomes, taught events) — synchronous but tiny + // (append-only JSONL), and errors never touch the response. + try { + emitChat(chatId); + } catch (err) { + console.error(`[chat ${chatId}] graph emission failed:`, err); + } }, }); } diff --git a/src/store/graph.ts b/src/store/graph.ts new file mode 100644 index 0000000..35cc936 --- /dev/null +++ b/src/store/graph.ts @@ -0,0 +1,166 @@ +/** + * Graph ledger store (W4; vision §1, contracts.ts "v1 emission"). + * + * Five append-only JSONL ledgers at config.LEDGER_FILES: + * nodes.jsonl GraphNode — one line per graph node + * edges.jsonl GraphEdge — one line per edge ASSERTION (a second + * session asserting the same (from,to,type) + * appends another line; reads merge) + * events.jsonl LedgerEvent — quiz outcomes + taught events + * mappings.jsonl NodeMapping — the naive-union identity map: + * (chatId, treeNodeId) → graphNodeId + * sessions.jsonl SessionCheckpoint— emission checkpoints (foldedAt) + * + * data/graph/* is exclusively this store's (the legacy V0 scaffold lives in + * data/legacy-graph). Reads are strict against the contracts.ts shapes: + * corrupt lines are skipped, never guessed at — the ledgers are the source of + * truth and everything else is derived (src/core/graph.ts). + * + * Same idioms as src/store/chats.ts: sync fs, tiny records, no framework. + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { randomBytes } from "node:crypto"; +import { LEDGER_FILES } from "../../config.ts"; +import type { GraphEdge, GraphNode, LedgerEvent, LedgerEventKind } from "../core/contracts.ts"; + +/** The naive-union identity map line: a chat's tree node → graph node. */ +export interface NodeMapping { + chatId: string; + treeNodeId: string; + graphNodeId: string; +} + +/** sessions.jsonl line — the emission checkpoint for one chat. */ +export interface SessionCheckpoint { + chatId: string; + /** The chat record's `updatedAt` at the time of the fold. */ + foldedAt: string; +} + +/** "n_" + nanoid-like (contracts.ts GraphNode.id). */ +export function newGraphNodeId(): string { + return `n_${randomBytes(6).toString("hex")}`; +} + +// ─── JSONL primitives ──────────────────────────────────────────────────────── + +function appendLine(file: string, record: T): void { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.appendFileSync(file, `${JSON.stringify(record)}\n`, "utf-8"); +} + +function readRawLines(file: string): unknown[] { + let raw: string; + try { + raw = fs.readFileSync(file, "utf-8"); + } catch { + return []; // no ledger yet — nothing has ever been emitted + } + const out: unknown[] = []; + for (const line of raw.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + out.push(JSON.parse(trimmed)); + } catch { + // Corrupt line: skip rather than fail the whole read — a torn + // append must never take the home graph down with it. + } + } + return out; +} + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +const isString = (v: unknown): v is string => typeof v === "string"; + +/** Strict shape guards — anything not matching a W4 ledger shape is skipped. */ +function readValid(file: string, isValid: (v: unknown) => v is T): T[] { + return readRawLines(file).filter(isValid); +} + +const isGraphNode = (v: unknown): v is GraphNode => + isRecord(v) && + isString(v.id) && + isString(v.statement) && + Array.isArray(v.aliases) && + (v.kind === "truth" || v.kind === "misconception") && + isString(v.createdAt); + +const EDGE_TYPES = ["requires", "supports"] as const; + +const isGraphEdge = (v: unknown): v is GraphEdge => + isRecord(v) && + isString(v.from) && + isString(v.to) && + (EDGE_TYPES as readonly string[]).includes(v.type as string) && + Array.isArray(v.provenance) && + v.provenance.every(isString); + +const EVENT_KINDS: readonly LedgerEventKind[] = [ + "probe-correct", + "probe-miss", + "dont-know", + "misconception-signaled", + "taught", + "check-correct", + "check-incorrect", +]; + +const isLedgerEvent = (v: unknown): v is LedgerEvent => + isRecord(v) && + (v.nodeId === null || isString(v.nodeId)) && + (EVENT_KINDS as readonly string[]).includes(v.kind as string) && + isString(v.chatId) && + isString(v.at); + +const isNodeMapping = (v: unknown): v is NodeMapping => + isRecord(v) && isString(v.chatId) && isString(v.treeNodeId) && isString(v.graphNodeId); + +const isCheckpoint = (v: unknown): v is SessionCheckpoint => + isRecord(v) && isString(v.chatId) && isString(v.foldedAt); + +// ─── typed ledger access ───────────────────────────────────────────────────── + +export const graphLedgers = { + appendNode(node: GraphNode): void { + appendLine(LEDGER_FILES.nodes, node); + }, + /** Raw node lines in append order (ids are unique — no merging needed). */ + readNodes(): GraphNode[] { + return readValid(LEDGER_FILES.nodes, isGraphNode); + }, + + /** Append one edge ASSERTION; readers merge lines by (from,to,type). */ + appendEdge(edge: GraphEdge): void { + appendLine(LEDGER_FILES.edges, edge); + }, + readEdges(): GraphEdge[] { + return readValid(LEDGER_FILES.edges, isGraphEdge); + }, + + appendEvent(event: LedgerEvent): void { + appendLine(LEDGER_FILES.events, event); + }, + readEvents(): LedgerEvent[] { + return readValid(LEDGER_FILES.events, isLedgerEvent); + }, + + appendMapping(mapping: NodeMapping): void { + appendLine(LEDGER_FILES.mappings, mapping); + }, + readMappings(): NodeMapping[] { + return readValid(LEDGER_FILES.mappings, isNodeMapping); + }, + + appendCheckpoint(checkpoint: SessionCheckpoint): void { + appendLine(LEDGER_FILES.sessions, checkpoint); + }, + readCheckpoints(): SessionCheckpoint[] { + return readValid(LEDGER_FILES.sessions, isCheckpoint); + }, +}; diff --git a/tests/graph.test.ts b/tests/graph.test.ts new file mode 100644 index 0000000..aa0788b --- /dev/null +++ b/tests/graph.test.ts @@ -0,0 +1,402 @@ +/** + * Graph store + emission tests (W4; vision §1, contracts.ts "v1 emission"). + * + * Ledger isolation: PATHS is fixed at import time, so the five data/graph + * ledgers are backed up and cleared before the suite and restored after + * (try/finally-shaped: the after() hook always runs, even on failure). + * The chat fixtures are real ChatRecords in the chat store (created and + * deleted by this suite) — emission reads exactly what production reads. + */ + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { after, describe, it } from "node:test"; +import type { UIMessage } from "ai"; +import type { PlanTree, PlanToolResult } from "../src/core/contracts.ts"; +import type { QuestionAnswerData } from "../src/core/question-core.ts"; +import { createTree, markDone, removeNode } from "../src/core/plan-tree.ts"; +import { emitChat, foldStaleChats, getGraph, graphSnapshot } from "../src/core/graph.ts"; +import { graphLedgers } from "../src/store/graph.ts"; +import { createChat, getChat, saveMessages, saveTree } from "../src/store/chats.ts"; +import { LEDGER_FILES, PATHS } from "../config.ts"; + +// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ────────────── + +const LEDGER_PATHS = Object.values(LEDGER_FILES); +const backups: Array<{ file: string; bak: string }> = []; +const createdChats: string[] = []; + +fs.mkdirSync(PATHS.graph, { recursive: true }); +for (const file of LEDGER_PATHS) { + if (fs.existsSync(file)) { + const bak = `${file}.w4test.bak`; + fs.renameSync(file, bak); + backups.push({ file, bak }); + } +} + +after(() => { + for (const file of LEDGER_PATHS) { + try { + fs.unlinkSync(file); + } catch { + /* nothing was written here */ + } + } + for (const { file, bak } of backups) fs.renameSync(bak, file); + for (const id of createdChats) { + try { + fs.unlinkSync(`${PATHS.chats}/${id}.json`); + } catch { + /* already gone */ + } + } +}); + +// ─── fixture builders ──────────────────────────────────────────────────────── + +function assistant(id: string, parts: unknown[]): UIMessage { + return { id, role: "assistant", parts } as unknown as UIMessage; +} + +function user(id: string, text: string): UIMessage { + return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage; +} + +function quizPart( + toolCallId: string, + input: Record, + output: QuestionAnswerData, +): unknown { + return { type: "tool-quiz", toolCallId, state: "output-available", input, output }; +} + +function planPart(toolCallId: string, name: string, input: Record, tree: PlanTree): unknown { + return { + type: `tool-${name}`, + toolCallId, + state: "output-available", + input, + output: { ok: true, tree } satisfies PlanToolResult, + }; +} + +function answer(over: Partial): QuestionAnswerData { + return { + questionId: "q", + kind: "quiz", + questionText: "question text", + dontKnow: false, + answers: [], + answeredAt: "2026-08-22T00:00:00.000Z", + ...over, + }; +} + +/** Create a chat in the real store with a tree and messages; returns the record. */ +function fixtureChat(title: string, tree: PlanTree | null, messages: UIMessage[]) { + const chat = createChat(title); + createdChats.push(chat.id); + if (tree) saveTree(chat.id, tree); + saveMessages(chat.id, messages); + return getChat(chat.id)!; +} + +/** The fixture tree: alpha → beta, gamma free-floating, alpha done. */ +function fixtureTree(): PlanTree { + return markDone( + createTree( + [ + { id: "alpha", title: "Alpha intuition" }, + { id: "beta", title: "Beta rule" }, + { id: "gamma", title: "Gamma application" }, + ], + [{ from: "alpha", to: "beta" }], + ), + "alpha", + ); +} + +/** The full fixture transcript: probes → plan → checks → mark_done → noise. */ +function fixtureMessages(tree: PlanTree): UIMessage[] { + return [ + user("u1", "teach me the fixture topic"), + assistant("a1", [ + quizPart( + "call-1", + { question: "Q1", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-1", questionText: "Q1 text", correct: false, answeredAt: "2026-08-22T01:00:00.000Z" }), + ), + ]), + assistant("a2", [ + quizPart( + "call-2", + { question: "Q2", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-2", questionText: "Q2 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T01:01:00.000Z" }), + ), + ]), + assistant("a3", [planPart("p1", "plan_init", { nodes: [], edges: [] }, tree)]), + assistant("a4", [ + quizPart( + "call-3", + { question: "Q3", nodeId: "alpha", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-3", questionText: "Q3 text", correct: true, note: "recalled the diagram", answeredAt: "2026-08-22T02:00:00.000Z" }), + ), + ]), + assistant("a5", [ + quizPart( + "call-4", + { question: "Q4", nodeId: "beta", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-4", questionText: "Q4 text", correct: false, answeredAt: "2026-08-22T02:01:00.000Z" }), + ), + ]), + assistant("a6", [ + // nodeId present but NOT in the tree → unmapped → null + question text + quizPart( + "call-5", + { question: "Q5", nodeId: "ghost", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-5", questionText: "Q5 text", dontKnow: true, correct: false, answeredAt: "2026-08-22T02:02:00.000Z" }), + ), + ]), + assistant("a7", [planPart("p2", "plan_mark_done", { id: "alpha" }, tree)]), + assistant("a8", [ + // dismissed quiz + an answered ask — neither emits an event + quizPart( + "call-6", + { question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }), + ), + { + type: "tool-ask_user_question", + toolCallId: "call-7", + state: "output-available", + input: { question: "free text?" }, + output: answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }), + }, + ]), + ]; +} + +/** The chat every emission test folds (shared, so ordering is intentional). */ +const chat1 = fixtureChat("Graph fixture", fixtureTree(), fixtureMessages(fixtureTree())); + +/** Set by the provenance test; the snapshot test (which runs after it) reads it. */ +let chat2Id = ""; + +// ─── emission ──────────────────────────────────────────────────────────────── + +/** Everything the graph holds for a set of chats (other processes' test + * emissions run concurrently against the same files — scope, never assume). */ +function scopedGraph(chatIds: string[]) { + const graph = getGraph(); + const nodeIds = new Set( + graphLedgers + .readMappings() + .filter((m) => chatIds.includes(m.chatId)) + .map((m) => m.graphNodeId), + ); + return { + nodes: graph.nodes.filter((n) => nodeIds.has(n.id)), + edges: graph.edges.filter((e) => e.provenance.some((c) => chatIds.includes(c))), + events: graph.events.filter((e) => chatIds.includes(e.chatId)), + }; +} + +describe("graph emission (emitChat)", () => { + it("folds tree nodes/edges and quiz/mark_done events with the probe/check boundary", () => { + const stats = emitChat(chat1.id); + assert.deepEqual(stats, { nodes: 3, edges: 1, events: 6 }); + + const graph = scopedGraph([chat1.id]); + assert.equal(graph.nodes.length, 3); + const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id])); + for (const node of graph.nodes) { + assert.equal(node.kind, "truth"); + assert.deepEqual(node.aliases, []); + assert.match(node.id, /^n_/); + } + + // Tree edge alpha→beta becomes a `requires` edge on the mapped nodes. + assert.equal(graph.edges.length, 1); + const edge = graph.edges[0]!; + assert.equal(edge.from, idByStatement.get("Alpha intuition")); + assert.equal(edge.to, idByStatement.get("Beta rule")); + assert.equal(edge.type, "requires"); + assert.deepEqual(edge.provenance, [chat1.id]); + + // Events — kinds, refs, node attribution, notes, question fallback. + assert.equal(graph.events.length, 6); + const byRef = new Map(graph.events.map((e) => [e.ref, e])); + + const probeMiss = byRef.get("call-1")!; + assert.equal(probeMiss.kind, "probe-miss"); // quiz BEFORE plan_init + assert.equal(probeMiss.nodeId, null); + assert.equal(probeMiss.question, "Q1 text"); + assert.equal(probeMiss.chatId, chat1.id); + + const probeDontKnow = byRef.get("call-2")!; + assert.equal(probeDontKnow.kind, "dont-know"); // same kind, both phases + assert.equal(probeDontKnow.nodeId, null); + + const check = byRef.get("call-3")!; + assert.equal(check.kind, "check-correct"); // quiz AFTER plan_init + assert.equal(check.nodeId, idByStatement.get("Alpha intuition")); + assert.equal(check.note, "recalled the diagram"); + assert.equal(check.question, undefined); // resolved nodeId → no question text + + const checkMiss = byRef.get("call-4")!; + assert.equal(checkMiss.kind, "check-incorrect"); + assert.equal(checkMiss.nodeId, idByStatement.get("Beta rule")); + + const unmapped = byRef.get("call-5")!; + assert.equal(unmapped.kind, "dont-know"); + assert.equal(unmapped.nodeId, null); // nodeId present but unmapped + assert.equal(unmapped.question, "Q5 text"); + + const taught = byRef.get("taught:alpha")!; + assert.equal(taught.kind, "taught"); + assert.equal(taught.nodeId, idByStatement.get("Alpha intuition")); + + // The dismissed quiz and the ask question emitted nothing. + assert.equal([...byRef.keys()].filter((r) => r === "call-6" || r === "call-7").length, 0); + }); + + it("is idempotent — re-emission appends nothing", () => { + const stats = emitChat(chat1.id); + assert.deepEqual(stats, { nodes: 0, edges: 0, events: 0 }); + const graph = scopedGraph([chat1.id]); + assert.equal(graph.nodes.length, 3); + assert.equal(graph.edges.length, 1); + assert.equal(graph.events.length, 6); + // exactly one checkpoint line per fold, though — checkpoints are a log + assert.equal(graphLedgers.readCheckpoints().filter((c) => c.chatId === chat1.id).length, 2); + }); + + it("accumulates edge provenance when another chat maps onto the same nodes", () => { + // Simulates what a future consolidation merge produces: chat2's tree + // slugs map onto chat1's graph nodes, so its edge assertion dedupes + // by (from,to,type) and just adds chat2 to the provenance. + const tree2 = createTree( + [ + { id: "alpha", title: "Alpha (second session)" }, + { id: "beta", title: "Beta (second session)" }, + ], + [{ from: "alpha", to: "beta" }], + ); + const chat2 = fixtureChat("Graph fixture 2", tree2, [ + user("u1", "again"), + assistant("a1", [planPart("p1", "plan_init", { nodes: [], edges: [] }, tree2)]), + ]); + + const before = getGraph(); + const gAlpha = before.nodes.find((n) => n.statement === "Alpha intuition")!.id; + const gBeta = before.nodes.find((n) => n.statement === "Beta rule")!.id; + graphLedgers.appendMapping({ chatId: chat2.id, treeNodeId: "alpha", graphNodeId: gAlpha }); + graphLedgers.appendMapping({ chatId: chat2.id, treeNodeId: "beta", graphNodeId: gBeta }); + + const stats = emitChat(chat2.id); + assert.deepEqual(stats, { nodes: 0, edges: 1, events: 0 }); + chat2Id = chat2.id; + + const after = scopedGraph([chat1.id, chat2.id]); + assert.equal(after.nodes.length, 3); // no duplicate nodes + assert.equal(after.edges.length, 1); // one merged edge… + assert.deepEqual(after.edges[0]!.provenance, [chat1.id, chat2.id]); // …two sessions + }); + + it("derives node states, sessions, and node provenance in graphSnapshot", () => { + const snap = graphSnapshot(); + const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id])); + assert.equal(snap.nodeStates[idByStatement.get("Alpha intuition")!], "taught"); + assert.equal(snap.nodeStates[idByStatement.get("Beta rule")!], "missed"); + assert.equal(snap.nodeStates[idByStatement.get("Gamma application")!], "candidate"); + + assert.equal(snap.sessions[chat1.id], "Graph fixture"); + assert.equal(snap.sessions[chat2Id], "Graph fixture 2"); + assert.deepEqual( + (snap.nodeSessions[idByStatement.get("Alpha intuition")!] ?? []).sort(), + [chat1.id, chat2Id].sort(), + ); + // Gamma was only ever created (mapping) by chat1. + assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]); + }); + + it("replays plan tool outputs when record.tree is missing (removed nodes stay out)", () => { + const initTree = createTree( + [ + { id: "x", title: "X live" }, + { id: "y", title: "Y pruned" }, + ], + [{ from: "x", to: "y" }], + ); + const prunedTree = removeNode(initTree, "y"); + const chat = fixtureChat("Replay fixture", null, [ + user("u1", "go"), + assistant("a1", [planPart("p1", "plan_init", { nodes: [], edges: [] }, initTree)]), + assistant("a2", [planPart("p2", "plan_remove_node", { id: "y" }, prunedTree)]), + ]); + + const stats = emitChat(chat.id); + assert.deepEqual(stats, { nodes: 1, edges: 0, events: 0 }); // y pruned, edge cascaded away + + const graph = scopedGraph([chat.id]); + assert.ok(graph.nodes.some((n) => n.statement === "X live")); + assert.ok(!graph.nodes.some((n) => n.statement === "Y pruned")); + }); + + it("returns zeros for unknown chats", () => { + assert.deepEqual(emitChat("chat_does_not_exist"), { nodes: 0, edges: 0, events: 0 }); + }); +}); + +// chat2 from the provenance test, reachable by the snapshot test below it. + +describe("catch-up (foldStaleChats)", () => { + it("folds chats newer than their checkpoint, exactly once, deriving known state", () => { + const tree = createTree([{ id: "solo", title: "Solo node" }], []); + const chat = fixtureChat("Catch-up fixture", tree, [ + user("u1", "go"), + assistant("a1", [planPart("p1", "plan_init", { nodes: [], edges: [] }, tree)]), + assistant("a2", [ + quizPart( + "call-s", + { question: "QS", nodeId: "solo", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" }, + answer({ questionId: "call-s", questionText: "QS text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }), + ), + ]), + ]); + + const folded = foldStaleChats(); + assert.ok(folded >= 1, "the catch-up fixture was folded"); + assert.equal(graphLedgers.readCheckpoints().filter((c) => c.chatId === chat.id).length, 1); + + const snap = graphSnapshot(); + const solo = snap.nodes.find((n) => n.statement === "Solo node"); + assert.ok(solo, "solo node was emitted by the catch-up fold"); + assert.equal(snap.nodeStates[solo.id], "known"); // correct check, no taught event + assert.equal(snap.sessions[chat.id], "Catch-up fixture"); + + // Already checkpointed → not folded again (other test files' chats may + // legitimately fold here, so assert on THIS chat only). + foldStaleChats(); + assert.equal(graphLedgers.readCheckpoints().filter((c) => c.chatId === chat.id).length, 1); + }); +}); + +// ─── store primitives ──────────────────────────────────────────────────────── + +describe("graph ledger store", () => { + it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => { + const node = { id: "n_storetest", statement: "Store test", aliases: [], kind: "truth" as const, createdAt: "2026-08-22T00:00:00.000Z" }; + graphLedgers.appendNode(node); + // A torn append and a legacy V0-shaped node (different schema) — both skipped. + fs.appendFileSync( + LEDGER_FILES.nodes, + `{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", domain: "x", state: "known" })}\n`, + "utf-8", + ); + const nodes = graphLedgers.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy"); + assert.equal(nodes.length, 1); + assert.equal(nodes[0]!.statement, "Store test"); + }); +});