diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index d5bbf30..662c209 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -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 = { + "\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(""); +} diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index c2d86c5..3658c54 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -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(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 = {}; + 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(quizInputBase, input); + const cleaned = boundaryParse(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(askInputSchema, input); + const cleaned = boundaryParse(askInputSchema, restoreMathEscapesDeep(input)); if (!cleaned) return undefined; if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined; return cleaned; diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index 2dbd585..25ae9dc 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -152,4 +152,67 @@ describe("parseProbeDoneInput", () => { it("a mid-stream partial (no strands yet) does not parse", () => { assert.equal(parseProbeDoneInput({ assessment: { summary: "…" } }), undefined); }); -}); \ No newline at end of file +}); +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"); + }); +});