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:
amosblomqvist
2026-09-29 20:06:35 +03:00
parent a53127a764
commit e92679254d
15 changed files with 319 additions and 106 deletions
+17 -2
View File
@@ -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);
+1 -1
View File
@@ -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();
+43
View File
@@ -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);
});
});
+6 -1
View File
@@ -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" }));
+37 -20
View File
@@ -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} />);