practice fix — creation is graph-domain: cluster picker, nodeIds-only API (owner ruling 2026-09-29)

Owner ruling: 'problem sets always take in nodes — graph objects' —
practice is graph-domain, not chat-domain. No session-created sets.

API: POST /api/practice/sets takes {nodeIds, params, title?} ONLY —
the chatId branch, UnknownChatError, and the any-provenance filter are
deleted; the selector is nodeIds (readNodes filtered to the selection,
unknown ids drop, empty live intersection → 400 empty_selection);
title optional from the UI (the picker's cluster label), dated default
stays. chatStore leaves the pipeline deps entirely (labels resolve
store-side in the clusters service).

GET /api/graph/clusters (new): the birth-session groups the graph view
renders — graphSnapshot's clusters verbatim (same ordering, same
MIN_CLUSTER_SIZE presentation filter), mapped to {key, kind
chat|notes, label (null when the naming record is gone — honest,
never guessed), nodeIds, nodeCount}; corrupt/empty graph → empty
list. GraphClusterGroup contract in core/contracts/graph.ts (client-
safe — the dialog imports it).

practice-dialog: session picker → cluster picker (rows show label +
proposition count, notes clusters marked); Create posts the cluster's
nodeIds + label as title + params. 400 empty-selection copy reworded
for the vanished-nodes edge ('Nothing practicable is left in that
cluster — the graph changed.'); 503 deep link unchanged. Preselected
mode gone — the dialog is the only creation entry for now (the graph-
view cluster entry comes later with free-form selection).

Sidebar: session right-click 'Practice this session's propositions'
removed (item + state + dialog wiring); the /practice nav row stays.

Tests: pipeline — chatId/any-provenance tests die, nodeIds selection
pins added (unknown-id filtering, dated-default title, title hint
rides, empty/vanished → EmptySelectionError); render — cluster picker
pins (label+count rows, notes kind, unlabeled cluster muted, empty
graph line), preselected pins removed; 16 render + 13 pipeline.

Docs: architecture.md §7 API+UI paragraph rewritten (graph-domain
ruling, /api/graph/clusters, cluster picker as the only entry, no
404-unknown-chat); README creation-flow line updated.

1130/1130, tsc 0, lint 0, build 0.
This commit is contained in:
amosblomqvist
2026-09-29 19:26:37 +03:00
parent 451caba1b1
commit f39e1b4294
13 changed files with 205 additions and 199 deletions
+1 -1
View File
@@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection. **The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; a creation flow (sidebar → a session, or `/practice`) freezes the selection's propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot. **Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server. **Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
+11
View File
@@ -0,0 +1,11 @@
/**
* GET /api/graph/clusters — the birth-session groups for the practice
* creation picker (owner ruling 2026-09-29: sets are created from graph
* clusters, node ids only). Thin transport; the grouping is the graph
* view's own (server/graph.ts → graphSnapshot's clusters).
*/
import { graphClusters } from "@/src/server";
export async function GET() {
return Response.json({ clusters: graphClusters() }, { headers: { "cache-control": "no-store" } });
}
+74 -79
View File
@@ -1,28 +1,31 @@
"use client"; "use client";
/** /**
* The problem-set creation dialog (practice slice 5, 2026-09-29): the * The problem-set creation dialog (practice slice 5, 2026-09-29;
* three parameter dials + the session picker → POST /api/practice/sets * cluster-picker fix same evening): the three parameter dials + the
* → router.push to the new exam page. In the delete-session dialog's * CLUSTER picker → POST /api/practice/sets → router.push to the new
* shape (portaled z-50, pure body exported for SSR pins). * exam page. In the delete-session dialog's shape (portaled z-50, pure
* body exported for SSR pins).
* *
* TWO OPEN MODES: * GRAPH-DOMAIN, NOT CHAT-DOMAIN (owner ruling 2026-09-29): problem sets
* - generic (the list page's "New problem set"): a session picker — * take NODES. The picker lists the birth-session clusters the graph
* the selection is "everything this session touched"; * view renders (GET /api/graph/clusters); picking one resolves to its
* - preselected (the sidebar's "Practice this session's * member node ids, and the cluster's label rides along as the set's
* propositions"): picker hidden, the session shown locked. * title. This dialog is the ONLY creation entry for now — the graph-view
* cluster entry comes later with the free-form selection UI.
* *
* Honest failures inline: 400 empty_selection → "This session has no * Honest failures inline: 400 empty_selection (the vanished-nodes edge
* propositions yet" (verbatim per brief); 503 (models/key refusal) → * — the cluster's propositions are gone) renders the honest line;
* the server's line + an "Open settings" deep link (the zero-keys * 503 (models/key refusal) renders the server's line + an "Open
* precedent — setup owns the fix, the dialog owns the pointer). * settings" deep link (the zero-keys precedent — setup owns the fix,
* the dialog owns the pointer).
*/ */
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog";
import type { ChatListItem } from "@/src/core"; import type { GraphClusterGroup } from "@/src/core";
/** The three dials — key order matches PracticeParams; anchors are the /** The three dials — key order matches PracticeParams; anchors are the
* honest display (a raw 0..1 number tells the learner nothing). */ * honest display (a raw 0..1 number tells the learner nothing). */
@@ -43,10 +46,9 @@ export interface PracticeFailure {
export function PracticeDialogBody({ export function PracticeDialogBody({
values, values,
onDial, onDial,
chats, clusters,
selectedId, selectedKey,
onSelect, onSelect,
lockedTitle,
busy, busy,
failure, failure,
onOpenSettings, onOpenSettings,
@@ -55,18 +57,16 @@ export function PracticeDialogBody({
}: { }: {
values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>; values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>;
onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void; onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void;
/** Null = preselected mode (picker hidden, `lockedTitle` shown). */ clusters: GraphClusterGroup[];
chats: ChatListItem[] | null; selectedKey: string | null;
selectedId: string | null; onSelect: (key: string) => void;
onSelect: (id: string) => void;
lockedTitle: string | null;
busy: boolean; busy: boolean;
failure: PracticeFailure | null; failure: PracticeFailure | null;
onOpenSettings: () => void; onOpenSettings: () => void;
onClose: () => void; onClose: () => void;
onRun: () => void; onRun: () => void;
}) { }) {
const canRun = !busy && (lockedTitle !== null || selectedId !== null); const canRun = !busy && selectedKey !== null;
return ( return (
<div <div
role="dialog" role="dialog"
@@ -78,37 +78,36 @@ export function PracticeDialogBody({
> >
<h2 className="font-title text-2xl tracking-tight">New problem set</h2> <h2 className="font-title text-2xl tracking-tight">New problem set</h2>
{/* The session: everything it touched (any provenance point). */} {/* The cluster: a birth-session group of the graph, resolved to
node ids on create (graph-domain, owner 2026-09-29). */}
<div className="mt-4 flex flex-col gap-1"> <div className="mt-4 flex flex-col gap-1">
{lockedTitle !== null ? ( <span className="text-xs uppercase tracking-widest text-muted-foreground/70">Cluster</span>
<div data-testid="practice-dialog-locked" className="rounded-lg border bg-muted/40 px-3 py-2 text-sm"> <div className="max-h-40 overflow-y-auto rounded-lg border" data-testid="practice-dialog-clusters">
<span className="text-muted-foreground">From session — </span> {clusters.map((cluster) => (
<span className="font-medium">{lockedTitle}</span> <button
</div> type="button"
) : ( key={cluster.key}
<> data-testid={`practice-cluster-${cluster.key}`}
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Session</span> onClick={() => onSelect(cluster.key)}
<div className="max-h-40 overflow-y-auto rounded-lg border" data-testid="practice-dialog-sessions"> className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
{(chats ?? []).map((chat) => ( selectedKey === cluster.key ? "bg-accent" : ""
<button }`}
type="button" >
key={chat.id} <span className="min-w-0 truncate">
data-testid={`practice-session-${chat.id}`} {cluster.label ?? <span className="text-muted-foreground/60">unlabeled cluster</span>}
onClick={() => onSelect(chat.id)} </span>
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${ <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
selectedId === chat.id ? "bg-accent" : "" {cluster.kind === "notes" ? "notes · " : ""}
}`} {cluster.nodeCount} propositions
> </span>
<span className="truncate">{chat.title}</span> </button>
{selectedId === chat.id && <span className="ml-2 text-xs text-muted-foreground">selected</span>} ))}
</button> {clusters.length === 0 && (
))} <div className="px-3 py-2 text-sm text-muted-foreground">
{(chats ?? []).length === 0 && ( No clusters yet — a cluster appears once a session births 3+ propositions.
<div className="px-3 py-2 text-sm text-muted-foreground">No sessions yet.</div>
)}
</div> </div>
</> )}
)} </div>
</div> </div>
{/* The three dials (SettingSlider's markup, anchors as display). */} {/* The three dials (SettingSlider's markup, anchors as display). */}
@@ -135,7 +134,7 @@ export function PracticeDialogBody({
{failure && ( {failure && (
<div data-testid="practice-dialog-error" className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <div data-testid="practice-dialog-error" className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{failure.code === "empty_selection" ? "This session has no propositions yet" : failure.error} {failure.code === "empty_selection" ? "Nothing practicable is left in that cluster — the graph changed." : failure.error}
{failure.status === 503 && ( {failure.status === 503 && (
<button <button
type="button" type="button"
@@ -172,15 +171,9 @@ export function PracticeDialogBody({
); );
} }
/** The stateful wrapper: fetches the session list (generic mode), runs /** The stateful wrapper: fetches the cluster list, runs the create POST
* the create POST, navigates on 202. Null `preselected` = generic. */ * (nodeIds + the cluster's label as title + params), navigates on 202. */
export function PracticeDialog({ export function PracticeDialog({ onClose }: { onClose: () => void }) {
preselected,
onClose,
}: {
preselected: { chatId: string; title: string } | null;
onClose: () => void;
}) {
const router = useRouter(); const router = useRouter();
const { open: openSettings } = useSettingsDialog(); const { open: openSettings } = useSettingsDialog();
const [values, setValues] = useState<Record<(typeof PRACTICE_DIALS)[number]["key"], number>>({ const [values, setValues] = useState<Record<(typeof PRACTICE_DIALS)[number]["key"], number>>({
@@ -188,27 +181,26 @@ export function PracticeDialog({
cueDistance: 0.5, cueDistance: 0.5,
presentation: 0.5, presentation: 0.5,
}); });
const [chats, setChats] = useState<ChatListItem[] | null>(null); const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<PracticeFailure | null>(null); const [failure, setFailure] = useState<PracticeFailure | null>(null);
// Generic mode: the picker's truth is the chat list (fresh on open). // The picker's truth is the graph's clustering (fresh on open).
useEffect(() => { useEffect(() => {
if (preselected) return;
let alive = true; let alive = true;
fetch("/api/chats", { cache: "no-store" }) fetch("/api/graph/clusters", { cache: "no-store" })
.then((res) => res.json() as Promise<{ chats: ChatListItem[] }>) .then((res) => res.json() as Promise<{ clusters: GraphClusterGroup[] }>)
.then((d) => { .then((d) => {
if (alive) setChats(d.chats); if (alive) setClusters(d.clusters);
}) })
.catch(() => { .catch(() => {
if (alive) setChats([]); if (alive) setClusters([]);
}); });
return () => { return () => {
alive = false; alive = false;
}; };
}, [preselected]); }, []);
// Esc closes (claimed, the delete-session discipline). // Esc closes (claimed, the delete-session discipline).
useEffect(() => { useEffect(() => {
@@ -223,15 +215,19 @@ export function PracticeDialog({
}, [onClose]); }, [onClose]);
const run = useCallback(async () => { const run = useCallback(async () => {
const chatId = preselected?.chatId ?? selectedId; const picked = (clusters ?? []).find((c) => c.key === selectedKey);
if (!chatId || busy) return; if (!picked || busy) return;
setBusy(true); setBusy(true);
setFailure(null); setFailure(null);
try { try {
const res = await fetch("/api/practice/sets", { const res = await fetch("/api/practice/sets", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ chatId, params: values }), body: JSON.stringify({
nodeIds: picked.nodeIds,
params: values,
title: picked.label ?? undefined,
}),
}); });
const body = (await res.json()) as { id?: string; error?: string; code?: string }; const body = (await res.json()) as { id?: string; error?: string; code?: string };
if (!res.ok || !body.id) { if (!res.ok || !body.id) {
@@ -244,7 +240,7 @@ export function PracticeDialog({
setFailure({ status: 0, error: "Create failed — network error. Try again." }); setFailure({ status: 0, error: "Create failed — network error. Try again." });
setBusy(false); setBusy(false);
} }
}, [preselected, selectedId, busy, values, router]); }, [clusters, selectedKey, busy, values, router]);
return createPortal( return createPortal(
<div <div
@@ -256,10 +252,9 @@ export function PracticeDialog({
<PracticeDialogBody <PracticeDialogBody
values={values} values={values}
onDial={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))} onDial={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
chats={preselected ? null : (chats ?? [])} clusters={clusters ?? []}
selectedId={selectedId} selectedKey={selectedKey}
onSelect={setSelectedId} onSelect={setSelectedKey}
lockedTitle={preselected?.title ?? null}
busy={busy} busy={busy}
failure={failure} failure={failure}
onOpenSettings={() => openSettings()} onOpenSettings={() => openSettings()}
+1 -1
View File
@@ -215,7 +215,7 @@ export function PracticeListPage({ initialSets }: { initialSets: ProblemSet[] })
onDelete={(set) => setDeleting(set)} onDelete={(set) => setDeleting(set)}
onNew={() => setCreating(true)} onNew={() => setCreating(true)}
/> />
{creating && <PracticeDialog preselected={null} onClose={() => setCreating(false)} />} {creating && <PracticeDialog onClose={() => setCreating(false)} />}
{deleting && {deleting &&
createPortal( createPortal(
<div <div
-24
View File
@@ -9,7 +9,6 @@ import type { ChatDeleteMode } from "@/src/core";
import { GraduationCapIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react"; import { GraduationCapIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock"; import { ActivitySection } from "@/components/activity-dock";
import { CoverageMenuBlock } from "@/components/extraction-coverage"; import { CoverageMenuBlock } from "@/components/extraction-coverage";
import { PracticeDialog } from "@/components/practice-dialog";
import { LhMark } from "@/components/lh-mark"; import { LhMark } from "@/components/lh-mark";
import { BrailleSpinner } from "@/components/braille-spinner"; import { BrailleSpinner } from "@/components/braille-spinner";
import { KeyboardShortcuts } from "@/components/keyboard-shortcuts"; import { KeyboardShortcuts } from "@/components/keyboard-shortcuts";
@@ -398,9 +397,6 @@ function SessionsSidebar() {
const ctxAnchorRef = useRef<HTMLElement | null>(null); const ctxAnchorRef = useRef<HTMLElement | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null); const [renamingId, setRenamingId] = useState<string | null>(null);
const [deleting, setDeleting] = useState<ChatListItem | null>(null); const [deleting, setDeleting] = useState<ChatListItem | null>(null);
// Practice slice 5 (2026-09-29): the right-click "Practice this session's
// propositions" opens the creation dialog PRESELECTED on that session.
const [practiceChat, setPracticeChat] = useState<{ chatId: string; title: string } | null>(null);
const openContext = (e: React.MouseEvent<HTMLButtonElement>, chat: ChatListItem) => { const openContext = (e: React.MouseEvent<HTMLButtonElement>, chat: ChatListItem) => {
e.preventDefault(); // suppress the native menu e.preventDefault(); // suppress the native menu
@@ -736,21 +732,6 @@ function SessionsSidebar() {
Delete… Delete…
</button> </button>
<div className="my-0.5 border-t border-border/60" aria-hidden="true" /> <div className="my-0.5 border-t border-border/60" aria-hidden="true" />
{/* Practice (slice 5): the session's propositions → a problem set.
Sits ABOVE the coverage block — an action like Extract, not
management like Rename/Delete. */}
<button
type="button"
data-testid="ctx-practice"
onClick={() => {
const target = chats?.find((c) => c.id === ctxChatId);
if (target) setPracticeChat({ chatId: target.id, title: target.title });
setCtxChatId(null);
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
>
Practice this session&apos;s propositions
</button>
<CoverageMenuBlock chatId={ctxChatId} /> <CoverageMenuBlock chatId={ctxChatId} />
</MenuShell> </MenuShell>
)} )}
@@ -762,11 +743,6 @@ function SessionsSidebar() {
onClose={() => setDeleting(null)} onClose={() => setDeleting(null)}
onDeleted={onDeleted} onDeleted={onDeleted}
/> />
{/* The preselected practice dialog (slice 5) — portaled by the
component itself, here for state locality like the delete dialog. */}
{practiceChat && (
<PracticeDialog preselected={practiceChat} onClose={() => setPracticeChat(null)} />
)}
</aside> </aside>
); );
} }
+1 -1
View File
@@ -380,7 +380,7 @@ Practice turns the graph into problem sets the learner reasons through out loud.
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`), one row per set-phase (`setId:generation`, `setId:grading`), feeding the activity dock (GraduationCap rows; abort is set-scoped and idempotent; a boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — the job runner's crash-honesty at set grain). Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`. **The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`), one row per set-phase (`setId:generation`, `setId:grading`), feeding the activity dock (GraduationCap rows; abort is set-scoped and idempotent; a boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — the job runner's crash-honesty at set grain). Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
**API + UI.** `POST /api/practice/sets` (create — `{chatId, params}` or `{nodeIds, params}`; 202 `{id}`, generation fire-and-forget; 400 empty selection/bad params, 404 unknown chat, 503 model/key refusal) · `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and a session picker) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entries: the sidebar's Practice nav row and the session right-click "Practice this session's propositions". **API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session clusters the graph view renders (`graphSnapshot`'s clusters verbatim — same ordering, same MIN_CLUSTER_SIZE presentation filter), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
**Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set). **Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set).
+8 -28
View File
@@ -34,7 +34,7 @@
* flight is rejected (single live grading run per set). * flight is rejected (single live grading run per set).
*/ */
import type { ChatStore, GraphStore, PracticeStore } from "../../core/ports.ts"; import type { GraphStore, PracticeStore } from "../../core/ports.ts";
import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts"; import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
import { chainsForSelection } from "../../core/practice.ts"; import { chainsForSelection } from "../../core/practice.ts";
import { import {
@@ -55,14 +55,6 @@ export class EmptySelectionError extends Error {
} }
} }
/** chatId path: the chat record is gone (404). */
export class UnknownChatError extends Error {
constructor(chatId: string) {
super(`no chat ${chatId}`);
this.name = "UnknownChatError";
}
}
/** Set id unknown (404). */ /** Set id unknown (404). */
export class PracticeSetNotFound extends Error { export class PracticeSetNotFound extends Error {
constructor(setId: string) { constructor(setId: string) {
@@ -267,8 +259,6 @@ function dedupInput(practiceStore: PracticeStore, selected: ReadonlySet<string>)
export interface PracticePipelineDeps extends PracticeModels { export interface PracticePipelineDeps extends PracticeModels {
graphStore: GraphStore; graphStore: GraphStore;
practiceStore: PracticeStore; practiceStore: PracticeStore;
/** Titles for the chat-born path (and existence: unknown chat → 404). */
chatStore: Pick<ChatStore, "getChat" | "listChats">;
} }
export function createPracticePipeline(deps: PracticePipelineDeps) { export function createPracticePipeline(deps: PracticePipelineDeps) {
@@ -428,27 +418,17 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
* (models/key) fire BEFORE anything is created. * (models/key) fire BEFORE anything is created.
*/ */
async function createProblemSet( async function createProblemSet(
selector: { chatId: string } | { nodeIds: string[] }, nodeIds: string[],
params: ProblemSet["params"], params: ProblemSet["params"],
titleHint?: string, titleHint?: string,
): Promise<{ set: ProblemSet; done: Promise<void> }> { ): Promise<{ set: ProblemSet; done: Promise<void> }> {
agents.refuseIfStagesUnready(); agents.refuseIfStagesUnready();
// Resolve the selection: chatId = any-provenance membership (owner: // Graph-domain selection (owner ruling 2026-09-29: problem sets
// a node belongs to a session when ANY provenance point names it — // take NODES — the UI resolves a picked cluster to its member ids;
// birth-session clustering deliberately NOT used here); nodeIds = // unknown ids simply drop, an empty live intersection refuses).
// the raw list, unknown ids simply drop. const wanted = new Set(nodeIds);
let sourceTitle: string | undefined; const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
let selected;
if ("chatId" in selector) {
const chat = deps.chatStore.getChat(selector.chatId);
if (!chat) throw new UnknownChatError(selector.chatId);
sourceTitle = chat.title;
selected = deps.graphStore.readNodes().filter((n) => n.provenance.some((pt) => pt.chatId === selector.chatId));
} else {
const wanted = new Set(selector.nodeIds);
selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
}
const selectedIds = new Set(selected.map((n) => n.id)); const selectedIds = new Set(selected.map((n) => n.id));
const edges = deps const edges = deps
.graphStore.readEdges() .graphStore.readEdges()
@@ -457,7 +437,7 @@ export function createPracticePipeline(deps: PracticePipelineDeps) {
if (chains.length === 0) throw new EmptySelectionError(); if (chains.length === 0) throw new EmptySelectionError();
const propositions: PropositionSnapshot = {}; const propositions: PropositionSnapshot = {};
for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement }; for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
const title = titleHint ?? sourceTitle ?? `Practice — ${new Date().toISOString().slice(0, 10)}`; const title = titleHint ?? `Practice — ${new Date().toISOString().slice(0, 10)}`;
const set = deps.practiceStore.createSet({ const set = deps.practiceStore.createSet({
title, title,
+19
View File
@@ -258,6 +258,25 @@ export interface Cluster {
members: string[]; members: string[];
} }
/** One birth-session cluster as the practice picker consumes it (owner
* ruling 2026-09-29: problem sets take NODES — the UI resolves a picked
* cluster to its member ids). Same grouping the graph view renders
* (birthSessionPartition semantics, MIN_CLUSTER_SIZE presentation
* filter included); `label` is null when the naming record is gone —
* honest, never guessed (the graph view renders those unlabeled). */
export interface GraphClusterGroup {
/** The birth session's id — a chatId or a notes source id. */
key: string;
/** What kind of record names the group. */
kind: "chat" | "notes";
/** Session title / source file path; null when the record is gone. */
label: string | null;
/** Member node ids, sorted (the partition's members). */
nodeIds: string[];
/** === nodeIds.length, denormalized for the picker row. */
nodeCount: number;
}
export interface GraphSnapshot { export interface GraphSnapshot {
nodes: GraphNode[]; nodes: GraphNode[];
edges: GraphEdge[]; edges: GraphEdge[];
-1
View File
@@ -103,7 +103,6 @@ export const extraction = createExtractionPipeline({
export const practice = createPracticePipeline({ export const practice = createPracticePipeline({
graphStore: stores.graphStore, graphStore: stores.graphStore,
practiceStore: stores.practiceStore, practiceStore: stores.practiceStore,
chatStore: stores.chatStore,
// BYOK (owner, 2026-09-22): the practice stages resolve the chain — // BYOK (owner, 2026-09-22): the practice stages resolve the chain —
// role override → slot → NOTHING — read FRESH per stage call; an empty // role override → slot → NOTHING — read FRESH per stage call; an empty
// chain flows through as undefined and the pipeline refuses the run // chain flows through as undefined and the pipeline refuses the run
+19 -1
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs"; import * as fs from "node:fs";
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts"; import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
import type { GraphSnapshot } from "../core/contracts.ts"; import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts"; import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts"; import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts"; import { graphSnapshot } from "../core/graph.ts";
@@ -23,6 +23,24 @@ export function graphView(): GraphSnapshot {
return graphSnapshot(stores); return graphSnapshot(stores);
} }
/** GET /api/graph/clusters — the birth-session groups the graph view
* renders, resolved to node ids for the practice picker (owner ruling
* 2026-09-29: cluster→nodeIds resolves in the UI; the API stays
* graph-domain). Reuses the snapshot's clustering verbatim — same
* ordering (size desc, ties by id) and the same MIN_CLUSTER_SIZE
* presentation filter the canvas honors. A corrupt/empty graph is an
* empty list. */
export function graphClusters(): GraphClusterGroup[] {
const snap = graphSnapshot(stores);
return snap.clusters.map((cluster) => ({
key: cluster.id,
kind: cluster.id in snap.sessions ? "chat" : "notes",
label: cluster.label,
nodeIds: cluster.members,
nodeCount: cluster.members.length,
}));
}
/** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost /** The graph view's cost surface (GET /api/graph/costs — the 2026-09 cost
* pill): fold every usage channel into stage totals + recent runs. A pure * pill): fold every usage channel into stage totals + recent runs. A pure
* read over the chat records (chat-front-door runs), the notes sources * read over the chat records (chat-front-door runs), the notes sources
+17 -16
View File
@@ -17,7 +17,6 @@ import {
EmptySelectionError, EmptySelectionError,
PracticeSetNotFound, PracticeSetNotFound,
PracticeSetNotReady, PracticeSetNotReady,
UnknownChatError,
} from "../agent/subagents/practice-pipeline.ts"; } from "../agent/subagents/practice-pipeline.ts";
import { import {
ModelsNotConfiguredError, ModelsNotConfiguredError,
@@ -72,7 +71,6 @@ export function practiceErrorResponse(err: unknown): Response {
); );
} }
if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 }); if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { status: 400 });
if (err instanceof UnknownChatError) return Response.json({ error: err.message, code: "unknown_chat" }, { status: 404 });
if (err instanceof BadAnswersError) return Response.json({ error: err.message, code: "bad_answers" }, { status: 400 }); if (err instanceof BadAnswersError) return Response.json({ error: err.message, code: "bad_answers" }, { status: 400 });
if (err instanceof PracticeSetNotFound) return Response.json({ error: err.message, code: "not_found" }, { status: 404 }); if (err instanceof PracticeSetNotFound) return Response.json({ error: err.message, code: "not_found" }, { status: 404 });
if (err instanceof PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 }); if (err instanceof PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 });
@@ -81,7 +79,10 @@ export function practiceErrorResponse(err: unknown): Response {
} }
/** POST /api/practice/sets — 202 {id}; the set generates in the /** POST /api/practice/sets — 202 {id}; the set generates in the
* background (fire-and-forget pipeline). */ * background (fire-and-forget pipeline). Body is {nodeIds, params,
* title?} ONLY (owner ruling 2026-09-29: problem sets take NODES —
* graph objects; the UI resolves a picked cluster to its member ids,
* and may pass the cluster label as the title). */
export async function createPracticeSetResponse(body: unknown): Promise<Response> { export async function createPracticeSetResponse(body: unknown): Promise<Response> {
const b = (body ?? {}) as Record<string, unknown>; const b = (body ?? {}) as Record<string, unknown>;
const params = parseParams(b.params); const params = parseParams(b.params);
@@ -91,23 +92,23 @@ export async function createPracticeSetResponse(body: unknown): Promise<Response
{ status: 400 }, { status: 400 },
); );
} }
const titleHint =
typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
if (
!Array.isArray(b.nodeIds) ||
!b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))
) {
return Response.json({ error: "Body must be {nodeIds, params, title?}" }, { status: 400 });
}
if (b.nodeIds.length === 0) {
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
}
try { try {
if (typeof b.chatId === "string" && b.chatId !== "") { const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint);
if (!ID_OK.test(b.chatId)) return Response.json({ error: "Invalid chatId" }, { status: 400 }); return Response.json({ id: set.id }, { status: 202 });
const { set } = await practice.createProblemSet({ chatId: b.chatId }, params);
return Response.json({ id: set.id }, { status: 202 });
}
if (Array.isArray(b.nodeIds) && b.nodeIds.every((id): id is string => typeof id === "string" && ID_OK.test(id))) {
if (b.nodeIds.length === 0) {
return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
}
const { set } = await practice.createProblemSet({ nodeIds: b.nodeIds }, params);
return Response.json({ id: set.id }, { status: 202 });
}
} catch (err) { } catch (err) {
return practiceErrorResponse(err); return practiceErrorResponse(err);
} }
return Response.json({ error: 'Body must be {chatId, params} or {nodeIds, params}' }, { status: 400 });
} }
/** POST /api/practice/sets/[id]/answers — 202; grades land in the /** POST /api/practice/sets/[id]/answers — 202; grades land in the
+21 -20
View File
@@ -20,7 +20,6 @@ import {
createPracticePipeline, createPracticePipeline,
practiceStatuses, practiceStatuses,
EmptySelectionError, EmptySelectionError,
UnknownChatError,
PracticeSetNotFound, PracticeSetNotFound,
PracticeSetNotReady, PracticeSetNotReady,
BadAnswersError, BadAnswersError,
@@ -230,7 +229,6 @@ function fixture(partial: Partial<Parameters<typeof createPracticePipeline>[0]>
const deps = { const deps = {
graphStore: diamondGraph(), graphStore: diamondGraph(),
practiceStore: practice.store, practiceStore: practice.store,
chatStore: memoryChats(),
...partial, ...partial,
}; };
return { practice, deps, make: () => createPracticePipeline(deps) }; return { practice, deps, make: () => createPracticePipeline(deps) };
@@ -273,7 +271,7 @@ describe("createProblemSet (generation)", () => {
grader: echoGrader(1), grader: echoGrader(1),
}); });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "c", "d", "e"] }, PARAMS); const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
assert.match(set.id, /^ps_/); assert.match(set.id, /^ps_/);
assert.equal(set.status, "partitioning"); assert.equal(set.status, "partitioning");
@@ -307,7 +305,7 @@ describe("createProblemSet (generation)", () => {
it("per-problem failure isolation: one generator fails, the set still completes", async () => { it("per-problem failure isolation: one generator fails, the set still completes", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "e"] }, PARAMS); // chains b + e const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS); // chains b + e
await done; await done;
const final = f.practice.store.getSet(set.id)!; const final = f.practice.store.getSet(set.id)!;
assert.equal(final.status, "ready"); assert.equal(final.status, "ready");
@@ -347,7 +345,7 @@ describe("createProblemSet (generation)", () => {
const deps = { ...f.deps, problemGenerator: fauxEngine(Array.from({ length: 5 }, () => factory)), grader: echoGrader(1) }; const deps = { ...f.deps, problemGenerator: fauxEngine(Array.from({ length: 5 }, () => factory)), grader: echoGrader(1) };
const pipeline = createPracticePipeline(deps); const pipeline = createPracticePipeline(deps);
const { done } = await pipeline.createProblemSet({ nodeIds: nodes.map((n) => n.id) }, PARAMS); const { done } = await pipeline.createProblemSet(nodes.map((n) => n.id), PARAMS);
await until(() => active === 3); await until(() => active === 3);
assert.equal(max, 3, "exactly three generators in flight"); assert.equal(max, 3, "exactly three generators in flight");
release(); release();
@@ -358,7 +356,7 @@ describe("createProblemSet (generation)", () => {
it("abort mid-generation: controllers die, everything generating is 'Aborted'", async () => { it("abort mid-generation: controllers die, everything generating is 'Aborted'", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }, { chains: ["d"] }, { chains: ["e"] }]), problemGenerator: hangingModel(4), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }, { chains: ["d"] }, { chains: ["e"] }]), problemGenerator: hangingModel(4), grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "c", "d", "e"] }, PARAMS); const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
await until(() => f.practice.store.getSet(set.id)?.status === "generating"); await until(() => f.practice.store.getSet(set.id)?.status === "generating");
pipeline.abortPractice(set.id); pipeline.abortPractice(set.id);
@@ -390,7 +388,7 @@ describe("createProblemSet (generation)", () => {
]); ]);
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: gen, grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: gen, grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["b"] }, PARAMS); const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
await until(() => practiceStatuses().length === 1 && practiceStatuses()[0]!.phase === "generating"); await until(() => practiceStatuses().length === 1 && practiceStatuses()[0]!.phase === "generating");
seen = practiceStatuses()[0]!; seen = practiceStatuses()[0]!;
assert.equal(seen.setId, set.id); assert.equal(seen.setId, set.id);
@@ -406,7 +404,7 @@ describe("createProblemSet (generation)", () => {
it("refusal gate fires BEFORE anything is created", async () => { it("refusal gate fires BEFORE anything is created", async () => {
const f = fixture({}); // no engines, no engineFor const f = fixture({}); // no engines, no engineFor
const pipeline = f.make(); const pipeline = f.make();
await assert.rejects(pipeline.createProblemSet({ chatId: "chat_1" }, PARAMS), ModelsNotConfiguredError); await assert.rejects(pipeline.createProblemSet(["a", "b"], PARAMS), ModelsNotConfiguredError);
assert.equal(f.practice.sets.size, 0, "no set persisted on refusal"); assert.equal(f.practice.sets.size, 0, "no set persisted on refusal");
}); });
}); });
@@ -414,22 +412,25 @@ describe("createProblemSet (generation)", () => {
// ─── selection + typed errors ──────────────────────────────────────────────── // ─── selection + typed errors ────────────────────────────────────────────────
describe("selection resolution + typed errors", () => { describe("selection resolution + typed errors", () => {
it("chatId: every node with ANY provenance point in the chat", async () => { it("nodeIds: the raw list filtered to live nodes (unknown ids drop)", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b", "c", "d", "e"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b", "c", "d", "e"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ chatId: "chat_1" }, PARAMS); const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e", "n_ghost"], PARAMS);
await done; await done;
const final = f.practice.store.getSet(set.id)!; const final = f.practice.store.getSet(set.id)!;
assert.deepEqual(final.nodeIds.sort(), ["a", "b", "c", "d", "e"]); assert.deepEqual(final.nodeIds.sort(), ["a", "b", "c", "d", "e"], "unknown ids silently drop");
assert.deepEqual(final.chains.map((c) => c.id).sort(), ["b", "c", "d", "e"]); assert.deepEqual(final.chains.map((c) => c.id).sort(), ["b", "c", "d", "e"]);
assert.ok(final.title.startsWith("Practice — "), "no title hint → the dated default");
}); });
it("unknown chat → UnknownChatError; empty selection → EmptySelectionError", async () => { it("empty selection / vanished nodes → EmptySelectionError; title hint rides", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
await assert.rejects(pipeline.createProblemSet({ chatId: "chat_x" }, PARAMS), UnknownChatError); await assert.rejects(pipeline.createProblemSet([], PARAMS), EmptySelectionError);
await assert.rejects(pipeline.createProblemSet({ nodeIds: [] }, PARAMS), EmptySelectionError); await assert.rejects(pipeline.createProblemSet(["n_ghost"], PARAMS), EmptySelectionError);
await assert.rejects(pipeline.createProblemSet({ nodeIds: ["n_ghost"] }, PARAMS), EmptySelectionError); const { set, done } = await pipeline.createProblemSet(["a", "b"], PARAMS, "Ethics cluster");
await done;
assert.equal(f.practice.store.getSet(set.id)!.title, "Ethics cluster");
}); });
}); });
@@ -439,7 +440,7 @@ describe("submitAnswers (grading)", () => {
async function readySet() { async function readySet() {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(2), grader: echoGrader(2) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(2), grader: echoGrader(2) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["a", "b", "e"] }, PARAMS); const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS);
await done; await done;
return { f, pipeline, set: f.practice.store.getSet(set.id)! }; return { f, pipeline, set: f.practice.store.getSet(set.id)! };
} }
@@ -472,13 +473,13 @@ describe("submitAnswers (grading)", () => {
const f2 = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f2 = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const p2 = f2.make(); const p2 = f2.make();
const { set: s2 } = await p2.createProblemSet({ nodeIds: ["b"] }, PARAMS); // stuck partitioning const { set: s2 } = await p2.createProblemSet(["b"], PARAMS); // stuck partitioning
await assert.rejects(p2.submitAnswers(s2.id, [{ problemId: "pp_x", text: "t" }]), PracticeSetNotReady); await assert.rejects(p2.submitAnswers(s2.id, [{ problemId: "pp_x", text: "t" }]), PracticeSetNotReady);
p2.abortPractice(s2.id); p2.abortPractice(s2.id);
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pl = f.make(); const pl = f.make();
const { set: s3, done } = await pl.createProblemSet({ nodeIds: ["b"] }, PARAMS); const { set: s3, done } = await pl.createProblemSet(["b"], PARAMS);
await done; await done;
await assert.rejects(pl.submitAnswers(s3.id, [{ problemId: "pp_ghost", text: "t" }]), BadAnswersError); await assert.rejects(pl.submitAnswers(s3.id, [{ problemId: "pp_ghost", text: "t" }]), BadAnswersError);
const settled = f.practice.store.getSet(s3.id)!; // the post-done record (s3 itself is the creation stub) const settled = f.practice.store.getSet(s3.id)!; // the post-done record (s3 itself is the creation stub)
@@ -510,7 +511,7 @@ describe("boot sweep + delete", () => {
it("a LIVE generating set (registry row) is NOT swept by a second instance", async () => { it("a LIVE generating set (registry row) is NOT swept by a second instance", async () => {
const f = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const a = f.make(); const a = f.make();
const { set, done } = await a.createProblemSet({ nodeIds: ["b"] }, PARAMS); // hangs partitioning const { set, done } = await a.createProblemSet(["b"], PARAMS); // hangs partitioning
f.make(); // second pipeline instance, same process → same registry f.make(); // second pipeline instance, same process → same registry
assert.equal(f.practice.store.getSet(set.id)!.status, "partitioning"); assert.equal(f.practice.store.getSet(set.id)!.status, "partitioning");
a.abortPractice(set.id); a.abortPractice(set.id);
@@ -520,7 +521,7 @@ describe("boot sweep + delete", () => {
it("delete: aborts live jobs first, drops the record; unknown → false", async () => { it("delete: aborts live jobs first, drops the record; unknown → false", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) }); const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
const pipeline = f.make(); const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet({ nodeIds: ["b"] }, PARAMS); const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
await done; await done;
assert.equal(await pipeline.deleteProblemSet(set.id), true); assert.equal(await pipeline.deleteProblemSet(set.id), true);
assert.equal(f.practice.store.getSet(set.id), null); assert.equal(f.practice.store.getSet(set.id), null);
+33 -27
View File
@@ -14,12 +14,14 @@ import {
PracticeDialogBody, PracticeDialogBody,
type PracticeFailure, type PracticeFailure,
} from "../components/practice-dialog.tsx"; } from "../components/practice-dialog.tsx";
import type { GraphClusterGroup } from "../src/core";
import { PracticeExamBody } from "../components/practice-exam.tsx"; import { PracticeExamBody } from "../components/practice-exam.tsx";
import type { ChatListItem, Problem, ProblemSet } from "../src/core"; import type { Problem, ProblemSet } from "../src/core";
/** /**
* The practice UI surfaces (slice 5, 2026-09-29): creation dialog (dials * The practice UI surfaces (slice 5, 2026-09-29; cluster-picker fix the
* + session picker + preselected lock), list page (rows, delete flow), * same evening — graph-domain, not chat-domain): creation dialog (dials
* + cluster picker), list page (rows, delete flow),
* exam page states (generating stubs — propositions never surface — * exam page states (generating stubs — propositions never surface —
* ready + answerable, graded reveal + lock). The pure bodies are pinned * ready + answerable, graded reveal + lock). The pure bodies are pinned
* here (the render-suite discipline); the live fetch chains (polling, * here (the render-suite discipline); the live fetch chains (polling,
@@ -36,10 +38,11 @@ const dialValues = {
presentation: 0.3, presentation: 0.3,
}; };
function chats(): ChatListItem[] { function clusters(): GraphClusterGroup[] {
return [ return [
{ id: "chat_1", title: "Ethics lesson", updatedAt: "2026-09-29T10:00:00.000Z", createdAt: "2026-09-29T09:00:00.000Z", busy: false } as ChatListItem, { key: "chat_1", kind: "chat", label: "Ethics lesson", nodeIds: ["n1", "n2", "n3", "n4"], nodeCount: 4 },
{ id: "chat_2", title: "Physics", updatedAt: "2026-09-28T10:00:00.000Z", createdAt: "2026-09-28T09:00:00.000Z", busy: false } as ChatListItem, { key: "src_ab12", kind: "notes", label: "~/notes/physics.md", nodeIds: ["n5", "n6", "n7"], nodeCount: 3 },
{ key: "chat_9", kind: "chat", label: null, nodeIds: ["n8", "n9", "n10"], nodeCount: 3 },
]; ];
} }
@@ -81,14 +84,13 @@ function set(over: Partial<ProblemSet> = {}): ProblemSet {
// ─── the creation dialog ───────────────────────────────────────────────────── // ─── the creation dialog ─────────────────────────────────────────────────────
describe("PracticeDialogBody — creation", () => { describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = { const base = {
values: dialValues, values: dialValues,
onDial: () => {}, onDial: () => {},
chats: chats(), clusters: clusters(),
selectedId: null, selectedKey: null,
onSelect: () => {}, onSelect: () => {},
lockedTitle: null,
busy: false, busy: false,
failure: null as PracticeFailure | null, failure: null as PracticeFailure | null,
onOpenSettings: () => {}, onOpenSettings: () => {},
@@ -105,37 +107,41 @@ describe("PracticeDialogBody — creation", () => {
} }
}); });
it("session picker: titled rows, selection lights up, Create gated until picked", () => { it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />); const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
assert.ok(html.includes('data-testid="practice-dialog-sessions"')); assert.ok(html.includes('data-testid="practice-dialog-clusters"'));
assert.ok(html.includes('data-testid="practice-cluster-chat_1"'));
assert.ok(html.includes("Ethics lesson")); assert.ok(html.includes("Ethics lesson"));
assert.ok(html.includes('disabled=""'), "no session picked — Create disabled"); assert.match(html, /notes · <!-- -->3 propositions|notes · 3 propositions/);
assert.ok(html.includes("4 propositions"), "chat cluster shows its count");
assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled");
const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedId="chat_2" />); const picked = renderToStaticMarkup(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
assert.ok(picked.includes('data-testid="practice-session-chat_2"')); assert.ok(picked.includes("~/notes/physics.md"));
assert.match(picked, /Physics<\/span>.*selected/s);
}); });
it("preselected mode: picker hidden, the session locked", () => { it("an unlabeled cluster (naming record gone) renders muted, never guessed", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
<PracticeDialogBody {...base} chats={null} lockedTitle="Ethics lesson" />, assert.ok(html.includes("unlabeled cluster"));
); assert.ok(html.includes('data-testid="practice-cluster-chat_9"'));
assert.ok(html.includes('data-testid="practice-dialog-locked"'));
assert.ok(!html.includes('data-testid="practice-dialog-sessions"'));
assert.ok(!html.includes('disabled=""'), "preselected — Create enabled");
}); });
it("empty selection renders the honest inline line", () => { it("an empty graph is an honest empty list", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} clusters={[]} />);
assert.ok(html.includes("No clusters yet"));
});
it("empty selection (vanished nodes) renders the honest inline line", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PracticeDialogBody {...base} failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />, <PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />,
); );
assert.ok(html.includes('data-testid="practice-dialog-error"')); assert.ok(html.includes('data-testid="practice-dialog-error"'));
assert.ok(html.includes("This session has no propositions yet")); assert.ok(html.includes("Nothing practicable is left in that cluster"));
}); });
it("503 renders the server's line + the settings deep link", () => { it("503 renders the server's line + the settings deep link", () => {
const html = renderToStaticMarkup( const html = renderToStaticMarkup(
<PracticeDialogBody {...base} lockedTitle="Ethics lesson" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />, <PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />,
); );
assert.ok(html.includes("No model configured")); assert.ok(html.includes("No model configured"));
assert.ok(html.includes('data-testid="practice-dialog-open-settings"')); assert.ok(html.includes('data-testid="practice-dialog-open-settings"'));