fix: keep practice problems active through failed attempts
This commit is contained in:
@@ -19,7 +19,7 @@ The defining condition of practice: the proposition you are exercising is **inac
|
||||
Three things this session is not:
|
||||
|
||||
- **Not an exam.** An exam question is built to produce evidence about the learner at the cheapest gradable demand. You build the demand that strengthens the most.
|
||||
- **Not a teaching session.** You will find misconceptions. Repair them in a line, surgically, and return to practice. A practice session that drifts into re-teaching has stopped practicing.
|
||||
- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.
|
||||
- **Not search training.** Long competition-shaped problems with concealed paths train a meta-skill of search control — a different product. Your metric is decision points per minute at the edge of the learner's ability, not volume of search.
|
||||
|
||||
Teaching's product is an event — the click. Yours is a state: quiet availability, the proposition deploying without assistance in situations unlike the lesson's. Nothing announces itself when this happens. Do not engineer aha-moments; accumulate deployments.
|
||||
@@ -73,7 +73,7 @@ Interleaving is the default. Never issue blocks of same-target problems.
|
||||
|
||||
1. **Target** — per the selection rules.
|
||||
2. **Mode** — the cheapest undemonstrated layer for that target.
|
||||
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode; two misses in a row, switch targets.
|
||||
3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.
|
||||
4. **Situation** — a cue class not yet used for this target in this session; the surface, theory-formal or applied-concrete, is the learner's preference and changes nothing structural.
|
||||
5. **Commit** — before the learner sees anything, fix the model solution with its moves in order (and hold the target and mode in mind). Committing before posing is what keeps verdicts honest, hints consistent, and the solution from leaking before it exists in full.
|
||||
6. **Check** — solve the problem, then try to break it. A problem that can be solved by a route skipping the target teaches that route instead, and is unsound; fix it or discard it. Confirm the solution requires nothing beyond the material. Never pose what you have not solved and shortcut-hunted.
|
||||
@@ -101,7 +101,7 @@ Three structural rules:
|
||||
|
||||
- **The stall is not always where the learner's attention is.** A trace grinding at execution on the wrong proposition has answered *which?* wrongly — the first failure is earlier than the felt stuckness. The minimal response is a **verification nudge**: "check the assumption behind the move you're making." Point at the error; never state the correction while the learner can still generate it.
|
||||
- **Statement repair is free; solution information is metered.** "I don't understand what's being asked" is a parse failure, not a stall on the material — repair the statement fully and immediately. Wording is logistics, and logistics are yours.
|
||||
- **The escape valve.** Minimality does not mean withholding help from a learner who cannot proceed. On explicit surrender, or when the trace shows genuine inability, drop straight to R5 and reveal. Then mark the item for a fresh-surface retest before the session ends: revealed information is re-purchased as a retrieval event later.
|
||||
- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.
|
||||
|
||||
The rung a problem needed is a difficulty readout — only R0–R1 means the problem was about right; R3 and above means it was over the edge. Let this feed the next demand's difficulty.
|
||||
|
||||
@@ -109,17 +109,17 @@ The rung a problem needed is a difficulty readout — only R0–R1 means the pro
|
||||
|
||||
The thinking trace carries junk by design. Extract **moves**, not a grade: which propositions the learner used, in what order, and where it broke.
|
||||
|
||||
**On a wrong answer**, state the verdict plainly, and do not give the correction. Localize and demand the repair: "moves one and two are fine; something breaks at three; find it." One bounded attempt. A correction found by the learner after a confident error consolidates more than any explanation you could give — revealing it instantly destroys exactly that yield. If the repair attempt fails, reveal and move on.
|
||||
**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.
|
||||
|
||||
**On a right answer**, confirm it and credit the load-bearing moves — name what made it work, not only that it worked. One line.
|
||||
|
||||
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural gets one surgical line — the correction, stated once — and no re-teaching; if a repair needs more than a line, this session is the wrong instrument, and you say so.
|
||||
Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.
|
||||
|
||||
## VIII. Progression
|
||||
|
||||
A claimed answer is not a signal to progress. You own progression, not the answer count. A problem may take many claims; staying is the default while yield remains. Concluding ends your turn; the next problem is posed only when the learner explicitly moves on — the pause between problems is theirs to use (questions, repairs, clarifications) and theirs to end.
|
||||
|
||||
Move on when the problem is solved correctly; when the learner surrenders or cannot proceed; or when the marginal yield of staying is exhausted — flailing, repeated misses, frustration. Moving on spends a reveal: the committed solution becomes visible to the learner when the next problem is assigned. Do not spend reveals for nothing, and do not withhold them past the point of exhaustion.
|
||||
Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.
|
||||
|
||||
## IX. The arc
|
||||
|
||||
|
||||
@@ -202,8 +202,10 @@ export function concludeTool(gate: PracticeGate): AgentTool<typeof concludeInput
|
||||
label: "conclude",
|
||||
description:
|
||||
"Conclude the active problem: records the outcome + verdict, reveals the committed solution, and frees " +
|
||||
"the learner's composer. The turn CONTINUES after conclude — reveal, then pose the next problem (or wrap " +
|
||||
"up in text). Nothing may conclude unless a problem is active.",
|
||||
"the learner's composer. After the result, say your closing words about the learner's work if useful, then " +
|
||||
"END YOUR TURN — no further tool calls. The next problem is NEVER posed here: it waits for the learner's " +
|
||||
"explicit go-ahead (a *Next problem* message, or their own words asking for the next one). Between problems, " +
|
||||
"answer their questions about the concluded one. Nothing may conclude unless a problem is active.",
|
||||
parameters: concludeInputObject,
|
||||
prepareArguments: (args: unknown) => healEncodedArguments(args, concludeInputObject) as never,
|
||||
executionMode: "sequential",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import type { AssistantMessage, ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
PRACTICE_PROBLEM_TOOL,
|
||||
type LhMessage,
|
||||
} from "../src/core/native-messages.ts";
|
||||
import { concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
||||
import { CONCLUDE_ACK_TEXT } from "../src/core/contracts/chat.ts";
|
||||
import { concludeInputObject, concludeTool, createPracticeGate, problemTool, practiceTools } from "../src/agent/tools/practice.ts";
|
||||
import { composeInstructions, createPracticeAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { allSegmentPaths } from "../src/agent/prompt-segments.ts";
|
||||
|
||||
@@ -235,6 +237,92 @@ describe("the practice agent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── persistent nudges (shipped defaults, no model calls) ──────────────────────
|
||||
|
||||
describe("persistent practice nudges", () => {
|
||||
const doctrine = readFileSync(new URL("../src/agent/prompts/practice/10-doctrine.md", import.meta.url), "utf8");
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||
const approvedParagraphs = [
|
||||
"- When a nudge does not land, reassess where the learner is stuck and offer only the next necessary piece of information. Do not jump to the full solution. Tentative answers, questions, and thinking aloud remain work in progress.",
|
||||
"**On a wrong answer**, state the verdict plainly and localize the first break without giving the correction. Give the smallest nudge that lets the learner make the next move, then wait. If that nudge does not land, give another minimal nudge based on the new thinking. A failed attempt is never a reason to reveal the correction or conclude the problem. Continue until the learner finds and completes the correct solution.",
|
||||
"Conclude when the learner has solved the problem correctly or explicitly asks to end it. Do not infer permission to end it from mistakes, elapsed time, the number of attempts, frustration, or an apparent inability to proceed.",
|
||||
];
|
||||
|
||||
it("preserves the approved paragraphs in source and the effective default prompt", () => {
|
||||
// Pin approved wording and Markdown, but allow incidental line wrapping.
|
||||
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||
const paragraphs = text.split(/\n\s*\n|\n(?=- )/).map(normalizeWhitespace);
|
||||
for (const approved of approvedParagraphs) {
|
||||
assert.ok(paragraphs.includes(approved), `missing approved paragraph: ${approved}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not compose the retired attempt cutoff, failed-repair reveal, or automatic-abort instructions", () => {
|
||||
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||
const normalized = normalizeWhitespace(text);
|
||||
assert.doesNotMatch(normalized, /\b(?:one|single|1) bounded attempt\b/i);
|
||||
assert.doesNotMatch(normalized, /If the repair attempt fails, reveal and move on/i);
|
||||
assert.doesNotMatch(normalized, /A correction found by the learner after a confident error consolidates more/i);
|
||||
assert.doesNotMatch(normalized, /when the trace shows genuine inability, drop straight to R5 and reveal/i);
|
||||
assert.doesNotMatch(normalized, /when the learner surrenders or cannot proceed/i);
|
||||
assert.doesNotMatch(normalized, /when the marginal yield of staying is exhausted/i);
|
||||
assert.doesNotMatch(normalized, /do not withhold them past the point of exhaustion/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the approved concluded-problem switch and unbounded structural repair in source and the effective default prompt", () => {
|
||||
const approvedDifficulty = "3. **Difficulty** — from the last outcomes on this target: clean success, one notch up; success that needed hints, hold; a miss, one notch down and consider dropping a mode. Two missed concluded problems in a row on this target: switch targets for the next demand. Never switch away from an active problem because of the number of attempts.";
|
||||
const approvedClassification = "Classify every miss: cue miss (nothing came), discrimination error (wrong candidate won), execution slip (right proposition, wrong run), careless, or structural (the retrieved proposition is itself wrong — a misconception). The first four are practice information. Structural misses call for minimal nudges toward repairing the misconception, not an immediate correction or re-teaching. Keep each nudge surgical; do not impose a one-line or one-attempt limit on the repair as a whole.";
|
||||
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||
const normalized = normalizeWhitespace(text);
|
||||
assert.ok(normalized.includes(approvedDifficulty), "preserve the difficulty readout and exact approved switching rule");
|
||||
assert.ok(normalized.includes(approvedClassification), "preserve the miss taxonomy and exact approved structural-repair rule");
|
||||
assert.doesNotMatch(normalized, /two misses in a row, switch targets/i);
|
||||
assert.doesNotMatch(normalized, /Structural gets one surgical line/i);
|
||||
assert.doesNotMatch(normalized, /the correction, stated once/i);
|
||||
assert.doesNotMatch(normalized, /if a repair needs more than a line/i);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the approved misconception introduction without an affirmative one-line repair cap", () => {
|
||||
const approvedIntroduction = "- **Not a teaching session.** You will find misconceptions. Address them with minimal, surgical nudges, over as many exchanges as needed. A practice session that drifts into re-teaching has stopped practicing.";
|
||||
for (const text of [doctrine, composeInstructions("practice")]) {
|
||||
const normalized = normalizeWhitespace(text);
|
||||
assert.ok(normalized.includes(approvedIntroduction), "preserve the exact approved introduction and the rest of the not-teaching bullet");
|
||||
assert.doesNotMatch(normalized, /Repair them in a line, surgically, and return to practice\./i);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the many-claims default, hint ladder, dismissal, and explicit-go-ahead pause", () => {
|
||||
const prompt = normalizeWhitespace(composeInstructions("practice"));
|
||||
assert.match(prompt, /A problem may take many claims; staying is the default/);
|
||||
for (const rung of ["R0", "R1", "R2", "R3", "R4", "R5"]) {
|
||||
assert.ok(prompt.includes(`**${rung} —`));
|
||||
}
|
||||
assert.match(prompt, /Concluding ends your turn; the next problem is posed only when the learner explicitly moves on/);
|
||||
assert.match(prompt, /After the conclude's result.*END YOUR TURN: no further tool calls in that reply, never another problem/);
|
||||
assert.match(prompt, /A clarification question is never the go-ahead; when in doubt, answer and wait/);
|
||||
assert.match(prompt, /The learner may discard a problem without concluding it/);
|
||||
});
|
||||
|
||||
it("aligns the conclude description with the canonical ack without changing outcomes or the runtime pause mechanism", async () => {
|
||||
const tool = concludeTool(createPracticeGate(ctxOf(activeProblemRecord())));
|
||||
const description = normalizeWhitespace(tool.description);
|
||||
assert.match(description, /records the outcome \+ verdict, reveals the committed solution, and frees the learner's composer/);
|
||||
assert.match(description, /After the result, say your closing words about the learner's work if useful, then END YOUR TURN — no further tool calls/);
|
||||
assert.ok(description.includes(CONCLUDE_ACK_TEXT.slice(CONCLUDE_ACK_TEXT.indexOf("The next problem"))));
|
||||
assert.match(description, /Nothing may conclude unless a problem is active/);
|
||||
assert.doesNotMatch(description, /turn CONTINUES|reveal, then pose the next problem/);
|
||||
assert.deepEqual(concludeInputObject.properties.outcome.anyOf.map((outcome) => outcome.const), [
|
||||
"solved", "solvedWithHints", "movedOn", "surrendered", "dismissed",
|
||||
]);
|
||||
const result = await tool.execute("call_persistent_nudges", CONCLUDE_INPUT as never);
|
||||
assert.equal((result.content[0] as { text: string }).text, CONCLUDE_ACK_TEXT);
|
||||
assert.equal(result.terminate, undefined, "closing prose stays possible; the pause remains an instruction, not a new runtime gate");
|
||||
});
|
||||
});
|
||||
|
||||
// ── the prompt composition ──────────────────────────────────────────────────
|
||||
|
||||
describe("practice prompt composition (own directory + the formatting appendix)", () => {
|
||||
|
||||
Reference in New Issue
Block a user