diff --git a/app/api/activity/abort/route.ts b/app/api/activity/abort/route.ts
index b6a348a..2f8eccb 100644
--- a/app/api/activity/abort/route.ts
+++ b/app/api/activity/abort/route.ts
@@ -17,7 +17,7 @@
* Extraction abort is idempotent by design (aborting with nothing in
* flight is success — the blob is gone either way) -> 200.
*/
-import { abortExtraction, abortJob, abortTurn, practice } from "@/src/server";
+import { abortExtraction, abortJob, abortTurn } from "@/src/server";
export async function POST(req: Request) {
let body: unknown;
@@ -27,11 +27,9 @@ export async function POST(req: Request) {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { kind, id } = (body ?? {}) as { kind?: string; id?: string };
- if ((kind !== "job" && kind !== "extraction" && kind !== "turn" && kind !== "practice") || typeof id !== "string" || id === "") {
- return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn"|"practice", id: string}' }, { status: 400 });
+ if ((kind !== "job" && kind !== "extraction" && kind !== "turn") || typeof id !== "string" || id === "") {
+ return Response.json({ error: 'Body must be {kind: "job"|"extraction"|"turn", id: string}' }, { status: 400 });
}
- // Practice row keys carry ":" (setId:gen:problemId) — the charset
- // admits it for every kind (job/turn/extraction ids never use it).
if (!/^[A-Za-z0-9:_-]+$/.test(id)) {
return Response.json({ error: "Invalid id" }, { status: 400 });
}
@@ -51,14 +49,6 @@ export async function POST(req: Request) {
}
// kind === "extraction": true cancellation; idempotent (nothing in
// flight is success — same dock outcome)
- if (kind === "practice") {
- // practice restructure (owner, 2026-09-29): id is the ROW key —
- // abort routes by shape (a gen row kills THAT problem only; the
- // partition row ends the run; a grade row drops that grade).
- // Idempotent like extraction (nothing in flight is success).
- practice.abortPracticeRow(id);
- return Response.json({ ok: true });
- }
abortExtraction(id);
return Response.json({ ok: true });
}
diff --git a/app/api/graph/clusters/route.ts b/app/api/graph/clusters/route.ts
deleted file mode 100644
index 8669b13..0000000
--- a/app/api/graph/clusters/route.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * 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" } });
-}
diff --git a/app/api/practice/sets/[id]/answers/route.ts b/app/api/practice/sets/[id]/answers/route.ts
deleted file mode 100644
index 2e54636..0000000
--- a/app/api/practice/sets/[id]/answers/route.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-/**
- * POST /api/practice/sets/[id]/answers — submit answers for grading
- * (202; one grading job per answered problem runs in the background).
- * Body: {answers: [{problemId, text}]}. Set must be ready; answers must
- * be non-empty text on known, not-yet-answered problems (400/409 via the
- * service's typed-error mapping). ONE ATTEMPT (owner 2026-09-29): once a
- * problem carries an answer it is done — re-submission is refused.
- */
-import { submitPracticeAnswersResponse } from "@/src/server";
-
-export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
- }
- return submitPracticeAnswersResponse(id, body);
-}
diff --git a/app/api/practice/sets/[id]/route.ts b/app/api/practice/sets/[id]/route.ts
deleted file mode 100644
index 54c4e4c..0000000
--- a/app/api/practice/sets/[id]/route.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * GET /api/practice/sets/[id] — one problem set (the exam page polls
- * this while status ≠ ready; graph-view's reveal-poll idiom).
- * DELETE — abort the set's live jobs, then drop the record (the unified
- * delete doctrine: no orphans, no zombies). 404 when never existed.
- */
-import { getPracticeSet, practice, practiceErrorResponse } from "@/src/server";
-
-export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- try {
- const set = getPracticeSet(id);
- if (!set) return Response.json({ error: "no practice set", code: "not_found" }, { status: 404 });
- return Response.json({ set });
- } catch (err) {
- return practiceErrorResponse(err);
- }
-}
-
-export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- try {
- const existed = await practice.deleteProblemSet(id);
- if (!existed) return Response.json({ error: "no practice set", code: "not_found" }, { status: 404 });
- return Response.json({ ok: true });
- } catch (err) {
- return practiceErrorResponse(err);
- }
-}
diff --git a/app/api/practice/sets/route.ts b/app/api/practice/sets/route.ts
deleted file mode 100644
index 8985da4..0000000
--- a/app/api/practice/sets/route.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-/**
- * POST /api/practice/sets — create a problem set (202 {id}; generation
- * runs in the background — the fire-and-forget pipeline). Body:
- * {nodeIds, params, title?, note?} — a node selection, the cluster's
- * label, and the learner's optional
- * free-form note
- * Validation + typed-error mapping live in the service (server/practice).
- * GET — the set list (createdAt desc, the store's order).
- */
-import { createPracticeSetResponse, listPracticeSets, practiceErrorResponse } from "@/src/server";
-
-export async function POST(req: Request) {
- let body: unknown;
- try {
- body = await req.json();
- } catch {
- return Response.json({ error: "Invalid JSON body" }, { status: 400 });
- }
- return createPracticeSetResponse(body);
-}
-
-export async function GET() {
- try {
- return Response.json({ sets: listPracticeSets() });
- } catch (err) {
- return practiceErrorResponse(err);
- }
-}
diff --git a/app/practice/[id]/page.tsx b/app/practice/[id]/page.tsx
deleted file mode 100644
index fb3aee6..0000000
--- a/app/practice/[id]/page.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * One exam page (slice 5): the set server-prefetched, notFound() on an
- * unknown id; the client body owns polling (generation + grading),
- * answering, and the reveal.
- */
-import { notFound } from "next/navigation";
-import { getPracticeSet } from "@/src/server";
-import { PracticeExam } from "@/components/practice-exam";
-
-export const dynamic = "force-dynamic";
-
-export default async function Page({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const set = getPracticeSet(id);
- if (!set) notFound();
- return ;
-}
diff --git a/app/practice/page.tsx b/app/practice/page.tsx
deleted file mode 100644
index 8a41512..0000000
--- a/app/practice/page.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-/**
- * The practice list page (slice 5): the set list, server-prefetched
- * (force-dynamic — a static list would go stale between requests), the
- * client body owns polling + dialogs. Reads through the server barrel
- * only (app/ never touches src/store directly).
- */
-import { listPracticeSets } from "@/src/server";
-import { PracticeListPage } from "@/components/practice-list";
-
-export const dynamic = "force-dynamic";
-
-export default function Page() {
- return ;
-}
diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx
index 5e9273f..2a8bf34 100644
--- a/components/activity-dock.tsx
+++ b/components/activity-dock.tsx
@@ -1,7 +1,7 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
-import { ActivityIcon, BotIcon, FileTextIcon, GraduationCapIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
+import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
@@ -65,24 +65,9 @@ export interface FeedExtraction {
startedAt: number;
}
-/** A live practice row — per-problem (owner ruling, 2026-09-29),
- * matching the extraction per-chunk pattern. id is the ROW KEY
- * (setId:partition | setId:gen:pid | setId:grade:pid) — the abort
- * seam is row-keyed; the label carries the i/N. */
-export interface FeedPractice {
- id: string;
- kind: "generation" | "grading";
- title: string;
- phase: string;
- startedAt: number;
- problemIndex?: number;
- problemTotal?: number;
-}
-
export interface ActivityFeed {
jobs: FeedJob[];
extractions: FeedExtraction[];
- practice?: FeedPractice[];
}
const EMPTY_FEED: ActivityFeed = { jobs: [], extractions: [] };
@@ -98,12 +83,12 @@ const ActivityContext = createContext<{ feed: ActivityFeed; refresh: () => void
* THE ACTIVITY FEED, PUSH-DRIVEN (architecture S5, 2026-10-02): the
* adaptive poll is RETIRED — the dock refetches exactly when truth moves.
* Every cause is announced on the events channel: extraction phase
- * transitions and practice rows (activity-changed), job writes
+ * transitions (activity-changed), job writes
* (jobs-changed — the running-jobs blobs), and channel resync (reconnect /
* dev-restart catch-up) — plus the one client-local cause that predates
* the channel: visibility, a tab becoming visible refetches at once.
- * Announced fetches ride a 250ms trailing debounce (extraction phases and
- * practice progress burst); resync and visibility are immediate. A failed
+ * Announced fetches ride a 250ms trailing debounce (extraction phase
+ * bursts); resync and visibility are immediate. A failed
* fetch keeps the last feed (the dock never flickers to empty on a blip).
*/
export function ActivityProvider({ children }: { children: React.ReactNode }) {
@@ -122,7 +107,7 @@ export function ActivityProvider({ children }: { children: React.ReactNode }) {
if (res.ok) {
const data = (await res.json()) as Partial;
if (alive)
- setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [], practice: data.practice ?? [] });
+ setFeed({ jobs: data.jobs ?? [], extractions: data.extractions ?? [] });
}
} catch {
// transport blip: keep the last feed, announce again next event
@@ -177,18 +162,14 @@ export function useActivity(): { feed: ActivityFeed; refresh: () => void } {
// ─── Pure presentation helpers (exported for tests) ──────────────────────────
-/** One dock blob: the unified view of a job, an extraction run, or a
- * practice run. */
+/** One dock blob: the unified view of a job or an extraction run. */
export interface ActivityBlob {
- /** job.id, the extraction job's row id (source id, or "src#i" for
- * chunk jobs — unique per row, per-chunk jobs owner 2026-09-28), or
- * the practice SET id (the abort seam is set-keyed). */
+ /** job.id, or the extraction job's row id (source id, or "src#i" for
+ * chunk jobs — unique per row, per-chunk jobs owner 2026-09-28). */
id: string;
- process: "job" | "extraction" | "practice";
- /** Sub-agent kind for jobs (extraction/practice blobs have none). */
+ process: "job" | "extraction";
+ /** Sub-agent kind for jobs (extraction blobs have none). */
jobKind?: JobKind;
- /** Practice run kind (generation | grading) — practice blobs only. */
- practiceKind?: "generation" | "grading";
/** Extraction phase (extracting/auditing/merging/embedding); jobs carry none. */
phase?: string;
title: string;
@@ -269,20 +250,6 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
activity: "",
});
}
- for (const run of feed.practice ?? []) {
- blobs.push({
- id: run.id, // the ROW key — practice abort is row-keyed
- process: "practice",
- practiceKind: run.kind,
- phase: run.phase,
- title: run.title,
- chatId: "", // practice is not chat-scoped
- startedAt: run.startedAt,
- cost: null, // practice runs ride no live cost channel (v1)
- context: null,
- activity: "",
- });
- }
blobs.sort((a, b) => a.startedAt - b.startedAt);
return blobs;
}
@@ -346,7 +313,7 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
body: JSON.stringify({
kind: blob.process,
// Extractions abort by SOURCE id (chatId) — chunk rows share one
- // source and die together; practice aborts by SET id (blob.id).
+ // source and die together.
id: blob.process === "extraction" ? blob.chatId : blob.id,
}),
});
@@ -420,14 +387,11 @@ function ActivityBlobRow({
abortState: AbortState | undefined;
onAbort: () => void;
}) {
- const Icon =
- blob.process === "extraction" ? WaypointsIcon : blob.process === "practice" ? GraduationCapIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
+ const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
const label =
blob.process === "extraction"
? `Extraction — ${blob.phase ?? "running"}`
- : blob.process === "practice"
- ? `Practice — ${blob.phase ?? "running"}`
- : blob.jobKind === "research"
+ : blob.jobKind === "research"
? "Researcher"
: blob.jobKind === "demo"
? "Demo builder"
diff --git a/components/chain-table.tsx b/components/chain-table.tsx
index eb09a0f..d333e05 100644
--- a/components/chain-table.tsx
+++ b/components/chain-table.tsx
@@ -40,7 +40,7 @@
* the Google-Photos pattern — appears only when something is selected,
* showing the agents' ACTUAL input (chainsForSelection over the flat
* node-id selection: a premise without its conclusion counts as the
- * singleton it becomes) + Clear + Generate problem set. DELETE (owner,
+ * singleton it becomes) + Clear. DELETE (owner,
* 2026-09-29 night: "this is really the view deletion should be done
* in") rides the same bar FENCED at the right end — divider + the
* destructive red pair — and opens the SAME full-death confirm dialog
@@ -51,8 +51,7 @@
* auto-expanded under their clusters (non-matching clusters hide; the
* collapsed view returns on clear).
*
- * Read-only toward the graph. Named neutrally (ChainTable, not
- * Practice*) — only the Generate handoff knows about practice.
+ * Read-only toward the graph.
*/
import { useEffect, useMemo, useState } from "react";
@@ -70,7 +69,6 @@ import {
import { chainsForSelection, formatChainLine, fuzzyScore } from "@/src/core";
import type { GraphChain, GraphNode } from "@/src/core";
import { TopBar } from "@/components/top-bar";
-import { PracticeDialog } from "@/components/practice-dialog";
import { DeleteNodeDialog } from "@/components/delete-node-dialog";
import { LatexGlyph } from "@/components/latex-glyph";
@@ -113,7 +111,6 @@ export function ChainTableBody({
onToggleChain,
onToggleNodes,
onClear,
- onGenerate,
onDelete,
onClose,
}: {
@@ -134,7 +131,6 @@ export function ChainTableBody({
* proposition checks all reduce to this. */
onToggleNodes: (ids: string[], next: boolean) => void;
onClear: () => void;
- onGenerate: () => void;
/** Opens the full-death confirm dialog (owner, 2026-09-29 night). */
onDelete: () => void;
onClose: () => void;
@@ -235,7 +231,7 @@ export function ChainTableBody({
{/* THE shared top bar (standardization ruling): title at the standard
size, drag mechanics, sidebar reopen, traffic-light gutter — the
- same element as every chat/practice surface. Search + close
+ same element as every chat surface. Search + close
carve out with lh-no-drag (the mask law: later in the DOM). */}
Chains
@@ -498,15 +494,7 @@ export function ChainTableBody({
>
Clear
-
- {/* The danger fence (owner ruling): a divider quarantines
+ {/* The danger fence (owner ruling): a divider quarantines
the destructive action at the bar's right end — the
safe actions and the kill switch never blend. The
full-death confirm dialog catches the misclick. */}
@@ -576,7 +564,7 @@ function RowCheckbox({
}
/** The graph view's overlay wrapper: owns query + sort + selection +
- * expansion + the custom practice dialog; mounts ABSOLUTE INSIDE the
+ * expansion; mounts ABSOLUTE INSIDE the
* graph area, full-bleed (owner ruling: the translucent blurred surface
* IS the overlay — no inner card). Esc + the top-bar X dismiss (there
* is no backdrop margin to click anymore). Read-only toward the graph. */
@@ -600,19 +588,18 @@ export function ChainTableOverlay({
const [selection, setSelection] = useState>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState>(() => new Set());
const [expandedChains, setExpandedChains] = useState>(() => new Set());
- const [dialogOpen, setDialogOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
// Esc dismisses (unless a dialog has focus — each dialog's own Esc
// owns its layer; all listen on window, so the guards are explicit).
useEffect(() => {
- if (!open || dialogOpen || deleteOpen) return;
+ if (!open || deleteOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
- }, [open, dialogOpen, deleteOpen, onClose]);
+ }, [open, deleteOpen, onClose]);
if (!open) return null;
@@ -665,18 +652,10 @@ export function ChainTableOverlay({
onToggleChain={(id) => setExpandedChains((prev) => toggleIn(prev, id))}
onToggleNodes={toggleNodes}
onClear={() => setSelection(new Set())}
- onGenerate={() => setDialogOpen(true)}
onDelete={() => setDeleteOpen(true)}
onClose={onClose}
/>
- {dialogOpen && (
- setDialogOpen(false)}
- />
- )}
{deleteOpen && (
({ id: n.id, name: n.name }))}
diff --git a/components/graph-view.tsx b/components/graph-view.tsx
index 12e3649..2cf337b 100644
--- a/components/graph-view.tsx
+++ b/components/graph-view.tsx
@@ -880,12 +880,11 @@ export function GraphChromeRow({
)}
)}
- {/* The chain table (chains overlay, owner plan 2026-09-29) — the
- graph's free-form practice entry. Own container (36px band),
- between the search and Import. */}
+ {/* The chain table (chains overlay, owner plan 2026-09-29). Own
+ container (36px band), between the search and Import. */}
{!isEmpty && (
-
+
- {/* Practice (slice 5, 2026-09-29): the set list's entry — the same
- quiet row language as New session, active-state lit while any
- /practice route is open. GraduationCapIcon to match the dock. */}
- router.push("/practice")}
- className={`mx-3 mb-1 flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider transition-colors hover:bg-accent hover:text-foreground ${
- pathname.startsWith("/practice") ? "bg-accent text-foreground" : "text-muted-foreground"
- }`}
- >
-
- Practice
-
-
Sessions
diff --git a/config.ts b/config.ts
index 1fdc5c1..ac17747 100644
--- a/config.ts
+++ b/config.ts
@@ -26,10 +26,6 @@ export const PATHS = {
/** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */
chats: path.join(DATA_ROOT, "chats"),
graph: path.join(DATA_ROOT, "graph"),
- /** Practice problem sets (one JSON document per set — src/store/practice.ts).
- * Bounded self-contained snapshots (propositions + chains frozen at
- * creation), not append-only ledgers. */
- practice: path.join(DATA_ROOT, "practice"),
/** Debug artifacts (env-gated): data/debug/provider-events/.jsonl
* raw provider stream recordings (src/agent/provider-events.ts). */
debug: path.join(DATA_ROOT, "debug"),
@@ -56,10 +52,7 @@ export type ModelRole =
| "visualMaker"
| "pdfReader"
| "extractor"
- | "auditor"
- | "partitioner"
- | "problemGenerator"
- | "grader";
+ | "auditor";
/**
* The LIVE graph store (src/store/graph.ts) — owned by the app's extraction
diff --git a/src/agent/subagents/bg-llm-semaphore.ts b/src/agent/subagents/bg-llm-semaphore.ts
index a37f9dd..8356364 100644
--- a/src/agent/subagents/bg-llm-semaphore.ts
+++ b/src/agent/subagents/bg-llm-semaphore.ts
@@ -1,14 +1,14 @@
/**
* THE SHARED BACKGROUND-LLM SEMAPHORE (owner ruling, 2026-09-29): ONE
* process-wide pool of 3 permits for ALL background LLM jobs — extraction
- * chunks AND practice partitioner/generators/graders. Total background
+ * chunks. Total background
* LLM concurrency is bounded at 3, whoever is calling: no subsystem gets
* a private lane past the provider-facing stance.
*
* Extraction owned this gate alone first (owner, 2026-09-28: at most 3
- * concurrent extractor calls). Practice grew its own twin that evening;
+ * concurrent extractor calls).
* the ruling then unified them — this module is the single truth, so
- * extraction.ts and practice-pipeline.ts must never cross-import.
+ * extraction chunks are the only holders.
*
* FIFO; abort-aware (a queued caller that aborts marks its ticket dead —
* the permit handoff skips it and hands to the next waiter).
@@ -26,7 +26,7 @@
/** Total background LLM concurrency, process-wide, across ALL subsystems
* (owner ruling 2026-09-29: one shared pool — extraction chunks +
- * practice partitioner/generators/graders). */
+ * extraction chunks). */
const PERMIT_COUNT = 3;
export interface LlmSlots {
diff --git a/src/agent/subagents/extraction.ts b/src/agent/subagents/extraction.ts
index 3f3a907..3474bf8 100644
--- a/src/agent/subagents/extraction.ts
+++ b/src/agent/subagents/extraction.ts
@@ -72,8 +72,7 @@ import { runSubmitAgent, healStringifiedArrays } from "./submit-agent.ts";
*
* The events ledger is GONE (2026-09-27): quiz outcomes and understanding
* observations are no longer formalized in the graph — the graph is purely
- * extracted reasoning chains (nodes + edges). Understanding-level
- * assessment is practice-module territory.
+ * extracted reasoning chains (nodes + edges).
*
* The extractor is a single-tool-call worker (one TypeBox-validated submit
* call — a pi agent purely so the model can retry a schema-invalid call).
@@ -244,7 +243,7 @@ function fillFindings(raw: FindingsSubmit): FindingsSubmit {
/** The chain laws (extractor redesign, 2026-09-30; digest-aware
* 2026-09-30 evening) — the LOUD channel: runSubmitAgent turns a
* problem string into an error tool result and the model re-submits
- * within its turns (the practice-proven hook). The MIXED NAMESPACE: an
+ * within its turns. The MIXED NAMESPACE: an
* endpoint resolves when it is a submitted proposition id OR an
* existing graph node id (the extractor continues a partly-extracted
* source and references digest ids verbatim). Identical (premise-set,
@@ -864,8 +863,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
/** THE EXTRACT SLOT (unified 2026-09-29): extraction chunk jobs run
* under the SHARED background-LLM semaphore — one process-wide pool
- * of 3 permits for extraction chunks AND practice's partitioner/
- * generators/graders (owner ruling; see bg-llm-semaphore.ts). The
+ * of 3 permits (see bg-llm-semaphore.ts). The
* job's registry-row signal kills a queued ticket (dead-ticket
* handoff). The audit side needs no slot: audits already serialize
* behind their own global lock. */
diff --git a/src/agent/subagents/practice-pipeline.ts b/src/agent/subagents/practice-pipeline.ts
deleted file mode 100644
index 9317840..0000000
--- a/src/agent/subagents/practice-pipeline.ts
+++ /dev/null
@@ -1,652 +0,0 @@
-/**
- * THE PRACTICE PIPELINE (practice arc, slice 4 — 2026-09-29): the
- * orchestration around the practice agents (slice 3) — set creation,
- * two-stage generation (partitioner → one generator per problem), answer
- * submission, grading, abort, and the live-run registry the activity
- * feed reads. Sibling of practice.ts (the agents + their doctrines);
- * this module owns STATE, the agents module owns CALLS.
- *
- * ARCHITECTURE (extraction's idioms, deliberately):
- * - Live runs live on globalThis.__lhPracticeRuns — PER-PROBLEM ROWS
- * (owner ruling, 2026-09-29: matching the extraction per-chunk
- * pattern): "${setId}:partition" while the partitioner runs, then one
- * "${setId}:gen:${problemId}" per problem ("queued"→"generating"),
- * and one "${setId}:grade:${problemId}" per graded answer
- * ("queued"→"grading") — because Next dev compiles each route entry
- * as its own module instance; a module-level Map would read empty
- * from /api/activity. One process, one truth (the turn/wake/abort/
- * extraction registries' lesson).
- * - Every set-file mutation funnels through the per-set write mutex
- * (the audit-lock idiom, per-set): the STORE adapter stays dumb —
- * serialization is THIS layer's job (store module's contract).
- * - Model calls (partitioner, generators, graders) gate behind the
- * SHARED background-LLM semaphore — one process-wide pool of 3
- * permits for ALL background LLM jobs, extraction chunks included
- * (owner ruling, 2026-09-29; bg-llm-semaphore.ts); abort-aware
- * while queued.
- * - The store adapter isSYNC (fs); reads may run unlocked, only
- * read-modify-write cycles take the lock.
- *
- * FIRE-AND-FORGET front doors: createProblemSet persists the set at
- * "partitioning" and returns immediately — the pipeline runs in the
- * background; the returned `done` promise is the test/diagnostic seam
- * (routes ignore it). submitAnswers mirrors it for grading. All
- * background failure paths settle the persisted record (never an
- * unhandled rejection); aborts mark "Aborted".
- *
- * ONE ATTEMPT PER PROBLEM (owner ruling 2026-09-29): a problem accepts
- * a single submission — once `answer` is set the problem is done, the
- * route refuses re-submission. Grades are per-problem: submitting
- * another problem while one grades is fine (the shared LLM pool bounds
- * parallelism); only the SAME problem's live grade row blocks (which
- * the one-attempt rule already prevents client-side).
- */
-
-import type { GraphStore, PracticeStore } from "../../core/ports.ts";
-import { emit } from "../../core/events.ts";
-import type { Problem, ProblemSet, ReasoningChain } from "../../core/contracts.ts";
-import { chainsForSelection } from "../../core/practice.ts";
-import { getGraph } from "../../core/graph.ts";
-import {
- createPracticeAgents,
- type PracticeModels,
- type PracticeStepLog,
- type PreviousProblemRef,
- type PropositionSnapshot,
-} from "./practice.ts";
-import { withBgLlmSlot } from "./bg-llm-semaphore.ts";
-
-// ─── typed errors (the service maps these to responses) ──────────────────────
-
-/** The selector resolved to nothing practicable (400 at the route). */
-export class EmptySelectionError extends Error {
- constructor(message = "selection has no propositions to practice") {
- super(message);
- this.name = "EmptySelectionError";
- }
-}
-
-/** Set id unknown (404). */
-export class PracticeSetNotFound extends Error {
- constructor(setId: string) {
- super(`no practice set ${setId}`);
- this.name = "PracticeSetNotFound";
- }
-}
-
-/** The set exists but is not in the state the caller needs (409). */
-export class PracticeSetNotReady extends Error {
- constructor(status: ProblemSet["status"]) {
- super(`practice set is ${status}, not ready`);
- this.name = "PracticeSetNotReady";
- }
-}
-
-/** Malformed answers: unknown problem, empty text, already graded, or a
- * grading run already in flight (400). */
-export class BadAnswersError extends Error {
- constructor(message: string) {
- super(message);
- this.name = "BadAnswersError";
- }
-}
-
-// ─── the live-run registry (globalThis — see module doc) ─────────────────────
-
-export type PracticeRunKind = "generation" | "grading";
-export type PracticePhase = "partitioning" | "queued" | "generating" | "grading";
-
-/** One live ROW — per-problem, matching the extraction per-chunk pattern
- * (owner ruling, 2026-09-29). The registry key IS the row's identity:
- * the feed's id and the dock's abort target. Each row owns its OWN
- * abort controller (abort routes by row: one problem dies alone) and
- * its OWN usage accumulation (this row's model call only). */
-export interface RunningPractice {
- /** `${setId}:partition` | `${setId}:gen:${problemId}` |
- * `${setId}:grade:${problemId}` — the registry key, the feed id,
- * and the abort key in one. */
- key: string;
- setId: string;
- kind: PracticeRunKind;
- phase: PracticePhase;
- /** "Partitioning — T" | "Problem i/N — T" | "Grading i/N — T" — the
- * dock's line (progress rides the label). */
- label: string;
- startedAt: number;
- /** 1-based position within the fan-out (gen rows: the partition's
- * order; grade rows: the submitted answers' order). */
- problemIndex?: number;
- problemTotal?: number;
- /** THIS row's model-call usage (one stage per row: partitioner,
- * generator, or grader — completion-folded, not per-step). */
- usage?: PracticeStepLog;
- /** True-cancellation for THIS row's model call (the dock's abort);
- * abort() also kills a queued semaphore ticket. */
- controller: AbortController;
-}
-
-interface LhPracticeGlobal {
- __lhPracticeRuns?: Map;
- /** Per-set write mutexes (audit-lock idiom, per-set). */
- __lhPracticeLocks?: Map }>;
-}
-
-function practiceRuns(): Map {
- const g = globalThis as typeof globalThis & LhPracticeGlobal;
- return (g.__lhPracticeRuns ??= new Map());
-}
-
-function practiceLocks(): Map }> {
- const g = globalThis as typeof globalThis & LhPracticeGlobal;
- return (g.__lhPracticeLocks ??= new Map());
-}
-
-/** In-flight practice runs, oldest first (the feed sorts again; this is
- * the stable, deterministic order tests pin). */
-export function practiceStatuses(): RunningPractice[] {
- return [...practiceRuns().values()].sort((a, b) => a.startedAt - b.startedAt);
-}
-
-/** The per-set serialized write section: read-modify-write cycles around
- * one set's file queue here, process-wide. */
-async function withSetLock(setId: string, critical: () => Promise | T): Promise {
- const locks = practiceLocks();
- const prev = locks.get(setId)?.tail ?? Promise.resolve();
- let release!: () => void;
- const mine = new Promise((resolve) => (release = resolve));
- locks.set(setId, { tail: prev.then(() => mine) });
- await prev;
- try {
- return await critical();
- } finally {
- release();
- }
-}
-
-// ─── small helpers ───────────────────────────────────────────────────────────
-
-/** "pp_" + ts36 + rand — mirrors the store's ps_ minting. */
-function newProblemId(): string {
- return `pp_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
-}
-
-function isAbortLike(err: unknown): boolean {
- return err instanceof Error && err.name === "AbortError";
-}
-
-function messageOf(err: unknown): string {
- return err instanceof Error ? err.message : String(err);
-}
-
-function registerRow(
- key: string,
- setId: string,
- kind: PracticeRunKind,
- phase: PracticePhase,
- label: string,
- part?: { i: number; n: number },
-): RunningPractice {
- const row: RunningPractice = {
- key,
- setId,
- kind,
- phase,
- label,
- startedAt: Date.now(),
- problemIndex: part?.i,
- problemTotal: part?.n,
- controller: new AbortController(),
- };
- practiceRuns().set(key, row);
- // The dock learns the row at the moment of cause (push S5, 2026-10-02):
- // notify-after-write — the registry is the feed's truth.
- emit("activity-changed", {});
- return row;
-}
-
-/** Row over (settled, failed, aborted — every exit): the row leaves the
- * feed. ONE seam for all delete sites so no exit path can miss the
- * announce (push S5, 2026-10-02). */
-function finishPractice(key: string): void {
- practiceRuns().delete(key);
- emit("activity-changed", {});
-}
-
-/** DEDUP INPUT (computed ONCE at creation, passed to partitioner and
- * every generator): ready problems from the most recent ≤5 sets sharing
- * at least one selected node. */
-function dedupInput(practiceStore: PracticeStore, selected: ReadonlySet): PreviousProblemRef[] {
- const refs: PreviousProblemRef[] = [];
- let setsSeen = 0;
- for (const set of practiceStore.listSets()) {
- if (setsSeen >= 5) break;
- if (!set.nodeIds.some((id) => selected.has(id))) continue;
- setsSeen++;
- for (const p of set.problems) {
- if (p.status === "ready" && p.statement !== "") refs.push({ statement: p.statement, chains: p.chains });
- }
- }
- return refs;
-}
-
-// ─── the pipeline ────────────────────────────────────────────────────────────
-
-export interface PracticePipelineDeps extends PracticeModels {
- graphStore: GraphStore;
- practiceStore: PracticeStore;
-}
-
-export function createPracticePipeline(deps: PracticePipelineDeps) {
- const agents = createPracticeAgents(deps);
-
- // BOOT SWEEP (owner bug fix, live test 2026-09-29 — the sweep once
- // stamped a LIVE run): a persisted partitioning/generating set with no
- // live row AND a heartbeat older than the grace window is a crash/
- // restart orphan (the registry is memory-only) — mark it, idempotently.
- // THREE guards against stamping the living:
- // 1. ONCE PER PROCESS (globalThis flag) — an instance could otherwise
- // evaluate against a registry whose fresh row it can't observe
- // (dev route bundles, restart paths).
- // 2. GRACE WINDOW (120s over updatedAt ?? createdAt) — a live run's
- // WATCHDOG re-stamps the file every 30s (cross-worker liveness —
- // the registry is NOT shared between dev route workers), so a
- // live run is structurally never 120s stale; a true crash stops
- // the beats and is minutes stale at the next boot.
- // 3. Live-row check (globalThis-shared registry) as before.
- const sweepFlag = globalThis as { __lhPracticeSweepDone?: boolean };
- if (!sweepFlag.__lhPracticeSweepDone) {
- sweepFlag.__lhPracticeSweepDone = true;
- const staleBefore = Date.now() - 120_000;
- for (const set of deps.practiceStore.listSets()) {
- if (set.status !== "partitioning" && set.status !== "generating") continue;
- let live = false;
- for (const key of practiceRuns().keys()) if (key.startsWith(`${set.id}:`)) { live = true; break; }
- if (live) continue;
- const heartbeat = Date.parse(set.updatedAt ?? set.createdAt);
- if (Number.isFinite(heartbeat) && heartbeat > staleBefore) continue; // grace
- console.error(`[practice ${set.id}] boot sweep: stale ${set.status} set marked interrupted`);
- set.status = "error";
- set.error = "Interrupted (server restarted)";
- deps.practiceStore.saveSet(set);
- }
- }
-
- /** Persist the error state on a set (partitioner failure / abort). */
- async function markSetError(setId: string, message: string): Promise {
- await withSetLock(setId, () => {
- const cur = deps.practiceStore.getSet(setId);
- if (!cur) return;
- cur.status = "error";
- cur.error = message;
- deps.practiceStore.saveSet(cur);
- });
- }
-
- // ── generation ───────────────────────────────────────────────────────
-
- async function generateSet(
- set: ProblemSet,
- previousProblems: PreviousProblemRef[],
- partitionRow: RunningPractice,
- note?: string,
- ): Promise {
- const controller = partitionRow.controller;
-
- // PERSISTENT HEARTBEAT (owner live test #2, 2026-09-29 — the sweep
- // stamped LIVE runs again): dev serves routes from workers that do
- // NOT share this process's registry, so memory liveness cannot
- // protect a cross-worker sweep, and a long-running last problem can
- // exceed the 120s grace between store writes. The watchdog
- // re-stamps updatedAt on the FILE every 30s while THIS run is live
- // (4-beat tolerance inside the grace window) — any process's sweep
- // then sees a fresh heartbeat; a true crash stops the beats and the
- // orphan goes stale honestly.
- const beat = setInterval(() => {
- void withSetLock(set.id, () => {
- const cur = deps.practiceStore.getSet(set.id);
- if (!cur) return;
- if (cur.status !== "partitioning" && cur.status !== "generating") return;
- deps.practiceStore.saveSet(cur);
- });
- }, 30_000);
- beat.unref?.();
-
- try {
- // Stage 1 — the partitioner (its OWN dock row; acquires a shared
- // permit like every background LLM job).
- let partitions: Array<{ chains: string[] }>;
- try {
- const submitted = await withBgLlmSlot(controller.signal, () =>
- agents.runPartitioner({
- chains: set.chains,
- propositions: set.propositions,
- params: set.params,
- previousProblems,
- note,
- signal: controller.signal,
- }),
- );
- partitionRow.usage = submitted.log;
- partitions = submitted.partitions;
- } catch (err) {
- await markSetError(set.id, isAbortLike(err) || controller.signal.aborted ? "Aborted" : messageOf(err));
- finishPractice(partitionRow.key);
- return;
- }
-
- // Stubs appear the moment the partition lands (numbered, generating);
- // per-problem gen rows take the partition row's place in the dock.
- const stubs: Problem[] = partitions.map((part) => ({
- id: newProblemId(),
- chains: part.chains,
- statement: "",
- solution: "",
- rubric: "",
- status: "generating",
- }));
- const saved = await withSetLock(set.id, () => {
- const cur = deps.practiceStore.getSet(set.id);
- if (!cur) return null;
- cur.problems = stubs;
- cur.status = "generating";
- deps.practiceStore.saveSet(cur);
- return cur;
- });
- finishPractice(partitionRow.key);
- if (!saved) return; // deleted mid-run — stop quietly
-
- const genRows = stubs.map((stub, i) =>
- registerRow(
- `${set.id}:gen:${stub.id}`,
- set.id,
- "generation",
- "queued",
- `Problem ${i + 1}/${stubs.length} — ${set.title}`,
- { i: i + 1, n: stubs.length },
- ),
- );
-
- // Stage 2 — one generator per problem, failure-isolated, each its
- // own row + abort controller (a row abort kills THAT problem only).
- await Promise.all(
- stubs.map(async (stub, i) => {
- const row = genRows[i]!; // 1:1 with stubs by construction
- try {
- await withBgLlmSlot(row.controller.signal, async () => {
- if (row.controller.signal.aborted) {
- const err = new Error("aborted while queued");
- err.name = "AbortError";
- throw err;
- }
- row.phase = "generating";
- emit("activity-changed", {}); // in-place phase write — announce it
- const generated = await agents.runProblemGenerator({
- target: stub.chains,
- fullChains: set.chains,
- propositions: set.propositions,
- params: set.params,
- previousProblems,
- note,
- signal: row.controller.signal,
- });
- row.usage = generated.log;
- await withSetLock(set.id, () => {
- const cur = deps.practiceStore.getSet(set.id);
- if (!cur) return;
- const p = cur.problems.find((q) => q.id === stub.id);
- if (!p) return;
- p.chains = generated.problem.chains;
- p.statement = generated.problem.statement;
- p.solution = generated.problem.solution;
- p.rubric = generated.problem.rubric;
- p.status = "ready";
- p.error = undefined;
- deps.practiceStore.saveSet(cur);
- });
- });
- } catch (err) {
- // Failure isolation: this problem errors, the set runs on.
- const aborted = isAbortLike(err) || row.controller.signal.aborted;
- await withSetLock(set.id, () => {
- const cur = deps.practiceStore.getSet(set.id);
- if (!cur) return;
- const p = cur.problems.find((q) => q.id === stub.id);
- if (!p) return;
- p.status = "error";
- p.error = aborted ? "Aborted" : messageOf(err);
- deps.practiceStore.saveSet(cur);
- });
- } finally {
- finishPractice(row.key);
- }
- }),
- );
-
- // Settle: ready even with errored problems (per-problem isolation).
- // A SET-level abort already wrote its verdict synchronously
- // (abortPractice) — re-reading under the lock, we keep it.
- await withSetLock(set.id, () => {
- const cur = deps.practiceStore.getSet(set.id);
- if (!cur) return;
- if (cur.status === "error") return; // set-abort's mark stands
- cur.status = "ready";
- cur.error = undefined;
- deps.practiceStore.saveSet(cur);
- });
- } finally {
- clearInterval(beat);
- }
- }
-
- /**
- * Create a problem set — fire-and-forget: persists the set at
- * "partitioning" and returns it immediately; `done` resolves when
- * generation has fully settled (any end state). Refusal gates
- * (models/key) fire BEFORE anything is created.
- */
- async function createProblemSet(
- nodeIds: string[],
- params: ProblemSet["params"],
- titleHint?: string,
- note?: string,
- ): Promise<{ set: ProblemSet; done: Promise }> {
- agents.refuseIfStagesUnready();
-
- // Graph-domain selection (owner ruling 2026-09-29: problem sets
- // take NODES — the UI resolves a picked cluster to its member ids;
- // unknown ids simply drop, an empty live intersection refuses).
- const wanted = new Set(nodeIds);
- const selected = deps.graphStore.readNodes().filter((n) => wanted.has(n.id));
- const selectedIds = new Set(selected.map((n) => n.id));
- // The STORED chains (chains-primary, owner 2026-09-30): chainsForSelection
- // keeps a chain iff every member is selected — the same inclusion law as
- // the pre-chains edge grouping; never the derived fan-out.
- const chains = chainsForSelection(selected, getGraph(deps.graphStore).chains);
- if (chains.length === 0) throw new EmptySelectionError();
- const propositions: PropositionSnapshot = {};
- for (const n of selected) propositions[n.id] = { name: n.name, statement: n.statement };
- const title = titleHint ?? `Practice — ${new Date().toISOString().slice(0, 10)}`;
-
- const set = deps.practiceStore.createSet({
- title,
- createdAt: new Date().toISOString(),
- params,
- nodeIds: [...selectedIds].sort(),
- propositions,
- chains,
- status: "partitioning",
- problems: [],
- ...(note ? { note } : {}),
- });
-
- const previousProblems = dedupInput(deps.practiceStore, selectedIds);
- const row = registerRow(`${set.id}:partition`, set.id, "generation", "partitioning", `Partitioning — ${title}`);
- const done = generateSet(set, previousProblems, row, note);
- return { set, done };
- }
-
- // ── grading ──────────────────────────────────────────────────────────
-
- /**
- * Save answers and spawn one grading job per answered problem —
- * fire-and-forget like creation: the persisted set carries the
- * answers immediately; grades land as their jobs settle.
- */
- async function submitAnswers(
- setId: string,
- answers: Array<{ problemId: string; text: string }>,
- ): Promise<{ set: ProblemSet; done: Promise }> {
- agents.refuseIfStagesUnready(); // grading needs its engine — refuse BEFORE saving answers
- for (const a of answers) {
- if (practiceRuns().has(`${setId}:grade:${(a as { problemId?: unknown }).problemId}`)) {
- throw new BadAnswersError("this problem's grade is already in flight");
- }
- }
- const set = await withSetLock(setId, () => {
- const cur = deps.practiceStore.getSet(setId);
- if (!cur) throw new PracticeSetNotFound(setId);
- if (cur.status !== "ready") throw new PracticeSetNotReady(cur.status);
- const ids = new Set(cur.problems.map((p) => p.id));
- for (const a of answers) {
- if (typeof a.problemId !== "string" || !ids.has(a.problemId)) throw new BadAnswersError(`unknown problem id`);
- if (typeof a.text !== "string" || a.text.trim() === "") throw new BadAnswersError("answers must be non-empty text");
- const p = cur.problems.find((q) => q.id === a.problemId)!;
- // ONE attempt (owner 2026-09-29): an existing answer ends the
- // problem — graded or not, it is never re-submitted.
- if (p.answer !== undefined) throw new BadAnswersError(`problem ${a.problemId} was already answered`);
- }
- for (const a of answers) {
- const p = cur.problems.find((q) => q.id === a.problemId)!;
- p.answer = a.text;
- }
- deps.practiceStore.saveSet(cur);
- return cur;
- });
-
- const gradeRows = answers.map((a, i) =>
- registerRow(
- `${setId}:grade:${a.problemId}`,
- setId,
- "grading",
- "queued",
- `Grading ${i + 1}/${answers.length} — ${set.title}`,
- { i: i + 1, n: answers.length },
- ),
- );
- const done = (async () => {
- await Promise.all(answers.map((a, i) => gradeOne(setId, a.problemId, gradeRows[i]!))); // 1:1
- })();
- return { set, done };
- }
-
- /** One grading job — its OWN row (key `${setId}:grade:${problemId}`);
- * abort/failure drops the grade (answer kept, re-submittable later);
- * success writes it under the set lock. */
- async function gradeOne(setId: string, problemId: string, row: RunningPractice): Promise {
- const controller = row.controller;
- try {
- await withBgLlmSlot(controller.signal, async () => {
- if (controller.signal.aborted) return; // dropped — no grade, no error state
- row.phase = "grading";
- emit("activity-changed", {}); // in-place phase write — announce it
- const set = deps.practiceStore.getSet(setId);
- if (!set) return;
- const problem = set.problems.find((p) => p.id === problemId);
- if (!problem || problem.grade) return;
- // The legend scoped to THIS problem's chains (its premises +
- // conclusions) — the grader sees only what it grades.
- const legend: PropositionSnapshot = {};
- for (const chainId of problem.chains) {
- const chain: ReasoningChain | undefined = set.chains.find((c) => c.id === chainId);
- if (!chain) continue;
- for (const nodeId of [chain.conclusion, ...chain.premises]) {
- const p = set.propositions[nodeId];
- if (p) legend[nodeId] = p;
- }
- }
- const graded = await agents.runGrader({
- problem: { statement: problem.statement, solution: problem.solution, rubric: problem.rubric },
- propositions: legend,
- answer: problem.answer ?? "",
- signal: controller.signal,
- });
- row.usage = graded.log;
- await withSetLock(setId, () => {
- const cur = deps.practiceStore.getSet(setId);
- if (!cur) return;
- const p = cur.problems.find((q) => q.id === problemId);
- if (!p || p.grade) return;
- p.grade = { score: graded.score, reasoning: graded.reasoning, gradedAt: new Date().toISOString() };
- deps.practiceStore.saveSet(cur);
- });
- });
- } catch (err) {
- // Grading failure: the grade simply stays absent (the answer
- // stands — one attempt; the UI shows "grade interrupted").
- console.error(`[practice] grading failed for ${problemId}:`, isAbortLike(err) ? "aborted" : messageOf(err));
- } finally {
- finishPractice(row.key);
- }
- }
-
- // ── abort + delete ───────────────────────────────────────────────────
-
- /** True-cancellation for ONE row (the dock's abort — id = the row
- * key). Routes by shape: a gen-row abort kills THAT problem only
- * (siblings continue; the failure-isolation catch writes "Aborted");
- * a partition-row abort ends the whole run (the partitioner's catch
- * writes the set error); a grade-row abort drops that grade (the
- * answer stays, re-submittable). Idempotent by design (a gone row
- * is success — the blob is gone either way). */
- function abortPracticeRow(key: string): void {
- const row = practiceRuns().get(key);
- if (!row) return;
- row.controller.abort();
- finishPractice(key);
- }
-
- /** SET-level abort — composes from row aborts (every live row of the
- * set) plus the synchronous persisted "Aborted" marks (a set-abort's
- * verdict must stand before the generators' settles re-read it).
- * Idempotent like abortExtraction. */
- function abortPractice(setId: string): void {
- for (const key of [...practiceRuns().keys()]) {
- if (key.startsWith(`${setId}:`)) abortPracticeRow(key);
- }
- const set = deps.practiceStore.getSet(setId);
- if (!set) return;
- let dirty = false;
- for (const p of set.problems) {
- if (p.status === "generating") {
- p.status = "error";
- p.error = "Aborted";
- dirty = true;
- }
- }
- if (set.status === "partitioning" || set.status === "generating") {
- set.status = "error";
- set.error = "Aborted";
- dirty = true;
- }
- if (dirty) deps.practiceStore.saveSet(set);
- }
-
- /** Delete a set: live jobs die first, then the record. Boolean like
- * the store (false = never existed → the route's 404). */
- async function deleteProblemSet(setId: string): Promise {
- abortPractice(setId);
- let existed = false;
- await withSetLock(setId, () => {
- existed = deps.practiceStore.deleteSet(setId);
- });
- return existed;
- }
-
- return {
- createProblemSet,
- submitAnswers,
- abortPractice,
- abortPracticeRow,
- deleteProblemSet,
- };
-}
diff --git a/src/agent/subagents/practice.ts b/src/agent/subagents/practice.ts
deleted file mode 100644
index ae6a355..0000000
--- a/src/agent/subagents/practice.ts
+++ /dev/null
@@ -1,567 +0,0 @@
-/**
- * The practice agents (practice arc, slice 3 — 2026-09-29): partitioner,
- * problem-generator, grader — the LLM stages of the practice pipeline.
- * Three single-submit workers on the shared runSubmitAgent seam (one
- * TypeBox-validated submit call each; a pi agent purely so the model can
- * retry a schema-invalid or LAW-violating call — the validate hook throws
- * an invalidInputResult isError result at the execute seam (pi 1.0 convention)
- * result the model sees).
- *
- * Roles: partitioner | problemGenerator | grader — all strong-tier
- * (config.ts ROLE_SLOT; problem engineering is the quality-critical
- * component). Engines resolve per stage exactly like extraction: test
- * override → engineFor (role override → slot → nothing) → honest refusal
- * (ModelsNotConfiguredError / ProviderKeyMissingError) before anything
- * runs. NO registry here: each runner returns its own step log (model +
- * per-step CallUsage); the pipeline (slice 4) owns live state, sets,
- * serialization, and the money channel.
- *
- * The GENERATOR's doctrine is the owner's problem-engineering framework
- * VERBATIM (2026-09-28 18.10, minus the Sources line) with an appended
- * wiring section (Part III). The partitioner and grader doctrines are
- * owner-approved drafts of the same date.
- */
-
-import type { Usage } from "@earendil-works/pi-ai";
-import { Type, type Static } from "typebox";
-import type { ModelRole } from "../../../config.ts";
-import {
- ModelsNotConfiguredError,
- ProviderKeyMissingError,
- ROLE_SLOT,
- type ModelSlotName,
- type ProviderKeyMissing,
-} from "../../core/settings.ts";
-import type { PiEngine } from "../models.ts";
-import { runSubmitAgent } from "./submit-agent.ts";
-import { callUsage } from "./extraction.ts";
-import type { CallUsage, PracticeParams, ReasoningChain } from "../../core/contracts.ts";
-import { chainCoverage, CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "../../core/practice.ts";
-
-// ─── doctrines ───────────────────────────────────────────────────────────────
-
-export const GENERATOR_DOCTRINE = `# The problem engineering framework
-
-## I. Theory
-
-### What you are doing
-
-You receive a graph: propositions as nodes, derivations as edges. Teaching installed this graph in the learner. You add nothing to it and change nothing in it. What you change is when the held propositions come to mind.
-
-Knowing a proposition and having it come to mind in the right situation are different states. After a lesson, a proposition comes to mind in approximately one kind of situation — the lesson's. A learner can hold a complete graph and be unable to use it, because the propositions come to mind only in the lesson's situation. Your problems exist to widen that.
-
-This is why you are not writing exam questions. An exam question is built to produce evidence about the learner, so that someone can judge them. A practice question is built to place a demand on the learner, so that the demand strengthens them. An exam needs only the cheapest demand that can be graded. You need the demand that strengthens the most.
-
-### What solving is, and what it strengthens
-
-Solving is a loop. The learner looks at the present situation — the givens and everything derived so far — and must think of a proposition that applies; applying it produces the next situation; this repeats until the task is met. One move is one proposition brought to mind and used.
-
-Each successful recall does three things. That kind of situation is afterward more likely to bring the proposition to mind — and if the situation is a new kind, the gain is a new kind of situation in which it appears. The proposition is re-recorded with features of the situation. And the strengthening is larger when the recall was harder — an unfamiliar situation, or several similar propositions competing — while a failed recall strengthens almost nothing, except through the explanation that follows it.
-
-What accumulates is, for each proposition, the set of situations in which it comes to mind. Command of a subject is that set covering the situations in which the knowledge is actually used. Variety has a further effect: what stays the same across many different situations is the proposition itself, with none of the situations attached. This is what transfer is, and it is why varied practice produces transfer while identical repetition does not.
-
-Build by these corollaries:
-
-- Recitation is not practice. It trains the proposition to appear in one situation — the lesson's — which will not occur again.
-- Restudy is not a substitute for problems. Rereading presents the lesson's situation again, so it strengthens recall in the one place it is already strong.
-- A demand that costs nothing strengthens nothing; a demand that cannot be met strengthens nothing. Both edges matter, always.
-
-### What a problem is
-
-A problem exercises whatever any correct solution must pass through: the propositions that must be used, and the derivations that must be traversed. Not what you intended — what every correct solution requires.
-
-A problem that can be solved by a route skipping your target teaches that route instead. Such a problem is unsound and must never be emitted.
-
-### The three distances
-
-**Solution distance** is the number of moves in the shortest correct solution. One move is one proposition brought to mind and used; a bare recall is one move, a derivation step is one move. Solution distance is fixed by the target — a chain of four edges cannot be exercised in three moves — and it is kept minimal, because a short solution can be held in mind as a whole and understood when found.
-
-**Search distance** is the number of plausible wrong moves the learner must consider and reject to find each right one. It is manufactured: by givens that suggest wrong methods, by information that no correct solution uses, by situations that resemble the ones where other propositions apply.
-
-**Cue distance** is how far the problem's situation is from every situation in which the learner has already used the targeted propositions, the lesson included.
-
-### The two axes
-
-The main axis is the ratio of search distance to solution distance: how much searching the problem demands per step of actual solution. At the bottom of the axis, the applicable proposition is effectively announced. At the top, finding it is most of the work.
-
-The bottom is the flashcard: one move of pure recitation, search distance near zero — and cue distance near zero too, since the card front is itself the cue. Spaced repetition is this framework with both knobs at zero and the graph collapsed to isolated nodes. Recitation from a card is legitimate practice for exactly one kind of proposition: one with no derivational work and a fixed use-cue. For anything derivable, the flashcard leaves the edges untraveled.
-
-The top is the competition problem, which is the ideal:
-
-- solution distance minimal — the solution, once found, can be seen whole and is obvious in retrospect;
-- search distance as large as it can be while every wrong turn remains resolvable;
-- fair — nothing required beyond the learned graph;
-- checkable — the learner can verify the answer without an authority.
-
-Search has an optimum, not a maximum. A resolved wrong turn teaches the boundary — when the proposition applies and when it does not. An unresolved wrong turn teaches nothing, or the wrong thing. Search is productive up to the learner's limit and destructive past it; the ideal problem sits just under the limit.
-
-The second axis is cue distance, and it must sit in a band. Too small, and the problem is recitation. Too large, and the learner cannot see that the proposition applies: the recall fails, and nothing is strengthened. The usable range is new-but-recognizable.
-
-### The operations
-
-Three operations modify a problem at any position on the axes.
-
-**Reverse.** Hand over the conclusion and demand the grounds: not "what follows from A" but "what does B rest on". Installation records the forward direction only, so the backward direction is unavailable until practiced. Being able to use B and being able to say what B rests on are different states, and the second is what explaining is.
-
-**Discriminate.** Where two propositions apply to almost the same situations, present a situation that fits both almost equally, and demand the choice. The learner must find the feature that separates them. This is often the real difficulty in applied subjects: not executing the method but choosing it. Discrimination is the one place where small cue distance is wanted — the situations must be nearly identical, so that the separating feature carries all the signal.
-
-**Probe the premises.** Take a derivation, remove or weaken one of its premises, and ask what breaks, and where. A derivation in use is one move; this operation opens the move and asks which premise was doing the work. It converts knowing that a step holds into knowing why. Build it only where the derivation's internal steps are known: where the step is stored as a single unit, there is nothing to open.
-
-### The ladder
-
-The main axis, bottom to top — five rungs, numbered; the request arrives as a rung:
-
-- **0 Worked example** — the solution is shown; nothing is demanded. An installation object; nothing yet to recall.
-- **1 Completion** — the path is shown with a gap; the learner supplies one step. What to recall is nearly announced.
-- **2 Direct exercise** — one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing.
-- **3 Composition** — several steps, chained; the path is narrow but must be held and run.
-- **4 Path-finding** — several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape.
-
-The ladder is the main axis only. Cue distance and the three operations apply at every point.
-
-### Presentation
-
-The same problem can be wrapped in a real-world story or stated as pure theory. Nothing structural changes. This is a preference of the learner, adjustable at no cost, and never a substitute for moving on the axes.
-
-### Coverage
-
-A single contact with a proposition is not coverage. A proposition is covered when it has been:
-
-- used forward;
-- used backwards, where a backwards question is meaningful;
-- used in several situations far enough apart to be genuinely different;
-- chosen against its nearest confusables, where such neighbors exist;
-- and, for a derivation, probed in its premises, where the internal steps are known.
-
-The optimal set is the smallest sound set that covers every proposition and derivation in the target graph in this sense. Read the mix off the graph's shape: a subject that is many propositions and few derivations collapses to recall in varied situations plus discrimination among confusables — spaced, interleaved flashcards, done properly. A derivation-dense subject is dominated by composition and path-finding. One framework; the mix is a property of the input.
-
-## II. Process
-
-Build one problem at a time through these steps. Emit nothing that has not passed all of them.
-
-### 1. Select the target
-
-From the graph and the given parameters: an edge; a chain of edges; a node; a cluster of mutually confusable propositions; an edge plus one of its premises. Degrade gracefully — whatever structure the input has determines which targets exist. A lone node with no edges yields only execution and discrimination problems; that is correct behavior, not a failure.
-
-### 2. Set the parameters
-
-The request arrives with three parameters: the search-to-solution ratio, the cue distance, and the presentation. Solution distance remains the target's own length — do not pad it — so the given ratio sets the search distance: as many plausible wrong moves per solution step as the ratio demands, concentrated on a short target or distributed along a chain. Hold both distances to their bands: search only as large as remains resolvable; cue new-but-recognizable. If the given parameters exceed what the target supports — nothing to discriminate against, no resolvable wrong turns available, no distance left from the lesson — lower the parameter and say so. Do not fake it.
-
-### 3. Choose the operations
-
-The operations are not part of the input; they are yours to choose. Reverse, discriminate, probe the premises — apply them where meaningful for the target, and skip them where they are not. They are available techniques, not obligations.
-
-### 4. Build the situation
-
-Re-bind the target into a concrete situation at the chosen cue distance: different from the lesson's situation, and different from every situation already used for this target elsewhere in the set. For search distance, add distractor givens that suggest wrong methods and decoy routes that fail — each for a reason the learner can discover. State the task so the target is required but not announced. Apply the given presentation wrapper last; it changes nothing structural.
-
-### 5. Solve it, then try to break it
-
-Write the model solution: every move, in order. Then check, in this order:
-
-- **Necessity.** The solution you wrote uses the targeted steps. The target is genuinely forced.
-- **Shortcut hunt.** Actively search for a different or shorter solution that skips the target. If one exists, the problem is unsound: fix it or discard it. Emit only problems you have tried to break and could not.
-- **Resolvability.** For each planted wrong turn, a reason it fails is discoverable — from the givens, or from the model solution after the attempt.
-- **Fairness and checkability.** The solution uses nothing outside the graph, and the learner can verify the answer without an authority.
-
-### 6. Check the set
-
-When the set is assembled: every node and edge covered in the repertoire sense; no two problems for the same target in the same kind of situation; confusables met in at least one discrimination; no problem removable without breaking coverage; every problem having passed step 5.
-
-### Standing prohibitions
-
-- Never emit a problem you have not solved.
-- Never emit a problem you have not tried to shortcut.
-- Never require anything outside the graph.
-- Never announce the target. If the situation makes the applicable proposition unmistakable, that is a direct exercise — it must be intended as one, not arrived at by accident.
-- Never build for gradability over strengthening. You impose demands; you do not extract evidence.
-
-## III. Inputs and outputs (the practice module's wiring)
-
-You are one generator in a two-stage pipeline: a partitioner has already divided the target into problems; you build exactly ONE of them.
-
-INPUTS (in the user message):
-- YOUR TARGET: the chains this problem is built to strengthen — the partitioner's assignment.
-- THE FULL SET: the proposition legend and chain list of the ENTIRE selection, not just the target. A coherent problem often cannot be built from the target alone — you may draw on other propositions of the full set as supporting material when the problem needs it. DOCTRINE: minimal use of outside propositions, maximal focus on the target — the target is what the learner must recall; outside propositions are material the solution may pass through. The never-announce rule covers every proposition, target or not.
-- THE PARAMETERS: three settings, each a named level — never a number to interpolate.
- THE LADDER RUNG (the main axis): 0 Worked example · 1 Completion · 2 Direct exercise · 3 Composition · 4 Path-finding (Part I's ladder). The invocation names your rung; build exactly that CLASS of problem. There is no continuum between rungs — a rung-3 problem is composition, not "composition with a little path-finding."
- THE CUE DISTANCE: 0 Lesson context · 1 Adjacent context · 2 Far context — how far the situation sits from the cues the learner has already met: the lesson's own examples AND every previous problem's situation. Far context is the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.
- THE PRESENTATION: Theory (the subject's own terms — symbols, formal objects, native vocabulary) or Application (a concrete worldly affair in which the proposition's structure appears in the wild). It dresses the situation; it never changes the rung or the cue distance.
- Apply Part II step 2: degrade honestly if the target cannot support them — the degradation shows in the problem's own shape, never in meta-commentary.
-- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set — wishes, emphasis, context about themselves. Weigh it as preference, not instruction: take what serves the target and the parameters, ignore what conflicts with them, and never let it move a problem off its rung or out of its band. Its absence means nothing.
-- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as \`code\`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.
-- PREVIOUS PROBLEMS: problems the learner has already answered, with the chains they exercised. Do not rebuild one; keep your situation distant from theirs — cue distance counts previous problems' situations (Part I, "Cue distance").
-
-OUTPUT — your entire deliverable is ONE submit_problem call:
-- chains: the chain ids this problem exercises — echo YOUR TARGET's ids VERBATIM (arg_… / n_…); never re-type a chain's premises or conclusion. The set's coverage is verified against the echo.
-- statement: the full problem formulation, world-facing; never names the propositions it tests (Part II: "Never announce the target").
-- solution: the model solution from Part II step 5 — every move, in order.
-- rubric: a brief free-form grading rubric — the bullet points of what actually matters when grading an answer against your solution.`;
-
-const PARTITIONER_INSTRUCTIONS = `You are the partitioning stage of a learning application's practice system. Teaching installed a knowledge graph in the learner: propositions (nodes) and reasoning chains — the stored arguments that derive conclusions from premises. Practice problems will now be built to strengthen recall of those propositions across new situations. You do not write problems — you decide what each problem will be ABOUT, and how many problems there are. One generator agent then builds each problem from your partition, independently.
-
-The graph arrives as THE CHAINS: every chain has its own id — an "arg_…" id for a derivation ("premises → conclusion"), or the proposition's own "n_…" id for a BARE proposition (a singular idea no argument uses — it is its own one-member target). A proposition legend gives each proposition id's name and full statement. You reference chains by ID ONLY — never re-type a chain's premises, never restate a chain as a line.
-
-PARTITION DOCTRINE:
-- COVER EVERYTHING, EXACTLY ONCE: every chain appears in exactly ONE problem's assignment — a true partition. Nothing dropped: a dropped chain is an uncovered proposition, and an uncovered proposition is never practiced. Nothing doubled: variety across operations accumulates ACROSS problem sets, never by double-assigning within one.
-- SMALLEST SOUND SET: prefer fewer, well-loaded problems. But never force unrelated material together: a problem's chains must share a real target relationship (confusable with each other, feeding the same conclusion, forming a path, or clustered around one idea). If the only thing two chains share is the graph, they belong in different problems.
-- GROUP CONFUSABLES TOGETHER: propositions that apply to almost the same situations must land in the same problem — discrimination (choosing between near-identical situations) is a problem-engineering operation, and it is only possible when both confusables are in the room.
-- READ THE MIX OFF THE SHAPE: many singletons and few multi-premise chains suggest recall + discrimination problems; long premise chains suggest composition and path-finding. Your partition is where that judgment lives.
-- RESPECT THE PARAMETERS: the request names the ladder rung, the cue distance, and the presentation. Low rungs (0–2) mean many small assignments — single chains standing alone; high rungs (3–4) mean larger assignments — composition needs material to chain, path-finding needs distractor material to conceal the path among. Far cue distance makes grouping confusables together MORE important: the discrimination must travel into the far situation. Presentation does not shape your partition.
-- DO NOT DUPLICATE: previous problems the learner has already seen are listed with their chains. If an existing problem already covers a group of chains, re-partition so the new set differs in what it groups and how.
-- THE LEARNER'S NOTE (optional): free-form text the learner attached at creation — it may wish for grouping, count, or emphasis. Weigh it as preference; the partition laws above always win, and when the note conflicts with them, follow the laws silently.
-
-OUTPUT — your entire deliverable is ONE submit_partition call: "partitions", a list of { chains: [chain ids] }, one entry per problem — the ids VERBATIM from THE CHAINS ("arg_…" for derivations, a bare proposition's own "n_…"), together containing every input chain exactly once. Never re-type a chain's propositions; never invent an id. No empty entries; no unknown ids; no problem without a real target relationship.`;
-
-export const GRADER_INSTRUCTIONS = `You are the grading stage of a learning application's practice system. One practice problem — built to strengthen the learner's recall of specific propositions — has been answered. Your job is small and fast: an integer score and a one-or-two-sentence justification. You are not assessing a person; you are scoring a recall.
-
-You receive: the problem's statement; its pre-written MODEL SOLUTION and GRADING RUBRIC (committed before the learner ever saw the problem — they are the reference, not you); the propositions (names + statements) the problem was built to exercise; and the learner's ANSWER — often dictated thinking-out-loud prose. Extract the reasoning from the prose; grade the substance, never the form. Do not demand the solution's exact wording — the rubric names what actually matters; nothing else is graded.
-
-JUDGE AGAINST THE REFERENCE:
-- Work the rubric points: which are met, which are missed, which are half-met in the answer's own terms.
-- Where the answer diverges from the model solution, ask whether the divergence is a different-but-valid route (the rubric decides) or a wrong turn.
-
-THE SCORE (one integer, 0–10):
-- 9–10: the targeted recall(s) happened — the answer meets the rubric's essentials. Peripheral gaps do not lower it.
-- 5–8: some of the target came to mind; the answer is materially incomplete or partly wrong.
-- 0–4: the targeted proposition(s) did not come to mind — the answer is empty or on the wrong track.
-
-The reasoning is plain prose, one or two sentences: which rubric points were met or missed and, when something failed, WHICH proposition failed to come to mind. Mathematics is LaTeX — inline as \\(…\\), display as \\[…\\] (a bare $ is literal currency); no headers, no lists — minimal is the standard.
-
-OUTPUT — your entire deliverable is ONE submit_grade call: "score" (integer 0–10) and "reasoning" (non-empty, brief).`;
-
-// ─── schemas + fills ─────────────────────────────────────────────────────────
-
-const partitionSchema = Type.Object(
- {
- partitions: Type.Array(
- Type.Object({
- chains: Type.Array(
- Type.String({
- minLength: 1,
- description:
- "Chain ids VERBATIM from THE CHAINS — arg_… for derivations, the proposition's own n_… for a bare proposition. Never re-type a chain's premises or conclusion; never invent an id.",
- }),
- {
- minItems: 1,
- description: "The ids this ONE problem will exercise — its target.",
- },
- ),
- }),
- {
- minItems: 1,
- description: "One entry per problem.",
- },
- ),
- },
- {
- description:
- "The partition: one entry per problem; together they contain every input chain exactly once — a true partition.",
- },
-);
-type PartitionSubmit = Static;
-
-const problemSchema = Type.Object({
- chains: Type.Array(
- Type.String({
- minLength: 1,
- description: "Chain ids — echo YOUR TARGET's ids verbatim (arg_… / n_…); never re-typed lines.",
- }),
- { minItems: 1 },
- ),
- statement: Type.String({ minLength: 1 }),
- solution: Type.String({ minLength: 1 }),
- rubric: Type.String({ minLength: 1 }),
-});
-type ProblemSubmit = Static;
-
-const gradeSchema = Type.Object({
- score: Type.Integer({ minimum: 0, maximum: 10 }),
- reasoning: Type.String({ minLength: 1 }),
-});
-type GradeSubmit = Static;
-
-// ─── rendering (the agent-facing text) ───────────────────────────────────────
-
-export type PropositionSnapshot = Record;
-
-/** A previous problem the learner already answered — the dedup/situation
- * context generators and the partitioner both see. */
-export interface PreviousProblemRef {
- statement: string;
- chains: string[];
-}
-
-export function renderPropositions(propositions: PropositionSnapshot): string {
- return Object.entries(propositions)
- .sort(([a], [b]) => (a < b ? -1 : 1))
- .map(([id, p]) => `[${id}] ${p.name} — ${p.statement}`)
- .join("\n");
-}
-
-/** One chain line for the agent-facing renderings — the chain's id LEADS
- * (owner fix, 2026-09-30 evening: the partitioner was told to reference
- * chain ids but the lines never showed one, so the model re-typed whole
- * rendered lines into the submit and everything failed validation). A
- * bare proposition repeats its own id with the no-premises marker —
- * mechanically teaching that its chain id IS its proposition id. */
-const chainLine = (c: Pick): string =>
- c.premises.length > 0
- ? `[${c.id}] ${c.premises.join(", ")} → ${c.conclusion}`
- : `[${c.id}] ${c.conclusion} (no premises)`;
-
-/** The proposition legend + chain list (ids LEAD every line — the
- * referenceable anchor, owner fix 2026-09-30) — the partitioner sees
- * ALL chains; the generator sees the full set too (renderChains) with
- * its target marked separately in the prompt. */
-export function renderChains(chains: ReasoningChain[], propositions: PropositionSnapshot): string {
- return `THE PROPOSITIONS\n${renderPropositions(propositions)}\n\nTHE CHAINS\n${chains.map(chainLine).join("\n")}`;
-}
-
-export function renderParams(params: PracticeParams): string {
- const rung = LADDER_RUNGS[params.ladderRung];
- const cue = CUE_LEVELS[params.cueDistance];
- const pres = PRESENTATIONS[params.presentation];
- return [
- "THE PARAMETERS",
- `Ladder rung ${params.ladderRung} — ${rung.name}: ${rung.definition}`,
- `Cue distance ${params.cueDistance} — ${cue.name}: ${cue.definition}`,
- `Presentation: ${pres.name} — ${pres.definition}`,
- ].join("\n");
-}
-
-export function renderPreviousProblems(problems: PreviousProblemRef[]): string {
- if (problems.length === 0) return "None — this is the learner's first set.";
- return problems.map((p, i) => `${i + 1}. ${p.statement} (chains: ${p.chains.join(", ")})`).join("\n");
-}
-
-// ─── the runners ─────────────────────────────────────────────────────────────
-
-export interface PracticeModels {
- /** Test seam: a scripted engine per stage — checked FIRST, ahead of the
- * engineFor chain (production wires no direct values). */
- partitioner?: PiEngine;
- problemGenerator?: PiEngine;
- grader?: PiEngine;
- /** Settings-resolved engine per role — read FRESH per stage call; the
- * BYOK chain may end empty → undefined → the refusal guard fires. */
- engineFor?: (role: ModelRole) => PiEngine | undefined;
- /** Key-presence gate (same injection discipline as extraction). */
- providerKeyMissing?: (provider: string, tier: ModelSlotName) => ProviderKeyMissing | null;
-}
-
-/** One stage's money trail — the runner's own summary; the pipeline
- * (slice 4) folds these into its registry, NOT extraction's. */
-export interface PracticeStepLog {
- /** Provider-qualified model stamp ("provider:id"), first step's. */
- model: string | null;
- steps: CallUsage[];
-}
-
-export interface PartitionerInput {
- chains: ReasoningChain[];
- propositions: PropositionSnapshot;
- params: PracticeParams;
- previousProblems: PreviousProblemRef[];
- /** The learner's creation note — optional; weighed, never obeyed. */
- note?: string;
- signal?: AbortSignal;
-}
-export interface PartitionerOutput {
- partitions: Array<{ chains: string[] }>;
- log: PracticeStepLog;
-}
-
-export interface GeneratorInput {
- /** The partitioner's assignment for THIS problem — the echo law. */
- target: string[];
- /** The ENTIRE selection's chains (supporting material, minimal use). */
- fullChains: ReasoningChain[];
- propositions: PropositionSnapshot;
- params: PracticeParams;
- previousProblems: PreviousProblemRef[];
- /** The learner's creation note — optional; weighed, never obeyed. */
- note?: string;
- signal?: AbortSignal;
-}
-export interface GeneratedProblem {
- chains: string[];
- statement: string;
- solution: string;
- rubric: string;
-}
-export interface GeneratorOutput {
- problem: GeneratedProblem;
- log: PracticeStepLog;
-}
-
-export interface GraderInput {
- problem: { statement: string; solution: string; rubric: string };
- /** The legend scoped to THIS problem's propositions. */
- propositions: PropositionSnapshot;
- answer: string;
- signal?: AbortSignal;
-}
-export interface GraderOutput {
- score: number;
- reasoning: string;
- log: PracticeStepLog;
-}
-
-/** The TRUE-PARTITION law (owner ruling 2026-09-29, prompt review): every
- * chain exactly once — nothing dropped, nothing doubled, nothing unknown.
- * Used at the submit seam; a violation goes back to the model as an error
- * tool result and retries. */
-function validatePartition(value: PartitionSubmit, chains: ReasoningChain[]): string | null {
- const known = new Set(chains.map((c) => c.id));
- const all: string[] = [];
- for (const p of value.partitions) all.push(...p.chains);
- const unknown = [...new Set(all.filter((id) => !known.has(id)))];
- if (unknown.length > 0) return `unknown chain ids: ${unknown.join(", ")}`;
- const dupes = [...new Set(all.filter((id, i) => all.indexOf(id) !== i))];
- if (dupes.length > 0) return `every chain belongs to exactly ONE problem — these appear more than once: ${dupes.join(", ")}`;
- const coverage = chainCoverage({ chains, problems: value.partitions });
- if (coverage.missing.length > 0) return `these chains are missing — nothing may be dropped: ${coverage.missing.join(", ")}`;
- return null;
-}
-
-/** The ECHO law: the generator's submitted chains set EQUALS its assigned
- * target — the set's coverage is verified against the echo. */
-function validateProblem(value: ProblemSubmit, target: string[]): string | null {
- if (value.chains.length !== new Set(value.chains).size) {
- return "chains contains duplicates — echo your assigned target verbatim";
- }
- const want = [...new Set(target)].sort().join("\u0000");
- const got = [...new Set(value.chains)].sort().join("\u0000");
- if (got !== want) return `chains must echo your assigned target exactly: ${target.join(", ")}`;
- return null;
-}
-
-/** The learner's creation note as a prompt block — present ONLY when a
- * note exists (owner, 2026-09-29: more learner control, nothing owed). */
-function learnerNoteBlock(note: string | undefined): string | null {
- return note
- ? `THE LEARNER'S NOTE (free-form additional input — weigh it; you are not obliged to follow it)\n${note}`
- : null;
-}
-
-export function createPracticeAgents(deps: PracticeModels) {
- const stageEngine = (role: ModelRole, override?: PiEngine): PiEngine | undefined =>
- override ?? deps.engineFor?.(role);
-
- /** The per-need refusal guard (extraction's pattern): which slot tiers
- * are unset, and whether a configured stage's provider key is gone —
- * both refuse BEFORE any stage runs. */
- function refuseIfStagesUnready(): void {
- const stages: readonly [ModelRole, PiEngine | undefined][] = [
- ["partitioner", stageEngine("partitioner", deps.partitioner)],
- ["problemGenerator", stageEngine("problemGenerator", deps.problemGenerator)],
- ["grader", stageEngine("grader", deps.grader)],
- ];
- const missing = new Set();
- for (const [role, engine] of stages) if (!engine) missing.add(ROLE_SLOT[role]);
- if (missing.size > 0) {
- console.info(`[practice] refused — no model configured for: ${[...missing].join(", ")}`);
- throw new ModelsNotConfiguredError([...missing]);
- }
- for (const [role, engine] of stages) {
- const refusal = deps.providerKeyMissing?.(engine!.model.provider, ROLE_SLOT[role]);
- if (refusal) {
- console.info(`[practice] refused — ${JSON.stringify(refusal)}`);
- throw new ProviderKeyMissingError(refusal.provider, refusal.slots);
- }
- }
- }
-
- function stepLogger(log: PracticeStepLog) {
- return (usage: Usage, model: string) => {
- if (!log.model) log.model = model;
- log.steps.push(callUsage(usage));
- };
- }
-
- async function runPartitioner(input: PartitionerInput): Promise {
- const log: PracticeStepLog = { model: null, steps: [] };
- const prompt = [
- renderChains(input.chains, input.propositions),
- renderParams(input.params),
- learnerNoteBlock(input.note),
- "PREVIOUS PROBLEMS (already seen — do not duplicate)",
- renderPreviousProblems(input.previousProblems),
- ]
- .filter((block): block is string => block !== null)
- .join("\n\n");
- const submitted = await runSubmitAgent({
- role: "partitioner",
- toolName: "submit_partition",
- toolDescription: "Submit the partition: one entry per problem, together containing every chain exactly once.",
- parameters: partitionSchema,
- fill: (raw) => raw,
- systemPrompt: PARTITIONER_INSTRUCTIONS,
- prompt,
- engine: stageEngine("partitioner", deps.partitioner),
- signal: input.signal,
- onStep: stepLogger(log),
- neverSubmitted: "the partitioner never submitted a partition",
- validate: (value: PartitionSubmit) => validatePartition(value, input.chains),
- });
- return { partitions: submitted.partitions, log };
- }
-
- async function runProblemGenerator(input: GeneratorInput): Promise {
- const log: PracticeStepLog = { model: null, steps: [] };
- const targetChains = input.fullChains.filter((c) => input.target.includes(c.id));
- const prompt = [
- `YOUR TARGET (the chains this problem is built to strengthen — reference these ids verbatim)\n${targetChains.map(chainLine).join("\n")}`,
- `THE FULL SET — propositions and chains of the entire selection (supporting material: minimal use, maximal focus on the target)\n${renderChains(input.fullChains, input.propositions)}`,
- renderParams(input.params),
- learnerNoteBlock(input.note),
- "PREVIOUS PROBLEMS (already answered — do not rebuild one; keep your situation distant from theirs)",
- renderPreviousProblems(input.previousProblems),
- ]
- .filter((block): block is string => block !== null)
- .join("\n\n");
- const problem = await runSubmitAgent({
- role: "problemGenerator",
- toolName: "submit_problem",
- toolDescription: "Submit ONE problem: chains (echo your target verbatim), statement, solution, rubric.",
- parameters: problemSchema,
- fill: (raw) => raw,
- systemPrompt: GENERATOR_DOCTRINE,
- prompt,
- engine: stageEngine("problemGenerator", deps.problemGenerator),
- signal: input.signal,
- onStep: stepLogger(log),
- neverSubmitted: "the problem generator never submitted a problem",
- validate: (value: ProblemSubmit) => validateProblem(value, input.target),
- });
- return { problem, log };
- }
-
- async function runGrader(input: GraderInput): Promise {
- const log: PracticeStepLog = { model: null, steps: [] };
- const prompt = [
- `THE PROBLEM\n${input.problem.statement}`,
- `THE PROPOSITIONS IT EXERCISES\n${renderPropositions(input.propositions)}`,
- `THE MODEL SOLUTION\n${input.problem.solution}`,
- `THE GRADING RUBRIC\n${input.problem.rubric}`,
- `THE LEARNER'S ANSWER\n${input.answer}`,
- ].join("\n\n");
- const grade = await runSubmitAgent({
- role: "grader",
- toolName: "submit_grade",
- toolDescription: "Submit the grade: an integer score 0-10 and brief non-empty reasoning.",
- parameters: gradeSchema,
- fill: (raw) => raw,
- systemPrompt: GRADER_INSTRUCTIONS,
- prompt,
- engine: stageEngine("grader", deps.grader),
- signal: input.signal,
- onStep: stepLogger(log),
- neverSubmitted: "the grader never submitted a grade",
- });
- return { score: grade.score, reasoning: grade.reasoning, log };
- }
-
- return { refuseIfStagesUnready, runPartitioner, runProblemGenerator, runGrader };
-}
diff --git a/src/agent/subagents/submit-agent.ts b/src/agent/subagents/submit-agent.ts
index 5448300..ac5ce04 100644
--- a/src/agent/subagents/submit-agent.ts
+++ b/src/agent/subagents/submit-agent.ts
@@ -1,6 +1,6 @@
/**
* runSubmitAgent — the shared single-submit agent seam (moved verbatim from
- * extraction.ts, practice slice 3, 2026-09-29): one TypeBox-validated submit
+ * extraction.ts): one TypeBox-validated submit
* call is the whole deliverable (a pi agent purely so the model can retry a
* schema-invalid call). The submitted value is captured from the execute
* seam, which only fires on a schema-valid call — the ??-fill (the old zod
diff --git a/src/agent/tools.ts b/src/agent/tools.ts
index 428817a..4028771 100644
--- a/src/agent/tools.ts
+++ b/src/agent/tools.ts
@@ -102,7 +102,7 @@ export const askUserQuestionTool: AgentTool = {
// chunk both agents compose (prompts/shared/20-question-tools.md and the
// teacher's question fragments), reversing the 2026-09-27 placement now
// that the question family has grown (predict/check roles, future
-// practice variants): duplicates stay honest by sharing SCHEMA ATOMS
+// quiz variants): duplicates stay honest by sharing SCHEMA ATOMS
// (question-core) + PROMPT CHUNKS, and each tool declares only its
// mechanical delta.
diff --git a/src/core/chains.ts b/src/core/chains.ts
new file mode 100644
index 0000000..ec716f7
--- /dev/null
+++ b/src/core/chains.ts
@@ -0,0 +1,67 @@
+/**
+ * Selection-facing chain views — pure + browser-safe (the shell's chain
+ * table consumes these; the barrel exports them). Born in the practice
+ * contracts (2026-09-29), orphaned onto their own module when the batch
+ * practice module was replaced by practice sessions (2026-10-04).
+ */
+
+/** One reasoning chain — a selection-facing VIEW of stored follows-edges:
+ * all edges into one conclusion, as premises → that conclusion. Singleton
+ * chains (premises: []) carry edge-less nodes. */
+export interface ReasoningChain {
+ /** The chain's identity — the CONCLUSION node's id (chains group by
+ * conclusion, so the two are one). */
+ id: string;
+ /** Premise node ids — every follows-edge source into the conclusion,
+ * sorted. Empty = a singleton chain (an edge-less node). */
+ premises: string[];
+ /** The conclusion node id (=== id; mirrored so renderings read
+ * "premises → conclusion" without indirection). */
+ conclusion: string;
+}
+
+/** The stored chains filtered to a selection (chains-primary, owner
+ * 2026-09-30): a chain rides along iff its conclusion AND every premise
+ * are selected; a node in no surviving chain is a singleton chain
+ * (premises: []). Premise-only nodes whose chain was excluded are
+ * singletons — the same inclusion law as the pre-chains edge grouping.
+ * Every stored chain keeps its OWN id (arg_…) — two independent
+ * arguments for one conclusion stay distinct. Deterministic: chains
+ * sorted by id, premises sorted. */
+export function chainsForSelection(
+ nodes: ReadonlyArray>,
+ chains: ReadonlyArray>,
+): ReasoningChain[] {
+ const selected = new Set(nodes.map((n) => n.id));
+ const chained = new Set();
+ const included: ReasoningChain[] = [];
+ for (const chain of chains) {
+ if (!selected.has(chain.conclusion)) continue;
+ if (!chain.premises.every((premise) => selected.has(premise))) continue;
+ included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion });
+ chained.add(chain.conclusion);
+ for (const premise of chain.premises) chained.add(premise);
+ }
+ for (const node of nodes) {
+ if (!chained.has(node.id)) {
+ included.push({ id: node.id, premises: [], conclusion: node.id });
+ }
+ }
+ return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
+}
+
+/** A chain as one world-facing line — the propositions' NAMES joined
+ * by the derivation arrow: "Premise one, Premise two → Conclusion"; a
+ * singleton shows its "(no premises)" marker. Pure + browser-safe.
+ * Unknown ids fall back to the raw id (honest, never guessed).
+ * Premises sort by id — the same determinism law as chainsForSelection. */
+export function formatChainLine(
+ chain: Pick,
+ propositions: Readonly>,
+): string {
+ const nameOf = (id: string) => propositions[id]?.name ?? id;
+ const premises = [...chain.premises].sort().map(nameOf).join(", ");
+ return premises !== ""
+ ? `${premises} → ${nameOf(chain.conclusion)}`
+ : `${nameOf(chain.conclusion)} (no premises)`;
+}
diff --git a/src/core/contracts.ts b/src/core/contracts.ts
index af29acc..7008638 100644
--- a/src/core/contracts.ts
+++ b/src/core/contracts.ts
@@ -4,7 +4,7 @@
* Split by domain (src/core/contracts/): plan.ts (the session plan tree),
* graph.ts (the global knowledge graph + its extraction pipeline), chat.ts
* (session records + UI extension points), jobs.ts (the async sub-agent
- * protocol), practice.ts (the practice-problem system). Each domain file is
+ * protocol), Each domain file is
* owned outright by its stream; schema changes to a domain you don't own go
* through master. This barrel keeps every existing import path
* ("@/src/core" and "./contracts.ts") unchanged.
@@ -16,4 +16,3 @@ export * from "./contracts/chat.ts";
export * from "./contracts/jobs.ts";
export * from "./contracts/probe.ts";
export * from "./contracts/models.ts";
-export * from "./contracts/practice.ts";
diff --git a/src/core/contracts/graph.ts b/src/core/contracts/graph.ts
index d3e9c57..9686552 100644
--- a/src/core/contracts/graph.ts
+++ b/src/core/contracts/graph.ts
@@ -7,8 +7,8 @@
* message-granular; extraction is free-form and post-hoc, fully decoupled
* from the session's plan tree. The graph is an extraction of the reasoning
* chains from all teaching sessions (and imported notes) — NOTHING else:
- * no quiz outcomes, no understanding assessments (2026-09-27 cleanup —
- * understanding becomes practice-module territory). Anything not
+ * no quiz outcomes, no understanding assessments (2026-09-27 cleanup).
+ * Anything not
* yet decided (the state model, strength derivation, selection strategies) is deliberately NOT here — transitional
* stubs carry the renderer until those land.
*
@@ -289,26 +289,6 @@ export interface Cluster {
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). EVERY birth-session group regardless of
- * size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing
- * rule — practice availability must not inherit a visual threshold);
- * `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 {
/** Proposition nodes + the DERIVED implication nodes (one per chain,
* id = chain id) — what the canvas renders. */
diff --git a/src/core/contracts/practice.ts b/src/core/contracts/practice.ts
deleted file mode 100644
index c8240c3..0000000
--- a/src/core/contracts/practice.ts
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * Practice-problem contracts — the practice arc (feat/practice-problems,
- * plan locked 2026-09-29).
- *
- * PURPOSE (owner, 2026-09-29): strengthening, NOT assessment — maximize
- * strengthening per unit of mental effort. Teaching sessions install the
- * graph; practice widens the set of situations in which held propositions
- * come to mind. The old vision doc's exam machinery (assessment records,
- * understanding writes) is PARKED: grading is feedback-only and never
- * touches the graph.
- *
- * THE CHAIN MODEL (owner, 2026-09-29 — two rulings):
- * - Chains group by CONCLUSION: every follows-edge into a node forms ONE
- * chain (its premises → it). A conclusion of one chain can be a premise
- * of later chains; nodes may repeat across chains. Edge-less nodes are
- * singleton chains.
- * - FOLLOWS ONLY: supports edges are invisible to practice ("no edges")
- * — they neither chain nor cover.
- * The translation is a bijection: the union of premises→conclusion pairs
- * reconstructs exactly the selection's follows-edges (core/practice.ts).
- *
- * GENERATION is two-stage: a partitioner agent partitions the chains into
- * the smallest covering set of problems (dedup-aware, confusables grouped);
- * then ONE generator agent per problem builds it (the problem-engineering
- * framework as its doctrine). Numbered problems appear in the UI the
- * moment the partition lands — loading until their generator finishes —
- * and NEVER show which propositions they test. Each problem commits to its
- * full formulation, model solution, and grading rubric up front
- * (commit-before-pose, the sycophancy mitigation). GRADING is one
- * background job per problem, against the pre-written solution + rubric,
- * feedback-only.
- *
- * Part of the domain-split contracts (src/core/contracts/): this file is
- * owned by the practice stream. Pure types + protocol docs only.
- */
-
-/** One reasoning chain — the practice module's agent-facing unit: all
- * follows-edges into one conclusion, as premises → that conclusion.
- * Singleton chains (premises: []) carry edge-less nodes. */
-export interface ReasoningChain {
- /** The chain's identity — the CONCLUSION node's id (chains group by
- * conclusion, so the two are one). */
- id: string;
- /** Premise node ids — every follows-edge source into the conclusion,
- * sorted. Empty = a singleton chain (an edge-less node). */
- premises: string[];
- /** The conclusion node id (=== id; mirrored so agent-facing renderings
- * read "premises → conclusion" without indirection). */
- conclusion: string;
-}
-
-/** The main axis, discretized (owner, 2026-09-29 — named levels, never
- * a number to interpolate; definitions verbatim from the ladder
- * doctrine): */
-export type LadderRung = 0 | 1 | 2 | 3 | 4;
-
-/** Cue distance from every situation the learner has already met — the
- * lesson's own examples AND every previous problem's situation
- * (definitions owner-approved 2026-09-29): */
-export type CueDistance = 0 | 1 | 2;
-
-/** How the situation is dressed — never changes the rung or the cue
- * distance: */
-export type Presentation = "theory" | "application";
-
-/** The three settings every problem set is configured with (owner,
- * 2026-09-29) — each a named level, invoked as that level exactly:
- *
- * THE LADDER (the main axis, bottom to top):
- * 0 Worked example — the solution is shown; nothing is demanded. An
- * installation object; nothing yet to recall.
- * 1 Completion — the path is shown with a gap; the learner supplies
- * one step. What to recall is nearly announced.
- * 2 Direct exercise — one step, no hint, but the situation makes the
- * applicable proposition unmistakable. Trains execution, not choosing.
- * 3 Composition — several steps, chained; the path is narrow but must
- * be held and run.
- * 4 Path-finding — several steps, the path concealed, distractors
- * present, the statement suggesting wrong methods. The competition
- * shape.
- *
- * THE CUE DISTANCE:
- * 0 Lesson context — the textbook/toy-example shape in the teaching's
- * own framing.
- * 1 Adjacent context — a genuinely new situation built from the
- * teaching's own world — same domain, different setup/packaging.
- * 2 Far context — as far from the typical cue as recognition allows,
- * the edge of the recognizable band; the proposition must still
- * genuinely apply, and the learner must be able to find it by looking.
- *
- * THE PRESENTATION:
- * theory — the subject's own terms — symbols, formal objects, native
- * vocabulary.
- * application — a concrete worldly affair — a case, episode, system,
- * or story — in which the proposition's structure appears in the wild. */
-export interface PracticeParams {
- /** Which class of problem to build — exactly that rung. */
- ladderRung: LadderRung;
- /** How far the situation sits from the cues already met. */
- cueDistance: CueDistance;
- /** How the situation is dressed. */
- presentation: Presentation;
-}
-
-/** Where a set stands in the two-stage run. The set is PERSISTED at
- * "partitioning" (before the partitioner runs), its problem stubs land
- * (still "generating") the moment the partition submits, and it flips to
- * "ready" when every generator has settled — errored problems included
- * (per-problem failure isolation: they carry their own status). */
-export type ProblemSetStatus = "partitioning" | "generating" | "ready" | "error";
-
-/** One problem's own lifecycle — a stub from the partition until its
- * generator submits. */
-export type ProblemStatus = "generating" | "ready" | "error";
-
-/** The grading job's deliverable (owner, 2026-09-29 — one attempt per
- * problem; the grader scores, it does not coach in prose): an integer
- * score against the pre-written rubric plus a minimal justification.
- * Nothing derives from it — no understanding writes. */
-export interface ProblemGrade {
- /** 0–10, integer. */
- score: number;
- /** Why this score — one or two sentences: which rubric points were
- * met or missed, and which proposition failed to come to mind. */
- reasoning: string;
- gradedAt: string;
-}
-
-/** One practice problem — built by ONE generator agent from its assigned
- * chains, fully committed before the learner ever sees it. No name field
- * (owner, 2026-09-29): problems are numbered by position, nothing else. */
-export interface Problem {
- /** "pp_" + ts36 + rand — store-assigned. */
- id: string;
- /** Chain ids (ReasoningChain.id — the conclusion node ids) this problem
- * exercises. The coverage law: the union over all problems must equal
- * the set's chains. */
- chains: string[];
- /** The full problem formulation — world-facing; never names the
- * propositions it tests. */
- statement: string;
- /** The pre-written model solution (commit-before-pose). */
- solution: string;
- /** The brief free-form grading rubric — bullet points of what actually
- * matters. */
- rubric: string;
- status: ProblemStatus;
- /** Set when this problem's generation failed (the set completes
- * around it). */
- error?: string;
- /** The learner's answer, once submitted (dictation-first — thinking
- * out loud is the primary answering mode). */
- answer?: string;
- /** The grader's score + reasoning, once it lands. */
- grade?: ProblemGrade;
-}
-
-/** One problem set — the persisted unit (one JSON document per set). */
-export interface ProblemSet {
- /** "ps_" + ts36 + rand — store-assigned. */
- id: string;
- /** Display label — the picked cluster's label, or the dated
- * default. */
- title: string;
- createdAt: string;
- /** Bumped on every store write — the live-run heartbeat the boot
- * sweep's grace window reads (a live run saves at every phase or
- * problem transition, so it is structurally never 120s stale).
- * Absent on legacy rows (validator tolerates; readers fall back
- * to createdAt). */
- updatedAt?: string;
- params: PracticeParams;
- /** The selected node ids — the raw selection record. The chain
- * decomposition derives from these + the follows-edges among them at
- * creation time. */
- nodeIds: string[];
- /** Snapshot of every selected proposition at creation — nodeId →
- * {name, statement}. The set is SELF-CONTAINED (planning ruling,
- * 2026-09-29): rendering, generation, grading, and dedup never
- * re-read the live graph, which keeps moving (extraction continues,
- * nodes get deleted); a set is a document of what WAS selected. */
- propositions: Record;
- /** The frozen chain derivation (chainsForSelection at creation time).
- * Coverage checks and the problems' chain tags resolve against THIS,
- * never against live edges. */
- chains: ReasoningChain[];
- status: ProblemSetStatus;
- /** Set when the partition itself (or the run as a whole) failed. */
- error?: string;
- /** The learner's free-form note from the creation dialog (owner,
- * 2026-09-29: more learner control, no other purpose). Carried to the
- * partitioner and every generator as ADDITIONAL INPUT — weighed as
- * preference, never an obligation, never overriding the laws. Trimmed,
- * capped (2000 chars at the API); persisted so the set stays a
- * self-contained document of what was asked for. */
- note?: string;
- /** Problem stubs appear here the moment the partitioner submits —
- * numbered by position, "generating", propositions invisible. */
- problems: Problem[];
-}
diff --git a/src/core/events.ts b/src/core/events.ts
index df53be8..13b42af 100644
--- a/src/core/events.ts
+++ b/src/core/events.ts
@@ -4,7 +4,7 @@
*
* One bus, every layer: server emits turn lifecycle here, the store
* adapters emit job/chat mutations, the agent layer's background
- * registries (extraction, practice) emit activity changes — and
+ * registries (extraction) emit activity changes — and
* GET /api/events turns every emission into an SSE frame for any open
* window. The events carry the FACT, never the data: consumers fetch
* truth on receipt (push the poke, pull the data), so payloads stay
@@ -34,7 +34,7 @@ export interface ServerEvents {
/** A job record changed (dispatch, progress, settle, abort, recovery).
* chatId when known — consumers that own one chat filter on it. */
"jobs-changed": { chatId?: string };
- /** An extraction or practice run changed phase (activity dock). */
+ /** An extraction run changed phase (activity dock). */
"activity-changed": Record;
/** The session list changed (create, delete, title). */
"chats-changed": Record;
diff --git a/src/core/graph.ts b/src/core/graph.ts
index 9273f63..7655901 100644
--- a/src/core/graph.ts
+++ b/src/core/graph.ts
@@ -48,7 +48,8 @@
*/
import { randomBytes } from "node:crypto";
-import type { CoverageEntry } from "./contracts/graph.ts";
+import type { CoverageEntry,
+} from "./contracts/graph.ts";
import type {
AuditVerdicts,
ExtractedChain,
@@ -62,8 +63,8 @@ import type {
GraphSnapshot,
} from "./contracts.ts";
import { MIN_CLUSTER_SIZE } from "./clusters.ts";
+import { formatChainLine } from "./chains.ts";
import { attachImpToCommunities, birthSessionOf, birthSessionPartition } from "./session-clusters.ts";
-import { formatChainLine } from "./practice.ts";
import type { ChatStore, GraphStore } from "./ports.ts";
/** The storage ports every graph operation needs, passed explicitly. */
@@ -500,7 +501,7 @@ export function graphSnapshot(deps: GraphDeps): GraphSnapshot {
const graph = getGraph(deps.graphStore);
// The canvas-facing lossy image: propositions + derived implication
// nodes, untyped arrows. `chains` (the stored truth) ride along for
- // the chain table and the practice pipeline — they read the truth,
+ // the chain table — it reads the truth, never the derived fan-out,
// never the derived fan-out.
const view = deriveView(graph);
const nodes = view.nodes;
@@ -657,7 +658,7 @@ export function graphSessionIndex(deps: GraphDeps): GraphSessionEntry[] {
}
/** One birth proposition of one session — WHAT WAS COVERED there (2026-09-27
- * cleanup: no understanding levels; assessment is practice-module
+ * cleanup: no understanding levels; assessment is
* territory and nothing writes it until that module exists). */
export interface GraphSessionProposition {
nodeId: string;
@@ -676,3 +677,5 @@ export function graphSessionPropositions(deps: GraphDeps, sessionId: string): Gr
// Re-export: the contract owns the shape; legacy importers still reach it.
export type { EmissionStats };
+
+
diff --git a/src/core/index.ts b/src/core/index.ts
index 761e476..0f40158 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -17,6 +17,7 @@
export * from "./contracts.ts";
export * from "./ports.ts";
+export * from "./chains.ts";
export * from "./render-settings.ts";
export * from "./question-core.ts";
@@ -35,7 +36,6 @@ export * from "./phases.ts";
export * from "./pending.ts";
export * from "./prompt-segments.ts";
export * from "./math-delims.ts";
-export * from "./practice.ts";
// Wave B (owner-approved 2026-09-26): the native record (LhMessage), the
// and the input-events contract — all
// pure logic, browser-safe.
diff --git a/src/core/ports.ts b/src/core/ports.ts
index 4c9924c..185262b 100644
--- a/src/core/ports.ts
+++ b/src/core/ports.ts
@@ -21,7 +21,6 @@ import type {
ExtractionRunUsage,
PlanTree,
Position,
- ProblemSet,
CoverageEntry,
SessionCheckpoint,
TurnUsage,
@@ -157,33 +156,6 @@ export interface JobStore {
deleteJobsForChat(chatId: string): number;
}
-// ─── PracticeStore — problem-set documents (data/practice) ──────────────────
-
-/** Everything a new set needs except the store-assigned "ps_" id. */
-export type CreateProblemSetInit = Omit;
-
-/**
- * The practice module's persistence: one JSON document per problem set
- * (src/store/practice.ts adapter). Sets are bounded, SELF-CONTAINED
- * snapshots — propositions + chains frozen at creation (contracts/
- * practice.ts) — so reads never join the live graph. Replace-whole
- * writes like settings.json, one-file-per-record like data/jobs.
- * Concurrent same-set writes are the pipeline's serialization duty
- * (slice 4), not the store's — this seam owns file integrity only.
- */
-export interface PracticeStore {
- /** Persist a new set; the store assigns the id ("ps_" + ts36 + rand). */
- createSet(init: CreateProblemSetInit): ProblemSet;
- /** The full set document, or null when unknown id / corrupt file. */
- getSet(id: string): ProblemSet | null;
- /** Every set, createdAt descending (the practice page's list view). */
- listSets(): ProblemSet[];
- /** Persist the full set document (replace-whole). */
- saveSet(set: ProblemSet): void;
- /** Delete a set file; false when unknown id. */
- deleteSet(id: string): boolean;
-}
-
// ─── SettingsStore — THE user settings record (settings-storage wave, 2026-09-19) ──
/**
diff --git a/src/core/practice.ts b/src/core/practice.ts
deleted file mode 100644
index d786460..0000000
--- a/src/core/practice.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-/**
- * Practice-module core — the chain bijection + coverage (slice 1 of the
- * 2026-09-29 plan). Pure, no I/O.
- *
- * chainsForSelection(): the stored CHAINS (chains-primary, owner
- * 2026-09-30) filtered to a selection — a chain rides along iff ALL of
- * its members are selected; nodes in no surviving chain become singleton
- * chains. formatChainLine(): one world-facing line per chain.
- * chainCoverage(): the set's completeness law — the union of the
- * problems' chains must equal the set's chains; the partitioner's submit
- * is validated against it.
- */
-import type { CueDistance, GraphChain, GraphNode, LadderRung, Presentation, Problem, ReasoningChain } from "./contracts.ts";
-
-/** The named levels (owner-approved 2026-09-29) — definitions verbatim
- * from the ladder doctrine / the contract's tables. The UI's stepped
- * controls, the exam header's levels line, and the agents' prompt
- * rendering all speak these names — never a bare number. */
-export const LADDER_RUNGS: Record = {
- 0: { name: "Worked example", definition: "the solution is shown; nothing is demanded. An installation object; nothing yet to recall." },
- 1: { name: "Completion", definition: "the path is shown with a gap; the learner supplies one step. What to recall is nearly announced." },
- 2: { name: "Direct exercise", definition: "one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing." },
- 3: { name: "Composition", definition: "several steps, chained; the path is narrow but must be held and run." },
- 4: { name: "Path-finding", definition: "several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape." },
-};
-
-export const CUE_LEVELS: Record = {
- 0: { name: "Lesson context", definition: "the textbook/toy-example shape in the teaching's own framing." },
- 1: { name: "Adjacent context", definition: "a genuinely new situation built from the teaching's own world — same domain, different setup/packaging." },
- 2: { name: "Far context", definition: "as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking." },
-};
-
-export const PRESENTATIONS: Record = {
- theory: { name: "Theory", definition: "the subject's own terms — symbols, formal objects, native vocabulary." },
- application: { name: "Application", definition: "a concrete worldly affair — a case, episode, system, or story — in which the proposition's structure appears in the wild." },
-};
-
-/** The stored chains filtered to a selection (chains-primary, owner
- * 2026-09-30): a chain rides along iff its conclusion AND every premise
- * are selected; a node in no surviving chain is a singleton chain
- * (premises: []). Premise-only nodes whose chain was excluded are
- * singletons — the same inclusion law as the pre-chains edge grouping.
- * Every stored chain keeps its OWN id (arg_…) — two independent
- * arguments for one conclusion stay distinct. Deterministic: chains
- * sorted by id, premises sorted. */
-export function chainsForSelection(
- nodes: ReadonlyArray>,
- chains: ReadonlyArray>,
-): ReasoningChain[] {
- const selected = new Set(nodes.map((n) => n.id));
- const chained = new Set();
- const included: ReasoningChain[] = [];
- for (const chain of chains) {
- if (!selected.has(chain.conclusion)) continue;
- if (!chain.premises.every((premise) => selected.has(premise))) continue;
- included.push({ id: chain.id, premises: [...chain.premises].sort(), conclusion: chain.conclusion });
- chained.add(chain.conclusion);
- for (const premise of chain.premises) chained.add(premise);
- }
- for (const node of nodes) {
- if (!chained.has(node.id)) {
- included.push({ id: node.id, premises: [], conclusion: node.id });
- }
- }
- return included.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
-}
-
-/** A chain as one world-facing line — the propositions' NAMES joined
- * by the derivation arrow: "Premise one, Premise two → Conclusion"; a
- * singleton shows its "(no premises)" marker. Pure + browser-safe:
- * the shell (chain table, derived previews) cannot import the agent
- * module — the level tables set this precedent. Unknown ids fall back
- * to the raw id (honest, never guessed). Premises sort by id — the
- * same determinism law as chainsForSelection. */
-export function formatChainLine(
- chain: Pick,
- propositions: Readonly>,
-): string {
- const nameOf = (id: string) => propositions[id]?.name ?? id;
- const premises = [...chain.premises].sort().map(nameOf).join(", ");
- return premises !== ""
- ? `${premises} → ${nameOf(chain.conclusion)}`
- : `${nameOf(chain.conclusion)} (no premises)`;
-}
-
-/** chainCoverage()'s report. `missing` fails the partition; `duplicated`
- * is informational — a chain MAY legitimately be exercised by several
- * problems (the operations make that sound). */
-export interface ChainCoverageReport {
- /** Chain ids no problem exercises — a partition leaving any is invalid. */
- missing: string[];
- /** Chain ids exercised by more than one problem (allowed). */
- duplicated: string[];
-}
-
-/** The set's completeness law: every chain of the input must be exercised
- * by the union of the problems' chains. Pure — the partitioner's submit
- * validation (slice 3) and the tests call this. */
-export function chainCoverage(
- set: {
- chains: ReadonlyArray>;
- problems: ReadonlyArray>;
- },
-): ChainCoverageReport {
- const counts = new Map();
- for (const problem of set.problems) {
- for (const chainId of problem.chains) {
- counts.set(chainId, (counts.get(chainId) ?? 0) + 1);
- }
- }
- const missing: string[] = [];
- const duplicated: string[] = [];
- for (const chain of set.chains) {
- const count = counts.get(chain.id) ?? 0;
- if (count === 0) missing.push(chain.id);
- else if (count > 1) duplicated.push(chain.id);
- }
- return { missing: missing.sort(), duplicated: duplicated.sort() };
-}
diff --git a/src/core/settings.ts b/src/core/settings.ts
index 31e1fdc..d551a22 100644
--- a/src/core/settings.ts
+++ b/src/core/settings.ts
@@ -46,9 +46,6 @@ export const BACKGROUND_MODEL_ROLES = [
"visualMaker",
"extractor",
"auditor",
- "partitioner",
- "problemGenerator",
- "grader",
] as const;
export type BackgroundModelRole = (typeof BACKGROUND_MODEL_ROLES)[number];
@@ -229,12 +226,6 @@ export const ROLE_SLOT: Readonly> = {
auditor: "cheap",
visualMaker: "vision",
pdfReader: "vision",
- // The practice trio (2026-09-29): problem engineering is the product's
- // quality-critical component — strong tier for partitioner/generator/
- // grader alike (owner-approved plan default; one edit away from cheap).
- partitioner: "strong",
- problemGenerator: "strong",
- grader: "strong",
};
/** General settings — app-level behavior. Version DISPLAY and plans login
diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts
index 5e562f4..3a5f502 100644
--- a/src/server/chat-handler.ts
+++ b/src/server/chat-handler.ts
@@ -3,7 +3,6 @@ import { createProberAgent, createTeacherAgent, type AgentBuildOptions, type Tea
import { piEngineForSpec, type PiEngine } from "../agent/models.ts";
import { createJobRunner } from "../agent/subagents/runner.ts";
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
-import { createPracticePipeline } from "../agent/subagents/practice-pipeline.ts";
import type { ModelRole } from "../../config.ts";
import {
deliverJobResult,
@@ -104,22 +103,6 @@ export const extraction = createExtractionPipeline({
providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
});
-/** The app's ONE practice pipeline instance — the API routes reach it
- * through the server barrel / service, same discipline as extraction. */
-export const practice = createPracticePipeline({
- graphStore: stores.graphStore,
- practiceStore: stores.practiceStore,
- // BYOK (owner, 2026-09-22): the practice stages resolve the chain —
- // role override → slot → NOTHING — read FRESH per stage call; an empty
- // chain flows through as undefined and the pipeline refuses the run
- // with the typed ModelsNotConfiguredError. Direct test values still win.
- engineFor: (role) =>
- resolveEngine(role),
- // Key-presence gate: same check extraction gets — a stage engine whose
- // provider key is gone refuses BEFORE any set is created.
- providerKeyMissing: (provider, tier) =>
- providerKeyPresent(provider) ? null : providerKeyMissingEnvelope(provider, [tier]),
-});
// Wake-pipeline wiring (contracts.ts "THE ASYNC PROTOCOL" §4): the pipeline
// delivers its steers through THIS composition root (one event: the steer
diff --git a/src/server/events-stream.ts b/src/server/events-stream.ts
index 6b583c4..6258f34 100644
--- a/src/server/events-stream.ts
+++ b/src/server/events-stream.ts
@@ -2,7 +2,7 @@
* THE NOTIFICATION CHANNEL (push-architecture slice 1): GET /api/events is
* an SSE response over the core event bus — one connection per open
* window, held open until the view goes away, fed by every emitter
- * (turn lifecycle, job/chat stores, extraction/practice registries).
+ * (turn lifecycle, job/chat stores, extraction registry).
*
* NOTIFY → FETCH (the architecture's whole trick): frames carry only the
* event type + a tiny payload; consumers respond by fetching truth
diff --git a/src/server/graph.ts b/src/server/graph.ts
index d47ea1d..7596c95 100644
--- a/src/server/graph.ts
+++ b/src/server/graph.ts
@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import { LEDGER_FILES, POSITIONS_FILE } from "../../config.ts";
-import type { GraphClusterGroup, GraphSnapshot } from "../core/contracts.ts";
+import type { GraphSnapshot } from "../core/contracts.ts";
import type { GraphCostSummary } from "../core/cost-summary.ts";
import { graphCostSummary } from "../core/cost-summary.ts";
import { graphSnapshot } from "../core/graph.ts";
@@ -22,29 +22,6 @@ export function graphView(): GraphSnapshot {
return graphSnapshot(stores);
}
-/** GET /api/graph/clusters — the birth-session groups, resolved to node
- * ids for the practice picker (owner ruling 2026-09-29: cluster→nodeIds
- * resolves in the UI; the API stays graph-domain). EVERY group is
- * listed regardless of size (owner ruling, same evening:
- * MIN_CLUSTER_SIZE is a hull-DRAWING rule — practice availability must
- * not inherit a visual threshold), with the partition's own ordering
- * (size desc, ties by id asc). The label join mirrors the snapshot's:
- * chat title / notes file path, null when the naming record is gone —
- * honest, never guessed. A corrupt/empty graph is an empty list. */
-export function graphClusters(): GraphClusterGroup[] {
- const snap = graphSnapshot(stores);
- // PROPOSITIONS ONLY: snap.nodes is the canvas image (imp nodes ride
- // it); the picker offers propositions — an arg_ id in nodeIds would
- // silently vanish inside chainsForSelection and lie about nodeCount.
- return birthSessionPartition(snap.propositions).map((group) => ({
- key: group.id,
- kind: group.id in snap.sessions ? "chat" : "notes",
- label: snap.sessions[group.id] ?? snap.fileSources[group.id] ?? null,
- nodeIds: group.members,
- nodeCount: group.members.length,
- }));
-}
-
/** 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
* read over the chat records (chat-front-door runs), the notes sources
diff --git a/src/server/index.ts b/src/server/index.ts
index ac83d3d..a1e9640 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -7,7 +7,6 @@
export * from "./chat-handler.ts";
export * from "./events-stream.ts";
export * from "./chats.ts";
-export * from "./practice.ts";
export * from "./graph.ts";
export * from "./commit-positions.ts";
export * from "./jobs.ts";
diff --git a/src/server/observability.ts b/src/server/observability.ts
index 5e311c5..a7d0edf 100644
--- a/src/server/observability.ts
+++ b/src/server/observability.ts
@@ -20,7 +20,6 @@
*/
import { extractionStatuses, type ExtractionPhase } from "../agent/subagents/extraction.ts";
-import { practiceStatuses, type PracticePhase } from "../agent/subagents/practice-pipeline.ts";
import { chatStore } from "../store/chats.ts";
import { graphRevision } from "./graph.ts";
import { catalogContextLimits } from "./model-catalog.ts";
@@ -28,7 +27,6 @@ import { listJobs } from "./jobs.ts";
import type { JobRecord } from "../core/contracts.ts";
export { abortExtraction, extractionStatuses, type ExtractionPhase, type RunningExtraction } from "../agent/subagents/extraction.ts";
-export { practiceStatuses, type PracticePhase, type RunningPractice } from "../agent/subagents/practice-pipeline.ts";
export { chatStore } from "../store/chats.ts";
/** A running sub-agent job, joined with its chat's title for the dock.
@@ -56,21 +54,6 @@ export interface ActivityExtraction {
startedAt: number;
}
-/** A live practice ROW in the dock's feed — per-problem, matching the
- * extraction per-chunk pattern (owner ruling, 2026-09-29). id is the
- * ROW KEY (`${setId}:partition` | `${setId}:gen:${pid}` |
- * `${setId}:grade:${pid}`) — the dock's abort target; problemIndex/
- * problemTotal ride when the row belongs to a fan-out. */
-export interface ActivityPractice {
- id: string;
- kind: "generation" | "grading";
- title: string;
- phase: PracticePhase;
- startedAt: number;
- problemIndex?: number;
- problemTotal?: number;
-}
-
/** The dock's whole world in one payload: every running process, every view.
* Jobs are RUNNING-ONLY — finished jobs belong to their chat's transcript;
* the blob disappears when the process ends. Cost math is NOT done here:
@@ -85,7 +68,6 @@ export interface ActivityPractice {
export function activityFeed(): {
jobs: ActivityJob[];
extractions: ActivityExtraction[];
- practice: ActivityPractice[];
graphRevision: number;
} {
const chats = chatStore.listChats();
@@ -115,21 +97,11 @@ export function activityFeed(): {
phase: run.phase,
startedAt: run.startedAt,
}));
- const practice: ActivityPractice[] = practiceStatuses().map((row) => ({
- id: row.key,
- kind: row.kind,
- title: row.label,
- phase: row.phase,
- startedAt: row.startedAt,
- problemIndex: row.problemIndex,
- problemTotal: row.problemTotal,
- }));
// oldest-first on last activity: the stable blob order (ISO strings sort chronologically)
jobs.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt));
return {
jobs,
extractions: extractions.sort((a, b) => a.startedAt - b.startedAt),
- practice: practice.sort((a, b) => a.startedAt - b.startedAt),
graphRevision: graphRevision(),
};
}
diff --git a/src/server/practice.ts b/src/server/practice.ts
deleted file mode 100644
index 9dadc32..0000000
--- a/src/server/practice.ts
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * THE PRACTICE SERVICE (slice 4, 2026-09-29): the thin, route-facing
- * surface over the practice pipeline — request parsing/validation, typed
- * error → response mapping. Routes call THIS, never the pipeline
- * internals (the chats/graph service discipline).
- *
- * The pipeline instance is the chat-handler composition root's export
- * (`practice`, next to `extraction`): engines resolve fresh per stage
- * from BYOK settings, and refusals map to the 503 envelope family.
- */
-
-import { practice } from "./chat-handler.ts";
-import { stores } from "./stores.ts";
-import type { PracticeParams, ProblemSet } from "../core/contracts.ts";
-import {
- BadAnswersError,
- EmptySelectionError,
- PracticeSetNotFound,
- PracticeSetNotReady,
-} from "../agent/subagents/practice-pipeline.ts";
-import {
- ModelsNotConfiguredError,
- ProviderKeyMissingError,
- PROVIDER_KEY_MISSING_CODE,
-} from "../core/settings.ts";
-
-// app/api uses base-URL charset ids (the activity/abort precedent).
-const ID_OK = /^[A-Za-z0-9_-]+$/;
-
-/** GET /api/practice/sets — the list, createdAt desc (store's order). */
-export function listPracticeSets(): ProblemSet[] {
- return stores.practiceStore.listSets();
-}
-
-/** GET /api/practice/sets/[id] — one set, or null (→ 404). */
-export function getPracticeSet(id: string): ProblemSet | null {
- return stores.practiceStore.getSet(id);
-}
-
-/** Strict params: three finite numbers in [0, 1]. */
-function parseParams(raw: unknown): PracticeParams | null {
- if (typeof raw !== "object" || raw === null) return null;
- const p = raw as Record;
- // Named levels ONLY (owner, 2026-09-29): exact unions, nothing between —
- // a rung is a rung; there is no float to interpolate.
- if (p.ladderRung !== 0 && p.ladderRung !== 1 && p.ladderRung !== 2 && p.ladderRung !== 3 && p.ladderRung !== 4) return null;
- if (p.cueDistance !== 0 && p.cueDistance !== 1 && p.cueDistance !== 2) return null;
- if (p.presentation !== "theory" && p.presentation !== "application") return null;
- return {
- ladderRung: p.ladderRung,
- cueDistance: p.cueDistance,
- presentation: p.presentation,
- };
-}
-
-/** The typed pipeline errors → HTTP (refusals → the 503 envelope family,
- * exactly like the import route's extraction mapping). */
-export function practiceErrorResponse(err: unknown): Response {
- if (err instanceof ModelsNotConfiguredError) {
- return Response.json(
- { error: err.envelope.error, code: "models_not_configured", missing: err.envelope.missing },
- { status: 503 },
- );
- }
- if (err instanceof ProviderKeyMissingError) {
- return Response.json(
- {
- error: err.envelope.error,
- code: PROVIDER_KEY_MISSING_CODE,
- provider: err.envelope.provider,
- slots: err.envelope.slots,
- },
- { status: 503 },
- );
- }
- if (err instanceof EmptySelectionError) return Response.json({ error: err.message, code: "empty_selection" }, { 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 PracticeSetNotReady) return Response.json({ error: err.message, code: "not_ready" }, { status: 409 });
- console.error("[practice] unexpected:", err);
- return Response.json({ error: "Internal error" }, { status: 500 });
-}
-
-/** The learner-note cap (creation dialog; the textarea's own maxLength
- * matches — server-side is the law). */
-const NOTE_MAX = 2000;
-
-/** POST /api/practice/sets — 202 {id}; the set generates in the
- * background (fire-and-forget pipeline). Body is {nodeIds, params,
- * title?, note?} 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; note is the learner's
- * free-form creation input, optional). */
-export async function createPracticeSetResponse(body: unknown): Promise {
- const b = (body ?? {}) as Record;
- const params = parseParams(b.params);
- if (!params) {
- return Response.json(
- { error: "params must carry ladderRung (0-4), cueDistance (0-2), presentation (theory|application) as exact levels" },
- { status: 400 },
- );
- }
- const titleHint =
- typeof b.title === "string" && b.title.trim() !== "" ? b.title.trim() : undefined;
- let note: string | undefined;
- if (b.note !== undefined) {
- if (typeof b.note !== "string") {
- return Response.json({ error: "note must be a string" }, { status: 400 });
- }
- const trimmed = b.note.trim();
- if (trimmed.length > NOTE_MAX) {
- return Response.json({ error: `note is over ${NOTE_MAX} characters` }, { status: 400 });
- }
- note = trimmed || undefined; // whitespace-only = no note
- }
- 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?, note?}" }, { status: 400 });
- }
- if (b.nodeIds.length === 0) {
- return Response.json({ error: "nodeIds is empty", code: "empty_selection" }, { status: 400 });
- }
- try {
- const { set } = await practice.createProblemSet(b.nodeIds, params, titleHint, note);
- return Response.json({ id: set.id }, { status: 202 });
- } catch (err) {
- return practiceErrorResponse(err);
- }
-}
-
-/** POST /api/practice/sets/[id]/answers — 202; grades land in the
- * background. Body: {answers: [{problemId, text}]}. */
-export async function submitPracticeAnswersResponse(setId: string, body: unknown): Promise {
- if (!ID_OK.test(setId)) return Response.json({ error: "Invalid set id" }, { status: 400 });
- const b = (body ?? {}) as Record;
- const raw = b.answers;
- if (!Array.isArray(raw) || raw.length === 0 || !raw.every((a): a is { problemId: string; text: string } =>
- typeof a === "object" && a !== null &&
- typeof (a as Record).problemId === "string" &&
- typeof (a as Record).text === "string",
- )) {
- return Response.json({ error: 'Body must be {answers: [{problemId: string, text: string}]}' }, { status: 400 });
- }
- try {
- const { set } = await practice.submitAnswers(setId, raw);
- return Response.json({ id: set.id }, { status: 202 });
- } catch (err) {
- return practiceErrorResponse(err);
- }
-}
diff --git a/src/server/stores.ts b/src/server/stores.ts
index bf148ea..c63f546 100644
--- a/src/server/stores.ts
+++ b/src/server/stores.ts
@@ -9,7 +9,6 @@
import { chatStore } from "../store/chats.ts";
import { graphStore } from "../store/graph.ts";
import { jobStore } from "../store/jobs.ts";
-import { practiceStore } from "../store/practice.ts";
import { settingsStore } from "../store/settings.ts";
import { resolveSettings, type EffectiveSettings } from "../core/settings-resolution.ts";
/** The wired adapters. Satisfies core/graph.ts's GraphDeps structurally. */
@@ -17,7 +16,6 @@ export const stores = {
chatStore,
graphStore,
jobStore,
- practiceStore,
settingsStore,
};
diff --git a/src/store/practice.ts b/src/store/practice.ts
deleted file mode 100644
index fbbcfd9..0000000
--- a/src/store/practice.ts
+++ /dev/null
@@ -1,192 +0,0 @@
-/**
- * PracticeStore — the fs JSON adapter: one file per problem set in
- * data/practice/ (config.PATHS.practice). Problem sets are bounded
- * SELF-CONTAINED documents (propositions + chains frozen at creation —
- * contracts/practice.ts), not append-only ledgers: read-modify-write
- * like settings.json, one-file-per-record like data/jobs. Reads are
- * validated (isProblemSet); corrupt or unparseable files return null /
- * are skipped by listSets — NEVER thrown. Writes are replace-whole,
- * tab-indented, mkdir-on-write.
- *
- * The adapter stays deliberately DUMB about concurrency: parallel
- * read-modify-write cycles on the SAME set (generator jobs finishing
- * around each other) are serialized by the pipeline module (slice 4),
- * not here. This module owns file integrity only.
- */
-
-import * as fs from "node:fs";
-import * as path from "node:path";
-import { PATHS } from "../../config.ts";
-import type { PracticeStore } from "../core/ports.ts";
-import type { ProblemSet } from "../core/contracts.ts";
-
-const PRACTICE_DIR = PATHS.practice;
-
-function setFile(id: string): string {
- if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error(`Invalid problem set id: ${id}`);
- return path.join(PRACTICE_DIR, `${id}.json`);
-}
-
-// ─── validation (hand-written, read-side) ────────────────────────────────────
-
-const isStringArray = (v: unknown): v is string[] =>
- Array.isArray(v) && v.every((x) => typeof x === "string");
-
-const isParams = (v: unknown): boolean => {
- if (typeof v !== "object" || v === null) return false;
- const p = v as Record;
- // Named levels ONLY (owner, 2026-09-29): legacy float-params sets fail
- // here and are skipped from lists — no migration (owner wipes
- // data/practice before testing).
- return (
- (p.ladderRung === 0 ||
- p.ladderRung === 1 ||
- p.ladderRung === 2 ||
- p.ladderRung === 3 ||
- p.ladderRung === 4) &&
- (p.cueDistance === 0 || p.cueDistance === 1 || p.cueDistance === 2) &&
- (p.presentation === "theory" || p.presentation === "application")
- );
-};
-
-const isChains = (v: unknown): boolean =>
- Array.isArray(v) &&
- v.every(
- (c) =>
- typeof c === "object" &&
- c !== null &&
- typeof (c as Record).id === "string" &&
- typeof (c as Record).conclusion === "string" &&
- isStringArray((c as Record).premises),
- );
-
-const isPropositions = (v: unknown): boolean => {
- if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
- return Object.values(v as Record).every(
- (p) =>
- typeof p === "object" &&
- p !== null &&
- typeof (p as Record).name === "string" &&
- typeof (p as Record).statement === "string",
- );
-};
-
-const SET_STATUSES = new Set(["partitioning", "generating", "ready", "error"]);
-const PROBLEM_STATUSES = new Set(["generating", "ready", "error"]);
-function isGrade(g: unknown): boolean {
- if (typeof g !== "object" || g === null) return false;
- const v = g as Record;
- // Integer 0–10 + non-empty reasoning (one-attempt redesign, owner
- // 2026-09-29: the grader scores; verdicts + prose feedback retired).
- return (
- typeof v.score === "number" &&
- Number.isInteger(v.score) &&
- v.score >= 0 &&
- v.score <= 10 &&
- typeof v.reasoning === "string" &&
- v.reasoning.length > 0 &&
- typeof v.gradedAt === "string"
- );
-}
-
-const isProblem = (v: unknown): boolean => {
- if (typeof v !== "object" || v === null) return false;
- const p = v as Record;
- if (typeof p.id !== "string" || !isStringArray(p.chains)) return false;
- if (typeof p.statement !== "string" || typeof p.solution !== "string" || typeof p.rubric !== "string") return false;
- if (typeof p.status !== "string" || !PROBLEM_STATUSES.has(p.status)) return false;
- if (p.error !== undefined && typeof p.error !== "string") return false;
- if (p.answer !== undefined && typeof p.answer !== "string") return false;
- if (p.grade !== undefined && !isGrade(p.grade)) return false;
- return true;
-};
-
-/** Read-side shape guard (exported for the store tests). Types-only:
- * dial RANGES and chain invariants (id === conclusion) are creation-seam
- * duties, not read duties — a file that parses and type-checks reads. */
-export function isProblemSet(value: unknown): value is ProblemSet {
- if (typeof value !== "object" || value === null) return false;
- const v = value as Record;
- return (
- typeof v.id === "string" &&
- v.id.startsWith("ps_") &&
- typeof v.title === "string" &&
- (v.note === undefined || typeof v.note === "string") &&
- typeof v.createdAt === "string" &&
- isParams(v.params) &&
- isStringArray(v.nodeIds) &&
- isPropositions(v.propositions) &&
- isChains(v.chains) &&
- typeof v.status === "string" &&
- SET_STATUSES.has(v.status) &&
- (v.error === undefined || typeof v.error === "string") &&
- Array.isArray(v.problems) &&
- v.problems.every(isProblem)
- );
-}
-
-// ─── the adapter ─────────────────────────────────────────────────────────────
-
-function writeSetFile(set: ProblemSet): void {
- fs.mkdirSync(PRACTICE_DIR, { recursive: true });
- fs.writeFileSync(setFile(set.id), JSON.stringify(set, null, "\t"), "utf-8");
-}
-
-function createSet(init: Omit): ProblemSet {
- const set: ProblemSet = {
- ...init,
- id: `ps_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`,
- updatedAt: init.updatedAt ?? init.createdAt,
- };
- writeSetFile(set);
- return set;
-}
-
-function getSet(id: string): ProblemSet | null {
- let parsed: unknown;
- try {
- parsed = JSON.parse(fs.readFileSync(setFile(id), "utf-8"));
- } catch {
- return null; // unknown id or unparseable file — never thrown
- }
- return isProblemSet(parsed) ? parsed : null;
-}
-
-function listSets(): ProblemSet[] {
- try {
- fs.mkdirSync(PRACTICE_DIR, { recursive: true });
- return fs
- .readdirSync(PRACTICE_DIR)
- .filter((f) => f.endsWith(".json"))
- .map((f) => getSet(f.slice(0, -5)))
- .filter((s): s is ProblemSet => s !== null)
- .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
- } catch {
- return []; // unreadable dir — the practice page shows empty
- }
-}
-
-function saveSet(set: ProblemSet): void {
- if (!isProblemSet(set)) throw new Error("problem set record failed validation");
- // The heartbeat: every write re-stamps (the boot sweep's grace window
- // reads this — a live run is structurally never 120s stale).
- set.updatedAt = new Date().toISOString();
- writeSetFile(set);
-}
-
-function deleteSet(id: string): boolean {
- try {
- fs.unlinkSync(setFile(id));
- return true;
- } catch {
- return false; // already gone — best-effort truth
- }
-}
-
-export const practiceStore: PracticeStore = {
- createSet,
- getSet,
- listSets,
- saveSet,
- deleteSet,
-};
diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts
deleted file mode 100644
index 9abb8e8..0000000
--- a/tests/practice-agents.test.ts
+++ /dev/null
@@ -1,404 +0,0 @@
-/**
- * THE PRACTICE AGENTS (slice 3, 2026-09-29): partitioner / problem
- * generator / grader on the shared runSubmitAgent seam — single-submit
- * workers whose LAWS (true partition; target echo) error back to the
- * model as tool results and retry. Faux-engine overrides, the same
- * seams as extraction.test.ts.
- */
-
-import assert from "node:assert/strict";
-import { describe, it } from "node:test";
-import {
- createFauxCore,
- fauxAssistantMessage,
- fauxToolCall,
- type FauxResponseStep,
- type ToolCall,
-} from "@earendil-works/pi-ai";
-import type { PiEngine } from "../src/agent/models.ts";
-import {
- GENERATOR_DOCTRINE,
- GRADER_INSTRUCTIONS,
-} from "../src/agent/subagents/practice.ts";
-import { createPracticeAgents, renderChains, renderParams, renderPreviousProblems } from "../src/agent/subagents/practice.ts";
-import { ModelsNotConfiguredError, ProviderKeyMissingError, type ProviderKeyMissing } from "../src/core/settings.ts";
-import type { PracticeParams, ReasoningChain } from "../src/core/contracts.ts";
-
-// ─── fixtures ────────────────────────────────────────────────────────────────
-
-const propositions = {
- n1: { name: "Self-ownership", statement: "Each person owns themself." },
- n2: { name: "Homesteading", statement: "Unowned goods are claimed by first use." },
- n3: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
-};
-const chains: ReasoningChain[] = [
- { id: "n3", premises: ["n1"], conclusion: "n3" },
- { id: "n2", premises: [], conclusion: "n2" },
-];
-const params: PracticeParams = { ladderRung: 3, cueDistance: 2, presentation: "application" };
-
-function fauxEngine(steps: FauxResponseStep[]): PiEngine {
- const faux = createFauxCore({});
- faux.setResponses(steps);
- return { model: faux.getModel(), streamFn: faux.streamSimple };
-}
-
-/** The whole deliverable in ONE submit call; stops after the clean submit. */
-function submitModel(name: string, input: unknown): PiEngine {
- return fauxEngine([
- () =>
- fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
- stopReason: "toolUse",
- }),
- ]);
-}
-
-/** A first (LAW-violating) submit, then the corrected one — the retry path. */
-function retryModel(name: string, bad: unknown, good: unknown): PiEngine {
- return fauxEngine([
- () =>
- fauxAssistantMessage([fauxToolCall(name, bad as ToolCall["arguments"], { id: `${name}-bad` })], {
- stopReason: "toolUse",
- }),
- () =>
- fauxAssistantMessage([fauxToolCall(name, good as ToolCall["arguments"], { id: `${name}-good` })], {
- stopReason: "toolUse",
- }),
- ]);
-}
-
-/** An engine that never calls a tool (the failure path). */
-function silentModel(): PiEngine {
- return fauxEngine([fauxAssistantMessage("I refuse to use tools"), fauxAssistantMessage("…")]);
-}
-
-/** A one-shot submitter that first records the LAST USER MESSAGE it saw —
- * for pinning what actually rode the prompt. */
-function capturingSubmitModel(name: string, input: unknown, seen: string[]): PiEngine {
- return fauxEngine([
- (context) => {
- const messages = (context as { messages: Array<{ role: string; content: unknown }> }).messages;
- for (let i = messages.length - 1; i >= 0; i--) {
- const m = messages[i]!;
- if (m.role !== "user") continue;
- seen.push(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
- break;
- }
- return fauxAssistantMessage([fauxToolCall(name, input as ToolCall["arguments"], { id: `${name}-call` })], {
- stopReason: "toolUse",
- });
- },
- ]);
-}
-
-// ─── rendering ───────────────────────────────────────────────────────────────
-
-describe("renderParams — named levels, never a number", () => {
- it("renders the exact named-level block", () => {
- const out = renderParams(params);
- assert.equal(
- out,
- [
- "THE PARAMETERS",
- "Ladder rung 3 — Composition: several steps, chained; the path is narrow but must be held and run.",
- "Cue distance 2 — Far context: as far from the typical cue as recognition allows, the edge of the recognizable band — the proposition must still genuinely apply, and the learner must be able to find it by looking.",
- 'Presentation: Application — a concrete worldly affair — a case, episode, system, or story — in which the proposition\'s structure appears in the wild.',
- ].join("\n"),
- );
- });
-});
-
-describe("rendering (the agent-facing text)", () => {
- it("renderChains: legend with names + statements, chain ids LEAD every line (the referenceable anchor)", () => {
- const text = renderChains(chains, propositions);
- assert.match(text, /\[n1\] Self-ownership — Each person owns themself\./);
- assert.match(text, /\[n2\] Homesteading — Unowned goods are claimed by first use\./);
- // The chain id precedes every line — the id the submit references.
- assert.match(text, /\[n3\] n1 → n3/);
- // A bare proposition: its own id, twice — the chain id IS the
- // proposition id (owner fix, 2026-09-30 evening).
- assert.match(text, /\[n2\] n2 \(no premises\)/);
- });
-
- it("renderPreviousProblems: numbered statements + chains; empty = first-set line", () => {
- assert.equal(renderPreviousProblems([]), "None — this is the learner's first set.");
- assert.match(
- renderPreviousProblems([{ statement: "Why does arguing presuppose ownership?", chains: ["n3"] }]),
- /^1\. Why does arguing presuppose ownership\? \(chains: n3\)$/,
- );
- });
-});
-
-// ─── the partitioner ─────────────────────────────────────────────────────────
-
-describe("runPartitioner", () => {
- it("accepts a true partition and returns its step log", async () => {
- const agents = createPracticeAgents({
- partitioner: submitModel("submit_partition", { partitions: [{ chains: ["n3"] }, { chains: ["n2"] }] }),
- });
- const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
- assert.deepEqual(out.partitions, [{ chains: ["n3"] }, { chains: ["n2"] }]);
- assert.ok(out.log.model);
- assert.equal(out.log.steps.length, 1);
- });
-
- it("errors back and retries when a chain is missing (coverage)", async () => {
- const agents = createPracticeAgents({
- partitioner: retryModel(
- "submit_partition",
- { partitions: [{ chains: ["n3"] }] }, // n2 dropped
- { partitions: [{ chains: ["n3", "n2"] }] },
- ),
- });
- const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
- assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
- assert.equal(out.log.steps.length, 2); // the retry is billed
- });
-
- it("errors back and retries on a doubled chain (exactly-once law)", async () => {
- const agents = createPracticeAgents({
- partitioner: retryModel(
- "submit_partition",
- { partitions: [{ chains: ["n3"] }, { chains: ["n3", "n2"] }] }, // n3 twice
- { partitions: [{ chains: ["n3", "n2"] }] },
- ),
- });
- const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
- assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
- });
-
- it("errors back on unknown chain ids", async () => {
- const agents = createPracticeAgents({
- partitioner: retryModel(
- "submit_partition",
- { partitions: [{ chains: ["n3", "n2", "nX"] }] },
- { partitions: [{ chains: ["n3", "n2"] }] },
- ),
- });
- const out = await agents.runPartitioner({ chains, propositions, params, previousProblems: [] });
- assert.deepEqual(out.partitions, [{ chains: ["n3", "n2"] }]);
- });
-
- it("neverSubmitted: a model that refuses to submit throws", async () => {
- const agents = createPracticeAgents({ partitioner: silentModel() });
- await assert.rejects(
- agents.runPartitioner({ chains, propositions, params, previousProblems: [] }),
- /the partitioner never submitted a partition/,
- );
- });
-
- it("the learner's note: rides the prompt as its own block when present, absent when unset", async () => {
- const seen: string[] = [];
- const agents = createPracticeAgents({
- partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seen),
- });
- await agents.runPartitioner({
- chains,
- propositions,
- params,
- previousProblems: [],
- note: "one big problem, please — and lean on pricing",
- });
- assert.equal(seen.length, 1);
- assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"), "the block title");
- assert.ok(seen[0]!.includes("one big problem, please — and lean on pricing"), "the note text");
-
- const seenBare: string[] = [];
- const bare = createPracticeAgents({
- partitioner: capturingSubmitModel("submit_partition", { partitions: [{ chains: ["n3", "n2"] }] }, seenBare),
- });
- await bare.runPartitioner({ chains, propositions, params, previousProblems: [] });
- assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
- });
-});
-
-// ─── the problem generator ───────────────────────────────────────────────────
-
-describe("runProblemGenerator", () => {
- const generatorInput = {
- target: ["n3"],
- fullChains: chains,
- propositions,
- params,
- previousProblems: [],
- };
-
- it("accepts a problem echoing its target verbatim", async () => {
- const agents = createPracticeAgents({
- problemGenerator: submitModel("submit_problem", {
- chains: ["n3"],
- statement: "A critic of property rights starts arguing. What does his arguing already presuppose?",
- solution: "That he owns himself — argumentation presupposes self-ownership.",
- rubric: "- names self-ownership as the presupposition",
- }),
- });
- const out = await agents.runProblemGenerator(generatorInput);
- assert.deepEqual(out.problem.chains, ["n3"]);
- assert.ok(out.log.model);
- });
-
- it("errors back and retries when chains do not echo the target (equality law)", async () => {
- const agents = createPracticeAgents({
- problemGenerator: retryModel(
- "submit_problem",
- {
- chains: ["n3", "n2"], // invented scope — the target is n3 only
- statement: "…",
- solution: "…",
- rubric: "…",
- },
- {
- chains: ["n3"],
- statement: "…",
- solution: "…",
- rubric: "…",
- },
- ),
- });
- const out = await agents.runProblemGenerator(generatorInput);
- assert.deepEqual(out.problem.chains, ["n3"]);
- assert.equal(out.log.steps.length, 2);
- });
-
- it("neverSubmitted: a silent model throws", async () => {
- const agents = createPracticeAgents({ problemGenerator: silentModel() });
- await assert.rejects(agents.runProblemGenerator(generatorInput), /the problem generator never submitted a problem/);
- });
-
- it("the learner's note: rides the prompt when present, absent when unset", async () => {
- const seen: string[] = [];
- const agents = createPracticeAgents({
- problemGenerator: capturingSubmitModel(
- "submit_problem",
- { chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
- seen,
- ),
- });
- await agents.runProblemGenerator({ ...generatorInput, note: "situations from central banking, if you can" });
- assert.ok(seen[0]!.includes("THE LEARNER'S NOTE"));
- assert.ok(seen[0]!.includes("situations from central banking, if you can"));
-
- const seenBare: string[] = [];
- const bare = createPracticeAgents({
- problemGenerator: capturingSubmitModel(
- "submit_problem",
- { chains: ["n3"], statement: "…", solution: "…", rubric: "…" },
- seenBare,
- ),
- });
- await bare.runProblemGenerator(generatorInput);
- assert.ok(!seenBare[0]!.includes("THE LEARNER'S NOTE"), "no block when no note");
- });
-});
-
-// ─── the grader ──────────────────────────────────────────────────────────────
-
-describe("runGrader", () => {
- const graderInput = {
- problem: {
- statement: "What does arguing presuppose?",
- solution: "Self-ownership.",
- rubric: "- names self-ownership",
- },
- propositions: { n3: propositions.n3! },
- answer: "Well, if I'm arguing at all, I must own myself…",
- };
-
- it("returns the score + reasoning with its step log", async () => {
- const agents = createPracticeAgents({
- grader: submitModel("submit_grade", { score: 9, reasoning: "The recall happened." }),
- });
- const out = await agents.runGrader(graderInput);
- assert.equal(out.score, 9);
- assert.equal(out.reasoning, "The recall happened.");
- assert.ok(out.log.model);
- assert.equal(out.log.steps.length, 1);
- });
-
- it("schema-rejects an out-of-range score and retries", async () => {
- const agents = createPracticeAgents({
- grader: retryModel(
- "submit_grade",
- { score: 12, reasoning: "…" },
- { score: 6, reasoning: "Half the target came to mind." },
- ),
- });
- const out = await agents.runGrader(graderInput);
- assert.equal(out.score, 6);
- assert.equal(out.log.steps.length, 2);
- });
-
- it("neverSubmitted: a silent model throws", async () => {
- const agents = createPracticeAgents({ grader: silentModel() });
- await assert.rejects(agents.runGrader(graderInput), /the grader never submitted a grade/);
- });
-});
-
-// ─── refusal gates ───────────────────────────────────────────────────────────
-
-describe("refusal gates (BYOK honest refusal, extraction's pattern)", () => {
- it("throws ModelsNotConfiguredError when no engines resolve", () => {
- const agents = createPracticeAgents({});
- assert.throws(() => agents.refuseIfStagesUnready(), ModelsNotConfiguredError);
- });
-
- it("throws ProviderKeyMissingError when a configured stage's provider key is gone", () => {
- const engine = { model: { provider: "prov", id: "m" } } as PiEngine;
- const refusal: ProviderKeyMissing = { provider: "prov", slots: ["strong"], error: "prov key missing", code: "provider_key_missing" };
- const agents = createPracticeAgents({
- engineFor: () => engine,
- providerKeyMissing: () => refusal,
- });
- assert.throws(() => agents.refuseIfStagesUnready(), ProviderKeyMissingError);
- });
-
- it("passes when every stage has an engine and its key", () => {
- const engine = { model: { provider: "prov", id: "m" } } as PiEngine;
- const agents = createPracticeAgents({ engineFor: () => engine, providerKeyMissing: () => null });
- agents.refuseIfStagesUnready(); // no throw
- });
-});
-
-// ─── formatting doctrine (owner-approved 2026-09-29) ─────────────────────────
-
-describe("formatting doctrine pins", () => {
- it("GENERATOR_DOCTRINE pins the learner-note stance (weighed, never owed)", () => {
- assert.ok(
- GENERATOR_DOCTRINE.includes("- THE LEARNER'S NOTE (optional): free-form text the learner attached when creating the set"),
- );
- assert.ok(GENERATOR_DOCTRINE.includes("never let it move a problem off its rung or out of its band"));
- });
-
- it("GENERATOR_DOCTRINE carries the FORMATTING bullet verbatim (Markdown + LaTeX + fenced code + tables/lists)", () => {
- assert.ok(
- GENERATOR_DOCTRINE.includes(
- "- FORMATTING: your statement, solution, and rubric are rendered as full Markdown — use everything it offers. Mathematics is always LaTeX: inline as \\(…\\), display as \\[…\\] on its own lines — a bare $ is literal currency and never starts math. Inside math, use LaTeX commands for symbols, not unicode stand-ins (they fail to render). Code, pseudocode, and formal systems live in fenced code blocks with a language tag; identifiers and symbols inline as `code`. Use tables for parallel structure and comparisons, lists for enumerations. Skip headers — at this size they are pointless decoration; structure with paragraphs, bold labels, and lists (a genuinely long derivation may earn one). Prose-only renderings of mathematical or structured content are a defect: if a sentence carries an equation, a derivation, or a piece of code, format it as what it is.",
- ),
- "the exact FORMATTING sentence",
- );
- // it sits after the parameters bullet, before PREVIOUS PROBLEMS
- assert.ok(
- GENERATOR_DOCTRINE.indexOf("- FORMATTING:") > GENERATOR_DOCTRINE.indexOf("THE PRESENTATION:"),
- "after the presentation bullet",
- );
- assert.ok(
- GENERATOR_DOCTRINE.indexOf("- PREVIOUS PROBLEMS:") > GENERATOR_DOCTRINE.indexOf("- FORMATTING:"),
- "before PREVIOUS PROBLEMS",
- );
- });
-
- it("GRADER_INSTRUCTIONS carries the formatting paragraph before the OUTPUT section", () => {
- assert.ok(
- GRADER_INSTRUCTIONS.includes(
- "The reasoning is plain prose, one or two sentences: which rubric points were met or missed and, when something failed, WHICH proposition failed to come to mind. Mathematics is LaTeX — inline as \\(…\\), display as \\[…\\] (a bare $ is literal currency); no headers, no lists — minimal is the standard.",
- ),
- "the exact grader paragraph",
- );
- assert.ok(
- GRADER_INSTRUCTIONS.indexOf("Feedback renders as full Markdown") < GRADER_INSTRUCTIONS.indexOf("OUTPUT — your entire deliverable is ONE submit_grade call"),
- "before the OUTPUT section",
- );
- });
-
-
-});
diff --git a/tests/practice-pipeline.test.ts b/tests/practice-pipeline.test.ts
deleted file mode 100644
index 2bba47c..0000000
--- a/tests/practice-pipeline.test.ts
+++ /dev/null
@@ -1,1019 +0,0 @@
-/**
- * THE PRACTICE PIPELINE (slice 4, 2026-09-29): set creation → partition →
- * stubs → 3-wide generator fan-out → ready; answers → grading; abort;
- * boot sweep; registry shape. Faux engines (the extraction.test.ts
- * seams) + in-memory stores; the globalThis registry/slots/locks are
- * per-process (node:test isolates files), reset implicitly per fixture.
- */
-
-import assert from "node:assert/strict";
-import { afterEach, describe, it } from "node:test";
-import {
- createFauxCore,
- fauxAssistantMessage,
- fauxToolCall,
- type FauxResponseStep,
- type ToolCall,
-} from "@earendil-works/pi-ai";
-import type { PiEngine } from "../src/agent/models.ts";
-import { bgLlmSlots, withBgLlmSlot } from "../src/agent/subagents/bg-llm-semaphore.ts";
-import {
- createPracticePipeline,
- practiceStatuses,
- EmptySelectionError,
- PracticeSetNotFound,
- PracticeSetNotReady,
- BadAnswersError,
- type RunningPractice,
-} from "../src/agent/subagents/practice-pipeline.ts";
-import { subscribe } from "../src/core/events.ts";
-import { chainCoverage } from "../src/core/practice.ts";
-import type { GraphChain, GraphNode, ProblemSet } from "../src/core/contracts.ts";
-import type { ChatStore, GraphStore, PracticeStore } from "../src/core/ports.ts";
-import { ModelsNotConfiguredError } from "../src/core/settings.ts";
-
-// ─── in-memory stores ────────────────────────────────────────────────────────
-
-function memoryGraph(nodes: GraphNode[], chains: GraphChain[]): GraphStore {
- return {
- appendNode: () => {},
- readNodes: () => nodes.map((n) => ({ ...n, provenance: [...n.provenance], aliases: [...n.aliases] })),
- appendChain: () => {},
- readChains: () => chains.map((c) => ({ ...c, premises: [...c.premises], provenance: [...c.provenance] })),
- appendCheckpoint: () => {},
- readCheckpoints: () => [],
- appendCoverage: () => {},
- readCoverage: () => [],
- upsertSource: () => {},
- readSources: () => [],
- appendSourceUsage: () => {},
- readPositions: () => ({}),
- writePositions: () => {},
- rewriteLedgers: () => {},
- } satisfies GraphStore;
-}
-
-let storeSeq = 0;
-function memoryPracticeStore() {
- const sets = new Map();
- const tag = `u${++storeSeq}`;
- let n = 0;
- const store: PracticeStore = {
- createSet: (init) => {
- const id = `ps_t${tag}_${++n}`;
- const set = { ...init, id, problems: init.problems.map((p) => ({ ...p })) };
- sets.set(id, set);
- return structuredClone(set);
- },
- getSet: (id) => {
- const s = sets.get(id);
- return s ? structuredClone(s) : null;
- },
- listSets: () =>
- [...sets.values()].map((s) => structuredClone(s)).sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)),
- saveSet: (set) => void sets.set(set.id, structuredClone(set)),
- deleteSet: (id) => {
- const had = sets.has(id);
- sets.delete(id);
- return had;
- },
- };
- return { store, sets };
-}
-
-function memoryChats(): Pick {
- const chats = new Map();
- chats.set("chat_1", {
- id: "chat_1",
- title: "Ethics lesson",
- createdAt: "2026-09-29T00:00:00.000Z",
- updatedAt: "2026-09-29T00:00:00.000Z",
- messages: [],
- });
- return {
- getChat: (id) => chats.get(id) ?? null,
- listChats: () => [...chats.values()].map((c) => ({ ...c, messages: 0 }) as never),
- };
-}
-
-// ─── faux engines ────────────────────────────────────────────────────────────
-
-function fauxEngine(steps: FauxResponseStep[]): PiEngine {
- const faux = createFauxCore({});
- faux.setResponses(steps);
- return { model: faux.getModel(), streamFn: faux.streamSimple };
-}
-
-/** The target chains parsed from a generator prompt ("YOUR TARGET" block). */
-function targetOf(prompt: string): string[] {
- const section = prompt.split("THE FULL SET")[0] ?? "";
- const ids: string[] = [];
- for (const line of section.split("\n")) {
- // Chain ids now LEAD every line ([arg_x] a, b → c) — strip the anchor
- // bracket to recover the id the submit must echo.
- const body = line.replace(/^\[[^\]]+\]\s+/, "");
- if (body.includes("→")) ids.push(body.split("→")[1]!.trim());
- else if (body.includes("(no premises)")) ids.push(body.replace("(no premises)", "").trim());
- }
- return ids;
-}
-
-function lastUserText(context: { messages: Array<{ role: string; content: unknown }> }): string {
- for (let i = context.messages.length - 1; i >= 0; i--) {
- const m = context.messages[i]!;
- if (m.role !== "user") continue;
- return typeof m.content === "string" ? m.content : JSON.stringify(m.content);
- }
- return "";
-}
-
-/** A generator that echoes whatever target it was assigned (law-compliant).
- * Queued generously: one response per ATTEMPT, and a failing target
- * burns maxTurns (4) attempts before neverSubmitted fires. */
-function echoGenerator(count = 24, failTargets: string[] = []): PiEngine {
- const factory: FauxResponseStep = (context) => {
- const prompt = lastUserText(context as never);
- const target = targetOf(prompt);
- if (target.some((id) => failTargets.includes(id))) {
- return fauxAssistantMessage("I would rather not submit a problem.");
- }
- return fauxAssistantMessage(
- [
- fauxToolCall(
- "submit_problem",
- {
- chains: target,
- statement: `Problem on ${target.join(", ")}`,
- solution: "The model solution.",
- rubric: "- names it",
- } as ToolCall["arguments"],
- { id: `gen-${target.join("-")}` },
- ),
- ],
- { stopReason: "toolUse" },
- );
- };
- return fauxEngine(Array.from({ length: count }, () => factory));
-}
-
-/** A grader whose score encodes in the answer text ("solid" → 9, else 6). */
-function echoGrader(count: number): PiEngine {
- const factory: FauxResponseStep = (context) => {
- const answer = lastUserText(context as never).split("THE LEARNER'S ANSWER\n")[1] ?? "";
- return fauxAssistantMessage(
- [
- fauxToolCall(
- "submit_grade",
- {
- score: answer.includes("solid") ? 9 : 6,
- reasoning: "Which rubric points were met.",
- } as ToolCall["arguments"],
- { id: "grade-call" },
- ),
- ],
- { stopReason: "toolUse" },
- );
- };
- return fauxEngine(Array.from({ length: count }, () => factory));
-}
-
-/** A partitioner submitting the given partition. */
-function partitionModel(partitions: Array<{ chains: string[] }>): PiEngine {
- return fauxEngine([
- () =>
- fauxAssistantMessage([fauxToolCall("submit_partition", { partitions } as ToolCall["arguments"], { id: "p-call" })], {
- stopReason: "toolUse",
- }),
- ]);
-}
-
-/** A partitioner that RECORDS the prompt it saw before submitting — the
- * learner-note thread pin. */
-function capturingPartitioner(partitions: Array<{ chains: string[] }>, seen: string[]): PiEngine {
- return fauxEngine([
- (context) => {
- seen.push(lastUserText(context as never));
- return fauxAssistantMessage(
- [fauxToolCall("submit_partition", { partitions } as ToolCall["arguments"], { id: "p-call" })],
- { stopReason: "toolUse" },
- );
- },
- ]);
-}
-
-/** An echo generator that RECORDS each prompt before submitting. */
-function capturingEchoGenerator(seen: string[], count: number): PiEngine {
- const factory: FauxResponseStep = (context) => {
- const prompt = lastUserText(context as never);
- seen.push(prompt);
- const target = targetOf(prompt);
- return fauxAssistantMessage(
- [
- fauxToolCall(
- "submit_problem",
- {
- chains: target,
- statement: `Problem on ${target.join(", ")}`,
- solution: "The model solution.",
- rubric: "- names it",
- } as ToolCall["arguments"],
- { id: "g-call" },
- ),
- ],
- { stopReason: "toolUse" },
- );
- };
- return fauxEngine(Array.from({ length: count }, () => factory));
-}
-
-/** Hangs until the call's signal aborts (then resolves no submit call) —
- * the extraction.test.ts cancellation-mimicking idiom. Queue one step
- * per concurrent call (faux queues are finite). */
-/** Abort-or-gate generator: each step resolves "late" when ITS call's
- * signal aborts, or a valid submit when the gate opens. The aborted
- * problem burns its retries and errors; the sibling completes on the
- * gate — order-independent (every step behaves identically). */
-function abortOrGateModel(count: number, gate: Promise): PiEngine {
- const factory: FauxResponseStep = (context, options) =>
- new Promise((resolve) => {
- const sig = options?.signal;
- if (sig?.aborted) {
- resolve(fauxAssistantMessage("late"));
- return;
- }
- let settled = false;
- const done = (m: ReturnType) => {
- if (settled) return;
- settled = true;
- resolve(m);
- };
- sig?.addEventListener("abort", () => done(fauxAssistantMessage("late")), { once: true });
- void gate.then(() => {
- const target = targetOf(lastUserText(context as never));
- done(
- fauxAssistantMessage(
- [fauxToolCall("submit_problem", { chains: target, statement: `S ${target.join(",")}`, solution: "v", rubric: "r" } as ToolCall["arguments"], { id: `g-${target.join("-")}` })],
- { stopReason: "toolUse" },
- ),
- );
- });
- });
- return fauxEngine(Array.from({ length: count }, () => factory));
-}
-
-function hangingModel(count = 1): PiEngine {
- const factory: FauxResponseStep = (_context, options) =>
- new Promise((resolve) => {
- const sig = options?.signal;
- if (sig?.aborted) {
- resolve(fauxAssistantMessage("late"));
- return;
- }
- sig?.addEventListener("abort", () => resolve(fauxAssistantMessage("late")), { once: true });
- });
- return fauxEngine(Array.from({ length: count }, () => factory));
-}
-
-// ─── fixtures ────────────────────────────────────────────────────────────────
-
-const NOW = "2026-09-29T12:00:00.000Z";
-const pt = (chatId: string) => [{ chatId, messageId: "m1" }];
-/** The diamond + isolated node: chains b, c, d (multi/linked) + e (singleton). */
-function diamondGraph(): GraphStore {
- const node = (id: string, name: string): GraphNode => ({
- id,
- name,
- statement: `${name} statement`,
- aliases: [],
- createdAt: NOW,
- provenance: pt("chat_1"),
- });
- const chain = (from: string, to: string): GraphChain => ({
- id: to, // fixture id = conclusion id (the form the generator prompt renders)
- premises: [from],
- conclusion: to,
- provenance: pt("chat_1"),
- createdAt: NOW,
- });
- return memoryGraph(
- [node("a", "A"), node("b", "B"), node("c", "C"), node("d", "D"), node("e", "E")],
- [chain("a", "b"), chain("a", "c"), { id: "d", premises: ["b", "c"], conclusion: "d", provenance: pt("chat_1"), createdAt: NOW }],
- );
-}
-
-const PARAMS = { ladderRung: 4, cueDistance: 1, presentation: "theory" } as const;
-
-function fixture(partial: Partial[0]> = {}) {
- const practice = memoryPracticeStore();
- // The shared background-LLM pool lives on globalThis — start every
- // test with a fresh, empty one (no inherited held permits).
- delete (globalThis as { __lhLlmSlots?: unknown }).__lhLlmSlots;
- const deps = {
- graphStore: diamondGraph(),
- practiceStore: practice.store,
- ...partial,
- };
- return { practice, deps, make: () => createPracticePipeline(deps) };
-}
-
-// Failure isolation between tests: a timed-out test can leave rows,
-// locks, and held permits behind — the NEXT test must start clean.
-afterEach(() => {
- const g = globalThis as {
- __lhPracticeRuns?: Map;
- __lhPracticeLocks?: Map;
- __lhLlmSlots?: unknown;
- };
- g.__lhPracticeRuns?.clear();
- g.__lhPracticeLocks?.clear();
- delete g.__lhLlmSlots;
-});
-
-/** Reset the boot sweep's once-per-process flag — each call simulates a
- * fresh process for the sweep's semantics (production sets it once and
- * never re-sweeps; tests need controlled re-arming). */
-const resetSweep = () => {
- delete (globalThis as { __lhPracticeSweepDone?: boolean }).__lhPracticeSweepDone;
-};
-
-/** Await a condition (the extraction until() idiom, trimmed). */
-async function until(cond: () => boolean, ms = 2000): Promise {
- const t0 = Date.now();
- while (!cond()) {
- if (Date.now() - t0 > ms) throw new Error("condition never became true");
- await new Promise((r) => setTimeout(r, 5));
- }
-}
-
-// ─── generation ──────────────────────────────────────────────────────────────
-
-describe("createProblemSet (generation)", () => {
- it("end-to-end: partition → stubs → generators fill → ready, coverage complete", async () => {
- // Generators gate on our promise: the stub state is assertable
- // deterministically (ungated, the whole run finishes between polls).
- let release!: () => void;
- const gate = new Promise((r) => (release = r));
- const factory: FauxResponseStep = (context) =>
- gate.then(() => {
- const target = targetOf(lastUserText(context as never));
- return fauxAssistantMessage(
- [
- fauxToolCall(
- "submit_problem",
- { chains: target, statement: `Problem on ${target.join(", ")}`, solution: "The model solution.", rubric: "- names it" } as ToolCall["arguments"],
- { id: `gen-${target.join("-")}` },
- ),
- ],
- { stopReason: "toolUse" },
- );
- });
- const f = fixture({
- partitioner: partitionModel([{ chains: ["b", "c"] }, { chains: ["d"] }, { chains: ["e"] }]),
- problemGenerator: fauxEngine(Array.from({ length: 3 }, () => factory)),
- grader: echoGrader(1),
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
-
- assert.match(set.id, /^ps_/);
- assert.equal(set.status, "partitioning");
- assert.equal(set.title, `Practice — ${new Date().toISOString().slice(0, 10)}`); // nodeIds path: dated fallback title
-
- // Stubs appear the moment the partition lands — numbered, generating.
- await until(() => f.practice.store.getSet(set.id)?.status === "generating");
- const mid = f.practice.store.getSet(set.id)!;
- assert.equal(mid.problems.length, 3);
- for (const p of mid.problems) {
- assert.equal(p.status, "generating");
- assert.equal(p.statement, "");
- }
-
- release();
- await done;
- const final = f.practice.store.getSet(set.id)!;
- assert.equal(final.status, "ready");
- assert.equal(final.problems.filter((p) => p.status === "ready").length, 3);
- for (const p of final.problems) assert.match(p.statement, /^Problem on /);
- // Coverage: the union of problems' chains equals the set's chains.
- assert.deepEqual(chainCoverage({ chains: final.chains, problems: final.problems }).missing, []);
- assert.deepEqual(
- final.problems.flatMap((p) => p.chains).sort(),
- ["b", "c", "d", "e"],
- );
- // Registry cleared.
- assert.equal(practiceStatuses().length, 0);
- });
-
- it("push S5: registerRow/phase-writes/finishPractice all announce activity-changed; reads stay silent", async () => {
- const factory: FauxResponseStep = () =>
- fauxAssistantMessage(
- [
- fauxToolCall(
- "submit_problem",
- { chains: ["b"], statement: "s", solution: "v", rubric: "r" } as ToolCall["arguments"],
- { id: "gen-b" },
- ),
- ],
- { stopReason: "toolUse" },
- );
- const f = fixture({
- partitioner: partitionModel([{ chains: ["b"] }]),
- problemGenerator: fauxEngine([factory]),
- grader: echoGrader(1),
- });
- const pipeline = f.make();
-
- let announces = 0;
- const off = subscribe((e) => {
- if (e.type === "activity-changed") announces++;
- });
- // Reads announce nothing.
- practiceStatuses();
- assert.equal(announces, 0);
-
- const { done } = await pipeline.createProblemSet(["a", "b"], PARAMS);
- await done;
- // The lifecycle announced: partition row register + its finish,
- // gen row register + the generating write + its finish.
- assert.ok(announces >= 5, `register/phase/finish all announced (got ${announces})`);
- const afterDone = announces;
- practiceStatuses();
- assert.equal(announces, afterDone, "reads stay silent");
- off();
- });
-
- it("the learner's note threads to the partitioner + every generator and persists on the set (owner, 2026-09-29)", async () => {
- const partSeen: string[] = [];
- const genSeen: string[] = [];
- const f = fixture({
- partitioner: capturingPartitioner([{ chains: ["b"] }, { chains: ["e"] }], partSeen),
- problemGenerator: capturingEchoGenerator(genSeen, 2),
- grader: echoGrader(1),
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS, undefined, "keep it geometric");
- await done;
-
- assert.equal(set.note, "keep it geometric", "persisted at creation");
- assert.equal(f.practice.store.getSet(set.id)!.note, "keep it geometric", "survives the store round-trip");
- assert.ok(partSeen[0]!.includes("THE LEARNER'S NOTE"), "rode the partitioner prompt");
- assert.ok(partSeen[0]!.includes("keep it geometric"));
- assert.equal(genSeen.length, 2);
- for (const prompt of genSeen) {
- assert.ok(prompt.includes("THE LEARNER'S NOTE"), "rode every generator prompt");
- assert.ok(prompt.includes("keep it geometric"));
- }
-
- // No note → no block, anywhere (optional in every sense).
- const partSeenBare: string[] = [];
- const genSeenBare: string[] = [];
- const f2 = fixture({
- partitioner: capturingPartitioner([{ chains: ["b", "e"] }], partSeenBare),
- problemGenerator: capturingEchoGenerator(genSeenBare, 1),
- grader: echoGrader(1),
- });
- const s2 = await f2.make().createProblemSet(["a", "b", "e"], PARAMS);
- await s2.done;
- assert.equal(s2.set.note, undefined);
- assert.ok(!partSeenBare[0]!.includes("THE LEARNER'S NOTE"));
- assert.ok(!genSeenBare[0]!.includes("THE LEARNER'S NOTE"));
- });
-
- 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 pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS); // chains b + e
- await done;
- const final = f.practice.store.getSet(set.id)!;
- assert.equal(final.status, "ready");
- const errored = final.problems.find((p) => p.status === "error");
- const ready = final.problems.find((p) => p.status === "ready");
- assert.ok(errored && /never submitted/.test(errored.error ?? ""));
- assert.ok(ready);
- });
-
- it("the 3-permit semaphore: five generators, never more than three in flight", async () => {
- const nodes = ["e1", "e2", "e3", "e4", "e5"].map((id) => ({
- id,
- name: id.toUpperCase(),
- statement: `${id} statement`,
- aliases: [],
- createdAt: NOW,
- provenance: pt("chat_1"),
- }));
- const f = fixture({ graphStore: memoryGraph(nodes, []), partitioner: partitionModel(nodes.map((n) => ({ chains: [n.id] }))), grader: echoGrader(1) });
-
- let active = 0;
- let max = 0;
- let release!: () => void;
- const gate = new Promise((r) => (release = r));
- const factory: FauxResponseStep = (context) => {
- active++;
- max = Math.max(max, active);
- return gate.then(() => {
- active--;
- const target = targetOf(lastUserText(context as never));
- return fauxAssistantMessage(
- [fauxToolCall("submit_problem", { chains: target, statement: "s", solution: "v", rubric: "r" } as ToolCall["arguments"], { id: "g" })],
- { stopReason: "toolUse" },
- );
- });
- };
- const deps = { ...f.deps, problemGenerator: fauxEngine(Array.from({ length: 5 }, () => factory)), grader: echoGrader(1) };
- const pipeline = createPracticePipeline(deps);
-
- const { done } = await pipeline.createProblemSet(nodes.map((n) => n.id), PARAMS);
- await until(() => active === 3);
- assert.equal(max, 3, "exactly three generators in flight");
- release();
- await done;
- assert.equal(max, 3);
- });
-
- 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 pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e"], PARAMS);
- await until(() => f.practice.store.getSet(set.id)?.status === "generating");
-
- pipeline.abortPractice(set.id);
- await done;
-
- const final = f.practice.store.getSet(set.id)!;
- assert.equal(final.status, "error");
- assert.equal(final.error, "Aborted");
- for (const p of final.problems) {
- assert.equal(p.status, "error");
- assert.equal(p.error, "Aborted");
- }
- assert.equal(practiceStatuses().length, 0);
- });
-
- it("registry: partition row first, then UNIQUE per-problem rows with i/N labels; every row clears", async () => {
- // BOTH generators hold on their own gates — the 2-row mid-flight
- // window is stable, then b releases alone (per-row usage pin).
- let releaseB!: () => void;
- let releaseC!: () => void;
- const gateB = new Promise((r) => (releaseB = r));
- const gateC = new Promise((r) => (releaseC = r));
- const step: FauxResponseStep = (context) => {
- const target = targetOf(lastUserText(context as never));
- const gate = target.includes("c") ? gateC : gateB;
- return gate.then(() =>
- fauxAssistantMessage(
- [fauxToolCall("submit_problem", { chains: target, statement: "s", solution: "v", rubric: "r" } as ToolCall["arguments"], { id: `g-${target.join("-")}` })],
- { stopReason: "toolUse" },
- ),
- );
- };
- const gen = fauxEngine([step, step]);
- // Freeze stage 1: the partitioner holds until released.
- let releasePartition!: () => void;
- const partitionGate = new Promise((r) => (releasePartition = r));
- const gatedPartitioner = fauxEngine([
- () =>
- partitionGate.then(() =>
- fauxAssistantMessage(
- [fauxToolCall("submit_partition", { partitions: [{ chains: ["b"] }, { chains: ["c"] }] } as ToolCall["arguments"], { id: "p1" })],
- { stopReason: "toolUse" },
- ),
- ),
- ]);
- const f = fixture({
- partitioner: gatedPartitioner,
- problemGenerator: gen,
- grader: echoGrader(1),
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c"], PARAMS);
-
- // Stage 1: exactly ONE row — the partition row (frozen).
- await until(() => practiceStatuses().length === 1);
- {
- const pr = practiceStatuses()[0]!;
- assert.equal(pr.key, `${set.id}:partition`, "the partition row's key shape");
- assert.equal(pr.phase, "partitioning");
- assert.equal(pr.label, `Partitioning — ${set.title}`);
- assert.equal(pr.kind, "generation");
- assert.equal(pr.problemIndex, undefined, "no fan-out yet");
- }
-
- releasePartition();
-
- // Stage 2: partition row retired; per-problem rows carry i/N.
- await until(() => practiceStatuses().length === 2 && practiceStatuses().every((r) => r.phase === "generating"));
- {
- const rows = practiceStatuses();
- assert.ok(!rows.some((r) => r.key === `${set.id}:partition`), "partition row retired at fan-out");
- const keys = rows.map((r) => r.key);
- assert.equal(new Set(keys).size, keys.length, "row keys are unique (the dock dup-key law)");
- for (const r of rows) {
- assert.match(r.key, new RegExp(`^${set.id}:gen:pp_`), "gen row key shape");
- assert.equal(r.kind, "generation");
- assert.match(r.label, /^Problem [12]\/2 — /, `label carries i/N: ${r.label}`);
- }
- const byIndex = [...rows].sort((a, b) => (a.problemIndex ?? 0) - (b.problemIndex ?? 0));
- assert.equal(byIndex[0]!.problemIndex, 1);
- assert.equal(byIndex[0]!.problemTotal, 2);
- assert.equal(byIndex[1]!.problemIndex, 2);
- assert.equal(byIndex[1]!.problemTotal, 2);
- }
-
- // PER-ROW USAGE ISOLATION: b submits and settles (its row clears);
- // c's row is still generating and carries NO usage — b's log lives
- // on b's row only (the aggregate row would have shown it here).
- releaseB();
- await until(() => practiceStatuses().length === 1);
- {
- const held = practiceStatuses()[0]!;
- assert.match(held.key, /:gen:pp_/, "the surviving row is c's");
- assert.equal(held.usage, undefined, "no cross-contamination of usage between rows");
- }
-
- releaseC();
- await done;
- assert.equal(practiceStatuses().length, 0, "every row clears");
- });
-
- it("grading rows: per-answer keys, Grading i/N labels, queued→grading, per-row usage", async () => {
- let release!: () => void;
- const gate = new Promise((r) => (release = r));
- // hold both grades at the model seam
- const holdSteps: FauxResponseStep[] = Array.from({ length: 2 }, () => () =>
- gate.then(() => fauxAssistantMessage([fauxToolCall("submit_grade", { score: 9, reasoning: "yes" } as ToolCall["arguments"], { id: "gr" })], { stopReason: "toolUse" })),
- );
- const f = fixture({
- partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }]),
- problemGenerator: echoGenerator(2),
- grader: fauxEngine(holdSteps),
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c"], PARAMS);
- await done;
- const ready = f.practice.store.getSet(set.id)!;
- const ids = ready.problems.map((p) => p.id);
-
- const sub = await pipeline.submitAnswers(set.id, [
- { problemId: ids[0]!, text: "first answer" },
- { problemId: ids[1]!, text: "second answer" },
- ]);
- await until(() => practiceStatuses().length === 2 && practiceStatuses().every((r) => r.phase === "grading"));
- const rows = practiceStatuses();
- for (const r of rows) {
- assert.equal(r.kind, "grading");
- assert.match(r.key, new RegExp(`^${set.id}:grade:pp_`), "grade row key shape");
- assert.match(r.label, /^Grading [12]\/2 — /, `label carries i/N: ${r.label}`);
- assert.ok(r.problemIndex === 1 || r.problemIndex === 2);
- assert.equal(r.problemTotal, 2);
- }
- const keys = rows.map((r) => r.key);
- assert.equal(new Set(keys).size, keys.length, "grade row keys unique");
-
- release();
- await sub.done;
- assert.equal(practiceStatuses().length, 0);
- const graded = f.practice.store.getSet(set.id)!;
- assert.ok(graded.problems.every((p) => p.grade?.score === 9));
- });
-
- it("abort ONE gen row: that problem dies alone, siblings complete, set reaches ready with one errored", async () => {
- let release!: () => void;
- const gate = new Promise((r) => (release = r));
- const f = fixture({
- partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }]),
- problemGenerator: abortOrGateModel(8, gate), // victim burns retries; sibling submits on gate
- grader: echoGrader(1),
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c"], PARAMS);
- await until(() => f.practice.store.getSet(set.id)?.status === "generating");
- const mid = f.practice.store.getSet(set.id)!;
- const victim = mid.problems[0]!.id;
-
- pipeline.abortPracticeRow(`${set.id}:gen:${victim}`);
- release(); // the sibling completes on the gate
- await done;
-
- const final = f.practice.store.getSet(set.id)!;
- assert.equal(final.status, "ready", "the set completes despite the single row abort");
- const dead = final.problems.find((p) => p.id === victim)!;
- assert.equal(dead.status, "error");
- assert.equal(dead.error, "Aborted");
- const sibling = final.problems.find((p) => p.id !== victim)!;
- assert.equal(sibling.status, "ready", "the sibling ran on");
- assert.equal(practiceStatuses().length, 0);
- });
-
- it("abort the PARTITION row: the whole run dies (set error Aborted)", async () => {
- const f = fixture({ partitioner: hangingModel(1), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
- await until(() => practiceStatuses().length === 1 && practiceStatuses()[0]!.key === `${set.id}:partition`);
-
- pipeline.abortPracticeRow(`${set.id}:partition`);
- await done;
-
- const final = f.practice.store.getSet(set.id)!;
- assert.equal(final.status, "error");
- assert.equal(final.error, "Aborted");
- assert.equal(practiceStatuses().length, 0);
- });
-
- it("abort ONE grade row (queued): that grade drops (answer kept, one attempt — no re-submission), sibling grade lands", async () => {
- const f = fixture({
- partitioner: partitionModel([{ chains: ["b"] }, { chains: ["c"] }]),
- problemGenerator: echoGenerator(2),
- grader: echoGrader(2), // sibling grade + the retry's grade
- });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c"], PARAMS);
- await done;
- const ready = f.practice.store.getSet(set.id)!;
- const ids = ready.problems.map((p) => p.id);
-
- // Hold ALL THREE shared permits (AFTER the fixture — it resets the
- // pool): both grade rows sit "queued" behind them, so the abort
- // exercises the dead-ticket path deterministically (no model call
- // for the victim at all).
- let rh1!: () => void;
- let rh2!: () => void;
- let rh3!: () => void;
- const h1 = withBgLlmSlot(undefined, () => new Promise((r) => (rh1 = r)));
- const h2 = withBgLlmSlot(undefined, () => new Promise((r) => (rh2 = r)));
- const h3 = withBgLlmSlot(undefined, () => new Promise((r) => (rh3 = r)));
- const slots = bgLlmSlots();
- await until(() => slots.active === 3);
-
- const sub = await pipeline.submitAnswers(set.id, [
- { problemId: ids[0]!, text: "first" },
- { problemId: ids[1]!, text: "second" },
- ]);
- await until(() => practiceStatuses().length === 2 && practiceStatuses().every((r) => r.phase === "queued"));
- const victimKey = practiceStatuses()[0]!.key;
- const victimId = victimKey.split(":").pop()!;
-
- pipeline.abortPracticeRow(victimKey);
- await until(() => practiceStatuses().length === 1); // the aborted row is gone at once
- assert.equal(practiceStatuses()[0]!.key !== victimKey, true);
-
- rh3(); // free one permit → the sibling grades
- await h3;
- await sub.done;
- const graded = f.practice.store.getSet(set.id)!;
- const dropped = graded.problems.find((p) => p.id === victimId)!;
- assert.equal(dropped.grade, undefined, "the aborted grade is absent");
- assert.equal(dropped.answer, "first", "the answer is kept");
- const other = graded.problems.find((p) => p.id !== victimId)!;
- assert.equal(other.grade?.score, 6, "the sibling grade landed (echoGrader: no 'solid' in its text)");
- assert.equal(practiceStatuses().length, 0);
-
- // ONE attempt (owner 2026-09-29): the dropped grade's answer
- // stands — re-submission is refused, the problem is done.
- await assert.rejects(
- () => pipeline.submitAnswers(set.id, [{ problemId: victimId, text: "retry answer" }]),
- /was already answered/,
- );
- const after = f.practice.store.getSet(set.id)!;
- assert.equal(after.problems.find((p) => p.id === victimId)!.grade, undefined, "still no grade — one attempt");
- assert.equal(after.problems.find((p) => p.id === victimId)!.answer, "first");
-
- rh1();
- rh2();
- await h1;
- await h2;
- assert.equal(bgLlmSlots().active, 0, "the pool drains");
- });
-
- it("shared pool: practice queues behind extraction-style holders of the SAME semaphore (identity + contention)", async () => {
- // identity: one process, one pool — practice and extraction share it
- assert.equal(bgLlmSlots(), bgLlmSlots());
-
- let releaseGen!: () => void;
- const genGate = new Promise((r) => (releaseGen = r));
- const gen = fauxEngine([
- (context) =>
- genGate.then(() => {
- const target = targetOf(lastUserText(context as never));
- return fauxAssistantMessage(
- [fauxToolCall("submit_problem", { chains: target, statement: "s", solution: "v", rubric: "r" } as ToolCall["arguments"], { id: "g" })],
- { stopReason: "toolUse" },
- );
- }),
- ]);
- const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: gen, grader: echoGrader(1) });
- const pipeline = f.make();
-
- // Hold all THREE permits as an extraction-style caller would (AFTER
- // the fixture — it resets the pool): the partitioner (a background
- // LLM job) must QUEUE behind them.
- let rh1!: () => void;
- let rh2!: () => void;
- let rh3!: () => void;
- const h1 = withBgLlmSlot(undefined, () => new Promise((r) => (rh1 = r)));
- const h2 = withBgLlmSlot(undefined, () => new Promise((r) => (rh2 = r)));
- const h3 = withBgLlmSlot(undefined, () => new Promise((r) => (rh3 = r)));
- const slots = bgLlmSlots();
- await until(() => slots.active === 3);
-
- const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
-
- // the partition row EXISTS while its call queues for a permit
- await until(() => slots.queue.length === 1);
- assert.equal(practiceStatuses()[0]!.key, `${set.id}:partition`, "the row exists while its call queues");
- assert.equal(practiceStatuses()[0]!.phase, "partitioning");
-
- rh3(); // free one permit → the partitioner runs and finishes
- await h3;
- await until(() => practiceStatuses().length === 1 && practiceStatuses()[0]!.phase === "generating");
- assert.match(practiceStatuses()[0]!.key, /:gen:pp_/, "the gen row took over");
- await until(() => slots.active === 3); // two holders + the generator
-
- releaseGen(); // the generator settles; the set completes
- await done;
- assert.equal(f.practice.store.getSet(set.id)!.status, "ready");
-
- rh1();
- rh2();
- await h1;
- await h2;
- assert.equal(bgLlmSlots().active, 0, "the pool drains");
- });
-
- it("refusal gate fires BEFORE anything is created", async () => {
- const f = fixture({}); // no engines, no engineFor
- const pipeline = f.make();
- await assert.rejects(pipeline.createProblemSet(["a", "b"], PARAMS), ModelsNotConfiguredError);
- assert.equal(f.practice.sets.size, 0, "no set persisted on refusal");
- });
-});
-
-// ─── selection + typed errors ────────────────────────────────────────────────
-
-describe("selection resolution + typed errors", () => {
- 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 pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "c", "d", "e", "n_ghost"], PARAMS);
- await done;
- const final = f.practice.store.getSet(set.id)!;
- 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.ok(final.title.startsWith("Practice — "), "no title hint → the dated default");
- });
-
- it("empty selection / vanished nodes → EmptySelectionError; title hint rides", async () => {
- const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
- const pipeline = f.make();
- await assert.rejects(pipeline.createProblemSet([], PARAMS), EmptySelectionError);
- await assert.rejects(pipeline.createProblemSet(["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");
- });
-});
-
-// ─── answers + grading ───────────────────────────────────────────────────────
-
-describe("submitAnswers (grading)", () => {
- async function readySet() {
- const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(2), grader: echoGrader(2) });
- const pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS);
- await done;
- return { f, pipeline, set: f.practice.store.getSet(set.id)! };
- }
-
- it("saves answers, grades land per problem, grading row clears; graded refuses re-answer", async () => {
- const { f, pipeline, set } = await readySet();
- const p1 = set.problems[0]!;
- const p2 = set.problems[1]!;
- const { done } = await pipeline.submitAnswers(set.id, [
- { problemId: p1.id, text: "a solid answer" },
- { problemId: p2.id, text: "a weak answer" },
- ]);
- await done;
- const final = f.practice.store.getSet(set.id)!;
- const g1 = final.problems.find((p) => p.id === p1.id)!.grade;
- const g2 = final.problems.find((p) => p.id === p2.id)!.grade;
- assert.ok(g1 && g1.score === 9, "score encodes from the answer text");
- assert.ok(g2 && g2.score === 6);
- assert.ok(g1!.gradedAt >= NOW);
- assert.equal(practiceStatuses().length, 0, "the grading row clears");
-
- // Graded problems refuse re-answers; the answers persist.
- await assert.rejects(pipeline.submitAnswers(set.id, [{ problemId: p1.id, text: "again" }]), BadAnswersError);
- assert.equal(final.problems.find((p) => p.id === p1.id)!.answer, "a solid answer");
- });
-
- it("typed errors: unknown set, not-ready set, unknown problem, empty text", async () => {
- const { pipeline } = await readySet();
- await assert.rejects(pipeline.submitAnswers("ps_nope", [{ problemId: "pp_x", text: "t" }]), PracticeSetNotFound);
-
- const f2 = fixture({ partitioner: hangingModel(), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
- const p2 = f2.make();
- const { set: s2 } = await p2.createProblemSet(["b"], PARAMS); // stuck partitioning
- await assert.rejects(p2.submitAnswers(s2.id, [{ problemId: "pp_x", text: "t" }]), PracticeSetNotReady);
- p2.abortPractice(s2.id);
-
- const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }]), problemGenerator: echoGenerator(1), grader: echoGrader(1) });
- const pl = f.make();
- const { set: s3, done } = await pl.createProblemSet(["b"], PARAMS);
- await done;
- 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)
- await assert.rejects(pl.submitAnswers(s3.id, [{ problemId: settled.problems[0]!.id, text: " " }]), BadAnswersError);
- });
-});
-
-// ─── boot sweep + delete ─────────────────────────────────────────────────────
-
-describe("boot sweep + delete", () => {
- it("a STALE orphan (>120s heartbeat, no live row) is marked interrupted", async () => {
- const f = fixture({});
- const orphan = f.practice.store.createSet({
- title: "orphan",
- createdAt: new Date(Date.now() - 300_000).toISOString(), // 5 min stale
- params: PARAMS,
- nodeIds: ["b"],
- propositions: { b: { name: "B", statement: "s" } },
- chains: [{ id: "b", premises: ["a"], conclusion: "b" }],
- status: "generating",
- problems: [{ id: "pp_orphan", chains: ["b"], statement: "", solution: "", rubric: "", status: "generating" }],
- });
- resetSweep();
- f.make(); // the sweep runs at construction
- const swept = f.practice.store.getSet(orphan.id)!;
- assert.equal(swept.status, "error");
- assert.equal(swept.error, "Interrupted (server restarted)");
- });
-
- it("a FRESH generating set (no live row) is NOT stamped — the grace window (owner bug fix)", async () => {
- const f = fixture({});
- const fresh = f.practice.store.createSet({
- title: "fresh",
- createdAt: new Date().toISOString(), // heartbeat seconds old
- params: PARAMS,
- nodeIds: ["b"],
- propositions: { b: { name: "B", statement: "s" } },
- chains: [{ id: "b", premises: ["a"], conclusion: "b" }],
- status: "generating",
- problems: [{ id: "pp_fresh", chains: ["b"], statement: "", solution: "", rubric: "", status: "generating" }],
- });
- resetSweep();
- f.make();
- const read = f.practice.store.getSet(fresh.id)!;
- assert.equal(read.status, "generating", "grace: a live run's fresh heartbeat is never stamped");
- assert.equal(read.error, undefined);
- });
-
- it("once per process: a second factory instantiation never re-sweeps", async () => {
- const f = fixture({});
- const orphanA = f.practice.store.createSet({
- title: "swept",
- createdAt: new Date(Date.now() - 300_000).toISOString(),
- params: PARAMS,
- nodeIds: ["b"],
- propositions: { b: { name: "B", statement: "s" } },
- chains: [{ id: "b", premises: ["a"], conclusion: "b" }],
- status: "generating",
- problems: [],
- });
- resetSweep();
- f.make(); // sweeps orphanA, sets the process flag
- assert.equal(f.practice.store.getSet(orphanA.id)!.status, "error");
-
- const orphanB = f.practice.store.createSet({
- title: "second",
- createdAt: new Date(Date.now() - 300_000).toISOString(),
- params: PARAMS,
- nodeIds: ["b"],
- propositions: { b: { name: "B", statement: "s" } },
- chains: [{ id: "b", premises: ["a"], conclusion: "b" }],
- status: "generating",
- problems: [],
- });
- f.make(); // flag set → NO sweep
- assert.equal(f.practice.store.getSet(orphanB.id)!.status, "generating", "the sweep runs once per process");
- });
-
- 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 a = f.make();
- const { set, done } = await a.createProblemSet(["b"], PARAMS); // hangs partitioning
- resetSweep();
- f.make(); // second pipeline instance, same process → same registry, sweep RE-ARMED
- assert.equal(f.practice.store.getSet(set.id)!.status, "partitioning", "the live row protects it even mid-sweep");
- a.abortPractice(set.id);
- await done;
- });
-
- 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 pipeline = f.make();
- const { set, done } = await pipeline.createProblemSet(["b"], PARAMS);
- await done;
- assert.equal(await pipeline.deleteProblemSet(set.id), true);
- assert.equal(f.practice.store.getSet(set.id), null);
- assert.equal(await pipeline.deleteProblemSet("ps_never"), false);
- });
-});
diff --git a/tests/practice-service.test.ts b/tests/practice-service.test.ts
deleted file mode 100644
index dafbc0a..0000000
--- a/tests/practice-service.test.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { describe, it } from "node:test";
-import assert from "node:assert/strict";
-import { createPracticeSetResponse } from "../src/server/practice.ts";
-
-/**
- * The practice create-route's parameter validation (owner, 2026-09-29):
- * the three axes are NAMED LEVELS — exact unions, nothing between. A
- * rung is a rung; a float between rungs, a wrong string, or a missing
- * field is a 400 BEFORE the pipeline is ever reached (these pins only
- * exercise the rejecting paths — the accepting path runs the real
- * pipeline and belongs to the pipeline suite).
- */
-
-const NODE_IDS = { nodeIds: ["n1", "n2", "n3"], title: "Ethics cluster" };
-
-async function statusOf(params: unknown): Promise {
- const res = await createPracticeSetResponse({ ...NODE_IDS, params });
- return res.status;
-}
-
-describe("createPracticeSetResponse — strict named-level params", () => {
- it("floats between levels → 400", async () => {
- assert.equal(await statusOf({ ladderRung: 2.5, cueDistance: 1, presentation: "theory" }), 400);
- assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1.5, presentation: "theory" }), 400);
- });
-
- it("out-of-range integers → 400", async () => {
- assert.equal(await statusOf({ ladderRung: 5, cueDistance: 1, presentation: "theory" }), 400);
- assert.equal(await statusOf({ ladderRung: -1, cueDistance: 1, presentation: "theory" }), 400);
- assert.equal(await statusOf({ ladderRung: 2, cueDistance: 3, presentation: "theory" }), 400);
- });
-
- it("wrong presentation → 400", async () => {
- assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: "real-world" }), 400);
- assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1, presentation: 1 }), 400);
- });
-
- it("missing / legacy float fields → 400", async () => {
- assert.equal(await statusOf({ ladderRung: 2, cueDistance: 1 }), 400);
- assert.equal(await statusOf({ searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 }), 400, "legacy dials are dead");
- assert.equal(await statusOf("high"), 400);
- });
-});
-
-describe("createPracticeSetResponse — the learner's note (owner, 2026-09-29)", () => {
- const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
-
- it("non-string note → 400", async () => {
- const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: 42 });
- assert.equal(res.status, 400);
- });
-
- it("over 2000 characters → 400 (the API is the law; the textarea's maxLength mirrors it)", async () => {
- const res = await createPracticeSetResponse({ ...NODE_IDS, params: PARAMS, note: "x".repeat(2001) });
- assert.equal(res.status, 400);
- const body = (await res.json()) as { error: string };
- assert.match(body.error, /over 2000 characters/);
- });
-
- it("exactly 2000 passes the note check (boundary — the next check, not the note, refuses)", async () => {
- // Malformed nodeIds AFTER a max-length note: reaching the nodeIds
- // error proves the note check passed (all checks are rejecting-path;
- // the pipeline is never reached — this suite's doctrine).
- const res = await createPracticeSetResponse({ nodeIds: 5, params: PARAMS, note: "x".repeat(2000) });
- assert.equal(res.status, 400);
- const body = (await res.json()) as { error: string };
- assert.match(body.error, /Body must be/);
- });
-});
diff --git a/tests/practice-store.test.ts b/tests/practice-store.test.ts
deleted file mode 100644
index 6f09db3..0000000
--- a/tests/practice-store.test.ts
+++ /dev/null
@@ -1,184 +0,0 @@
-/**
- * PracticeStore (slice 2, 2026-09-29): the fs adapter for problem sets —
- * one JSON document per set in data/practice/ (a BRAND-NEW domain dir, so
- * no snapshot/restore dance: the whole dir is ours and gets removed in
- * after()). Round-trip, corrupt-tolerance, validator pins, id shape.
- */
-
-import assert from "node:assert/strict";
-import fs from "node:fs";
-import { after, afterEach, describe, it } from "node:test";
-import { PATHS } from "../config.ts";
-import { isProblemSet, practiceStore } from "../src/store/practice.ts";
-import type { ProblemSet } from "../src/core/contracts.ts";
-
-after(() => {
- fs.rmSync(PATHS.practice, { recursive: true, force: true });
-});
-afterEach(() => {
- fs.rmSync(PATHS.practice, { recursive: true, force: true });
-});
-
-// ─── fixtures ────────────────────────────────────────────────────────────────
-
-const init = (over: Partial> = {}): Omit => ({
- title: "Libertarianism — session practice",
- createdAt: "2026-09-29T12:00:00.000Z",
- params: { ladderRung: 2, cueDistance: 1, presentation: "theory" },
- nodeIds: ["n_a", "n_b", "n_c"],
- propositions: {
- n_a: { name: "Self-ownership", statement: "Each person owns themself." },
- n_b: { name: "Homesteading", statement: "Unowned goods are claimed by first use." },
- n_c: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
- },
- chains: [
- { id: "n_c", premises: ["n_a"], conclusion: "n_c" },
- { id: "n_b", premises: [], conclusion: "n_b" },
- ],
- status: "partitioning",
- problems: [],
- ...over,
-});
-
-// ─── round-trip ──────────────────────────────────────────────────────────────
-
-describe("updatedAt — the live-run heartbeat", () => {
- it("createSet stamps updatedAt = createdAt; saveSet re-stamps on every write", () => {
- const set = practiceStore.createSet(init());
- assert.equal(set.updatedAt, set.createdAt, "birth stamp");
- const before = new Date().toISOString();
- const edited = { ...set, title: "Renamed" };
- practiceStore.saveSet(edited);
- const read = practiceStore.getSet(set.id)!
- assert.ok(read.updatedAt !== undefined && read.updatedAt >= before, "every write re-stamps");
- assert.notEqual(read.title, set.title);
- });
-
- it("legacy rows without updatedAt still validate (readers fall back to createdAt)", () => {
- const set = practiceStore.createSet(init());
- const bare = { ...set } as Partial;
- delete bare.updatedAt;
- assert.ok(isProblemSet({ ...bare, id: set.id }));
- });
-});
-
-describe("the practice store", () => {
- it("createSet assigns a ps_ id, mkdirs, and round-trips through getSet", () => {
- const set = practiceStore.createSet(init());
- assert.match(set.id, /^ps_[a-z0-9]+_[a-f0-9]{8}$/);
- assert.deepEqual(practiceStore.getSet(set.id), set);
- });
-
- it("saveSet replaces whole — mutations persist", () => {
- const set = practiceStore.createSet(init({ status: "generating" }));
- set.problems.push({
- id: "pp_x",
- chains: ["n_c"],
- statement: "Why does arguing presuppose self-ownership?",
- solution: "Because…",
- rubric: "- names the performative contradiction",
- status: "ready",
- answer: "Because…",
- grade: { score: 9, reasoning: "Named it.", gradedAt: "2026-09-29T13:00:00.000Z" },
- });
- set.status = "ready";
- practiceStore.saveSet(set);
- const reread = practiceStore.getSet(set.id);
- assert.equal(reread?.status, "ready");
- assert.equal(reread?.problems[0]?.grade?.score, 9);
- });
-
- it("listSets returns createdAt-descending and skips corrupt/unparseable files", () => {
- const older = practiceStore.createSet(init({ createdAt: "2026-09-28T00:00:00.000Z" }));
- const newer = practiceStore.createSet(init({ createdAt: "2026-09-29T00:00:00.000Z", title: "Newer" }));
- fs.writeFileSync(`${PATHS.practice}/ps_corrupt.json`, "{not json", "utf-8");
- fs.writeFileSync(
- `${PATHS.practice}/ps_wrongshape.json`,
- JSON.stringify({ id: "ps_wrongshape", title: 42 }),
- "utf-8",
- );
- const listed = practiceStore.listSets();
- assert.deepEqual(
- listed.map((s) => s.id),
- [newer.id, older.id],
- );
- assert.equal(practiceStore.getSet("ps_corrupt"), null);
- assert.equal(practiceStore.getSet("ps_wrongshape"), null);
- });
-
- it("missing dir: listSets is [] and getSet is null, never a throw", () => {
- assert.deepEqual(practiceStore.listSets(), []);
- assert.equal(practiceStore.getSet("ps_missing"), null);
- });
-
- it("deleteSet: true then false, file gone", () => {
- const set = practiceStore.createSet(init());
- assert.equal(practiceStore.deleteSet(set.id), true);
- assert.equal(practiceStore.getSet(set.id), null);
- assert.equal(practiceStore.deleteSet(set.id), false);
- });
-
- it("saveSet refuses to write an invalid record (file integrity seam)", () => {
- const set = practiceStore.createSet(init());
- const bad = { ...set, status: "nonsense" } as unknown as ProblemSet;
- assert.throws(() => practiceStore.saveSet(bad));
- // the original survives untouched
- assert.equal(practiceStore.getSet(set.id)?.status, "partitioning");
- });
-});
-
-// ─── validator pins ──────────────────────────────────────────────────────────
-
-describe("isProblemSet", () => {
- it("accepts the full shape, all statuses, empty problems", () => {
- for (const status of ["partitioning", "generating", "ready", "error"] as const) {
- assert.ok(isProblemSet({ ...init({ status }), id: "ps_valid" }));
- }
- });
-
- it("rejects garbage", () => {
- assert.ok(!isProblemSet(null));
- assert.ok(!isProblemSet("set"));
- assert.ok(!isProblemSet({}));
- assert.ok(!isProblemSet({ ...init(), id: "xx_1" }));
- assert.ok(!isProblemSet({ ...init(), params: { searchSolutionRatio: "high" } }));
- // Legacy float-params sets fail the named-level unions and are
- // skipped from lists (owner, 2026-09-29: no migration — data/practice
- // is wiped before testing).
- assert.ok(!isProblemSet({ ...init(), id: "ps_ok", params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 } }));
- assert.ok(!isProblemSet({ ...init(), id: "ps_ok2", params: { ladderRung: 2.5, cueDistance: 1, presentation: "theory" } }), "no floats between rungs");
- assert.ok(!isProblemSet({ ...init(), chains: [{ id: "n_c", premises: "n_a", conclusion: "n_c" }] }));
- assert.ok(!isProblemSet({ ...init(), propositions: { n_a: { name: "x" } } }));
- assert.ok(!isProblemSet({ ...init(), status: "cooked" }));
- });
-
- it("the learner's note: optional string, round-trips; non-string refused (owner, 2026-09-29)", () => {
- assert.ok(isProblemSet({ ...init({ note: "keep it geometric" }), id: "ps_noted" }));
- assert.ok(!isProblemSet({ ...init({ note: 42 as unknown as string }), id: "ps_badnote" }));
- const created = practiceStore.createSet(init({ note: "lean toward pricing" }));
- assert.equal(practiceStore.getSet(created.id)!.note, "lean toward pricing");
- });
-
- it("tolerates optional fields — a minimal stub (no error/answer/grade) reads", () => {
- const minimal = { ...init({ problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "generating" }] }), id: "ps_valid" };
- assert.ok(isProblemSet(minimal));
- });
-
- it("grades are integer scores 0–10 with non-empty reasoning (one-attempt redesign)", () => {
- const withGrade = (score: unknown, reasoning = "why"): unknown => ({
- ...init(),
- id: "ps_valid",
- problems: [
- { id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { score, reasoning, gradedAt: "t" } },
- ],
- });
- assert.ok(isProblemSet(withGrade(0)));
- assert.ok(isProblemSet(withGrade(10)));
- assert.ok(!isProblemSet(withGrade(11)), "above the range");
- assert.ok(!isProblemSet(withGrade(-1)), "below the range");
- assert.ok(!isProblemSet(withGrade(7.5)), "fractional scores are refused");
- assert.ok(!isProblemSet(withGrade("9")), "string scores are refused");
- assert.ok(!isProblemSet(withGrade(9, "")), "empty reasoning is refused");
- assert.ok(!isProblemSet({ ...init(), id: "ps_valid", problems: [{ id: "pp_1", chains: [], statement: "", solution: "", rubric: "", status: "ready", grade: { verdict: "solid", feedback: "legacy", gradedAt: "t" } }] }), "legacy verdict grades fail (wipe regime)");
- });
-});
diff --git a/tests/practice.test.ts b/tests/practice.test.ts
deleted file mode 100644
index e519408..0000000
--- a/tests/practice.test.ts
+++ /dev/null
@@ -1,153 +0,0 @@
-/**
- * PRACTICE-MODULE CORE: chainsForSelection over the STORED chains
- * (chains-primary, owner 2026-09-30: a chain rides along iff every member
- * is selected; nodes in no surviving chain are singletons) and
- * chainCoverage (the set's completeness law: union of the problems' chains
- * must equal the set's chains).
- */
-
-import assert from "node:assert/strict";
-import { describe, it } from "node:test";
-import type { GraphChain } from "../src/core/contracts.ts";
-import { chainCoverage, chainsForSelection, formatChainLine } from "../src/core/practice.ts";
-
-// ─── fixtures ────────────────────────────────────────────────────────────────
-
-const n = (id: string) => ({ id });
-const arg = (id: string, premises: string[], conclusion: string): GraphChain => ({
- id,
- premises,
- conclusion,
- provenance: [],
- createdAt: "2026-09-30T00:00:00.000Z",
-});
-
-/** The plan's canonical shape: diamond a→b, a→c, b∧c→d — three stored
- * arguments (each its own arg_ id, conclusion ids may repeat). */
-const diamondNodes = [n("a"), n("b"), n("c"), n("d")];
-const diamondArgs = [arg("arg_b", ["a"], "b"), arg("arg_c", ["a"], "c"), arg("arg_d", ["b", "c"], "d")];
-
-// ─── chainsForSelection ──────────────────────────────────────────────────────
-
-describe("chainsForSelection", () => {
- it("keeps every fully-selected stored argument, each under its own id", () => {
- assert.deepEqual(chainsForSelection(diamondNodes, diamondArgs), [
- { id: "arg_b", premises: ["a"], conclusion: "b" },
- { id: "arg_c", premises: ["a"], conclusion: "c" },
- { id: "arg_d", premises: ["b", "c"], conclusion: "d" },
- ]);
- });
-
- it("a premise-only node is NOT its own chain — it lives inside the arguments it feeds", () => {
- const ids = chainsForSelection(diamondNodes, diamondArgs).map((c) => c.id);
- assert.ok(!ids.includes("a")); // a is only ever a premise
- });
-
- it("an isolated node becomes a singleton chain", () => {
- const chains = chainsForSelection([...diamondNodes, n("z")], diamondArgs);
- assert.deepEqual(chains.at(-1), { id: "z", premises: [], conclusion: "z" });
- });
-
- it("two independent arguments for one conclusion stay DISTINCT chains", () => {
- // The bijection problem that killed edge-grouping (owner, 2026-09-30):
- // A∧B⇒E and C∧D⇒E must never union into A∧B∧C∧D⇒E.
- const chains = chainsForSelection(
- [n("a"), n("b"), n("c"), n("d"), n("e")],
- [arg("arg_e1", ["a", "b"], "e"), arg("arg_e2", ["c", "d"], "e")],
- );
- assert.deepEqual(chains, [
- { id: "arg_e1", premises: ["a", "b"], conclusion: "e" },
- { id: "arg_e2", premises: ["c", "d"], conclusion: "e" },
- ]);
- });
-
- it("an argument with ANY member outside the selection is excluded entirely", () => {
- // arg_ab needs b; b is not selected, so the argument is gone and
- // a (nothing else left) falls back to a singleton.
- const chains = chainsForSelection([n("a")], [arg("arg_ab", ["a"], "b"), arg("arg_ia", ["in"], "a")]);
- assert.deepEqual(chains, [{ id: "a", premises: [], conclusion: "a" }]);
- });
-
- it("a chain referencing unknown node ids simply never applies", () => {
- const chains = chainsForSelection([n("a"), n("b")], [arg("arg_ghost", ["ghost"], "b")]);
- assert.deepEqual(chains, [
- { id: "a", premises: [], conclusion: "a" },
- { id: "b", premises: [], conclusion: "b" },
- ]);
- });
-
- it("is deterministic — chains sorted by id, premises sorted", () => {
- const shuffled = chainsForSelection(
- [n("d"), n("c"), n("b"), n("a")],
- [arg("arg_d", ["c", "b"], "d"), arg("arg_c", ["a"], "c"), arg("arg_b", ["a"], "b")],
- );
- assert.deepEqual(shuffled, [
- { id: "arg_b", premises: ["a"], conclusion: "b" },
- { id: "arg_c", premises: ["a"], conclusion: "c" },
- { id: "arg_d", premises: ["b", "c"], conclusion: "d" },
- ]);
- });
-});
-
-// ─── chainCoverage ───────────────────────────────────────────────────────────
-
-const diamondChains = [
- { id: "arg_b", premises: ["a"], conclusion: "b" },
- { id: "arg_c", premises: ["a"], conclusion: "c" },
- { id: "arg_d", premises: ["b", "c"], conclusion: "d" },
-];
-
-describe("chainCoverage", () => {
- it("complete cover: every chain exercised, no missing", () => {
- const set = {
- chains: diamondChains,
- problems: [{ chains: ["arg_b", "arg_c"] }, { chains: ["arg_d"] }],
- };
- assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
- });
-
- it("missing: a chain no problem exercises", () => {
- const set = {
- chains: diamondChains,
- problems: [{ chains: ["arg_d"] }],
- };
- assert.deepEqual(chainCoverage(set), { missing: ["arg_b", "arg_c"], duplicated: [] });
- });
-
- it("duplicated is informational — a chain exercised twice is allowed", () => {
- const set = {
- chains: diamondChains,
- problems: [{ chains: ["arg_b", "arg_c", "arg_d"] }, { chains: ["arg_b", "arg_d"] }],
- };
- assert.deepEqual(chainCoverage(set), { missing: [], duplicated: ["arg_b", "arg_d"] });
- });
-
- it("problems claiming chain ids outside the set neither cover nor fail anything", () => {
- const set = {
- chains: [{ id: "arg_b" }] as Array<{ id: string }>,
- problems: [{ chains: ["arg_b", "ghost"] }],
- };
- assert.deepEqual(chainCoverage(set), { missing: [], duplicated: [] });
- });
-});
-
-describe("formatChainLine (the chain table's one-line shape, 2026-09-29)", () => {
- const props = {
- p1: { name: "Premise one" },
- p2: { name: "Premise two" },
- c1: { name: "Conclusion" },
- solo: { name: "Standalone" },
- };
-
- it("joins sorted premise NAMES by the derivation arrow", () => {
- assert.equal(formatChainLine({ premises: ["p2", "p1"], conclusion: "c1" }, props), "Premise one, Premise two → Conclusion");
- });
-
- it("a singleton shows its (no premises) marker", () => {
- assert.equal(formatChainLine({ premises: [], conclusion: "solo" }, props), "Standalone (no premises)");
- });
-
- it("unknown ids fall back to the raw id (honest, never guessed)", () => {
- assert.equal(formatChainLine({ premises: ["ghost"], conclusion: "c1" }, props), "ghost → Conclusion");
- });
-});
diff --git a/tests/render-chain-table.test.tsx b/tests/render-chain-table.test.tsx
index 05883aa..75c34fe 100644
--- a/tests/render-chain-table.test.tsx
+++ b/tests/render-chain-table.test.tsx
@@ -265,13 +265,12 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
assert.ok(!html.includes('data-testid="chain-selection-bar"'));
});
- it("appears with the derived N nodes → M chains + Clear + Generate + the FENCED Delete", () => {
+ it("appears with the derived N nodes → M chains + Clear + the FENCED Delete", () => {
const html = render({ selection: new Set(["a", "b", "c1"]) });
assert.ok(html.includes('data-testid="chain-selection-bar"'));
assert.ok(html.includes('data-testid="chain-selected-summary"'));
assert.ok(html.includes("3 nodes → 1 chain"));
assert.ok(html.includes('data-testid="chain-clear"'));
- assert.ok(html.includes('data-testid="chain-generate"'));
// The danger fence (owner, 2026-09-29 night): a divider quarantines
// the destructive action at the bar's right end, red + trash icon,
// riding the same confirm dialog as the canvas delete.
@@ -280,8 +279,7 @@ describe("ChainTableBody — the floating selection bar (never shifts layout)",
assert.match(del, /border-destructive\/50/); // the destructive pair
assert.match(del, /aria-label="Delete 3 selected nodes"/);
assert.ok(html.includes("lucide-trash-2"), "the trash icon");
- // the fence is AFTER generate in the DOM (right end of the bar)
- assert.ok(html.indexOf('chain-generate"') < html.indexOf('chain-danger-divider"'));
+ // the fence is before Delete in the DOM (right end of the bar)
assert.ok(html.indexOf('chain-danger-divider"') < html.indexOf('chain-delete"'));
});
diff --git a/tests/render-practice.test.tsx b/tests/render-practice.test.tsx
deleted file mode 100644
index d112a63..0000000
--- a/tests/render-practice.test.tsx
+++ /dev/null
@@ -1,438 +0,0 @@
-import React from "react";
-(globalThis as Record).React = React;
-
-import { renderToStaticMarkup } from "react-dom/server";
-import { describe, it } from "node:test";
-import assert from "node:assert/strict";
-import {
- DeleteSetBody,
- PracticeListBody,
- setStatusWord,
-} from "../components/practice-list.tsx";
-import { DEFAULT_PRACTICE_PARAMS, PracticeDialogBody, type PracticeFailure } from "../components/practice-dialog.tsx";
-import type { GraphClusterGroup } from "../src/core";
-import { PracticeExamBody } from "../components/practice-exam.tsx";
-import type { Problem, ProblemSet } from "../src/core";
-
-/**
- * The practice UI surfaces (slice 5, 2026-09-29; cluster-picker fix the
- * 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 —
- * ready + answerable, graded reveal + lock). The pure bodies are pinned
- * here (the render-suite discipline); the live fetch chains (polling,
- * dictation, submit) are Playwright's beat.
- */
-
-// ─── fixtures ────────────────────────────────────────────────────────────────
-
-const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
-
-function clusters(): GraphClusterGroup[] {
- return [
- { key: "chat_1", kind: "chat", label: "Ethics lesson", nodeIds: ["n1", "n2", "n3", "n4"], nodeCount: 4 },
- { 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 },
- ];
-}
-
-/** Small groups are listable (owner ruling: practice availability never
- * inherits the hull-drawing threshold). */
-function smallClusters(): GraphClusterGroup[] {
- return [
- { key: "chat_2", kind: "chat", label: "One-off question", nodeIds: ["n20"], nodeCount: 1 },
- { key: "chat_3", kind: "chat", label: "Quick exchange", nodeIds: ["n30", "n31"], nodeCount: 2 },
- ];
-}
-
-function stubProblem(id: string, chains: string[]): Problem {
- return { id, chains, statement: "", solution: "", rubric: "", status: "generating" };
-}
-
-function readyProblem(id: string, chains: string[], over: Partial = {}): Problem {
- return {
- id,
- chains,
- statement: `Why does arguing presuppose **self-ownership**?`,
- solution: "The model solution.",
- rubric: "- names self-ownership",
- status: "ready",
- ...over,
- };
-}
-
-function set(over: Partial = {}): ProblemSet {
- return {
- id: "ps_1",
- title: "Ethics lesson",
- createdAt: "2026-09-29T12:00:00.000Z",
- params: PARAMS,
- nodeIds: ["n1", "n3"],
- propositions: {
- n1: { name: "Self-ownership", statement: "Each person owns themself." },
- n3: { name: "Argumentation", statement: "Arguing presupposes self-ownership." },
- },
- chains: [
- { id: "n3", premises: ["n1"], conclusion: "n3" },
- ],
- status: "ready",
- problems: [],
- ...over,
- };
-}
-
-// ─── the creation dialog ─────────────────────────────────────────────────────
-
-describe("PracticeDialogBody — creation (cluster picker)", () => {
- const base = {
- params: { ...DEFAULT_PRACTICE_PARAMS },
- onParam: () => {},
- note: "",
- onNote: () => {},
- clusters: clusters(),
- selectedKey: null,
- onSelect: () => {},
- busy: false,
- failure: null as PracticeFailure | null,
- onOpenSettings: () => {},
- onClose: () => {},
- onRun: () => {},
- };
-
- it("stepped controls: 5-detent ladder + 3-detent cue + Theory|Application toggle, defaults rung 2 / cue 1 / theory", () => {
- const html = renderToStaticMarkup();
- assert.ok(html.includes('data-testid="param-ladder"'));
- assert.match(html, /min="0" max="4" step="1"/);
- assert.ok(html.includes('data-testid="param-cue"'));
- assert.match(html, /min="0" max="2" step="1"/);
- assert.ok(html.includes('data-testid="param-presentation-theory"'));
- assert.ok(html.includes('data-testid="param-presentation-application"'));
- // The DEFAULTS: rung 2 / cue 1 / theory, with names + definitions.
- assert.match(html, /value="2"/); // ladder detent
- assert.match(html, /value="1"/); // cue detent
- assert.ok(html.includes("Direct exercise"), "default rung name");
- assert.ok(html.includes("Adjacent context"), "default cue name");
- assert.ok(html.includes("one step, no hint"), "current rung definition shown");
- assert.ok(html.includes("same domain, different setup/packaging"), "current cue definition shown");
- });
-
- it("moving a detent swaps the shown name + definition; the toggle switches", () => {
- const moved = renderToStaticMarkup(
- ,
- );
- assert.ok(moved.includes("Path-finding"));
- assert.ok(moved.includes("the statement suggesting wrong methods"), "rung-4 definition");
- assert.ok(moved.includes("Lesson context"));
- assert.ok(moved.includes("textbook/toy-example shape"), "cue-0 definition");
- assert.ok(moved.includes("a concrete worldly affair"), "application definition shown");
- assert.match(moved, /param-presentation-application[^]*border-primary/, "application segment active");
- });
-
- it("the learner's note: optional textarea under the params, carries text, capped client-side", () => {
- const html = renderToStaticMarkup();
- assert.ok(html.includes('data-testid="practice-note"'));
- assert.ok(html.includes("Anything else? (optional)"), "the label");
- assert.ok(html.includes("lean toward pricing applications"), "controlled value renders");
- assert.match(html, /maxlength="2000"/i, "client-side cap mirrors the API law");
- // Empty default: present but empty — optional in every sense.
- const bare = renderToStaticMarkup();
- assert.match(bare, /data-testid="practice-note"[^>]*><\/textarea>/, "no value when unset");
- });
-
- it("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
- const html = renderToStaticMarkup();
- 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.match(html, /notes · 3 nodes|notes · 3 nodes/);
- assert.ok(html.includes("4 nodes"), "chat cluster shows its count");
- assert.ok(html.includes('disabled=""'), "no cluster picked — Create disabled");
-
- const picked = renderToStaticMarkup();
- assert.ok(picked.includes("~/notes/physics.md"));
- });
-
- it("an unlabeled cluster (naming record gone) renders muted, never guessed", () => {
- const html = renderToStaticMarkup();
- assert.ok(html.includes("unlabeled cluster"));
- assert.ok(html.includes('data-testid="practice-cluster-chat_9"'));
- });
-
- it("an empty graph is an honest empty list", () => {
- const html = renderToStaticMarkup();
- assert.ok(html.includes("No clusters yet"));
- assert.ok(html.includes("once a session births its first node"), "no 3+ mechanics in copy");
- });
-
- it("1- and 2-proposition groups appear — no hull threshold in the picker", () => {
- const html = renderToStaticMarkup();
- assert.ok(html.includes('data-testid="practice-cluster-chat_2"'), "singleton group listed");
- assert.ok(html.includes("1 node"));
- assert.ok(html.includes('data-testid="practice-cluster-chat_3"'), "pair group listed");
- assert.ok(html.includes("2 nodes"));
- });
-
- it("empty selection (vanished nodes) renders the honest inline line", () => {
- const html = renderToStaticMarkup(
- ,
- );
- assert.ok(html.includes('data-testid="practice-dialog-error"'));
- assert.ok(html.includes("Nothing practicable is left in that cluster"));
- });
-
- it("503 renders the server's line + the settings deep link", () => {
- const html = renderToStaticMarkup(
- ,
- );
- assert.ok(html.includes("No model configured"));
- assert.ok(html.includes('data-testid="practice-dialog-open-settings"'));
- });
-});
-
-describe("PracticeDialogBody — creation (CUSTOM mode, the chains overlay's handoff)", () => {
- const base = {
- params: { ...DEFAULT_PRACTICE_PARAMS },
- onParam: () => {},
- note: "",
- onNote: () => {},
- clusters: clusters(),
- selectedKey: null,
- onSelect: () => {},
- busy: false,
- failure: null as PracticeFailure | null,
- onOpenSettings: () => {},
- onClose: () => {},
- onRun: () => {},
- };
-
- it("no cluster picker — the selection summary + dated default title instead", () => {
- const html = renderToStaticMarkup(
- ,
- );
- assert.ok(!html.includes('data-testid="practice-dialog-clusters"'), "custom mode has no picker");
- assert.ok(html.includes('data-testid="practice-dialog-selection"'));
- assert.match(html, /Practice — ()?\d{4}-\d{2}-\d{2}/, "the dated default title (the pipeline's own stamp)");
- assert.ok(html.includes('data-testid="practice-dialog-selection-line"'));
- assert.ok(html.includes("4 nodes"));
- assert.ok(html.includes("2 chains"));
- assert.ok(!html.includes('disabled=""'), "a non-empty selection enables Create immediately");
- });
-
- it("dials + note still ride along — the params/note laws are mode-independent", () => {
- const html = renderToStaticMarkup(
- ,
- );
- assert.ok(html.includes('data-testid="param-ladder"'));
- assert.ok(html.includes('data-testid="practice-note"'));
- assert.ok(html.includes("1 node →"));
- });
-});
-
-// ─── the list page ───────────────────────────────────────────────────────────
-
-describe("PracticeListBody — the set list", () => {
- it("rows: title, date, counts, status word; the delete affordance", () => {
- const s = set({
- status: "generating",
- problems: [readyProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
- });
- const html = renderToStaticMarkup(
- {}} onDelete={() => {}} onNew={() => {}} />,
- );
- assert.ok(html.includes('data-testid="practice-row-ps_1"'));
- assert.ok(html.includes("Ethics lesson"));
- assert.ok(html.includes("2026-09-29"));
- assert.ok(html.includes("1/2 problems"));
- assert.ok(html.includes('data-testid="practice-status-ps_1"'));
- assert.ok(html.includes("generating"));
- assert.ok(html.includes('data-testid="practice-delete-ps_1"'));
- assert.ok(html.includes('data-testid="practice-new"'));
- });
-
- it("graded count appears; empty list states it honestly", () => {
- const s = set({
- problems: [
- readyProblem("pp_1", ["n3"], { answer: "a", grade: { score: 9, reasoning: "r", gradedAt: "2026-09-29T13:00:00.000Z" } }),
- readyProblem("pp_2", ["n1"]),
- ],
- });
- const html = renderToStaticMarkup(
- {}} onDelete={() => {}} onNew={() => {}} />,
- );
- assert.ok(html.includes("1 graded"));
- const empty = renderToStaticMarkup(
- {}} onDelete={() => {}} onNew={() => {}} />,
- );
- assert.ok(empty.includes('data-testid="practice-empty"'));
- assert.ok(empty.includes("No problem sets yet"));
- });
-
- it("setStatusWord covers the four states", () => {
- assert.equal(setStatusWord({ status: "partitioning" }), "partitioning");
- assert.equal(setStatusWord({ status: "generating" }), "generating");
- assert.equal(setStatusWord({ status: "ready" }), "ready");
- assert.equal(setStatusWord({ status: "error" }), "error");
- });
-});
-
-describe("DeleteSetBody — the delete confirm", () => {
- it("the title, the plain-consequence copy, destructive Delete", () => {
- const html = renderToStaticMarkup(
- {}} onRun={() => {}} />,
- );
- assert.ok(html.includes('data-testid="delete-set-dialog"'));
- assert.ok(html.includes("Delete problem set?"));
- assert.ok(html.includes("Ethics lesson"));
- assert.ok(html.includes("Nothing in the graph or your sessions changes."));
- assert.ok(html.includes('data-testid="delete-set-run"'));
- });
-
- it("failures render inline", () => {
- const html = renderToStaticMarkup(
- {}} onRun={() => {}} />,
- );
- assert.ok(html.includes('data-testid="delete-set-error"'));
- assert.ok(html.includes("Delete failed (409)"));
- });
-});
-
-// ─── the exam page ───────────────────────────────────────────────────────────
-
-describe("PracticeExamBody — exam states", () => {
- const examBase = {
- drafts: {} as Record,
- onDraft: () => {},
- onSubmit: () => {},
- submittingId: null as string | null,
- submitErrors: {} as Record,
- liveGradeIds: new Set(),
- };
-
- it("generating: numbered stubs with the placeholder — propositions NEVER surface", () => {
- const s = set({
- status: "generating",
- problems: [stubProblem("pp_1", ["n3"]), stubProblem("pp_2", ["n1"])],
- });
- const html = renderToStaticMarkup();
- assert.ok(html.includes("Problem 1"));
- assert.ok(html.includes("Problem 2"));
- assert.ok(html.includes('data-testid="generating-pp_1"'));
- assert.ok(html.includes("Generating problem"));
- // The never-announce law starts early: no proposition names/statements.
- assert.ok(!html.includes("Self-ownership"));
- assert.ok(!html.includes("Each person owns themself"));
- assert.ok(!html.includes('data-testid="answer-pp_1"'), "no answer box for a stub");
- // No submit while generating.
- assert.ok(!html.includes('data-testid="practice-submit-'));
- });
-
- it("the top bar carries the title + sidebar toggle; the body carries the levels line (owner, UI round 2026-09-29)", () => {
- const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
- const html = renderToStaticMarkup();
- // The chat-pattern top bar: drag surface, toggle slot, truncating title.
- assert.ok(html.includes("lh-drag"));
- assert.ok(html.includes('aria-label="Open sidebar"'));
- assert.ok(html.includes('data-testid="practice-exam-title"'));
- assert.ok(html.includes("Ethics lesson"));
- // The internal scroller (the sidebar stays fixed when this page scrolls).
- assert.ok(html.includes('data-testid="practice-exam-scroll"'));
- assert.ok(html.includes('data-testid="practice-exam-levels"'));
- assert.ok(html.includes("Direct exercise · Adjacent context · Theory"), "the fixture's rung 2 / cue 1 / theory");
- });
-
- it("ready: the statement renders as markdown, the box is answerable, submit gated on a draft", () => {
- const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
- const gated = renderToStaticMarkup();
- assert.ok(gated.includes("self-ownership")); // markdown strong rendered
- assert.ok(gated.includes('data-testid="answer-pp_1"'));
- // PER-PROBLEM submit (owner 2026-09-29): the button lives under the
- // box, gated on a draft; there is NO global submit anywhere.
- assert.ok(gated.includes('data-testid="practice-submit-pp_1"'));
- assert.ok(gated.includes('disabled=""'), "empty drafts — submit disabled");
- const filled = renderToStaticMarkup();
- assert.ok(!filled.includes('disabled=""'), "a draft arms the submit");
- assert.ok(!filled.includes('data-testid="practice-submit-error-pp_1"'));
- });
-
- it("submitted + graded: answer read-only, score + reasoning + solution revealed, rubric collapsed", () => {
- const s = set({
- problems: [
- readyProblem("pp_1", ["n3"], {
- answer: "because arguing is intentional action",
- grade: { score: 6, reasoning: "Half the target came to mind.", gradedAt: "2026-09-29T13:00:00.000Z" },
- }),
- readyProblem("pp_2", ["n1"]),
- ],
- });
- const html = renderToStaticMarkup();
- // The locked answer — one attempt, done.
- assert.ok(html.includes("because arguing is intentional action"));
- assert.ok(/readOnly/.test(html), "the submitted answer is read-only");
- assert.ok(!html.includes('data-testid="practice-submit-pp_1"'), "no re-submission");
- // The grade: mono score + brief reasoning.
- assert.ok(html.includes('data-testid="grade-pp_1"'));
- assert.ok(html.includes("6/10"));
- assert.ok(html.includes('data-testid="reasoning-pp_1"'));
- assert.ok(html.includes("Half the target came to mind."));
- // The instant reveal.
- assert.ok(html.includes('data-testid="reveal-pp_1"'));
- assert.ok(html.includes('data-testid="solution-pp_1"'));
- assert.ok(html.includes("The model solution."));
- // The rubric rides a collapsed (owner 2026-09-29).
- assert.ok(html.includes(" {
- const s = set({
- problems: [readyProblem("pp_1", ["n3"], { answer: "because arguing is intentional action" })],
- });
- const live = renderToStaticMarkup(
- ,
- );
- // Instant reveal — the solution needs no grade.
- assert.ok(live.includes('data-testid="solution-pp_1"'));
- assert.ok(live.includes('data-testid="rubric-pp_1"'));
- // The live spinner for exactly this problem.
- assert.ok(live.includes('data-testid="grading-pp_1"'));
- assert.ok(live.includes("Grading"));
- assert.ok(!live.includes('data-testid="grade-pp_1"'), "no score yet");
- // No live row (dropped grade): honest, no eternal spinner.
- const dropped = renderToStaticMarkup();
- assert.ok(dropped.includes('data-testid="grade-interrupted-pp_1"'));
- assert.ok(!dropped.includes('data-testid="grading-pp_1"'));
- });
-
- it("a failed problem says so; a failed set says so", () => {
- const s = set({
- status: "ready",
- problems: [readyProblem("pp_1", ["n3"], { status: "error", error: "the problem generator never submitted a problem" })],
- });
- const html = renderToStaticMarkup();
- assert.ok(html.includes('data-testid="problem-error-pp_1"'));
- assert.ok(html.includes("This problem failed to generate"));
-
- const dead = set({ status: "error", error: "Aborted", problems: [] });
- const deadHtml = renderToStaticMarkup();
- assert.ok(deadHtml.includes("Aborted"));
- });
-
- it("math healing rides the display seam (restoreMathEscapes at render)", () => {
- // The streaming-args parse turns single-backslash LaTeX into control
- // chars; the seam restores — the healed string must contain the slash.
- const s = set({
- problems: [readyProblem("pp_1", ["n3"], { statement: "Show that \\\\frac{a}{b} holds." })],
- });
- const html = renderToStaticMarkup();
- assert.ok(html.includes("frac"), "the LaTeX survives to render");
- });
-});
diff --git a/tests/settings-body.test.ts b/tests/settings-body.test.ts
index 0a8fafb..4b08146 100644
--- a/tests/settings-body.test.ts
+++ b/tests/settings-body.test.ts
@@ -49,7 +49,7 @@ describe("parseAgentSettingsBody", () => {
});
const bad = parseAgentSettingsBody({ models: { teacher: {} } });
assert.equal(bad.ok, false);
- if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor, partitioner, problemGenerator, grader\)/);
+ if (!bad.ok) assert.match(bad.error, /unknown role "teacher" \(supported: researcher, demoBuilder, visualMaker, extractor, auditor\)/);
const malformed = parseAgentSettingsBody({ models: { researcher: { model: { provider: 1, id: "x" } } } });
assert.equal(malformed.ok, false);
});
diff --git a/tests/settings.test.ts b/tests/settings.test.ts
index 4f344bc..1ffff21 100644
--- a/tests/settings.test.ts
+++ b/tests/settings.test.ts
@@ -203,16 +203,13 @@ describe("sanitizeUserSettings", () => {
assert.deepEqual(out.agent?.tools, { quiz: false, demo: true });
});
- it("BACKGROUND_MODEL_ROLES matches the eight background roles (practice trio 2026-09-29)", () => {
+ it("BACKGROUND_MODEL_ROLES matches the five background roles (practice trio deleted 2026-10-04)", () => {
assert.deepEqual([...BACKGROUND_MODEL_ROLES], [
"researcher",
"demoBuilder",
"visualMaker",
"extractor",
"auditor",
- "partitioner",
- "problemGenerator",
- "grader",
]);
});
});
diff --git a/tests/zero-keys.test.tsx b/tests/zero-keys.test.tsx
index 4c9ff6f..a8804ab 100644
--- a/tests/zero-keys.test.tsx
+++ b/tests/zero-keys.test.tsx
@@ -128,9 +128,6 @@ const catalog = (strongProvider: string | null, ...keyedProviders: string[]): Mo
visualMaker: null,
extractor: null,
auditor: null,
- partitioner: null,
- problemGenerator: null,
- grader: null,
},
slots: {
strong: strongProvider