Merge feat/graph-rendering: name/statement schema pivot, state classes stripped, graph observability
User-decided after seeing real extracted data (three-commit batch, all in the graph lane): full-sentence canvas labels were illegible, so nodes gain a short presentational name (glossary-headword; statement stays the identity anchor; description dropped); the transitional NodeStateClass vocabulary is deleted (raw latest-quiz outcomes feed only the teacher-side hotspot grouping; the home renderer is state-neutral pending the state-model ideation); per-session understanding observations surface in the detail card and a background-pipeline registry + /api/graph/status show live extraction runs on the graph.
This commit is contained in:
@@ -2,8 +2,8 @@ import { graphView } from "@/src/server";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The home graph view's data endpoint (W4; vision §1 "Home page") — thin
|
* 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
|
* transport: the server entry (src/server/graph.ts) returns nodes, edges,
|
||||||
* returns nodes, edges, derived node states, and session provenance.
|
* session provenance, and per-session understanding observations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
+166
-47
@@ -10,13 +10,15 @@
|
|||||||
* and cancelled on unmount — idle CPU stays near zero. Physics is
|
* and cancelled on unmount — idle CPU stays near zero. Physics is
|
||||||
* placement only: pan/zoom, no dragging, no editing.
|
* placement only: pan/zoom, no dragging, no editing.
|
||||||
*
|
*
|
||||||
* Labels render the node `name` (the short header, never the statement)
|
* Labels render the node `name` — the very short, glanceable label the
|
||||||
* through custom canvas drawers that replace sigma's defaults:
|
* 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,
|
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
||||||
* 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
|
||||||
@@ -38,36 +40,43 @@
|
|||||||
* 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 search panel (top-right overlay): a search box matching name or
|
||||||
|
* statement text — the transitional node-state classes are gone (2026-09-08;
|
||||||
|
* nodes render uniformly until the state model ideation lands), so there
|
||||||
|
* are no state legend rows or toggles anymore. Filtering flows through
|
||||||
|
* sigma's official
|
||||||
|
* nodeReducer/edgeReducer settings (`hidden`), so rendering, hover,
|
||||||
|
* picking, and labels all respect it; edges drop when either endpoint is
|
||||||
|
* hidden, and an open detail card closes if its node is filtered out.
|
||||||
* Clicking a node opens the detail card anchored just below the node's
|
* Clicking a node opens the detail card anchored just below the node's
|
||||||
* viewport position (flipping above near the bottom edge); any camera move
|
* viewport position (flipping above near the bottom edge); any camera move
|
||||||
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
||||||
* prerequisite → dependent.
|
* prerequisite → dependent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { WaypointsIcon } from "lucide-react";
|
import { Loader2Icon, 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",
|
* Pure filter predicate for the search panel: a node is visible when the
|
||||||
known: "known",
|
* query is empty or matches its name or statement (case-insensitive).
|
||||||
missed: "missed",
|
* Exported for tests; the sigma reducers just consume its verdict.
|
||||||
"dont-know": "don't know",
|
*/
|
||||||
candidate: "candidate",
|
export function isNodeVisible(node: Pick<GraphNode, "name" | "statement">, query: string): boolean {
|
||||||
};
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return node.name.toLowerCase().includes(q) || node.statement.toLowerCase().includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Label drawing (canvas) ---------------------------------------------
|
// ---- Label drawing (canvas) ---------------------------------------------
|
||||||
//
|
//
|
||||||
@@ -199,7 +208,7 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wrapped node name, centered below the node — used by both drawers
|
/** The wrapped node name (the short label), centered below the node — used by both drawers
|
||||||
* with identical geometry so hover only changes the color, nothing jumps. */
|
* with identical geometry so hover only changes the color, nothing jumps. */
|
||||||
function drawName(
|
function drawName(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
@@ -313,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 };
|
||||||
@@ -324,8 +332,29 @@ 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);
|
||||||
|
// Search panel state: the query text.
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
// The sigma instance and its graphology graph live inside the mount
|
||||||
|
// effect; refs expose them to the filters effect, and `rendererEpoch`
|
||||||
|
// re-triggers it when a new renderer is constructed.
|
||||||
|
const rendererRef = useRef<import("sigma").default | null>(null);
|
||||||
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const [rendererEpoch, setRendererEpoch] = useState(0);
|
||||||
|
|
||||||
// Fetch the derived graph (the route folds stale chats first).
|
// The id set that survives the current search — consumed by the sigma
|
||||||
|
// reducers and the "no matches" hint.
|
||||||
|
const visibleIds = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
if (snapshot)
|
||||||
|
for (const node of snapshot.nodes) {
|
||||||
|
if (isNodeVisible(node, query)) set.add(node.id);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}, [snapshot, query]);
|
||||||
|
|
||||||
|
// Fetch the derived graph. Re-runs when a background pipeline run
|
||||||
|
// finishes (refreshTick) — its merges just landed in the ledger.
|
||||||
|
const [refreshTick, setRefreshTick] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetch("/api/graph")
|
fetch("/api/graph")
|
||||||
@@ -337,6 +366,30 @@ export function GraphView() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
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.
|
// Build the graphology graph, lay it out once, mount sigma.
|
||||||
@@ -348,24 +401,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.statement,
|
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,
|
||||||
});
|
});
|
||||||
@@ -426,6 +475,9 @@ export function GraphView() {
|
|||||||
defaultDrawNodeLabel: drawLabel,
|
defaultDrawNodeLabel: drawLabel,
|
||||||
defaultDrawNodeHover: drawHover,
|
defaultDrawNodeHover: drawHover,
|
||||||
});
|
});
|
||||||
|
graphRef.current = graph;
|
||||||
|
rendererRef.current = renderer;
|
||||||
|
setRendererEpoch((e) => e + 1);
|
||||||
renderer.on("clickNode", ({ node }) => {
|
renderer.on("clickNode", ({ node }) => {
|
||||||
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
||||||
if (!graphNode) return;
|
if (!graphNode) return;
|
||||||
@@ -433,7 +485,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 },
|
||||||
});
|
});
|
||||||
@@ -496,10 +547,32 @@ export function GraphView() {
|
|||||||
return () => {
|
return () => {
|
||||||
killed = true;
|
killed = true;
|
||||||
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
||||||
|
rendererRef.current = null;
|
||||||
|
graphRef.current = null;
|
||||||
renderer?.kill();
|
renderer?.kill();
|
||||||
};
|
};
|
||||||
}, [snapshot]);
|
}, [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
|
// Position the detail card: below the clicked node, flipped above near
|
||||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||||
// before paint) because the flip needs the card's measured height.
|
// before paint) because the flip needs the card's measured height.
|
||||||
@@ -544,6 +617,44 @@ export function GraphView() {
|
|||||||
<div ref={containerRef} className="h-full w-full" />
|
<div ref={containerRef} className="h-full w-full" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Legend + filters: search box and per-state toggle rows. */}
|
||||||
|
{!isEmpty && (
|
||||||
|
<div className="absolute right-3 top-3 z-10 w-64 rounded-xl border bg-card/85 p-2.5 shadow-lg backdrop-blur">
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{snapshot !== null && visibleIds.size === 0 && query.trim() !== "" && (
|
||||||
|
<div className="mt-1.5 border-t px-1.5 pt-1.5 text-[11px] text-muted-foreground">
|
||||||
|
No matching nodes.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Background pipeline status: what is currently running. */}
|
||||||
|
{pipelineRuns.length > 0 && (
|
||||||
|
<div className="absolute bottom-3 left-3 z-10 flex flex-col gap-1.5">
|
||||||
|
{pipelineRuns.map((run) => (
|
||||||
|
<div
|
||||||
|
key={run.chatId}
|
||||||
|
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
|
||||||
|
>
|
||||||
|
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
|
||||||
|
<span className="capitalize">{run.phase}</span>
|
||||||
|
<span className="max-w-48 truncate text-foreground">{run.title}</span>
|
||||||
|
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{selected && !isEmpty && (
|
{selected && !isEmpty && (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
@@ -552,23 +663,31 @@ export function GraphView() {
|
|||||||
style={{ visibility: "hidden", left: 0, top: 0 }}
|
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"
|
className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium leading-snug">{selected.node.statement}</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.description}</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>
|
||||||
{selected.chatIds.map((chatId) => (
|
{selected.chatIds.map((chatId) => {
|
||||||
<Link
|
// That session's understanding observation — the
|
||||||
key={chatId}
|
// extractor's named-word note (confidence history).
|
||||||
href={`/chat/${chatId}`}
|
const note = snapshot?.nodeObservations?.[selected.node.id]?.[chatId];
|
||||||
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
return (
|
||||||
>
|
<div key={chatId} className="flex items-center gap-1.5">
|
||||||
{snapshot?.sessions[chatId] ?? chatId}
|
<Link
|
||||||
</Link>
|
href={`/chat/${chatId}`}
|
||||||
))}
|
className="min-w-0 flex-1 truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||||||
|
>
|
||||||
|
{snapshot?.sessions[chatId] ?? chatId}
|
||||||
|
</Link>
|
||||||
|
{note && (
|
||||||
|
<span className="shrink-0 rounded-full border px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{note}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
|
||||||
|
|||||||
+2
-1
@@ -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**.
|
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.
|
- **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.
|
- **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)
|
### 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 |
|
| `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 |
|
| 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) |
|
| `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 |
|
| 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 |
|
| 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 |
|
| UI deferred, "not important" | UI exists (Next.js, v3.4); the graph view is the app's home page |
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ import { getGraph, mergeFindings, type EmissionStats, type GraphDeps } from "../
|
|||||||
* trigger while one run is in flight is a no-op (the next trigger picks up
|
* 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 —
|
* whatever the in-flight run missed). On failure nothing is checkpointed —
|
||||||
* the next trigger re-runs the whole thing.
|
* the next trigger re-runs the whole thing.
|
||||||
|
*
|
||||||
|
* Observability (§5 baseline): every stage logs one line per step, its
|
||||||
|
* start, and its duration; the pipeline logs a failure line before
|
||||||
|
* rethrowing, so the server log reads start → stage timings → merged
|
||||||
|
* stats (or failed) for every run.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// ─── the transcript view (what the extractor + auditor see) ─────────────────
|
// ─── the transcript view (what the extractor + auditor see) ─────────────────
|
||||||
@@ -94,6 +99,13 @@ const messagesField = z
|
|||||||
|
|
||||||
const propositionSchema = z.object({
|
const propositionSchema = z.object({
|
||||||
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
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
|
statement: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
@@ -102,11 +114,6 @@ const propositionSchema = z.object({
|
|||||||
'"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' +
|
'"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.',
|
'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."),
|
aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."),
|
||||||
messages: messagesField,
|
messages: messagesField,
|
||||||
evidence,
|
evidence,
|
||||||
@@ -163,8 +170,12 @@ misconception is NOT a node of its own wrong claim — state the TRUE correction
|
|||||||
quiz outcomes carry that the learner believed the wrong version.
|
quiz outcomes carry that the learner believed the wrong version.
|
||||||
|
|
||||||
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
|
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
|
||||||
- Each is a self-contained TRUE statement ("statement"), plus its elaboration ("description" — more
|
- Each has a "name" — a VERY short label, a few words, a compact handle like a glossary headword
|
||||||
words, same proposition: scope, precision, what exactly it means).
|
("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.
|
- 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.
|
- 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.
|
- "messages": the [id] tags of every message where the proposition came up — provenance, report it.
|
||||||
@@ -249,10 +260,12 @@ FAITHFULNESS — verify every candidate against the transcript:
|
|||||||
scale or drop.
|
scale or drop.
|
||||||
|
|
||||||
IDENTITY — link-or-create, per proposition:
|
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?
|
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 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.
|
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.
|
- 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
|
Also drop propositions not referenced by anything else ONLY if they have no other backing — an unreferenced but
|
||||||
@@ -266,7 +279,7 @@ function graphView(nodes: GraphNode[]): string {
|
|||||||
if (nodes.length === 0) return "(the graph is currently empty — every proposition is a new node)";
|
if (nodes.length === 0) return "(the graph is currently empty — every proposition is a new node)";
|
||||||
const lines = nodes.map(
|
const lines = nodes.map(
|
||||||
(n) =>
|
(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(" | ")}` : ""),
|
(n.aliases.length > 0 ? `\n aliases: ${n.aliases.join(" | ")}` : ""),
|
||||||
);
|
);
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
@@ -291,6 +304,25 @@ function createAuditor(model: LanguageModel) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── the pipeline status registry ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The pipeline phases, in order — what the home status endpoint shows. */
|
||||||
|
export type ExtractionPhase = "extracting" | "auditing" | "merging";
|
||||||
|
|
||||||
|
/** One in-flight pipeline run. Module-level: one process, one registry. */
|
||||||
|
interface RunningExtraction {
|
||||||
|
chatId: string;
|
||||||
|
phase: ExtractionPhase;
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningExtractions = new Map<string, RunningExtraction>();
|
||||||
|
|
||||||
|
/** 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 ───────────────────────────────────────────────────────────
|
// ─── the pipeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ExtractionModels {
|
export interface ExtractionModels {
|
||||||
@@ -314,15 +346,25 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
|
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
|
||||||
const inFlight = new Set<string>();
|
const inFlight = new Set<string>();
|
||||||
|
|
||||||
|
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<SessionFindings> {
|
async function extract(chatId: string): Promise<SessionFindings> {
|
||||||
const record = chatStore.getChat(chatId);
|
const record = chatStore.getChat(chatId);
|
||||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||||
const view = transcriptView(record);
|
const view = transcriptView(record);
|
||||||
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
|
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}` });
|
const extraction = await extractorAgent.generate({ prompt: `Transcript:\n\n${view}` });
|
||||||
for (const part of extraction.content) {
|
for (const part of extraction.content) {
|
||||||
if (part.type === "tool-result" && part.toolName === "submit_findings") {
|
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;
|
return part.output as SessionFindings;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -338,6 +380,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
`[extraction ${chatId}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` +
|
`[extraction ${chatId}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` +
|
||||||
`${Object.keys(findings.attribution.quiz).length} attributions, ${graph.nodes.length} graph nodes)`,
|
`${Object.keys(findings.attribution.quiz).length} attributions, ${graph.nodes.length} graph nodes)`,
|
||||||
);
|
);
|
||||||
|
const t0 = performance.now();
|
||||||
|
|
||||||
const review = await auditorAgent.generate({
|
const review = await auditorAgent.generate({
|
||||||
prompt:
|
prompt:
|
||||||
@@ -362,23 +405,37 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
|||||||
`${auditResult.edges.length}/${findings.edges.length} edges, ` +
|
`${auditResult.edges.length}/${findings.edges.length} edges, ` +
|
||||||
`${auditResult.propositions.filter((p) => p.linkTo !== undefined).length} linked to existing nodes`,
|
`${auditResult.propositions.filter((p) => p.linkTo !== undefined).length} linked to existing nodes`,
|
||||||
);
|
);
|
||||||
|
console.info(`[extraction ${chatId}] auditor done in ${Math.round(performance.now() - t0)}ms`);
|
||||||
return auditResult;
|
return auditResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
||||||
if (inFlight.has(chatId)) return { nodes: 0, edges: 0, events: 0 };
|
if (inFlight.has(chatId)) return { nodes: 0, edges: 0, events: 0 };
|
||||||
inFlight.add(chatId);
|
inFlight.add(chatId);
|
||||||
|
const t0 = performance.now();
|
||||||
try {
|
try {
|
||||||
console.info(`[extraction ${chatId}] pipeline start`);
|
console.info(`[extraction ${chatId}] pipeline start`);
|
||||||
|
setPhase(chatId, "extracting");
|
||||||
const findings = await extract(chatId);
|
const findings = await extract(chatId);
|
||||||
|
setPhase(chatId, "auditing");
|
||||||
const auditResult = await audit(chatId, findings);
|
const auditResult = await audit(chatId, findings);
|
||||||
|
setPhase(chatId, "merging");
|
||||||
// The merger re-reads the chat record itself — events are rebuilt
|
// The merger re-reads the chat record itself — events are rebuilt
|
||||||
// from the transcript, never from stale copies.
|
// from the transcript, never from stale copies.
|
||||||
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore });
|
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore });
|
||||||
console.info(`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events`);
|
console.info(
|
||||||
|
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
|
||||||
|
`(${Math.round(performance.now() - t0)}ms total)`,
|
||||||
|
);
|
||||||
return stats;
|
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 {
|
} finally {
|
||||||
inFlight.delete(chatId);
|
inFlight.delete(chatId);
|
||||||
|
runningExtractions.delete(chatId); // done or failed — no longer running
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-24
@@ -33,13 +33,18 @@ export interface ProvenancePoint {
|
|||||||
export interface GraphNode {
|
export interface GraphNode {
|
||||||
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
||||||
id: string;
|
id: string;
|
||||||
/** The minimal proposition — one true, self-contained statement. This is
|
/** A very short label for the node — a compact handle like a glossary
|
||||||
* BOTH the rendering label AND the identity key: the auditor recognizes
|
* headword ("Will to power", "Divine command theory"): what the graph
|
||||||
* a re-encounter by judgment (link-or-create), not by string matching. */
|
* 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;
|
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
|
/** Verbatim phrasings sessions used for this proposition (the identity
|
||||||
* audit trail — how the world said what the node says). */
|
* audit trail — how the world said what the node says). */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
@@ -108,10 +113,11 @@ export interface LedgerEvent {
|
|||||||
* (nodes/edges/events/mappings/sessions), all append-only.
|
* (nodes/edges/events/mappings/sessions), all append-only.
|
||||||
*
|
*
|
||||||
* nodes.jsonl one line per node — the FIRST line for an id defines
|
* 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):
|
* same id are link-appends (a session's re-encounter):
|
||||||
* their statements/aliases join `aliases`, their
|
* 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
|
* edges.jsonl one line per edge ASSERTION; readers merge by
|
||||||
* (from,to,type), unioning provenance.
|
* (from,to,type), unioning provenance.
|
||||||
*
|
*
|
||||||
@@ -136,13 +142,16 @@ export interface LedgerEvent {
|
|||||||
export interface ExtractedProposition {
|
export interface ExtractedProposition {
|
||||||
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
|
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
|
||||||
id: string;
|
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
|
/** The minimal proposition — a true, self-contained, world-facing
|
||||||
* statement. No learner / teacher / session references: misconceptions
|
* statement, at full precision. No learner / teacher / session
|
||||||
* are stated as the true correction, and wrongness lives in the event
|
* references: misconceptions are stated as the true correction, and
|
||||||
* ledger, never in the node. */
|
* wrongness lives in the event ledger, never in the node. */
|
||||||
statement: string;
|
statement: string;
|
||||||
/** The elaborated version — required. */
|
|
||||||
description: string;
|
|
||||||
/** Verbatim phrasings the session used. */
|
/** Verbatim phrasings the session used. */
|
||||||
aliases: string[];
|
aliases: string[];
|
||||||
/** The message ids where this proposition came up — provenance. */
|
/** The message ids where this proposition came up — provenance. */
|
||||||
@@ -212,25 +221,19 @@ 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). */
|
||||||
nodeSessions: Record<string, string[]>;
|
nodeSessions: Record<string, string[]>;
|
||||||
|
/** 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<string, Record<string, string>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive a short chat title from the learner's first message (pure).
|
/** Derive a short chat title from the learner's first message (pure).
|
||||||
|
|||||||
+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);
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-24
@@ -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";
|
||||||
@@ -166,10 +165,12 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
|
|||||||
// line — the session's phrasings (statement + aliases) join the
|
// line — the session's phrasings (statement + aliases) join the
|
||||||
// node's aliases, the provenance unions. Reads merge by id.
|
// node's aliases, the provenance unions. Reads merge by id.
|
||||||
graphNodeId = p.linkTo;
|
graphNodeId = p.linkTo;
|
||||||
|
// Link-append: phrasings + provenance join at read time (the
|
||||||
|
// first line's name/statement/createdAt stand).
|
||||||
ledgers.appendNode({
|
ledgers.appendNode({
|
||||||
id: graphNodeId,
|
id: graphNodeId,
|
||||||
|
name: p.name,
|
||||||
statement: p.statement,
|
statement: p.statement,
|
||||||
description: p.description,
|
|
||||||
aliases: [...p.aliases],
|
aliases: [...p.aliases],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
provenance: points,
|
provenance: points,
|
||||||
@@ -177,8 +178,8 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
|
|||||||
} else {
|
} else {
|
||||||
const node: GraphNode = {
|
const node: GraphNode = {
|
||||||
id: newGraphNodeId(),
|
id: newGraphNodeId(),
|
||||||
|
name: p.name,
|
||||||
statement: p.statement,
|
statement: p.statement,
|
||||||
description: p.description,
|
|
||||||
aliases: [...p.aliases],
|
aliases: [...p.aliases],
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
provenance: points,
|
provenance: points,
|
||||||
@@ -329,8 +330,9 @@ export interface DerivedGraph {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
||||||
* appends): the FIRST line defines statement/description/createdAt; later
|
* appends): the FIRST line defines name/statement/createdAt; later
|
||||||
* lines' statements and aliases join `aliases`, provenance unions. Edge
|
* 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,
|
* LINES are assertions, merged by (from,to,type) with unioned provenance,
|
||||||
* first-seen order. `store events, derive state` — this is the only reader
|
* first-seen order. `store events, derive state` — this is the only reader
|
||||||
* that matters.
|
* that matters.
|
||||||
@@ -374,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;
|
||||||
@@ -395,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 ──────────────────────────────────────────────────
|
||||||
@@ -411,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 => {
|
||||||
@@ -453,5 +447,26 @@ 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 };
|
// 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<string, Record<string, string>> = {};
|
||||||
|
const latestObs = new Map<string, { at: string; note: string; index: number }>();
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -83,8 +83,8 @@ const isProvenancePoint = (v: unknown): v is ProvenancePoint =>
|
|||||||
const isGraphNode = (v: unknown): v is GraphNode =>
|
const isGraphNode = (v: unknown): v is GraphNode =>
|
||||||
isRecord(v) &&
|
isRecord(v) &&
|
||||||
isString(v.id) &&
|
isString(v.id) &&
|
||||||
|
isString(v.name) &&
|
||||||
isString(v.statement) &&
|
isString(v.statement) &&
|
||||||
isString(v.description) &&
|
|
||||||
Array.isArray(v.aliases) &&
|
Array.isArray(v.aliases) &&
|
||||||
v.aliases.every(isString) &&
|
v.aliases.every(isString) &&
|
||||||
isString(v.createdAt) &&
|
isString(v.createdAt) &&
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import type {
|
|||||||
SessionFindings,
|
SessionFindings,
|
||||||
} from "../src/core/contracts.ts";
|
} from "../src/core/contracts.ts";
|
||||||
import type { GraphStore } from "../src/core/ports.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 { chatStore } from "../src/store/chats.ts";
|
||||||
import { PATHS } from "../config.ts";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
@@ -135,8 +135,8 @@ const FINDINGS: SessionFindings = {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
name: "Derivative",
|
||||||
statement: "The derivative measures how fast a function changes",
|
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: [],
|
aliases: [],
|
||||||
messages: ["a2"],
|
messages: ["a2"],
|
||||||
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
|
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
|
||||||
@@ -144,8 +144,8 @@ const FINDINGS: SessionFindings = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p2",
|
id: "p2",
|
||||||
statement: "Chain rule intuition",
|
name: "Chain rule",
|
||||||
description: "The chain rule differentiates composed functions by multiplying local rates.",
|
statement: "The chain rule differentiates composed functions by multiplying local rates.",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["a2"],
|
messages: ["a2"],
|
||||||
evidence: ["LEARNER [u1]: teach me the chain rule"],
|
evidence: ["LEARNER [u1]: teach me the chain rule"],
|
||||||
@@ -163,13 +163,16 @@ const FINDINGS: SessionFindings = {
|
|||||||
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
|
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A model whose ONE tool call is `name` with the given input; then text. */
|
/** A model whose ONE tool call is `name` with the given input; then text.
|
||||||
function submitModel(name: string, input: unknown): MockLanguageModelV4 {
|
* `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;
|
let turn = 0;
|
||||||
return new MockLanguageModelV4({
|
return new MockLanguageModelV4({
|
||||||
doGenerate: async () => {
|
doGenerate: async () => {
|
||||||
turn++;
|
turn++;
|
||||||
if (turn === 1) {
|
if (turn === 1) {
|
||||||
|
onCall?.();
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -267,8 +270,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
const existingId = "n_existing";
|
const existingId = "n_existing";
|
||||||
ledgers.appendNode({
|
ledgers.appendNode({
|
||||||
id: existingId,
|
id: existingId,
|
||||||
|
name: "Derivative",
|
||||||
statement: "Derivatives measure rates of change",
|
statement: "Derivatives measure rates of change",
|
||||||
description: "Pre-existing node from an earlier session.",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
createdAt: "2026-08-01T00:00:00.000Z",
|
createdAt: "2026-08-01T00:00:00.000Z",
|
||||||
provenance: [],
|
provenance: [],
|
||||||
@@ -342,6 +345,39 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
|||||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
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 () => {
|
it("is single-flight per chat — a concurrent second run is a no-op", async () => {
|
||||||
const { chatId } = fixtureChat();
|
const { chatId } = fixtureChat();
|
||||||
const ledgers = memoryLedgers();
|
const ledgers = memoryLedgers();
|
||||||
|
|||||||
+37
-39
@@ -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 {
|
||||||
@@ -51,8 +51,8 @@ import { PATHS } from "../config.ts";
|
|||||||
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
name: statement,
|
||||||
statement,
|
statement,
|
||||||
description: `${statement} — elaborated.`,
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
createdAt: "2026-08-22T00:00:00.000Z",
|
createdAt: "2026-08-22T00:00:00.000Z",
|
||||||
provenance: [],
|
provenance: [],
|
||||||
@@ -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)", () => {
|
||||||
@@ -346,16 +344,16 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
statement: "Derivative as a limit",
|
name: "Derivative as a limit",
|
||||||
description: "The derivative is the limit of difference quotients.",
|
statement: "The derivative is the limit of difference quotients.",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["u1"],
|
messages: ["u1"],
|
||||||
evidence: ["e"],
|
evidence: ["e"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p2",
|
id: "p2",
|
||||||
statement: "Chain rule intuition",
|
name: "Chain rule",
|
||||||
description: "Composed functions differentiate by multiplying local rates.",
|
statement: "Composed functions differentiate by multiplying local rates.",
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["u1"],
|
messages: ["u1"],
|
||||||
evidence: ["e"],
|
evidence: ["e"],
|
||||||
@@ -377,8 +375,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
|
name: "Derivative as a limit",
|
||||||
statement: "The derivative as a limit of quotients", // different phrasing
|
statement: "The derivative as a limit of quotients", // different phrasing
|
||||||
description: "…",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["u1"],
|
messages: ["u1"],
|
||||||
evidence: ["e"],
|
evidence: ["e"],
|
||||||
@@ -386,8 +384,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "q2",
|
id: "q2",
|
||||||
|
name: "Chain rule",
|
||||||
statement: "Chain rule, intuitively",
|
statement: "Chain rule, intuitively",
|
||||||
description: "…",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["u1"],
|
messages: ["u1"],
|
||||||
evidence: ["e"],
|
evidence: ["e"],
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Legend/filters predicate tests — the pure half of the graph-view filters
|
||||||
|
* panel (isNodeVisible); the sigma reducers just consume its verdict.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { isNodeVisible } from "../components/graph-view.tsx";
|
||||||
|
|
||||||
|
const node = {
|
||||||
|
name: "Money multiplier",
|
||||||
|
statement: "The maximum ratio of broad money a bank can create from each unit of base money.",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("isNodeVisible (search predicate)", () => {
|
||||||
|
it("is visible with an empty query", () => {
|
||||||
|
assert.equal(isNodeVisible(node, ""), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search matches the statement case-insensitively", () => {
|
||||||
|
assert.equal(isNodeVisible(node, "MONEY multiplier"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search matches the statement when the name does not", () => {
|
||||||
|
assert.equal(isNodeVisible(node, "MAXIMUM ratio"), true);
|
||||||
|
assert.equal(isNodeVisible(node, " velocity"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("search matches the name when the statement does not", () => {
|
||||||
|
assert.equal(isNodeVisible(node, "money MULTIPLIER"), true);
|
||||||
|
assert.equal(isNodeVisible(node, " banking"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whitespace-only query is no filter", () => {
|
||||||
|
assert.equal(isNodeVisible(node, " "), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
});
|
||||||
+24
-17
@@ -167,8 +167,8 @@ function fixtureAudit(): AuditResult {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "p1",
|
id: "p1",
|
||||||
|
name: "Alpha",
|
||||||
statement: "Alpha intuition",
|
statement: "Alpha intuition",
|
||||||
description: "Alpha intuition, elaborated: what alpha means and how it behaves in practice.",
|
|
||||||
aliases: ["alpha feel"],
|
aliases: ["alpha feel"],
|
||||||
messages: ["a1", "a3"],
|
messages: ["a1", "a3"],
|
||||||
evidence: ["fixture evidence"],
|
evidence: ["fixture evidence"],
|
||||||
@@ -176,16 +176,16 @@ function fixtureAudit(): AuditResult {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p2",
|
id: "p2",
|
||||||
|
name: "Beta rule",
|
||||||
statement: "Beta rule",
|
statement: "Beta rule",
|
||||||
description: "The beta rule and when it applies.",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["a4"],
|
messages: ["a4"],
|
||||||
evidence: ["fixture evidence"],
|
evidence: ["fixture evidence"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "p3",
|
id: "p3",
|
||||||
|
name: "Gamma",
|
||||||
statement: "Gamma application",
|
statement: "Gamma application",
|
||||||
description: "Applying the whole framework to gamma cases.",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["a5"],
|
messages: ["a5"],
|
||||||
evidence: ["fixture evidence"],
|
evidence: ["fixture evidence"],
|
||||||
@@ -241,7 +241,7 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id]));
|
const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id]));
|
||||||
for (const node of graph.nodes) {
|
for (const node of graph.nodes) {
|
||||||
assert.match(node.id, /^n_/);
|
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.equal("kind" in node, false, "no kind field");
|
||||||
assert.ok(Array.isArray(node.provenance), "message-granular provenance");
|
assert.ok(Array.isArray(node.provenance), "message-granular provenance");
|
||||||
}
|
}
|
||||||
@@ -333,8 +333,8 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
|
name: "Alpha",
|
||||||
statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings
|
statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings
|
||||||
description: "The intuitive grasp of alpha.",
|
|
||||||
aliases: ["alpha intuition"],
|
aliases: ["alpha intuition"],
|
||||||
messages: ["a1"],
|
messages: ["a1"],
|
||||||
evidence: ["fixture evidence"],
|
evidence: ["fixture evidence"],
|
||||||
@@ -363,13 +363,12 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId });
|
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 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");
|
||||||
@@ -379,6 +378,12 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
);
|
);
|
||||||
// Gamma was only ever touched by chat1.
|
// Gamma was only ever touched by chat1.
|
||||||
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]);
|
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", () => {
|
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||||
@@ -390,8 +395,8 @@ describe("the merger (mergeFindings)", () => {
|
|||||||
propositions: [
|
propositions: [
|
||||||
{
|
{
|
||||||
id: "r1",
|
id: "r1",
|
||||||
|
name: "Alpha",
|
||||||
statement: "Alpha intuition", // IDENTICAL to the existing node's statement
|
statement: "Alpha intuition", // IDENTICAL to the existing node's statement
|
||||||
description: "Same words, no verdict.",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
messages: ["u1"],
|
messages: ["u1"],
|
||||||
evidence: ["fixture evidence"],
|
evidence: ["fixture evidence"],
|
||||||
@@ -473,22 +478,24 @@ describe("graph ledger store", () => {
|
|||||||
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
|
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
|
||||||
const node = {
|
const node = {
|
||||||
id: "n_storetest",
|
id: "n_storetest",
|
||||||
statement: "Store test",
|
name: "Store test",
|
||||||
description: "Store test, elaborated.",
|
statement: "Store test, elaborated.",
|
||||||
aliases: [] as string[],
|
aliases: [] as string[],
|
||||||
createdAt: "2026-08-22T00:00:00.000Z",
|
createdAt: "2026-08-22T00:00:00.000Z",
|
||||||
provenance: [{ chatId: "chat_c", messageId: "m1" }],
|
provenance: [{ chatId: "chat_c", messageId: "m1" }],
|
||||||
};
|
};
|
||||||
graphStore.appendNode(node);
|
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(
|
fs.appendFileSync(
|
||||||
LEDGER_FILES.nodes,
|
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",
|
"utf-8",
|
||||||
);
|
);
|
||||||
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
|
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
|
||||||
assert.equal(nodes.length, 1);
|
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" }]);
|
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -496,8 +503,8 @@ describe("graph ledger store", () => {
|
|||||||
const line = (msgId: string) =>
|
const line = (msgId: string) =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: "n_linktest",
|
id: "n_linktest",
|
||||||
|
name: "Link test",
|
||||||
statement: `Phrasing ${msgId}`,
|
statement: `Phrasing ${msgId}`,
|
||||||
description: "Elaboration.",
|
|
||||||
aliases: [],
|
aliases: [],
|
||||||
createdAt: "2026-08-22T00:00:00.000Z",
|
createdAt: "2026-08-22T00:00:00.000Z",
|
||||||
provenance: [{ chatId: "chat_c", messageId: msgId }],
|
provenance: [{ chatId: "chat_c", messageId: msgId }],
|
||||||
|
|||||||
Reference in New Issue
Block a user