W4: graph store + emission + home graph view

This commit is contained in:
Amos
2026-08-22 10:30:00 +03:00
parent 9a99b01159
commit 8728573253
8 changed files with 1297 additions and 11 deletions
+15
View File
@@ -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());
}
+220
View File
@@ -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<NodeStateClass, { cssVar: string; size: number }> = {
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<NodeStateClass, string> = {
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<HTMLDivElement | null>(null);
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [selected, setSelected] = useState<Selection | null>(null);
// Fetch the derived graph (the route folds stale chats first).
useEffect(() => {
let cancelled = false;
fetch("/api/graph")
.then((res) => res.json() as Promise<GraphSnapshot>)
.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<NodeStateClass, string>();
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 (
<div className="relative h-full w-full">
{isEmpty ? (
// The original home placeholder — kept verbatim for the empty graph.
<div className="flex h-full w-full items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<p className="max-w-sm text-sm">
Every session you complete will map what you know — and where the edge of your
understanding is — right here.
</p>
</div>
</div>
) : (
<div ref={containerRef} className="h-full w-full" />
)}
{selected && (
// Background click closes; the card swallows its own clicks.
<button
type="button"
aria-label="Close node details"
className="absolute inset-0 z-10 cursor-default bg-transparent"
onClick={() => setSelected(null)}
>
<div
className="absolute left-1/2 top-6 w-80 max-w-[90%] -translate-x-1/2 rounded-xl border bg-card p-4 text-left shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="text-sm font-medium leading-snug">{selected.node.statement}</div>
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
{STATE_LABELS[selected.state]}
</div>
{selected.chatIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
{selected.chatIds.map((chatId) => (
<Link
key={chatId}
href={`/chat/${chatId}`}
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
>
{snapshot?.sessions[chatId] ?? chatId}
</Link>
))}
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
)}
</div>
</button>
)}
</div>
);
}
+5 -11
View File
@@ -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() {
<SidebarToggle />
</header>
{/* Graph — the main view. Placeholder until the knowledge graph lands. */}
<main className="flex flex-1 items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<p className="max-w-sm text-sm">
Every session you complete will map what you know — and where the edge of your
understanding is — right here.
</p>
</div>
{/* Graph — the main view (W4: sigma.js render of the derived graph). */}
<main className="min-h-0 flex-1 px-4 py-2">
<GraphView />
</main>
{/* New session — same floating blob as the chat composer. */}
+70
View File
@@ -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<string, unknown>): 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 });
+410
View File
@@ -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:<treeNodeId>)
*
* 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<string, string>();
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<string, Set<string>>();
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:<treeNodeId>) — a ref already
// in the ledger means this turn was folded before.
const seenRefs = new Set<string>();
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<string, string>,
planSeen: boolean,
now: string,
): LedgerEvent | null {
const out = part.output;
if (typeof out !== "object" || out === null) return null;
const answer = out as Partial<QuestionAnswerData>;
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<string, GraphEdge>();
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<string> = 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<string, NodeStateClass> {
const states: Record<string, NodeStateClass> = {};
for (const node of nodes) states[node.id] = "candidate";
const taught = new Set<string>();
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
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<string, NodeStateClass>;
/** chatId → title, for every chat that touched the graph. */
sessions: Record<string, string>;
/** nodeId → chatIds that touched the node (click-through provenance). */
nodeSessions: Record<string, string[]>;
}
/** 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<string, Set<string>>();
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<string>();
for (const chats of nodeChats.values()) {
for (const id of chats) chatIds.add(id);
}
const sessions: Record<string, string> = {};
for (const id of chatIds) {
const chat = getChat(id);
if (chat) sessions[id] = chat.title;
}
const nodeSessions: Record<string, string[]> = {};
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<string, string>();
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;
}
+9
View File
@@ -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);
}
},
});
}
+166
View File
@@ -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<T>(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<string, unknown> {
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<T>(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);
},
};
+402
View File
@@ -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<string, unknown>,
output: QuestionAnswerData,
): unknown {
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
}
function planPart(toolCallId: string, name: string, input: Record<string, unknown>, tree: PlanTree): unknown {
return {
type: `tool-${name}`,
toolCallId,
state: "output-available",
input,
output: { ok: true, tree } satisfies PlanToolResult,
};
}
function answer(over: Partial<QuestionAnswerData>): 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");
});
});