From 094bafaf2d2af43d827ad3ce4f34b49c1ce0bfff Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 5 Sep 2026 21:35:07 +0300 Subject: [PATCH 1/8] Legend/filters panel: search + per-state visibility toggles The top-right overlay doubles as the state legend and the filter surface: a search box (matches name or statement, case-insensitive) plus one row per node state in reading order (taught, known, missed, don't know, candidate) with its color dot, label, and total count. Clicking a row toggles that state's visibility. Filtering flows through sigma's official nodeReducer/edgeReducer settings: hidden nodes drop out of rendering, hover, picking, AND labels; edges drop when either endpoint is hidden; an open detail card closes if its node is filtered out. Pure predicate (isNodeVisible) is exported and covered by 7 tests; the reducers just consume its verdict. Verified live: search 'Nietzsche' 5457->1687->5457 label px (exact restore), known toggle drops 13 discs+labels and restores, nonsense query shows the 'No matching nodes.' hint with zero labels, counts 0/13/0/0/10 match the ledger. --- components/graph-view.tsx | 146 ++++++++++++++++++++++++++++++- tests/graph-view-filters.test.ts | 48 ++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 tests/graph-view-filters.test.ts diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 99666be..69aefc4 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -38,15 +38,22 @@ * no per-node size gating, so labels never pop in one by one. (The Obsidian * text-fade feel, applied to the whole graph.) * + * The legend/filters panel (top-right overlay) doubles as the state legend: + * a search box plus one row per node state (colored dot, label, count). + * Clicking a row toggles that state's visibility; search matches the + * name or statement text. 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 { SearchIcon, WaypointsIcon } from "lucide-react"; import Graph from "graphology"; import forceAtlas2 from "graphology-layout-forceatlas2"; import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering"; @@ -69,6 +76,26 @@ const STATE_LABELS: Record = { candidate: "candidate", }; +/** Legend row order (the "progress" reading of the states). */ +const STATES_ORDER: NodeStateClass[] = ["taught", "known", "missed", "dont-know", "candidate"]; + +/** + * Pure filter predicate for the legend/filters panel: a node is visible + * when its state is toggled on AND (no query OR name/statement matches). + * Exported for tests; the sigma reducers just consume its verdict. + */ +export function isNodeVisible( + node: Pick, + state: NodeStateClass, + hiddenStates: ReadonlySet, + query: string, +): boolean { + if (hiddenStates.has(state)) return false; + const q = query.trim().toLowerCase(); + if (!q) return true; + return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); +} + // ---- Label drawing (canvas) --------------------------------------------- // // sigma's defaults draw the label on ONE line to the right of the node with @@ -324,6 +351,50 @@ export function GraphView() { const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); + // Legend/filters panel state: toggled-off states + search text. + const [hiddenStates, setHiddenStates] = useState>(new Set()); + 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); + + const toggleState = (state: NodeStateClass) => { + setHiddenStates((prev) => { + const next = new Set(prev); + if (next.has(state)) next.delete(state); + else next.add(state); + return next; + }); + }; + + // Per-state node counts (of ALL nodes, not filtered ones). + const stateCounts = useMemo(() => { + const counts: Record = { + taught: 0, + known: 0, + missed: 0, + "dont-know": 0, + candidate: 0, + }; + if (snapshot) + for (const state of Object.values(snapshot.nodeStates)) counts[state]++; + return counts; + }, [snapshot]); + + // The id set that survives the current filters — 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, snapshot.nodeStates[node.id] ?? "candidate", hiddenStates, query)) + set.add(node.id); + } + return set; + }, [snapshot, hiddenStates, query]); // Fetch the derived graph (the route folds stale chats first). useEffect(() => { @@ -426,6 +497,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; @@ -496,10 +570,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 +640,52 @@ 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" + /> +
+
+ {STATES_ORDER.map((state) => { + const active = !hiddenStates.has(state); + return ( + + ); + })} +
+ {snapshot !== null && visibleIds.size === 0 && (query.trim() !== "" || hiddenStates.size > 0) && ( +
+ No matching nodes. +
+ )} +
+ )} + {selected && !isEmpty && (
= new Set(); +const node = { name: "Money multiplier", statement: "The money multiplier is the maximum ratio of broad money to base money." }; + +describe("isNodeVisible (legend/filters predicate)", () => { + it("is visible with no filters", () => { + assert.equal(isNodeVisible(node, "known", none, ""), true); + }); + + it("is hidden when its state is toggled off, even if the query matches", () => { + const hidden = new Set(["known"]); + assert.equal(isNodeVisible(node, "known", hidden, "money"), false); + }); + + it("a toggled-off state does not affect other states", () => { + const hidden = new Set(["known"]); + assert.equal(isNodeVisible(node, "taught", hidden, ""), true); + }); + + it("search matches the name case-insensitively", () => { + assert.equal(isNodeVisible(node, "candidate", none, "MONEY multiplier"), true); + }); + + it("search matches the statement when the name does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "maximum ratio"), true); + assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false); + }); + + it("whitespace-only query is no filter", () => { + assert.equal(isNodeVisible(node, "missed", none, " "), true); + }); + + it("a node with no state entry defaults to candidate", () => { + // candidate hidden → invisible; visible otherwise. + const hidden = new Set(["candidate"]); + assert.equal(isNodeVisible(node, "candidate", hidden, ""), false); + assert.equal(isNodeVisible(node, "candidate", none, ""), true); + }); +}); \ No newline at end of file From 072671f6da77160e3aa6ccac5e8801ea0c13d01e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sat, 5 Sep 2026 21:36:40 +0300 Subject: [PATCH 2/8] Extraction observability: stage durations + pipeline failure line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The §5 baseline had per-step lines, starts, drops, kept ratios, and merge stats — but no durations (a background pipeline's cost profile was invisible) and no failure line: a mid-pipeline throw left a hanging 'pipeline start' in the server log with nothing recording what failed or when. Now the extractor and auditor each log their elapsed ms on completion, the merged line carries the pipeline total, and runExtraction logs a failure line (with elapsed) before rethrowing. Log shape only — no behavior, contract, or control-flow change. --- src/agent/subagents/extraction.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts index dc9edaf..4c640dc 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -25,6 +25,11 @@ import { mergeFindings, type EmissionStats, type GraphDeps } from "../../core/gr * 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 LLM sees) ──────────────────────────────── @@ -285,10 +290,12 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti 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; } } @@ -303,6 +310,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)`, ); + const t0 = performance.now(); const review = await auditorAgent.generate({ prompt: `Transcript:\n\n${view}\n\nProposed findings:\n\n${JSON.stringify(findings, null, 2)}`, @@ -325,12 +333,14 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti `[extraction ${chatId}] audit kept ${verified.propositions.length}/${findings.propositions.length} propositions, ` + `${verified.edges.length}/${findings.edges.length} edges`, ); + console.info(`[extraction ${chatId}] auditor done in ${Math.round(performance.now() - t0)}ms`); return verified; } 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`); const findings = await extract(chatId); @@ -338,8 +348,16 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti // The merger re-reads the chat record itself — events are rebuilt // from the transcript, never from stale copies. const stats = mergeFindings(chatId, verified, { 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); } From 4131cb6b2408b31c615253089800b4a6e031892e Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 8 Sep 2026 22:04:10 +0300 Subject: [PATCH 3/8] =?UTF-8?q?Vision=20=C2=A71=20redesign:=20node=20theor?= =?UTF-8?q?y,=20follows/supports,=20free-form=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the 2026-09-02 graph philosophy decisions, replacing the 2026-08-22 live-emission construction model (second reversal — back to post-hoc, now deliberately tree-blind): - Node theory: every node is a TRUE proposition (no kind field); statement (minimal, context-relative) + description (required) + aliases (verbatim phrasings); contradictions coexist — the surrounding subgraph is the context; misconceptions enter as true corrections, wrongness lives in the event ledger. - Edges: follows (derivable) | supports (grounds without entailing); no scalars, no encoded strength — derived at read time. - Provenance: message-granular (chat, message) points on nodes and edges, deep-linkable. - Construction: free-form extraction fully decoupled from the session's formalism. Extractor sees ONLY the session transcript (tree-blind, message ids); auditor sees extractor output + the global graph (faithfulness net + identity judgment, link-or-create, aliases as phrasings); merger stays deterministic append-only. - Event ledger: quiz outcomes deterministic from tool parts; NO taught events; extractor records per-proposition understanding notes (named-word scale, no numbers) — treatment TBD. - Understanding: inferred, never encoded; state model (session- independent, computed from dependency depth/strength) is pending ideation — current five-class derivation is transitional. Also updates: §2 plan tree (session scaffolding only), graph-as-context (follows-edge subtree, event-derived hotspots), home page (message provenance, transitional states), roadmap item 5, open questions #2/#3 resolved, amendments table (+6 rows). --- docs/vision.md | 66 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/docs/vision.md b/docs/vision.md index 0a93ae1..695d9b4 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -2,7 +2,7 @@ Living document. Started 2026-08-22, capturing the current vision and all agreed decisions. Amend as things evolve. -Prior art: `knowledge graph design.md` in the Obsidian vault (2025-08-20 ideation) — still canonical for the epistemic model (known vs understood, requires/supports, event ledger, calibration rubric, merging policy). This document records what today's decisions **amend** in it, and adds the session-experience and presentation layers the original doc deferred. +Prior art: `knowledge graph design.md` in the Obsidian vault (2025-08-20 ideation) — was canonical for the epistemic model; the 2026-09-02 node-theory + construction redesign in §1 amends it further (no misconception nodes, `follows`/`supports`, no taught events, message-granular provenance). This document records what today's decisions **amend** in it, and adds the session-experience and presentation layers the original doc deferred. --- @@ -10,33 +10,55 @@ Prior art: `knowledge graph design.md` in the Obsidian vault (2025-08-20 ideatio Everything the learner learns accumulates into one persistent, cross-domain graph. It is the home page of the app and the heart of the system: structure builds out visibly over time, and the data supports frontier expansion, what-next queries, repairs, unlock-value (per the original design doc). +**Node theory + construction redesigned 2026-09-02** — the subsections below from "What the graph is" through "Understanding is derived" are the current philosophy, superseding both the original design doc's epistemic model in part and the 2026-08-22 live-emission construction decision (see the amendments table). + ### Critical requirement: frequent updates -The graph updates **continuously, not per-session**. A learner who answers a few questions and leaves without "finishing" a session must still see something new on the home page. Partial sessions deposit value. This requirement drove the construction decision below. +The graph updates **continuously, not per-session**. A learner who answers a few questions and leaves without "finishing" a session must still see something new on the home page. Partial sessions deposit value. The background extraction pipeline's checkpoint mechanism satisfies this (fold on home/idle, never the same turns twice) — not live emission. -### Construction: live events + background consolidation (DECIDED) +### What the graph is — node theory (DECIDED 2026-09-02) -**Amendment to the original design doc.** The original doc stated "the teacher agent is never modified; all graph-building is post-hoc," and rejected live structured emission as too much cognitive load. That is now **reversed**, because the teacher will be maintaining a formal plan tree anyway (§2) — once it does, live emission is nearly free, and it's the only way to satisfy the frequent-updates requirement. +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**. -- **Live:** the teacher maintains the session's dependency tree via structured tools and tags quiz outcomes to tree nodes as they happen. The home graph reflects this in near real time. -- **Background consolidation (lightweight, frequent):** cross-session node merging (embed → kNN → LLM adjudication, conservative — duplicates over wrong merges), calibration rubric application, and edges that were articulated only in prose. Runs on navigation home / idle — not as a heavy per-session batch. -- The original three-stage transcript-extraction pipeline survives only as (a) the retroactive bootstrap over old session files, (b) the audit/faithfulness net. +- **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. +- **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. -### One substrate, merged across sessions (DECIDED) +### Edges (DECIDED 2026-09-02) -A single node model: propositions at one-reasoning-step grain, `requires`/`supports` edges, no edge scalars. A session's plan tree **is** a session-scoped subgraph. The global graph is the merge of all session trees: +Two kinds, no scalars, no encoded strength — prerequisiteness is a spectrum, and we deliberately refuse to formalize the spectrum: -- **v1 (bare bones):** merge = naive union. No adjudication yet. -- **later:** the embed + LLM-adjudication merge pipeline from the design doc (the quiet superpower: cross-session/cross-domain edges only ever get created here). +- **`follows`** — `A → B`: B is *derivable* from A (A is a premise of B). If C follows from both A and B, both `A → C` and `B → C` exist; joint requirements are just sibling edges. +- **`supports`** — `A → B`: A *grounds or justifies* B without entailing it. -Carried over unchanged from the design doc: known (evidence property) vs understood (derived structural property); event ledger as source of truth with derived state recomputed; anchored 5-level calibration rubric; misconception nodes as first-class structure; candidate node class for frontier territory. +Strength/depth of dependencies is **derived at read time** from topology (chains, corroboration), never stored — this is the input the future understanding model consumes. + +### Provenance — message granularity (DECIDED 2026-09-02) + +Every node records every **(chat, message) point** where it came up; every edge records every message that asserted it. Deep-linkable to the exact transcript points — the derivation audit trail is a first-class feature, not a byproduct. + +### Construction: free-form extraction, fully decoupled from sessions (DECIDED 2026-09-02 — supersedes live emission) + +**Second amendment, reversing the 2026-08-22 live-emission decision.** The session's plan tree is the teacher's scaffolding for the session experience (§2); the global graph does not depend on it — structurally or otherwise. What the teacher formalizes is session-relative; the graph must not inherit those choices. (The original design doc's post-hoc stance was right after all.) + +Three stages, one pipeline — the sole graph writer: + +- **Extractor (LLM) — session-only scope.** Sees ONLY the session transcript (tree-blind, with message ids). Reports freely, with verbatim evidence: propositions from anywhere in the session (one reasoning step each; prose counts as much as anything the teacher formalized), edges as the transcript articulates them, message-granular provenance, retroactive quiz attribution, and per proposition an **understanding note** — how strongly the learner accepted/understood it in this session, a small named-word scale, no numbers. The notes are data for the future state model; their treatment is TBD. +- **Auditor (LLM) — the global-graph scope.** Sees the extractor's output AND the existing graph. Two jobs: the faithfulness net (every candidate verified against verbatim evidence — unchanged), and the **identity judgment**: link each proposition to an existing node (recording the session's phrasing as an alias) or create it new. Conservative: duplicates over wrong merges. +- **Merger (deterministic).** Append-only ledger writes; quiz event kinds/refs rebuilt mechanically from quiz tool parts. Every judgment is an LLM's; everything mechanical stays code. + +**Event ledger (amended):** quiz outcomes are rebuilt deterministically from quiz tool parts (correct / miss / don't-know, plus the learner's note). **There is no `taught` event** — no marking-things-done. The extractor's per-proposition understanding notes join the ledger as session-scoped observations. Identity is no longer exact-string matching — it is the auditor's judgment, with aliases as the phrasing record. + +### Understanding is derived (amended 2026-09-02) + +Known vs. understood, event ledger as source of truth, derived state recomputed on read — carried over from the design doc in spirit. The state model itself is now an explicit design discussion: **node states should be session-independent and computed from the depth and strength of dependencies** (the `follows`/`supports` topology) plus quiz events and understanding notes. That redesign is pending ideation; the current five-class derivation (taught / known / missed / don't-know / candidate) is transitional. The anchored calibration rubric stays parked until real data. ### Rendering (DECIDED — research report 2026-08-22) - **Library: sigma.js v3 + graphology + graphology-layout-forceatlas2.** ~40 KB gzipped all-in — half of vis-network, a third of cytoscape, far below react-force-graph (~250 KB deps) and cosmos.gl (luma.gl stack). - **Layout: synchronous run-then-freeze.** `forceAtlas2.assign(graph, { iterations: 100, settings: inferSettings(graph) })` runs once, writes x/y, and no animation loop ever starts — near-zero idle CPU (sigma only redraws on camera/graph events). <150 ms at hundreds of nodes; at ~2k enable `barnesHutOptimize`, then the bundled Web Worker, then server-cached positions — in that order, only if latency bites. - **Labels at zoom:** sigma's `labelRenderedSizeThreshold` replicates Obsidian's "text fade threshold" exactly. -- **Pan/zoom** built in; hover/click events (`enterNode`, `clickNode`) free. Typed edges: `EdgeArrowProgram` and/or two edge colors for `requires` vs `supports`. +- **Pan/zoom** built in; hover/click events (`enterNode`, `clickNode`) free. Typed edges: `EdgeArrowProgram` and/or two edge colors for `follows` vs `supports`. - **React integration:** thin ~40-line `useEffect` wrapper (sigma is imperative; `@react-sigma/core` exists but is optional). - **Obsidian internals (confirmed by research):** PixiJS **WebGL** renderer (canvas fallback), custom d3-force-style simulation (its four force sliders map 1:1 onto d3-force primitives), zoom-gated label fade, alpha-decay settling. Our chosen stack reproduces that feel with a documented, maintained library. - Rejected: vis-network / cytoscape.js (Canvas 2D, 3× bundle, editing features we don't need), react-force-graph-2d (best freeze API but heaviest dep tree; its strength is *animated* simulation, which we explicitly don't want), cosmos.gl (GPU forces — overkill below ~10k nodes). @@ -47,11 +69,11 @@ Carried over unchanged from the design doc: known (evidence property) vs underst Every session starts with the learner's knowledge graph as base context for the teacher. This is what makes probing *precise* — probe questions can target subtleties and nuances at the boundary of what the learner actually holds, instead of re-discovering it from scratch each session. Personalization compounds across sessions. -**The selection problem (open):** the whole graph may eventually be too much context, and pedagogical relevance ≠ semantic closeness. What matters for a new topic: (a) its **structural neighborhood** — the `requires`-edge subtree the topic stands on, regardless of surface similarity; (b) **diagnostic hotspots** — misconception / shaky / floating nodes anywhere in the graph (a live misconception is always relevant context); (c) the frontier strip near the topic. Candidate strategies, to tune on real data: whole graph while it's small; k-hop structural neighborhood around the topic's nodes; ledger-informed slices (weak/misconceived/floating first); a cheap routing pass that picks subtrees given the session topic. +**The selection problem (open):** the whole graph may eventually be too much context, and pedagogical relevance ≠ semantic closeness. What matters for a new topic: (a) its **structural neighborhood** — the `follows`-edge subtree the topic stands on, regardless of surface similarity; (b) **diagnostic hotspots** — nodes the ledger marks as shaky / misunderstood / misconceived, anywhere in the graph (a live misconception — events showing the learner holds a node wrong — is always relevant context); (c) the frontier strip near the topic. Candidate strategies, to tune on real data: whole graph while it's small; k-hop structural neighborhood around the topic's nodes; ledger-informed slices (weak/misconceived/floating first); a cheap routing pass that picks subtrees given the session topic. ### Home page -The graph view replaces the current placeholder. v1: all nodes from all sessions (naive union), node color/shape by state class (taught / probed / known / not-held / candidate later), pan/zoom, labels at zoom. Clicking a node → the session(s) that touched it (cheap version: title list). Everything richer (frontier glow, rot map, blast radius) comes after the consolidation pipeline exists. +The graph view replaces the current placeholder. v1: all nodes from all sessions, node color/shape by state class (the current five classes are transitional — see "Understanding is derived"), pan/zoom, labels at zoom. Clicking a node → the exact (session, message) points where it came up (message-granular provenance, deep-linked). Everything richer (frontier glow, rot map, blast radius) comes after the state model exists. --- @@ -63,6 +85,7 @@ Goal: a learning session must look and feel structurally different from a ChatGP - The agent authors the tree via **structured tools** — `add_node`, `add_edge`, `reparent`, `remove_node`, `set_current`, … — schema-validated, with stable node identity across edits. Mermaid text as the source of truth was rejected: edits would be text surgery and identity would drift. - The agent may edit the tree mid-session at its own initiative (detour when the learner asks something worth exploring, go deeper, prune) — that power is explicit. +- The tree is **session scaffolding only**: the global graph is built by free-form post-hoc extraction that is deliberately tree-blind (§1, construction). Nothing structural rides from the tree into the graph. - **"Where we are"** is tracked as structured state (a `set_current` tool), not inferred from the transcript. - **Mermaid survives only for ad-hoc lesson diagrams** (flows, cycles, comparisons) that are not the plan tree — and the teacher is explicitly **encouraged to diagram freely in explanations**; diagrams are first-class teaching output, not decoration. Its rendering quality must improve (integrated display, working zoom/pan — current code-block-ish rendering is unacceptable), but it's now a peripheral concern. @@ -173,7 +196,7 @@ The teacher's context window and attention are precious — it has the lesson to 2. **Bare-bones global graph + home view** — live emission from tree tools into a graph store, naive-union merge, home page renders it with the chosen lightweight renderer. *This is the "see something appear even from a partial session" milestone.* **✅ Wave 2 (W4): JSONL ledgers, idempotent emission, sigma.js + forceAtlas2 home view, foldStaleChats catch-up. Live-verified.** 3. **Phase separation** — probe/teach marker, transcript divider, collapsed teach-phase checks. **✅ Wave 2 (W5): phaseAt from first plan_init, "Teaching · N nodes" divider, invitation bar + reveal. Live-verified.** 4. **Right-panel tree view** — persistent, current-position highlight. **✅ Wave 3 (W7): topo order, done/current/next/upcoming, progress header, collapse-to-rail persisted. Live-verified.** -5. **Background consolidation** — merge adjudication, calibration, prose-only edges. **◐ Wave 3 (W8, lite): exact/alias-match merge + graph-as-context injection (whole graph while small, 150-node cap with hotspot+1-hop truncation). Live-verified personalization. Full embed+adjudication merge and calibration rubric remain deferred.** +5. **Background consolidation → free-form extraction pipeline** — merge adjudication, calibration, prose-only edges. **◐ Wave 3 (W8, lite) shipped exact/alias-match merge + graph-as-context injection. Superseded direction (2026-09-02): the pipeline becomes free-form post-hoc extraction (extractor session-only → auditor with global-graph identity judgment → deterministic merger), tree-blind, message-granular provenance, no taught events, per-proposition understanding notes. Minimal implementation of the §1 philosophy next.** 6. **Demo capability** — the `demo` artifact contract (sandboxed iframe, error-fix loop) + the **demo-builder sub-agent** that tests code before delivery (§2.5). High value; slots in any time after step 1. **✅ Wave 2 (W6): demo + visual tools, verifying builders (headless Chrome), sandboxed demo card, SVG card. Live-verified (visual needs a vision-capable LH_VISUAL_MODEL).** 6b. **Researcher tool** — fact verification for the teacher (§2.5); cheap port of proven pi pieces. **✅ Wave 1 (W2): async job protocol, live activity card, sourced brief, teacher continuation. Live-verified.** 7. **Presentation fixes** — LaTeX delimiters, line breaks, mermaid integration. **✅ Wave 1 (W3): \(…\)/\[…\] contract ($ literal forever), remark-breaks, dark mermaid + styled streamdown chrome. Live-verified.** @@ -185,8 +208,8 @@ The teacher's context window and attention are precious — it has the lesson to ## 5. Open questions 1. ~~Graph rendering library + layout architecture~~ — **decided 2026-08-22: sigma.js + forceatlas2, run-then-freeze** (see §1). Remaining: run the 2k-node synthetic benchmark before implementation. -2. What exactly the naive-union v1 shows for overlapping topics across sessions (duplicate nodes acceptable until consolidation? — presumed yes, cosmetic only). -3. How the probe phase's questions map onto tree nodes when no tree exists yet (probe happens *before* the plan is written — do probe outcomes attach to a provisional node set, or to the eventual tree retroactively?). +2. ~~Overlapping topics across sessions~~ — resolved by the 2026-09-02 model: identity is the auditor's judgment (link-or-create, aliases as phrasings); duplicates are acceptable-and-correctable, wrong merges are not. +3. ~~Probe questions when no tree exists yet~~ — resolved by free-form extraction: quiz attribution is retroactive against the whole transcript (extractor judgment, auditor-verified), with no dependence on the tree at all. 4. Home-graph v1 interactions beyond pan/zoom (node click → session list? — presumed cheap yes). 5. Grain mismatch frequency (`subsumes`-style relations) — per original doc, measured by data, not pre-solved. 6. Aggregation semantics for understanding (min / mean / two-level) — fit on real data, per original doc. @@ -197,7 +220,12 @@ The teacher's context window and attention are precious — it has the lesson to | Original | Now | |---|---| -| Teacher never modified; graph built post-hoc | Teacher emits structured data live; background pass consolidates | +| Teacher never modified; graph built post-hoc | Post-hoc again (2026-09-02): free-form extraction, fully decoupled from the session's formalism — the 2026-08-22 live-emission reversal is itself reversed | +| Misconception nodes as first-class structure | No node `kind`: every node is a true proposition; misconceptions enter as true corrections, wrongness lives in the event ledger | +| `requires` edges | `follows` / `supports` — derivability vs. grounding; strength derived at read time, never stored | +| `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) | | 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 | From 021c659d1489a1b96367ee11418e004cb54bf4d9 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 8 Sep 2026 22:43:17 +0300 Subject: [PATCH 4/8] graph-view docs: labels now render statement (post-redesign rekey follow-up) --- components/graph-view.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index e4b31d9..8bb2209 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -10,20 +10,21 @@ * 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) + * Labels render the node `statement` (the minimal proposition — identity + * key + rendering label; the full `description` lives on the detail card) * through custom canvas drawers that 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 - * disc edge, and its name brightens — the "this is clickable, + * disc edge, and its statement 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 * halo's opacity and the text's muted→bright cross-fade stay in * sync (no growth, no pulse; interpolating from the current value * means rapid hover toggles glide instead of snapping). Hover - * always shows the name, even when labels are zoomed out. + * always shows the statement, even when labels are zoomed out. * Node hit-testing is the disc PLUS a pad: sigma picks nodes from a * half-resolution framebuffer (no v3 setting enlarges hitboxes), and the * glow halo is bigger than the stock hitbox — touching the halo @@ -101,7 +102,7 @@ export function isNodeVisible( // sigma's defaults draw the label on ONE line to the right of the node with // a white box on hover. We replace both: names wrap under the node at a max // line width, muted at rest; on hover the node pulses a soft glow in its -// own state color and the name brightens (no background box). +// own state color and the statement brightens (no background box). const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line @@ -226,7 +227,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 statement, centered below the node — used by both drawers * with identical geometry so hover only changes the color, nothing jumps. */ function drawName( context: CanvasRenderingContext2D, @@ -253,7 +254,7 @@ function drawName( * 1 = fully hovered): a glow halo in the node's own state color at a * CONSTANT radius (opacity fades in/out; the halo never grows) starting * flush at the disc edge (the evenodd punch-out keeps the disc itself - * unpainted). The name is drawn at partial alpha in the bright hover + * unpainted). The statement is drawn at partial alpha in the bright hover * color — the muted resting label on the layer beneath shows through, * so text and halo cross-fade on the SAME factor and stay in sync. */ function paintHover( @@ -277,7 +278,7 @@ function paintHover( context.fill("evenodd"); } // Same fade factor as the halo: text and glow rise together. The - // bright name at partial alpha over the muted resting label beneath is + // bright statement at partial alpha over the muted resting label beneath is // a smooth muted→bright cross-fade rather than an instant color swap. context.globalAlpha = fade; drawName(context, data, settings, hoverTextColor); From bdd9cac2b76b331f642f4587a2d0395a1b75831b Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 00:40:02 +0300 Subject: [PATCH 5/8] Node schema pivot: short name (label) + full-precision statement; description dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With real extracted data, full-sentence statements as canvas labels proved unworkable: 10-line label blocks, illegible overprint clusters, bottom-edge clipping. Per the user's 2026-09-08 decision: - GraphNode/ExtractedProposition gain `name` — a very short, glanceable label (glossary-headword style: "Will to power"), presentational only: not unique, not identity, never precise. Set at creation; link-appends never change it (first line defines; labels stay stable across sessions). - `statement` keeps its full precision and becomes the identity anchor; the detail card shows it under the name. `description` is dropped. - Extractor coins the name (freshest session context); auditor carries it through on create, rewording only clearly unglanceable names; linked propositions keep the existing node's name. - Renderer: canvas label = name; search matches name + statement; detail card = name (bold) + statement (muted). - Store guard requires name (pre-pivot node lines are skipped as legacy); vision.md node-schema bullet + amendments table updated (2026-09-08). --- components/graph-view.tsx | 31 ++++++++++++++------------- docs/vision.md | 3 ++- src/agent/subagents/extraction.ts | 30 ++++++++++++++++---------- src/core/contracts/graph.ts | 35 +++++++++++++++++++------------ src/core/graph.ts | 11 ++++++---- src/store/graph.ts | 2 +- tests/extraction.test.ts | 8 +++---- tests/graph-context.test.ts | 14 ++++++------- tests/graph-view-filters.test.ts | 13 ++++++++---- tests/graph.test.ts | 26 ++++++++++++----------- 10 files changed, 101 insertions(+), 72 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 8bb2209..934d192 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -10,21 +10,22 @@ * and cancelled on unmount — idle CPU stays near zero. Physics is * placement only: pan/zoom, no dragging, no editing. * - * Labels render the node `statement` (the minimal proposition — identity - * key + rendering label; the full `description` lives on the detail card) - * 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 - * disc edge, and its statement brightens — the "this is clickable, + * 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 * halo's opacity and the text's muted→bright cross-fade stay in * sync (no growth, no pulse; interpolating from the current value * means rapid hover toggles glide instead of snapping). Hover - * always shows the statement, even when labels are zoomed out. + * always shows the name, even when labels are zoomed out. * Node hit-testing is the disc PLUS a pad: sigma picks nodes from a * half-resolution framebuffer (no v3 setting enlarges hitboxes), and the * glow halo is bigger than the stock hitbox — touching the halo @@ -82,11 +83,11 @@ const STATES_ORDER: NodeStateClass[] = ["taught", "known", "missed", "dont-know" /** * Pure filter predicate for the legend/filters panel: a node is visible - * when its state is toggled on AND (no query OR statement matches). + * when its state is toggled on AND (no query OR name/statement matches). * Exported for tests; the sigma reducers just consume its verdict. */ export function isNodeVisible( - node: Pick, + node: Pick, state: NodeStateClass, hiddenStates: ReadonlySet, query: string, @@ -94,7 +95,7 @@ export function isNodeVisible( if (hiddenStates.has(state)) return false; const q = query.trim().toLowerCase(); if (!q) return true; - return node.statement.toLowerCase().includes(q) || node.description.toLowerCase().includes(q); + return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q); } // ---- Label drawing (canvas) --------------------------------------------- @@ -102,7 +103,7 @@ export function isNodeVisible( // sigma's defaults draw the label on ONE line to the right of the node with // a white box on hover. We replace both: names wrap under the node at a max // line width, muted at rest; on hover the node pulses a soft glow in its -// own state color and the statement brightens (no background box). +// own state color and the name brightens (no background box). const LABEL_MAX_WIDTH = 170; // px — wraps long names instead of a wide strip const LABEL_GAP = 6; // px between node edge and first label line @@ -227,7 +228,7 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu return lines; } -/** The wrapped node statement, 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, @@ -254,7 +255,7 @@ function drawName( * 1 = fully hovered): a glow halo in the node's own state color at a * CONSTANT radius (opacity fades in/out; the halo never grows) starting * flush at the disc edge (the evenodd punch-out keeps the disc itself - * unpainted). The statement is drawn at partial alpha in the bright hover + * unpainted). The name is drawn at partial alpha in the bright hover * color — the muted resting label on the layer beneath shows through, * so text and halo cross-fade on the SAME factor and stay in sync. */ function paintHover( @@ -278,7 +279,7 @@ function paintHover( context.fill("evenodd"); } // Same fade factor as the halo: text and glow rise together. The - // bright statement at partial alpha over the muted resting label beneath is + // bright name at partial alpha over the muted resting label beneath is // a smooth muted→bright cross-fade rather than an instant color swap. context.globalAlpha = fade; drawName(context, data, settings, hoverTextColor); @@ -435,7 +436,7 @@ export function GraphView() { // frozen layout stable for a given node set across remounts. const rand = seededRandom(node.id); graph.addNode(node.id, { - label: node.statement, + label: node.name, size: STATE_TOKENS[state].size, color: stateColors.get(state) ?? edgeColor, x: (rand() - 0.5) * 100, @@ -695,8 +696,8 @@ 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" > -
{selected.node.statement}
-
{selected.node.description}
+
{selected.node.name}
+
{selected.node.statement}
{STATE_LABELS[selected.state]}
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 843be08..4dd1e9d 100644 --- a/src/agent/subagents/extraction.ts +++ b/src/agent/subagents/extraction.ts @@ -99,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( @@ -107,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, @@ -168,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. @@ -254,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 @@ -271,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"); diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index bb0d8e1..8ee59cf 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. */ diff --git a/src/core/graph.ts b/src/core/graph.ts index 1501078..d3791bd 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -166,10 +166,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 +179,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 +331,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. 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..b533895 100644 --- a/tests/extraction.test.ts +++ b/tests/extraction.test.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"], @@ -267,8 +267,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: [], diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts index 2611de0..6e1c923 100644 --- a/tests/graph-context.test.ts +++ b/tests/graph-context.test.ts @@ -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: [], @@ -346,16 +346,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 +377,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 +386,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 index aeb7b56..f42e062 100644 --- a/tests/graph-view-filters.test.ts +++ b/tests/graph-view-filters.test.ts @@ -10,8 +10,8 @@ import type { NodeStateClass } from "../src/core/index.ts"; const none: ReadonlySet = new Set(); const node = { - statement: "The money multiplier is the maximum ratio of broad money to base money.", - description: "How much broad money banks create from each unit of base money.", + name: "Money multiplier", + statement: "The maximum ratio of broad money a bank can create from each unit of base money.", }; describe("isNodeVisible (legend/filters predicate)", () => { @@ -33,11 +33,16 @@ describe("isNodeVisible (legend/filters predicate)", () => { assert.equal(isNodeVisible(node, "candidate", none, "MONEY multiplier"), true); }); - it("search matches the description when the statement does not", () => { - assert.equal(isNodeVisible(node, "candidate", none, "maximum ratio"), true); + it("search matches the statement when the name does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "MAXIMUM ratio"), true); assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false); }); + it("search matches the name when the statement does not", () => { + assert.equal(isNodeVisible(node, "candidate", none, "money MULTIPLIER"), true); + assert.equal(isNodeVisible(node, "candidate", none, " banking"), false); + }); + it("whitespace-only query is no filter", () => { assert.equal(isNodeVisible(node, "missed", none, " "), true); }); diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 567d144..9bf55e0 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"], @@ -390,8 +390,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 +473,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 +498,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 }], From 54f88f37e1bac745081f14db9be130ef22ae05d3 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 01:24:08 +0300 Subject: [PATCH 6/8] Strip the transitional node-state classes (2026-09-08) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five-class NodeStateClass (taught/known/missed/dont-know/candidate) was transitional scaffelage from the 2026-09-02 redesign, deriving state by latest-quiz-wins — exactly what the philosophy rejects: understanding is inferred at read time from the real state model (still pending ideation), never encoded as quiz-derived classes. - contracts: NodeStateClass deleted; GraphSnapshot no longer carries nodeStates (objective graph + sessions only). - core: deriveNodeStates replaced by latestQuizOutcomes — the raw event fact (nodeId -> latest quiz kind), consumed ONLY by the teacher's graph-as-context selection, never by the home renderer. - graph-context: hotspot selection re-keyed to outcomes — groups are now 'Hotspots (last answer wrong or don-t-know) / Answered correctly / Never probed'. Same diagnostic selection, no state taxonomy. - graph-view: nodes render uniformly (accent color, size 8); the panel is search-only; the detail card's state badge is gone. Quiz outcomes remain ledger data for the future state model. Verified live (headless): uniform discs (single color/size, zero state pixels), search-only panel, card = name + statement + sessions, no console errors. --- components/graph-view.tsx | 123 ++++++------------------------- src/core/contracts/graph.ts | 11 --- src/core/graph-context.ts | 50 +++++++------ src/core/graph.ts | 31 +++----- tests/graph-context.test.ts | 62 ++++++++-------- tests/graph-view-filters.test.ts | 37 +++------- tests/graph.test.ts | 7 +- 7 files changed, 104 insertions(+), 217 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index 934d192..b11145a 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -18,7 +18,7 @@ * 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 @@ -40,10 +40,11 @@ * no per-node size gating, so labels never pop in one by one. (The Obsidian * text-fade feel, applied to the whole graph.) * - * The legend/filters panel (top-right overlay) doubles as the state legend: - * a search box plus one row per node state (colored dot, label, count). - * Clicking a row toggles that state's visibility; search matches the - * name or statement text. Filtering flows through sigma's official + * 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. @@ -59,40 +60,19 @@ import { 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 }, -}; - -const STATE_LABELS: Record = { - taught: "taught", - known: "known", - missed: "missed", - "dont-know": "don't know", - candidate: "candidate", -}; - -/** Legend row order (the "progress" reading of the states). */ -const STATES_ORDER: NodeStateClass[] = ["taught", "known", "missed", "dont-know", "candidate"]; +/** 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; /** - * Pure filter predicate for the legend/filters panel: a node is visible - * when its state is toggled on AND (no query OR name/statement matches). + * 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, - state: NodeStateClass, - hiddenStates: ReadonlySet, - query: string, -): boolean { - if (hiddenStates.has(state)) return false; +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); @@ -342,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 }; @@ -353,8 +332,7 @@ export function GraphView() { const cardRef = useRef(null); const [snapshot, setSnapshot] = useState(null); const [selected, setSelected] = useState(null); - // Legend/filters panel state: toggled-off states + search text. - const [hiddenStates, setHiddenStates] = useState>(new Set()); + // 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` @@ -363,40 +341,16 @@ export function GraphView() { const graphRef = useRef(null); const [rendererEpoch, setRendererEpoch] = useState(0); - const toggleState = (state: NodeStateClass) => { - setHiddenStates((prev) => { - const next = new Set(prev); - if (next.has(state)) next.delete(state); - else next.add(state); - return next; - }); - }; - - // Per-state node counts (of ALL nodes, not filtered ones). - const stateCounts = useMemo(() => { - const counts: Record = { - taught: 0, - known: 0, - missed: 0, - "dont-know": 0, - candidate: 0, - }; - if (snapshot) - for (const state of Object.values(snapshot.nodeStates)) counts[state]++; - return counts; - }, [snapshot]); - - // The id set that survives the current filters — consumed by the sigma + // 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, snapshot.nodeStates[node.id] ?? "candidate", hiddenStates, query)) - set.add(node.id); + if (isNodeVisible(node, query)) set.add(node.id); } return set; - }, [snapshot, hiddenStates, query]); + }, [snapshot, query]); // Fetch the derived graph (the route folds stale chats first). useEffect(() => { @@ -421,24 +375,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.name, - size: STATE_TOKENS[state].size, - color: stateColors.get(state) ?? edgeColor, + size: NODE_SIZE, + color: nodeColor, x: (rand() - 0.5) * 100, y: (rand() - 0.5) * 100, }); @@ -509,7 +459,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 }, }); @@ -655,32 +604,7 @@ export function GraphView() { 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" />
-
- {STATES_ORDER.map((state) => { - const active = !hiddenStates.has(state); - return ( - - ); - })} -
- {snapshot !== null && visibleIds.size === 0 && (query.trim() !== "" || hiddenStates.size > 0) && ( + {snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
No matching nodes.
@@ -698,9 +622,6 @@ export function GraphView() { >
{selected.node.name}
{selected.node.statement}
-
- {STATE_LABELS[selected.state]} -
{selected.chatIds.length > 0 ? (
Sessions
diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 8ee59cf..6a2ad44 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -221,21 +221,10 @@ 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). */ 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 d3791bd..b2d2281 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"; @@ -377,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; @@ -398,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 ────────────────────────────────────────────────── @@ -414,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 => { @@ -456,5 +447,5 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { if (ids.length > 0) nodeSessions[nodeId] = ids; } - return { nodes, edges, nodeStates, sessions, nodeSessions }; + return { nodes, edges, sessions, nodeSessions }; } diff --git a/tests/graph-context.test.ts b/tests/graph-context.test.ts index 6e1c923..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 { @@ -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)", () => { diff --git a/tests/graph-view-filters.test.ts b/tests/graph-view-filters.test.ts index f42e062..b31ec5b 100644 --- a/tests/graph-view-filters.test.ts +++ b/tests/graph-view-filters.test.ts @@ -6,51 +6,34 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { isNodeVisible } from "../components/graph-view.tsx"; -import type { NodeStateClass } from "../src/core/index.ts"; -const none: ReadonlySet = new Set(); const node = { name: "Money multiplier", statement: "The maximum ratio of broad money a bank can create from each unit of base money.", }; -describe("isNodeVisible (legend/filters predicate)", () => { - it("is visible with no filters", () => { - assert.equal(isNodeVisible(node, "known", none, ""), true); - }); - - it("is hidden when its state is toggled off, even if the query matches", () => { - const hidden = new Set(["known"]); - assert.equal(isNodeVisible(node, "known", hidden, "money"), false); - }); - - it("a toggled-off state does not affect other states", () => { - const hidden = new Set(["known"]); - assert.equal(isNodeVisible(node, "taught", hidden, ""), true); +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, "candidate", none, "MONEY multiplier"), true); + assert.equal(isNodeVisible(node, "MONEY multiplier"), true); }); it("search matches the statement when the name does not", () => { - assert.equal(isNodeVisible(node, "candidate", none, "MAXIMUM ratio"), true); - assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false); + 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, "candidate", none, "money MULTIPLIER"), true); - assert.equal(isNodeVisible(node, "candidate", none, " banking"), false); + assert.equal(isNodeVisible(node, "money MULTIPLIER"), true); + assert.equal(isNodeVisible(node, " banking"), false); }); it("whitespace-only query is no filter", () => { - assert.equal(isNodeVisible(node, "missed", none, " "), true); + assert.equal(isNodeVisible(node, " "), true); }); - it("a node with no state entry defaults to candidate", () => { - // candidate hidden → invisible; visible otherwise. - const hidden = new Set(["candidate"]); - assert.equal(isNodeVisible(node, "candidate", hidden, ""), false); - assert.equal(isNodeVisible(node, "candidate", none, ""), true); - }); + }); \ No newline at end of file diff --git a/tests/graph.test.ts b/tests/graph.test.ts index 9bf55e0..ccc2e18 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -366,10 +366,9 @@ describe("the merger (mergeFindings)", () => { it("derives node states and sessions 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"); From 0fccbbe2ad9c43552c1b51ce7da596e3bd8325cf Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 01:25:14 +0300 Subject: [PATCH 7/8] Detail card: per-session understanding notes next to the session list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extractor's per-proposition understanding observations (named-word scale: solid/shaky/accepted/...) were ledger data since the redesign, with no surface. This exposes them at read time and shows each session's note as a small chip on its session row in the node detail card — the per-session confidence history the user asked for, one observation per (node, chat), latest wins within a chat. - GraphSnapshot.nodeObservations: nodeId -> chatId -> note, built in graphSnapshot() from observation events (pure read-time join). - Sessions list rows: link + observation chip. - Session-relative judgments stay session-scoped: they render next to their session, never as node state (the state model stays pending). --- components/graph-view.tsx | 29 ++++++++++++++++++++--------- src/core/contracts/graph.ts | 5 +++++ src/core/graph.ts | 23 ++++++++++++++++++++++- tests/graph.test.ts | 8 +++++++- 4 files changed, 54 insertions(+), 11 deletions(-) diff --git a/components/graph-view.tsx b/components/graph-view.tsx index b11145a..bb4e349 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -625,15 +625,26 @@ export function GraphView() { {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/src/core/contracts/graph.ts b/src/core/contracts/graph.ts index 6a2ad44..158b320 100644 --- a/src/core/contracts/graph.ts +++ b/src/core/contracts/graph.ts @@ -229,6 +229,11 @@ export interface GraphSnapshot { 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.ts b/src/core/graph.ts index b2d2281..f40d684 100644 --- a/src/core/graph.ts +++ b/src/core/graph.ts @@ -447,5 +447,26 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot { if (ids.length > 0) nodeSessions[nodeId] = ids; } - return { nodes, edges, 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/tests/graph.test.ts b/tests/graph.test.ts index ccc2e18..f440ab6 100644 --- a/tests/graph.test.ts +++ b/tests/graph.test.ts @@ -363,7 +363,7 @@ 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])); // No node-state classes anymore (2026-09-08): the snapshot carries @@ -378,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", () => { From 0c12d53fca9d0c2c5ed62d047eae1a2d1227d80c Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Wed, 9 Sep 2026 01:26:49 +0300 Subject: [PATCH 8/8] Background pipeline status: registry + /api/graph/status + graph-view chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The background extraction pipeline is invisible on the home surface — essential observability per the user's directive: the graph view must show what is currently running (extracting / auditing / merging) and for which chat. - extraction.ts: module-level registry of in-flight runs (chatId, phase, startedAt), updated at each phase transition in runExtraction and cleared in finally (done or failed). extractionStatuses() is the read; one process, one registry. - GET /api/graph/status: thin transport over the registry, chat titles joined from the chat store. - graph-view: polls every 2s and shows one chip per running pipeline (spinner + phase + title + elapsed seconds, bottom-left); when a run finishes (some -> none) the graph refetches, so merges land live. Status is cosmetic — poll errors never surface. - Unit test drives a mocked pipeline run and observes the registry mid-flight: 'extracting' during the extractor's generate, 'auditing' during the auditor's, empty after. --- app/api/graph/route.ts | 4 +-- app/api/graph/status/route.ts | 21 ++++++++++++++ components/graph-view.tsx | 47 +++++++++++++++++++++++++++++-- src/agent/subagents/extraction.ts | 31 ++++++++++++++++++++ tests/extraction.test.ts | 42 +++++++++++++++++++++++++-- 5 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 app/api/graph/status/route.ts 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 bb4e349..f8c2e47 100644 --- a/components/graph-view.tsx +++ b/components/graph-view.tsx @@ -56,7 +56,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; -import { SearchIcon, 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"; @@ -352,7 +352,9 @@ export function GraphView() { return set; }, [snapshot, query]); - // Fetch the derived graph (the route folds stale chats first). + // 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") @@ -364,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. @@ -612,6 +638,23 @@ export function GraphView() {
)} + {/* 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 && (
(); + +/** 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 { @@ -327,6 +346,14 @@ 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}`); @@ -388,8 +415,11 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti 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 }); @@ -405,6 +435,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti throw err; } finally { inFlight.delete(chatId); + runningExtractions.delete(chatId); // done or failed — no longer running } } diff --git a/tests/extraction.test.ts b/tests/extraction.test.ts index b533895..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"; @@ -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: [ { @@ -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();