practice: the learner's creation note — optional free-form input carried to partitioner + generators (owner, 2026-09-29)

- dialog: optional 'Anything else?' textarea (typing-only, 2000-char mirror)
- API: body gains note? — trimmed, whitespace-only omitted, hard 400 over 2000
- pipeline: note persisted on the set and threaded to both agent stages
- agents: THE LEARNER'S NOTE prompt block (present only when set) + the
  weighed-never-owed stance pinned in both doctrines
- fix en route: doubled THE PARAMETERS heading in both prompts since slice 3
  (renderParams owns the heading; the literal blocks removed)
This commit is contained in:
amosblomqvist
2026-09-29 22:16:28 +03:00
parent cf0ec4a2f0
commit 06032769d2
15 changed files with 593 additions and 266 deletions
+75
View File
@@ -72,6 +72,25 @@ 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", () => {
@@ -164,6 +183,30 @@ describe("runPartitioner", () => {
/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 ───────────────────────────────────────────────────
@@ -218,6 +261,31 @@ describe("runProblemGenerator", () => {
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 ──────────────────────────────────────────────────────────────
@@ -291,6 +359,13 @@ describe("refusal gates (BYOK honest refusal, extraction's pattern)", () => {
// ─── 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(
+76
View File
@@ -183,6 +183,45 @@ function partitionModel(partitions: Array<{ chains: string[] }>): PiEngine {
]);
}
/** A partitioner that RECORDS the prompt it saw before submitting — the
* learner-note thread pin. */
function capturingPartitioner(partitions: Array<{ chains: string[] }>, seen: string[]): PiEngine {
return fauxEngine([
(context) => {
seen.push(lastUserText(context as never));
return fauxAssistantMessage(
[fauxToolCall("submit_partition", { partitions } as ToolCall["arguments"], { id: "p-call" })],
{ stopReason: "toolUse" },
);
},
]);
}
/** An echo generator that RECORDS each prompt before submitting. */
function capturingEchoGenerator(seen: string[], count: number): PiEngine {
const factory: FauxResponseStep = (context) => {
const prompt = lastUserText(context as never);
seen.push(prompt);
const target = targetOf(prompt);
return fauxAssistantMessage(
[
fauxToolCall(
"submit_problem",
{
chains: target,
statement: `Problem on ${target.join(", ")}`,
solution: "The model solution.",
rubric: "- names it",
} as ToolCall["arguments"],
{ id: "g-call" },
),
],
{ stopReason: "toolUse" },
);
};
return fauxEngine(Array.from({ length: count }, () => factory));
}
/** Hangs until the call's signal aborts (then resolves no submit call) —
* the extraction.test.ts cancellation-mimicking idiom. Queue one step
* per concurrent call (faux queues are finite). */
@@ -360,6 +399,43 @@ describe("createProblemSet (generation)", () => {
assert.equal(practiceStatuses().length, 0);
});
it("the learner's note threads to the partitioner + every generator and persists on the set (owner, 2026-09-29)", async () => {
const partSeen: string[] = [];
const genSeen: string[] = [];
const f = fixture({
partitioner: capturingPartitioner([{ chains: ["b"] }, { chains: ["e"] }], partSeen),
problemGenerator: capturingEchoGenerator(genSeen, 2),
grader: echoGrader(1),
});
const pipeline = f.make();
const { set, done } = await pipeline.createProblemSet(["a", "b", "e"], PARAMS, undefined, "keep it geometric");
await done;
assert.equal(set.note, "keep it geometric", "persisted at creation");
assert.equal(f.practice.store.getSet(set.id)!.note, "keep it geometric", "survives the store round-trip");
assert.ok(partSeen[0]!.includes("THE LEARNER'S NOTE"), "rode the partitioner prompt");
assert.ok(partSeen[0]!.includes("keep it geometric"));
assert.equal(genSeen.length, 2);
for (const prompt of genSeen) {
assert.ok(prompt.includes("THE LEARNER'S NOTE"), "rode every generator prompt");
assert.ok(prompt.includes("keep it geometric"));
}
// No note → no block, anywhere (optional in every sense).
const partSeenBare: string[] = [];
const genSeenBare: string[] = [];
const f2 = fixture({
partitioner: capturingPartitioner([{ chains: ["b", "e"] }], partSeenBare),
problemGenerator: capturingEchoGenerator(genSeenBare, 1),
grader: echoGrader(1),
});
const s2 = await f2.make().createProblemSet(["a", "b", "e"], PARAMS);
await s2.done;
assert.equal(s2.set.note, undefined);
assert.ok(!partSeenBare[0]!.includes("THE LEARNER'S NOTE"));
assert.ok(!genSeenBare[0]!.includes("THE LEARNER'S NOTE"));
});
it("per-problem failure isolation: one generator fails, the set still completes", async () => {
const f = fixture({ partitioner: partitionModel([{ chains: ["b"] }, { chains: ["e"] }]), problemGenerator: echoGenerator(8, ["e"]), grader: echoGrader(1) });
const pipeline = f.make();
+26
View File
@@ -41,3 +41,29 @@ describe("createPracticeSetResponse — strict named-level params", () => {
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/);
});
});
+7
View File
@@ -152,6 +152,13 @@ describe("isProblemSet", () => {
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));
+13
View File
@@ -87,6 +87,8 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
const base = {
params: { ...DEFAULT_PRACTICE_PARAMS },
onParam: () => {},
note: "",
onNote: () => {},
clusters: clusters(),
selectedKey: null,
onSelect: () => {},
@@ -126,6 +128,17 @@ describe("PracticeDialogBody — creation (cluster picker)", () => {
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"'));