From 4f3bbf61d030837f2b325277a9c3398327ad5f09 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 20:23:48 +0300 Subject: [PATCH] =?UTF-8?q?practice:=20untagged=20problems=20=E2=80=94=20t?= =?UTF-8?q?arget=20optional,=20topic=20sessions=20work=20(owner=20round=20?= =?UTF-8?q?12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session whose material is not in the map is a SUPPORTED mode now: target is optional (absent = untagged problem, poses freely), an explicit [] heals away at the seam (the record's canonical form is tagged-or-absent), and when targets ARE present the round-9 law is unchanged — whole-graph validation, names normalized to ids. The rejection message teaches the exit ('omit target entirely'), so a model inventing targets for an off-graph topic converges to omitting on the retry; omitting always works, no brick is possible. Prompt carries the clause: material in the map → ids; not → omit, problems stand on their own. --- src/agent/prompts/practice/00-base.md | 2 +- src/agent/tools/practice.ts | 42 ++++++++++++++++++--------- tests/practice-agents.test.ts | 12 ++++++++ 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/src/agent/prompts/practice/00-base.md b/src/agent/prompts/practice/00-base.md index a8e1074..e01b92e 100644 --- a/src/agent/prompts/practice/00-base.md +++ b/src/agent/prompts/practice/00-base.md @@ -23,7 +23,7 @@ He has to be able to trust you completely; one confidently-delivered hallucinati Two tools, and everything else is conversation. The mechanics only — the method (when to pose, what to pose, how to respond) is the doctrine's. -**`problem`** — poses one problem. The statement field is what the learner sees (markdown and LaTeX render); the modelSolution is your private commitment, hidden from the learner until you conclude. The target field takes the proposition ids from the session context — ids are how every problem is traced to the propositions it exercises. The call ends the turn. The learner then works in a thinking field; each submission arrives back as a *Your thinking* message. Whether it claims an answer is IN THE TEXT, not declared anywhere — read it: +**`problem`** — poses one problem. The statement field is what the learner sees (markdown and LaTeX render); the modelSolution is your private commitment, hidden from the learner until you conclude. When the session's material lives in the map, target takes the proposition ids from the session context — ids are how every problem is traced to the propositions it exercises. When it does not (a topic with nothing in the map), omit target entirely: problems stand on their own, untagged. The call ends the turn. The learner then works in a thinking field; each submission arrives back as a *Your thinking* message. Whether it claims an answer is IN THE TEXT, not declared anywhere — read it: - Working trace (mid-work, stuck, or asking for a nudge): the hint ladder applies, one rung per request. A trace may span several submissions. - A claimed answer: extract the moves, deliver the verdict. A claim is not a progress signal. diff --git a/src/agent/tools/practice.ts b/src/agent/tools/practice.ts index 32e0cc5..16ef2e7 100644 --- a/src/agent/tools/practice.ts +++ b/src/agent/tools/practice.ts @@ -161,12 +161,15 @@ export const problemInputObject = Type.Object({ mode: Type.Union([Type.Literal("recall"), Type.Literal("discrimination"), Type.Literal("composition")], { description: 'REQUIRED — the demand mode: "recall" | "discrimination" | "composition".', }), - target: Type.Array(Type.String(), { - minItems: 1, - description: - "REQUIRED — the proposition id(s) this problem exercises, from the session context's [id] list. " + - "Names are normalized to ids at the seam; an unknown proposition is rejected.", - }), + target: Type.Optional( + Type.Array(Type.String(), { + minItems: 1, + description: + "The proposition id(s) this problem exercises, from the session context's [id] list — include when " + + "the material is in the map; OMIT entirely when it is not (a topic session poses untagged problems). " + + "Names are normalized to ids at the seam; an unknown proposition is rejected.", + }), + ), modelSolution: Type.String({ description: "REQUIRED — the full solution, moves in order, committed BEFORE the learner sees the problem. This IS the reveal — conclude shows it verbatim; never re-author it.", }), @@ -193,9 +196,19 @@ export function problemTool( // TARGET NORMALIZATION (owner round 9): names silently become ids — // the RECORD carries canonical ids (the mastery trace groups on // them); unresolvable strings pass through for execute to reject. - if (!graphNodes) return healed as never; + // UNTAGGED PROBLEMS (owner round 12): an explicit empty list is + // the model saying "no targets" — healed AWAY, so the record's + // canonical form is tagged-or-absent, never []. + if (!graphNodes) { + const bare = (healed as { target?: unknown }).target; + return Array.isArray(bare) && bare.length === 0 + ? ({ ...(healed as Record), target: undefined } as never) + : (healed as never); + } const target = (healed as { target?: unknown }).target; - if (!Array.isArray(target)) return healed as never; + if (!Array.isArray(target) || target.length === 0) { + return { ...(healed as Record), target: undefined } as never; + } const list = graphNodes(); const normalized = target.map((t) => typeof t === "string" ? (resolveTargetId(t, list) ?? t) : t, @@ -209,15 +222,18 @@ export function problemTool( "a problem is already active — call conclude on it before posing the next one", ]); } - // TARGET VALIDATION (owner round 9): against the WHOLE graph. A - // one-line rejection — the proposition list already sits in the - // agent's context (the [id] marker); no re-listing. - if (graphNodes) { + // TARGET VALIDATION (owner round 9, round 12): against the WHOLE + // graph, and ONLY when targets are present — absent means an + // untagged problem (topic sessions, owner round 12), which poses + // freely. A one-line rejection — the proposition list already + // sits in the agent's context (the [id] marker); no re-listing. + // The message teaches the exit: omitting always works. + if (graphNodes && input.target && input.target.length > 0) { const list = graphNodes(); const unknown = input.target.filter((t) => resolveTargetId(t, list) === null); if (unknown.length > 0) { return invalidInputResult("problem", [ - `target ${JSON.stringify(unknown)} is not a known proposition — targets must be proposition ids from the session context`, + `target ${JSON.stringify(unknown)} is not a known proposition — targets must be proposition ids from the session context; if the material is not in the map, omit target entirely`, ]); } } diff --git a/tests/practice-agents.test.ts b/tests/practice-agents.test.ts index b6eff12..ad1fba9 100644 --- a/tests/practice-agents.test.ts +++ b/tests/practice-agents.test.ts @@ -167,12 +167,24 @@ describe("target validation — names normalize to ids, unknowns reject", () => assert.equal(rejected.isError, true); assert.match((rejected.details as { problems: string[] }).problems[0]!, /ghost-prop/); assert.match((rejected.details as { problems: string[] }).problems[0]!, /proposition ids from the session context/); + // the message teaches the exit (round 12): omitting always works + assert.match((rejected.details as { problems: string[] }).problems[0]!, /omit target entirely/); // honest activation (the BKT law): a rejected pose frees the slot — // the next, valid pose goes through. const valid = await tool.execute("call_y", { ...PROBLEM_INPUT, target: ["n_parity"] } as never); assert.equal(valid.isError, undefined); }); + it("UNTAGGED PROBLEMS (owner round 12): absent target poses freely; [] heals away to absent", async () => { + const tool = problemTool(createPracticeGate(ctxOf([])), graphNodes); + const absent = await tool.execute("call_a", { statement: "S", mode: "recall", modelSolution: "M" } as never); + assert.equal(absent.isError, undefined, "no targets = an untagged problem, never a rejection"); + const healed = tool.prepareArguments!({ statement: "S", mode: "recall", target: [], modelSolution: "M" }) as { + target?: string[]; + }; + assert.ok(!healed.target || healed.target.length === 0, "an explicit [] is the model saying no-targets — healed away"); + }); + it("NO node source (the legacy/test wiring) = no validation, free strings ack", async () => { const result = await problemTool(createPracticeGate(ctxOf([]))).execute("call_x", PROBLEM_INPUT as never); assert.equal(result.isError, undefined);