Merge source-preserving math content presentation

This commit is contained in:
amosblomqvist
2026-10-26 05:26:13 +02:00
21 changed files with 1134 additions and 652 deletions
+57
View File
@@ -0,0 +1,57 @@
/** Read-only provenance for historical question cards. Never persist this
* projection: answer identities, ordering, grading, and learner text remain
* the recorded facts; only model-authored display text comes from the call. */
import { normalizeOptions, type QuestionAnswerData } from "./question-core.ts";
import { parseAskInput, parseExplainInput, parseQuizInput } from "./tool-parts.ts";
import { repairMathForDisplay } from "./math-delims.ts";
export function answerForPresentation(answer: QuestionAnswerData, input: unknown): QuestionAnswerData {
if (answer.dismissed) return answer;
const quiz = answer.kind === "quiz" ? parseQuizInput(input) : undefined;
const explain = answer.kind === "explain" ? parseExplainInput(input) : undefined;
const source = quiz ?? explain ?? (answer.kind === "ask" ? parseAskInput(input) : undefined);
if (!source) return answer;
const projected = { ...answer, questionText: source.question };
if (quiz) projected.explanation = quiz.explanation;
if (explain) projected.committed = explain.correctAnswer;
if (!answer.options?.length || !("options" in source) || !source.options?.length) return projected;
const original = normalizeOptions(source.options);
const saved = answer.options;
const bindings = new Map<number, number>(); // saved position -> original position
const used = new Set<number>();
const bindUnique = (candidates: number[][]) => {
for (let si = 0; si < saved.length; si++) {
if (bindings.has(si)) continue;
const possible = candidates[si]!.filter((oi) => !used.has(oi));
if (possible.length !== 1) continue;
const oi = possible[0]!;
// Two saved rows claiming the same source are ambiguous, even if
// each row independently has just one candidate.
if (candidates.some((other, sj) => sj !== si && !bindings.has(sj) && other.includes(oi))) continue;
bindings.set(si, oi);
used.add(oi);
}
};
// Selected-answer values are stable identities when the record retained
// them. Do not normalize those values, recompute a shuffle, or regrade.
bindUnique(saved.map((option) => {
const values = answer.answers.filter((a) => a.index === option.index).map((a) => a.value);
return original.flatMap((o, oi) => values.includes(o.value) ? [oi] : []);
}));
const recoveredLabels = original.map((o) => repairMathForDisplay(o.label));
bindUnique(saved.map((option) => original.flatMap((o, oi) =>
o.label === option.label || recoveredLabels[oi] === option.label ? [oi] : [])));
// A bijection with one remaining pair has no ordering ambiguity. With
// multiple unmatched rows, keep their snapshots rather than guess.
if (saved.length === original.length && bindings.size === saved.length - 1) {
const si = saved.findIndex((_, i) => !bindings.has(i));
const oi = original.findIndex((_, i) => !used.has(i));
bindings.set(si, oi);
}
projected.options = saved.map((option, si) => {
const oi = bindings.get(si);
return oi === undefined ? option : { ...option, label: original[oi]!.label };
});
return projected;
}
+215
View File
@@ -0,0 +1,215 @@
/** Source-preserving boundaries for tool-authored Markdown. Recovery never
* mutates input, reserializes Markdown, or enters protected literal content. */
import katex from "katex";
import { fromMarkdown } from "mdast-util-from-markdown";
import { mathFromMarkdown } from "mdast-util-math";
import { math } from "micromark-extension-math";
export type ContentSpan =
| { kind: "prose" | "code"; raw: string }
| { kind: "math"; raw: string; open: string; body: string; close: string; display: boolean };
const blankLine = /^\r?\n[ \t]*\r?\n/;
const escaped = (text: string, i: number) => {
let n = 0; while (i > 0 && text[--i] === "\\") n++;
return n % 2 === 1;
};
const runLength = (text: string, i: number, char: string) => { let n = 0; while (text[i + n] === char) n++; return n; };
type MarkdownNode = { type: string; position?: { start: { offset?: number }; end: { offset?: number } }; children?: MarkdownNode[] };
/** Use the rendering stack's actual CommonMark+math grammar for literal
* ranges, including nested quotes/lists and implicit container endings.
* Keep source offsets: never serialize the AST back into Markdown. */
function literalRanges(text: string): Array<{ start: number; end: number }> {
const pending: MarkdownNode[] = [fromMarkdown(text, { extensions: [math({ singleDollarTextMath: true })], mdastExtensions: [mathFromMarkdown()] })];
const ranges: Array<{ start: number; end: number }> = [];
while (pending.length) {
const node = pending.pop()!;
if (node.type === "code" || node.type === "inlineCode") {
const start = node.position?.start.offset, end = node.position?.end.offset;
if (start !== undefined && end !== undefined) ranges.push({ start, end });
} else if (node.children) pending.push(...node.children);
}
return ranges.sort((a, b) => a.start - b.start);
}
/** Mirrors the app's native dollar-run and legacy-delimiter conventions,
* with code and blank paragraphs as inline boundaries. */
export function readContentSpans(text: string): ContentSpan[] {
const spans: ContentSpan[] = [];
const literals = literalRanges(text);
let literal = 0, i = 0, plain = 0;
const opaque = (end: number) => {
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
spans.push({ kind: "code", raw: text.slice(i, end) }); i = plain = end;
};
while (i < text.length) {
while (literal < literals.length && literals[literal]!.end <= i) literal++;
const barrier = literals[literal]?.start ?? Infinity;
if (i >= barrier) { opaque(literals[literal]!.end); continue; }
let open = "", close = "", display = false, after = -1, at = -1;
if (text[i] === "\\" && !escaped(text, i) && ["(", "["].includes(text[i + 1] ?? "")) {
open = text.slice(i, i + 2); close = open === "\\(" ? "\\)" : "\\]"; display = open === "\\[";
for (let j = i + 2; j < text.length - 1; j++) {
if (j >= barrier || (!display && blankLine.test(text.slice(j)))) break;
if (text.startsWith(close, j) && !escaped(text, j)) { at = j; after = j + 2; break; }
}
} else if (text[i] === "$" && !escaped(text, i)) {
const size = runLength(text, i, "$"); open = "$".repeat(size);
const eol = text.indexOf("\n", i + size);
const before = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
display = size >= 2 && /^[ \t]{0,3}$/.test(before) && !text.slice(i + size, eol < 0 ? text.length : eol).includes("$");
if (display) {
let start = eol;
while (start >= 0 && start < text.length) {
start++;
const next = text.indexOf("\n", start);
const match = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(text.slice(start, next < 0 ? text.length : next));
if (match && match[2]!.length >= size) { at = start + match[1]!.length; close = match[2]!; after = at + close.length; break; }
start = next;
}
} else {
for (let j = i + size; j < text.length;) {
if (j >= barrier || blankLine.test(text.slice(j))) break;
if (text[j] === "$" && !escaped(text, j)) {
const n = runLength(text, j, "$");
if (n === size) { at = j; close = open; after = j + n; break; }
j += n;
} else j++;
}
}
}
if (at >= 0) {
if (plain < i) spans.push({ kind: "prose", raw: text.slice(plain, i) });
spans.push({ kind: "math", raw: text.slice(i, after), open, body: text.slice(i + open.length, at), close, display });
i = plain = after;
} else i += open.length || 1;
}
if (plain < text.length) spans.push({ kind: "prose", raw: text.slice(plain) });
return spans;
}
export function mapMathContent(text: string, transform: (body: string, display: boolean) => string): string {
return readContentSpans(text).map(span => span.kind === "math" ? span.open + transform(span.body, span.display) + span.close : span.raw).join("");
}
const knownCommands = new Map<string, boolean>();
/** Probe only a command name, never arbitrary content. Missing-argument errors
* still identify a known command. No trust, macros, IO, or persistent TeX state. */
export function knownTexCommand(name: string): boolean {
if (!/^[A-Za-z]+$/.test(name)) return false;
const cached = knownCommands.get(name); if (cached !== undefined) return cached;
let known = true;
try { katex.renderToString("\\" + name, { throwOnError: true, trust: false, strict: false }); }
catch (error) { known = !String(error).includes("Undefined control sequence: \\" + name); }
knownCommands.set(name, known);
return known;
}
export function canParseTex(body: string, display: boolean): boolean {
try { katex.renderToString(body, { throwOnError: true, trust: false, strict: false, displayMode: display }); return true; }
catch { return false; }
}
export interface TexRange { start: number; end: number; kind: "row" | "encoded" | "comment"; factor?: number }
export function numericPercentEscape(inner: string, percent: number): boolean {
let start = percent; while (start > 0 && inner[start - 1] === "\\") start--;
if (percent - start < 2 || (percent - start) % 2 !== 0 || !/\d[ \t]*$/.test(inner.slice(0, start))) return false;
const tail = inner.slice(percent + 1);
// A final source-formatting LF before the closing fence is not a next
// equation row. A meaningful following line or comment text remains literal.
if (/^[\s.)\]}]*$/.test(tail)) return true;
return !/[\r\n]/.test(tail) && !/^[ \t]*[A-Za-z]/.test(tail);
}
/** TeX boundaries shared by document decoding and command recovery. Native
* rows are literal; consistently extra-escaped environment pairs supply an
* explicit escaping factor for both commands AND row separators. */
export function texRanges(inner: string): TexRange[] {
const ranges: TexRange[] = [];
const stack: Array<{ name: string; start: number; factor: number }> = [];
let i = 0;
while (i < inner.length) {
if (inner[i] === "%" && !escaped(inner, i)) {
let start = i; while (start > 0 && inner[start - 1] === "\\") start--;
const factor = stack.at(-1)?.factor ?? ranges.filter(r => r.kind === "encoded" && i >= r.start && i < r.end).at(-1)?.factor;
const encodedLiteral = factor && factor > 1 && (i - start) % factor === 0 && ((i - start) / factor) % 2 === 1;
const native = stack.some(frame => frame.factor === 1) || ranges.some(r => r.kind === "row" && i >= r.start && i < r.end);
if (encodedLiteral || (!native && numericPercentEscape(inner, i))) { i++; continue; }
const newline = inner.indexOf("\n", i);
const end = newline < 0 ? inner.length : newline + 1;
ranges.push({ start: i, end, kind: "comment" }); i = end; continue;
}
if (inner[i] !== "\\") { i++; continue; }
let end = i + 1; while (inner[end] === "\\") end++;
const count = end - i;
const marker = /^(begin|end)\s*\{([^{}]+)\}/.exec(inner.slice(end));
if (marker) {
const factor = count % 2 === 1 ? 1 : count;
if (marker[1] === "begin") stack.push({ name: marker[2]!, start: i, factor });
else if (stack.at(-1)?.name === marker[2] && stack.at(-1)?.factor === factor) {
const open = stack.pop()!;
ranges.push({ start: open.start, end: end + marker[0].length, kind: factor === 1 ? "row" : "encoded", factor });
}
i = end + marker[0].length; continue;
}
// KaTeX's substack macro expands its braced argument into subarray:
// these are row separators even without literal begin/end markers.
const substack = /^substack\s*\{/.exec(inner.slice(end));
if (substack) {
let depth = 1, j = end + substack[0].length;
for (; j < inner.length && depth > 0; j++) {
if (inner[j] === "%" && !escaped(inner, j)) { const lf = inner.indexOf("\n", j); j = lf < 0 ? inner.length : lf; continue; }
if (!escaped(inner, j)) { if (inner[j] === "{") depth++; else if (inner[j] === "}") depth--; }
}
if (depth === 0) ranges.push({ start: i, end: j, kind: count % 2 === 1 ? "row" : "encoded", factor: count % 2 === 1 ? 1 : count });
}
i = end;
}
return ranges.sort((a, b) => a.start - b.start);
}
export function outsideTexComments(body: string, transform: (part: string) => string): string {
let from = 0; const out: string[] = [];
for (const range of texRanges(body).filter(r => r.kind === "comment")) {
out.push(transform(body.slice(from, range.start)), body.slice(range.start, range.end)); from = range.end;
}
out.push(transform(body.slice(from))); return out.join("");
}
/** Document escapes are not arbitrary string decoding. Complete known TeX
* words win over the prefix `\\n`; code is never inspected or rewritten. */
export function recoverDocumentLayout(text: string): string {
const spans = readContentSpans(text);
const candidates = spans.filter(span => span.kind !== "code").map(span => span.raw).join("\u0000");
const declared = new Set([
...candidates.matchAll(/\\(?:newcommand|renewcommand|providecommand)\*?\s*(?:\{\s*)?\\(n[A-Za-z]+)/g),
...candidates.matchAll(/\\(?:def|gdef|edef)\s*\\(n[A-Za-z]+)/g),
].map(m => m[1]!));
const knownN = (name: string) => declared.has(name) || knownTexCommand(name);
const tokens = [...candidates.matchAll(/(\\+)(n[A-Za-z]*)/g)];
const evidence = tokens.filter(m => m[1]!.length % 2 === 1 && !knownN(m[2]!));
const structural = evidence.length >= 2 || candidates.includes("\\n\\n") || (evidence.length > 0 &&
[...candidates.matchAll(/(\\+)([A-Za-z]+)/g)].some(m => m[1]!.length % 2 === 0 && knownTexCommand(m[2]!)));
if (!structural) return text;
const decode = (part: string) => part.replace(/(\\+)(n[A-Za-z]*)/g, (whole: string, slashes: string, name: string, offset: number) => {
if (slashes.length % 2 === 0 || knownN(name)) return whole;
const word = part.slice(0, offset).split(/\s/).at(-1) ?? "";
if (/^(?:[A-Za-z]:[\\/]|https?:\/\/|\/)/.test(word + whole)) return whole;
return slashes.slice(0, -1) + "\n" + name.slice(1);
});
const projected = spans.map(span => ({ span, body: span.kind === "math" ? outsideTexComments(span.body, decode) : span.kind === "code" ? span.raw : decode(span.raw) }));
let out = "";
for (let i = 0; i < projected.length; i++) {
const { span, body } = projected[i]!;
if (span.kind !== "math") { out += body; continue; }
// Decoding document layout can expose a flow opener whose close was
// written on the formula line. Frame BOTH ends; otherwise Markdown
// consumes the closing dollars as TeX and introduces a rendering error.
if (/^\${2,}$/.test(span.open) && body !== span.body && body.includes("\n")) {
if (!/^[ \t]{0,3}$/.test(out.slice(out.lastIndexOf("\n") + 1))) out += "\n\n";
out += span.open + (body.startsWith("\n") ? "" : "\n") + body + (body.endsWith("\n") ? "" : "\n") + span.close;
const next = projected[i + 1];
if (next && !/^[\r\n]/.test(next.span.kind === "math" ? next.span.open : next.body)) out += "\n\n";
} else out += span.open + body + span.close;
}
return out;
}
+1
View File
@@ -47,6 +47,7 @@ export * from "./input-events.ts";
// the deleted server projection to the client side. Pure, browser-safe.
export * from "./view.ts";
export * from "./tool-parts.ts";
export * from "./answer-presentation.ts";
export * from "./prices.ts";
export * from "./context-usage.ts";
export type { ChatGPTStatus, ChatGPTControlBridge } from "./contracts/chatgpt.ts";
+107 -558
View File
@@ -1,572 +1,121 @@
/**
* Legacy math delimiter preprocessing — the \(...\)/\[...\] reader.
*
* Current authoring uses `$...$` inline and `$$...$$` on separate lines for
* display (owner, 2026-10-12). This pure transform keeps correctly formed
* old backslash-delimited content readable; dollar math passes through to
* Streamdown's native parser. Escape restoration below recognizes both
* syntaxes while retaining the existing recovery rules.
*
* Rules:
* - `\(...\)` → `$$...$$` (two-dollar inline, still accepted by micromark),
* `\[...\]` → `$$\n...\n$$` (block form: flow math).
* - An escaped delimiter (`\\(` = literal backslash + paren) is left alone.
* - Nothing inside fenced code blocks or inline code spans is touched.
* - Unbalanced delimiters are left untouched: Streamdown streams chunks, and
* an incomplete `\[` completes on a later chunk (the whole text is
* re-transformed each render).
*
* Pure string → string. No markdown parsing — a single linear scan with a
* code-state machine, cheap enough to run in the render path of a memo'd
* component.
*/
/** Shared Markdown/TeX boundaries for legacy delimiter compatibility and
* display-only tool-content recovery. Source arguments are never rewritten. */
import {
canParseTex, knownTexCommand, mapMathContent, numericPercentEscape, outsideTexComments,
readContentSpans, recoverDocumentLayout, texRanges,
} from "./content-reader.ts";
const INLINE_OPEN = "\\(";
const INLINE_CLOSE = "\\)";
const DISPLAY_OPEN = "\\[";
const DISPLAY_CLOSE = "\\]";
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})/;
/**
* Is the backslash at `i` itself escaped (i.e. preceded by an odd number of
* backslashes)? If so, the `\(`/`\[` starting there is a literal backslash
* followed by a bracket — not a delimiter.
*/
function isEscaped(text: string, i: number): boolean {
let count = 0;
for (let j = i - 1; j >= 0 && text[j] === "\\"; j--) count += 1;
return count % 2 === 1;
}
/** Length of the backtick run starting at `i` (0 if `text[i]` is not a tick). */
function backtickRun(text: string, i: number): number {
let count = 0;
while (text[i + count] === "`") count += 1;
return count;
}
/**
* Try to find the closing delimiter for the opening one at `i` (2 chars,
* already verified unescaped). The close scan stops at inline code spans and
* fence starts (barriers — math never contains them), and returns -1 when the
* delimiter is unbalanced (mid-stream chunk).
*/
function findClose(
text: string,
from: number,
close: string,
): number {
for (let j = from; j < text.length - 1; j++) {
if (text[j] === "`") return -1; // inline code barrier
if (
text[j] === "\\" &&
text[j + 1] === close[1] &&
!isEscaped(text, j)
) {
return j;
}
}
return -1;
}
/** True when `i` sits at the start of a line (position 0 or right after \n). */
function atLineStart(text: string, i: number): boolean {
return i === 0 || text[i - 1] === "\n";
}
/**
* Copy a fenced code block starting at `i` (line start, fence regex matched)
* verbatim to the output. Returns the index after the closing fence line —
* or the end of the text when the fence is unclosed (mid-stream: everything
* after is literal code until the chunk completes).
*/
function copyFencedBlock(text: string, i: number, out: string[]): number {
const open = FENCE_OPEN_RE.exec(text.slice(i));
const marker = open?.[1] ?? "```";
const closeRe = new RegExp(`^ {0,3}\\${marker[0]}{${marker.length},}[ \\t]*$`);
let j = i;
// Copy lines until the matching closing fence.
while (j < text.length) {
const lineEnd = text.indexOf("\n", j);
const next = lineEnd === -1 ? text.length : lineEnd + 1;
const line = text.slice(j, lineEnd === -1 ? text.length : lineEnd);
out.push(text.slice(j, next));
if (j > i && closeRe.test(line)) return next; // closing fence line
j = next;
}
return text.length;
}
/** Is the `\[` at `i` block-positioned (own line, only whitespace before)? */
function displayIsBlockPositioned(text: string, i: number): boolean {
for (let j = i - 1; j >= 0; j--) {
const ch = text[j];
if (ch === "\n") return true;
if (ch !== " " && ch !== "\t") return false;
}
return true;
}
/** Is the `\[` at `i` indented (whitespace before it on the line)? */
function displayIsIndented(text: string, i: number): boolean {
let sawWhitespace = false;
for (let j = i - 1; j >= 0; j--) {
const ch = text[j];
if (ch === "\n") return sawWhitespace;
if (ch !== " " && ch !== "\t") return false; // mid-paragraph
sawWhitespace = true;
}
return sawWhitespace;
}
/** Does the closing delimiter end its line (only \n or EOF after it)? */
function closeEndsLine(text: string, afterClose: number): boolean {
return afterClose >= text.length || text[afterClose] === "\n";
}
/**
* Rewrite balanced `\(...\)` → `$$...$$` and `\[...\]` → `$$\n...\n$$`.
* Everything else passes through byte-for-byte.
*/
/** The escaped-dollar-aware inline closer (owner, 2026-10-12): micromark's
* mathText closer is code-span logic — ANY size-matched `$` run closes,
* `\$` included, so `$x = \$5$` closes at the `\$` and errors. KaTeX's
* own auto-render scanner skips escaped dollars, and KaTeX renders `\$`
* fine — the author's intent is unambiguous: an escaped dollar (odd number
* of backslashes before it) never closes the span. Mirrors findDollarClose's
* inline rules otherwise (exact size match, whole-run skip). */
function findInlineDollarCloseEscapeAware(text: string, from: number): number {
for (let j = from; j < text.length;) {
if (text[j] !== "$") {
j += 1;
continue;
}
if (isEscaped(text, j)) {
j += 1;
continue; // `\$` never closes
}
const count = dollarRun(text, j);
if (count === 1) return j;
j += count;
}
return -1;
}
/** Replace `\$` (odd-backslash dollars) inside a math span with the
* dollar-free `\text{\textdollar}` — KaTeX-safe in math mode (bare
* `\textdollar` is text-mode-only there). Even-backslash dollars (`\\$`,
* row break + dollar) are left alone. */
function replaceEscapedDollars(content: string): string {
let replaced = "";
for (let j = 0; j < content.length;) {
if (content[j] === "\\" && content[j + 1] === "$" && !isEscaped(content, j)) {
replaced += "\\text{\\textdollar}";
j += 2;
} else {
replaced += content[j];
j += 1;
}
}
return replaced;
}
export function transformMathDelimiters(markdown: string): string {
const out: string[] = [];
let i = 0;
while (i < markdown.length) {
const ch = markdown[i]!;
// Fenced code blocks: copied verbatim (only detectable at line start).
if (atLineStart(markdown, i) && FENCE_OPEN_RE.test(markdown.slice(i))) {
i = copyFencedBlock(markdown, i, out);
continue;
}
// Inline code spans: copied verbatim.
if (ch === "`") {
const run = backtickRun(markdown, i);
let j = i + run;
// Find the matching run (same length, per CommonMark).
while (j < markdown.length) {
const nextRun = backtickRun(markdown, j);
if (nextRun === run) break;
j += Math.max(nextRun, 1);
}
if (j < markdown.length) {
out.push(markdown.slice(i, j + run));
i = j + run;
} else {
// Unclosed run (mid-stream): literal, keep scanning after it.
out.push(markdown.slice(i, i + run));
i += run;
}
continue;
}
// Math delimiters.
if (ch === "\\" && !isEscaped(markdown, i)) {
const next = markdown[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(markdown, i + open.length, close);
if (closeAt !== -1) {
const content = markdown.slice(i + open.length, closeAt).trim();
if (content.length > 0) {
if (isDisplay && displayIsIndented(markdown, i)) {
// Indented display math (inside a list item, quote, …):
// an indented $$ flow block half-parses inside list
// continuations (empty math, eaten items), so degrade to
// the inline form — renders inline, stays inside the item.
// mathText cannot span line endings, so collapse them.
out.push(`$$${content.replace(/\s*\n\s*/g, " ")}$$`);
} else if (isDisplay) {
// Flow math ($$ alone on its lines) renders as display
// KaTeX. Mid-paragraph \[...\] (or trailing text after the
// closer) would half-parse into literal dollars + KaTeX
// errors — pad with blank lines to force a clean block.
// Block-positioned math passes through in place.
const afterClose = closeAt + close.length;
const padBefore =
displayIsBlockPositioned(markdown, i) ? "" : "\n\n";
const padAfter =
closeEndsLine(markdown, afterClose) ? "" : "\n\n";
out.push(`${padBefore}$$\n${content}\n$$${padAfter}`);
} else {
// Preserve the legacy reader's two-dollar inline output
// (renders inline, not display).
out.push(`$$${content}$$`);
}
i = closeAt + close.length;
continue;
}
}
// Unbalanced or empty: leave both characters untouched.
}
}
// Escaped dollars inside inline math (owner, 2026-10-12): normalize
// BEFORE tokenization — inside a balanced inline span, `\$` becomes the
// dollar-free `\text{\textdollar}` so the scanner's closer is the one
// the author meant (micromark would otherwise close at the `\$`, error
// out, and cascade). Display runs are copied whole and left untouched —
// a lone `$` can't close `$$`, so `\$` already survives there — and
// copying the run stops its second `$` posing as an inline opener.
// Spans without escaped dollars emit byte-identical text; prose `\$`,
// code, and unbalanced spans are untouched.
if (ch === "$" && !isEscaped(markdown, i)) {
const run = dollarRun(markdown, i);
if (run === 1) {
const closeAt = findInlineDollarCloseEscapeAware(markdown, i + 1);
if (closeAt !== -1) {
const content = markdown.slice(i + 1, closeAt);
const replaced = replaceEscapedDollars(content);
if (replaced !== content) {
out.push(`$${replaced}$`);
i = closeAt + 1;
continue;
}
}
} else {
out.push(markdown.slice(i, i + run));
i += run;
continue;
}
}
out.push(ch);
i += 1;
}
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 math spans are touched. The legacy `\(…\)` / `\[…\]` pass stays
* unchanged; the dollar reader adds `$…$` and fenced `$$…$$` recognition,
* excluding code and escaped opening dollars. Unbalanced spans pass through.
*
* ASYMMETRIC NEWLINE RULE (owner ruling 2026-09-27, Option A): inline spans
* restore all six debris chars — real newlines/tabs inside INLINE math
* essentially never occur (options and inline math are single-line by
* nature). Display spans restore five and KEEP real newlines — multi-line
* display math (aligned environments, equation systems) is common, and a
* real line break is byte-identical on the wire to sloppy `\neq`/`\nu`/
* `\neg`/`\nabla` (JSON `\n` resolves to a newline either way). Corrupting
* legitimate content is strictly worse than failing to repair that rarest
* slice, so display-span n-command sloppiness stays broken by design.
*/
const ESCAPE_DEBRIS: Record<string, string> = {
"\b": "\\b",
"\t": "\\t",
"\n": "\\n",
"\v": "\\v",
"\f": "\\f",
"\r": "\\r",
const escaped = (text: string, i: number) => {
let n = 0; while (i > 0 && text[--i] === "\\") n++;
return n % 2 === 1;
};
const DEBRIS_RE = /[\b\t\n\v\f\r]/;
/** Display spans keep real newlines (multi-line equations) — debris there is
* everything except \n (a lone real \r never occurs in authored text, and
* `\right`/`\rho`/`\rangle` corruption is common in display math). */
const DISPLAY_DEBRIS_RE = /[\b\t\v\f\r]/;
/** Shared display-only repair. Keep raw arguments and model history unchanged. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(text));
/** Preserve the legacy list-continuation policy: indented display math
* degrades to inline rather than disrupting list structure. Literal code
* indentation is already protected by the shared reader. */
function indented(text: string, i: number): boolean {
const prefix = text.slice(text.lastIndexOf("\n", i - 1) + 1, i);
return prefix.length > 0 && /^[ \t]+$/.test(prefix);
}
function blockPosition(text: string, i: number): boolean {
return /^[ \t]*$/.test(text.slice(text.lastIndexOf("\n", i - 1) + 1, i));
}
/** micromark closes native single-dollar spans at escaped dollars too.
* Replace an intended literal dollar with equivalent dollar-free TeX before
* tokenization. Code, prose, unbalanced spans and display dollars stay raw. */
function replaceEscapedDollars(content: string): string {
let out = "";
for (let i = 0; i < content.length; i++) {
if (content[i] === "\\" && content[i + 1] === "$" && !escaped(content, i)) {
out += "\\text{\\textdollar}"; i++;
} else out += content[i];
}
return out;
}
/** Read old \(...\)/\[...\] syntax without touching literal content or
* reparsing native math as Markdown. Dollar authoring remains native. */
export function transformMathDelimiters(markdown: string): string {
let offset = 0;
return readContentSpans(markdown).map(span => {
const start = offset; offset += span.raw.length;
if (span.kind !== "math") return span.raw;
if (span.open === "$") return "$" + replaceEscapedDollars(span.body) + span.close;
if (span.open !== "\\(" && span.open !== "\\[") return span.raw;
const body = span.body.trim();
if (!body) return span.raw;
if (span.open === "\\(") return `$$${body}$$`;
if (indented(markdown, start)) return `$$${body.replace(/\s*\n\s*/g, " ")}$$`;
const before = blockPosition(markdown, start) ? "" : "\n\n";
const end = start + span.raw.length;
const after = end === markdown.length || markdown[end] === "\n" ? "" : "\n\n";
return `${before}$$\n${body}\n$$${after}`;
}).join("");
}
const DEBRIS: Record<string, string> = {
"\b": "\\b", "\t": "\\t", "\v": "\\v", "\f": "\\f", "\r": "\\r",
};
/** Recover JSON-swallowed command prefixes only in recognized math, never
* in code or genuine TeX comments. Restore all debris in one pass. Physical
* display newlines and CRLF remain source whitespace. Inline LF recovery is
* limited to complete known n-command names. Under the approved typical-
* model-authoring policy this includes newline+u→nu in inline math only;
* already-correct commands are never decoded into whitespace. */
export function restoreMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text)) return text;
return restoreDollarMathEscapes(restoreLegacyMathEscapes(text));
if (!/[\b\t\n\v\f\r]/.test(text)) return text;
return mapMathContent(text, (body, display) => outsideTexComments(body, part => {
const restored = part.replace(/[\b\t\v\f\r]/g, (char, offset: number) => {
if (char === "\r" && part[offset + 1] === "\n") return char;
const suffix = /^[A-Za-z]+/.exec(part.slice(offset + 1))?.[0];
return suffix && knownTexCommand(DEBRIS[char]!.slice(1) + suffix) ? DEBRIS[char]! : char;
});
if (display) return restored;
return restored.replace(/\n([A-Za-z]+)/g, (whole, suffix: string) =>
knownTexCommand("n" + suffix) ? "\\n" + suffix : whole);
}));
}
/** Keep legacy recovery byte-for-byte; only the recognized syntax expands. */
function restoreLegacyMathEscapes(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);
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
out.push(open + inner.replace(debris, (c) => ESCAPE_DEBRIS[c]!) + close);
i = closeAt + close.length;
continue;
}
}
}
out.push(ch);
i += 1;
}
return out.join("");
}
const commandSymbol = /[,;:!|{}%]/;
function dollarRun(text: string, i: number): number {
let count = 0;
while (text[i + count] === "$") count += 1;
return count;
}
/** Native math fences start at a line's block position; one-line $$…$$
* remains inline. A flow opener may have metadata, but never another $. */
function dollarIsDisplay(text: string, i: number, size: number): boolean {
if (size < 2 || !displayIsBlockPositioned(text, i)) return false;
const end = text.indexOf("\n", i + size);
return !text.slice(i + size, end === -1 ? text.length : end).includes("$");
}
/** Match micromark's delimiter runs: exact size inline, at least the
* opener's size on an otherwise-empty line for display. Inside math,
* backslashes are TeX data, not Markdown escapes for closing dollars. */
function findDollarClose(
text: string,
from: number,
size: number,
isDisplay: boolean,
): { at: number; after: number } | undefined {
if (isDisplay) {
let lineEnd = text.indexOf("\n", from);
while (lineEnd !== -1) {
const start = lineEnd + 1;
lineEnd = text.indexOf("\n", start);
const line = text.slice(start, lineEnd === -1 ? text.length : lineEnd);
const close = /^([ \t]{0,3})(\$+)[ \t]*\r?$/.exec(line);
if (close && close[2]!.length >= size) {
const at = start + close[1]!.length;
return { at, after: at + close[2]!.length };
}
/** Decode prefixes and row separators according to ORIGINAL TeX context.
* A native pair is literal; an encoded pair supplies a factor, so an encoded
* four-backslash row becomes TWO, never a command prefix. Unknown commands
* without that structural evidence are left alone. */
function recoverCommandRuns(inner: string, display: boolean): string {
const ranges = texRanges(inner);
const recovered = inner.replace(/\\+/g, (run: string, offset: number) => {
const contexts = ranges.filter(r => offset >= r.start && offset < r.end);
if (contexts.some(r => r.kind === "row" || r.kind === "comment") || run.length % 2 !== 0) return run;
const next = inner[offset + run.length] ?? "";
const encoded = contexts.filter(r => r.kind === "encoded").at(-1);
const word = /^[A-Za-z]+/.exec(inner.slice(offset + run.length))?.[0];
if (encoded) {
const factor = encoded.factor!;
const rows = run.length % (2 * factor) === 0;
const command = word ? knownTexCommand(word) : commandSymbol.test(next);
if (run.length % factor === 0 && (rows || command)) return "\\".repeat(run.length / factor);
return run;
}
return undefined;
}
for (let j = from; j < text.length;) {
if (text[j] !== "$") {
j += 1;
continue;
}
const count = dollarRun(text, j);
if (count === size) return { at: j, after: j + count };
j += count;
}
return undefined;
}
/** Dollar recognition only — the same six/five-character repair maps,
* with no over-escaping normalization or mathematical validation. */
function restoreDollarMathEscapes(text: string): string {
if (!DEBRIS_RE.test(text) || !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;
}
// Legacy spans have already received their own inline/display policy.
// Do not reinterpret dollars inside them in this second pass.
if (text[i] === "\\" && !isEscaped(text, i)) {
const next = text[i + 1];
if (next === "(" || next === "[") {
const closeAt = findClose(text, i + 2, next === "[" ? DISPLAY_CLOSE : INLINE_CLOSE);
if (closeAt !== -1) {
out.push(text.slice(i, closeAt + 2));
i = closeAt + 2;
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) {
const debris = isDisplay ? DISPLAY_DEBRIS_RE : DEBRIS_RE;
out.push(
text.slice(i, i + size) +
text.slice(i + size, close.at).replace(debris, (c) => ESCAPE_DEBRIS[c]!) +
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("");
}
/**
* 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),
);
if (next === "%") return numericPercentEscape(inner, offset + run.length) ? "\\" : run;
if (word) return word.length > 1 && knownTexCommand(word) ? "\\" : run;
return next && commandSymbol.test(next) ? "\\" : run;
});
// Structural evidence is primary (parse success cannot prove row
// preservation). As an additional guard, never turn parseable source into
// a parse failure through partial command recovery.
if (recovered !== inner && !canParseTex(recovered, display) && canParseTex(inner, display)) return inner;
return recovered;
}
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("");
return text.includes("\\\\") ? mapMathContent(text, recoverCommandRuns) : text;
}
/** Presentation-only, language-independent recovery. Shape parsing, saved
* identities, grading and model history must use the unprojected source. */
export function repairMathForDisplay(text: string): string {
return healOverescapedCommands(restoreMathEscapes(recoverDocumentLayout(text)));
}
+6 -26
View File
@@ -30,7 +30,6 @@
import { Value } from "typebox/value";
import type { TSchema } from "typebox";
import { quizDisplayInputSchema, askInputSchema, askOptionProblems, quizInputProblems, explainInputProblems, explainDisplayInputSchema, type AskToolInput, type QuizToolInput, type ExplainToolInput } from "./question-core.ts";
import { repairMathForDisplay } from "./math-delims.ts";
import { healEncodedArguments } from "./argument-heal.ts";
import { probeDoneInputSchema, type ProbeDoneInput } from "./contracts/probe.ts";
@@ -67,28 +66,9 @@ 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.
* 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 {
if (typeof value === "string") return repairMathForDisplay(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;
}
/** Shape parsing never repairs authored text. The dock builds its answer
* data from this source copy; recovery belongs exclusively to presentation
* (ToolContentResponse), never option identities, grading, or snapshots. */
export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// quizDisplayInputSchema (question-core, owner 2026-10-03): base + the
// OPTIONAL `type` — new teach-phase records carry predict|check and the
@@ -100,7 +80,7 @@ export function parseQuizInput(input: unknown): QuizToolInput | undefined {
// wobble must heal HERE too or a posed quiz renders invisible (the
// tool-parts law). healEncodedArguments is the same pure pass the
// boundary's withArgumentHeal runs.
const healedInput = healEncodedArguments(restoreMathEscapesDeep(input), quizDisplayInputSchema);
const healedInput = healEncodedArguments(input, quizDisplayInputSchema);
const cleaned = boundaryParse<QuizToolInput>(quizDisplayInputSchema, healedInput);
if (!cleaned) return undefined;
if (quizInputProblems(cleaned).length > 0) return undefined;
@@ -114,7 +94,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, restoreMathEscapesDeep(input));
const cleaned = boundaryParse<AskToolInput>(askInputSchema, input);
if (!cleaned) return undefined;
if (askOptionProblems(cleaned.options ?? []).length > 0) return undefined;
return cleaned;
@@ -127,7 +107,7 @@ export function parseAskInput(input: unknown): AskToolInput | undefined {
* variants share the same fields; the teacher's nodeId is an unknown
* key Value.Clean strips, same as the quiz seam. */
export function parseExplainInput(input: unknown): ExplainToolInput | undefined {
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, restoreMathEscapesDeep(input));
const cleaned = boundaryParse<ExplainToolInput>(explainDisplayInputSchema, input);
if (!cleaned) return undefined;
if (explainInputProblems(cleaned).length > 0) return undefined;
return cleaned;