Merge feat/graph-rendering: name/statement schema pivot, state classes stripped, graph observability

User-decided after seeing real extracted data (three-commit batch, all in the
graph lane): full-sentence canvas labels were illegible, so nodes gain a
short presentational name (glossary-headword; statement stays the identity
anchor; description dropped); the transitional NodeStateClass vocabulary is
deleted (raw latest-quiz outcomes feed only the teacher-side hotspot
grouping; the home renderer is state-neutral pending the state-model
ideation); per-session understanding observations surface in the detail card
and a background-pipeline registry + /api/graph/status show live extraction
runs on the graph.
This commit is contained in:
amosblomqvist
2026-09-09 01:27:58 +03:00
13 changed files with 498 additions and 196 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ import { graphView } from "@/src/server";
/**
* The home graph view's data endpoint (W4; vision §1 "Home page") — thin
* transport: the server entry (src/server/graph.ts) folds stale chats and
* returns nodes, edges, derived node states, and session provenance.
* transport: the server entry (src/server/graph.ts) returns nodes, edges,
* session provenance, and per-session understanding observations.
*/
export const dynamic = "force-dynamic";
+21
View File
@@ -0,0 +1,21 @@
/**
* GET /api/graph/status — the in-flight background extraction runs
* (vision §1: the pipeline status must be visible on the home graph).
* Thin transport over the pipeline's module-level registry: phase
* (extracting / auditing / merging) per chat, oldest first. Titles joined
* from the chat store; a missing chat record still reports the run.
*/
import { extractionStatuses } from "@/src/agent/subagents/extraction.ts";
import { chatStore } from "@/src/store/chats.ts";
export const dynamic = "force-dynamic";
export async function GET() {
const runs = extractionStatuses().map((run) => ({
chatId: run.chatId,
title: chatStore.getChat(run.chatId)?.title ?? run.chatId,
phase: run.phase,
startedAt: run.startedAt,
}));
return Response.json({ runs });
}
+166 -47
View File
@@ -10,13 +10,15 @@
* and cancelled on unmount — idle CPU stays near zero. Physics is
* placement only: pan/zoom, no dragging, no editing.
*
* Labels render the node `name` (the short header, never the statement)
* through custom canvas drawers that replace sigma's defaults:
* Labels render the node `name` — the very short, glanceable label the
* extractor coins (a glossary-headword-style handle); the full-precision
* `statement` lives on the detail card. Custom canvas drawers replace
* sigma's defaults:
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
* word-wrapped at a max line width — no truncation, no one-line sprawl.
* - defaultDrawNodeHover: NO background box (sigma's default is a
* hardcoded white one). Instead the hovered node gets a soft glow
* halo in its own state color at a constant radius, flush with the
* halo in the node's color at a constant radius, flush with the
* disc edge, and its name brightens — the "this is clickable,
* click for the full fact" affordance. Entering/leaving hover fades
* ONE transition factor shared by halo and text alike, so the
@@ -38,36 +40,43 @@
* no per-node size gating, so labels never pop in one by one. (The Obsidian
* text-fade feel, applied to the whole graph.)
*
* The search panel (top-right overlay): a search box matching name or
* statement text — the transitional node-state classes are gone (2026-09-08;
* nodes render uniformly until the state model ideation lands), so there
* are no state legend rows or toggles anymore. Filtering flows through
* sigma's official
* nodeReducer/edgeReducer settings (`hidden`), so rendering, hover,
* picking, and labels all respect it; edges drop when either endpoint is
* hidden, and an open detail card closes if its node is filtered out.
* Clicking a node opens the detail card anchored just below the node's
* viewport position (flipping above near the bottom edge); any camera move
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
* prerequisite → dependent.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { WaypointsIcon } from "lucide-react";
import { Loader2Icon, SearchIcon, WaypointsIcon } from "lucide-react";
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
import { seededRandom, type GraphNode, type GraphSnapshot, type NodeStateClass } from "@/src/core";
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/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 },
};
/** The uniform node look (no state classes — WebGL wants hex, not oklch):
* the accent color + the old "known" size, until the state model lands. */
const NODE_COLOR_TOKEN = "--primary";
const NODE_SIZE = 8;
const STATE_LABELS: Record<NodeStateClass, string> = {
taught: "taught",
known: "known",
missed: "missed",
"dont-know": "don't know",
candidate: "candidate",
};
/**
* Pure filter predicate for the search panel: a node is visible when the
* query is empty or matches its name or statement (case-insensitive).
* Exported for tests; the sigma reducers just consume its verdict.
*/
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
}
// ---- Label drawing (canvas) ---------------------------------------------
//
@@ -199,7 +208,7 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu
return lines;
}
/** The wrapped node name, centered below the node — used by both drawers
/** The wrapped node name (the short label), centered below the node — used by both drawers
* with identical geometry so hover only changes the color, nothing jumps. */
function drawName(
context: CanvasRenderingContext2D,
@@ -313,7 +322,6 @@ function tokenHex(cssVar: string): string {
interface Selection {
node: GraphNode;
state: NodeStateClass;
chatIds: string[];
/** Node position in container pixels (from graphToViewport at click time). */
anchor: { x: number; y: number };
@@ -324,8 +332,29 @@ export function GraphView() {
const cardRef = useRef<HTMLDivElement | null>(null);
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [selected, setSelected] = useState<Selection | null>(null);
// Search panel state: the query text.
const [query, setQuery] = useState("");
// The sigma instance and its graphology graph live inside the mount
// effect; refs expose them to the filters effect, and `rendererEpoch`
// re-triggers it when a new renderer is constructed.
const rendererRef = useRef<import("sigma").default | null>(null);
const graphRef = useRef<Graph | null>(null);
const [rendererEpoch, setRendererEpoch] = useState(0);
// Fetch the derived graph (the route folds stale chats first).
// The id set that survives the current search — consumed by the sigma
// reducers and the "no matches" hint.
const visibleIds = useMemo(() => {
const set = new Set<string>();
if (snapshot)
for (const node of snapshot.nodes) {
if (isNodeVisible(node, query)) set.add(node.id);
}
return set;
}, [snapshot, query]);
// Fetch the derived graph. Re-runs when a background pipeline run
// finishes (refreshTick) — its merges just landed in the ledger.
const [refreshTick, setRefreshTick] = useState(0);
useEffect(() => {
let cancelled = false;
fetch("/api/graph")
@@ -337,6 +366,30 @@ export function GraphView() {
return () => {
cancelled = true;
};
}, [refreshTick]);
// Background pipeline status: poll /api/graph/status; when a run
// finishes (some -> none), refetch the graph.
const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]);
useEffect(() => {
let cancelled = false;
let hadRuns = false;
const poll = () =>
fetch("/api/graph/status")
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
.then(({ runs }) => {
if (cancelled) return;
setPipelineRuns(runs);
if (hadRuns && runs.length === 0) setRefreshTick((t) => t + 1);
hadRuns = runs.length > 0;
})
.catch(() => {}); // status is cosmetic — never surface poll errors
poll();
const timer = setInterval(poll, 2000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, []);
// Build the graphology graph, lay it out once, mount sigma.
@@ -348,24 +401,20 @@ export function GraphView() {
const edgeColor = tokenHex("--ring");
const labelColor = tokenHex("--muted-foreground");
const hoverTextColor = tokenHex("--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 nodeColor = tokenHex(NODE_COLOR_TOKEN);
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
const hover: HoverState = { fade: 0, repaint: null };
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
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,
label: node.name,
size: NODE_SIZE,
color: nodeColor,
x: (rand() - 0.5) * 100,
y: (rand() - 0.5) * 100,
});
@@ -426,6 +475,9 @@ export function GraphView() {
defaultDrawNodeLabel: drawLabel,
defaultDrawNodeHover: drawHover,
});
graphRef.current = graph;
rendererRef.current = renderer;
setRendererEpoch((e) => e + 1);
renderer.on("clickNode", ({ node }) => {
const graphNode = snapshot.nodes.find((n) => n.id === node);
if (!graphNode) return;
@@ -433,7 +485,6 @@ export function GraphView() {
const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 });
setSelected({
node: graphNode,
state: snapshot.nodeStates[node] ?? "candidate",
chatIds: snapshot.nodeSessions[node] ?? [],
anchor: anchor ?? { x: 0, y: 0 },
});
@@ -496,10 +547,32 @@ export function GraphView() {
return () => {
killed = true;
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
rendererRef.current = null;
graphRef.current = null;
renderer?.kill();
};
}, [snapshot]);
// Filtering through sigma's official reducers: hidden nodes drop out of
// rendering, picking, hover, AND labels; edges drop when either endpoint
// is hidden. Re-runs whenever filters change or a renderer is (re)built
// (epoch), so the reducers always close over current values.
useEffect(() => {
const renderer = rendererRef.current;
const graph = graphRef.current;
if (!renderer || !graph) return;
renderer.setSetting("nodeReducer", (key, attrs) =>
visibleIds.has(key) ? attrs : { ...attrs, hidden: true },
);
renderer.setSetting("edgeReducer", (key, attrs) => {
const source = graph.source(key);
const target = graph.target(key);
return visibleIds.has(source) && visibleIds.has(target) ? attrs : { ...attrs, hidden: true };
});
// The open detail card cannot outlive its node's visibility.
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
}, [rendererEpoch, visibleIds]);
// Position the detail card: below the clicked node, flipped above near
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
// before paint) because the flip needs the card's measured height.
@@ -544,6 +617,44 @@ export function GraphView() {
<div ref={containerRef} className="h-full w-full" />
)}
{/* Legend + filters: search box and per-state toggle rows. */}
{!isEmpty && (
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
<div className="relative">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search nodes…"
spellCheck={false}
className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
</div>
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
No matching nodes.
</div>
)}
</div>
)}
{/* Background pipeline status: what is currently running. */}
{pipelineRuns.length > 0 && (
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
{pipelineRuns.map((run) => (
<div
key={run.chatId}
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
>
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
<span className="capitalize">{run.phase}</span>
<span className="max-w-48 truncate text-foreground">{run.title}</span>
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
</div>
))}
</div>
)}
{selected && !isEmpty && (
<div
ref={cardRef}
@@ -552,23 +663,31 @@ export function GraphView() {
style={{ visibility: "hidden", left: 0, top: 0 }}
className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
>
<div className="text-sm font-medium leading-snug">{selected.node.statement}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.description}</div>
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
{STATE_LABELS[selected.state]}
</div>
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{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>
))}
{selected.chatIds.map((chatId) => {
// That session's understanding observation — the
// extractor's named-word note (confidence history).
const note = snapshot?.nodeObservations?.[selected.node.id]?.[chatId];
return (
<div key={chatId} className="flex items-center gap-1.5">
<Link
href={`/chat/${chatId}`}
className="min-w-0 flex-1 truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
>
{snapshot?.sessions[chatId] ?? chatId}
</Link>
{note && (
<span className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
{note}
</span>
)}
</div>
);
})}
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
+2 -1
View File
@@ -21,7 +21,7 @@ The graph updates **continuously, not per-session**. A learner who answers a few
The global graph is an **objective world-model of the domains the learner has encountered**: the facts, their structure (what follows from / is supported by what), and — inferred from that structure plus the event ledger — the state of the learner's understanding. Nothing learner- or session-relative is ever stored **in** the graph; understanding is **inferred, never encoded**.
- **Every node is a true proposition.** Worded objectively, in minimal form as it exists w.r.t. its context. Truth is context-relative and that is fine: "Gravity is a force" and "Gravity is not a force" coexist as nodes — each true in its context (classical mechanics vs. GR), with **the surrounding subgraph as the context**. No context field, no pedantic self-containment of facts.
- **Node schema:** `statement` — the proposition in minimal form (a short sentence; the rendering label and identity key); `description` — the elaborated version (required); `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** field.
- **Node schema:** `name` — a very short label for the node, a compact handle like a glossary headword (“Will to power”, “Divine command theory”): what the graph shows at a glance. Presentational only — not unique, not an identity key, never precise. Set at creation; a re-encounter never changes it. `statement` — the proposition at full precision (the identity anchor; shown in the detail card). `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** and **no `description`** (2026-09-08 amendment: with real data, full-sentence canvas labels proved unworkable — ten-line labels, illegible overprint. The label is the name; the precision lives one interaction away in the statement).
- **Misconceptions are not a node category.** A myth enters the graph as its true correction ("The 10%-of-brain claim is a myth"); the learner's holding of the wrong idea lives entirely in the event ledger. Wrongness is about the learner, and the learner lives in the ledger.
### Edges (DECIDED 2026-09-02)
@@ -226,6 +226,7 @@ The teacher's context window and attention are precious — it has the lesson to
| `taught` events (marking things done) | Dropped; the extractor records per-proposition understanding notes (named-word scale) as session-scoped observations instead |
| Chat-granular node/edge provenance | Message-granular (chat, message) provenance on nodes and edges — the exact derivation points |
| `name` + `statement` on nodes | `statement` (minimal proposition, the rendering label) + `description` (required elaboration) + `aliases` (verbatim phrasings) |
| `statement` + `description` (2026-09-02) | `name` (very short label, the rendering handle) + `statement` (full precision, identity anchor, detail card); `description` dropped — full-sentence canvas labels cluttered the graph (2026-09-08, seeing real extracted data) |
| Updates per completed session | Continuous; partial sessions deposit value |
| Mermaid DAG as the plan artifact | Structured tools as source of truth; mermaid only for ad-hoc diagrams |
| UI deferred, "not important" | UI exists (Next.js, v3.4); the graph view is the app's home page |
+69 -12
View File
@@ -30,6 +30,11 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../
* trigger while one run is in flight is a no-op (the next trigger picks up
* whatever the in-flight run missed). On failure nothing is checkpointed —
* the next trigger re-runs the whole thing.
*
* Observability (§5 baseline): every stage logs one line per step, its
* start, and its duration; the pipeline logs a failure line before
* rethrowing, so the server log reads start → stage timings → merged
* stats (or failed) for every run.
*/
// ─── the transcript view (what the extractor + auditor see) ─────────────────
@@ -94,6 +99,13 @@ const messagesField = z
const propositionSchema = z.object({
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
name: z
.string()
.describe(
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
'(\'Will to power\', \'Divine command theory\'). Not a sentence, needn\'t be unique or ' +
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
),
statement: z
.string()
.describe(
@@ -102,11 +114,6 @@ const propositionSchema = z.object({
'"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' +
'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.',
),
description: z
.string()
.describe(
"The elaborated version of the same proposition — more words: scope, precision, what exactly it means. Still true, still world-facing.",
),
aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."),
messages: messagesField,
evidence,
@@ -163,8 +170,12 @@ misconception is NOT a node of its own wrong claim — state the TRUE correction
quiz outcomes carry that the learner believed the wrong version.
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
- Each is a self-contained TRUE statement ("statement"), plus its elaboration ("description" — more
words, same proposition: scope, precision, what exactly it means).
- Each has a "name" — a VERY short label, a few words, a compact handle like a glossary headword
("Will to power", "Divine command theory"). Not a sentence; needn't be unique or self-contained.
It becomes the node's label in the graph, so keep it glanceable: names are what one reads on
the graph, statements are what one reads on the node.
- And a "statement" — the self-contained TRUE proposition at full precision (the elaboration lives
in the graph only as this one statement).
- Include corrections of misconceptions the teacher articulated, and true facts explained in prose.
- One reasoning step per proposition: never a topic blob, never a sub-step.
- "messages": the [id] tags of every message where the proposition came up — provenance, report it.
@@ -249,10 +260,12 @@ FAITHFULNESS — verify every candidate against the transcript:
scale or drop.
IDENTITY — link-or-create, per proposition:
- You see the current graph (every node: id, statement, description, aliases). For each verified proposition,
- You see the current graph (every node: id, name, statement, aliases). For each verified proposition,
decide: does an EXISTING node already state this same proposition?
- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance.
- If no: omit "linkTo" and a new node is created.
- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance;
the node keeps its existing name (labels stay stable across sessions — ignore your name for linked nodes).
- If no: omit "linkTo" and a new node is created, with the extractor's name as its label. Reword only a
clearly unglanceable name (an unwieldy sentence of a name); keep it short.
- When unsure, omit — duplicates are recoverable later; wrong merges are not.
Also drop propositions not referenced by anything else ONLY if they have no other backing — an unreferenced but
@@ -266,7 +279,7 @@ function graphView(nodes: GraphNode[]): string {
if (nodes.length === 0) return "(the graph is currently empty — every proposition is a new node)";
const lines = nodes.map(
(n) =>
`- ${n.id} :: ${n.statement}\n description: ${n.description}` +
`- ${n.id} :: ${n.name}\n statement: ${n.statement}` +
(n.aliases.length > 0 ? `\n aliases: ${n.aliases.join(" | ")}` : ""),
);
return lines.join("\n");
@@ -291,6 +304,25 @@ function createAuditor(model: LanguageModel) {
});
}
// ─── the pipeline status registry ─────────────────────────────────────────────
/** The pipeline phases, in order — what the home status endpoint shows. */
export type ExtractionPhase = "extracting" | "auditing" | "merging";
/** One in-flight pipeline run. Module-level: one process, one registry. */
interface RunningExtraction {
chatId: string;
phase: ExtractionPhase;
startedAt: number;
}
const runningExtractions = new Map<string, RunningExtraction>();
/** In-flight pipeline runs (oldest first) — the /api/graph/status payload. */
export function extractionStatuses(): RunningExtraction[] {
return [...runningExtractions.values()].sort((a, b) => a.startedAt - b.startedAt);
}
// ─── the pipeline ───────────────────────────────────────────────────────────
export interface ExtractionModels {
@@ -314,15 +346,25 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
const inFlight = new Set<string>();
function setPhase(chatId: string, phase: ExtractionPhase): void {
runningExtractions.set(chatId, {
chatId,
phase,
startedAt: runningExtractions.get(chatId)?.startedAt ?? Date.now(),
});
}
async function extract(chatId: string): Promise<SessionFindings> {
const record = chatStore.getChat(chatId);
if (!record) throw new Error(`Chat not found: ${chatId}`);
const view = transcriptView(record);
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
const t0 = performance.now();
const extraction = await extractorAgent.generate({ prompt: `Transcript:\n\n${view}` });
for (const part of extraction.content) {
if (part.type === "tool-result" && part.toolName === "submit_findings") {
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return part.output as SessionFindings;
}
}
@@ -338,6 +380,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
`[extraction ${chatId}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` +
`${Object.keys(findings.attribution.quiz).length} attributions, ${graph.nodes.length} graph nodes)`,
);
const t0 = performance.now();
const review = await auditorAgent.generate({
prompt:
@@ -362,23 +405,37 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
`${auditResult.edges.length}/${findings.edges.length} edges, ` +
`${auditResult.propositions.filter((p) => p.linkTo !== undefined).length} linked to existing nodes`,
);
console.info(`[extraction ${chatId}] auditor done in ${Math.round(performance.now() - t0)}ms`);
return auditResult;
}
async function runExtraction(chatId: string): Promise<EmissionStats> {
if (inFlight.has(chatId)) return { nodes: 0, edges: 0, events: 0 };
inFlight.add(chatId);
const t0 = performance.now();
try {
console.info(`[extraction ${chatId}] pipeline start`);
setPhase(chatId, "extracting");
const findings = await extract(chatId);
setPhase(chatId, "auditing");
const auditResult = await audit(chatId, findings);
setPhase(chatId, "merging");
// The merger re-reads the chat record itself — events are rebuilt
// from the transcript, never from stale copies.
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore });
console.info(`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events`);
console.info(
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
`(${Math.round(performance.now() - t0)}ms total)`,
);
return stats;
} catch (err) {
// The throw travels to the caller; this line is what makes the
// run's log legible end-to-end (no hanging "pipeline start").
console.error(`[extraction ${chatId}] pipeline failed (${Math.round(performance.now() - t0)}ms):`, err);
throw err;
} finally {
inFlight.delete(chatId);
runningExtractions.delete(chatId); // done or failed — no longer running
}
}
+27 -24
View File
@@ -33,13 +33,18 @@ export interface ProvenancePoint {
export interface GraphNode {
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
id: string;
/** The minimal proposition — one true, self-contained statement. This is
* BOTH the rendering label AND the identity key: the auditor recognizes
* a re-encounter by judgment (link-or-create), not by string matching. */
/** A very short label for the node — a compact handle like a glossary
* headword ("Will to power", "Divine command theory"): what the graph
* shows at a glance. Presentational only — not unique, not an identity
* key, never precise: the statement carries the precision. Set at
* creation (first line defines); a link-append never changes it. */
name: string;
/** The minimal proposition — one true, self-contained statement, at
* full precision. The IDENTITY ANCHOR: the auditor recognizes a
* re-encounter by judgment against this (link-or-create), not by
* string matching. Shown in the detail card; the canvas label is the
* name. */
statement: string;
/** The elaborated version — required. Same proposition, more words:
* scope, precision, the "what exactly does it mean". */
description: string;
/** Verbatim phrasings sessions used for this proposition (the identity
* audit trail — how the world said what the node says). */
aliases: string[];
@@ -108,10 +113,11 @@ export interface LedgerEvent {
* (nodes/edges/events/mappings/sessions), all append-only.
*
* nodes.jsonl one line per node — the FIRST line for an id defines
* statement/description/createdAt; later lines for the
* name/statement/createdAt; later lines for the
* same id are link-appends (a session's re-encounter):
* their statements/aliases join `aliases`, their
* provenance unions. getGraph merges by id.
* provenance unions; the name stays the first line's.
* getGraph merges by id.
* edges.jsonl one line per edge ASSERTION; readers merge by
* (from,to,type), unioning provenance.
*
@@ -136,13 +142,16 @@ export interface LedgerEvent {
export interface ExtractedProposition {
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
id: string;
/** A very short label — a few words, a compact handle like a glossary
* headword. Not a sentence, needn't be unique or self-contained: the
* statement carries the precision. Becomes the node's name on create;
* on link, the existing node keeps its name. */
name: string;
/** The minimal proposition — a true, self-contained, world-facing
* statement. No learner / teacher / session references: misconceptions
* are stated as the true correction, and wrongness lives in the event
* ledger, never in the node. */
* statement, at full precision. No learner / teacher / session
* references: misconceptions are stated as the true correction, and
* wrongness lives in the event ledger, never in the node. */
statement: string;
/** The elaborated version — required. */
description: string;
/** Verbatim phrasings the session used. */
aliases: string[];
/** The message ids where this proposition came up — provenance. */
@@ -212,25 +221,19 @@ export interface AuditResult {
dropped: { ref: string; reason: string }[];
}
/**
* Per-node state class for the home view — TRANSITIONAL (kept so the
* renderer survives the contract change untouched). Derived, never stored:
* the LATEST quiz event decides (known / missed / dont-know); a node with
* no quiz events is a candidate. `taught` is now unreachable (no taught
* events are emitted anymore) — the real state model (dependency depth +
* topology-derived strength + quiz events) is pending ideation.
*/
export type NodeStateClass = "taught" | "known" | "missed" | "dont-know" | "candidate";
/** The GET /api/graph payload — everything the home view's PURE renderer needs. */
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[]>;
/** nodeId → chatId → that session's understanding observation (the
* extractor's per-session named-word note; session-relative data,
* shown next to the session list — the confidence history). Multiple
* notes for one (node, chat) collapse to the latest. */
nodeObservations: Record<string, Record<string, string>>;
}
/** Derive a short chat title from the learner's first message (pure).
+28 -22
View File
@@ -20,9 +20,8 @@
* the next session should probe.
*/
import type { GraphEdge, GraphNode } from "./contracts.ts";
import { deriveNodeStates, type DerivedGraph } from "./graph.ts";
import type { NodeStateClass } from "./contracts.ts";
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
/** The section header the teacher is taught to recognize (50-graph-context.md). */
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
@@ -30,46 +29,51 @@ export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
export const MAX_CONTEXT_NODES = 150;
/** Group order for node lines: diagnostic value first (vision §1 hotspots). */
const HOTSPOT_STATES: ReadonlySet<NodeStateClass> = new Set(["missed", "dont-know"]);
/**
* Group order for node lines: diagnostic value first (vision §1 hotspots).
* No node-state classes (removed 2026-09-08) — the grouping is the raw
* event fact: the node's LATEST quiz outcome (miss/dont-know = hotspot).
*/
type Outcome = LedgerEventKind | "never-probed";
function groupOf(node: GraphNode, state: NodeStateClass): number {
if (HOTSPOT_STATES.has(state)) return 0;
if (state === "known") return 1;
return 2; // candidate
function groupOf(outcome: Outcome): number {
if (outcome === "miss" || outcome === "dont-know") return 0;
if (outcome === "correct") return 1;
return 2; // never probed
}
const GROUP_LABELS = [
"Hotspots (missed / dont-know) — likely misconception sites and unexplored ground:",
"Known (answered correctly):",
"Candidates (never probed — assume nothing):",
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
"Answered correctly:",
"Never probed (assume nothing):",
] as const;
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
function isHotspot(node: GraphNode, state: NodeStateClass): boolean {
return groupOf(node, state) === 0;
function isHotspot(outcome: Outcome): boolean {
return groupOf(outcome) === 0;
}
/**
* Render the context block, or null when there is nothing worth injecting
* (no nodes; or a truncated graph whose selection came out empty).
*
* Pure: callers hand in exactly what getGraph(graphStore) + deriveNodeStates() produce.
* Pure: callers hand in exactly what getGraph(graphStore) produces; the
* outcomes are the raw latest-quiz-event facts (core/graph.ts).
*/
export function renderGraphContext(
nodes: GraphNode[],
edges: GraphEdge[],
states: Record<string, NodeStateClass>,
outcomes: Map<string, LedgerEventKind>,
): string | null {
if (nodes.length === 0) return null;
// ── selection ───────────────────────────────────────────────────────────
const stateOf = (node: GraphNode): NodeStateClass => states[node.id] ?? "candidate";
const outcomeOf = (node: GraphNode): Outcome => outcomes.get(node.id) ?? "never-probed";
let selected = nodes;
let truncated = false;
if (nodes.length > MAX_CONTEXT_NODES) {
truncated = true;
const hotspots = nodes.filter((n) => isHotspot(n, stateOf(n)));
const hotspots = nodes.filter((n) => isHotspot(outcomeOf(n)));
const keep = new Set(hotspots.map((n) => n.id));
// 1-hop follows neighborhood: premises AND derivations.
for (const edge of edges) {
@@ -91,12 +95,14 @@ export function renderGraphContext(
for (const [group, label] of GROUP_LABELS.entries()) {
const groupNodes = selected
.filter((n) => groupOf(n, stateOf(n)) === group)
.filter((n) => groupOf(outcomeOf(n)) === group)
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
if (groupNodes.length === 0) continue;
lines.push("", label);
for (const node of groupNodes) {
lines.push(`• [${stateOf(node)}] ${node.statement} (${node.id})`);
const outcome = outcomeOf(node);
const tag = outcome === "never-probed" ? "never probed" : outcome;
lines.push(`• [${tag}] ${node.statement} (${node.id})`);
}
}
@@ -161,6 +167,6 @@ function edgeChains(edges: GraphEdge[]): string[][] {
* chat-handler, so it self-updates as the graph grows mid-session).
*/
export function buildGraphContext(graph: DerivedGraph): string | null {
const states = deriveNodeStates(graph.nodes, graph.events);
return renderGraphContext(graph.nodes, graph.edges, states);
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
return renderGraphContext(graph.nodes, graph.edges, outcomes);
}
+39 -24
View File
@@ -48,7 +48,6 @@ import type {
GraphSnapshot,
LedgerEvent,
LedgerEventKind,
NodeStateClass,
} from "./contracts.ts";
import type { QuestionAnswerData } from "./question-core.ts";
import type { ChatStore, GraphStore } from "./ports.ts";
@@ -166,10 +165,12 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
// line — the session's phrasings (statement + aliases) join the
// node's aliases, the provenance unions. Reads merge by id.
graphNodeId = p.linkTo;
// Link-append: phrasings + provenance join at read time (the
// first line's name/statement/createdAt stand).
ledgers.appendNode({
id: graphNodeId,
name: p.name,
statement: p.statement,
description: p.description,
aliases: [...p.aliases],
createdAt: now,
provenance: points,
@@ -177,8 +178,8 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
} else {
const node: GraphNode = {
id: newGraphNodeId(),
name: p.name,
statement: p.statement,
description: p.description,
aliases: [...p.aliases],
createdAt: now,
provenance: points,
@@ -329,8 +330,9 @@ export interface DerivedGraph {
/**
* The whole graph from the ledgers. Node LINES may repeat an id (link-
* appends): the FIRST line defines statement/description/createdAt; later
* lines' statements and aliases join `aliases`, provenance unions. Edge
* appends): the FIRST line defines name/statement/createdAt; later
* lines' statements and aliases join `aliases`, provenance unions — the
* name never changes after creation. Edge
* LINES are assertions, merged by (from,to,type) with unioned provenance,
* first-seen order. `store events, derive state` — this is the only reader
* that matters.
@@ -374,18 +376,14 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-know"]);
/**
* Derive node states from events (never stored): the LATEST quiz event
* decides (known / missed / dont-know); a node with no quiz events is a
* candidate. TRANSITIONAL (2026-09-02 redesign): `taught` is unreachable
* now that no taught events are emitted, and observation events are NOT
* state inputs — the real state model (dependency depth + topology-derived
* strength + quiz events) is pending ideation; this keeps the renderer
* alive until it lands.
* The latest quiz outcome per node — a raw EVENT FACT, not a node state
* (the transitional node-state classes were removed 2026-09-08: nothing
* colors nodes by quiz results anymore). Used by the teacher's
* graph-as-context selection (graph-context.ts) to find diagnostic
* hotspots; the home renderer does not consume it. Nodes with no quiz
* events have no entry.
*/
export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Record<string, NodeStateClass> {
const states: Record<string, NodeStateClass> = {};
for (const node of nodes) states[node.id] = "candidate";
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
events.forEach((event, index) => {
if (event.nodeId === null) return;
@@ -395,15 +393,12 @@ export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Rec
latest.set(event.nodeId, { at: event.at, kind: event.kind, index });
}
});
const out = new Map<string, LedgerEventKind>();
for (const node of nodes) {
const last = latest.get(node.id);
if (!last) continue; // candidate
if (last.kind === "correct") states[node.id] = "known";
else if (last.kind === "dont-know") states[node.id] = "dont-know";
else states[node.id] = "missed";
if (last) out.set(node.id, last.kind);
}
return states;
return out;
}
// ─── the /api/graph payload ──────────────────────────────────────────────────
@@ -411,7 +406,6 @@ export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Rec
/** Everything the home graph view needs, in one fetch (shape: contracts). */
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
const { nodes, edges, events } = getGraph(deps.graphStore);
const nodeStates = deriveNodeStates(nodes, events);
const nodeChats = new Map<string, Set<string>>();
const touch = (nodeId: string, chatId: string): void => {
@@ -453,5 +447,26 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
if (ids.length > 0) nodeSessions[nodeId] = ids;
}
return { nodes, edges, nodeStates, sessions, nodeSessions };
// Understanding observations per (node, chat) — the extractor's
// session-relative note (latest wins within a chat). Read-time join for
// the detail card: the confidence history next to the session list.
const nodeObservations: Record<string, Record<string, string>> = {};
const latestObs = new Map<string, { at: string; note: string; index: number }>();
events.forEach((event, index) => {
if (event.kind !== "observation" || event.nodeId === null) return;
if (!event.note) return;
const key = `${event.nodeId}\u0000${event.chatId}`;
const current = latestObs.get(key);
if (!current || event.at > current.at || (event.at === current.at && index > current.index)) {
latestObs.set(key, { at: event.at, note: event.note, index });
}
});
for (const [key, { note }] of latestObs) {
const sep = key.indexOf("\u0000");
const nodeId = key.slice(0, sep);
const chatId = key.slice(sep + 1);
(nodeObservations[nodeId] ??= {})[chatId] = note;
}
return { nodes, edges, sessions, nodeSessions, nodeObservations };
}
+1 -1
View File
@@ -83,8 +83,8 @@ const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
const isGraphNode = (v: unknown): v is GraphNode =>
isRecord(v) &&
isString(v.id) &&
isString(v.name) &&
isString(v.statement) &&
isString(v.description) &&
Array.isArray(v.aliases) &&
v.aliases.every(isString) &&
isString(v.createdAt) &&
+43 -7
View File
@@ -34,7 +34,7 @@ import type {
SessionFindings,
} from "../src/core/contracts.ts";
import type { GraphStore } from "../src/core/ports.ts";
import { createExtractionPipeline, transcriptView } from "../src/agent/subagents/extraction.ts";
import { createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
@@ -135,8 +135,8 @@ const FINDINGS: SessionFindings = {
propositions: [
{
id: "p1",
name: "Derivative",
statement: "The derivative measures how fast a function changes",
description: "The derivative of a function at a point is its instantaneous rate of change there.",
aliases: [],
messages: ["a2"],
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
@@ -144,8 +144,8 @@ const FINDINGS: SessionFindings = {
},
{
id: "p2",
statement: "Chain rule intuition",
description: "The chain rule differentiates composed functions by multiplying local rates.",
name: "Chain rule",
statement: "The chain rule differentiates composed functions by multiplying local rates.",
aliases: [],
messages: ["a2"],
evidence: ["LEARNER [u1]: teach me the chain rule"],
@@ -163,13 +163,16 @@ const FINDINGS: SessionFindings = {
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
};
/** A model whose ONE tool call is `name` with the given input; then text. */
function submitModel(name: string, input: unknown): MockLanguageModelV4 {
/** A model whose ONE tool call is `name` with the given input; then text.
* `onCall` (optional) fires at the moment of the first generate — the
* observation point for mid-run assertions. */
function submitModel(name: string, input: unknown, onCall?: () => void): MockLanguageModelV4 {
let turn = 0;
return new MockLanguageModelV4({
doGenerate: async () => {
turn++;
if (turn === 1) {
onCall?.();
return {
content: [
{
@@ -267,8 +270,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
const existingId = "n_existing";
ledgers.appendNode({
id: existingId,
name: "Derivative",
statement: "Derivatives measure rates of change",
description: "Pre-existing node from an earlier session.",
aliases: [],
createdAt: "2026-08-01T00:00:00.000Z",
provenance: [],
@@ -342,6 +345,39 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
assert.equal(ledgers.readCheckpoints().length, 0);
});
it("reports its phase through the status registry while running, and clears it when done", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
const existingId = "n_existing";
ledgers.appendNode({
id: existingId,
name: "Derivative",
statement: "Derivatives measure rates of change",
aliases: [],
createdAt: "2026-08-01T00:00:00.000Z",
provenance: [],
});
const seen: (string | undefined)[] = [];
const phaseOf = () => extractionStatuses().find((r) => r.chatId === chatId)?.phase;
const verdict: AuditResult = {
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
edges: [],
attribution: { quiz: {} },
dropped: [{ ref: "p2", reason: "evidence not found" }],
};
const pipeline = createExtractionPipeline({
chatStore,
graphStore: ledgers,
// Each stage's first generate is the observation point: the
// registry must already carry this chat at the right phase.
extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())),
auditor: submitModel("submit_review", verdict, () => seen.push(phaseOf())),
});
await pipeline.runExtraction(chatId);
assert.deepEqual(seen, ["extracting", "auditing"]);
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
});
it("is single-flight per chat — a concurrent second run is a no-op", async () => {
const { chatId } = fixtureChat();
const ledgers = memoryLedgers();
+37 -39
View File
@@ -31,7 +31,7 @@ import type {
NodeMapping,
SessionCheckpoint,
} from "../src/core/contracts.ts";
import type { NodeStateClass } from "../src/core/contracts.ts";
import type { LedgerEventKind } from "../src/core/contracts.ts";
import { getGraph, mergeFindings } from "../src/core/graph.ts";
import type { GraphStore } from "../src/core/ports.ts";
import {
@@ -51,8 +51,8 @@ import { PATHS } from "../config.ts";
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
return {
id,
name: statement,
statement,
description: `${statement} — elaborated.`,
aliases: [],
createdAt: "2026-08-22T00:00:00.000Z",
provenance: [],
@@ -64,18 +64,19 @@ function edge(from: string, to: string): GraphEdge {
return { from, to, type: "follows", provenance: [{ chatId: "chat_x", messageId: "m1" }] };
}
function states(...entries: [string, NodeStateClass][]): Record<string, NodeStateClass> {
return Object.fromEntries(entries);
/** Latest-quiz-outcome map: nodeId → quiz event kind (the raw event fact). */
function outcomes(...entries: [string, LedgerEventKind][]): Map<string, LedgerEventKind> {
return new Map(entries);
}
// ─── renderGraphContext (pure) ───────────────────────────────────────────────
describe("renderGraphContext (pure)", () => {
it("returns null for an empty graph (inject nothing)", () => {
assert.equal(renderGraphContext([], [], {}), null);
assert.equal(renderGraphContext([], [], outcomes()), null);
});
it("renders a header, state-grouped node lines (hotspots first), and chained follows edges", () => {
it("renders a header, outcome-grouped node lines (hotspots first), and chained follows edges", () => {
const nodes = [
node("n_a", "Chain rule intuition"),
node("n_b", "Quotient rule"),
@@ -83,25 +84,24 @@ describe("renderGraphContext (pure)", () => {
node("n_d", "Composite functions"),
];
const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")];
const st = states(
["n_a", "known"],
["n_b", "missed"],
["n_c", "candidate"],
const oc = outcomes(
["n_a", "correct"],
["n_b", "miss"],
["n_d", "dont-know"],
);
const out = renderGraphContext(nodes, edges, st)!;
const out = renderGraphContext(nodes, edges, oc)!;
assert.ok(out.includes(GRAPH_CONTEXT_HEADER), "header line present");
assert.match(out, /4 nodes, 3 follows edges/);
// Node lines: "• [state] statement (id)".
assert.ok(out.includes("• [missed] Quotient rule (n_b)"));
assert.ok(out.includes("• [known] Chain rule intuition (n_a)"));
// Node lines: "• [outcome] statement (id)".
assert.ok(out.includes("• [miss] Quotient rule (n_b)"));
assert.ok(out.includes("• [correct] Chain rule intuition (n_a)"));
assert.ok(out.includes("• [dont-know] Composite functions (n_d)"));
assert.ok(out.includes("• [candidate] Product rule (n_c)"));
assert.ok(out.includes("• [never probed] Product rule (n_c)"));
// Groups in the decided order: hotspots, known, candidates.
const order = ["Hotspots", "Known", "Candidates"].map((g) => out.indexOf(g));
// Groups in the decided order: hotspots, correct, never probed.
const order = ["Hotspots", "Answered correctly", "Never probed"].map((g) => out.indexOf(g));
assert.deepEqual([...order].sort((a, b) => a - b), order, "groups appear in priority order");
// Edges merged into chains: d → a → b → c (one line, three arrows).
@@ -112,7 +112,7 @@ describe("renderGraphContext (pure)", () => {
it("renders branching edges as sibling chains, not one false chain", () => {
const nodes = [node("n_a", "A"), node("n_b", "B"), node("n_c", "C")];
const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], states())!;
const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], outcomes())!;
assert.ok(out.includes("A → B"));
assert.ok(out.includes("A → C"));
assert.ok(!out.includes("B → C"), "no edge was invented between siblings");
@@ -123,16 +123,15 @@ describe("renderGraphContext (pure)", () => {
// long tail of disconnected candidates that must drop out.
const nodes: GraphNode[] = [];
const edges: GraphEdge[] = [];
const st: Record<string, NodeStateClass> = {};
const oc = new Map<string, LedgerEventKind>();
const hotspot = (i: number) => {
const h = `n_h${i}`;
const pre = `n_p${i}`;
const dep = `n_d${i}`;
nodes.push(node(pre, `Premise ${i}`), node(h, `Hotspot ${i}`), node(dep, `Derivation ${i}`));
edges.push(edge(pre, h), edge(h, dep));
st[pre] = "known";
st[h] = "missed";
st[dep] = "candidate";
oc.set(pre, "correct");
oc.set(h, "miss");
};
hotspot(1);
hotspot(2);
@@ -140,14 +139,13 @@ describe("renderGraphContext (pure)", () => {
for (let i = 0; i < MAX_CONTEXT_NODES; i++) {
const id = `n_f${i}`;
nodes.push(node(id, `Far away ${i}`));
st[id] = "candidate";
}
assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap");
const out = renderGraphContext(nodes, edges, st)!;
assert.ok(out.includes("• [missed] Hotspot 1 (n_h1)"), "hotspots stay");
assert.ok(out.includes("• [known] Premise 1 (n_p1)"), "1-hop premises stay");
assert.ok(out.includes("• [candidate] Derivation 1 (n_d1)"), "1-hop derivations stay");
const out = renderGraphContext(nodes, edges, oc)!;
assert.ok(out.includes("• [miss] Hotspot 1 (n_h1)"), "hotspots stay");
assert.ok(out.includes("• [correct] Premise 1 (n_p1)"), "1-hop premises stay");
assert.ok(out.includes("• [never probed] Derivation 1 (n_d1)"), "1-hop derivations stay");
assert.ok(!out.includes("Far away"), "disconnected nodes drop out");
assert.match(out, new RegExp(`showing 9 of ${nodes.length} nodes`), "truncation is noted");
assert.ok(out.includes(", 6 follows edges"), "edges among the selected nodes stay");
@@ -155,14 +153,14 @@ describe("renderGraphContext (pure)", () => {
it("keeps the full graph at exactly the cap (no truncation note)", () => {
const nodes = Array.from({ length: MAX_CONTEXT_NODES }, (_, i) => node(`n_${i}`, `Node ${i}`));
const out = renderGraphContext(nodes, [], {})!;
assert.ok(out.includes(`• [candidate] Node ${MAX_CONTEXT_NODES - 1}`));
const out = renderGraphContext(nodes, [], outcomes())!;
assert.ok(out.includes(`• [never probed] Node ${MAX_CONTEXT_NODES - 1}`));
assert.ok(!out.includes("showing"), "at the cap nothing is dropped");
});
it("returns null when a too-large graph has no hotspots at all", () => {
const nodes = Array.from({ length: MAX_CONTEXT_NODES + 1 }, (_, i) => node(`n_${i}`, `Node ${i}`));
assert.equal(renderGraphContext(nodes, [], {}), null);
assert.equal(renderGraphContext(nodes, [], outcomes()), null);
});
});
@@ -173,15 +171,15 @@ describe("buildGraphContext", () => {
assert.equal(buildGraphContext({ nodes: [], edges: [], events: [] }), null);
});
it("derives states from events and renders the block", () => {
it("derives latest quiz outcomes from events and renders the block", () => {
const nodes = [node("n_a", "Solo node"), node("n_b", "Checked node")];
const events: LedgerEvent[] = [
{ nodeId: "n_b", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
];
const out = buildGraphContext({ nodes, edges: [], events })!;
assert.ok(out.includes(GRAPH_CONTEXT_HEADER));
assert.ok(out.includes("• [known] Checked node (n_b)"), "correct → known");
assert.ok(out.includes("• [candidate] Solo node (n_a)"), "no events → candidate");
assert.ok(out.includes("• [correct] Checked node (n_b)"), "correct → answered correctly");
assert.ok(out.includes("• [never probed] Solo node (n_a)"), "no events → never probed");
});
it("reads the live ledgers without throwing (read-only integration)", () => {
@@ -346,16 +344,16 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
propositions: [
{
id: "p1",
statement: "Derivative as a limit",
description: "The derivative is the limit of difference quotients.",
name: "Derivative as a limit",
statement: "The derivative is the limit of difference quotients.",
aliases: [],
messages: ["u1"],
evidence: ["e"],
},
{
id: "p2",
statement: "Chain rule intuition",
description: "Composed functions differentiate by multiplying local rates.",
name: "Chain rule",
statement: "Composed functions differentiate by multiplying local rates.",
aliases: [],
messages: ["u1"],
evidence: ["e"],
@@ -377,8 +375,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
propositions: [
{
id: "q1",
name: "Derivative as a limit",
statement: "The derivative as a limit of quotients", // different phrasing
description: "…",
aliases: [],
messages: ["u1"],
evidence: ["e"],
@@ -386,8 +384,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
},
{
id: "q2",
name: "Chain rule",
statement: "Chain rule, intuitively",
description: "…",
aliases: [],
messages: ["u1"],
evidence: ["e"],
+39
View File
@@ -0,0 +1,39 @@
/**
* Legend/filters predicate tests — the pure half of the graph-view filters
* panel (isNodeVisible); the sigma reducers just consume its verdict.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isNodeVisible } from "../components/graph-view.tsx";
const node = {
name: "Money multiplier",
statement: "The maximum ratio of broad money a bank can create from each unit of base money.",
};
describe("isNodeVisible (search predicate)", () => {
it("is visible with an empty query", () => {
assert.equal(isNodeVisible(node, ""), true);
});
it("search matches the statement case-insensitively", () => {
assert.equal(isNodeVisible(node, "MONEY multiplier"), true);
});
it("search matches the statement when the name does not", () => {
assert.equal(isNodeVisible(node, "MAXIMUM ratio"), true);
assert.equal(isNodeVisible(node, " velocity"), false);
});
it("search matches the name when the statement does not", () => {
assert.equal(isNodeVisible(node, "money MULTIPLIER"), true);
assert.equal(isNodeVisible(node, " banking"), false);
});
it("whitespace-only query is no filter", () => {
assert.equal(isNodeVisible(node, " "), true);
});
});
+24 -17
View File
@@ -167,8 +167,8 @@ function fixtureAudit(): AuditResult {
propositions: [
{
id: "p1",
name: "Alpha",
statement: "Alpha intuition",
description: "Alpha intuition, elaborated: what alpha means and how it behaves in practice.",
aliases: ["alpha feel"],
messages: ["a1", "a3"],
evidence: ["fixture evidence"],
@@ -176,16 +176,16 @@ function fixtureAudit(): AuditResult {
},
{
id: "p2",
name: "Beta rule",
statement: "Beta rule",
description: "The beta rule and when it applies.",
aliases: [],
messages: ["a4"],
evidence: ["fixture evidence"],
},
{
id: "p3",
name: "Gamma",
statement: "Gamma application",
description: "Applying the whole framework to gamma cases.",
aliases: [],
messages: ["a5"],
evidence: ["fixture evidence"],
@@ -241,7 +241,7 @@ describe("the merger (mergeFindings)", () => {
const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id]));
for (const node of graph.nodes) {
assert.match(node.id, /^n_/);
assert.ok(node.description.length > 0, "description is required");
assert.ok(node.name.length > 0, "name is required");
assert.equal("kind" in node, false, "no kind field");
assert.ok(Array.isArray(node.provenance), "message-granular provenance");
}
@@ -333,8 +333,8 @@ describe("the merger (mergeFindings)", () => {
propositions: [
{
id: "q1",
name: "Alpha",
statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings
description: "The intuitive grasp of alpha.",
aliases: ["alpha intuition"],
messages: ["a1"],
evidence: ["fixture evidence"],
@@ -363,13 +363,12 @@ describe("the merger (mergeFindings)", () => {
assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId });
});
it("derives node states and sessions in graphSnapshot", () => {
it("derives sessions and per-session understanding observations in graphSnapshot", () => {
const snap = graphSnapshot(graphDeps);
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
// Alpha's latest quiz is call-9 (chat2, correct) → known.
assert.equal(snap.nodeStates[idByStatement.get("Alpha intuition")!], "known");
assert.equal(snap.nodeStates[idByStatement.get("Beta rule")!], "missed");
assert.equal(snap.nodeStates[idByStatement.get("Gamma application")!], "dont-know");
// No node-state classes anymore (2026-09-08): the snapshot carries
// only the objective graph + sessions; quiz outcomes stay ledger data.
assert.equal("nodeStates" in snap, false);
assert.equal(snap.sessions[chat1.id], "Graph fixture");
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
@@ -379,6 +378,12 @@ describe("the merger (mergeFindings)", () => {
);
// Gamma was only ever touched by chat1.
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]);
// Understanding observations: (node → chat → the extractor's note).
// Alpha carries chat1's "solid after the check"; chat2's q1 has no note.
const alphaObs = snap.nodeObservations[idByStatement.get("Alpha intuition")!] ?? {};
assert.equal(alphaObs[chat1.id], "solid after the check");
assert.equal(alphaObs[chat2Id], undefined);
});
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
@@ -390,8 +395,8 @@ describe("the merger (mergeFindings)", () => {
propositions: [
{
id: "r1",
name: "Alpha",
statement: "Alpha intuition", // IDENTICAL to the existing node's statement
description: "Same words, no verdict.",
aliases: [],
messages: ["u1"],
evidence: ["fixture evidence"],
@@ -473,22 +478,24 @@ describe("graph ledger store", () => {
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
const node = {
id: "n_storetest",
statement: "Store test",
description: "Store test, elaborated.",
name: "Store test",
statement: "Store test, elaborated.",
aliases: [] as string[],
createdAt: "2026-08-22T00:00:00.000Z",
provenance: [{ chatId: "chat_c", messageId: "m1" }],
};
graphStore.appendNode(node);
// A torn append and a legacy pre-redesign node (old schema) — both skipped.
// A torn append and a pre-name-pivot node (old schema: description, no
// name) — both skipped by the strict guard.
fs.appendFileSync(
LEDGER_FILES.nodes,
`{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", kind: "truth", name: "Legacy", createdAt: "2026-08-22T00:00:00.000Z" })}\n`,
`{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", description: "Old schema.", aliases: [], createdAt: "2026-08-22T00:00:00.000Z", provenance: [] })}\n`,
"utf-8",
);
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
assert.equal(nodes.length, 1);
assert.equal(nodes[0]!.statement, "Store test");
assert.equal(nodes[0]!.name, "Store test");
assert.equal(nodes[0]!.statement, "Store test, elaborated.");
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
});
@@ -496,8 +503,8 @@ describe("graph ledger store", () => {
const line = (msgId: string) =>
JSON.stringify({
id: "n_linktest",
name: "Link test",
statement: `Phrasing ${msgId}`,
description: "Elaboration.",
aliases: [],
createdAt: "2026-08-22T00:00:00.000Z",
provenance: [{ chatId: "chat_c", messageId: msgId }],