practice — discretized parameter axes: named levels, never a number (owner-approved 2026-09-29)
Contracts: LadderRung 0-4, CueDistance 0-2, Presentation theory|
application; PracticeParams carries the three named levels. Doc
comments hold the three definition tables verbatim (rung names +
definitions from the ladder doctrine; cue levels; presentation
levels).
Core: LADDER_RUNGS/CUE_LEVELS/PRESENTATIONS tables in src/core/
practice.ts (definitions verbatim) — ONE source spoken by the agents'
prompts, the dialog's stepped controls, and the exam header; core
barrel re-exports practice.ts (pure domain, browser-safe).
Agents: Part I's ladder intro now 'five rungs, numbered; the request
arrives as a rung' with bullets numbered 0-4 (definitions untouched);
Part III's parameters block replaced with the named-level doctrine
(rung = exact class, no continuum; cue counts lesson examples AND
previous problems' situations; presentation dresses, never changes);
partitioner's RESPECT THE PARAMETERS replaced (low rungs = many small
assignments, high = larger; far cue makes confusables-together MORE
important; presentation does not shape the partition). renderParams
outputs the exact named-level block. Anchor/0..1 language swept dead.
Service + store: strict union validation — floats between levels,
out-of-range, wrong strings, legacy dial shapes all 400 at the API
and fail isProblemSet (legacy sets skipped from lists; no migration —
data/practice is disposable scratch, owner wipes before testing).
UI: dialog = 5-detent ladder slider + 3-detent cue slider (each shows
the level NAME + one-line definition) + Theory|Application segmented
toggle; defaults rung 2 / cue 1 / theory; Create posts the new shape.
Exam header gains the levels line ('Direct exercise · Adjacent
context · Theory').
Tests: renderParams exact-block pin; strict-validation pins (new
tests/practice-service.test.ts — bad unions 400, legacy shape 400);
store pins (legacy floats + 2.5 rung fail); render pins (detents +
names + definitions, defaults, detent-swap, toggle active segment,
levels line); every params fixture moved off floats. 1138/1138, tsc
0, lint 0, build 0.
Docs: architecture.md — discretization paragraph (compact tables,
named-level invocation, defaults, one-table source), UI mentions;
README axes sentence.
This commit is contained in:
@@ -156,7 +156,7 @@ data/ chats/ · graph/ (six ledgers + positions/source
|
||||
|
||||
**The knowledge graph (two channels, one writer).** The write channel: the extraction pipeline — extractor (LLM: sees only the session, rendered as readable markdown by the shared `core/session-markdown.ts` engine — propositions as true statements, `follows`/`supports` edges with verbatim evidence; fully trusted, graph-blind) → auditor (LLM, an agentic loop that never sees the source: explores the existing graph through `find` / `list_sessions` / `list_propositions` tools, stamps each proposition with an identity verdict — link to an existing node (equivalence bias: paraphrases merge) or create — and may draw bridging edges between existing nodes and the new batch) → merger (`mergeFindings`, deterministic: applies the verdicts, link-appends phrasings/provenance, resolves and dedupes edges, checkpoints coverage) → layout settle. No quiz outcomes or understanding assessments live in the graph (2026-09-27 cleanup); practice (`/practice`) consumes the graph as feedback-only problem sets and writes nothing back. Triggered when a chat's **uncovered tokens** (message-view fingerprints, `coverage.jsonl`) cross `graph.extractionChunkTokens` (a settings knob, default 4096, floor 500 — read fresh per turn); failure checkpoints nothing, the next trigger re-runs. The same pipeline serves the **notes front door** (`POST /api/graph/import`: any `*.md`/`*.txt` becomes a source; re-imports are idempotent by path hash). The read channel: `GET /api/graph` derives the snapshot from the ledgers — a pure read; the home view is a hand-rolled Canvas 2D renderer (one rAF loop, dirty-flag idle, d3-zoom camera, zoom-gated labels, cluster hulls) over a d3-force layout, with server-persisted render settings and drag-committed positions. Clusters are **birth-session groupings**: a node joins the cluster of the session that birthed it (a chat, or an imported notes file as a file-kind session), derived at read — no embedding pass, no naming pass. The teacher reaches the graph on its own terms through the gated prober tools (`list_sessions` / `list_propositions`, the `agent.graphTools` setting) — there is no automatic context injection.
|
||||
|
||||
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
**Practice (problem sets).** The graph's `follows`-edges become reasoning chains; the `/practice` creation dialog picks a birth-session cluster (the graph view's own groups) and freezes its propositions + chains into a self-contained `ProblemSet`. Three named-level axes steer generation (ladder rung 0–4, cue distance 0–2, Theory|Application — never a number to interpolate; defaults Direct exercise · Adjacent context · Theory). A partitioner agent covers every chain exactly once, one generator per partition writes problems against the owner's problem-engineering framework (commit-before-pose: statement + solution + rubric together), and grading is **strengthening, not assessment** — one grader per answer, verdict (`solid`/`partial`/`missed`) + feedback, post-grade reveal, nothing ever written back to the graph. Runs are registry-backed background jobs on the activity dock (no steer, never wakes the teacher); sets live in `data/practice`, crash-swept on boot.
|
||||
|
||||
**Dictation.** Optional speech-to-text: the server mints short-lived Deepgram tokens; audio streams browser→Deepgram over WebSocket, never through the server.
|
||||
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
/**
|
||||
* The problem-set creation dialog (practice slice 5, 2026-09-29;
|
||||
* cluster-picker fix same evening): the three parameter dials + 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).
|
||||
* 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
|
||||
@@ -25,15 +27,16 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useSettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import type { GraphClusterGroup } from "@/src/core";
|
||||
import { CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "@/src/core";
|
||||
import type { GraphClusterGroup, PracticeParams } from "@/src/core";
|
||||
|
||||
/** The three dials — key order matches PracticeParams; anchors are the
|
||||
* honest display (a raw 0..1 number tells the learner nothing). */
|
||||
export const PRACTICE_DIALS = [
|
||||
{ key: "searchSolutionRatio", label: "Search ↔ Solve", anchors: "flashcard · competition" },
|
||||
{ key: "cueDistance", label: "Cue distance", anchors: "near the lesson · far transfer" },
|
||||
{ key: "presentation", label: "Presentation", anchors: "theory · application" },
|
||||
] as const;
|
||||
/** 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",
|
||||
};
|
||||
|
||||
export interface PracticeFailure {
|
||||
status: number;
|
||||
@@ -44,8 +47,8 @@ export interface PracticeFailure {
|
||||
/** The dialog's pure body — exported for SSR pins (the delete-session /
|
||||
* settings body pattern). The wrapper owns state + fetch + portal. */
|
||||
export function PracticeDialogBody({
|
||||
values,
|
||||
onDial,
|
||||
params,
|
||||
onParam,
|
||||
clusters,
|
||||
selectedKey,
|
||||
onSelect,
|
||||
@@ -55,8 +58,8 @@ export function PracticeDialogBody({
|
||||
onClose,
|
||||
onRun,
|
||||
}: {
|
||||
values: Record<(typeof PRACTICE_DIALS)[number]["key"], number>;
|
||||
onDial: (key: (typeof PRACTICE_DIALS)[number]["key"], v: number) => void;
|
||||
params: PracticeParams;
|
||||
onParam: (patch: Partial<PracticeParams>) => void;
|
||||
clusters: GraphClusterGroup[];
|
||||
selectedKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
@@ -110,26 +113,74 @@ export function PracticeDialogBody({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* The three dials (SettingSlider's markup, anchors as display). */}
|
||||
{/* 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">
|
||||
{PRACTICE_DIALS.map((dial) => (
|
||||
<label key={dial.key} className="flex flex-col gap-1">
|
||||
<span className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
{dial.label}
|
||||
<span className="text-muted-foreground/80">{dial.anchors}</span>
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={values[dial.key]}
|
||||
data-testid={`dial-${dial.key}`}
|
||||
onChange={(e) => onDial(dial.key, Number(e.target.value))}
|
||||
className="h-1.5 w-full accent-primary"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{failure && (
|
||||
@@ -176,11 +227,7 @@ export function PracticeDialogBody({
|
||||
export function PracticeDialog({ onClose }: { onClose: () => void }) {
|
||||
const router = useRouter();
|
||||
const { open: openSettings } = useSettingsDialog();
|
||||
const [values, setValues] = useState<Record<(typeof PRACTICE_DIALS)[number]["key"], number>>({
|
||||
searchSolutionRatio: 0.5,
|
||||
cueDistance: 0.5,
|
||||
presentation: 0.5,
|
||||
});
|
||||
const [params, setParams] = useState<PracticeParams>({ ...DEFAULT_PRACTICE_PARAMS });
|
||||
const [clusters, setClusters] = useState<GraphClusterGroup[] | null>(null);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -225,7 +272,7 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
nodeIds: picked.nodeIds,
|
||||
params: values,
|
||||
params,
|
||||
title: picked.label ?? undefined,
|
||||
}),
|
||||
});
|
||||
@@ -240,7 +287,7 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
|
||||
setFailure({ status: 0, error: "Create failed — network error. Try again." });
|
||||
setBusy(false);
|
||||
}
|
||||
}, [clusters, selectedKey, busy, values, router]);
|
||||
}, [clusters, selectedKey, busy, params, router]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -250,8 +297,8 @@ export function PracticeDialog({ onClose }: { onClose: () => void }) {
|
||||
}}
|
||||
>
|
||||
<PracticeDialogBody
|
||||
values={values}
|
||||
onDial={(key, v) => setValues((prev) => ({ ...prev, [key]: v }))}
|
||||
params={params}
|
||||
onParam={(patch) => setParams((prev) => ({ ...prev, ...patch }))}
|
||||
clusters={clusters ?? []}
|
||||
selectedKey={selectedKey}
|
||||
onSelect={setSelectedKey}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { GradeVerdict, 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 { BrailleSpinner } from "@/components/braille-spinner";
|
||||
@@ -87,6 +88,10 @@ export function PracticeExamBody({
|
||||
<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">
|
||||
<h1 className="font-title text-3xl tracking-tight">{set.title}</h1>
|
||||
<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`}
|
||||
|
||||
@@ -374,13 +374,15 @@ Practice turns the graph into problem sets the learner reasons through out loud.
|
||||
|
||||
**Self-contained sets.** A `ProblemSet` freezes its world at creation: the selected node ids, a **propositions snapshot** (`Record<nodeId, {name, statement}>`), and the derived chains all persist into the set (`data/practice/*.json`, one file per set, `PracticeStore` port). Generation, grading, coverage, and the UI read ONLY the set — later extractions can never shift the ground under a live or historical set.
|
||||
|
||||
**Two-stage generation.** One **partitioner** agent reads the chains plus the three creator-set dials (`searchSolutionRatio`, `cueDistance`, `presentation` — each 0..1, shown as anchor text in the UI, never raw numbers) and submits the true partition; each partition becomes one problem's target. Then **one generator agent per problem**: its prompt carries the problem-engineering framework verbatim (the owner's doctrine — the three distances, the search-to-solution axis, the rung ladder, reverse/discriminate/probe-the-premises, the standing prohibitions), **YOUR TARGET** (the assigned chains echoed verbatim — the echo law: the submit's chain set must equal the assignment exactly), and **THE FULL SET** (the whole selection: outside propositions are supporting material the generator may draw on with minimal use and maximal focus; the never-announce law covers ALL propositions). The submit is commit-before-pose — statement, solution, and rubric arrive together (the same sycophancy mitigation the quiz/explain mechanic uses); a problem never exists without its pre-written answer. Generators run in parallel (a process-wide 3-permit semaphore shared with grading) with per-problem failure isolation: one bad problem errors alone; the set still reaches `ready`. **Dedup across sets**: each generator also sees the problems of the ≤5 most recent sets sharing a node, so situations don't repeat between sets; within a set, parallel generators can't see each other — accepted for V1 (owner ruling).
|
||||
**The three axes are discretized named levels** (owner, 2026-09-29 — never a number to interpolate; there is no continuum between levels): the **ladder rung** (the main axis — 0 Worked example, 1 Completion, 2 Direct exercise, 3 Composition, 4 Path-finding; the invocation names the rung and the generator builds exactly that CLASS of problem), 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; 2 Far context — the edge of the recognizable band, where the proposition must still genuinely apply and be findable by looking), and the **presentation** (Theory — the subject's own terms, or Application — a concrete worldly affair in which the proposition's structure appears in the wild; it dresses the situation, never changes the rung or the cue distance). The names + one-line definitions live in one place (`src/core/practice.ts`'s level tables) and are spoken by everything: the agents' prompts (`renderParams`), the creation dialog's stepped controls, the exam header's levels line. Creation defaults: rung 2 Direct exercise · cue 1 Adjacent context · Theory. The API validates the exact unions — a float between rungs is a 400; legacy float-params sets fail the store validator and are skipped (no migration — `data/practice` is disposable scratch).
|
||||
|
||||
**Two-stage generation.** One **partitioner** agent reads the chains plus the three NAMED-LEVEL parameters and submits the true partition; each partition becomes one problem's target. Then **one generator agent per problem**: its prompt carries the problem-engineering framework verbatim (the owner's doctrine — the three distances, the search-to-solution axis, the rung ladder, reverse/discriminate/probe-the-premises, the standing prohibitions), **YOUR TARGET** (the assigned chains echoed verbatim — the echo law: the submit's chain set must equal the assignment exactly), and **THE FULL SET** (the whole selection: outside propositions are supporting material the generator may draw on with minimal use and maximal focus; the never-announce law covers ALL propositions). The submit is commit-before-pose — statement, solution, and rubric arrive together (the same sycophancy mitigation the quiz/explain mechanic uses); a problem never exists without its pre-written answer. Generators run in parallel (a process-wide 3-permit semaphore shared with grading) with per-problem failure isolation: one bad problem errors alone; the set still reaches `ready`. **Dedup across sets**: each generator also sees the problems of the ≤5 most recent sets sharing a node, so situations don't repeat between sets; within a set, parallel generators can't see each other — accepted for V1 (owner ruling).
|
||||
|
||||
**Grading is feedback-only.** Submitting answers fires one **grader** job per answered problem: the grader sees the problem, the propositions its chains touch, the pre-written solution and rubric, and the learner's answer — never the full set (minimal, by ruling). It returns a verdict (`solid`/`partial`/`missed`) and feedback that names the failed recall and teaches the boundary. The verdict lands on the problem and the UI reveals it with the solution and rubric; the answer locks. Nothing reaches the graph, no level, no count, anywhere. A grader failure leaves the answer gradeless and re-submittable.
|
||||
|
||||
**The jobs pattern — registry, not JobRecords.** Practice runs are NOT Workbench jobs: no `JobStore` records, no steer messages, nothing ever wakes the teacher — a practice run isn't chat-scoped (a set outlives its birth session and has no lesson to re-enter; its result is itself, persisted in its own store and read by its own page). What it shares with the Workbench is the observability surface: a `globalThis` run registry (the module-instance idiom — `__lhPracticeRuns`), one row per set-phase (`setId:generation`, `setId:grading`), feeding the activity dock (GraduationCap rows; abort is set-scoped and idempotent; a boot sweep marks orphaned `partitioning`/`generating` sets as `error "Interrupted (server restarted)"` — the job runner's crash-honesty at set grain). Store writes serialize behind a per-set mutex; the pipeline is the sole writer of `data/practice`.
|
||||
|
||||
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the three dials and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
|
||||
**API + UI.** Creation is GRAPH-DOMAIN, not chat-domain (owner ruling 2026-09-29: "problem sets always take in nodes — graph objects"): `POST /api/practice/sets` takes `{nodeIds, params, title?}` ONLY — no chatId, no clusterId; the UI resolves a picked cluster to its member node ids and passes the cluster's label as the title. `GET /api/graph/clusters` serves the picker: the birth-session partition itself (`birthSessionPartition`, same ordering — size desc, ties by id) — EVERY group regardless of size (owner ruling, same evening: MIN_CLUSTER_SIZE is a hull-drawing rule; practice availability must not inherit a visual threshold), each as `{key, kind: "chat"|"notes", label, nodeIds, nodeCount}`; a cluster whose naming record is gone keeps `label: null` — honest, never guessed. 202 `{id}` with generation fire-and-forget; 400 empty selection (empty intersection with live nodes — the vanished-nodes edge) / bad params; 503 model/key refusal. `GET /api/practice/sets` · `GET/DELETE /api/practice/sets/[id]` (delete aborts live runs first) · `POST /api/practice/sets/[id]/answers` (202; 409 when not ready). Surfaces: `/practice` (the list — status/counts rows, delete confirm, the new-set dialog with the named-level controls (a 5-detent ladder slider, a 3-detent cue slider, a Theory|Application toggle — each showing the level's name + definition) and the cluster picker — the ONLY creation entry for now; the graph-view cluster entry comes later with the free-form selection UI) and `/practice/[id]` (the exam page — a levels line in the header, numbered problems, markdown statements with math healing at the display seam, a textarea + focus-gated dictation bar per problem, one Submit per set, the post-grade reveal). Entry: the sidebar's Practice nav row.
|
||||
|
||||
**Model wiring.** Three roles — `partitioner`, `problemGenerator`, `grader` — all pinned to the **strong** slot (quality-critical, one settings edit from cheap) and in `BACKGROUND_MODEL_ROLES` (per-role overrides). The stage agents are single-submit loops on the shared `runSubmitAgent` seam (`submit-agent.ts`, extracted from extraction for exactly this): a `validate` hook turns a bad submit into a tool-input error the model retries against, within `maxTurns 4`; exhaustion fails the stage honestly (the set says so, the chains re-run cleanly on a new set).
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ 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 } from "../../core/practice.ts";
|
||||
import { chainCoverage, CUE_LEVELS, LADDER_RUNGS, PRESENTATIONS } from "../../core/practice.ts";
|
||||
|
||||
// ─── doctrines ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -109,13 +109,13 @@ Three operations modify a problem at any position on the axes.
|
||||
|
||||
### The ladder
|
||||
|
||||
Canonical points on the main axis, bottom to top:
|
||||
The main axis, bottom to top — five rungs, numbered; the request arrives as a rung:
|
||||
|
||||
- **Worked example** — the solution is shown; nothing is demanded. An installation object; nothing yet to recall.
|
||||
- **Completion** — the path is shown with a gap; the learner supplies one step. What to recall is nearly announced.
|
||||
- **Direct exercise** — one step, no hint, but the situation makes the applicable proposition unmistakable. Trains execution, not choosing.
|
||||
- **Composition** — several steps, chained; the path is narrow but must be held and run.
|
||||
- **Path-finding** — several steps, the path concealed, distractors present, the statement suggesting wrong methods. The competition shape.
|
||||
- **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.
|
||||
|
||||
@@ -183,7 +183,11 @@ You are one generator in a two-stage pipeline: a partitioner has already divided
|
||||
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: search-to-solution ratio, cue distance, presentation, each 0..1 (0 = flashcard end / lesson-close / pure theory; 1 = competition end / far transfer / real-world application). 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 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.
|
||||
- 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:
|
||||
@@ -201,7 +205,7 @@ PARTITION DOCTRINE:
|
||||
- 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 carries the three dials (search-to-solution ratio, cue distance, presentation). Higher ratios support — and favor — larger assignments (more material to conceal a path in); at the bottom of the axis, one-step problems dominate and single chains stand alone.
|
||||
- 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.
|
||||
|
||||
OUTPUT — your entire deliverable is ONE submit_partition call: "partitions", a list of { chains: [ids] }, one entry per problem, together containing every input chain exactly once. No empty entries; no unknown ids; no problem without a real target relationship.`;
|
||||
@@ -277,10 +281,14 @@ export function renderChains(chains: ReasoningChain[], propositions: Proposition
|
||||
}
|
||||
|
||||
export function renderParams(params: PracticeParams): string {
|
||||
const rung = LADDER_RUNGS[params.ladderRung];
|
||||
const cue = CUE_LEVELS[params.cueDistance];
|
||||
const pres = PRESENTATIONS[params.presentation];
|
||||
return [
|
||||
`search-to-solution ratio: ${params.searchSolutionRatio.toFixed(1)} (0 = announced recall, 1 = competition problem)`,
|
||||
`cue distance: ${params.cueDistance.toFixed(1)} (0 = lesson-close, 1 = far transfer)`,
|
||||
`presentation: ${params.presentation.toFixed(1)} (0 = pure theory, 1 = real-world application)`,
|
||||
"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");
|
||||
}
|
||||
|
||||
|
||||
@@ -49,17 +49,57 @@ export interface ReasoningChain {
|
||||
conclusion: string;
|
||||
}
|
||||
|
||||
/** The three dials every problem set is configured with (owner, 2026-09-29)
|
||||
* — all 0..1, interpreted by the agents against the framework's axes. */
|
||||
/** 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 {
|
||||
/** Search-to-solution ratio — the main axis: flashcard recall (0) to
|
||||
* competition problem (1). */
|
||||
searchSolutionRatio: number;
|
||||
/** Cue distance from every situation already used (0 = near the
|
||||
* lesson, 1 = far transfer — still inside the recognizable band). */
|
||||
cueDistance: number;
|
||||
/** Presentation axis: pure theory (0) to real-world application (1). */
|
||||
presentation: number;
|
||||
/** 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
|
||||
@@ -119,8 +159,8 @@ export interface Problem {
|
||||
export interface ProblemSet {
|
||||
/** "ps_" + ts36 + rand — store-assigned. */
|
||||
id: string;
|
||||
/** Display label — auto-generated (the session's title, or
|
||||
* date-based). */
|
||||
/** Display label — the picked cluster's label, or the dated
|
||||
* default. */
|
||||
title: string;
|
||||
createdAt: string;
|
||||
params: PracticeParams;
|
||||
|
||||
@@ -31,6 +31,7 @@ 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.
|
||||
|
||||
+24
-1
@@ -11,7 +11,30 @@
|
||||
* the union of the problems' chains must equal the set's chains; the
|
||||
* partitioner's submit is validated against it.
|
||||
*/
|
||||
import type { GraphEdge, GraphNode, Problem, ReasoningChain } from "./contracts.ts";
|
||||
import type { CueDistance, GraphEdge, 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 selection's follows-edges as chains: every edge into a node joins
|
||||
* that node's (one) chain; a node with no in- AND no out-follows-edge in
|
||||
|
||||
@@ -41,10 +41,13 @@ export function getPracticeSet(id: string): ProblemSet | null {
|
||||
function parseParams(raw: unknown): PracticeParams | null {
|
||||
if (typeof raw !== "object" || raw === null) return null;
|
||||
const p = raw as Record<string, unknown>;
|
||||
const dial = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
|
||||
if (!dial(p.searchSolutionRatio) || !dial(p.cueDistance) || !dial(p.presentation)) return null;
|
||||
// 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 {
|
||||
searchSolutionRatio: p.searchSolutionRatio,
|
||||
ladderRung: p.ladderRung,
|
||||
cueDistance: p.cueDistance,
|
||||
presentation: p.presentation,
|
||||
};
|
||||
@@ -88,7 +91,7 @@ export async function createPracticeSetResponse(body: unknown): Promise<Response
|
||||
const params = parseParams(b.params);
|
||||
if (!params) {
|
||||
return Response.json(
|
||||
{ error: "params must carry searchSolutionRatio, cueDistance, presentation as numbers in [0,1]" },
|
||||
{ error: "params must carry ladderRung (0-4), cueDistance (0-2), presentation (theory|application) as exact levels" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
+10
-6
@@ -35,13 +35,17 @@ const isStringArray = (v: unknown): v is 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 (
|
||||
typeof p.searchSolutionRatio === "number" &&
|
||||
Number.isFinite(p.searchSolutionRatio) &&
|
||||
typeof p.cueDistance === "number" &&
|
||||
Number.isFinite(p.cueDistance) &&
|
||||
typeof p.presentation === "number" &&
|
||||
Number.isFinite(p.presentation)
|
||||
(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")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type ToolCall,
|
||||
} from "@earendil-works/pi-ai";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import { createPracticeAgents, renderChains, renderPreviousProblems } 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";
|
||||
|
||||
@@ -31,7 +31,7 @@ const chains: ReasoningChain[] = [
|
||||
{ id: "n3", premises: ["n1"], conclusion: "n3" },
|
||||
{ id: "n2", premises: [], conclusion: "n2" },
|
||||
];
|
||||
const params: PracticeParams = { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 };
|
||||
const params: PracticeParams = { ladderRung: 3, cueDistance: 2, presentation: "application" };
|
||||
|
||||
function fauxEngine(steps: FauxResponseStep[]): PiEngine {
|
||||
const faux = createFauxCore({});
|
||||
@@ -70,6 +70,21 @@ function silentModel(): PiEngine {
|
||||
|
||||
// ─── 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, premises → conclusion, singletons marked", () => {
|
||||
const text = renderChains(chains, propositions);
|
||||
|
||||
@@ -222,7 +222,7 @@ function diamondGraph(): GraphStore {
|
||||
);
|
||||
}
|
||||
|
||||
const PARAMS = { searchSolutionRatio: 0.6, cueDistance: 0.5, presentation: 0.4 };
|
||||
const PARAMS = { ladderRung: 4, cueDistance: 1, presentation: "theory" } as const;
|
||||
|
||||
function fixture(partial: Partial<Parameters<typeof createPracticePipeline>[0]> = {}) {
|
||||
const practice = memoryPracticeStore();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
const init = (over: Partial<Omit<ProblemSet, "id">> = {}): Omit<ProblemSet, "id"> => ({
|
||||
title: "Libertarianism — session practice",
|
||||
createdAt: "2026-09-29T12:00:00.000Z",
|
||||
params: { searchSolutionRatio: 0.7, cueDistance: 0.4, presentation: 0.6 },
|
||||
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." },
|
||||
@@ -122,6 +122,11 @@ describe("isProblemSet", () => {
|
||||
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" }));
|
||||
|
||||
@@ -9,11 +9,7 @@ import {
|
||||
PracticeListBody,
|
||||
setStatusWord,
|
||||
} from "../components/practice-list.tsx";
|
||||
import {
|
||||
PRACTICE_DIALS,
|
||||
PracticeDialogBody,
|
||||
type PracticeFailure,
|
||||
} from "../components/practice-dialog.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";
|
||||
@@ -30,13 +26,7 @@ import type { Problem, ProblemSet } from "../src/core";
|
||||
|
||||
// ─── fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PARAMS = { searchSolutionRatio: 0.5, cueDistance: 0.5, presentation: 0.5 };
|
||||
|
||||
const dialValues = {
|
||||
searchSolutionRatio: 0.7,
|
||||
cueDistance: 0.5,
|
||||
presentation: 0.3,
|
||||
};
|
||||
const PARAMS = { ladderRung: 2, cueDistance: 1, presentation: "theory" } as const;
|
||||
|
||||
function clusters(): GraphClusterGroup[] {
|
||||
return [
|
||||
@@ -95,8 +85,8 @@ function set(over: Partial<ProblemSet> = {}): ProblemSet {
|
||||
|
||||
describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
const base = {
|
||||
values: dialValues,
|
||||
onDial: () => {},
|
||||
params: { ...DEFAULT_PRACTICE_PARAMS },
|
||||
onParam: () => {},
|
||||
clusters: clusters(),
|
||||
selectedKey: null,
|
||||
onSelect: () => {},
|
||||
@@ -107,13 +97,33 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
|
||||
onRun: () => {},
|
||||
};
|
||||
|
||||
it("renders the three dials with anchor labels", () => {
|
||||
it("stepped controls: 5-detent ladder + 3-detent cue + Theory|Application toggle, defaults rung 2 / cue 1 / theory", () => {
|
||||
const html = renderToStaticMarkup(<PracticeDialogBody {...base} />);
|
||||
for (const dial of PRACTICE_DIALS) {
|
||||
assert.ok(html.includes(`data-testid="dial-${dial.key}"`), `the ${dial.key} dial`);
|
||||
assert.ok(html.includes(dial.anchors), `the ${dial.key} anchors`);
|
||||
assert.ok(html.includes(dial.label));
|
||||
}
|
||||
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("cluster picker: label + proposition-count rows, notes kind marked, Create gated until picked", () => {
|
||||
@@ -264,6 +274,13 @@ describe("PracticeExamBody — exam states", () => {
|
||||
assert.ok(!html.includes('data-testid="practice-submit"'));
|
||||
});
|
||||
|
||||
it("the header carries the levels line (named levels, owner 2026-09-29)", () => {
|
||||
const s = set({ problems: [readyProblem("pp_1", ["n3"])] });
|
||||
const html = renderToStaticMarkup(<PracticeExamBody set={s} {...examBase} />);
|
||||
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} />);
|
||||
|
||||
Reference in New Issue
Block a user