delete the batch practice-problems module (superseded by practice sessions)

Owner ruling 2026-10-04: the module is a sunk cost, retrievable from
history if ever revisited. Removed: core practice contracts + pipeline
helpers, the PracticeStore port + adapter + data dir path, server
service + API routes (sets, clusters picker, practice aborts), the
practice pages + dialog/exam/list UI, chain-table's Generate handoff,
sidebar nav, activity-dock practice rows, and the partitioner/
problemGenerator/grader model roles (BACKGROUND_MODEL_ROLES 8 → 5).

Survivors, moved to core/chains.ts (pure + browser-safe): ReasoningChain,
chainsForSelection, formatChainLine — the chain table and graph
emission still consume them.
This commit is contained in:
amosblomqvist
2026-10-04 21:34:24 +03:00
parent a2c1287415
commit 814c2ad412
50 changed files with 125 additions and 5767 deletions
+3 -13
View File
@@ -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 });
}
-11
View File
@@ -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" } });
}
@@ -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);
}
-29
View File
@@ -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);
}
}
-28
View File
@@ -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);
}
}
-17
View File
@@ -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 <PracticeExam initial={set} />;
}
-14
View File
@@ -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 <PracticeListPage initialSets={listPracticeSets()} />;
}
+13 -49
View File
@@ -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<ActivityFeed>;
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"
+7 -28
View File
@@ -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({
<div data-testid="chain-table" className="relative flex h-full min-h-0 flex-col">
{/* 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). */}
<TopBar>
<span className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">Chains</span>
@@ -498,15 +494,7 @@ export function ChainTableBody({
>
Clear
</button>
<button
type="button"
data-testid="chain-generate"
onClick={onGenerate}
className="rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Generate problem set
</button>
{/* 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<ReadonlySet<string>>(() => new Set());
const [expandedClusters, setExpandedClusters] = useState<ReadonlySet<string>>(() => new Set());
const [expandedChains, setExpandedChains] = useState<ReadonlySet<string>>(() => 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}
/>
</div>
{dialogOpen && (
<PracticeDialog
mode="custom"
selection={{ nodeIds, propositions: selectedNodes.length, chains: derived.length }}
onClose={() => setDialogOpen(false)}
/>
)}
{deleteOpen && (
<DeleteNodeDialog
nodes={selectedNodes.map((n) => ({ id: n.id, name: n.name }))}
+4 -5
View File
@@ -880,12 +880,11 @@ export function GraphChromeRow({
)}
</div>
)}
{/* 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 && (
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain, selectable for practice" side="bottom">
<Hint label="Every reasoning chain" side="bottom">
<button
type="button"
onClick={onOpenChains}
@@ -1664,7 +1663,7 @@ export function GraphView() {
ABSOLUTE INSIDE the graph area (the session-overlay idiom —
never a body portal) so the app chrome stays sharp and
reachable; the overlay itself is read-only toward the graph
and owns its selection + the custom practice dialog.
and owns its selection.
LAST CHILD OF THE ROOT, DELIBERATELY (owner, 2026-09-29
night): app-region subtraction resolves by DOM order, not
z-index — mounted earlier, the chrome's lh-no-drag column
-466
View File
@@ -1,466 +0,0 @@
"use client";
/**
* The problem-set creation dialog (practice slice 5, 2026-09-29;
* cluster-picker + discretized-axes fixes same evening): the three
* NAMED-LEVEL parameters — a 5-detent ladder slider, a 3-detent cue
* slider, a Theory|Application toggle — + the CLUSTER picker → POST
* /api/practice/sets → router.push to the new exam page. In the
* delete-session dialog's shape (portaled z-50, pure body exported for
* SSR pins).
*
* GRAPH-DOMAIN, NOT CHAT-DOMAIN (owner ruling 2026-09-29): problem sets
* take NODES. The picker lists the birth-session clusters the graph
* view renders (GET /api/graph/clusters); picking one resolves to its
* member node ids, and the cluster's label rides along as the set's
* title. This dialog is the ONLY creation entry for now — the graph-view
* cluster entry comes later with the free-form selection UI.
*
* Honest failures inline: 400 empty_selection (the vanished-nodes edge
* — the cluster's propositions are gone) renders the honest line;
* 503 (models/key refusal) renders the server's line + an "Open
* settings" deep link (the zero-keys precedent — setup owns the fix,
* the dialog owns the pointer).
*/
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import type { GraphClusterGroup, PracticeParams } from "@/src/core";
/** The creation defaults (owner, 2026-09-29): rung 2 Direct exercise,
* cue 1 Adjacent context, Theory. */
export const DEFAULT_PRACTICE_PARAMS: PracticeParams = {
ladderRung: 2,
cueDistance: 1,
presentation: "theory",
};
/** The two creation modes (chains overlay, owner plan 2026-09-29):
* "cluster" — the /practice page's picker (stays cluster-ONLY, owner
* ruling 2026-09-29); "custom" — the graph view's chain table hands in
* a free-form node selection; no picker, a selection summary instead. */
export type PracticeDialogMode = "cluster" | "custom";
/** The custom mode's handoff: the flat node-id selection + the derived
* shape (chainsForSelection over the selection — the overlay computes
* it for its pinned preview and passes the same numbers here). */
export interface PracticeSelection {
nodeIds: string[];
propositions: number;
chains: number;
}
export interface PracticeFailure {
status: number;
error: string;
code?: string;
}
/** The dialog's pure body — exported for SSR pins (the delete-session /
* settings body pattern). The wrapper owns state + fetch + portal. */
export function PracticeDialogBody({
mode = "cluster",
params,
onParam,
note,
onNote,
clusters,
selectedKey,
onSelect,
selection,
busy,
failure,
onOpenSettings,
onClose,
onRun,
}: {
mode?: PracticeDialogMode;
params: PracticeParams;
onParam: (patch: Partial<PracticeParams>) => void;
/** The learner's free-form note (owner, 2026-09-29) — optional,
* typing-only; carried to the partitioner + generators as additional
* input they weigh but need not follow. */
note: string;
onNote: (note: string) => void;
clusters: GraphClusterGroup[];
selectedKey: string | null;
onSelect: (key: string) => void;
/** The custom mode's handoff (chain-table selections). */
selection?: PracticeSelection;
busy: boolean;
failure: PracticeFailure | null;
onOpenSettings: () => void;
onClose: () => void;
onRun: () => void;
}) {
const canRun = !busy && (mode === "custom" ? (selection?.nodeIds.length ?? 0) > 0 : selectedKey !== null);
return (
<div
role="dialog"
aria-modal="true"
aria-label="New problem set"
data-testid="practice-dialog"
onClick={(e) => e.stopPropagation()}
className="flex max-h-[85vh] w-[32rem] max-w-[calc(100vw-2rem)] flex-col rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="font-title text-2xl tracking-tight">New problem set</h2>
{/* The cluster picker — CLUSTER mode only (custom mode replaces
it with the selection summary below): a birth-session group of
the graph, resolved to node ids on create (graph-domain, owner
2026-09-29). */}
{mode === "cluster" && (
<div className="mt-4 flex flex-col gap-1">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">
Cluster
</span>
<div
className="max-h-40 overflow-y-auto rounded-lg border"
data-testid="practice-dialog-clusters"
>
{clusters.map((cluster) => (
<button
type="button"
key={cluster.key}
data-testid={`practice-cluster-${cluster.key}`}
onClick={() => onSelect(cluster.key)}
className={`flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm transition-colors hover:bg-accent ${
selectedKey === cluster.key ? "bg-accent" : ""
}`}
>
<span className="min-w-0 truncate">
{cluster.label ?? (
<span className="text-muted-foreground/60">
unlabeled cluster
</span>
)}
</span>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{cluster.kind === "notes" ? "notes · " : ""}
{cluster.nodeCount} {cluster.nodeCount === 1 ? "node" : "nodes"}
</span>
</button>
))}
{clusters.length === 0 && (
<div className="px-3 py-2 text-sm text-muted-foreground">
No clusters yet — a cluster appears once a session births its
first node.
</div>
)}
</div>
</div>
)}
{/* CUSTOM mode: no picker — the chain table already chose. The
dated default title (the pipeline's own `Practice — date`,
stamped server-side when no title is posted) + the derived
shape of the handoff. */}
{mode === "custom" && (
<div className="mt-4 flex flex-col gap-1" data-testid="practice-dialog-selection">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">
Selection
</span>
<div className="rounded-lg border bg-accent/30 px-3 py-2 text-sm">
<div className="text-muted-foreground">Practice — {new Date().toISOString().slice(0, 10)}</div>
<div data-testid="practice-dialog-selection-line">
{selection?.propositions ?? 0} {selection?.propositions === 1 ? "node" : "nodes"} →{" "}
{selection?.chains ?? 0} {selection?.chains === 1 ? "chain" : "chains"}
</div>
</div>
</div>
)}
{/* The three parameters — named levels, never a number to
interpolate (owner, 2026-09-29). Stepped sliders snap to the
detents; each shows the level's NAME + its one-line
definition; presentation is a two-segment toggle. */}
<div className="mt-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Ladder
<span className="text-muted-foreground/80">0 · 1 · 2 · 3 · 4</span>
</span>
<input
type="range"
min={0}
max={4}
step={1}
value={params.ladderRung}
data-testid="param-ladder"
onChange={(e) =>
onParam({
ladderRung: Number(
e.target.value,
) as PracticeParams["ladderRung"],
})
}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-ladder-level" className="text-xs">
<span className="font-medium">
{LADDER_RUNGS[params.ladderRung].name}
</span>
<span className="text-muted-foreground">
{" "}
— {LADDER_RUNGS[params.ladderRung].definition}
</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
Cue distance
<span className="text-muted-foreground/80">0 · 1 · 2</span>
</span>
<input
type="range"
min={0}
max={2}
step={1}
value={params.cueDistance}
data-testid="param-cue"
onChange={(e) =>
onParam({
cueDistance: Number(
e.target.value,
) as PracticeParams["cueDistance"],
})
}
className="h-1.5 w-full accent-primary"
/>
<span data-testid="param-cue-level" className="text-xs">
<span className="font-medium">
{CUE_LEVELS[params.cueDistance].name}
</span>
<span className="text-muted-foreground">
{" "}
— {CUE_LEVELS[params.cueDistance].definition}
</span>
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">
Presentation
</span>
<div className="flex gap-1" data-testid="param-presentation">
{(["theory", "application"] as const).map((pres) => (
<button
type="button"
key={pres}
data-testid={`param-presentation-${pres}`}
onClick={() => onParam({ presentation: pres })}
className={`flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors ${
params.presentation === pres
? "border-primary bg-primary/10 font-medium text-foreground"
: "text-muted-foreground hover:bg-accent"
}`}
>
{PRESENTATIONS[pres].name}
</button>
))}
</div>
<span
data-testid="param-presentation-level"
className="text-xs text-muted-foreground"
>
{PRESENTATIONS[params.presentation].definition}
</span>
</div>
{/* The learner's free-form note — the last word before create:
wishes for the problems, weighed by the agents, nothing owed. */}
<div className="flex flex-col gap-1">
<span className="text-[11px] text-muted-foreground">
Anything else? (optional)
</span>
<textarea
value={note}
data-testid="practice-note"
onChange={(e) => onNote(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Anything you want the problems to lean toward — situations, emphasis, context about you. The agents weigh it; nothing is promised."
className="w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
{failure && (
<div
data-testid="practice-dialog-error"
className="mt-3 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{failure.code === "empty_selection"
? "Nothing practicable is left in that cluster — the graph changed."
: failure.error}
{failure.status === 503 && (
<button
type="button"
data-testid="practice-dialog-open-settings"
onClick={onOpenSettings}
className="ml-2 underline underline-offset-2"
>
Open settings
</button>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="button"
data-testid="practice-dialog-create"
onClick={onRun}
disabled={!canRun}
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{busy ? "Creating…" : "Create"}
</button>
</div>
</div>
);
}
/** The stateful wrapper: fetches the cluster list, runs the create POST
* (nodeIds + the cluster's label as title + params), navigates on 202. */
export function PracticeDialog({
mode = "cluster",
selection,
onClose,
}: {
mode?: PracticeDialogMode;
selection?: PracticeSelection;
onClose: () => void;
}) {
const router = useRouter();
const { open: openSettings } = useSettingsDialog();
const [params, setParams] = useState<PracticeParams>({
...DEFAULT_PRACTICE_PARAMS,
});
const [note, setNote] = useState("");
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [failure, setFailure] = useState<PracticeFailure | null>(null);
// The picker's truth is the graph's clustering (fresh on open).
// The picker's truth is the graph's clustering (fresh on open) —
// cluster mode only; custom mode arrives with its selection in hand.
useEffect(() => {
if (mode !== "cluster") return;
let alive = true;
fetch("/api/graph/clusters", { cache: "no-store" })
.then((res) => res.json() as Promise<{ clusters: GraphClusterGroup[] }>)
.then((d) => {
if (alive) setClusters(d.clusters);
})
.catch(() => {
if (alive) setClusters([]);
});
return () => {
alive = false;
};
}, []);
// Esc closes (claimed, the delete-session discipline).
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose]);
const run = useCallback(async () => {
if (busy) return;
// The POST payload, by mode: custom mode sends the chain table's
// nodeIds with NO title (the pipeline stamps its dated default);
// cluster mode resolves the group + posts its label as title.
let postNodeIds: string[];
let postTitle: string | undefined;
if (mode === "custom") {
if (!selection || selection.nodeIds.length === 0) return;
postNodeIds = selection.nodeIds;
postTitle = undefined;
} else {
const picked = (clusters ?? []).find((c) => c.key === selectedKey);
if (!picked) return;
postNodeIds = picked.nodeIds;
postTitle = picked.label ?? undefined;
}
setBusy(true);
setFailure(null);
try {
const res = await fetch("/api/practice/sets", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
nodeIds: postNodeIds,
params,
title: postTitle,
note: note.trim() || undefined,
}),
});
const body = (await res.json()) as {
id?: string;
error?: string;
code?: string;
};
if (!res.ok || !body.id) {
setFailure({
status: res.status,
error: body.error ?? `Create failed (${res.status})`,
code: body.code,
});
setBusy(false);
return;
}
router.push(`/practice/${body.id}`);
} catch {
setFailure({
status: 0,
error: "Create failed — network error. Try again.",
});
setBusy(false);
}
}, [mode, selection, clusters, selectedKey, busy, params, note, router]);
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!busy) onClose();
}}
>
<PracticeDialogBody
mode={mode}
params={params}
selection={selection}
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
note={note}
onNote={setNote}
clusters={clusters ?? []}
selectedKey={selectedKey}
onSelect={setSelectedKey}
busy={busy}
failure={failure}
onOpenSettings={() => openSettings()}
onClose={onClose}
onRun={() => void run()}
/>
</div>,
document.body,
);
}
-445
View File
@@ -1,445 +0,0 @@
"use client";
/**
* The exam page (practice slice 5, one-attempt redesign 2026-09-29):
* one problem set, numbered problems, markdown statements, a textarea +
* dictation per problem (the bar renders only on the FOCUSED box —
* thinking out loud is the primary answering mode). ONE ATTEMPT: each
* problem has its OWN submit button; once submitted the box locks and
* the MODEL SOLUTION reveals instantly (rubric collapsed by default).
* The grade (score 0–10 + brief reasoning) lands via the events channel and
* updates live — no reload.
*
* TOP BAR (owner, UI round 2026-09-29): the chat pattern — an h-15
* lh-drag header in flow above the scroller, the sidebar reopen toggle
* first (it clears the traffic lights via .lh-closed-toggle), the set
* title truncating beside it. The levels + status stay in the body.
*
* DISPLAY-SEAM MATH HEALING: statements/solutions/rubrics/reasoning ride
* the same streaming-args parse as quiz/ask strings — restoreMathEscapes
* heals them at render (the same helper the tool-part seam uses).
*
* POLLING: 2s while the set isn't ready (stubs → filled), and while a
* grading run is live for this set (activity feed practice rows are
* keyed setId:grade:problemId — the LIVE rows name exactly which
* problems' grades are pending). A settled page goes quiet; a dropped
* grade shows "grade interrupted" honestly.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import type { Problem, ProblemSet } from "@/src/core";
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
import { restoreMathEscapes } from "@/src/core";
import { MessageResponse } from "@/components/ai-elements/message";
import { TopBar } from "@/components/top-bar";
import { BrailleSpinner } from "@/components/braille-spinner";
import { DictationBar, useDictation } from "@/components/dictation";
/** One answerable problem: textarea + focus-gated dictation bar. */
export function ProblemBox({
problem,
draft,
onDraft,
}: {
problem: Pick<Problem, "id">;
draft: string;
onDraft: (text: string) => void;
}) {
const [focused, setFocused] = useState(false);
// The dictation appends into THIS box only (per-box instance).
const dictation = useDictation({
append: (text) => onDraft(text),
});
return (
<div
className="flex flex-col gap-1.5"
data-testid={`problem-box-${problem.id}`}
>
<textarea
data-testid={`answer-${problem.id}`}
value={draft}
rows={5}
placeholder="Think out loud — dictation or typing, both fine."
onChange={(e) => onDraft(e.target.value)}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
{focused && <DictationBar dictation={dictation} />}
</div>
);
}
/** The exam page's pure body — exported for SSR pins. */
export function PracticeExamBody({
set,
drafts,
onDraft,
onSubmit,
submittingId,
submitErrors,
liveGradeIds,
}: {
set: ProblemSet;
drafts: Record<string, string>;
onDraft: (problemId: string, text: string) => void;
/** Per-problem submit (owner 2026-09-29): ONE attempt per problem. */
onSubmit: (problemId: string) => void;
/** The problem whose submit POST is in flight. */
submittingId: string | null;
/** Per-problem submit failures, keyed by problem id. */
submitErrors: Record<string, string>;
/** Problems whose grade rows are LIVE (the activity feed read). */
liveGradeIds: ReadonlySet<string>;
}) {
return (
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* The top bar (owner, UI round 2026-09-29): the new-session
pattern — h-15, lh-drag (the Electron window-drag surface, so
the traffic lights never cover content), the sidebar reopen
toggle first, the set title truncating beside it. */}
<TopBar>
<div
className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight"
data-testid="practice-exam-title"
>
{set.title}
</div>
</TopBar>
<div className="relative flex min-h-0 flex-1">
<div
className="h-full min-w-0 flex-1 overflow-y-auto"
data-testid="practice-exam-scroll"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6"
data-testid="practice-exam"
>
<div className="flex flex-col gap-1">
<p
className="text-xs text-muted-foreground/80"
data-testid="practice-exam-levels"
>
{LADDER_RUNGS[set.params.ladderRung].name} ·{" "}
{CUE_LEVELS[set.params.cueDistance].name} ·{" "}
{PRESENTATIONS[set.params.presentation].name}
</p>
<p
className="text-sm text-muted-foreground"
data-testid="practice-exam-status"
>
{set.status === "partitioning" && "Partitioning the chains…"}
{set.status === "generating" &&
`Generating — ${set.problems.filter((p) => p.status === "ready").length}/${set.problems.length} problems ready`}
{set.status === "error" &&
(set.error ?? "Something went wrong")}
{set.status === "ready" &&
(liveGradeIds.size > 0
? "Grading your answers…"
: `${set.problems.length} problems · ${set.problems.filter((p) => p.grade).length} graded`)}
</p>
</div>
{set.problems.map((problem, i) => (
<section
key={problem.id}
className="rounded-xl border bg-card p-4"
data-testid={`problem-${problem.id}`}
>
<h2 className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Problem {i + 1}
</h2>
{problem.status === "generating" ? (
// The stub: propositions NEVER surface (never-announce law).
<div
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid={`generating-${problem.id}`}
>
<BrailleSpinner className="size-4" />
Generating problem…
</div>
) : problem.status === "error" ? (
<p
className="text-sm text-destructive"
data-testid={`problem-error-${problem.id}`}
>
This problem failed to generate
{problem.error ? ` — ${problem.error}` : ""}.
</p>
) : (
<>
<div className="lh-prose text-sm">
<MessageResponse mode="static">
{restoreMathEscapes(problem.statement)}
</MessageResponse>
</div>
{problem.answer === undefined ? (
// ANSWERABLE (owner 2026-09-29): the box + its OWN submit
// button — one attempt, submitted the moment it's clicked.
<div className="flex flex-col items-start gap-2">
<div className="w-full">
<ProblemBox
problem={problem}
draft={drafts[problem.id] ?? ""}
onDraft={(text) => onDraft(problem.id, text)}
/>
</div>
<button
type="button"
data-testid={`practice-submit-${problem.id}`}
onClick={() => onSubmit(problem.id)}
disabled={
set.status !== "ready" ||
(drafts[problem.id] ?? "").trim() === "" ||
submittingId !== null
}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-40"
>
{submittingId === problem.id
? "Submitting…"
: "Submit answer"}
</button>
{submitErrors[problem.id] && (
<p
data-testid={`practice-submit-error-${problem.id}`}
className="text-sm text-destructive"
>
{submitErrors[problem.id]}
</p>
)}
</div>
) : (
// SUBMITTED (owner 2026-09-29): the answer locks, the
// model solution reveals INSTANTLY, the rubric stays
// collapsed, and the grade lands live when it lands.
<div className="mt-4 flex flex-col gap-3">
<textarea
data-testid={`answer-${problem.id}`}
value={problem.answer}
readOnly
rows={5}
className="w-full resize-y rounded-lg border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"
/>
<div
className="rounded-lg border bg-background p-3"
data-testid={`reveal-${problem.id}`}
>
{problem.grade ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline gap-2">
<span
data-testid={`grade-${problem.id}`}
className="font-mono text-sm font-medium"
>
{problem.grade.score}/10
</span>
</div>
<div
className="lh-prose text-sm"
data-testid={`reasoning-${problem.id}`}
>
<MessageResponse mode="static">
{restoreMathEscapes(
problem.grade.reasoning,
)}
</MessageResponse>
</div>
</div>
) : liveGradeIds.has(problem.id) ? (
<div
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid={`grading-${problem.id}`}
>
<BrailleSpinner className="size-4" />
Grading…
</div>
) : (
<p
className="text-sm text-muted-foreground"
data-testid={`grade-interrupted-${problem.id}`}
>
Grade interrupted — the answer stands.
</p>
)}
<div className="mt-3 text-xs uppercase tracking-wide text-muted-foreground">
Model solution
</div>
<div
className="lh-prose text-sm"
data-testid={`solution-${problem.id}`}
>
<MessageResponse mode="static">
{restoreMathEscapes(problem.solution)}
</MessageResponse>
</div>
<details
className="mt-3"
data-testid={`rubric-${problem.id}`}
>
<summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground">
Rubric
</summary>
<div className="mt-2 lh-prose text-sm">
<MessageResponse mode="static">
{restoreMathEscapes(problem.rubric)}
</MessageResponse>
</div>
</details>
</div>
</div>
)}
</>
)}
</section>
))}
</div>
</div>
</div>
</div>
</div>
);
}
export function PracticeExam({ initial }: { initial: ProblemSet }) {
const [set, setSet] = useState<ProblemSet>(initial);
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [submittingId, setSubmittingId] = useState<string | null>(null);
const [submitErrors, setSubmitErrors] = useState<Record<string, string>>({});
// Which problems' grade rows are LIVE (activity feed) — drives the
// per-problem "Grading…" spinners and the status line.
const [liveGradeIds, setLiveGradeIds] = useState<Set<string>>(new Set());
// True from a successful submit until the feed shows no live grade
// rows for this set — kept fresh across refetches (S7: the tick).
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
const refetch = useCallback(async () => {
try {
const res = await fetch(`/api/practice/sets/${initial.id}`, {
cache: "no-store",
});
if (!res.ok) return;
const d = (await res.json()) as { set: ProblemSet };
if (mounted.current) setSet(d.set);
} catch {
// transient fetch failures keep the last snapshot
}
}, [initial.id]);
// PUSH (S7, 2026-10-02): the 2s grading-liveness interval is RETIRED —
// the practice registry announces every grade-row transition
// (activity-changed), and tick() reads exactly that liveness, so it
// fires when truth moved: debounced (the S4 law) + on mount + on
// resync. Ungated by design: a fresh grade registration must be seen
// even from a fully-settled page (the old needsPoll gate would have
// missed it — gradingLive fed the gate, and registration is what
// flips it). The activity feed's practice rows are ROW-KEYED
// (setId:grade:problemId) — the live rows name exactly which grades
// are pending; a dropped grade (no live row) settles the page.
const tick = useCallback(async () => {
await refetch();
try {
const res = await fetch("/api/activity", { cache: "no-store" });
if (!res.ok) return;
const feed = (await res.json()) as {
practice?: Array<{ id: string; kind: string }>;
};
if (mounted.current) {
const prefix = `${initial.id}:grade:`;
const pids = (feed.practice ?? [])
.filter((row) => row.id.startsWith(prefix))
.map((row) => row.id.slice(prefix.length));
setLiveGradeIds(new Set(pids));
}
} catch {
// keep the last liveness read
}
}, [refetch, initial.id]);
const tickRef = useRef(tick);
tickRef.current = tick;
const tickDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
void tickRef.current();
return () => {
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
};
}, []);
useServerEvent("activity-changed", () => {
if (tickDebounceRef.current) clearTimeout(tickDebounceRef.current);
tickDebounceRef.current = setTimeout(() => {
tickDebounceRef.current = undefined;
void tickRef.current();
}, 250);
});
useServerResync(() => void tickRef.current());
// ONE attempt per problem (owner 2026-09-29): the submit posts THAT
// problem's answer alone; the server refuses a second submission
// forever after. On success the box locks (answer persisted) and the
// reveal shows instantly; the grade arrives via the events channel.
const submit = useCallback(
async (problemId: string) => {
if (submittingId !== null) return;
const p = set.problems.find((q) => q.id === problemId);
if (!p || p.answer !== undefined) return; // done problems never resubmit
const text = drafts[problemId] ?? "";
if (text.trim() === "") return;
setSubmittingId(problemId);
setSubmitErrors((prev) => {
const next = { ...prev };
delete next[problemId];
return next;
});
try {
const res = await fetch(`/api/practice/sets/${set.id}/answers`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ answers: [{ problemId, text }] }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
setSubmitErrors((prev) => ({
...prev,
[problemId]: body.error ?? `Submit failed (${res.status})`,
}));
setSubmittingId(null);
return;
}
setSubmittingId(null);
// The grade lands via the events channel: the grade row's register
// announces activity-changed (S7), which triggers tick() above.
await refetch();
} catch {
setSubmitErrors((prev) => ({
...prev,
[problemId]: "Submit failed — network error. Try again.",
}));
setSubmittingId(null);
}
},
[submittingId, set, drafts, refetch],
);
return (
<PracticeExamBody
set={set}
drafts={drafts}
onDraft={(problemId, text) =>
setDrafts((prev) => ({ ...prev, [problemId]: text }))
}
onSubmit={(problemId) => void submit(problemId)}
submittingId={submittingId}
submitErrors={submitErrors}
liveGradeIds={liveGradeIds}
/>
);
}
-304
View File
@@ -1,304 +0,0 @@
"use client";
/**
* The problem-set list (practice slice 5): rows of sets — title, date,
* status, problem/graded counts — click through to the exam page; a
* delete affordance in the delete-session dialog's shape; "New problem
* set" opens the creation dialog. Refetches /api/practice/sets when a
* practice phase moves (events channel, S7), so statuses live-update;
* a settled list goes quiet.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useServerEvent, useServerResync } from "@/components/server-events";
import { useRouter } from "next/navigation";
import { createPortal } from "react-dom";
import { PlusIcon, TrashIcon } from "lucide-react";
import { TopBar } from "@/components/top-bar";
import type { ProblemSet } from "@/src/core";
import { PracticeDialog } from "@/components/practice-dialog";
/** A set's status word — plain and honest, no invented progress. */
export function setStatusWord(set: Pick<ProblemSet, "status">): string {
return set.status === "partitioning"
? "partitioning"
: set.status === "generating"
? "generating"
: set.status;
}
/** The list's pure body — exported for SSR pins. Rows are buttons
* (click-through); the delete affordance is a nested icon button. */
export function PracticeListBody({
sets,
onOpen,
onDelete,
onNew,
}: {
sets: ProblemSet[];
onOpen: (id: string) => void;
onDelete: (set: ProblemSet) => void;
onNew: () => void;
}) {
return (
<div className="flex h-dvh">
<div className="flex min-w-0 flex-1 flex-col">
{/* The top bar (owner, UI round 2026-09-29): the new-session
pattern — h-15, lh-drag (the Electron window-drag surface),
the sidebar reopen toggle first, "Practice" beside it, the
page's one action at the right edge (lh-no-drag — it must
stay clickable inside the drag band). */}
<TopBar>
<div className="min-w-0 flex-1 truncate font-title text-3xl tracking-tight">
Practice
</div>
<button
type="button"
data-testid="practice-new"
onClick={onNew}
className="lh-no-drag flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors hover:bg-accent"
>
<PlusIcon className="size-3.5" />
New problem set
</button>
</TopBar>
<div className="relative flex min-h-0 flex-1">
<div
className="h-full min-w-0 flex-1 overflow-y-auto"
data-testid="practice-list-scroll"
>
<div
className="mx-auto flex w-full max-w-3xl flex-col gap-3 p-6"
data-testid="practice-list"
>
{sets.length === 0 && (
<div
className="rounded-xl border bg-card p-6 text-sm text-muted-foreground"
data-testid="practice-empty"
>
No problem sets yet — create one from a session.
</div>
)}
<div className="flex flex-col gap-2">
{sets.map((set) => {
const ready = set.problems.filter(
(p) => p.status === "ready",
).length;
const graded = set.problems.filter((p) => p.grade).length;
return (
<div key={set.id} className="flex items-stretch gap-1">
<button
type="button"
data-testid={`practice-row-${set.id}`}
onClick={() => onOpen(set.id)}
className="flex flex-1 items-center justify-between gap-4 rounded-xl border bg-card px-4 py-3 text-left transition-colors hover:bg-accent/50"
>
<span className="min-w-0">
<span className="block truncate font-medium">
{set.title}
</span>
<span className="block text-xs text-muted-foreground">
{set.createdAt.slice(0, 10)} ·{" "}
{set.problems.length > 0
? `${ready}/${set.problems.length} problems`
: "no problems yet"}
{graded > 0 ? ` · ${graded} graded` : ""}
</span>
</span>
<span
data-testid={`practice-status-${set.id}`}
className={`shrink-0 text-xs uppercase tracking-wide ${
set.status === "error"
? "text-destructive"
: "text-muted-foreground"
}`}
>
{setStatusWord(set)}
</span>
</button>
<button
type="button"
data-testid={`practice-delete-${set.id}`}
aria-label={`Delete ${set.title}`}
onClick={() => onDelete(set)}
className="flex items-center rounded-xl border px-3 text-muted-foreground/60 transition-colors hover:bg-accent hover:text-foreground"
>
<TrashIcon className="size-4" />
</button>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
</div>
);
}
/** The delete confirm — the delete-session dialog's shape (no mode
* checkbox: a set has no preserve-graph question). Pure body + portal
* wrapper. */
export function DeleteSetBody({
title,
busy,
failure,
onClose,
onRun,
}: {
title: string;
busy: boolean;
failure: string | null;
onClose: () => void;
onRun: () => void;
}) {
return (
<div
role="dialog"
aria-modal="true"
aria-label="Delete problem set"
data-testid="delete-set-dialog"
onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="font-title text-2xl tracking-tight text-destructive">
Delete problem set?
</h2>
<p className="mt-2 font-mono text-sm">{title}</p>
<p className="mt-3 text-sm text-muted-foreground">
The set and its problems are removed. Nothing in the graph or your
sessions changes.
</p>
{failure && (
<p
data-testid="delete-set-error"
className="mt-3 text-sm text-destructive"
>
{failure}
</p>
)}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="button"
data-testid="delete-set-run"
onClick={onRun}
disabled={busy}
className="rounded-md bg-destructive px-3 py-1.5 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90 disabled:opacity-40"
>
{busy ? "Deleting…" : "Delete"}
</button>
</div>
</div>
);
}
export function PracticeListPage({
initialSets,
}: {
initialSets: ProblemSet[];
}) {
const router = useRouter();
const [sets, setSets] = useState<ProblemSet[]>(initialSets);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<ProblemSet | null>(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [deleteFailure, setDeleteFailure] = useState<string | null>(null);
const refresh = useCallback(async () => {
try {
const res = await fetch("/api/practice/sets", { cache: "no-store" });
if (!res.ok) return;
const d = (await res.json()) as { sets: ProblemSet[] };
setSets(d.sets);
} catch {
// a transient failure keeps the last snapshot
}
}, []);
// PUSH (S7, 2026-10-02): the bounded 2s reveal poll is RETIRED — the
// practice registry announces every phase transition (register,
// generating, grading, finish) as activity-changed, so the list
// refetches exactly when a set's status moved. Debounced (the S4
// law); resync heals a reconnect; the mount read catches drift since
// the server-rendered snapshot.
const refreshRef = useRef(refresh);
refreshRef.current = refresh;
const listDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => {
void refreshRef.current();
return () => {
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
};
}, []);
useServerEvent("activity-changed", () => {
if (listDebounceRef.current) clearTimeout(listDebounceRef.current);
listDebounceRef.current = setTimeout(() => {
listDebounceRef.current = undefined;
void refreshRef.current();
}, 250);
});
useServerResync(() => void refreshRef.current());
const runDelete = useCallback(async () => {
if (!deleting || deleteBusy) return;
setDeleteBusy(true);
setDeleteFailure(null);
try {
const res = await fetch(`/api/practice/sets/${deleting.id}`, {
method: "DELETE",
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string };
setDeleteFailure(body.error ?? `Delete failed (${res.status})`);
setDeleteBusy(false);
return;
}
setDeleting(null);
setDeleteBusy(false);
await refresh();
} catch {
setDeleteFailure("Delete failed — network error. Try again.");
setDeleteBusy(false);
}
}, [deleting, deleteBusy, refresh]);
return (
<>
<PracticeListBody
sets={sets}
onOpen={(id) => router.push(`/practice/${id}`)}
onDelete={(set) => setDeleting(set)}
onNew={() => setCreating(true)}
/>
{creating && <PracticeDialog onClose={() => setCreating(false)} />}
{deleting &&
createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80"
onClick={() => {
if (!deleteBusy) setDeleting(null);
}}
>
<DeleteSetBody
title={deleting.title}
busy={deleteBusy}
failure={deleteFailure}
onClose={() => setDeleting(null)}
onRun={() => void runDelete()}
/>
</div>,
document.body,
)}
</>
);
}
+1 -1
View File
@@ -49,7 +49,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
/** The pickers' slot identity — same roster language as the Agent
* section's slot rows. */
const SLOT_META: Record<ModelSlotName, { label: string; roster: string }> = {
strong: { label: "Strong", roster: "Prober, teacher, practice trio" },
strong: { label: "Strong", roster: "Prober, teacher" },
cheap: { label: "Cheap", roster: "Researcher, demo builder, extractor, auditor" },
vision: { label: "Vision", roster: "Visual maker + PDF reader" },
};
+1 -16
View File
@@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
import { MenuShell } from "@/components/menu-shell";
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
import type { ChatDeleteMode } from "@/src/core";
import { GraduationCapIcon, PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { PanelLeftCloseIcon, PanelLeftOpenIcon, PlusIcon, SearchIcon, SettingsIcon } from "lucide-react";
import { ActivitySection } from "@/components/activity-dock";
import { CoverageMenuBlock } from "@/components/extraction-coverage";
import { LhMark } from "@/components/lh-mark";
@@ -655,21 +655,6 @@ function SessionsSidebar() {
<span className="sr-only">create a new session</span>
</button>
{/* 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. */}
<button
type="button"
data-testid="nav-practice"
onClick={() => 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"
}`}
>
<GraduationCapIcon className="size-3" />
Practice
</button>
<div className="mx-3 mb-1 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<Hint label="Search sessions" action="sessions-search" side="bottom">
+1 -8
View File
@@ -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/<chatId>.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
+4 -4
View File
@@ -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 {
+3 -5
View File
@@ -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. */
-652
View File
@@ -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<string, RunningPractice>;
/** Per-set write mutexes (audit-lock idiom, per-set). */
__lhPracticeLocks?: Map<string, { tail: Promise<void> }>;
}
function practiceRuns(): Map<string, RunningPractice> {
const g = globalThis as typeof globalThis & LhPracticeGlobal;
return (g.__lhPracticeRuns ??= new Map());
}
function practiceLocks(): Map<string, { tail: Promise<void> }> {
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<T>(setId: string, critical: () => Promise<T> | T): Promise<T> {
const locks = practiceLocks();
const prev = locks.get(setId)?.tail ?? Promise.resolve();
let release!: () => void;
const mine = new Promise<void>((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<string>): 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<void> {
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<void> {
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<void> }> {
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<void> }> {
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<void> {
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<boolean> {
abortPractice(setId);
let existed = false;
await withSetLock(setId, () => {
existed = deps.practiceStore.deleteSet(setId);
});
return existed;
}
return {
createProblemSet,
submitAnswers,
abortPractice,
abortPracticeRow,
deleteProblemSet,
};
}
-567
View File
@@ -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<typeof partitionSchema>;
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<typeof problemSchema>;
const gradeSchema = Type.Object({
score: Type.Integer({ minimum: 0, maximum: 10 }),
reasoning: Type.String({ minLength: 1 }),
});
type GradeSubmit = Static<typeof gradeSchema>;
// ─── rendering (the agent-facing text) ───────────────────────────────────────
export type PropositionSnapshot = Record<string, { name: string; statement: string }>;
/** 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<ReasoningChain, "id" | "premises" | "conclusion">): 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<ModelSlotName>();
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<PartitionerOutput> {
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<GeneratorOutput> {
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<GraderOutput> {
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 };
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -102,7 +102,7 @@ export const askUserQuestionTool: AgentTool<typeof askInputSchema> = {
// 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.
+67
View File
@@ -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<Pick<{ id: string }, "id">>,
chains: ReadonlyArray<Pick<{ id: string; premises: string[]; conclusion: string }, "id" | "premises" | "conclusion">>,
): ReasoningChain[] {
const selected = new Set(nodes.map((n) => n.id));
const chained = new Set<string>();
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<ReasoningChain, "premises" | "conclusion">,
propositions: Readonly<Record<string, { name: string }>>,
): 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)`;
}
+1 -2
View File
@@ -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";
+2 -22
View File
@@ -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. */
-200
View File
@@ -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<string, { name: string; statement: string }>;
/** 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[];
}
+2 -2
View File
@@ -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<string, never>;
/** The session list changed (create, delete, title). */
"chats-changed": Record<string, never>;
+7 -4
View File
@@ -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 };
+1 -1
View File
@@ -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.
-28
View File
@@ -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<ProblemSet, "id">;
/**
* 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) ──
/**
-119
View File
@@ -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<LadderRung, { name: string; definition: string }> = {
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<CueDistance, { name: string; definition: string }> = {
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<Presentation, { name: string; definition: string }> = {
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<Pick<GraphNode, "id">>,
chains: ReadonlyArray<Pick<GraphChain, "id" | "premises" | "conclusion">>,
): ReasoningChain[] {
const selected = new Set(nodes.map((n) => n.id));
const chained = new Set<string>();
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<ReasoningChain, "premises" | "conclusion">,
propositions: Readonly<Record<string, { name: string }>>,
): 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<Pick<ReasoningChain, "id">>;
problems: ReadonlyArray<Pick<Problem, "chains">>;
},
): ChainCoverageReport {
const counts = new Map<string, number>();
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() };
}
-9
View File
@@ -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<Record<ModelRole, ModelSlotName>> = {
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
-17
View File
@@ -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
+1 -1
View File
@@ -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
+1 -24
View File
@@ -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
-1
View File
@@ -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";
-28
View File
@@ -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(),
};
}
-152
View File
@@ -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<string, unknown>;
// 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<Response> {
const b = (body ?? {}) as Record<string, unknown>;
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<Response> {
if (!ID_OK.test(setId)) return Response.json({ error: "Invalid set id" }, { status: 400 });
const b = (body ?? {}) as Record<string, unknown>;
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<string, unknown>).problemId === "string" &&
typeof (a as Record<string, unknown>).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);
}
}
-2
View File
@@ -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,
};
-192
View File
@@ -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<string, unknown>;
// 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<string, unknown>).id === "string" &&
typeof (c as Record<string, unknown>).conclusion === "string" &&
isStringArray((c as Record<string, unknown>).premises),
);
const isPropositions = (v: unknown): boolean => {
if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
return Object.values(v as Record<string, unknown>).every(
(p) =>
typeof p === "object" &&
p !== null &&
typeof (p as Record<string, unknown>).name === "string" &&
typeof (p as Record<string, unknown>).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<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
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, "id">): 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,
};
-404
View File
@@ -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",
);
});
});
File diff suppressed because it is too large Load Diff
-69
View File
@@ -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<number> {
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/);
});
});
-184
View File
@@ -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<ProblemSet, "id">> = {}): Omit<ProblemSet, "id"> => ({
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<typeof set>;
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)");
});
});
-153
View File
@@ -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");
});
});
+2 -4
View File
@@ -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"'));
});
-438
View File
@@ -1,438 +0,0 @@
import React from "react";
(globalThis as Record<string, unknown>).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> = {}): 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> = {}): 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(<PracticeDialogBody {...base} />);
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(
<PracticeDialogBody {...base} params={{ ladderRung: 4, cueDistance: 0, presentation: "application" }} />,
);
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(<PracticeDialogBody {...base} note="lean toward pricing applications" />);
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(<PracticeDialogBody {...base} />);
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(<PracticeDialogBody {...base} />);
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(<PracticeDialogBody {...base} selectedKey="src_ab12" />);
assert.ok(picked.includes("~/notes/physics.md"));
});
it("an unlabeled cluster (naming record gone) renders muted, never guessed", () => {
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
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(<PracticeDialogBody {...base} clusters={[]} />);
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(<PracticeDialogBody {...base} clusters={smallClusters()} />);
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(
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 400, error: "selection has no propositions to practice", code: "empty_selection" }} />,
);
assert.ok(html.includes('data-testid="practice-dialog-error"'));
assert.ok(html.includes("Nothing practicable is left in that cluster"));
});
it("503 renders the server's line + the settings deep link", () => {
const html = renderToStaticMarkup(
<PracticeDialogBody {...base} selectedKey="chat_1" failure={{ status: 503, error: "No model configured — set up your models first", code: "models_not_configured" }} />,
);
assert.ok(html.includes("No model configured"));
assert.ok(html.includes('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(
<PracticeDialogBody
{...base}
mode="custom"
selection={{ nodeIds: ["n1", "n2", "n3", "n4"], propositions: 4, chains: 2 }}
/>,
);
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(
<PracticeDialogBody {...base} mode="custom" selection={{ nodeIds: ["n1"], propositions: 1, chains: 1 }} />,
);
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(
<PracticeListBody sets={[s]} onOpen={() => {}} 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(
<PracticeListBody sets={[s]} onOpen={() => {}} onDelete={() => {}} onNew={() => {}} />,
);
assert.ok(html.includes("1 graded"));
const empty = renderToStaticMarkup(
<PracticeListBody sets={[]} onOpen={() => {}} 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(
<DeleteSetBody title="Ethics lesson" busy={false} failure={null} onClose={() => {}} 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(
<DeleteSetBody title="X" busy={false} failure="Delete failed (409)" onClose={() => {}} 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<string, string>,
onDraft: () => {},
onSubmit: () => {},
submittingId: null as string | null,
submitErrors: {} as Record<string, string>,
liveGradeIds: new Set<string>(),
};
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(<PracticeExamBody set={s} {...examBase} />);
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(<PracticeExamBody set={s} {...examBase} />);
// 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(<PracticeExamBody set={s} {...examBase} />);
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(<PracticeExamBody set={s} {...examBase} drafts={{ pp_1: "because…" }} />);
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(<PracticeExamBody set={s} {...examBase} />);
// 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 <details> (owner 2026-09-29).
assert.ok(html.includes("<details"));
assert.ok(!html.includes("<details open"), "collapsed by default");
assert.ok(html.includes('data-testid="rubric-pp_1"'));
assert.ok(html.includes("names self-ownership"));
// Unanswered sibling stays answerable.
assert.ok(html.includes('data-testid="answer-pp_2"'));
assert.ok(html.includes('data-testid="practice-submit-pp_2"'));
});
it("submitted, grade pending: the reveal shows instantly, grading spins per problem", () => {
const s = set({
problems: [readyProblem("pp_1", ["n3"], { answer: "because arguing is intentional action" })],
});
const live = renderToStaticMarkup(
<PracticeExamBody set={s} {...examBase} liveGradeIds={new Set(["pp_1"])} />,
);
// 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(<PracticeExamBody set={s} {...examBase} />);
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(<PracticeExamBody set={s} {...examBase} />);
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(<PracticeExamBody set={dead} {...examBase} />);
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(<PracticeExamBody set={s} {...examBase} />);
assert.ok(html.includes("frac"), "the LaTeX survives to render");
});
});
+1 -1
View File
@@ -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);
});
+1 -4
View File
@@ -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",
]);
});
});
-3
View File
@@ -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