practice: untagged problems — target optional, topic sessions work (owner round 12)

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.
This commit is contained in:
amosblomqvist
2026-10-05 20:23:48 +03:00
parent 1f7a3516fe
commit 4f3bbf61d0
3 changed files with 42 additions and 14 deletions
+1 -1
View File
@@ -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.
+29 -13
View File
@@ -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<string, unknown>), 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<string, unknown>), 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`,
]);
}
}
+12
View File
@@ -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);