Strip the transitional node-state classes (2026-09-08)
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.
This commit is contained in:
@@ -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<string, NodeStateClass>;
|
||||
/** chatId → title, for every chat that touched the graph. */
|
||||
sessions: Record<string, string>;
|
||||
/** nodeId → chatIds that touched the node (click-through provenance). */
|
||||
|
||||
+28
-22
@@ -20,9 +20,8 @@
|
||||
* the next session should probe.
|
||||
*/
|
||||
|
||||
import type { GraphEdge, GraphNode } from "./contracts.ts";
|
||||
import { deriveNodeStates, type DerivedGraph } from "./graph.ts";
|
||||
import type { NodeStateClass } from "./contracts.ts";
|
||||
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
|
||||
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
|
||||
|
||||
/** The section header the teacher is taught to recognize (50-graph-context.md). */
|
||||
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
||||
@@ -30,46 +29,51 @@ export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
||||
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
|
||||
export const MAX_CONTEXT_NODES = 150;
|
||||
|
||||
/** Group order for node lines: diagnostic value first (vision §1 hotspots). */
|
||||
const HOTSPOT_STATES: ReadonlySet<NodeStateClass> = new Set(["missed", "dont-know"]);
|
||||
/**
|
||||
* Group order for node lines: diagnostic value first (vision §1 hotspots).
|
||||
* No node-state classes (removed 2026-09-08) — the grouping is the raw
|
||||
* event fact: the node's LATEST quiz outcome (miss/dont-know = hotspot).
|
||||
*/
|
||||
type Outcome = LedgerEventKind | "never-probed";
|
||||
|
||||
function groupOf(node: GraphNode, state: NodeStateClass): number {
|
||||
if (HOTSPOT_STATES.has(state)) return 0;
|
||||
if (state === "known") return 1;
|
||||
return 2; // candidate
|
||||
function groupOf(outcome: Outcome): number {
|
||||
if (outcome === "miss" || outcome === "dont-know") return 0;
|
||||
if (outcome === "correct") return 1;
|
||||
return 2; // never probed
|
||||
}
|
||||
|
||||
const GROUP_LABELS = [
|
||||
"Hotspots (missed / dont-know) — likely misconception sites and unexplored ground:",
|
||||
"Known (answered correctly):",
|
||||
"Candidates (never probed — assume nothing):",
|
||||
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
|
||||
"Answered correctly:",
|
||||
"Never probed (assume nothing):",
|
||||
] as const;
|
||||
|
||||
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
|
||||
function isHotspot(node: GraphNode, state: NodeStateClass): boolean {
|
||||
return groupOf(node, state) === 0;
|
||||
function isHotspot(outcome: Outcome): boolean {
|
||||
return groupOf(outcome) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the context block, or null when there is nothing worth injecting
|
||||
* (no nodes; or a truncated graph whose selection came out empty).
|
||||
*
|
||||
* Pure: callers hand in exactly what getGraph(graphStore) + deriveNodeStates() produce.
|
||||
* Pure: callers hand in exactly what getGraph(graphStore) produces; the
|
||||
* outcomes are the raw latest-quiz-event facts (core/graph.ts).
|
||||
*/
|
||||
export function renderGraphContext(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
states: Record<string, NodeStateClass>,
|
||||
outcomes: Map<string, LedgerEventKind>,
|
||||
): string | null {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
// ── selection ───────────────────────────────────────────────────────────
|
||||
const stateOf = (node: GraphNode): NodeStateClass => states[node.id] ?? "candidate";
|
||||
const outcomeOf = (node: GraphNode): Outcome => outcomes.get(node.id) ?? "never-probed";
|
||||
let selected = nodes;
|
||||
let truncated = false;
|
||||
if (nodes.length > MAX_CONTEXT_NODES) {
|
||||
truncated = true;
|
||||
const hotspots = nodes.filter((n) => isHotspot(n, stateOf(n)));
|
||||
const hotspots = nodes.filter((n) => isHotspot(outcomeOf(n)));
|
||||
const keep = new Set(hotspots.map((n) => n.id));
|
||||
// 1-hop follows neighborhood: premises AND derivations.
|
||||
for (const edge of edges) {
|
||||
@@ -91,12 +95,14 @@ export function renderGraphContext(
|
||||
|
||||
for (const [group, label] of GROUP_LABELS.entries()) {
|
||||
const groupNodes = selected
|
||||
.filter((n) => groupOf(n, stateOf(n)) === group)
|
||||
.filter((n) => groupOf(outcomeOf(n)) === group)
|
||||
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
|
||||
if (groupNodes.length === 0) continue;
|
||||
lines.push("", label);
|
||||
for (const node of groupNodes) {
|
||||
lines.push(`• [${stateOf(node)}] ${node.statement} (${node.id})`);
|
||||
const outcome = outcomeOf(node);
|
||||
const tag = outcome === "never-probed" ? "never probed" : outcome;
|
||||
lines.push(`• [${tag}] ${node.statement} (${node.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +167,6 @@ function edgeChains(edges: GraphEdge[]): string[][] {
|
||||
* chat-handler, so it self-updates as the graph grows mid-session).
|
||||
*/
|
||||
export function buildGraphContext(graph: DerivedGraph): string | null {
|
||||
const states = deriveNodeStates(graph.nodes, graph.events);
|
||||
return renderGraphContext(graph.nodes, graph.edges, states);
|
||||
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
|
||||
return renderGraphContext(graph.nodes, graph.edges, outcomes);
|
||||
}
|
||||
|
||||
+11
-20
@@ -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<string> = new Set(["correct", "miss", "dont-know"]);
|
||||
|
||||
/**
|
||||
* Derive node states from events (never stored): the LATEST quiz event
|
||||
* decides (known / missed / dont-know); a node with no quiz events is a
|
||||
* candidate. TRANSITIONAL (2026-09-02 redesign): `taught` is unreachable
|
||||
* now that no taught events are emitted, and observation events are NOT
|
||||
* state inputs — the real state model (dependency depth + topology-derived
|
||||
* strength + quiz events) is pending ideation; this keeps the renderer
|
||||
* alive until it lands.
|
||||
* The latest quiz outcome per node — a raw EVENT FACT, not a node state
|
||||
* (the transitional node-state classes were removed 2026-09-08: nothing
|
||||
* colors nodes by quiz results anymore). Used by the teacher's
|
||||
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
||||
* events have no entry.
|
||||
*/
|
||||
export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Record<string, NodeStateClass> {
|
||||
const states: Record<string, NodeStateClass> = {};
|
||||
for (const node of nodes) states[node.id] = "candidate";
|
||||
|
||||
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||
events.forEach((event, index) => {
|
||||
if (event.nodeId === null) return;
|
||||
@@ -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<string, LedgerEventKind>();
|
||||
for (const node of nodes) {
|
||||
const last = latest.get(node.id);
|
||||
if (!last) continue; // candidate
|
||||
if (last.kind === "correct") states[node.id] = "known";
|
||||
else if (last.kind === "dont-know") states[node.id] = "dont-know";
|
||||
else states[node.id] = "missed";
|
||||
if (last) out.set(node.id, last.kind);
|
||||
}
|
||||
return states;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
||||
@@ -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<string, Set<string>>();
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user