LaTeX-escape restoration at the display seam (owner ruling 2026-09-26): JSON-escape debris (FF ate \forall's f, VT \varphi's v, TAB \to's t) restored inside balanced math spans — one seam (tool-parts parse) heals new turns and poisoned records; outside spans untouched
This commit is contained in:
@@ -214,3 +214,56 @@ export function transformMathDelimiters(markdown: string): string {
|
||||
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* LaTeX-escape restoration (owner ruling 2026-09-26) — the JSON-debris fix.
|
||||
*
|
||||
* When a model emits LaTeX with SINGLE backslashes inside JSON tool-call
|
||||
* arguments (`\forall`, not the properly escaped `\\forall`), JSON escape
|
||||
* resolution eats the command's first letter: `\f` → form feed + "orall",
|
||||
* `\v` → vertical tab + "arphi", `\t` → tab + "o". The debris survives into
|
||||
* the persisted RAW args and renders as garbage. Inside a MATH SPAN these
|
||||
* control chars can never be legitimate content, so restoring the two-char
|
||||
* escape reconstructs the model's intent byte-perfectly. Outside spans the
|
||||
* same chars might be real prose (pasted tables, formatted text), so only
|
||||
* balanced `\(…\)` / `\[…\]` spans are touched — the same scanner rules as
|
||||
* transformMathDelimiters (escaped delimiters are literal, unbalanced
|
||||
* delimiters pass through untouched).
|
||||
*/
|
||||
const ESCAPE_DEBRIS: Record<string, string> = {
|
||||
"\b": "\\b",
|
||||
"\t": "\\t",
|
||||
"\n": "\\n",
|
||||
"\v": "\\v",
|
||||
"\f": "\\f",
|
||||
"\r": "\\r",
|
||||
};
|
||||
|
||||
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
|
||||
|
||||
export function restoreMathEscapes(text: string): string {
|
||||
if (!DEBRIS_RE.test(text)) return text;
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!;
|
||||
if (ch === "\\" && !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 + inner.replace(DEBRIS_RE, (c) => ESCAPE_DEBRIS[c]!) + close);
|
||||
i = closeAt + close.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(ch);
|
||||
i += 1;
|
||||
}
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
+20
-2
@@ -30,6 +30,7 @@
|
||||
import { Value } from "typebox/value";
|
||||
import type { TSchema } from "typebox";
|
||||
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts";
|
||||
import { restoreMathEscapes } from "./math-delims.ts";
|
||||
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||
|
||||
/** The pi-boundary parse step, mirrored here (live-smoke fix, 2026-09-26):
|
||||
@@ -65,8 +66,25 @@ function boundaryParse<T>(schema: TSchema, input: unknown): T | undefined {
|
||||
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
||||
* widens to "") renders as a quiet fallback, not a broken quiz.
|
||||
*/
|
||||
/** LaTeX-escape restoration, deep over every string field (the JSON-debris
|
||||
* fix, owner ruling 2026-09-26): records persist RAW call args, so
|
||||
* 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
|
||||
* display path. This is the ONE seam every rendered question passes
|
||||
* through — new turns and already-poisoned records alike heal here. */
|
||||
function restoreMathEscapesDeep(value: unknown): unknown {
|
||||
if (typeof value === "string") return restoreMathEscapes(value);
|
||||
if (Array.isArray(value)) return value.map(restoreMathEscapesDeep);
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value)) out[k] = restoreMathEscapesDeep(v);
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, input);
|
||||
const cleaned = boundaryParse<QuizToolInput>(quizInputBase, restoreMathEscapesDeep(input));
|
||||
if (!cleaned) return undefined;
|
||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||
return cleaned;
|
||||
@@ -79,7 +97,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
|
||||
* keying, so they parse to undefined exactly like the zod seam did).
|
||||
*/
|
||||
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
|
||||
const cleaned = boundaryParse<AskToolInput>(askInputSchema, restoreMathEscapesDeep(input));
|
||||
if (!cleaned) return undefined;
|
||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||
return cleaned;
|
||||
|
||||
@@ -152,4 +152,67 @@ describe("parseProbeDoneInput", () => {
|
||||
it("a mid-stream partial (no strands yet) does not parse", () => {
|
||||
assert.equal(parseProbeDoneInput({ assessment: { summary: "…" } }), undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26)", () => {
|
||||
// The exact live shapes from the modal-logic session: single-backslash
|
||||
// LaTeX inside JSON args — JSON escape resolution ate the command's
|
||||
// first letter (\f → FF ate 'f' of \forall, \v → VT ate 'v' of \varphi).
|
||||
it("parseQuizInput restores escape debris inside math spans (question + options + explanation)", () => {
|
||||
const parsed = parseQuizInput({
|
||||
question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?",
|
||||
options: [
|
||||
{ label: "\\(\x0barphi\\) is true when every element satisfies it", value: "tarski" },
|
||||
{ label: "\\(\x0corall\\) and \\(\\exists\\) are just shorthand", value: "shorthand" },
|
||||
],
|
||||
correctAnswer: "tarski",
|
||||
explanation: "\\(\x0corall\\) quantifies over the domain.",
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.question, "What does \\(\\forall x\\, \\varphi(x)\\) mean?");
|
||||
assert.equal(parsed.options[0]!.label, "\\(\\varphi\\) is true when every element satisfies it");
|
||||
assert.equal(parsed.options[1]!.label, "\\(\\forall\\) and \\(\\exists\\) are just shorthand");
|
||||
assert.equal(parsed.explanation, "\\(\\forall\\) quantifies over the domain.");
|
||||
});
|
||||
|
||||
it("parseAskInput restores debris in ask option labels too", () => {
|
||||
const parsed = parseAskInput({
|
||||
question: "Goal?",
|
||||
options: [{ label: "\\(\x0corall\\) rigor" }, { label: "Intuition first" }],
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.options![0]!.label, "\\(\\forall\\) rigor");
|
||||
});
|
||||
|
||||
it("restores TAB debris (the \\to case) inside spans only", () => {
|
||||
const parsed = parseQuizInput({
|
||||
question: "Is \\(p\to q\\) valid?",
|
||||
options: [{ label: "yes" }, { label: "no" }],
|
||||
correctAnswer: "no",
|
||||
explanation: "",
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.question, "Is \\(p\\to q\\) valid?");
|
||||
});
|
||||
|
||||
it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => {
|
||||
const parsed = parseQuizInput({
|
||||
question: "Pick:\ta\x0bb",
|
||||
options: [{ label: "x" }, { label: "y" }],
|
||||
correctAnswer: "x",
|
||||
explanation: "",
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.question, "Pick:\ta\x0bb");
|
||||
});
|
||||
|
||||
it("unbalanced delimiters pass through untouched (mid-stream safety)", () => {
|
||||
const parsed = parseQuizInput({
|
||||
question: "\\(\\x0corall",
|
||||
options: [{ label: "x" }, { label: "y" }],
|
||||
correctAnswer: "x",
|
||||
explanation: "",
|
||||
});
|
||||
assert.ok(parsed);
|
||||
assert.equal(parsed.question, "\\(\\x0corall");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user