math: dollar delimiters end-to-end (exact answers, escaping examples, context rendering)

- switch the math authoring/rendering contract from \(...\)/\[...\] to the
  standard $...$ inline / $$...$$ display: prompts (20-question-tools,
  40-formatting), singleDollarTextMath on the renderer, legacy reader kept,
  and escape-debris recovery extended to dollar spans (mirroring micromark
  delimiter-run rules)
- exact answer membership now precedes debris cleanup in
  coerceCorrectAnswer (calculus incident: an exact sentence-final-period
  label was stripped as model debris, then rejected); option keys are
  passed at the boundary/grading seams, the dock no longer pre-coerces
- a complete, phase-correct JSON-escaping example rides the probe/quiz
  tool descriptions (serialized from one canonical body; quiz leads with
  type+nodeId), plus the one-sentence JSON-escaping rule in 40-formatting
- ask option descriptions render markdown/LaTeX in the pending dock;
  settled cards keep the details line and option descriptions
  retroactively, read from the persisted call arguments (chat passes
  part.input to AnsweredCard)

1566/1566 tests on both navigator configs; typecheck, lint, production
build, standalone packaging, and Electron-runtime smoke all green.
This commit is contained in:
amosblomqvist
2026-10-13 06:24:33 +03:00
parent a395f37727
commit bf29a2070f
19 changed files with 628 additions and 93 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ import { buildSubagentSteer } from "../src/core/contracts.ts";
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { buildAnswer, coerceCorrectAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
import { projectView } from "../src/core/view.ts";
import type { PiEngine } from "../src/agent/models.ts";
@@ -113,7 +113,7 @@ function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
question: input.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(input.correctAnswer), options).indices,
correctIndices: resolveCorrect(input.correctAnswer, options).indices,
explanation: input.explanation,
createdAt: new Date().toISOString(),
};
+6 -2
View File
@@ -295,8 +295,12 @@ describe("practice prompt composition (own directory + the formatting appendix)"
it("the SHARED formatting guide composes (owner, 2026-10-04 late): LaTeX delimiters in every setting", () => {
const prompt = composeInstructions("practice");
// The delimiter law — practice must write \(...\)/\[...\] too.
assert.match(prompt, /NEVER `\$` or `\$\$`/);
// The standard delimiter law is shared by all three authoring agents.
for (const agent of ["practice", "prober", "teacher"] as const) {
const instructions = composeInstructions(agent);
assert.ok(instructions.includes("Inline `$f(x)$`, display `$$` fenced on its own lines."));
assert.ok(!instructions.includes("NEVER `$` or `$$`"));
}
assert.ok(prompt.indexOf("# The practice session") < prompt.indexOf("## Formatting"), "formatting rides as the appendix");
});
+99 -5
View File
@@ -36,6 +36,7 @@ import {
toClientQuestion,
} from "../src/core/question-core.ts";
import { toolQuestionKind } from "../src/core/pending.ts";
import { parseQuizInput } from "../src/core/tool-parts.ts";
// ─── the authoring contract (TypeBox schemas + execute-time refinements) ──
// Schema-valid ⇔ gradeable: a quiz whose correctAnswer can't be resolved
@@ -249,20 +250,28 @@ describe("shuffleOptions", () => {
// ─── coerceCorrectAnswer ────────────────────────────────────────────────────
describe("coerceCorrectAnswer", () => {
const values = ["mercury", "a", "b"];
it("wraps a single string", () => {
assert.deepEqual(coerceCorrectAnswer("mercury"), ["mercury"]);
assert.deepEqual(coerceCorrectAnswer("mercury", values), ["mercury"]);
});
it("passes arrays through", () => {
assert.deepEqual(coerceCorrectAnswer(["a", "b"]), ["a", "b"]);
assert.deepEqual(coerceCorrectAnswer(["a", "b"], values), ["a", "b"]);
});
it("parses a JSON-stringified multi-select array", () => {
assert.deepEqual(coerceCorrectAnswer('["a", "b"]'), ["a", "b"]);
assert.deepEqual(coerceCorrectAnswer('["a", "b"]', values), ["a", "b"]);
});
it("leaves a non-JSON bracketed string alone", () => {
assert.deepEqual(coerceCorrectAnswer("[not json"), ["[not json"]);
assert.deepEqual(coerceCorrectAnswer("[not json", values), ["[not json"]);
});
it("preserves an exact key even when it looks like a JSON array", () => {
const key = '["a", "b"]';
assert.deepEqual(coerceCorrectAnswer(key, [...values, key]), [key]);
assert.deepEqual(coerceCorrectAnswer("[]", ["[]"]), ["[]"]);
});
});
@@ -302,6 +311,91 @@ describe("resolveCorrect", () => {
});
});
describe("exact answer membership precedes debris cleanup", () => {
it("the calculus label's final period survives boundary, display, shuffle, and grading", () => {
const correct = "$f$ need not be differentiable at $a$.";
const input = {
question: "All partial derivatives of $f$ exist at $a$. Must $f$ be differentiable there?",
options: [{ label: "$f$ is differentiable at $a$." }, { label: correct }],
explanation: "Existing partial derivatives need not give a linear approximation.",
correctAnswer: correct,
};
const before = JSON.stringify(input);
assert.ok(boundaryOk(probeQuizInputSchema, input, quizInputProblems(input)));
const parsed = parseQuizInput(input);
assert.ok(parsed, "the accepted raw call must also render");
assert.equal(parsed.correctAnswer, correct);
const options = normalizeOptions(parsed.options).reverse();
const resolved = resolveCorrect(parsed.correctAnswer, options);
assert.deepEqual(resolved, { indices: [1] });
const qd: QuestionData = {
id: "calculus-period",
kind: "quiz",
question: parsed.question,
mode: "single-select",
options,
correctIndices: resolved.indices,
explanation: parsed.explanation,
createdAt: "2026-10-12T00:00:00Z",
};
assert.equal(buildAnswer(qd, { values: [correct] }).correct, true);
assert.equal(buildAnswer(qd, { values: [input.options[0]!.label] }).correct, false);
assert.equal(JSON.stringify(input), before, "raw arguments are never rewritten");
});
it("retains punctuation, quotes, colons, and backticks in exact labels and explicit keys", () => {
for (const key of ["value.", "value:", '"value"', "'value'", "`value`", ":value"]) {
for (const option of [{ label: key }, { label: "A claim", value: key }]) {
const input = {
question: "Which claim?",
options: [option, { label: "Other", value: "other" }],
explanation: "The first claim follows.",
correctAnswer: key,
};
assert.deepEqual(quizInputProblems(input), [], key);
assert.ok(parseQuizInput(input), key);
assert.deepEqual(resolveCorrect(key, normalizeOptions(input.options)), { indices: [1] }, key);
}
}
});
it("never redirects an exact punctuated key to a different debris-free option", () => {
const keys = ["value", "value.", '"value"', "`value`", ":value"];
const options = normalizeOptions(keys.map((value, i) => ({ label: `Claim ${i}`, value })));
for (const [i, key] of keys.entries()) {
assert.deepEqual(resolveCorrect(key, options), { indices: [i + 1] }, key);
}
assert.deepEqual(options.map((o) => o.value), keys, "canonical keys stay distinct and untouched");
});
it("preserves exact multi-select entries while still healing non-matching copy debris", () => {
const input = {
question: "Which claims?",
options: [{ label: "First." }, { label: "Second", value: "second" }],
explanation: "Both claims follow.",
multiSelect: true,
};
for (const correctAnswer of [["First.", ': "second"'], JSON.stringify(["First.", ': "second"'])]) {
assert.deepEqual(quizInputProblems({ ...input, correctAnswer }), []);
assert.ok(parseQuizInput({ ...input, correctAnswer }));
assert.deepEqual(resolveCorrect(correctAnswer, normalizeOptions(input.options)), { indices: [1, 2] });
}
});
it("retains an exact JSON-looking option key across validation and grading", () => {
const key = '["a", "b"]';
const input = {
question: "Which literal?",
options: [{ label: key }, { label: "Other" }],
explanation: "The first literal is intended.",
correctAnswer: key,
};
assert.deepEqual(quizInputProblems(input), []);
assert.ok(parseQuizInput(input));
assert.deepEqual(resolveCorrect(key, normalizeOptions(input.options)), { indices: [1] });
});
});
// ─── isCorrect (exact-set match) ────────────────────────────────────────────
describe("isCorrect", () => {
@@ -692,7 +786,7 @@ describe("the quiz role field (predict | check)", () => {
question: valid.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(valid.correctAnswer), options).indices,
correctIndices: resolveCorrect(valid.correctAnswer, options).indices,
explanation: valid.explanation,
createdAt: new Date().toISOString(),
};
+43 -1
View File
@@ -21,13 +21,14 @@ import {
createTeacherTools,
probeQuizTool,
probeExplainTool,
quizExampleBody,
teachExplainToolFor,
teachQuizToolFor,
type TeacherToolContext,
} from "../src/agent/tools.ts";
import { TOGGLEABLE_TOOLS } from "../src/core/pending.ts";
import { jobStore } from "../src/store/jobs.ts";
import { probeQuizInputSchema, quizInputObject } from "../src/core/question-core.ts";
import { probeQuizInputSchema, quizInputObject, quizInputProblems } from "../src/core/question-core.ts";
import { chatStore } from "../src/store/chats.ts";
import { PATHS } from "../config.ts";
@@ -208,6 +209,47 @@ describe("the prober's quiz: no nodeId field at all", () => {
assert.equal(Value.Check(probe.parameters, { ...validQuiz, nodeId: "anything" }), true, "no session check to fail");
});
});
// ─── the description example (owner, 2026-10-12) ─────────────────────────
describe("quiz tool descriptions carry the complete, phase-correct example", () => {
it("the canonical body is schema-valid and authoring-clean (never ship a broken exemplar)", () => {
assert.deepEqual(quizInputProblems(quizExampleBody), []);
assert.equal(Value.Check(probeQuizInputSchema, quizExampleBody), true);
const teachExample = { type: "check", nodeId: "chain-rule", ...quizExampleBody };
assert.equal(Value.Check(quizInputObject, teachExample), true);
});
it("probe: the serialized body rides the description with correct JSON escaping, no phase metadata", () => {
const desc = probeQuizTool.description;
assert.ok(
desc.includes("```json\n" + JSON.stringify(quizExampleBody, null, 2) + "\n```"),
"the example is the exact JSON.stringify of the canonical body",
);
// The escaping exemplar must be VISIBLE as wire text — the runtime
// string carries literal double backslashes, exactly what the model
// must type inside JSON.
assert.ok(desc.includes("A signal is $y=\\\\sin(2x)$"), "LaTeX command backslash shows doubled");
assert.ok(desc.includes("a LaTeX backslash is \\\\ and a newline is \\n"), "the one-line escaping hint");
assert.ok(!desc.includes("nodeId"), "probe example carries no phase metadata");
});
it("quiz: the required pair leads the example, with the plan-id caveat", () => {
const chatId = newChat("Teacher: description example");
const desc = teachQuizToolFor({ chatId, chatStore }).description;
const teachExample = { type: "check", nodeId: "chain-rule", ...quizExampleBody };
assert.ok(desc.includes("```json\n" + JSON.stringify(teachExample, null, 2) + "\n```"));
// The 2026-10-09 drop-order ruling: type+nodeId LEAD the object —
// fields generated first get dropped least.
assert.ok(
/```json\n\{\n {2}"type": "check",\n {2}"nodeId": "chain-rule",/.test(desc),
"type and nodeId lead the serialized example",
);
assert.ok(
desc.includes("illustrative — copy an exact id from this session's plan_init"),
"the illustrative-nodeId caveat",
);
});
});
// ─── tool availability (2026-09 settings seam) ───────────────────────────────
describe("tool toggles (core/pending.ts + factories)", () => {
+102 -19
View File
@@ -25,7 +25,6 @@ import type { JobRecord } from "../src/core/contracts.ts";
import { AnsweredCard } from "../components/answered-card.tsx";
import {
buildAnswer,
coerceCorrectAnswer,
normalizeOptions,
resolveCorrect,
questionNeedsReveal,
@@ -47,7 +46,7 @@ function quizQD(): QuestionData {
question: quizInput.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(quizInput.correctAnswer), options).indices,
correctIndices: resolveCorrect(quizInput.correctAnswer, options).indices,
explanation: quizInput.explanation,
createdAt: new Date().toISOString(),
};
@@ -342,14 +341,13 @@ describe("render smoke tests", () => {
});
describe("math rendering (streamdown + KaTeX)", () => {
// New delimiter contract (W3): the teacher writes \(...\) / \[...\]; a
// bare $ is ALWAYS literal currency (singleDollarTextMath: false kills it
// structurally). transformMathDelimiters rewrites both to the two-dollar
// forms remark-math still parses. Full coverage: render-markdown.test.tsx.
it("renders $...$ as LITERAL text — a $ is never math (the currency fix)", () => {
// Standard authoring: $...$ inline and $$ on separate lines for display.
// The legacy backslash reader remains for existing content.
// Full coverage: render-markdown.test.tsx.
it("renders standard $...$ as inline math", () => {
const html = renderToStaticMarkup(<MessageResponse>{"The map $f(x) = x^2$ is smooth."}</MessageResponse>);
assert.ok(!html.includes("katex"), `$-math must not render, got: ${html.slice(0, 300)}`);
assert.match(html, /\$f\(x\) = x\^2\$/, "old-contract math renders verbatim");
assert.ok(html.includes("katex"), `$-math must render, got: ${html.slice(0, 300)}`);
assert.ok(!html.includes("katex-display"), "single-dollar math is inline");
});
it("renders display math ($$ fenced on own lines — the transform's target form)", () => {
@@ -366,10 +364,8 @@ describe("math rendering (streamdown + KaTeX)", () => {
assert.ok(html.includes("katex") && !html.includes("katex-display"), "one-liner should render inline");
});
it("does NOT eat escaped currency (\\$5 stays text — belt and suspenders)", () => {
// With singleDollarTextMath: false even a bare $5 can't parse as math;
// an escaped \$ is doubly safe. The prompt contract still asks the
// teacher to prefer plain "$5" (it renders literally now).
it("does NOT eat escaped currency (\\$5 stays text)", () => {
// With dollar math enabled, Markdown-escaped dollars remain literal.
const html = renderToStaticMarkup(<MessageResponse>{"It costs \\$5 and \\$10 more."}</MessageResponse>);
assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
assert.match(html, /\$5/);
@@ -377,10 +373,10 @@ describe("math rendering (streamdown + KaTeX)", () => {
it("quiz cards render math in question, options, and explanation", () => {
const mathQuiz = {
question: "What is \\(\\frac{dy}{dx}\\) of \\(y = x^2\\)?",
options: [{ label: "\\(2x\\)" }, { label: "\\(x^2\\)" }, { label: "\\(x\\)" }],
correctAnswer: "\\(2x\\)",
explanation: "The power rule gives \\(\\frac{dy}{dx} = 2x\\).",
question: "What is $\\frac{dy}{dx}$ of $y = x^2$?",
options: [{ label: "$2x$" }, { label: "$x^2$" }, { label: "$x$" }],
correctAnswer: "$2x$",
explanation: "The power rule gives $\\frac{dy}{dx} = 2x$.",
};
const pendingHtml = renderDock({ toolName: "quiz", toolCallId: "m1", input: mathQuiz as never });
assert.ok(pendingHtml.includes("katex"), "pending dock: question/options math renders");
@@ -392,12 +388,12 @@ describe("math rendering (streamdown + KaTeX)", () => {
question: mathQuiz.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(mathQuiz.correctAnswer), options).indices,
correctIndices: resolveCorrect(mathQuiz.correctAnswer, options).indices,
explanation: mathQuiz.explanation,
createdAt: new Date().toISOString(),
};
const answeredHtml = renderToStaticMarkup(
<AnsweredCard answer={buildAnswer(qd, { values: ["\\(2x\\)"] })} />,
<AnsweredCard answer={buildAnswer(qd, { values: ["$2x$"] })} />,
);
assert.ok(answeredHtml.includes("katex"), "answered card: option/explanation math renders");
});
@@ -692,3 +688,90 @@ describe("predict vs check rendering", () => {
assert.ok(legacyHtml.includes("a 2026-09 answer") || legacyHtml.includes('data-testid="your-thinking-trigger"'), "records from before the rename still render");
});
});
// ─── details + option descriptions render as markdown (owner, 2026-10-12) ──
describe("question context renders as markdown everywhere (owner, 2026-10-12)", () => {
it("pending dock: ask option descriptions render LaTeX, not plain text", () => {
const html = renderDock({
toolName: "ask_user_question",
toolCallId: "d1",
input: {
question: "Which tool?",
options: [
{ label: "Pencil", description: "Wood around $\\text{Pb}_x\\text{C}$ graphite — the classic $x^2$ choice." },
{ label: "Pen" },
],
} as never,
});
// The description rides the same MessageResponse path as the label:
// KaTeX spans render inside the option subtext.
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 2, `option description math renders (got ${katexCount})`);
assert.ok(!html.includes("$\\text"), "raw dollar delimiters do not leak as visible text");
});
it("settled ask card keeps the details line and option descriptions — retroactively from the persisted call", () => {
const input = {
question: "Which destination?",
details: "Inline $a^2+b^2=c^2$ and display:\n$$\nE = mc^2\n$$",
options: [
{ label: "Classical", description: "Entropy $H(X)$ first." },
{ label: "ML toolkit" },
],
};
const qd: QuestionData = {
id: "d2",
kind: "ask",
question: input.question,
mode: "single-select",
options: normalizeOptions(input.options),
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { values: ["Classical"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 3, `details (inline+display) + description math render (got ${katexCount})`);
assert.match(html, /Entropy/, "the option description text is present");
assert.ok(html.includes("katex-display"), "display math in the details line renders");
});
it("settled quiz card keeps the details line; quiz options stay description-less", () => {
const input = {
question: "Typical sets?",
details: "Recall $2^{nH(X)}$ — the asymptotic equipartition bound.",
options: [
{ label: "$\\approx 2^{nH(X)}$", value: "a" },
{ label: "$nH(X)$", value: "b" },
],
explanation: "AEP.",
correctAnswer: "a",
};
const options = normalizeOptions(input.options);
const qd: QuestionData = {
id: "d3",
kind: "quiz",
question: input.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(input.correctAnswer, options).indices,
explanation: input.explanation,
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { values: ["a"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} input={input} />);
const katexCount = (html.match(/class="katex"/g) ?? []).length;
assert.ok(katexCount >= 3, `details + option-label math renders (got ${katexCount})`);
// Quiz options carry NO description field at all (owner ruling
// 2026-09-26) — the desc map is ask-only and stays empty here.
assert.ok(!html.includes("lh-prose block text-xs"), "no option-subtext slot on quiz rows");
});
it("legacy settled records (no input prop) render exactly as before", () => {
const answer = buildAnswer(askQD(), { values: ["Very"] });
const html = renderToStaticMarkup(<AnsweredCard answer={answer} />);
assert.match(html, /Very/, "the card renders");
assert.equal((html.match(/class="katex"/g) ?? []).length, 0, "no context slot appears from nothing");
assert.ok(!html.includes("lh-prose block text-xs"), "no description slot without an ask input");
});
});
+20 -7
View File
@@ -94,7 +94,7 @@ describe("math delimiter transform (pure unit)", () => {
});
});
describe("render — new math contract (streamdown + KaTeX)", () => {
describe("render — standard dollar math + legacy compatibility (streamdown + KaTeX)", () => {
it("renders \\(...\\) as inline KaTeX", () => {
const html = render("The map \\(f(x) = x^2\\) is smooth.");
assert.ok(html.includes("katex"), `inline math should render, got: ${html.slice(0, 300)}`);
@@ -121,16 +121,29 @@ describe("render — new math contract (streamdown + KaTeX)", () => {
assert.equal(items.length, 2, "both list items render");
});
it("renders bare $ as literal currency — the hazard dies structurally", () => {
const html = render("It costs $5 and $10 more.");
assert.ok(!html.includes("katex"), "currency must never parse as math");
it("keeps escaped currency literal", () => {
const html = render("It costs \\$5 and \\$10 more.");
assert.ok(!html.includes("katex"), "escaped currency must not parse as math");
assert.match(html, /\$5 and \$10/);
});
it("renders old-contract $...$ math as literal text (singleDollarTextMath: false)", () => {
it("renders standard $...$ math inline", () => {
const html = render("The map $f(x) = x^2$ is smooth.");
assert.ok(!html.includes("katex"), "$ can never start math");
assert.match(html, /\$f\(x\) = x\^2\$/, "the old form renders verbatim");
assert.ok(html.includes("katex"), "single-dollar math renders");
assert.ok(!html.includes("katex-display"), "single-dollar math is inline");
});
it("renders standard dollar commands and fenced display math", () => {
const html = render("The symbol $\\Omega$ appears here.\n\n$$\nS = k_B \\ln \\Omega\n$$");
assert.ok(html.includes("katex-display"), "standalone dollar fences render display math");
assert.ok(html.includes("Ω"), "the command renders as the Greek symbol");
assert.ok(!html.includes("katex-error"), "both formulas are valid");
});
it("keeps dollar examples in inline code and code fences literal", () => {
const html = render("Use `$x$` here.\n\n```text\n$$\ny = x^2\n$$\n```");
assert.ok(!html.includes("katex"), "code examples are not math");
assert.ok(html.includes("$x$"), "inline code retains its delimiters");
});
it("keeps \\(...\\) inside a code fence literal", () => {
+26 -4
View File
@@ -34,6 +34,7 @@ import {
import { parseQuizInput } from "../src/core/tool-parts.ts";
import { resolveTargetId } from "../src/core/chains.ts";
import { bareQuestionTools } from "../src/agent/teacher-agent.ts";
import { probeQuizTool } from "../src/agent/tools.ts";
/** The incident's exact encoding: the value wrapped in literal quotes. */
function QUOTED(value: string): string {
@@ -126,9 +127,9 @@ describe("the colon shape — correctAnswer with copy-syntax debris", () => {
assert.equal(stripModelValueDebris('["x"]'), '["x"]'); // interior quotes are content (JSON-array strings)
});
it("coerceCorrectAnswer strips every entry (boundary check + grading seam)", () => {
assert.deepEqual(coerceCorrectAnswer([":parmenides"]), ["parmenides"]);
assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external")), ["primary-external"]);
it("coerceCorrectAnswer cleans non-matching entries (boundary check + grading seam)", () => {
assert.deepEqual(coerceCorrectAnswer([":parmenides"], ["parmenides"]), ["parmenides"]);
assert.deepEqual(coerceCorrectAnswer(QUOTED("primary-external"), ["primary-external"]), ["primary-external"]);
});
it("the incident's exact call passes the semantic boundary (quizInputProblems)", () => {
@@ -153,6 +154,27 @@ describe("the colon shape — correctAnswer with copy-syntax debris", () => {
});
});
describe("the calculus retry loop — an exact answer was incorrectly stripped", () => {
it("the real probe tool accepts a label with its sentence-final period", async () => {
const input = {
question: "Which claim?",
options: [{ label: "The claim holds." }, { label: "The claim need not hold." }],
explanation: "The premise does not imply the claim.",
correctAnswer: "The claim need not hold.",
};
const validated = validateToolArguments(probeQuizTool, {
type: "toolCall",
id: "calculus-period",
name: "probe",
arguments: probeQuizTool.prepareArguments!(input),
});
assert.equal(validated.correctAnswer, input.correctAnswer);
const result = await probeQuizTool.execute("calculus-period", validated);
assert.notEqual(result.isError, true, "an already-exact answer must not trigger a retry");
assert.equal(result.terminate, true, "the valid question is posed normally");
});
});
describe("the display mirror — raw records heal on read", () => {
it("parseQuizInput renders the incident's persisted raw record (no invisible quiz)", () => {
const raw = whiteheadQuiz(QUOTED("predict"), [":parmenides"]);
@@ -163,6 +185,6 @@ describe("the display mirror — raw records heal on read", () => {
// which strips the recorded wobble to the intended role.
assert.equal(normalizeQuizType(parsed?.type), "predict");
// The graded answer resolves to the option despite the recorded colon.
assert.deepEqual(parsed ? coerceCorrectAnswer(parsed.correctAnswer) : [], ["parmenides"]);
assert.deepEqual(parsed ? coerceCorrectAnswer(parsed.correctAnswer, parsed.options.map((o) => o.value ?? o.label)) : [], ["parmenides"]);
});
});
+84
View File
@@ -12,6 +12,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { quizInputProblems } from "../src/core/question-core.ts";
import { Value } from "typebox/value";
import { restoreMathEscapes } from "../src/core/math-delims.ts";
import {
parseAskInput,
parseProbeDoneInput,
@@ -279,6 +280,89 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () =>
});
});
describe("dollar-delimiter recognition — existing escape restoration rules", () => {
it("restores question, option, and explanation debris in dollar math", () => {
const parsed = parseQuizInput({
question: "What does $\x0corall x$ mean?",
options: [{ label: "$\x0barphi$ holds", value: "yes" }, { label: "No", value: "no" }],
correctAnswer: "yes",
explanation: "Here $p\to q$ is an implication.",
});
assert.ok(parsed);
assert.equal(parsed.question, "What does $\\forall x$ mean?");
assert.equal(parsed.options[0]!.label, "$\\varphi$ holds");
assert.equal(parsed.explanation, "Here $p\\to q$ is an implication.");
});
it("keeps the same six-character inline recovery map", () => {
for (const [debris, command] of [
["\beta", "\\beta"], ["\to", "\\to"], ["\neq", "\\neq"],
["\x0barphi", "\\varphi"], ["\x0corall", "\\forall"], ["\rho", "\\rho"],
]) {
assert.equal(restoreMathEscapes(`$${debris}$`), `$${command}$`);
}
});
it("preserves display newlines and real LaTeX row breaks", () => {
const math = "Compare:\n$$\n\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}\n$$";
assert.equal(restoreMathEscapes(math), math);
});
it("restores five display debris characters but keeps newline ambiguity unchanged", () => {
for (const [debris, command] of [
["\beta", "\\beta"], ["\to", "\\to"], ["\x0barphi", "\\varphi"],
["\x0corall", "\\forall"], ["\\left(x \right)", "\\left(x \\right)"],
]) {
assert.equal(
restoreMathEscapes(`Compare:\n$$\n${debris}, p \neq q\n$$`),
`Compare:\n$$\n${command}, p \neq q\n$$`,
);
}
});
it("preserves legacy replacement frequency when a span has multiple debris characters", () => {
const body = "p\to q, \rho";
const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2);
assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`);
assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged");
});
it("shares dollar recovery with ask and free-recall fields", () => {
const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] });
assert.ok(ask);
assert.equal(ask.options![0]!.label, "$\\forall$ rigor");
const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." });
assert.ok(explain);
assert.equal(explain.question, "Explain $p\\to q$");
assert.equal(explain.correctAnswer, "$p\\neq q$ is different.");
});
it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => {
const prose = "Pasted:\t\x0cb. \\$\x0corall x$. A good $x$.";
assert.equal(restoreMathEscapes(prose), prose);
const code = "Literal `$\x0corall x$`.\n\n```text\n$\x0barphi$\n```\n\nReal $\x0corall x$.";
assert.equal(restoreMathEscapes(code), code.replace("Real $\x0corall x$", "Real $\\forall x$"));
});
it("leaves unbalanced and mismatched delimiter runs untouched", () => {
for (const text of ["$\x0corall", "$$\n\x0corall x\n", "$\x0corall$$", "$$\x0corall$"]) {
assert.equal(restoreMathEscapes(text), text);
}
});
it("recognizes native inline runs and longer display closing fences", () => {
assert.equal(restoreMathEscapes("Inline $$p\neq q$$."), "Inline $$p\\neq q$$.");
assert.equal(restoreMathEscapes("$$\n\x0corall x\n$$$"), "$$\n\\forall x\n$$$");
});
it("does not add an over-escaping repair or reprocess legacy spans", () => {
const overescaped = "$$\n\\\\Omega\n$$";
assert.equal(restoreMathEscapes(overescaped), overescaped);
const legacy = "\\[\np $a\neq b$\n\\]";
assert.equal(restoreMathEscapes(legacy), legacy);
});
});
// ─── parseExplainInput (free-recall display parse, owner 2026-09-27) ───────
describe("parseExplainInput: the dock's display seam for explain calls", () => {