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
|
||||
* transport: the server entry (src/server/graph.ts) folds stale chats and
|
||||
* returns nodes, edges, derived node states, and session provenance.
|
||||
* transport: the server entry (src/server/graph.ts) returns nodes, edges,
|
||||
* session provenance, and per-session understanding observations.
|
||||
*/
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -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
|
||||
* placement only: pan/zoom, no dragging, no editing.
|
||||
*
|
||||
* Labels render the node `name` (the short header, never the statement)
|
||||
* through custom canvas drawers that replace sigma's defaults:
|
||||
* Labels render the node `name` — the very short, glanceable label the
|
||||
* extractor coins (a glossary-headword-style handle); the full-precision
|
||||
* `statement` lives on the detail card. Custom canvas drawers replace
|
||||
* sigma's defaults:
|
||||
* - defaultDrawNodeLabel: plain wrapped text, centered below the node,
|
||||
* word-wrapped at a max line width — no truncation, no one-line sprawl.
|
||||
* - defaultDrawNodeHover: NO background box (sigma's default is a
|
||||
* hardcoded white one). Instead the hovered node gets a soft glow
|
||||
* halo in its own state color at a constant radius, flush with the
|
||||
* halo in the node's color at a constant radius, flush with the
|
||||
* disc edge, and its name brightens — the "this is clickable,
|
||||
* click for the full fact" affordance. Entering/leaving hover fades
|
||||
* ONE transition factor shared by halo and text alike, so the
|
||||
@@ -38,36 +40,43 @@
|
||||
* no per-node size gating, so labels never pop in one by one. (The Obsidian
|
||||
* 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
|
||||
* viewport position (flipping above near the bottom edge); any camera move
|
||||
* (pan/zoom) dismisses it. The `requires` edges render as arrows pointing
|
||||
* prerequisite → dependent.
|
||||
*/
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { WaypointsIcon } from "lucide-react";
|
||||
import { Loader2Icon, SearchIcon, WaypointsIcon } from "lucide-react";
|
||||
import Graph from "graphology";
|
||||
import forceAtlas2 from "graphology-layout-forceatlas2";
|
||||
import type { NodeHoverDrawingFunction, NodeLabelDrawingFunction } from "sigma/rendering";
|
||||
import { seededRandom, type GraphNode, type GraphSnapshot, type NodeStateClass } from "@/src/core";
|
||||
import { seededRandom, type GraphNode, type GraphSnapshot } from "@/src/core";
|
||||
|
||||
/** Per-state visuals: color token + node size (WebGL wants hex, not oklch). */
|
||||
const STATE_TOKENS: Record<NodeStateClass, { cssVar: string; size: number }> = {
|
||||
taught: { cssVar: "--foreground", size: 10 },
|
||||
known: { cssVar: "--primary", size: 8 },
|
||||
missed: { cssVar: "--destructive", size: 8 },
|
||||
"dont-know": { cssVar: "--muted-foreground", size: 6.5 },
|
||||
candidate: { cssVar: "--ring", size: 5 },
|
||||
};
|
||||
/** The uniform node look (no state classes — WebGL wants hex, not oklch):
|
||||
* the accent color + the old "known" size, until the state model lands. */
|
||||
const NODE_COLOR_TOKEN = "--primary";
|
||||
const NODE_SIZE = 8;
|
||||
|
||||
const STATE_LABELS: Record<NodeStateClass, string> = {
|
||||
taught: "taught",
|
||||
known: "known",
|
||||
missed: "missed",
|
||||
"dont-know": "don't know",
|
||||
candidate: "candidate",
|
||||
};
|
||||
/**
|
||||
* Pure filter predicate for the search panel: a node is visible when the
|
||||
* query is empty or matches its name or statement (case-insensitive).
|
||||
* Exported for tests; the sigma reducers just consume its verdict.
|
||||
*/
|
||||
export function isNodeVisible(node: Pick<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) ---------------------------------------------
|
||||
//
|
||||
@@ -199,7 +208,7 @@ function wrapLabel(context: CanvasRenderingContext2D, text: string, maxWidth: nu
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** The wrapped node name, centered below the node — used by both drawers
|
||||
/** The wrapped node name (the short label), centered below the node — used by both drawers
|
||||
* with identical geometry so hover only changes the color, nothing jumps. */
|
||||
function drawName(
|
||||
context: CanvasRenderingContext2D,
|
||||
@@ -313,7 +322,6 @@ function tokenHex(cssVar: string): string {
|
||||
|
||||
interface Selection {
|
||||
node: GraphNode;
|
||||
state: NodeStateClass;
|
||||
chatIds: string[];
|
||||
/** Node position in container pixels (from graphToViewport at click time). */
|
||||
anchor: { x: number; y: number };
|
||||
@@ -324,8 +332,29 @@ export function GraphView() {
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<GraphSnapshot | 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(() => {
|
||||
let cancelled = false;
|
||||
fetch("/api/graph")
|
||||
@@ -337,6 +366,30 @@ export function GraphView() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshTick]);
|
||||
|
||||
// Background pipeline status: poll /api/graph/status; when a run
|
||||
// finishes (some -> none), refetch the graph.
|
||||
const [pipelineRuns, setPipelineRuns] = useState<{ chatId: string; title: string; phase: string; startedAt: number }[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let hadRuns = false;
|
||||
const poll = () =>
|
||||
fetch("/api/graph/status")
|
||||
.then((res) => res.json() as Promise<{ runs: typeof pipelineRuns }>)
|
||||
.then(({ runs }) => {
|
||||
if (cancelled) return;
|
||||
setPipelineRuns(runs);
|
||||
if (hadRuns && runs.length === 0) setRefreshTick((t) => t + 1);
|
||||
hadRuns = runs.length > 0;
|
||||
})
|
||||
.catch(() => {}); // status is cosmetic — never surface poll errors
|
||||
poll();
|
||||
const timer = setInterval(poll, 2000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the graphology graph, lay it out once, mount sigma.
|
||||
@@ -348,24 +401,20 @@ export function GraphView() {
|
||||
const edgeColor = tokenHex("--ring");
|
||||
const labelColor = tokenHex("--muted-foreground");
|
||||
const hoverTextColor = tokenHex("--foreground");
|
||||
const stateColors = new Map<NodeStateClass, string>();
|
||||
for (const state of Object.keys(STATE_TOKENS) as NodeStateClass[]) {
|
||||
stateColors.set(state, tokenHex(STATE_TOKENS[state].cssVar));
|
||||
}
|
||||
const nodeColor = tokenHex(NODE_COLOR_TOKEN);
|
||||
// Mutable hover-transition state: 0 = resting, 1 = fully hovered.
|
||||
const hover: HoverState = { fade: 0, repaint: null };
|
||||
const { drawLabel, drawHover } = makeLabelDrawers(labelColor, hoverTextColor, hover);
|
||||
|
||||
const graph = new Graph();
|
||||
for (const node of snapshot.nodes) {
|
||||
const state: NodeStateClass = snapshot.nodeStates[node.id] ?? "candidate";
|
||||
// Seeded start positions (FA2 requires non-zero x/y) keep the
|
||||
// frozen layout stable for a given node set across remounts.
|
||||
const rand = seededRandom(node.id);
|
||||
graph.addNode(node.id, {
|
||||
label: node.statement,
|
||||
size: STATE_TOKENS[state].size,
|
||||
color: stateColors.get(state) ?? edgeColor,
|
||||
label: node.name,
|
||||
size: NODE_SIZE,
|
||||
color: nodeColor,
|
||||
x: (rand() - 0.5) * 100,
|
||||
y: (rand() - 0.5) * 100,
|
||||
});
|
||||
@@ -426,6 +475,9 @@ export function GraphView() {
|
||||
defaultDrawNodeLabel: drawLabel,
|
||||
defaultDrawNodeHover: drawHover,
|
||||
});
|
||||
graphRef.current = graph;
|
||||
rendererRef.current = renderer;
|
||||
setRendererEpoch((e) => e + 1);
|
||||
renderer.on("clickNode", ({ node }) => {
|
||||
const graphNode = snapshot.nodes.find((n) => n.id === node);
|
||||
if (!graphNode) return;
|
||||
@@ -433,7 +485,6 @@ export function GraphView() {
|
||||
const anchor = renderer?.graphToViewport({ x: pos.x ?? 0, y: pos.y ?? 0 });
|
||||
setSelected({
|
||||
node: graphNode,
|
||||
state: snapshot.nodeStates[node] ?? "candidate",
|
||||
chatIds: snapshot.nodeSessions[node] ?? [],
|
||||
anchor: anchor ?? { x: 0, y: 0 },
|
||||
});
|
||||
@@ -496,10 +547,32 @@ export function GraphView() {
|
||||
return () => {
|
||||
killed = true;
|
||||
if (hoverAnimId !== null) cancelAnimationFrame(hoverAnimId);
|
||||
rendererRef.current = null;
|
||||
graphRef.current = null;
|
||||
renderer?.kill();
|
||||
};
|
||||
}, [snapshot]);
|
||||
|
||||
// Filtering through sigma's official reducers: hidden nodes drop out of
|
||||
// rendering, picking, hover, AND labels; edges drop when either endpoint
|
||||
// is hidden. Re-runs whenever filters change or a renderer is (re)built
|
||||
// (epoch), so the reducers always close over current values.
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
const graph = graphRef.current;
|
||||
if (!renderer || !graph) return;
|
||||
renderer.setSetting("nodeReducer", (key, attrs) =>
|
||||
visibleIds.has(key) ? attrs : { ...attrs, hidden: true },
|
||||
);
|
||||
renderer.setSetting("edgeReducer", (key, attrs) => {
|
||||
const source = graph.source(key);
|
||||
const target = graph.target(key);
|
||||
return visibleIds.has(source) && visibleIds.has(target) ? attrs : { ...attrs, hidden: true };
|
||||
});
|
||||
// The open detail card cannot outlive its node's visibility.
|
||||
setSelected((sel) => (sel && visibleIds.has(sel.node.id) ? sel : null));
|
||||
}, [rendererEpoch, visibleIds]);
|
||||
|
||||
// Position the detail card: below the clicked node, flipped above near
|
||||
// the bottom edge, clamped horizontally. Imperative (ref + layout effect,
|
||||
// before paint) because the flip needs the card's measured height.
|
||||
@@ -544,6 +617,44 @@ export function GraphView() {
|
||||
<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 && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
@@ -552,23 +663,31 @@ export function GraphView() {
|
||||
style={{ visibility: "hidden", left: 0, top: 0 }}
|
||||
className="absolute z-10 w-80 max-w-[calc(100%-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
|
||||
>
|
||||
<div className="text-sm font-medium leading-snug">{selected.node.statement}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.description}</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wide text-muted-foreground">
|
||||
{STATE_LABELS[selected.state]}
|
||||
</div>
|
||||
<div className="text-sm font-medium leading-snug">{selected.node.name}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
|
||||
{selected.chatIds.length > 0 ? (
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<div className="text-xs text-muted-foreground">Sessions</div>
|
||||
{selected.chatIds.map((chatId) => (
|
||||
<Link
|
||||
key={chatId}
|
||||
href={`/chat/${chatId}`}
|
||||
className="truncate rounded-md px-2 py-1 text-sm text-foreground transition-colors hover:bg-accent"
|
||||
>
|
||||
{snapshot?.sessions[chatId] ?? chatId}
|
||||
</Link>
|
||||
))}
|
||||
{selected.chatIds.map((chatId) => {
|
||||
// That session's understanding observation — the
|
||||
// extractor's named-word note (confidence history).
|
||||
const note = snapshot?.nodeObservations?.[selected.node.id]?.[chatId];
|
||||
return (
|
||||
<div key={chatId} className="flex items-center gap-1.5">
|
||||
<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 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**.
|
||||
|
||||
- **Every node is a true proposition.** Worded objectively, in minimal form as it exists w.r.t. its context. Truth is context-relative and that is fine: "Gravity is a force" and "Gravity is not a force" coexist as nodes — each true in its context (classical mechanics vs. GR), with **the surrounding subgraph as the context**. No context field, no pedantic self-containment of facts.
|
||||
- **Node schema:** `statement` — the proposition in minimal form (a short sentence; the rendering label and identity key); `description` — the elaborated version (required); `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** field.
|
||||
- **Node schema:** `name` — a very short label for the node, a compact handle like a glossary headword (“Will to power”, “Divine command theory”): what the graph shows at a glance. Presentational only — not unique, not an identity key, never precise. Set at creation; a re-encounter never changes it. `statement` — the proposition at full precision (the identity anchor; shown in the detail card). `aliases` — the verbatim phrasings sessions used (merge keys + audit trail). There is **no `kind`** and **no `description`** (2026-09-08 amendment: with real data, full-sentence canvas labels proved unworkable — ten-line labels, illegible overprint. The label is the name; the precision lives one interaction away in the statement).
|
||||
- **Misconceptions are not a node category.** A myth enters the graph as its true correction ("The 10%-of-brain claim is a myth"); the learner's holding of the wrong idea lives entirely in the event ledger. Wrongness is about the learner, and the learner lives in the ledger.
|
||||
|
||||
### Edges (DECIDED 2026-09-02)
|
||||
@@ -226,6 +226,7 @@ The teacher's context window and attention are precious — it has the lesson to
|
||||
| `taught` events (marking things done) | Dropped; the extractor records per-proposition understanding notes (named-word scale) as session-scoped observations instead |
|
||||
| Chat-granular node/edge provenance | Message-granular (chat, message) provenance on nodes and edges — the exact derivation points |
|
||||
| `name` + `statement` on nodes | `statement` (minimal proposition, the rendering label) + `description` (required elaboration) + `aliases` (verbatim phrasings) |
|
||||
| `statement` + `description` (2026-09-02) | `name` (very short label, the rendering handle) + `statement` (full precision, identity anchor, detail card); `description` dropped — full-sentence canvas labels cluttered the graph (2026-09-08, seeing real extracted data) |
|
||||
| Updates per completed session | Continuous; partial sessions deposit value |
|
||||
| Mermaid DAG as the plan artifact | Structured tools as source of truth; mermaid only for ad-hoc diagrams |
|
||||
| UI deferred, "not important" | UI exists (Next.js, v3.4); the graph view is the app's home page |
|
||||
|
||||
@@ -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
|
||||
* whatever the in-flight run missed). On failure nothing is checkpointed —
|
||||
* the next trigger re-runs the whole thing.
|
||||
*
|
||||
* Observability (§5 baseline): every stage logs one line per step, its
|
||||
* start, and its duration; the pipeline logs a failure line before
|
||||
* rethrowing, so the server log reads start → stage timings → merged
|
||||
* stats (or failed) for every run.
|
||||
*/
|
||||
|
||||
// ─── the transcript view (what the extractor + auditor see) ─────────────────
|
||||
@@ -94,6 +99,13 @@ const messagesField = z
|
||||
|
||||
const propositionSchema = z.object({
|
||||
id: z.string().describe("Local id: \"p1\", \"p2\", … — referenced by edges and attribution."),
|
||||
name: z
|
||||
.string()
|
||||
.describe(
|
||||
"A very short label for the proposition — a few words, a compact handle like a glossary headword " +
|
||||
'(\'Will to power\', \'Divine command theory\'). Not a sentence, needn\'t be unique or ' +
|
||||
"self-contained: the statement carries the precision. This becomes the node's label in the graph.",
|
||||
),
|
||||
statement: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -102,11 +114,6 @@ const propositionSchema = z.object({
|
||||
'"X". A misconception is stated as the TRUE correction ("Nietzsche did not endorse ' +
|
||||
'might-makes-right social Darwinism") — the wrongness lives in the quiz outcomes, never here.',
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
"The elaborated version of the same proposition — more words: scope, precision, what exactly it means. Still true, still world-facing.",
|
||||
),
|
||||
aliases: z.array(z.string()).describe("Verbatim phrasings this session used for the same proposition, if any."),
|
||||
messages: messagesField,
|
||||
evidence,
|
||||
@@ -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.
|
||||
|
||||
1. PROPOSITIONS — every distinct reasoning-step-sized proposition the session touched:
|
||||
- Each is a self-contained TRUE statement ("statement"), plus its elaboration ("description" — more
|
||||
words, same proposition: scope, precision, what exactly it means).
|
||||
- Each has a "name" — a VERY short label, a few words, a compact handle like a glossary headword
|
||||
("Will to power", "Divine command theory"). Not a sentence; needn't be unique or self-contained.
|
||||
It becomes the node's label in the graph, so keep it glanceable: names are what one reads on
|
||||
the graph, statements are what one reads on the node.
|
||||
- And a "statement" — the self-contained TRUE proposition at full precision (the elaboration lives
|
||||
in the graph only as this one statement).
|
||||
- Include corrections of misconceptions the teacher articulated, and true facts explained in prose.
|
||||
- One reasoning step per proposition: never a topic blob, never a sub-step.
|
||||
- "messages": the [id] tags of every message where the proposition came up — provenance, report it.
|
||||
@@ -249,10 +260,12 @@ FAITHFULNESS — verify every candidate against the transcript:
|
||||
scale or drop.
|
||||
|
||||
IDENTITY — link-or-create, per proposition:
|
||||
- You see the current graph (every node: id, statement, description, aliases). For each verified proposition,
|
||||
- You see the current graph (every node: id, name, statement, aliases). For each verified proposition,
|
||||
decide: does an EXISTING node already state this same proposition?
|
||||
- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance.
|
||||
- If no: omit "linkTo" and a new node is created.
|
||||
- If yes: set "linkTo" to that node's id. The session's phrasings will join its aliases and its provenance;
|
||||
the node keeps its existing name (labels stay stable across sessions — ignore your name for linked nodes).
|
||||
- If no: omit "linkTo" and a new node is created, with the extractor's name as its label. Reword only a
|
||||
clearly unglanceable name (an unwieldy sentence of a name); keep it short.
|
||||
- When unsure, omit — duplicates are recoverable later; wrong merges are not.
|
||||
|
||||
Also drop propositions not referenced by anything else ONLY if they have no other backing — an unreferenced but
|
||||
@@ -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)";
|
||||
const lines = nodes.map(
|
||||
(n) =>
|
||||
`- ${n.id} :: ${n.statement}\n description: ${n.description}` +
|
||||
`- ${n.id} :: ${n.name}\n statement: ${n.statement}` +
|
||||
(n.aliases.length > 0 ? `\n aliases: ${n.aliases.join(" | ")}` : ""),
|
||||
);
|
||||
return lines.join("\n");
|
||||
@@ -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 ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface ExtractionModels {
|
||||
@@ -314,15 +346,25 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
const auditorAgent = createAuditor(deps.auditor ?? modelFor("auditor"));
|
||||
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> {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
const view = transcriptView(record);
|
||||
console.info(`[extraction ${chatId}] extractor starting (${view.split("\n").length} transcript lines)`);
|
||||
const t0 = performance.now();
|
||||
|
||||
const extraction = await extractorAgent.generate({ prompt: `Transcript:\n\n${view}` });
|
||||
for (const part of extraction.content) {
|
||||
if (part.type === "tool-result" && part.toolName === "submit_findings") {
|
||||
console.info(`[extraction ${chatId}] extractor done in ${Math.round(performance.now() - t0)}ms`);
|
||||
return part.output as SessionFindings;
|
||||
}
|
||||
}
|
||||
@@ -338,6 +380,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
`[extraction ${chatId}] auditor starting (${findings.propositions.length} propositions, ${findings.edges.length} edges, ` +
|
||||
`${Object.keys(findings.attribution.quiz).length} attributions, ${graph.nodes.length} graph nodes)`,
|
||||
);
|
||||
const t0 = performance.now();
|
||||
|
||||
const review = await auditorAgent.generate({
|
||||
prompt:
|
||||
@@ -362,23 +405,37 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
|
||||
`${auditResult.edges.length}/${findings.edges.length} edges, ` +
|
||||
`${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;
|
||||
}
|
||||
|
||||
async function runExtraction(chatId: string): Promise<EmissionStats> {
|
||||
if (inFlight.has(chatId)) return { nodes: 0, edges: 0, events: 0 };
|
||||
inFlight.add(chatId);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
console.info(`[extraction ${chatId}] pipeline start`);
|
||||
setPhase(chatId, "extracting");
|
||||
const findings = await extract(chatId);
|
||||
setPhase(chatId, "auditing");
|
||||
const auditResult = await audit(chatId, findings);
|
||||
setPhase(chatId, "merging");
|
||||
// The merger re-reads the chat record itself — events are rebuilt
|
||||
// from the transcript, never from stale copies.
|
||||
const stats = mergeFindings(chatId, auditResult, { chatStore, graphStore });
|
||||
console.info(`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events`);
|
||||
console.info(
|
||||
`[extraction ${chatId}] merged: ${stats.nodes} nodes, ${stats.edges} edges, ${stats.events} events ` +
|
||||
`(${Math.round(performance.now() - t0)}ms total)`,
|
||||
);
|
||||
return stats;
|
||||
} catch (err) {
|
||||
// The throw travels to the caller; this line is what makes the
|
||||
// run's log legible end-to-end (no hanging "pipeline start").
|
||||
console.error(`[extraction ${chatId}] pipeline failed (${Math.round(performance.now() - t0)}ms):`, err);
|
||||
throw err;
|
||||
} finally {
|
||||
inFlight.delete(chatId);
|
||||
runningExtractions.delete(chatId); // done or failed — no longer running
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
-24
@@ -33,13 +33,18 @@ export interface ProvenancePoint {
|
||||
export interface GraphNode {
|
||||
/** Opaque stable id, store-assigned: "n_" + nanoid-like. */
|
||||
id: string;
|
||||
/** The minimal proposition — one true, self-contained statement. This is
|
||||
* BOTH the rendering label AND the identity key: the auditor recognizes
|
||||
* a re-encounter by judgment (link-or-create), not by string matching. */
|
||||
/** A very short label for the node — a compact handle like a glossary
|
||||
* headword ("Will to power", "Divine command theory"): what the graph
|
||||
* shows at a glance. Presentational only — not unique, not an identity
|
||||
* key, never precise: the statement carries the precision. Set at
|
||||
* creation (first line defines); a link-append never changes it. */
|
||||
name: string;
|
||||
/** The minimal proposition — one true, self-contained statement, at
|
||||
* full precision. The IDENTITY ANCHOR: the auditor recognizes a
|
||||
* re-encounter by judgment against this (link-or-create), not by
|
||||
* string matching. Shown in the detail card; the canvas label is the
|
||||
* name. */
|
||||
statement: string;
|
||||
/** The elaborated version — required. Same proposition, more words:
|
||||
* scope, precision, the "what exactly does it mean". */
|
||||
description: string;
|
||||
/** Verbatim phrasings sessions used for this proposition (the identity
|
||||
* audit trail — how the world said what the node says). */
|
||||
aliases: string[];
|
||||
@@ -108,10 +113,11 @@ export interface LedgerEvent {
|
||||
* (nodes/edges/events/mappings/sessions), all append-only.
|
||||
*
|
||||
* nodes.jsonl one line per node — the FIRST line for an id defines
|
||||
* statement/description/createdAt; later lines for the
|
||||
* name/statement/createdAt; later lines for the
|
||||
* same id are link-appends (a session's re-encounter):
|
||||
* their statements/aliases join `aliases`, their
|
||||
* provenance unions. getGraph merges by id.
|
||||
* provenance unions; the name stays the first line's.
|
||||
* getGraph merges by id.
|
||||
* edges.jsonl one line per edge ASSERTION; readers merge by
|
||||
* (from,to,type), unioning provenance.
|
||||
*
|
||||
@@ -136,13 +142,16 @@ export interface LedgerEvent {
|
||||
export interface ExtractedProposition {
|
||||
/** Extractor-local id ("p1", …) referenced by edges + attribution. */
|
||||
id: string;
|
||||
/** A very short label — a few words, a compact handle like a glossary
|
||||
* headword. Not a sentence, needn't be unique or self-contained: the
|
||||
* statement carries the precision. Becomes the node's name on create;
|
||||
* on link, the existing node keeps its name. */
|
||||
name: string;
|
||||
/** The minimal proposition — a true, self-contained, world-facing
|
||||
* statement. No learner / teacher / session references: misconceptions
|
||||
* are stated as the true correction, and wrongness lives in the event
|
||||
* ledger, never in the node. */
|
||||
* statement, at full precision. No learner / teacher / session
|
||||
* references: misconceptions are stated as the true correction, and
|
||||
* wrongness lives in the event ledger, never in the node. */
|
||||
statement: string;
|
||||
/** The elaborated version — required. */
|
||||
description: string;
|
||||
/** Verbatim phrasings the session used. */
|
||||
aliases: string[];
|
||||
/** The message ids where this proposition came up — provenance. */
|
||||
@@ -212,25 +221,19 @@ export interface AuditResult {
|
||||
dropped: { ref: string; reason: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-node state class for the home view — TRANSITIONAL (kept so the
|
||||
* renderer survives the contract change untouched). Derived, never stored:
|
||||
* the LATEST quiz event decides (known / missed / dont-know); a node with
|
||||
* no quiz events is a candidate. `taught` is now unreachable (no taught
|
||||
* events are emitted anymore) — the real state model (dependency depth +
|
||||
* topology-derived strength + quiz events) is pending ideation.
|
||||
*/
|
||||
export type NodeStateClass = "taught" | "known" | "missed" | "dont-know" | "candidate";
|
||||
|
||||
/** The GET /api/graph payload — everything the home view's PURE renderer needs. */
|
||||
export interface GraphSnapshot {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
nodeStates: Record<string, NodeStateClass>;
|
||||
/** chatId → title, for every chat that touched the graph. */
|
||||
sessions: Record<string, string>;
|
||||
/** nodeId → chatIds that touched the node (click-through provenance). */
|
||||
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).
|
||||
|
||||
+28
-22
@@ -20,9 +20,8 @@
|
||||
* the next session should probe.
|
||||
*/
|
||||
|
||||
import type { GraphEdge, GraphNode } from "./contracts.ts";
|
||||
import { deriveNodeStates, type DerivedGraph } from "./graph.ts";
|
||||
import type { NodeStateClass } from "./contracts.ts";
|
||||
import type { GraphEdge, GraphNode, LedgerEventKind } from "./contracts.ts";
|
||||
import { latestQuizOutcomes, type DerivedGraph } from "./graph.ts";
|
||||
|
||||
/** The section header the teacher is taught to recognize (50-graph-context.md). */
|
||||
export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
||||
@@ -30,46 +29,51 @@ export const GRAPH_CONTEXT_HEADER = "LEARNER KNOWLEDGE GRAPH (auto-generated)";
|
||||
/** Above this node count only hotspots + their 1-hop neighborhood are shown. */
|
||||
export const MAX_CONTEXT_NODES = 150;
|
||||
|
||||
/** Group order for node lines: diagnostic value first (vision §1 hotspots). */
|
||||
const HOTSPOT_STATES: ReadonlySet<NodeStateClass> = new Set(["missed", "dont-know"]);
|
||||
/**
|
||||
* Group order for node lines: diagnostic value first (vision §1 hotspots).
|
||||
* No node-state classes (removed 2026-09-08) — the grouping is the raw
|
||||
* event fact: the node's LATEST quiz outcome (miss/dont-know = hotspot).
|
||||
*/
|
||||
type Outcome = LedgerEventKind | "never-probed";
|
||||
|
||||
function groupOf(node: GraphNode, state: NodeStateClass): number {
|
||||
if (HOTSPOT_STATES.has(state)) return 0;
|
||||
if (state === "known") return 1;
|
||||
return 2; // candidate
|
||||
function groupOf(outcome: Outcome): number {
|
||||
if (outcome === "miss" || outcome === "dont-know") return 0;
|
||||
if (outcome === "correct") return 1;
|
||||
return 2; // never probed
|
||||
}
|
||||
|
||||
const GROUP_LABELS = [
|
||||
"Hotspots (missed / dont-know) — likely misconception sites and unexplored ground:",
|
||||
"Known (answered correctly):",
|
||||
"Candidates (never probed — assume nothing):",
|
||||
"Hotspots (last answer wrong or don't-know) — likely misconception sites and unexplored ground:",
|
||||
"Answered correctly:",
|
||||
"Never probed (assume nothing):",
|
||||
] as const;
|
||||
|
||||
/** A hotspot is a node worth showing even under truncation (§1: diagnostic hotspots). */
|
||||
function isHotspot(node: GraphNode, state: NodeStateClass): boolean {
|
||||
return groupOf(node, state) === 0;
|
||||
function isHotspot(outcome: Outcome): boolean {
|
||||
return groupOf(outcome) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the context block, or null when there is nothing worth injecting
|
||||
* (no nodes; or a truncated graph whose selection came out empty).
|
||||
*
|
||||
* Pure: callers hand in exactly what getGraph(graphStore) + deriveNodeStates() produce.
|
||||
* Pure: callers hand in exactly what getGraph(graphStore) produces; the
|
||||
* outcomes are the raw latest-quiz-event facts (core/graph.ts).
|
||||
*/
|
||||
export function renderGraphContext(
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
states: Record<string, NodeStateClass>,
|
||||
outcomes: Map<string, LedgerEventKind>,
|
||||
): string | null {
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
// ── selection ───────────────────────────────────────────────────────────
|
||||
const stateOf = (node: GraphNode): NodeStateClass => states[node.id] ?? "candidate";
|
||||
const outcomeOf = (node: GraphNode): Outcome => outcomes.get(node.id) ?? "never-probed";
|
||||
let selected = nodes;
|
||||
let truncated = false;
|
||||
if (nodes.length > MAX_CONTEXT_NODES) {
|
||||
truncated = true;
|
||||
const hotspots = nodes.filter((n) => isHotspot(n, stateOf(n)));
|
||||
const hotspots = nodes.filter((n) => isHotspot(outcomeOf(n)));
|
||||
const keep = new Set(hotspots.map((n) => n.id));
|
||||
// 1-hop follows neighborhood: premises AND derivations.
|
||||
for (const edge of edges) {
|
||||
@@ -91,12 +95,14 @@ export function renderGraphContext(
|
||||
|
||||
for (const [group, label] of GROUP_LABELS.entries()) {
|
||||
const groupNodes = selected
|
||||
.filter((n) => groupOf(n, stateOf(n)) === group)
|
||||
.filter((n) => groupOf(outcomeOf(n)) === group)
|
||||
.sort((a, b) => (a.statement < b.statement ? -1 : a.statement > b.statement ? 1 : 0));
|
||||
if (groupNodes.length === 0) continue;
|
||||
lines.push("", label);
|
||||
for (const node of groupNodes) {
|
||||
lines.push(`• [${stateOf(node)}] ${node.statement} (${node.id})`);
|
||||
const outcome = outcomeOf(node);
|
||||
const tag = outcome === "never-probed" ? "never probed" : outcome;
|
||||
lines.push(`• [${tag}] ${node.statement} (${node.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +167,6 @@ function edgeChains(edges: GraphEdge[]): string[][] {
|
||||
* chat-handler, so it self-updates as the graph grows mid-session).
|
||||
*/
|
||||
export function buildGraphContext(graph: DerivedGraph): string | null {
|
||||
const states = deriveNodeStates(graph.nodes, graph.events);
|
||||
return renderGraphContext(graph.nodes, graph.edges, states);
|
||||
const outcomes = latestQuizOutcomes(graph.nodes, graph.events);
|
||||
return renderGraphContext(graph.nodes, graph.edges, outcomes);
|
||||
}
|
||||
|
||||
+39
-24
@@ -48,7 +48,6 @@ import type {
|
||||
GraphSnapshot,
|
||||
LedgerEvent,
|
||||
LedgerEventKind,
|
||||
NodeStateClass,
|
||||
} from "./contracts.ts";
|
||||
import type { QuestionAnswerData } from "./question-core.ts";
|
||||
import type { ChatStore, GraphStore } from "./ports.ts";
|
||||
@@ -166,10 +165,12 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
|
||||
// line — the session's phrasings (statement + aliases) join the
|
||||
// node's aliases, the provenance unions. Reads merge by id.
|
||||
graphNodeId = p.linkTo;
|
||||
// Link-append: phrasings + provenance join at read time (the
|
||||
// first line's name/statement/createdAt stand).
|
||||
ledgers.appendNode({
|
||||
id: graphNodeId,
|
||||
name: p.name,
|
||||
statement: p.statement,
|
||||
description: p.description,
|
||||
aliases: [...p.aliases],
|
||||
createdAt: now,
|
||||
provenance: points,
|
||||
@@ -177,8 +178,8 @@ export function mergeFindings(chatId: string, audit: AuditResult, deps: GraphDep
|
||||
} else {
|
||||
const node: GraphNode = {
|
||||
id: newGraphNodeId(),
|
||||
name: p.name,
|
||||
statement: p.statement,
|
||||
description: p.description,
|
||||
aliases: [...p.aliases],
|
||||
createdAt: now,
|
||||
provenance: points,
|
||||
@@ -329,8 +330,9 @@ export interface DerivedGraph {
|
||||
|
||||
/**
|
||||
* The whole graph from the ledgers. Node LINES may repeat an id (link-
|
||||
* appends): the FIRST line defines statement/description/createdAt; later
|
||||
* lines' statements and aliases join `aliases`, provenance unions. Edge
|
||||
* appends): the FIRST line defines name/statement/createdAt; later
|
||||
* lines' statements and aliases join `aliases`, provenance unions — the
|
||||
* name never changes after creation. Edge
|
||||
* LINES are assertions, merged by (from,to,type) with unioned provenance,
|
||||
* first-seen order. `store events, derive state` — this is the only reader
|
||||
* that matters.
|
||||
@@ -374,18 +376,14 @@ export function getGraph(graphStore: GraphStore): DerivedGraph {
|
||||
const QUIZ_EVENT_KINDS: ReadonlySet<string> = new Set(["correct", "miss", "dont-know"]);
|
||||
|
||||
/**
|
||||
* Derive node states from events (never stored): the LATEST quiz event
|
||||
* decides (known / missed / dont-know); a node with no quiz events is a
|
||||
* candidate. TRANSITIONAL (2026-09-02 redesign): `taught` is unreachable
|
||||
* now that no taught events are emitted, and observation events are NOT
|
||||
* state inputs — the real state model (dependency depth + topology-derived
|
||||
* strength + quiz events) is pending ideation; this keeps the renderer
|
||||
* alive until it lands.
|
||||
* The latest quiz outcome per node — a raw EVENT FACT, not a node state
|
||||
* (the transitional node-state classes were removed 2026-09-08: nothing
|
||||
* colors nodes by quiz results anymore). Used by the teacher's
|
||||
* graph-as-context selection (graph-context.ts) to find diagnostic
|
||||
* hotspots; the home renderer does not consume it. Nodes with no quiz
|
||||
* events have no entry.
|
||||
*/
|
||||
export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Record<string, NodeStateClass> {
|
||||
const states: Record<string, NodeStateClass> = {};
|
||||
for (const node of nodes) states[node.id] = "candidate";
|
||||
|
||||
export function latestQuizOutcomes(nodes: GraphNode[], events: LedgerEvent[]): Map<string, LedgerEventKind> {
|
||||
const latest = new Map<string, { at: string; kind: LedgerEventKind; index: number }>();
|
||||
events.forEach((event, index) => {
|
||||
if (event.nodeId === null) return;
|
||||
@@ -395,15 +393,12 @@ export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Rec
|
||||
latest.set(event.nodeId, { at: event.at, kind: event.kind, index });
|
||||
}
|
||||
});
|
||||
|
||||
const out = new Map<string, LedgerEventKind>();
|
||||
for (const node of nodes) {
|
||||
const last = latest.get(node.id);
|
||||
if (!last) continue; // candidate
|
||||
if (last.kind === "correct") states[node.id] = "known";
|
||||
else if (last.kind === "dont-know") states[node.id] = "dont-know";
|
||||
else states[node.id] = "missed";
|
||||
if (last) out.set(node.id, last.kind);
|
||||
}
|
||||
return states;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── the /api/graph payload ──────────────────────────────────────────────────
|
||||
@@ -411,7 +406,6 @@ export function deriveNodeStates(nodes: GraphNode[], events: LedgerEvent[]): Rec
|
||||
/** Everything the home graph view needs, in one fetch (shape: contracts). */
|
||||
export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
const { nodes, edges, events } = getGraph(deps.graphStore);
|
||||
const nodeStates = deriveNodeStates(nodes, events);
|
||||
|
||||
const nodeChats = new Map<string, Set<string>>();
|
||||
const touch = (nodeId: string, chatId: string): void => {
|
||||
@@ -453,5 +447,26 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
|
||||
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 =>
|
||||
isRecord(v) &&
|
||||
isString(v.id) &&
|
||||
isString(v.name) &&
|
||||
isString(v.statement) &&
|
||||
isString(v.description) &&
|
||||
Array.isArray(v.aliases) &&
|
||||
v.aliases.every(isString) &&
|
||||
isString(v.createdAt) &&
|
||||
|
||||
@@ -34,7 +34,7 @@ import type {
|
||||
SessionFindings,
|
||||
} from "../src/core/contracts.ts";
|
||||
import type { GraphStore } from "../src/core/ports.ts";
|
||||
import { createExtractionPipeline, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||
import { createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -135,8 +135,8 @@ const FINDINGS: SessionFindings = {
|
||||
propositions: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Derivative",
|
||||
statement: "The derivative measures how fast a function changes",
|
||||
description: "The derivative of a function at a point is its instantaneous rate of change there.",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["TEACHER [a2]: The derivative measures how fast a function changes."],
|
||||
@@ -144,8 +144,8 @@ const FINDINGS: SessionFindings = {
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
statement: "Chain rule intuition",
|
||||
description: "The chain rule differentiates composed functions by multiplying local rates.",
|
||||
name: "Chain rule",
|
||||
statement: "The chain rule differentiates composed functions by multiplying local rates.",
|
||||
aliases: [],
|
||||
messages: ["a2"],
|
||||
evidence: ["LEARNER [u1]: teach me the chain rule"],
|
||||
@@ -163,13 +163,16 @@ const FINDINGS: SessionFindings = {
|
||||
attribution: { quiz: { "probe-1": "p1", "check-1": "p2" } },
|
||||
};
|
||||
|
||||
/** A model whose ONE tool call is `name` with the given input; then text. */
|
||||
function submitModel(name: string, input: unknown): MockLanguageModelV4 {
|
||||
/** A model whose ONE tool call is `name` with the given input; then text.
|
||||
* `onCall` (optional) fires at the moment of the first generate — the
|
||||
* observation point for mid-run assertions. */
|
||||
function submitModel(name: string, input: unknown, onCall?: () => void): MockLanguageModelV4 {
|
||||
let turn = 0;
|
||||
return new MockLanguageModelV4({
|
||||
doGenerate: async () => {
|
||||
turn++;
|
||||
if (turn === 1) {
|
||||
onCall?.();
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
@@ -267,8 +270,8 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
const existingId = "n_existing";
|
||||
ledgers.appendNode({
|
||||
id: existingId,
|
||||
name: "Derivative",
|
||||
statement: "Derivatives measure rates of change",
|
||||
description: "Pre-existing node from an earlier session.",
|
||||
aliases: [],
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
provenance: [],
|
||||
@@ -342,6 +345,39 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
});
|
||||
|
||||
it("reports its phase through the status registry while running, and clears it when done", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
const existingId = "n_existing";
|
||||
ledgers.appendNode({
|
||||
id: existingId,
|
||||
name: "Derivative",
|
||||
statement: "Derivatives measure rates of change",
|
||||
aliases: [],
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
provenance: [],
|
||||
});
|
||||
const seen: (string | undefined)[] = [];
|
||||
const phaseOf = () => extractionStatuses().find((r) => r.chatId === chatId)?.phase;
|
||||
const verdict: AuditResult = {
|
||||
propositions: [{ ...FINDINGS.propositions[0]!, linkTo: existingId }],
|
||||
edges: [],
|
||||
attribution: { quiz: {} },
|
||||
dropped: [{ ref: "p2", reason: "evidence not found" }],
|
||||
};
|
||||
const pipeline = createExtractionPipeline({
|
||||
chatStore,
|
||||
graphStore: ledgers,
|
||||
// Each stage's first generate is the observation point: the
|
||||
// registry must already carry this chat at the right phase.
|
||||
extractor: submitModel("submit_findings", FINDINGS, () => seen.push(phaseOf())),
|
||||
auditor: submitModel("submit_review", verdict, () => seen.push(phaseOf())),
|
||||
});
|
||||
await pipeline.runExtraction(chatId);
|
||||
assert.deepEqual(seen, ["extracting", "auditing"]);
|
||||
assert.equal(extractionStatuses().length, 0, "the registry clears when the run ends");
|
||||
});
|
||||
|
||||
it("is single-flight per chat — a concurrent second run is a no-op", async () => {
|
||||
const { chatId } = fixtureChat();
|
||||
const ledgers = memoryLedgers();
|
||||
|
||||
+37
-39
@@ -31,7 +31,7 @@ import type {
|
||||
NodeMapping,
|
||||
SessionCheckpoint,
|
||||
} from "../src/core/contracts.ts";
|
||||
import type { NodeStateClass } from "../src/core/contracts.ts";
|
||||
import type { LedgerEventKind } from "../src/core/contracts.ts";
|
||||
import { getGraph, mergeFindings } from "../src/core/graph.ts";
|
||||
import type { GraphStore } from "../src/core/ports.ts";
|
||||
import {
|
||||
@@ -51,8 +51,8 @@ import { PATHS } from "../config.ts";
|
||||
function node(id: string, statement: string, over: Partial<GraphNode> = {}): GraphNode {
|
||||
return {
|
||||
id,
|
||||
name: statement,
|
||||
statement,
|
||||
description: `${statement} — elaborated.`,
|
||||
aliases: [],
|
||||
createdAt: "2026-08-22T00:00:00.000Z",
|
||||
provenance: [],
|
||||
@@ -64,18 +64,19 @@ function edge(from: string, to: string): GraphEdge {
|
||||
return { from, to, type: "follows", provenance: [{ chatId: "chat_x", messageId: "m1" }] };
|
||||
}
|
||||
|
||||
function states(...entries: [string, NodeStateClass][]): Record<string, NodeStateClass> {
|
||||
return Object.fromEntries(entries);
|
||||
/** Latest-quiz-outcome map: nodeId → quiz event kind (the raw event fact). */
|
||||
function outcomes(...entries: [string, LedgerEventKind][]): Map<string, LedgerEventKind> {
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
// ─── renderGraphContext (pure) ───────────────────────────────────────────────
|
||||
|
||||
describe("renderGraphContext (pure)", () => {
|
||||
it("returns null for an empty graph (inject nothing)", () => {
|
||||
assert.equal(renderGraphContext([], [], {}), null);
|
||||
assert.equal(renderGraphContext([], [], outcomes()), null);
|
||||
});
|
||||
|
||||
it("renders a header, state-grouped node lines (hotspots first), and chained follows edges", () => {
|
||||
it("renders a header, outcome-grouped node lines (hotspots first), and chained follows edges", () => {
|
||||
const nodes = [
|
||||
node("n_a", "Chain rule intuition"),
|
||||
node("n_b", "Quotient rule"),
|
||||
@@ -83,25 +84,24 @@ describe("renderGraphContext (pure)", () => {
|
||||
node("n_d", "Composite functions"),
|
||||
];
|
||||
const edges = [edge("n_a", "n_b"), edge("n_b", "n_c"), edge("n_d", "n_a")];
|
||||
const st = states(
|
||||
["n_a", "known"],
|
||||
["n_b", "missed"],
|
||||
["n_c", "candidate"],
|
||||
const oc = outcomes(
|
||||
["n_a", "correct"],
|
||||
["n_b", "miss"],
|
||||
["n_d", "dont-know"],
|
||||
);
|
||||
const out = renderGraphContext(nodes, edges, st)!;
|
||||
const out = renderGraphContext(nodes, edges, oc)!;
|
||||
|
||||
assert.ok(out.includes(GRAPH_CONTEXT_HEADER), "header line present");
|
||||
assert.match(out, /4 nodes, 3 follows edges/);
|
||||
|
||||
// Node lines: "• [state] statement (id)".
|
||||
assert.ok(out.includes("• [missed] Quotient rule (n_b)"));
|
||||
assert.ok(out.includes("• [known] Chain rule intuition (n_a)"));
|
||||
// Node lines: "• [outcome] statement (id)".
|
||||
assert.ok(out.includes("• [miss] Quotient rule (n_b)"));
|
||||
assert.ok(out.includes("• [correct] Chain rule intuition (n_a)"));
|
||||
assert.ok(out.includes("• [dont-know] Composite functions (n_d)"));
|
||||
assert.ok(out.includes("• [candidate] Product rule (n_c)"));
|
||||
assert.ok(out.includes("• [never probed] Product rule (n_c)"));
|
||||
|
||||
// Groups in the decided order: hotspots, known, candidates.
|
||||
const order = ["Hotspots", "Known", "Candidates"].map((g) => out.indexOf(g));
|
||||
// Groups in the decided order: hotspots, correct, never probed.
|
||||
const order = ["Hotspots", "Answered correctly", "Never probed"].map((g) => out.indexOf(g));
|
||||
assert.deepEqual([...order].sort((a, b) => a - b), order, "groups appear in priority order");
|
||||
|
||||
// Edges merged into chains: d → a → b → c (one line, three arrows).
|
||||
@@ -112,7 +112,7 @@ describe("renderGraphContext (pure)", () => {
|
||||
|
||||
it("renders branching edges as sibling chains, not one false chain", () => {
|
||||
const nodes = [node("n_a", "A"), node("n_b", "B"), node("n_c", "C")];
|
||||
const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], states())!;
|
||||
const out = renderGraphContext(nodes, [edge("n_a", "n_b"), edge("n_a", "n_c")], outcomes())!;
|
||||
assert.ok(out.includes("A → B"));
|
||||
assert.ok(out.includes("A → C"));
|
||||
assert.ok(!out.includes("B → C"), "no edge was invented between siblings");
|
||||
@@ -123,16 +123,15 @@ describe("renderGraphContext (pure)", () => {
|
||||
// long tail of disconnected candidates that must drop out.
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const st: Record<string, NodeStateClass> = {};
|
||||
const oc = new Map<string, LedgerEventKind>();
|
||||
const hotspot = (i: number) => {
|
||||
const h = `n_h${i}`;
|
||||
const pre = `n_p${i}`;
|
||||
const dep = `n_d${i}`;
|
||||
nodes.push(node(pre, `Premise ${i}`), node(h, `Hotspot ${i}`), node(dep, `Derivation ${i}`));
|
||||
edges.push(edge(pre, h), edge(h, dep));
|
||||
st[pre] = "known";
|
||||
st[h] = "missed";
|
||||
st[dep] = "candidate";
|
||||
oc.set(pre, "correct");
|
||||
oc.set(h, "miss");
|
||||
};
|
||||
hotspot(1);
|
||||
hotspot(2);
|
||||
@@ -140,14 +139,13 @@ describe("renderGraphContext (pure)", () => {
|
||||
for (let i = 0; i < MAX_CONTEXT_NODES; i++) {
|
||||
const id = `n_f${i}`;
|
||||
nodes.push(node(id, `Far away ${i}`));
|
||||
st[id] = "candidate";
|
||||
}
|
||||
assert.ok(nodes.length > MAX_CONTEXT_NODES, "fixture exceeds the cap");
|
||||
|
||||
const out = renderGraphContext(nodes, edges, st)!;
|
||||
assert.ok(out.includes("• [missed] Hotspot 1 (n_h1)"), "hotspots stay");
|
||||
assert.ok(out.includes("• [known] Premise 1 (n_p1)"), "1-hop premises stay");
|
||||
assert.ok(out.includes("• [candidate] Derivation 1 (n_d1)"), "1-hop derivations stay");
|
||||
const out = renderGraphContext(nodes, edges, oc)!;
|
||||
assert.ok(out.includes("• [miss] Hotspot 1 (n_h1)"), "hotspots stay");
|
||||
assert.ok(out.includes("• [correct] Premise 1 (n_p1)"), "1-hop premises stay");
|
||||
assert.ok(out.includes("• [never probed] Derivation 1 (n_d1)"), "1-hop derivations stay");
|
||||
assert.ok(!out.includes("Far away"), "disconnected nodes drop out");
|
||||
assert.match(out, new RegExp(`showing 9 of ${nodes.length} nodes`), "truncation is noted");
|
||||
assert.ok(out.includes(", 6 follows edges"), "edges among the selected nodes stay");
|
||||
@@ -155,14 +153,14 @@ describe("renderGraphContext (pure)", () => {
|
||||
|
||||
it("keeps the full graph at exactly the cap (no truncation note)", () => {
|
||||
const nodes = Array.from({ length: MAX_CONTEXT_NODES }, (_, i) => node(`n_${i}`, `Node ${i}`));
|
||||
const out = renderGraphContext(nodes, [], {})!;
|
||||
assert.ok(out.includes(`• [candidate] Node ${MAX_CONTEXT_NODES - 1}`));
|
||||
const out = renderGraphContext(nodes, [], outcomes())!;
|
||||
assert.ok(out.includes(`• [never probed] Node ${MAX_CONTEXT_NODES - 1}`));
|
||||
assert.ok(!out.includes("showing"), "at the cap nothing is dropped");
|
||||
});
|
||||
|
||||
it("returns null when a too-large graph has no hotspots at all", () => {
|
||||
const nodes = Array.from({ length: MAX_CONTEXT_NODES + 1 }, (_, i) => node(`n_${i}`, `Node ${i}`));
|
||||
assert.equal(renderGraphContext(nodes, [], {}), null);
|
||||
assert.equal(renderGraphContext(nodes, [], outcomes()), null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,15 +171,15 @@ describe("buildGraphContext", () => {
|
||||
assert.equal(buildGraphContext({ nodes: [], edges: [], events: [] }), null);
|
||||
});
|
||||
|
||||
it("derives states from events and renders the block", () => {
|
||||
it("derives latest quiz outcomes from events and renders the block", () => {
|
||||
const nodes = [node("n_a", "Solo node"), node("n_b", "Checked node")];
|
||||
const events: LedgerEvent[] = [
|
||||
{ nodeId: "n_b", kind: "correct", chatId: "c1", at: "2026-08-22T00:00:00.000Z", ref: "r1" },
|
||||
];
|
||||
const out = buildGraphContext({ nodes, edges: [], events })!;
|
||||
assert.ok(out.includes(GRAPH_CONTEXT_HEADER));
|
||||
assert.ok(out.includes("• [known] Checked node (n_b)"), "correct → known");
|
||||
assert.ok(out.includes("• [candidate] Solo node (n_a)"), "no events → candidate");
|
||||
assert.ok(out.includes("• [correct] Checked node (n_b)"), "correct → answered correctly");
|
||||
assert.ok(out.includes("• [never probed] Solo node (n_a)"), "no events → never probed");
|
||||
});
|
||||
|
||||
it("reads the live ledgers without throwing (read-only integration)", () => {
|
||||
@@ -346,16 +344,16 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
||||
propositions: [
|
||||
{
|
||||
id: "p1",
|
||||
statement: "Derivative as a limit",
|
||||
description: "The derivative is the limit of difference quotients.",
|
||||
name: "Derivative as a limit",
|
||||
statement: "The derivative is the limit of difference quotients.",
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["e"],
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
statement: "Chain rule intuition",
|
||||
description: "Composed functions differentiate by multiplying local rates.",
|
||||
name: "Chain rule",
|
||||
statement: "Composed functions differentiate by multiplying local rates.",
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["e"],
|
||||
@@ -377,8 +375,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
||||
propositions: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Derivative as a limit",
|
||||
statement: "The derivative as a limit of quotients", // different phrasing
|
||||
description: "…",
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["e"],
|
||||
@@ -386,8 +384,8 @@ describe("cross-chat edge assertions (merger + read-time merging)", () => {
|
||||
},
|
||||
{
|
||||
id: "q2",
|
||||
name: "Chain rule",
|
||||
statement: "Chain rule, intuitively",
|
||||
description: "…",
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["e"],
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
id: "p1",
|
||||
name: "Alpha",
|
||||
statement: "Alpha intuition",
|
||||
description: "Alpha intuition, elaborated: what alpha means and how it behaves in practice.",
|
||||
aliases: ["alpha feel"],
|
||||
messages: ["a1", "a3"],
|
||||
evidence: ["fixture evidence"],
|
||||
@@ -176,16 +176,16 @@ function fixtureAudit(): AuditResult {
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
name: "Beta rule",
|
||||
statement: "Beta rule",
|
||||
description: "The beta rule and when it applies.",
|
||||
aliases: [],
|
||||
messages: ["a4"],
|
||||
evidence: ["fixture evidence"],
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
name: "Gamma",
|
||||
statement: "Gamma application",
|
||||
description: "Applying the whole framework to gamma cases.",
|
||||
aliases: [],
|
||||
messages: ["a5"],
|
||||
evidence: ["fixture evidence"],
|
||||
@@ -241,7 +241,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
const idByStatement = new Map(graph.nodes.map((n) => [n.statement, n.id]));
|
||||
for (const node of graph.nodes) {
|
||||
assert.match(node.id, /^n_/);
|
||||
assert.ok(node.description.length > 0, "description is required");
|
||||
assert.ok(node.name.length > 0, "name is required");
|
||||
assert.equal("kind" in node, false, "no kind field");
|
||||
assert.ok(Array.isArray(node.provenance), "message-granular provenance");
|
||||
}
|
||||
@@ -333,8 +333,8 @@ describe("the merger (mergeFindings)", () => {
|
||||
propositions: [
|
||||
{
|
||||
id: "q1",
|
||||
name: "Alpha",
|
||||
statement: "Alpha feel", // a DIFFERENT phrasing — identity is the verdict, not strings
|
||||
description: "The intuitive grasp of alpha.",
|
||||
aliases: ["alpha intuition"],
|
||||
messages: ["a1"],
|
||||
evidence: ["fixture evidence"],
|
||||
@@ -363,13 +363,12 @@ describe("the merger (mergeFindings)", () => {
|
||||
assert.deepEqual(m, { chatId: chat2.id, sourceId: "q1", graphNodeId: alphaId });
|
||||
});
|
||||
|
||||
it("derives node states and sessions in graphSnapshot", () => {
|
||||
it("derives sessions and per-session understanding observations in graphSnapshot", () => {
|
||||
const snap = graphSnapshot(graphDeps);
|
||||
const idByStatement = new Map(snap.nodes.map((n) => [n.statement, n.id]));
|
||||
// Alpha's latest quiz is call-9 (chat2, correct) → known.
|
||||
assert.equal(snap.nodeStates[idByStatement.get("Alpha intuition")!], "known");
|
||||
assert.equal(snap.nodeStates[idByStatement.get("Beta rule")!], "missed");
|
||||
assert.equal(snap.nodeStates[idByStatement.get("Gamma application")!], "dont-know");
|
||||
// No node-state classes anymore (2026-09-08): the snapshot carries
|
||||
// only the objective graph + sessions; quiz outcomes stay ledger data.
|
||||
assert.equal("nodeStates" in snap, false);
|
||||
|
||||
assert.equal(snap.sessions[chat1.id], "Graph fixture");
|
||||
assert.equal(snap.sessions[chat2Id], "Graph fixture 2");
|
||||
@@ -379,6 +378,12 @@ describe("the merger (mergeFindings)", () => {
|
||||
);
|
||||
// Gamma was only ever touched by chat1.
|
||||
assert.deepEqual(snap.nodeSessions[idByStatement.get("Gamma application")!], [chat1.id]);
|
||||
|
||||
// Understanding observations: (node → chat → the extractor's note).
|
||||
// Alpha carries chat1's "solid after the check"; chat2's q1 has no note.
|
||||
const alphaObs = snap.nodeObservations[idByStatement.get("Alpha intuition")!] ?? {};
|
||||
assert.equal(alphaObs[chat1.id], "solid after the check");
|
||||
assert.equal(alphaObs[chat2Id], undefined);
|
||||
});
|
||||
|
||||
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||
@@ -390,8 +395,8 @@ describe("the merger (mergeFindings)", () => {
|
||||
propositions: [
|
||||
{
|
||||
id: "r1",
|
||||
name: "Alpha",
|
||||
statement: "Alpha intuition", // IDENTICAL to the existing node's statement
|
||||
description: "Same words, no verdict.",
|
||||
aliases: [],
|
||||
messages: ["u1"],
|
||||
evidence: ["fixture evidence"],
|
||||
@@ -473,22 +478,24 @@ describe("graph ledger store", () => {
|
||||
it("round-trips lines and skips corrupt / foreign-shaped lines (strict reads)", () => {
|
||||
const node = {
|
||||
id: "n_storetest",
|
||||
statement: "Store test",
|
||||
description: "Store test, elaborated.",
|
||||
name: "Store test",
|
||||
statement: "Store test, elaborated.",
|
||||
aliases: [] as string[],
|
||||
createdAt: "2026-08-22T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_c", messageId: "m1" }],
|
||||
};
|
||||
graphStore.appendNode(node);
|
||||
// A torn append and a legacy pre-redesign node (old schema) — both skipped.
|
||||
// A torn append and a pre-name-pivot node (old schema: description, no
|
||||
// name) — both skipped by the strict guard.
|
||||
fs.appendFileSync(
|
||||
LEDGER_FILES.nodes,
|
||||
`{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", kind: "truth", name: "Legacy", createdAt: "2026-08-22T00:00:00.000Z" })}\n`,
|
||||
`{not json}\n${JSON.stringify({ id: "legacy", statement: "Legacy shape", description: "Old schema.", aliases: [], createdAt: "2026-08-22T00:00:00.000Z", provenance: [] })}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
const nodes = graphStore.readNodes().filter((n) => n.id === "n_storetest" || n.id === "legacy");
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(nodes[0]!.statement, "Store test");
|
||||
assert.equal(nodes[0]!.name, "Store test");
|
||||
assert.equal(nodes[0]!.statement, "Store test, elaborated.");
|
||||
assert.deepEqual(nodes[0]!.provenance, [{ chatId: "chat_c", messageId: "m1" }]);
|
||||
});
|
||||
|
||||
@@ -496,8 +503,8 @@ describe("graph ledger store", () => {
|
||||
const line = (msgId: string) =>
|
||||
JSON.stringify({
|
||||
id: "n_linktest",
|
||||
name: "Link test",
|
||||
statement: `Phrasing ${msgId}`,
|
||||
description: "Elaboration.",
|
||||
aliases: [],
|
||||
createdAt: "2026-08-22T00:00:00.000Z",
|
||||
provenance: [{ chatId: "chat_c", messageId: msgId }],
|
||||
|
||||
Reference in New Issue
Block a user