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:
+22
-101
@@ -18,7 +18,7 @@
|
|||||||
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
||||||
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
||||||
* hardcoded white one). Instead the hovered node gets a soft glow
|
* 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,
|
* disc edge, and its name brightens — the "this is clickable,
|
||||||
* click for the full fact" affordance. Entering/leaving hover fades
|
* click for the full fact" affordance. Entering/leaving hover fades
|
||||||
* ONE transition factor shared by halo and text alike, so the
|
* 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
|
* no per-node size gating, so labels never pop in one by one. (The Obsidian
|
||||||
* text-fade feel, applied to the whole graph.)
|
* text-fade feel, applied to the whole graph.)
|
||||||
*
|
*
|
||||||
* The legend/filters panel (top-right overlay) doubles as the state legend:
|
* The search panel (top-right overlay): a search box matching name or
|
||||||
* a search box plus one row per node state (colored dot, label, count).
|
* statement text — the transitional node-state classes are gone (2026-09-08;
|
||||||
* Clicking a row toggles that state's visibility; search matches the
|
* nodes render uniformly until the state model ideation lands), so there
|
||||||
* name or statement text. Filtering flows through sigma's official
|
* are no state legend rows or toggles anymore. Filtering flows through
|
||||||
|
* sigma's official
|
||||||
* nodeReducer/edgeReducer settings (`hidden`), so rendering, hover,
|
* nodeReducer/edgeReducer settings (`hidden`), so rendering, hover,
|
||||||
* picking, and labels all respect it; edges drop when either endpoint is
|
* 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.
|
* 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 Graph from "graphology";
|
||||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
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). */
|
/** The uniform node look (no state classes — WebGL wants hex, not oklch):
|
||||||
const STATE_TOKENS: Record<NodeStateClass, { cssVar: string; size: number }> = {
|
* the accent color + the old "known" size, until the state model lands. */
|
||||||
taught: { cssVar: "--foreground", size: 10 },
|
const NODE_COLOR_TOKEN = "--primary";
|
||||||
known: { cssVar: "--primary", size: 8 },
|
const NODE_SIZE = 8;
|
||||||
missed: { cssVar: "--destructive", size: 8 },
|
|
||||||
"dont-know": { cssVar: "--muted-foreground", size: 6.5 },
|
|
||||||
candidate: { cssVar: "--ring", size: 5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const STATE_LABELS: Record<NodeStateClass, string> = {
|
|
||||||
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"];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure filter predicate for the legend/filters panel: a node is visible
|
* Pure filter predicate for the search panel: a node is visible when the
|
||||||
* when its state is toggled on AND (no query OR name/statement matches).
|
* query is empty or matches its name or statement (case-insensitive).
|
||||||
* Exported for tests; the sigma reducers just consume its verdict.
|
* Exported for tests; the sigma reducers just consume its verdict.
|
||||||
*/
|
*/
|
||||||
export function isNodeVisible(
|
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
|
||||||
node: Pick<GraphNode, "name" | "statement">,
|
|
||||||
state: NodeStateClass,
|
|
||||||
hiddenStates: ReadonlySet<NodeStateClass>,
|
|
||||||
query: string,
|
|
||||||
): boolean {
|
|
||||||
if (hiddenStates.has(state)) return false;
|
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) return true;
|
if (!q) return true;
|
||||||
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
||||||
@@ -342,7 +322,6 @@ function tokenHex(cssVar: string): string {
|
|||||||
|
|
||||||
interface Selection {
|
interface Selection {
|
||||||
node: GraphNode;
|
node: GraphNode;
|
||||||
state: NodeStateClass;
|
|
||||||
chatIds: string[];
|
chatIds: string[];
|
||||||
/** Node position in container pixels (from graphToViewport at click time). */
|
/** Node position in container pixels (from graphToViewport at click time). */
|
||||||
anchor: { x: number; y: number };
|
anchor: { x: number; y: number };
|
||||||
@@ -353,8 +332,7 @@ export function GraphView() {
|
|||||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
|
||||||
const [selected, setSelected] = useState<Selection | null>(null);
|
const [selected, setSelected] = useState<Selection | null>(null);
|
||||||
// Legend/filters panel state: toggled-off states + search text.
|
// Search panel state: the query text.
|
||||||
const [hiddenStates, setHiddenStates] = useState<ReadonlySet<NodeStateClass>>(new Set());
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
// The sigma instance and its graphology graph live inside the mount
|
// The sigma instance and its graphology graph live inside the mount
|
||||||
// effect; refs expose them to the filters effect, and `rendererEpoch`
|
// effect; refs expose them to the filters effect, and `rendererEpoch`
|
||||||
@@ -363,40 +341,16 @@ export function GraphView() {
|
|||||||
const graphRef = useRef<Graph | null>(null);
|
const graphRef = useRef<Graph | null>(null);
|
||||||
const [rendererEpoch, setRendererEpoch] = useState(0);
|
const [rendererEpoch, setRendererEpoch] = useState(0);
|
||||||
|
|
||||||
const toggleState = (state: NodeStateClass) => {
|
// The id set that survives the current search — consumed by the sigma
|
||||||
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<NodeStateClass, number> = {
|
|
||||||
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.
|
// reducers and the "no matches" hint.
|
||||||
const visibleIds = useMemo(() => {
|
const visibleIds = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
if (snapshot)
|
if (snapshot)
|
||||||
for (const node of snapshot.nodes) {
|
for (const node of snapshot.nodes) {
|
||||||
if (isNodeVisible(node, snapshot.nodeStates[node.id] ?? "candidate", hiddenStates, query))
|
if (isNodeVisible(node, query)) set.add(node.id);
|
||||||
set.add(node.id);
|
|
||||||
}
|
}
|
||||||
return set;
|
return set;
|
||||||
}, [snapshot, hiddenStates, query]);
|
}, [snapshot, query]);
|
||||||
|
|
||||||
// Fetch the derived graph (the route folds stale chats first).
|
// Fetch the derived graph (the route folds stale chats first).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -421,24 +375,20 @@ export function GraphView() {
|
|||||||
const edgeColor = tokenHex("--ring");
|
const edgeColor = tokenHex("--ring");
|
||||||
const labelColor = tokenHex("--muted-foreground");
|
const labelColor = tokenHex("--muted-foreground");
|
||||||
const hoverTextColor = tokenHex("--foreground");
|
const hoverTextColor = tokenHex("--foreground");
|
||||||
const stateColors = new Map<NodeStateClass, string>();
|
const nodeColor = tokenHex(NODE_COLOR_TOKEN);
|
||||||
for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) {
|
|
||||||
stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar));
|
|
||||||
}
|
|
||||||
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
|
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
|
||||||
const hover: HoverState = { fade: 0, repaint: null };
|
const hover: HoverState = { fade: 0, repaint: null };
|
||||||
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
|
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
|
||||||
|
|
||||||
const graph = new Graph();
|
const graph = new Graph();
|
||||||
for (const node of snapshot.nodes) {
|
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
|
// Seeded start positions (FA2 requires non-zero x/y) keep the
|
||||||
// frozen layout stable for a given node set across remounts.
|
// frozen layout stable for a given node set across remounts.
|
||||||
const rand = seededRandom(node.id);
|
const rand = seededRandom(node.id);
|
||||||
graph.addNode(node.id, {
|
graph.addNode(node.id, {
|
||||||
label: node.name,
|
label: node.name,
|
||||||
size: STATE_TOKENS[state].size,
|
size: NODE_SIZE,
|
||||||
color: stateColors.get(state) ?? edgeColor,
|
color: nodeColor,
|
||||||
x: (rand() - 0.5) * 100,
|
x: (rand() - 0.5) * 100,
|
||||||
y: (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 });
|
const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 });
|
||||||
setSelected({
|
setSelected({
|
||||||
node: graphNode,
|
node: graphNode,
|
||||||
state: snapshot.nodeStates[node] ?? "candidate",
|
|
||||||
chatIds: snapshot.nodeSessions[node] ?? [],
|
chatIds: snapshot.nodeSessions[node] ?? [],
|
||||||
anchor: anchor ?? { x: 0, y: 0 },
|
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"
|
className="h-8 w-full rounded-lg border bg-background pl-8 pr-2 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-col">
|
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||||
{STATES_ORDER.map((state) => {
|
|
||||||
const active = !hiddenStates.has(state);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={state}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleState(state)}
|
|
||||||
aria-pressed={!active}
|
|
||||||
className="flex items-center gap-2 rounded-md px-1.5 py-1 text-left text-xs transition-colors hover:bg-accent"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="size-2.5 shrink-0 rounded-full"
|
|
||||||
style={{ background: `var(${STATE_TOKENS[state].cssVar})`, opacity: active ? 1 : 0.25 }}
|
|
||||||
/>
|
|
||||||
<span className={active ? "text-foreground" : "text-muted-foreground line-through"}>
|
|
||||||
{STATE_LABELS[state]}
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto tabular-nums text-[10px] text-muted-foreground">
|
|
||||||
{stateCounts[state]}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{snapshot !== null && visibleIds.size === 0 && (query.trim() !== "" || hiddenStates.size > 0) && (
|
|
||||||
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
||||||
No matching nodes.
|
No matching nodes.
|
||||||
</div>
|
</div>
|
||||||
@@ -698,9 +622,6 @@ export function GraphView() {
|
|||||||
>
|
>
|
||||||
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
||||||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||||||
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
{STATE_LABELS[selected.state]}
|
|
||||||
</div>
|
|
||||||
{selected.chatIds.length > 0 ? (
|
{selected.chatIds.length > 0 ? (
|
||||||
<div className="mt-3 flex flex-col gap-1">
|
<div className="mt-3 flex flex-col gap-1">
|
||||||
<div className="text-xs text-muted-foreground">Sessions</div>
|
<div className="text-xs text-muted-foreground">Sessions</div>
|
||||||
|
|||||||
@@ -221,21 +221,10 @@ export interface AuditResult {
|
|||||||
dropped: { ref: string; reason: string }[];
|
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. */
|
/** The GET /api/graph payload — everything the home view's PURE renderer needs. */
|
||||||
export interface GraphSnapshot {
|
export interface GraphSnapshot {
|
||||||
nodes: GraphNode[];
|
nodes: GraphNode[];
|
||||||
edges: GraphEdge[];
|
edges: GraphEdge[];
|
||||||
nodeStates: Record<string, NodeStateClass>;
|
|
||||||
/** chatId → title, for every chat that touched the graph. */
|
/** chatId → title, for every chat that touched the graph. */
|
||||||
sessions: Record<string, string>;
|
sessions: Record<string, string>;
|
||||||
/** nodeId → chatIds that touched the node (click-through provenance). */
|
/** nodeId → chatIds that touched the node (click-through provenance). */
|
||||||
|
|||||||
+28
-22
@@ -20,9 +20,8 @@
|
|||||||
* the next session should probe.
|
* the next session should probe.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { GraphEdge, GraphNode } from "./contracts.ts";
|
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
|
||||||
import { deriveNodeStates, type DerivedGraph } from "./graph.ts";
|
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
|
||||||
import type { NodeStateClass } from "./contracts.ts";
|
|
||||||
|
|
||||||
/** The section header the teacher is taught to recognize (50-graph-context.md). */
|
/** The section header the teacher is taught to recognize (50-graph-context.md). */
|
||||||
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
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. */
|
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
|
||||||
export const MAX_CONTEXT_NODES = 150;
|
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 {
|
function groupOf(outcome: Outcome): number {
|
||||||
if (HOTSPOT_STATES.has(state)) return 0;
|
if (outcome === "miss" || outcome === "dont-know") return 0;
|
||||||
if (state === "known") return 1;
|
if (outcome === "correct") return 1;
|
||||||
return 2; // candidate
|
return 2; // never probed
|
||||||
}
|
}
|
||||||
|
|
||||||
const GROUP_LABELS = [
|
const GROUP_LABELS = [
|
||||||
"Hotspots (missed / dont-know) — likely misconception sites and unexplored ground:",
|
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
|
||||||
"Known (answered correctly):",
|
"Answered correctly:",
|
||||||
"Candidates (never probed — assume nothing):",
|
"Never probed (assume nothing):",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
|
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
|
||||||
function isHotspot(node: GraphNode, state: NodeStateClass): boolean {
|
function isHotspot(outcome: Outcome): boolean {
|
||||||
return groupOf(node, state) === 0;
|
return groupOf(outcome) === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the context block, or null when there is nothing worth injecting
|
* Render the context block, or null when there is nothing worth injecting
|
||||||
* (no nodes; or a truncated graph whose selection came out empty).
|
* (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(
|
export function renderGraphContext(
|
||||||
nodes: GraphNode[],
|
nodes: GraphNode[],
|
||||||
edges: GraphEdge[],
|
edges: GraphEdge[],
|
||||||
states: Record<string, NodeStateClass>,
|
outcomes: Map<string, LedgerEventKind>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (nodes.length === 0) return null;
|
if (nodes.length === 0) return null;
|
||||||
|
|
||||||
// ── selection ───────────────────────────────────────────────────────────
|
// ── 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 selected = nodes;
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
if (nodes.length > MAX_CONTEXT_NODES) {
|
if (nodes.length > MAX_CONTEXT_NODES) {
|
||||||
truncated = true;
|
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));
|
const keep = new Set(hotspots.map((n) => n.id));
|
||||||
// 1-hop follows neighborhood: premises AND derivations.
|
// 1-hop follows neighborhood: premises AND derivations.
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
@@ -91,12 +95,14 @@ export function renderGraphContext(
|
|||||||
|
|
||||||
for (const [group, label] of GROUP_LABELS.entries()) {
|
for (const [group, label] of GROUP_LABELS.entries()) {
|
||||||
const groupNodes = selected
|
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));
|
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
|
||||||
if (groupNodes.length === 0) continue;
|
if (groupNodes.length === 0) continue;
|
||||||
lines.push("", label);
|
lines.push("", label);
|
||||||
for (const node of groupNodes) {
|
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).
|
* chat-handler, so it self-updates as the graph grows mid-session).
|
||||||
*/
|
*/
|
||||||
export function buildGraphContext(graph: DerivedGraph): string | null {
|
export function buildGraphContext(graph: DerivedGraph): string | null {
|
||||||
const states = deriveNodeStates(graph.nodes, graph.events);
|
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
|
||||||
return renderGraphContext(graph.nodes, graph.edges, states);
|
return renderGraphContext(graph.nodes, graph.edges, outcomes);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-20
@@ -48,7 +48,6 @@ import type {
|
|||||||
GraphSnapshot,
|
GraphSnapshot,
|
||||||
LedgerEvent,
|
LedgerEvent,
|
||||||
LedgerEventKind,
|
LedgerEventKind,
|
||||||
NodeStateClass,
|
|
||||||
} from "./contracts.ts";
|
} from "./contracts.ts";
|
||||||
import type { QuestionAnswerData } from "./question-core.ts";
|
import type { QuestionAnswerData } from "./question-core.ts";
|
||||||
import type { ChatStore, GraphStore } from "./ports.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"]);
|
const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-know"]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Derive node states from events (never stored): the LATEST quiz event
|
* The latest quiz outcome per node — a raw EVENT FACT, not a node state
|
||||||
* decides (known / missed / dont-know); a node with no quiz events is a
|
* (the transitional node-state classes were removed 2026-09-08: nothing
|
||||||
* candidate. TRANSITIONAL (2026-09-02 redesign): `taught` is unreachable
|
* colors nodes by quiz results anymore). Used by the teacher's
|
||||||
* now that no taught events are emitted, and observation events are NOT
|
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||||
* state inputs — the real state model (dependency depth + topology-derived
|
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
||||||
* strength + quiz events) is pending ideation; this keeps the renderer
|
* events have no entry.
|
||||||
* alive until it lands.
|
|
||||||
*/
|
*/
|
||||||
export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Record<string, NodeStateClass> {
|
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||||
const states: Record<string, NodeStateClass> = {};
|
|
||||||
for (const node of nodes) states[node.id] = "candidate";
|
|
||||||
|
|
||||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||||
events.forEach((event, index) => {
|
events.forEach((event, index) => {
|
||||||
if (event.nodeId === null) return;
|
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 });
|
latest.set(event.nodeId, { at: event.at, kind: event.kind, index });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
const out = new Map<string, LedgerEventKind>();
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const last = latest.get(node.id);
|
const last = latest.get(node.id);
|
||||||
if (!last) continue; // candidate
|
if (last) out.set(node.id, last.kind);
|
||||||
if (last.kind === "correct") states[node.id] = "known";
|
|
||||||
else if (last.kind === "dont-know") states[node.id] = "dont-know";
|
|
||||||
else states[node.id] = "missed";
|
|
||||||
}
|
}
|
||||||
return states;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
// ─── 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). */
|
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
||||||
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||||
const { nodes, edges, events } = getGraph(deps.graphStore);
|
const { nodes, edges, events } = getGraph(deps.graphStore);
|
||||||
const nodeStates = deriveNodeStates(nodes, events);
|
|
||||||
|
|
||||||
const nodeChats = new Map<string, Set<string>>();
|
const nodeChats = new Map<string, Set<string>>();
|
||||||
const touch = (nodeId: string, chatId: string): void => {
|
const touch = (nodeId: string, chatId: string): void => {
|
||||||
@@ -456,5 +447,5 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
|||||||
if (ids.length > 0) nodeSessions[nodeId] = ids;
|
if (ids.length > 0) nodeSessions[nodeId] = ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { nodes, edges, nodeStates, sessions, nodeSessions };
|
return { nodes, edges, sessions, nodeSessions };
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-32
@@ -31,7 +31,7 @@ import type {
|
|||||||
NodeMapping,
|
NodeMapping,
|
||||||
SessionCheckpoint,
|
SessionCheckpoint,
|
||||||
} from "../src/core/contracts.ts";
|
} 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 { getGraph, mergeFindings } from "../src/core/graph.ts";
|
||||||
import type { GraphStore } from "../src/core/ports.ts";
|
import type { GraphStore } from "../src/core/ports.ts";
|
||||||
import {
|
import {
|
||||||
@@ -64,18 +64,19 @@ function edge(from: string, to: string): GraphEdge {
|
|||||||
return { from, to, type: "follows", provenance: [{ chatId: "chat_x", messageId: "m1" }] };
|
return { from, to, type: "follows", provenance: [{ chatId: "chat_x", messageId: "m1" }] };
|
||||||
}
|
}
|
||||||
|
|
||||||
function states(...entries: [string, NodeStateClass][]): Record<string, NodeStateClass> {
|
/** Latest-quiz-outcome map: nodeId → quiz event kind (the raw event fact). */
|
||||||
return Object.fromEntries(entries);
|
function outcomes(...entries: [string, LedgerEventKind][]): Map<string, LedgerEventKind> {
|
||||||
|
return new Map(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── renderGraphContext (pure) ───────────────────────────────────────────────
|
// ─── renderGraphContext (pure) ───────────────────────────────────────────────
|
||||||
|
|
||||||
describe("renderGraphContext (pure)", () => {
|
describe("renderGraphContext (pure)", () => {
|
||||||
it("returns null for an empty graph (inject nothing)", () => {
|
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 = [
|
const nodes = [
|
||||||
node("n_a", "Chain rule intuition"),
|
node("n_a", "Chain rule intuition"),
|
||||||
node("n_b", "Quotient rule"),
|
node("n_b", "Quotient rule"),
|
||||||
@@ -83,25 +84,24 @@ describe("renderGraphContext (pure)", () => {
|
|||||||
node("n_d", "Composite functions"),
|
node("n_d", "Composite functions"),
|
||||||
];
|
];
|
||||||
const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")];
|
const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")];
|
||||||
const st = states(
|
const oc = outcomes(
|
||||||
["n_a", "known"],
|
["n_a", "correct"],
|
||||||
["n_b", "missed"],
|
["n_b", "miss"],
|
||||||
["n_c", "candidate"],
|
|
||||||
["n_d", "dont-know"],
|
["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.ok(out.includes(GRAPH_CONTEXT_HEADER), "header line present");
|
||||||
assert.match(out, /4 nodes, 3 follows edges/);
|
assert.match(out, /4 nodes, 3 follows edges/);
|
||||||
|
|
||||||
// Node lines: "• [state] statement (id)".
|
// Node lines: "• [outcome] statement (id)".
|
||||||
assert.ok(out.includes("• [missed] Quotient rule (n_b)"));
|
assert.ok(out.includes("• [miss] Quotient rule (n_b)"));
|
||||||
assert.ok(out.includes("• [known] Chain rule intuition (n_a)"));
|
assert.ok(out.includes("• [correct] Chain rule intuition (n_a)"));
|
||||||
assert.ok(out.includes("• [dont-know] Composite functions (n_d)"));
|
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.
|
// Groups in the decided order: hotspots, correct, never probed.
|
||||||
const order = ["Hotspots", "Known", "Candidates"].map((g) => out.indexOf(g));
|
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");
|
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).
|
// 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", () => {
|
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 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 → B"));
|
||||||
assert.ok(out.includes("A → C"));
|
assert.ok(out.includes("A → C"));
|
||||||
assert.ok(!out.includes("B → C"), "no edge was invented between siblings");
|
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.
|
// long tail of disconnected candidates that must drop out.
|
||||||
const nodes: GraphNode[] = [];
|
const nodes: GraphNode[] = [];
|
||||||
const edges: GraphEdge[] = [];
|
const edges: GraphEdge[] = [];
|
||||||
const st: Record<string, NodeStateClass> = {};
|
const oc = new Map<string, LedgerEventKind>();
|
||||||
const hotspot = (i: number) => {
|
const hotspot = (i: number) => {
|
||||||
const h = `n_h${i}`;
|
const h = `n_h${i}`;
|
||||||
const pre = `n_p${i}`;
|
const pre = `n_p${i}`;
|
||||||
const dep = `n_d${i}`;
|
const dep = `n_d${i}`;
|
||||||
nodes.push(node(pre, `Premise ${i}`), node(h, `Hotspot ${i}`), node(dep, `Derivation ${i}`));
|
nodes.push(node(pre, `Premise ${i}`), node(h, `Hotspot ${i}`), node(dep, `Derivation ${i}`));
|
||||||
edges.push(edge(pre, h), edge(h, dep));
|
edges.push(edge(pre, h), edge(h, dep));
|
||||||
st[pre] = "known";
|
oc.set(pre, "correct");
|
||||||
st[h] = "missed";
|
oc.set(h, "miss");
|
||||||
st[dep] = "candidate";
|
|
||||||
};
|
};
|
||||||
hotspot(1);
|
hotspot(1);
|
||||||
hotspot(2);
|
hotspot(2);
|
||||||
@@ -140,14 +139,13 @@ describe("renderGraphContext (pure)", () => {
|
|||||||
for (let i = 0; i < MAX_CONTEXT_NODES; i++) {
|
for (let i = 0; i < MAX_CONTEXT_NODES; i++) {
|
||||||
const id = `n_f${i}`;
|
const id = `n_f${i}`;
|
||||||
nodes.push(node(id, `Far away ${i}`));
|
nodes.push(node(id, `Far away ${i}`));
|
||||||
st[id] = "candidate";
|
|
||||||
}
|
}
|
||||||
assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap");
|
assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap");
|
||||||
|
|
||||||
const out = renderGraphContext(nodes, edges, st)!;
|
const out = renderGraphContext(nodes, edges, oc)!;
|
||||||
assert.ok(out.includes("• [missed] Hotspot 1 (n_h1)"), "hotspots stay");
|
assert.ok(out.includes("• [miss] Hotspot 1 (n_h1)"), "hotspots stay");
|
||||||
assert.ok(out.includes("• [known] Premise 1 (n_p1)"), "1-hop premises stay");
|
assert.ok(out.includes("• [correct] Premise 1 (n_p1)"), "1-hop premises stay");
|
||||||
assert.ok(out.includes("• [candidate] Derivation 1 (n_d1)"), "1-hop derivations 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.ok(!out.includes("Far away"), "disconnected nodes drop out");
|
||||||
assert.match(out, new RegExp(`showing 9 of ${nodes.length} nodes`), "truncation is noted");
|
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");
|
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)", () => {
|
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 nodes = Array.from({ length: MAX_CONTEXT_NODES }, (_, i) => node(`n_${i}`, `Node ${i}`));
|
||||||
const out = renderGraphContext(nodes, [], {})!;
|
const out = renderGraphContext(nodes, [], outcomes())!;
|
||||||
assert.ok(out.includes(`• [candidate] Node ${MAX_CONTEXT_NODES - 1}`));
|
assert.ok(out.includes(`• [never probed] Node ${MAX_CONTEXT_NODES - 1}`));
|
||||||
assert.ok(!out.includes("showing"), "at the cap nothing is dropped");
|
assert.ok(!out.includes("showing"), "at the cap nothing is dropped");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null when a too-large graph has no hotspots at all", () => {
|
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}`));
|
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);
|
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 nodes = [node("n_a", "Solo node"), node("n_b", "Checked node")];
|
||||||
const events: LedgerEvent[] = [
|
const events: LedgerEvent[] = [
|
||||||
{ nodeId: "n_b", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
|
{ nodeId: "n_b", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
|
||||||
];
|
];
|
||||||
const out = buildGraphContext({ nodes, edges: [], events })!;
|
const out = buildGraphContext({ nodes, edges: [], events })!;
|
||||||
assert.ok(out.includes(GRAPH_CONTEXT_HEADER));
|
assert.ok(out.includes(GRAPH_CONTEXT_HEADER));
|
||||||
assert.ok(out.includes("• [known] Checked node (n_b)"), "correct → known");
|
assert.ok(out.includes("• [correct] Checked node (n_b)"), "correct → answered correctly");
|
||||||
assert.ok(out.includes("• [candidate] Solo node (n_a)"), "no events → candidate");
|
assert.ok(out.includes("• [never probed] Solo node (n_a)"), "no events → never probed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the live ledgers without throwing (read-only integration)", () => {
|
it("reads the live ledgers without throwing (read-only integration)", () => {
|
||||||
|
|||||||
@@ -6,51 +6,34 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { isNodeVisible } from "../components/graph-view.tsx";
|
import { isNodeVisible } from "../components/graph-view.tsx";
|
||||||
import type { NodeStateClass } from "../src/core/index.ts";
|
|
||||||
|
|
||||||
const none: ReadonlySet<NodeStateClass> = new Set();
|
|
||||||
const node = {
|
const node = {
|
||||||
name: "Money multiplier",
|
name: "Money multiplier",
|
||||||
statement: "The maximum ratio of broad money a bank can create from each unit of base money.",
|
statement: "The maximum ratio of broad money a bank can create from each unit of base money.",
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("isNodeVisible (legend/filters predicate)", () => {
|
describe("isNodeVisible (search predicate)", () => {
|
||||||
it("is visible with no filters", () => {
|
it("is visible with an empty query", () => {
|
||||||
assert.equal(isNodeVisible(node, "known", none, ""), true);
|
assert.equal(isNodeVisible(node, ""), true);
|
||||||
});
|
|
||||||
|
|
||||||
it("is hidden when its state is toggled off, even if the query matches", () => {
|
|
||||||
const hidden = new Set<NodeStateClass>(["known"]);
|
|
||||||
assert.equal(isNodeVisible(node, "known", hidden, "money"), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a toggled-off state does not affect other states", () => {
|
|
||||||
const hidden = new Set<NodeStateClass>(["known"]);
|
|
||||||
assert.equal(isNodeVisible(node, "taught", hidden, ""), true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("search matches the statement case-insensitively", () => {
|
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", () => {
|
it("search matches the statement when the name does not", () => {
|
||||||
assert.equal(isNodeVisible(node, "candidate", none, "MAXIMUM ratio"), true);
|
assert.equal(isNodeVisible(node, "MAXIMUM ratio"), true);
|
||||||
assert.equal(isNodeVisible(node, "candidate", none, " velocity"), false);
|
assert.equal(isNodeVisible(node, " velocity"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("search matches the name when the statement does not", () => {
|
it("search matches the name when the statement does not", () => {
|
||||||
assert.equal(isNodeVisible(node, "candidate", none, "money MULTIPLIER"), true);
|
assert.equal(isNodeVisible(node, "money MULTIPLIER"), true);
|
||||||
assert.equal(isNodeVisible(node, "candidate", none, " banking"), false);
|
assert.equal(isNodeVisible(node, " banking"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("whitespace-only query is no filter", () => {
|
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<NodeStateClass>(["candidate"]);
|
|
||||||
assert.equal(isNodeVisible(node, "candidate", hidden, ""), false);
|
|
||||||
assert.equal(isNodeVisible(node, "candidate", none, ""), true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+3
-4
@@ -366,10 +366,9 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
it("derives node states and sessions in graphSnapshot", () => {
|
it("derives node states and sessions in graphSnapshot", () => {
|
||||||
const snap = graphSnapshot(graphDeps);
|
const snap = graphSnapshot(graphDeps);
|
||||||
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
|
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
|
||||||
// Alpha's latest quiz is call-9 (chat2, correct) → known.
|
// No node-state classes anymore (2026-09-08): the snapshot carries
|
||||||
assert.equal(snap.nodeStates[idByStatement.get("Alpha intuition")!], "known");
|
// only the objective graph + sessions; quiz outcomes stay ledger data.
|
||||||
assert.equal(snap.nodeStates[idByStatement.get("Beta rule")!], "missed");
|
assert.equal("nodeStates" in snap, false);
|
||||||
assert.equal(snap.nodeStates[idByStatement.get("Gamma application")!], "dont-know");
|
|
||||||
|
|
||||||
assert.equal(snap.sessions[chat1.id], "Graph fixture");
|
assert.equal(snap.sessions[chat1.id], "Graph fixture");
|
||||||
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
|
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
|
||||||
|
|||||||
Reference in New Issue
Block a user