diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index 392f8a7..08cd053 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -2,7 +2,7 @@ import { MessageResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; -import type { ArtifactPartProps } from "@/src/core"; +import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core"; /** * The practice transcript cards (practice sessions, owner design @@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
Problem
- {statement} + {repairMathForDisplay(statement)}
); @@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) { (owner round 3): the same quiet 50% register as quiz explanations. */} {verdict.length > 0 && (
- {verdict} + {repairMathForDisplay(verdict)}
)} {/* Then the divider, then the labeled model solution (Slice G: the @@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
Model solution
- {reveal} + {repairMathForDisplay(reveal)}
diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index e4ed501..e24f4f4 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { joinDictation, useDictationTarget } from "@/components/dictation"; import { useEnterSubmits } from "@/components/enter-submits"; import { cn } from "@/lib/utils"; +import { repairMathForDisplay } from "@/src/core"; /** * The practice dock (practice sessions, owner design 2026-10-04): an @@ -108,7 +109,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
Problem
- {input.statement} + {typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}
diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index 9299dd2..b939054 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -325,6 +325,11 @@ const DEBRIS_RE = /[\b\t\n\v\f\r]/; * `\right`/`\rho`/`\rangle` corruption is common in display math). */ const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/; +/** Shared display-only repair. Keep raw arguments and model history unchanged. */ +export function repairMathForDisplay(text: string): string { + return healOverescapedCommands(restoreMathEscapes(text)); +} + export function restoreMathEscapes(text: string): string { if (!DEBRIS_RE.test(text)) return text; return restoreDollarMathEscapes(restoreLegacyMathEscapes(text)); diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index 980166e..cb5585d 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -30,7 +30,7 @@ import { Value } from "typebox/value"; import type { TSchema } from "typebox"; import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts"; -import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts"; +import { repairMathForDisplay } from "./math-delims.ts"; import { healEncodedArguments } from "./argument-heal.ts"; import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts"; @@ -79,7 +79,7 @@ function boundaryParse(schema: TSchema, input: unknown): T | undefined { * plus "nabla" as letters), so even runs before a command start inside * balanced math spans collapse to one backslash. */ function restoreMathEscapesDeep(value: unknown): unknown { - if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value)); + if (typeof value === "string") return repairMathForDisplay(value); if (Array.isArray(value)) return value.map(restoreMathEscapesDeep); if (typeof value === "object" && value !== null) { const out: Record = {}; diff --git a/tests/repair-math-display.test.tsx b/tests/repair-math-display.test.tsx new file mode 100644 index 0000000..6ab3d22 --- /dev/null +++ b/tests/repair-math-display.test.tsx @@ -0,0 +1,204 @@ +/** Display repair is shared, not a mutation of saved practice/teaching arguments. */ +import React from "react"; +(globalThis as Record).React = React; + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { validateToolArguments } from "@earendil-works/pi-ai"; +import { problemInputObject } from "../src/agent/tools/practice.ts"; +import { PracticeDock } from "../components/practice-dock.tsx"; +import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx"; +import { MessageResponse } from "../components/ai-elements/message.tsx"; +import { + repairMathForDisplay, healOverescapedCommands, restoreMathEscapes, + parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView, + toLlmMessages, type LhMessage, type ArtifactPartProps, +} from "../src/core/index.ts"; + +const tex = String.raw`\nabla f(x) \in \mathbb{R}^{m \times n}`; +const clean = `$${tex}$`; +const doubled = clean.replace(/\\/g, "\\\\"); +const quadrupled = clean.replace(/\\/g, "\\\\\\\\"); +const debris = "$\forall x \\in \\mathbb{R}$"; // JSON's form feed consumed the f. +const repairedDebris = String.raw`\forall x \in \mathbb{R}`; +const matrix = "$$\n" + String.raw`\\begin{pmatrix}1 & 2 \\ 3 & 4\\end{pmatrix}` + "\n$$"; +const repairedMatrix = String.raw`\begin{pmatrix}1 & 2 \\ 3 & 4\end{pmatrix}`; +const part = (input: unknown, over: Partial = {}): ArtifactPartProps["part"] => ({ + type: "tool-problem", state: "input-available", toolCallId: "call_math", input, ...over, +}); +const dock = (input: { statement: string }, expanded = true) => ( + {}} onSubmit={() => {}} /> +); + +function math(html: string) { + const { document } = parseHTML(html); + return { + annotations: [...document.querySelectorAll('annotation[encoding="application/x-tex"]')].map((n) => n.textContent), + glyphs: [...document.querySelectorAll(".katex-html")].map((n) => n.textContent).join(""), + errors: document.querySelectorAll(".katex-error").length, + }; +} +function assertMath(html: string, expected = [tex], glyphs = ["∇", "∈", "×"]) { + const actual = math(html); + assert.deepEqual(actual.annotations, expected, "exact TeX, not merely absence of errors"); + assert.equal(actual.errors, 0); + for (const glyph of glyphs) assert.ok(actual.glyphs.includes(glyph), `rendered glyph ${glyph}`); +} + +describe("repairMathForDisplay — the existing composition, unchanged", () => { + const unchanged = [ + clean, + String.raw`outside \\nabla`, + String.raw`$a \\\nabla b$`, + String.raw`$a \\\\\nabla b$`, + String.raw`$a \\ 2 \\& 3 \\[6pt] 4$`, + "$$\na \\\\\\\\ 2\nb\n$$", // four real row-break slashes survive too. + "`" + doubled + "`", + "```tex\n" + doubled + "\n```", + "`" + debris + "`", + "```tex\n" + debris + "\n```", + String.raw`$\\nabla`, + "$$A = \\\\begin{pmatrix}\n1 & 2\n\\\\end{pmatrix}$$", // unsupported midline display close. + ]; + for (const [index, value] of [...unchanged, doubled, quadrupled, debris, matrix, + String.raw`\(\\nabla\)`, "\\[\n\\\\nabla\n\\]"].entries()) { + it(`matches the old composition byte-for-byte (${index})`, () => { + assert.equal(repairMathForDisplay(value), healOverescapedCommands(restoreMathEscapes(value))); + }); + } + it("preserves clean math, odd runs, row breaks, code, prose and existing recognition limits", () => { + for (const value of unchanged) assert.equal(repairMathForDisplay(value), value); + assert.equal(repairMathForDisplay(doubled), clean); + assert.equal(repairMathForDisplay(quadrupled), clean); + assert.equal(repairMathForDisplay(debris), `$${repairedDebris}$`); + assert.equal(repairMathForDisplay(matrix), `$$\n${repairedMatrix}\n$$`); + }); +}); + +describe("practice display surfaces", () => { + for (const [name, raw, expected, glyphs] of [ + ["two command slashes", doubled, tex, ["∇", "∈", "×"]], + ["four command slashes", quadrupled, tex, ["∇", "∈", "×"]], + ["JSON debris", debris, repairedDebris, ["∀", "∈"]], + ["clean math", clean, tex, ["∇", "∈", "×"]], + ["matrix with legitimate row break", matrix, repairedMatrix, ["1", "2", "3", "4"]], + ] as const) { + it(`${name}: expanded dock, settled statement, verdict, committed reveal and legacy fallback`, () => { + const input = Object.freeze({ statement: raw, modelSolution: `HIDDEN COMMITMENT ${raw}` }); + const conclusion = Object.freeze({ outcome: "solved", verdict: raw, solutionReveal: raw }); + const before = JSON.stringify({ input, conclusion }); + for (const element of [dock(input), ]) { + const html = renderToStaticMarkup(element); + assertMath(html, [expected], [...glyphs]); + assert.ok(!html.includes("HIDDEN COMMITMENT")); + } + assertMath(renderToStaticMarkup(), + [expected, expected], [...glyphs]); + assertMath(renderToStaticMarkup(), + [expected], [...glyphs]); + assert.equal(JSON.stringify({ input, conclusion }), before); + }); + } + + it("keeps a boundary-converted numeric statement raw and preserves prior dock passthrough", () => { + const raw = { statement: 17, mode: "recall", modelSolution: "Committed solution." }; + const call = { type: "toolCall", id: "numeric_problem", name: "problem", arguments: raw }; + const validated = validateToolArguments( + { name: "problem", parameters: problemInputObject } as never, + call as never, + ) as { statement: unknown }; + assert.equal(validated.statement, "17", "the actual tool boundary converts its cloned arguments"); + assert.equal(raw.statement, 17, "saved arguments retain the number"); + const messages = [ + { id: "numeric_pose", role: "assistant", content: [call], timestamp: 1 }, + { id: "numeric_ack", role: "toolResult", toolCallId: call.id, toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 }, + ] as unknown as LhMessage[]; + const before = structuredClone(messages); + const active = activeProblemOf(messages); + assert.ok(active); + assert.equal((active.input as { statement: unknown }).statement, 17); + // Mirror the existing runtime cast at the Chat -> PracticeDock seam. + const input = active.input as { statement: string }; + let expanded = ""; + assert.doesNotThrow(() => { expanded = renderToStaticMarkup(dock(input)); }); + const { document } = parseHTML(expanded); + assert.equal(document.querySelector(".lh-prose")!.innerHTML, + parseHTML(renderToStaticMarkup({input.statement})).document.firstElementChild!.outerHTML, + "non-string values pass through exactly as before, without string coercion or a new fallback"); + const collapsed = renderToStaticMarkup(dock(input, false)); + assert.match(collapsed, /data-testid="practice-dock-bar"/); + assert.equal(parseHTML(collapsed).document.querySelector("span.truncate")!.textContent, "17"); + assert.deepEqual(math(collapsed).annotations, []); + assert.deepEqual(messages, before); + assert.equal(raw.statement, 17); + }); + + it("preserves code spans/fences across every repaired display surface", () => { + const raw = "`" + doubled + "`\n\n```text\n" + doubled + "\n```"; + for (const element of [dock({ statement: raw }), , + ]) { + const html = renderToStaticMarkup(element); + assert.deepEqual(math(html).annotations, []); + const { document } = parseHTML(html); + assert.ok(document.querySelector("code")?.textContent?.includes(doubled)); + } + }); + + it("does not change the collapsed preview, dismissal or global prose renderer", () => { + const preview = renderToStaticMarkup(dock({ statement: doubled }, false)); + assert.ok(preview.includes(doubled)); + assert.deepEqual(math(preview).annotations, []); + const dismissed = renderToStaticMarkup(); + assert.ok(dismissed.includes("Problem dismissed.")); + assert.deepEqual(math(dismissed).annotations, []); + const plain = math(renderToStaticMarkup({doubled})); + assert.deepEqual(plain.annotations, [doubled.slice(1, -1)]); + assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path. + }); + + it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => { + const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris }; + const ask = { question: doubled }; + const explain = { question: doubled, correctAnswer: debris }; + const before = structuredClone({ quiz, ask, explain }); + assert.equal(parseQuizInput(quiz)?.question, clean); + assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`); + assert.equal(parseAskInput(ask)?.question, clean); + assert.equal(parseExplainInput(explain)?.question, clean); + assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`); + assertMath(renderToStaticMarkup({parseAskInput(ask)!.question})); + assert.deepEqual({ quiz, ask, explain }, before); + }); + + it("native history, active args and the view join remain raw; commitment appears only at conclusion", () => { + const stamps = { api: "faux", provider: "test", model: "test-model", timestamp: 1, stopReason: "toolUse" }; + const input = { statement: doubled, modelSolution: `COMMITTED ${quadrupled}` }; + const messages = [ + { ...stamps, id: "a1", role: "assistant", content: [{ type: "toolCall", id: "p1", name: "problem", arguments: input }] }, + { id: "r1", role: "toolResult", toolCallId: "p1", toolName: "problem", content: [{ type: "text", text: "Problem posed" }], details: null, isError: false, timestamp: 2 }, + ] as unknown as LhMessage[]; + const concluded = [...messages, + { ...stamps, id: "a2", role: "assistant", content: [{ type: "toolCall", id: "c1", name: "conclude", arguments: { outcome: "solved", verdict: doubled } }] }, + { id: "r2", role: "toolResult", toolCallId: "c1", toolName: "conclude", content: [{ type: "text", text: "Problem concluded" }], details: null, isError: false, timestamp: 3 }, + ] as unknown as LhMessage[]; + const before = structuredClone(concluded); + const history = structuredClone(toLlmMessages(concluded)); + assert.deepEqual(activeProblemOf(messages)?.input, input); + const problem = projectView(messages)[0]!.parts.find((p) => p.type === "tool-problem")!; + assert.ok(problem.state && problem.toolCallId); + assert.deepEqual(problem.input, input); + const statementHtml = renderToStaticMarkup(); + assertMath(statementHtml); + assert.ok(!statementHtml.includes("COMMITTED")); + const conclusion = projectView(concluded).flatMap((m) => m.parts).find((p) => p.type === "tool-conclude")!; + assert.ok(conclusion.state && conclusion.toolCallId); + assert.equal(conclusion.reveal, input.modelSolution); + const revealHtml = renderToStaticMarkup(); + assert.ok(revealHtml.includes("COMMITTED")); + assertMath(revealHtml, [tex, tex]); + assert.deepEqual(concluded, before); + assert.deepEqual(toLlmMessages(concluded), history); + }); +});