math: heal overescaped LaTeX commands in question displays
This commit is contained in:
@@ -468,3 +468,100 @@ function restoreDollarMathEscapes(text: string): string {
|
|||||||
}
|
}
|
||||||
return out.join("");
|
return out.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Over-escaped command heal (owner ruling 2026-10-15) — the doubled-
|
||||||
|
* backslash fix.
|
||||||
|
*
|
||||||
|
* Some models stochastically emit DOUBLED backslashes inside JSON tool-call
|
||||||
|
* arguments: the saved record holds `\\nabla` (two real backslashes) where
|
||||||
|
* `\nabla` was meant. KaTeX reads the `\\` as a row break and then renders
|
||||||
|
* the command name as plain letters — the learner sees "the word nabla, not
|
||||||
|
* the symbol". Field evidence (2026-10-15, real data): xiaomi/mimo-v2.6-pro
|
||||||
|
* doubled per-call, per-field (one probe's question doubled while its
|
||||||
|
* explanation and options in the SAME call stayed clean — authoring, not
|
||||||
|
* transport), and deepseek-v4.1-flash once; quadrupling appeared when the
|
||||||
|
* in-session agent "compensated" for its own doubled first attempt.
|
||||||
|
*
|
||||||
|
* THE RULE. Inside a balanced math span ONLY (legacy `\(…\)`/`\[…\]` and
|
||||||
|
* dollar `$…$`/`$$…$$`, code spans and fences excluded), an EVEN-length
|
||||||
|
* backslash run (2, 4, …) immediately followed by a command start — a letter
|
||||||
|
* (any command name; bare letters after a row break are never authored
|
||||||
|
* content) or a punctuation command (`,` `;` `:` `!` `|` — thin spaces,
|
||||||
|
* norm bars) — collapses to a single backslash. Even runs before anything
|
||||||
|
* else (space, newline, `&`, `[`, `$`, digits) are legitimate row breaks and
|
||||||
|
* stay; ODD runs (3, 5, …) are row break(s) plus a real command with its own
|
||||||
|
* backslash — e.g. matrix cells written `a \\\nabla` — and stay untouched,
|
||||||
|
* so matrix content cannot be corrupted. Outside spans nothing is touched:
|
||||||
|
* doubled backslashes in prose or code are not ours to judge. Unbalanced
|
||||||
|
* spans pass through, exactly like the debris passes.
|
||||||
|
*/
|
||||||
|
const COMMAND_START_CLASS = "[A-Za-z,;:!|]";
|
||||||
|
|
||||||
|
/** Collapse even backslash runs before a command start; keep odd runs. */
|
||||||
|
function halveOverescapedCommandRuns(inner: string): string {
|
||||||
|
return inner.replace(
|
||||||
|
new RegExp("(\\\\+)(?=" + COMMAND_START_CLASS + ")", "g"),
|
||||||
|
(run: string) => (run.length % 2 === 0 ? "\\" : run),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healOverescapedCommands(text: string): string {
|
||||||
|
if (!text.includes("\\\\")) return text;
|
||||||
|
const out: string[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < text.length) {
|
||||||
|
if (atLineStart(text, i) && FENCE_OPEN_RE.test(text.slice(i))) {
|
||||||
|
i = copyFencedBlock(text, i, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[i] === "`") {
|
||||||
|
const run = backtickRun(text, i);
|
||||||
|
let j = i + run;
|
||||||
|
while (j < text.length) {
|
||||||
|
const nextRun = backtickRun(text, j);
|
||||||
|
if (nextRun === run) break;
|
||||||
|
j += Math.max(nextRun, 1);
|
||||||
|
}
|
||||||
|
const after = j < text.length ? j + run : i + run;
|
||||||
|
out.push(text.slice(i, after));
|
||||||
|
i = after;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (text[i] === "\\" && !isEscaped(text, i)) {
|
||||||
|
const next = text[i + 1];
|
||||||
|
if (next === "(" || next === "[") {
|
||||||
|
const isDisplay = next === "[";
|
||||||
|
const open = isDisplay ? DISPLAY_OPEN : INLINE_OPEN;
|
||||||
|
const close = isDisplay ? DISPLAY_CLOSE : INLINE_CLOSE;
|
||||||
|
const closeAt = findClose(text, i + open.length, close);
|
||||||
|
if (closeAt !== -1) {
|
||||||
|
const inner = text.slice(i + open.length, closeAt);
|
||||||
|
out.push(open + halveOverescapedCommandRuns(inner) + close);
|
||||||
|
i = closeAt + close.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (text[i] === "$" && !isEscaped(text, i)) {
|
||||||
|
const size = dollarRun(text, i);
|
||||||
|
const isDisplay = dollarIsDisplay(text, i, size);
|
||||||
|
const close = findDollarClose(text, i + size, size, isDisplay);
|
||||||
|
if (close) {
|
||||||
|
out.push(
|
||||||
|
text.slice(i, i + size) +
|
||||||
|
halveOverescapedCommandRuns(text.slice(i + size, close.at)) +
|
||||||
|
text.slice(close.at, close.after),
|
||||||
|
);
|
||||||
|
i = close.after;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(text.slice(i, i + size));
|
||||||
|
i += size;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(text[i]!);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return out.join("");
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
import { Value } from "typebox/value";
|
import { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
|
||||||
import { restoreMathEscapes } from "./math-delims.ts";
|
import { restoreMathEscapes, healOverescapedCommands } from "./math-delims.ts";
|
||||||
import { healEncodedArguments } from "./argument-heal.ts";
|
import { healEncodedArguments } from "./argument-heal.ts";
|
||||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
@@ -72,9 +72,14 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
|||||||
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
|
* single-backslash LaTeX eaten by JSON escape resolution (FF ate the 'f' of
|
||||||
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
|
* \forall, VT the 'v' of \varphi, TAB the 't' of \to) survives to the
|
||||||
* display path. This is the ONE seam every rendered question passes
|
* display path. This is the ONE seam every rendered question passes
|
||||||
* through — new turns and already-poisoned records alike heal here. */
|
* through — new turns and already-poisoned records alike heal here.
|
||||||
|
* healOverescapedCommands (owner ruling 2026-10-15) rides the same walk:
|
||||||
|
* the same models also stochastically DOUBLE command backslashes
|
||||||
|
* (`\\nabla` saved where `\nabla` was meant — KaTeX shows the row break
|
||||||
|
* plus "nabla" as letters), so even runs before a command start inside
|
||||||
|
* balanced math spans collapse to one backslash. */
|
||||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||||
if (typeof value === "string") return restoreMathEscapes(value);
|
if (typeof value === "string") return healOverescapedCommands(restoreMathEscapes(value));
|
||||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||||
if (typeof value === "object" && value !== null) {
|
if (typeof value === "object" && value !== null) {
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { describe, it } from "node:test";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
import { MessageResponse } from "../components/ai-elements/message.tsx";
|
||||||
import { MermaidError } from "../components/mermaid-error.tsx";
|
import { MermaidError } from "../components/mermaid-error.tsx";
|
||||||
import { transformMathDelimiters } from "../src/core/math-delims.ts";
|
import { transformMathDelimiters, healOverescapedCommands } from "../src/core/math-delims.ts";
|
||||||
|
|
||||||
/** Render through MessageResponse in static mode (no streaming/remend artifacts). */
|
/** Render through MessageResponse in static mode (no streaming/remend artifacts). */
|
||||||
function render(md: string): string {
|
function render(md: string): string {
|
||||||
@@ -345,3 +345,102 @@ describe("escaped dollars inside math — rendering", () => {
|
|||||||
assert.ok(html.includes("$x = \\$5$"), "the literal source stays visible");
|
assert.ok(html.includes("$x = \\$5$"), "the literal source stays visible");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("over-escaped command heal — doubled backslashes in tool args (owner ruling 2026-10-15)", () => {
|
||||||
|
// Field evidence: xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash
|
||||||
|
// stochastically DOUBLE LaTeX command backslashes inside JSON tool
|
||||||
|
// arguments — sometimes per-field within one call (the gradient-descent
|
||||||
|
// session's first probe: question doubled, explanation+options clean).
|
||||||
|
// KaTeX reads `\\` as a row break and renders the command name as plain
|
||||||
|
// letters — "renders as LaTeX but reads the word nabla". The heal lives
|
||||||
|
// at the tool-parts argument seam (restoreMathEscapesDeep); these are its
|
||||||
|
// pure units. TS literals: 4 backslashes = 2 real, 6 = 3 real.
|
||||||
|
|
||||||
|
/** The gradient-descent session's probe question (chat_mv7r02og, msg 3)
|
||||||
|
* in its CLEAN single-backslash form. */
|
||||||
|
const GD_QUESTION =
|
||||||
|
"Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. The directional derivative along a unit direction $u$ is $D_u f(x) = \\nabla f(x)^\\top u$. Among all unit directions $\\|u\\|_2 = 1$, which one makes $D_u f(x) **most negative** (steepest descent)?";
|
||||||
|
|
||||||
|
it("heals a doubled letter command inside $…$", () => {
|
||||||
|
assert.equal(healOverescapedCommands("$\\\\nabla f$"), "$\\nabla f$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals a quadrupled letter command (the agent's over-correction)", () => {
|
||||||
|
assert.equal(healOverescapedCommands("$\\\\\\\\mathbb{R}^n$"), "$\\mathbb{R}^n$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals inside multi-line $$…$$ display spans", () => {
|
||||||
|
assert.equal(
|
||||||
|
healOverescapedCommands("$$\n\\\\nabla f(x)\n$$"),
|
||||||
|
"$$\n\\nabla f(x)\n$$",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals legacy \\(…\\) spans too", () => {
|
||||||
|
assert.equal(healOverescapedCommands("\\(\\\\nabla\\)"), "\\(\\nabla\\)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heals the punctuation commands: \\| \\, \\; \\: \\!", () => {
|
||||||
|
assert.equal(healOverescapedCommands("$\\\\|u\\\\|_2$"), "$\\|u\\|_2$");
|
||||||
|
assert.equal(healOverescapedCommands("$a\\\\,b$"), "$a\\,b$");
|
||||||
|
assert.equal(healOverescapedCommands("$a\\\\;b$"), "$a\\;b$");
|
||||||
|
assert.equal(healOverescapedCommands("$a\\\\:b$"), "$a\\:b$");
|
||||||
|
assert.equal(healOverescapedCommands("$a\\\\!b$"), "$a\\!b$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("KEEPS legitimate \\ row breaks — space, newline, &, [, digits follow", () => {
|
||||||
|
const matrix = "\\begin{pmatrix} 1 \\\\ 2 \\\\\\ 3\\end{pmatrix}"; // \\ then \\\\
|
||||||
|
assert.equal(healOverescapedCommands(matrix), matrix);
|
||||||
|
const aligned = "f(x) \\\\& g(x) \\\\[6pt] h(x)";
|
||||||
|
assert.equal(healOverescapedCommands(aligned), aligned);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("KEEPS odd runs — a row break plus a real command cell", () => {
|
||||||
|
// 3 real backslashes then a letter: \\ row break + \\nabla command.
|
||||||
|
const cell = "$a \\\\\\nabla b$";
|
||||||
|
assert.equal(healOverescapedCommands(cell), cell);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("touches nothing outside math spans (prose, code spans, fences)", () => {
|
||||||
|
assert.equal(healOverescapedCommands("path C:\\\\Users\\\\amos"), "path C:\\\\Users\\\\amos");
|
||||||
|
assert.equal(healOverescapedCommands("`$\\\\nabla$` in code"), "`$\\\\nabla$` in code");
|
||||||
|
assert.equal(
|
||||||
|
healOverescapedCommands("```\n$\\\\nabla$\n```"),
|
||||||
|
"```\n$\\\\nabla$\n```",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unbalanced spans pass through untouched (mid-stream safety)", () => {
|
||||||
|
assert.equal(healOverescapedCommands("$\\\\nabla"), "$\\\\nabla");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the raw doubled gradient-descent question renders the word-signature (the bug this heal exists for)", () => {
|
||||||
|
// The real session record (chat_mv7r02og, msg 3), expressed as the
|
||||||
|
// clean form with every backslash doubled — exactly what mimo emitted.
|
||||||
|
const raw = GD_QUESTION.replace(/\\/g, "\\\\");
|
||||||
|
const html = render(raw);
|
||||||
|
assert.ok(!html.includes("∇"), "doubled form does not produce the glyph");
|
||||||
|
assert.ok(html.includes("katex-error"), "doubled form errors in KaTeX");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the healed question renders ∇ and ⊤ with zero KaTeX errors", () => {
|
||||||
|
const html = render(healOverescapedCommands(GD_QUESTION.replace(/\\/g, "\\\\")));
|
||||||
|
assert.ok(html.includes("∇"), "nabla glyph renders");
|
||||||
|
assert.ok(html.includes("mathbb"), "mathbb R renders (double-struck class)");
|
||||||
|
assert.ok(html.includes("⊤"), "top renders");
|
||||||
|
assert.ok(!html.includes("katex-error"), "no KaTeX errors");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the healed inference-runtime question renders ∈ and ×", () => {
|
||||||
|
// The real session record (chat_mv8ybpvs, msg 7) — mimo doubled the
|
||||||
|
// whole field; clean form with every backslash doubled.
|
||||||
|
const ir =
|
||||||
|
"Let $A \\in \\mathbb{R}^{m \\times k}$ and $B \\in \\mathbb{R}^{k \\times n}$, and let $C = AB$ be their matrix product.";
|
||||||
|
const html = render(healOverescapedCommands(ir.replace(/\\/g, "\\\\")));
|
||||||
|
assert.ok(html.includes("∈"), "in renders");
|
||||||
|
assert.ok(html.includes("mathbb"), "mathbb renders (double-struck class)");
|
||||||
|
assert.ok(html.includes("×"), "times renders");
|
||||||
|
assert.ok(!html.includes("katex-error"), "no KaTeX errors");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -400,3 +400,81 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => {
|
|||||||
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: 42 })?.correctAnswer, "42");
|
assert.equal(parseExplainInput({ question: "q", explanation: "e", correctAnswer: 42 })?.correctAnswer, "42");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => {
|
||||||
|
// xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE
|
||||||
|
// LaTeX command backslashes inside JSON tool arguments — per-call and
|
||||||
|
// per-field (gradient-descent msg 3: question doubled, explanation and
|
||||||
|
// options in the SAME call clean). restoreMathEscapesDeep now rides
|
||||||
|
// healOverescapedCommands after the debris restore, so doubled spans
|
||||||
|
// heal at every display parse: live dock, settled cards, old records.
|
||||||
|
const CLEAN =
|
||||||
|
"Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?";
|
||||||
|
const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\");
|
||||||
|
const BASE = {
|
||||||
|
options: [
|
||||||
|
{ label: "$u^\\star = -\\nabla f(x)$", value: "neg" },
|
||||||
|
{ label: "$u^\\star = \\nabla f(x)$", value: "pos" },
|
||||||
|
],
|
||||||
|
correctAnswer: "neg",
|
||||||
|
explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => {
|
||||||
|
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) });
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.question, CLEAN);
|
||||||
|
// The CLEAN sibling fields of the same call stay byte-identical.
|
||||||
|
assert.equal(parsed.options[0]!.label, BASE.options[0]!.label);
|
||||||
|
assert.equal(parsed.explanation, BASE.explanation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => {
|
||||||
|
const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) });
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.question, CLEAN);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => {
|
||||||
|
const parsed = parseQuizInput({
|
||||||
|
question: "Let $A \\in \\mathbb{R}^{m \\times k}$ and $C = AB$.",
|
||||||
|
options: [
|
||||||
|
{ label: DOUBLE("$mkn$"), value: "mkn" },
|
||||||
|
{ label: DOUBLE("$2mkn$"), value: "2" },
|
||||||
|
],
|
||||||
|
correctAnswer: "mkn",
|
||||||
|
explanation: DOUBLE("$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"),
|
||||||
|
});
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.options[0]!.label, "$mkn$");
|
||||||
|
assert.equal(parsed.options[1]!.label, "$2mkn$");
|
||||||
|
assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parseAskInput heals doubled ask-option descriptions the same way", () => {
|
||||||
|
const parsed = parseAskInput({
|
||||||
|
question: "Depth?",
|
||||||
|
options: [
|
||||||
|
{ label: "Rigorous", description: DOUBLE("why $-\\nabla f$ is steepest descent") },
|
||||||
|
{ label: "Intuition" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clean records pass through byte-identical (no over-heal)", () => {
|
||||||
|
const parsed = parseQuizInput({ ...BASE, question: CLEAN });
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.question, CLEAN);
|
||||||
|
assert.equal(parsed.explanation, BASE.explanation);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legitimate row breaks inside matrices survive the seam", () => {
|
||||||
|
const matrix =
|
||||||
|
"Which matrix matches? $$\\begin{pmatrix} 1 \\ 2 \\\\ 3 \\ 4\\end{pmatrix}$$";
|
||||||
|
const parsed = parseQuizInput({ ...BASE, question: matrix });
|
||||||
|
assert.ok(parsed, "parses");
|
||||||
|
assert.equal(parsed.question, matrix);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user