diff --git a/app/api/graph/route.ts b/app/api/graph/route.ts index 3cbfc0a..96f5508 100644 --- a/app/api/graph/route.ts +++ b/app/api/graph/route.ts @@ -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"; diff --git a/app/api/graph/status/route.ts b/app/api/graph/status/route.ts new file mode 100644 index 0000000..e2a22f4 --- /dev/null +++ b/app/api/graph/status/route.ts @@ -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 }); +} diff --git a/components/graph-view.tsx b/components/graph-view.tsx index dc0b4dd..f8c2e47 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -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 = { - 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 = { - 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, 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(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(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(null); + const graphRef = useRef(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(); + 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(); - 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() {
)} + {/* Legend + filters: search box and per-state toggle rows. */} + {!isEmpty && ( +
+
+ + 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" + /> +
+ {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && ( +
+ No matching nodes. +
+ )} +
+ )} + + {/* Background pipeline status: what is currently running. */} + {pipelineRuns.length > 0 && ( +
+ {pipelineRuns.map((run) => ( +
+ + {run.phase} + {run.title} + {Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s +
+ ))} +
+ )} + {selected && !isEmpty && (
-
{selected.node.statement}
-
{selected.node.description}
-
- {STATE_LABELS[selected.state]} -
+
{selected.node.name}
+
{selected.node.statement}
{selected.chatIds.length > 0 ? (
Sessions
- {selected.chatIds.map((chatId) => ( - - {snapshot?.sessions[chatId] ?? chatId} - - ))} + {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 ( +
+ + {snapshot?.sessions[chatId] ?? chatId} + + {note && ( + + {note} + + )} +
+ ); + })}
) : (
No sessions recorded.
diff --git a/docs/vision.md b/docs/vision.md index 695d9b4..d722f9f 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -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 | diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index 57db55a..105e9e1 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -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(); + +/** 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(); + 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 { 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 { 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 } } diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index bb0d8e1..158b320 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -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; /** chatId → title, for every chat that touched the graph. */ sessions: Record; /** nodeId → chatIds that touched the node (click-through provenance). */ nodeSessions: Record; + /** 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>; } /** Derive a short chat title from the learner's first message (pure). diff --git a/src/core/graph-context.ts b/src/core/graph-context.ts index 9f1bf6d..a583137 100644 --- a/src/core/graph-context.ts +++ b/src/core/graph-context.ts @@ -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 = 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, + outcomes: Map, ): 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); } diff --git a/src/core/graph.ts b/src/core/graph.ts index 1501078..f40d684 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -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 = 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 { - const states: Record = {}; - for (const node of nodes) states[node.id] = "candidate"; - +export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map { const latest = new Map(); 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(); 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>(); 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> = {}; + const latestObs = new Map(); + 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 }; } diff --git a/src/store/graph.ts b/src/store/graph.ts index 8bc9353..ac86c45 100644 --- a/src/store/graph.ts +++ b/src/store/graph.ts @@ -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) && diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index d275ffc..5a5fbec 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.ts @@ -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(); diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts index 2611de0..a4c082b 100644 --- a/tests/graph-context.test.ts +++ b/tests/graph-context.test.ts @@ -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 { 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 { - return Object.fromEntries(entries); +/** Latest-quiz-outcome map: nodeId → quiz event kind (the raw event fact). */ +function outcomes(...entries: [string, LedgerEventKind][]): Map { + 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 = {}; + const oc = new Map(); 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"], diff --git a/tests/graph-view-filters.test.ts b/tests/graph-view-filters.test.ts new file mode 100644 index 0000000..b31ec5b --- /dev/null +++ b/tests/graph-view-filters.test.ts @@ -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); + }); + + +}); \ No newline at end of file diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 567d144..f440ab6 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -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 }],