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("");
|
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 { Value } from "typebox/value";
|
||||||
import type { TSchema } from "typebox";
|
import type { TSchema } from "typebox";
|
||||||
import { quizInputBase, askInputSchema, askOptionProblems, quizInputProblems, type AskToolInput, type QuizToolInput } from "./question-core.ts";
|
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";
|
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
|
||||||
|
|
||||||
/** The pi-boundary parse step, mirrored here (live-smoke fix, 2026-09-26):
|
/** 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
|
* never have been posed (e.g. `correctAnswer: undefined`, which Convert
|
||||||
* widens to "") renders as a quiet fallback, not a broken quiz.
|
* 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 {
|
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 (!cleaned) return undefined;
|
||||||
if (quizInputProblems(cleaned).length > 0) return undefined;
|
if (quizInputProblems(cleaned).length > 0) return undefined;
|
||||||
return cleaned;
|
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).
|
* keying, so they parse to undefined exactly like the zod seam did).
|
||||||
*/
|
*/
|
||||||
export function parseAskInput(input: unknown): AskToolInput | undefined {
|
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 (!cleaned) return undefined;
|
||||||
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
|
||||||
return cleaned;
|
return cleaned;
|
||||||
|
|||||||
@@ -152,4 +152,67 @@ describe("parseProbeDoneInput", () => {
|
|||||||
it("a mid-stream partial (no strands yet) does not parse", () => {
|
it("a mid-stream partial (no strands yet) does not parse", () => {
|
||||||
assert.equal(parseProbeDoneInput({ assessment: { summary: "…" } }), undefined);
|
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