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
+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");
});
});