From 83dad65481680e09e4b4dfb5a7392d73fcb6da0f Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 06:31:22 +0300 Subject: [PATCH] practice: target-validation pins + the id convention in the base prompt (round 9 slice 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pins on the mastery-trace contract: a name target normalizes to the canonical id (case-insensitive, whole-graph set), an id passes through untouched, an unknown target rejects one-line AND does not arm the gate (honest activation — the next valid pose goes through), and the legacy node-source-less wiring stays unvalidated. The base prompt now states the convention: target takes the proposition ids from the session context — ids are how problems are traced. --- src/agent/prompts/practice/00-base.md | 2 +- tests/practice-agents.test.ts | 44 +++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/agent/prompts/practice/00-base.md b/src/agent/prompts/practice/00-base.md index 95bd427..cdad4bc 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 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. 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: - 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/tests/practice-agents.test.ts b/tests/practice-agents.test.ts index 71b370b..816aa18 100644 --- a/tests/practice-agents.test.ts +++ b/tests/practice-agents.test.ts @@ -135,6 +135,50 @@ describe("the practice tool gating law", () => { }); }); +// ── target validation (the mastery-trace contract, owner round 9) ────────── + +describe("target validation — names normalize to ids, unknowns reject", () => { + const NODES = [ + { id: "n_parity", name: "Parity of a sum", statement: "The sum of two odds is even." }, + { id: "n_pairing", name: "Pairing", statement: "Pair elements to count parity." }, + ] as const; + const graphNodes = () => NODES; + + it("a NAME target silently normalizes to the canonical id (the record carries ids)", async () => { + const healed = problemTool(createPracticeGate(ctxOf([])), graphNodes).prepareArguments!({ + ...PROBLEM_INPUT, + target: ["Parity of a sum", "pairing"], + }) as { target: string[] }; + assert.deepEqual(healed.target, ["n_parity", "n_pairing"], "case-insensitive name → id, whole-graph set"); + }); + + it("an id target passes through untouched (already canonical)", async () => { + const healed = problemTool(createPracticeGate(ctxOf([])), graphNodes).prepareArguments!({ + ...PROBLEM_INPUT, + target: ["n_parity"], + }) as { target: string[] }; + assert.deepEqual(healed.target, ["n_parity"]); + }); + + it("an UNKNOWN target rejects with a one-line error and does NOT arm the gate", async () => { + const gate = createPracticeGate(ctxOf([])); + const tool = problemTool(gate, graphNodes); + const rejected = await tool.execute("call_x", { ...PROBLEM_INPUT, target: ["ghost-prop"] } as never); + 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/); + // 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("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); + }); +}); + // ── the registry + the agent ──────────────────────────────────────────────── describe("the practice agent", () => {