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:
amosblomqvist
2026-09-27 02:19:27 +03:00
parent d6d7c09e2e
commit 8faf97edc7
3 changed files with 137 additions and 3 deletions
+53
View File
@@ -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
View File
@@ -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;
+64 -1
View File
@@ -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");
});
});