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");