From d3c8709bd2ca6c0dde7019d7377e0ddb7cbca7c2 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 26 Oct 2026 04:49:47 +0200 Subject: [PATCH] fix: preserve source data and recover tool math at presentation --- components/ai-elements/message.tsx | 12 +- components/answered-card.tsx | 25 +- components/practice-cards.tsx | 10 +- components/practice-dock.tsx | 5 +- components/question-dock.tsx | 15 +- docs/tool-content-recovery.md | 63 ++ package-lock.json | 3 + package.json | 3 + src/core/answer-presentation.ts | 57 ++ src/core/content-reader.ts | 215 ++++++ src/core/index.ts | 1 + src/core/math-delims.ts | 665 +++--------------- src/core/tool-parts.ts | 32 +- tests/content-recovery.test.ts | 116 +++ .../document-percent-regressions.json | 90 +++ .../tool-rendering/matrix-regressions.json | 135 ++++ .../mixed-escaping-regressions.json | 19 + tests/repair-math-display.test.tsx | 16 +- tests/tool-content-interaction.test.tsx | 78 ++ tests/tool-content-presentation.test.tsx | 164 +++++ tests/tool-parts.test.ts | 62 +- 21 files changed, 1134 insertions(+), 652 deletions(-) create mode 100644 docs/tool-content-recovery.md create mode 100644 src/core/answer-presentation.ts create mode 100644 src/core/content-reader.ts create mode 100644 tests/content-recovery.test.ts create mode 100644 tests/fixtures/tool-rendering/document-percent-regressions.json create mode 100644 tests/fixtures/tool-rendering/matrix-regressions.json create mode 100644 tests/fixtures/tool-rendering/mixed-escaping-regressions.json create mode 100644 tests/tool-content-interaction.test.tsx create mode 100644 tests/tool-content-presentation.test.tsx diff --git a/components/ai-elements/message.tsx b/components/ai-elements/message.tsx index e4b533b..9e22382 100644 --- a/components/ai-elements/message.tsx +++ b/components/ai-elements/message.tsx @@ -34,7 +34,7 @@ import { type PluginConfig, } from "streamdown"; import { mdComponents } from "@/components/md-blocks"; -import { transformMathDelimiters } from "@/src/core"; +import { repairMathForDisplay, transformMathDelimiters } from "@/src/core"; export type MessageProps = HTMLAttributes & { from: LhViewMessage["role"]; @@ -388,6 +388,16 @@ export const MessageResponse = memo( MessageResponse.displayName = "MessageResponse"; +/** Recovery is a tool-text presentation concern, not a shape/answer-data + * transform. Ordinary assistant/user prose keeps MessageResponse unchanged. */ +export function ToolContentResponse({ children, ...props }: MessageResponseProps) { + return ( + + {typeof children === "string" ? repairMathForDisplay(children) : children} + + ); +} + export type MessageToolbarProps = ComponentProps<"div">; export const MessageToolbar = ({ diff --git a/components/answered-card.tsx b/components/answered-card.tsx index f281e33..ce76b80 100644 --- a/components/answered-card.tsx +++ b/components/answered-card.tsx @@ -5,9 +5,9 @@ import { ConfidenceRail } from "@/components/confidence-rail"; import { GlyphMark } from "@/components/ui/glyph-mark"; import { ThinkingBlock } from "@/components/thinking-block"; import { cn } from "@/lib/utils"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import type { QuestionAnswerData } from "@/src/core"; -import { parseAskInput, parseQuizInput } from "@/src/core"; +import { answerForPresentation, parseAskInput, parseQuizInput } from "@/src/core"; /** * Static rendering of an answered question (tool part in `output-available`). @@ -19,7 +19,8 @@ import { parseAskInput, parseQuizInput } from "@/src/core"; * client — the card reveals his answer, the committed correct answer, and * the explanation, with NO verdict chips (owner ruling 2026-09-27). */ -export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) { +export function AnsweredCard({ answer: savedAnswer, input }: { answer: QuestionAnswerData; input?: unknown }) { + const answer = answerForPresentation(savedAnswer, input); const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03) if (answer.dismissed) { return ( @@ -86,13 +87,13 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in ))}
- {answer.questionText} + {answer.questionText}
{parsedDetails && ( // The details line, same paint as the pending dock (owner, // 2026-10-12): full markdown, LaTeX included.
- {parsedDetails} + {parsedDetails}
)} @@ -131,14 +132,14 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in )} - {opt.label} + {opt.label} {descByIndex.get(opt.index) && ( // Ask-option subtext (owner, 2026-10-12): same markdown path // as the pending dock — LaTeX included. Quiz options carry // none (owner ruling 2026-09-26); the map stays empty for them. - + {descByIndex.get(opt.index)} - + )} @@ -211,9 +212,9 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {whyOpen && (
Why: - {/* The markdown wrapper must go truly inline — a block

after the - "Why:" label created the oversized leading gap (owner's fix list). */} - {answer.explanation} + {/* Keep the first paragraph beside the label, not every paragraph: + later paragraphs must retain their recovered document structure. */} + {answer.explanation}

)} @@ -225,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in {isExplain && answer.committed && (
The answer: - {answer.committed} + {answer.committed}
)} diff --git a/components/practice-cards.tsx b/components/practice-cards.tsx index 08cd053..eb4e507 100644 --- a/components/practice-cards.tsx +++ b/components/practice-cards.tsx @@ -1,8 +1,8 @@ "use client"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; -import { repairMathForDisplay, type ArtifactPartProps } from "@/src/core"; +import type { ArtifactPartProps } from "@/src/core"; /** * The practice transcript cards (practice sessions, owner design @@ -66,7 +66,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
Problem
- {repairMathForDisplay(statement)} + {statement}
); @@ -133,7 +133,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) { (owner round 3): the same quiet 50% register as quiz explanations. */} {verdict.length > 0 && (
- {repairMathForDisplay(verdict)} + {verdict}
)} {/* Then the divider, then the labeled model solution (Slice G: the @@ -144,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
Model solution
- {repairMathForDisplay(reveal)} + {reveal}
diff --git a/components/practice-dock.tsx b/components/practice-dock.tsx index e24f4f4..4c53b59 100644 --- a/components/practice-dock.tsx +++ b/components/practice-dock.tsx @@ -2,12 +2,11 @@ import { useCallback, useRef, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { Button } from "@/components/ui/button"; import { joinDictation, useDictationTarget } from "@/components/dictation"; import { useEnterSubmits } from "@/components/enter-submits"; import { cn } from "@/lib/utils"; -import { repairMathForDisplay } from "@/src/core"; /** * The practice dock (practice sessions, owner design 2026-10-04): an @@ -109,7 +108,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
Problem
- {typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement} + {input.statement}
diff --git a/components/question-dock.tsx b/components/question-dock.tsx index 3b6f848..e0a94d7 100644 --- a/components/question-dock.tsx +++ b/components/question-dock.tsx @@ -5,10 +5,11 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { finishDictation, isDictationActive, joinDictation, useDictationTarget } from "@/components/dictation"; import { useEnterSubmits } from "@/components/enter-submits"; -import { MessageResponse } from "@/components/ai-elements/message"; +import { ToolContentResponse } from "@/components/ai-elements/message"; import { cn } from "@/lib/utils"; import { buildAnswer, + repairMathForDisplay, normalizeQuizType, CONFIDENCE_STOPS, DEFAULT_CONFIDENCE_INDEX, @@ -166,7 +167,7 @@ export function QuestionDock({ /** A plain-text teaser of a question for the collapsed bar: unwrap math, flatten common LaTeX. */ function teaserText(q: string): string { - return q + return repairMathForDisplay(q) .replace(/\\\(([\s\S]*?)\\\)/g, "$1") // \(...\) inline math .replace(/\\\[([\s\S]*?)\\\]/g, "$1") // \[...\] display math .replace(/\$\$?([^$]*)\$\$?/g, "$1") // standard $...$/$$...$$ math @@ -560,11 +561,11 @@ function QuestionDockForm({ {qd.mode === "multi-select" && " · select all that apply"}
- {qd.question} + {qd.question}
{qd.context && (
- {qd.context} + {qd.context}
)} @@ -600,7 +601,7 @@ function QuestionDockForm({ )} - {opt.label} + {opt.label} {kind !== "quiz" && opt.description && ( // Option subtext renders in Source Sans 3 (owner's mockup); // the "I don't know" copy stays JetBrains Mono. QUIZ options render @@ -609,9 +610,9 @@ function QuestionDockForm({ // records must not leak either. Same MessageResponse path as the // label (owner, 2026-10-12): descriptions are full markdown — // LaTeX included — not plain text. - + {opt.description} - + )} diff --git a/docs/tool-content-recovery.md b/docs/tool-content-recovery.md new file mode 100644 index 0000000..8151b1a --- /dev/null +++ b/docs/tool-content-recovery.md @@ -0,0 +1,63 @@ +# Tool-content presentation recovery + +## Contract + +Model-authored tool content is parsed against its schema before presentation. Math or document recovery must not change persisted tool arguments, option identities, learner answers, shuffling, grading, or future answer snapshots. Those use the source-derived, schema-parsed content. + +`ToolContentResponse` projects text through `repairMathForDisplay`, then uses the ordinary Markdown renderer. Teaching/probing questions, asks, recalls/explanations, and practice statements/solutions share this path. Ordinary assistant prose does not receive tool-content recovery. + +There are three separate responsibilities: + +1. **Argument/shape parsing:** decode encoded argument shapes, then schema conversion, validation and cleaning. No LaTeX recovery here. +2. **Content projection:** distinguish literal code, prose/document structure and math; recover only supported escaping defects. +3. **Presentation:** Markdown/KaTeX rendering, including paragraph layout. In labeled Why/answer reveals, only the first direct paragraph is inline with its label; subsequent paragraphs retain their normal layout. + +## Recovery rules + +### Literal code + +Code and inline-code source ranges come from the rendering stack's CommonMark parser with its math extensions. This covers fences, indentation, quotes and lists without maintaining separate container heuristics. The projection copies those source ranges verbatim; it does not serialize an AST back into Markdown. + +### Document escapes + +A literal two-character `\n` is different from a physical newline. Document-layout recovery requires evidence such as repeated document-escape candidates or a candidate alongside over-escaped known TeX commands. Complete known n-prefixed TeX words, including `\nu`, `\nabla`, `\neq` and declared n-prefixed macros, take precedence over interpreting their prefix as document layout. Code, genuine TeX comments and recognized path tokens are protected. + +Recovered double-dollar display framing puts the formula and closing fence on their own lines. Decoding the opening line alone can otherwise turn closing dollars into erroneous TeX content. + +### JSON-swallowed command prefixes + +Inside math, a control character can be the result of JSON consuming the start of a command: TAB + `ext` instead of `\text`, for example. Recovery reconstructs a command only if the control character plus the **complete following alphabetic word** names a known KaTeX command. All supported occurrences recover in one projection. + +The owner-approved typical-model-authoring policy is: + +- **Inline math:** recover recognized prefixes, including physical LF + `u` to `\nu`, and TAB + `o` to `\to`. There is no word-length cutoff. +- **Display math:** keep physical LF and CRLF as source formatting; recover recognized non-newline prefixes. +- **Correctly written commands:** remain commands in both modes. `\nu` is never decoded into LF + `u`. +- **Unrecognized whitespace:** remains unchanged. `x+y` does not become invented `\t`/`\ty` commands. + +The control-character case is inherently ambiguous: whitespace followed by a variable may happen to spell a command. This policy targets typical model-authored LaTeX, not every syntactically possible human whitespace arrangement. TeX parse success alone cannot distinguish the two meanings. + +### Extra command escaping, percentages and rows + +Recovery examines the original TeX context before changing backslash runs: + +- Complete native environment pairs and native `\substack` arguments preserve genuine row separators. +- Consistently extra-escaped environment pairs establish an escaping factor for commands **and rows**. For example, an encoded four-backslash row becomes two backslashes, not one. +- Outside protected native row/comment contexts, extra prefixes on known commands can recover. Unknown command words are not invented. +- Doubled percent escapes in ordinary numeric expressions can recover to `\%`, including numeric percentages at the end of a display formula. Genuine following comment text or equation rows remain literal. This is not a blanket `%` addition to an even-backslash rule. +- As an additional guard, partial command recovery does not turn parseable source into a TeX parse failure. Parse success is not the primary correctness test: both a corrupted matrix and a lost percentage can parse successfully. + +Unbalanced, unsupported or ambiguous content is not generally corrected into new mathematics. This is an escaping recovery projection, not a general TeX authoring repair system. There are no language-specific decoders. + +## Historical answers + +`answerForPresentation` joins model-authored display fields to the original tool call without changing recorded answer facts. Option labels bind only through unambiguous selected values, exact/source-recovered labels, or one uniquely remaining pair in a complete bijection. Ambiguous rows keep their saved snapshots. Saved order, indices, selected values, verdicts, confidence and learner text are retained. Dismissed answers are unchanged. No migration or record rewrite is performed. + +## Verification + +- `tests/content-recovery.test.ts`: recovery/preservation rules and adversarial row/comment/code examples. +- `tests/tool-content-presentation.test.tsx`: provenance-labeled synthetic corpus regressions, historical joins and ambiguity. +- `tests/tool-content-interaction.test.tsx`: select/submit/persist/reveal flow and paragraph structure. +- Existing Markdown, card, practice, tool-shape and grading suites remain applicable. + +The local frozen corpus can be replayed offline with `scripts/replay-tool-rendering-corpus.ts`. Replay verifies source hashes and preserves baseline findings; it reports glyph loss, matrix rows, rendering errors, code bytes, schema regressions, answer facts and projection convergence. Corpus collection and larger diagnostic batches are separate from product recovery and are not prerequisites for running these unit tests. diff --git a/package-lock.json b/package-lock.json index 7a91ffc..21e2398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", diff --git a/package.json b/package.json index 1b0c649..fefaa8c 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,10 @@ "katex": "0.18.2", "linkedom": "^0.18.13", "lucide-react": "^1.33.0", + "mdast-util-from-markdown": "2.0.3", + "mdast-util-math": "3.0.0", "media-chrome": "^4.19.2", + "micromark-extension-math": "3.1.0", "mermaid": "^11.17.0", "motion": "^13.1.1", "nanoid": "^6.0.1", diff --git a/src/core/answer-presentation.ts b/src/core/answer-presentation.ts new file mode 100644 index 0000000..ffc61ef --- /dev/null +++ b/src/core/answer-presentation.ts @@ -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(); // saved position -> original position + const used = new Set(); + 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; +} diff --git a/src/core/content-reader.ts b/src/core/content-reader.ts new file mode 100644 index 0000000..a774094 --- /dev/null +++ b/src/core/content-reader.ts @@ -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(); +/** 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; +} diff --git a/src/core/index.ts b/src/core/index.ts index b4106d7..77de4ed 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -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"; diff --git a/src/core/math-delims.ts b/src/core/math-delims.ts index b939054..21d7bed 100644 --- a/src/core/math-delims.ts +++ b/src/core/math-delims.ts @@ -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 = { - "\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 = { + "\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))); } diff --git a/src/core/tool-parts.ts b/src/core/tool-parts.ts index cb5585d..db4305c 100644 --- a/src/core/tool-parts.ts +++ b/src/core/tool-parts.ts @@ -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(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 = {}; - 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(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(askInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(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(explainDisplayInputSchema, restoreMathEscapesDeep(input)); + const cleaned = boundaryParse(explainDisplayInputSchema, input); if (!cleaned) return undefined; if (explainInputProblems(cleaned).length > 0) return undefined; return cleaned; diff --git a/tests/content-recovery.test.ts b/tests/content-recovery.test.ts new file mode 100644 index 0000000..d8f9196 --- /dev/null +++ b/tests/content-recovery.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { repairMathForDisplay, restoreMathEscapes, transformMathDelimiters } from "../src/core/math-delims.ts"; + +const math = String.raw; +test("document paragraphs and display framing recover without interpreting n-commands as newlines", () => { + const source = math`First.\n\nSecond: $\nabla f\neq0$.\n\n$$\n\frac{1}{2}\n$$`; + assert.equal(repairMathForDisplay(source), "First.\n\nSecond: " + math`$\nabla f\neq0$.` + "\n\n$$\n" + math`\frac{1}{2}` + "\n$$"); +}); +test("literal document escapes fused to unknown math commands use document context", () => { + const source = math`Jacobian:\n\n$$\nJ=\begin{pmatrix}1&2\\3&4\end{pmatrix}\n$$`; + assert.equal(repairMathForDisplay(source), "Jacobian:\n\n$$\n" + math`J=\begin{pmatrix}1&2\\3&4\end{pmatrix}` + "\n$$"); +}); +test("encoded environment profiles decode row separators by their factor, not to one slash", () => { + for (const [source, expected] of [ + [math`$\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`], + [math`$\\begin{pmatrix}1&2\\\\3&4\\end{pmatrix}$`, math`$\begin{pmatrix}1&2\\3&4\end{pmatrix}$`], + [math`$\\begin{pmatrix}a&b\\\\\\nabla f&d\\end{pmatrix}$`, math`$\begin{pmatrix}a&b\\\nabla f&d\end{pmatrix}$`], + [math`$\\substack{a\\\\beta}$`, math`$\substack{a\\beta}$`], + ] as const) { + assert.equal(repairMathForDisplay(source), expected); + assert.equal(repairMathForDisplay(expected), expected); + } +}); +test("native substack rows and literal document escapes in genuine comments remain literal", () => { + const source = math`$\substack{a\\beta}$`; + assert.equal(repairMathForDisplay(source), source); + const comment = "$x % " + math`literal \n\n` + "\ny$"; + assert.equal(repairMathForDisplay(comment), comment); +}); +test("inline lost prefixes recover only known full commands; display newlines remain whitespace", () => { + assert.equal(repairMathForDisplay("$\nabla f$"), math`$\nabla f$`); + assert.equal(repairMathForDisplay("$x+\ny$"), "$x+\ny$"); + assert.equal(repairMathForDisplay("$x+\nu$"), math`$x+\nu$`); // approved typical-inline-authoring policy + assert.equal(repairMathForDisplay("$$\nabla f\n$$"), "$$\nabla f\n$$"); // display source whitespace +}); +test("physical display newlines and genuine row breaks/comments survive", () => { + const source = "$$\n" + math`\begin{aligned}a&=b\\% row comment` + "\nc&=d\end{aligned}\n$$"; + assert.equal(repairMathForDisplay(source), source); +}); +test("proper nu survives every context, including fields with document escapes", () => { + for (const source of [math`$\nu$`, "$$\n" + math`\nu` + "\n$$", math`$\nabla f+\nu+\neq$`]) { + assert.equal(repairMathForDisplay(source), source); + } + assert.equal(repairMathForDisplay(math`First.\n\n$\nu$`), "First.\n\n" + math`$\nu$`); + assert.equal(repairMathForDisplay("$$\nu\n$$"), "$$\nu\n$$"); // LF + variable u in display +}); +test("tabs recover known commands including to, not arbitrary whitespace", () => { + assert.equal(repairMathForDisplay("$$\nx\t+\ty\n$$"), "$$\nx\t+\ty\n$$"); + assert.equal(repairMathForDisplay("$a\to b$"), math`$a\to b$`); + assert.equal(repairMathForDisplay("$$\na\to b\n$$"), "$$\n" + math`a\to b` + "\n$$"); + assert.equal(repairMathForDisplay("$$\r\nx+1\r\n$$"), "$$\r\nx+1\r\n$$"); +}); +test("Markdown containers protect literal code and do not swallow following content", () => { + for (const source of [ + "> ~~~text\n> " + math`$\\alpha$ \n\n` + "\n> ~~~", + "> > ~~~text\n> > " + math`$\\alpha$` + "\n> > ~~~", + "- ~~~text\n " + math`$\\alpha$` + "\n ~~~", + "> ~~~text\n> " + math`$\\alpha$` + "\n\n~~~text\n" + math`$\\beta$` + "\n~~~", + ]) { + assert.equal(repairMathForDisplay(source), source); + assert.equal(transformMathDelimiters(source), source); + assert.equal(repairMathForDisplay(source + "\n\n" + math`$\\gamma$`), source + "\n\n" + math`$\gamma$`); + } +}); +test("numeric percentages recover, including percentages inside text groups", () => { + for (const [source, expected] of [ + [math`$60\\%$`, math`$60\%$`], + [math`$50\\%+10\\%=60\\%$`, math`$50\%+10\%=60\%$`], + [math`$\text{ (50\\%)}$`, math`$\text{ (50\%)}$`], + ] as const) assert.equal(repairMathForDisplay(source), expected); +}); +test("numeric row breaks followed by genuine comments are not percentage escapes", () => { + const source = "$60\\\\% explanation of this row\nx$"; + assert.equal(repairMathForDisplay(source), source); + const ambiguous = math`$x\\%$`; + assert.equal(repairMathForDisplay(ambiguous), ambiguous); +}); +test("inline and fenced code retain all bytes, including legacy delimiters and actual control characters", () => { + const code = "```text\n\\(x\nabla\\)\n\\n\\n\n```\n" + "`" + math`\n\n $60\\%$ \(x\)` + "`"; + assert.equal(repairMathForDisplay(code), code); + assert.equal(restoreMathEscapes(code), code); +}); +test("math matching cannot consume a blank paragraph or a code span", () => { + const source = "Price $5.\n\nThen $x$ and `code` remains."; + assert.equal(repairMathForDisplay(source), source); + assert.equal(repairMathForDisplay("$not `code` math$"), "$not `code` math$"); +}); +test("Windows paths and correctly written n-commands are not document escapes", () => { + const source = math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.\n\nNext paragraph.`; + assert.equal(repairMathForDisplay(source), math`Path C:\new\notes; symbols $\nu+\ni+\neq+\nabla f$.` + "\n\nNext paragraph."); +}); +test("declared n-prefixed macros are commands, not document escapes", () => { + const source = math`$\newcommand{\nThing}{x}\nThing$.\n\nNext.`; + assert.equal(repairMathForDisplay(source), math`$\newcommand{\nThing}{x}\nThing$.` + "\n\nNext."); +}); +test("partial recovery cannot turn otherwise parseable TeX into a missing-argument error", () => { + const source = math`$x\\frac{a}$`; + assert.equal(repairMathForDisplay(source), source); +}); +test("legacy compatibility and recovery share code barriers, including tilde and indentation", () => { + for (const source of [" " + math`\(x\) \n\n`, "\\[\n~~~tex\n" + math`\n\n \(x\)` + "\n\\]\n~~~"]) { + assert.equal(repairMathForDisplay(source), source); + assert.equal(transformMathDelimiters(source), source); + } + const comment = "$60% " + math`\n\n` + "\nx$"; + assert.equal(repairMathForDisplay(comment), comment); +}); +test("recovery converges after one pass, including multiple debris characters", () => { + for (const source of ["$\forall x,\theta+\to$", math`First.\n\n$$\nJ=\\frac{1}{2}+60\\%\n$$`, + math`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`, "```text\n\\(\nabla\\)\n```", "$\nabla f$", + ]) { + const once = repairMathForDisplay(source); + assert.equal(repairMathForDisplay(once), once); + } +}); diff --git a/tests/fixtures/tool-rendering/document-percent-regressions.json b/tests/fixtures/tool-rendering/document-percent-regressions.json new file mode 100644 index 0000000..fd5ed9b --- /dev/null +++ b/tests/fixtures/tool-rendering/document-percent-regressions.json @@ -0,0 +1,90 @@ +[ + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo321f7_4202fae0", + "source": "messages.1.content.1.arguments.explanation", + "model": "google/gemini-3.7-flash", + "kind": "percent-at-display-end", + "raw": "To find the posterior probability $P(D \\\\mid +)$, we apply Bayes' theorem:\n$$\nP(D \\\\mid +) = \\\\frac{P(D \\\\cap +)}{P(+)} = \\\\frac{P(D)P(+ \\\\mid D)}{P(D)P(+ \\\\mid D) + P(\\\\neg D)P(+ \\\\mid \\\\neg D)}\n$$\n\nGiven:\n- $P(D) = 0.01$ (and thus $P(\\\\neg D) = 0.99$)\n- $P(+ \\\\mid D) = 0.90$\n- $P(+ \\\\mid \\\\neg D) = 0.05$\n\nEvaluating the numerator and denominator:\n$$\nP(D \\\\cap +) = 0.01 \\\\times 0.90 = 0.009\n$$\n$$\nP(\\\\neg D \\\\cap +) = 0.99 \\\\times 0.05 = 0.0495\n$$\n$$\nP(+) = 0.009 + 0.0495 = 0.0585\n$$\n\nTherefore:\n$$\nP(D \\\\mid +) = \\\\frac{0.009}{0.0585} = \\\\frac{9}{58.5} = \\\\frac{2}{13} \\\\approx 15.4\\\\%\n$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo321f7_4202fae0", + "source": "messages.5.content.1.arguments.explanation", + "model": "google/gemini-3.7-flash", + "kind": "percent-at-display-end", + "raw": "Because the two tests are conditionally independent given disease status:\n$$\nP(T_1 = +, T_2 = + \\\\mid D) = P(T_1 = + \\\\mid D) \\\\cdot P(T_2 = + \\\\mid D) = 0.90 \\\\times 0.90 = 0.81\n$$\n$$\nP(T_1 = +, T_2 = + \\\\mid \\\\neg D) = P(T_1 = + \\\\mid \\\\neg D) \\\\cdot P(T_2 = + \\\\mid \\\\neg D) = 0.10 \\\\times 0.10 = 0.01\n$$\n\nNow calculate the joint probabilities:\n$$\nP(D \\\\cap T_1 = + \\\\cap T_2 = +) = P(D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid D) = 0.01 \\\\times 0.81 = 0.0081\n$$\n$$\nP(\\\\neg D \\\\cap T_1 = + \\\\cap T_2 = +) = P(\\\\neg D) \\\\cdot P(T_1 = +, T_2 = + \\\\mid \\\\neg D) = 0.99 \\\\times 0.01 = 0.0099\n$$\n\nTotal probability of two positive tests:\n$$\nP(T_1 = +, T_2 = +) = 0.0081 + 0.0099 = 0.0180\n$$\n\nApplying Bayes' theorem:\n$$\nP(D \\\\mid T_1 = +, T_2 = +) = \\\\frac{0.0081}{0.0180} = \\\\frac{81}{180} = \\\\frac{9}{20} = 45\\\\%\n$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo4op90_74572548", + "source": "messages.1.content.2.arguments.explanation", + "language": "pt", + "model": "deepseek/deepseek-v4.1-flash", + "kind": "percent-at-display-end", + "raw": "Em $10\\\\,000$ pessoas: $100$ estão doentes e $99$ dessas testam positivo; $9\\\\,900$ estão saudáveis e $5\\\\%$ delas, isto é $495$, testam positivo. Total de positivos: $99 + 495 = 594$. Entre os positivos, a fração de doentes é\\n$$\\n\\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%\\n$$\\nA base pequena de doentes faz os falsos positivos ($495$) dominarem os verdadeiros positivos ($99$)." + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo4op90_74572548", + "source": "messages.3.content.2.arguments.explanation", + "language": "pt", + "model": "deepseek/deepseek-v4.1-flash", + "kind": "percent-at-display-end", + "raw": "Continua valendo a mesma estrutura de Bayes, agora com números diferentes. Em $10\\\\,000$ pessoas: $200$ doentes, das quais $90\\\\%$ testam positivo $= 180$; $9\\\\,800$ saudáveis, das quais $5\\\\%$ testam positivo $= 490$ falsos positivos. Total de positivos: $180 + 490 = 670$.\\n$$\\nP(\\\\text{doente} \\\\mid +) = \\\\frac{180}{670} \\\\approx 26{,}9\\\\%\\n$$\\nNote que a especificidade $95\\\\%$ entra na conta pela sua taxa de falso positivo $1 - 0{,}95 = 0{,}05$, pesada por $P(\\\\text{saudável}) = 0{,}98$." + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo8kgtc_65767ffd", + "source": "messages.5.content.1.arguments.question", + "language": "es", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "document-math-framing", + "raw": "Una enfermedad afecta al $1\\%$ de una población. La prueba tiene una sensibilidad del $99\\%$ (probabilidad de dar positivo si la enfermedad está presente) y una tasa de falsos positivos del $5\\%$ (probabilidad de dar positivo si no está presente). Una persona al azar da positivo. ¿Cuál es la probabilidad de que realmente tenga la enfermedad?\\n\\n$$\\nP(\\text{enf} \\mid +) \\;=\\; ?$$" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.3.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "条件概率把样本空间缩小到条件所指定的那一部分。已知\"检测阳性\",人群就从 $1000$ 人缩到 $140$ 人,其中患病的有 $45$ 人,所以\\n$$\\nP(\\\\text{患病}\\\\mid\\\\text{阳性})=\\\\frac{P(\\\\text{患病}\\\\cap\\\\text{阳性})}{P(\\\\text{阳性})}=\\\\frac{45/1000}{140/1000}=\\\\frac{45}{140}\\\\approx 32.1\\\\%.\\n$$\\n另外三个数各有各的含义:$\\\\frac{45}{50}=90\\\\%$ 是 $P(\\\\text{阳性}\\\\mid\\\\text{患病})$(敏感度,条件方向反了);$\\\\frac{45}{1000}=4.5\\\\%$ 是联合概率 $P(\\\\text{患病}\\\\cap\\\\text{阳性})$(没做条件化);$\\\\frac{140}{1000}=14\\\\%$ 是 $P(\\\\text{阳性})$(不分是否患病的总阳性率)。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.7.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "用 $10000$ 人(或直接用全概率公式)算分母:\\n$$\\n\\\\begin{aligned}\\n\\\\text{患病且阳性}&=10000\\\\times 0.01\\\\times 0.99=99,\\\\\\\\\\n\\\\text{未患病但阳性}&=10000\\\\times 0.99\\\\times 0.05=495.\\n\\\\end{aligned}\\n$$\\n阳性总数 $=99+495=594$,所以\\n$$\\nP(D\\\\mid +)=\\\\frac{99}{594}\\\\approx 16.7\\\\%.\\n$$\\n各错误选项对应的具体失误:$99\\\\%$ 是把敏感度 $P(+\\\\mid D)$ 当成了 $P(D\\\\mid +)$(条件方向颠倒);$95.2\\\\%=\\\\frac{0.99}{0.99+0.05}$ 是把阳性率和假阳性率直接当人数用,漏掉了基础发生率的加权($99\\\\%$ 的人未患病,所以少量假阳性也架不住人多);$1\\\\%$ 是看到了阳性结果却不更新概率。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo91klg_5332fa79", + "source": "messages.9.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "两次阳性后的后验,用同样的公式但似然要平方:\\n$$\\n\\\\begin{aligned}\\nP(++\\\\mid D)&=0.99^2=0.9801,\\\\\\\\\\nP(++\\\\mid D')&=0.05^2=0.0025.\\n\\\\end{aligned}\\n$$\\n$$\\nP(D\\\\mid ++)\\\\frac{0.99^2\\\\times 0.01}{0.99^2\\\\times 0.01+0.05^2\\\\times 0.99}=\\\\frac{0.009801}{0.009801+0.002475}\\\\approx 79.8\\\\%.\\n$$\\n关键在于**两条分支都要平方**:患病分支的似然变成 $0.99^2$,未患病分支的假阳性率变成 $0.05^2$($5\\%$ 是很小的数,平方后变得更小,所以假阳性这一侧收缩得比患病侧更狠,后验从 $16.7\\\\%$ 跳到约 $80\\\\%$)。其他选项的失误:$98\\\\%$ 是把 $P(++\\\\mid D)$ 当成了 $P(D\\\\mid ++)$;$16.5\\\\%$ 只对患病分支用了两次独立,未患病分支仍用单次假阳性率;$33\\\\%$ 是把单次后验直接乘 $2$——两次阳性提供的证据不是相加的,而要在似然层面相乘。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo9c830_3866e4a6", + "source": "messages.7.content.1.arguments.explanation", + "language": "zh-Hans", + "model": "anthropic/claude-haiku-5.5", + "kind": "percent-at-display-end", + "raw": "阳性总人数:真阳性 $100\\\\times0.9=90$,假阳性 $9900\\\\times0.05=495$,合计 $585$。\n\n所求的是阳性者中患病者的占比,分母是全部阳性者:\n$$\n\\\\frac{90}{585}\\\\approx15.4\\\\%\n$$\n\n$90\\\\%$ 是患病者中阳性的比例,分母错成了患病者;$84.6\\\\%$ 是阳性者中健康者的占比,回答的是另一个问题;$5\\\\%$ 是健康者中的假阳性率,分母是健康人群。" + }, + { + "group": "multilingual-20261025-01", + "chatId": "chat_mvo9nu8w_dcc7add9", + "source": "messages.3.content.1.arguments.explanation", + "language": "id", + "model": "xiaomi/mimo-v2.6-flash", + "kind": "percent-at-display-end", + "raw": "Pakai aturan Bayes dengan menghitung dulu kejadian gabungan dalam satu kelompok besar, misalnya $10.000$ orang:\\n\\n- Sakit dan positif: $10.000 \\\\times 0{,}01 \\\\times 0{,}99 = 99$ orang.\\n- Sehat dan positif: $10.000 \\\\times 0{,}99 \\\\times 0{,}05 = 495$ orang.\\n\\nSemua yang hasilnya positif: $99 + 495 = 594$ orang. Maka\\n$$\\nP(\\\\text{sakit}\\\\mid +) = \\\\frac{99}{594} = \\\\frac{1}{6} \\\\approx 16{,}7\\\\%.\\n$$\\n\\nYang menipu di sini adalah base rate: kelompok sehat jauh lebih besar, sehingga meski hanya 5% dari mereka yang salah positif, jumlahnya tetap 5 kali lipat jumlah positif yang benar. Nilai $95{,}2\\\\%$ muncul kalau kelompok sakit dan sehat dianggap sama besar ($\\\\frac{99}{99+5}$); nilai $99\\\\%$ muncul kalau arah kondisinya dibalik, membaca $P(+\\\\mid \\\\text{sakit})$ sebagai $P(\\\\text{sakit}\\\\mid +)$." + } +] diff --git a/tests/fixtures/tool-rendering/matrix-regressions.json b/tests/fixtures/tool-rendering/matrix-regressions.json new file mode 100644 index 0000000..48d409d --- /dev/null +++ b/tests/fixtures/tool-rendering/matrix-regressions.json @@ -0,0 +1,135 @@ +{ + "notice": "Unmodified model-authored fields from the frozen multilingual corpus. oldDisplay records the diagnosed application damage, not an expected output.", + "cases": [ + { + "run": "pilot-20261025-01", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo1rzjs_056eac45", + "source": "messages.5.content.1.arguments.explanation", + "raw": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result.", + "oldDisplay": "For a $2\\times2$ matrix, $\\det\\begin{bmatrix}a&b\\c&d\\end{bmatrix}=ad-bc$. Here $3\\cdot2-6\\cdot1=0$. A square matrix is invertible exactly when its determinant is nonzero, so $M$ is singular. Its columns are also proportional, which matches the determinant-zero result." + }, + { + "run": "pilot-20261025-01", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo1sdnj_8f2d8c5e", + "source": "messages.9.content.1.arguments.explanation", + "raw": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.", + "oldDisplay": "The Jacobian has one row per output and one column per input: $J_F=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. At $(1,2)$ this is $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$." + }, + { + "run": "pilot-20261025-01", + "model": "anthropic/claude-haiku-5.5", + "language": "en", + "replicate": 1, + "chatId": "chat_mvo30cfe_57114723", + "source": "messages.3.content.1.arguments.explanation", + "raw": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term.", + "oldDisplay": "$A-\\lambda I=\\begin{pmatrix}2-\\lambda&1\\\\1&2-\\lambda\\end{pmatrix}$, and for a $2\\times2$ matrix $\\det\\begin{pmatrix}p&q\\r&s\\end{pmatrix}=ps-qr$. So $$\\det(A-\\lambda I)=(2-\\lambda)^2-1\\cdot1=\\lambda^2-4\\lambda+3.$$ Its roots $\\lambda=1,3$ are the eigenvalues. Option $b$ drops the minus sign in $ps-qr$; $c$ subtracts the constant incorrectly; $d$ flips the sign of the linear term." + }, + { + "run": "multilingual-20261025-01-zh-Hans-r1", + "model": "openai/gpt-6-luna", + "language": "zh-Hans", + "replicate": 1, + "chatId": "chat_mvo60bsn_1cbbe884", + "source": "messages.3.content.1.arguments.question", + "raw": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?", + "oldDisplay": "对 $2\\times2$ 矩阵 $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$,以下哪组关于行列式的说法全都正确?" + }, + { + "run": "multilingual-20261025-01-zh-Hans-r1", + "model": "xiaomi/mimo-v2.6-flash", + "language": "zh-Hans", + "replicate": 1, + "chatId": "chat_mvo66u6w_a233fc16", + "source": "messages.9.content.2.arguments.explanation", + "raw": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。", + "oldDisplay": "雅可比矩阵按“第 $i$ 行 = 第 $i$ 个分量对所有自变量的偏导”排布:\n$$\nJ_{\\mathbf F}=\n\\begin{pmatrix}\n\\dfrac{\\partial (xy)}{\\partial x} & \\dfrac{\\partial (xy)}{\\partial y}\\\\[6pt]\n\\dfrac{\\partial (x+y^2)}{\\partial x} & \\dfrac{\\partial (x+y^2)}{\\partial y}\n\\end{pmatrix}\n=\n\\begin{pmatrix}\ny & x\\\\\n1 & 2y\n\\end{pmatrix}.\n$$\n注意 $\\partial(xy)/\\partial x=y$($y$ 当常数),$\\partial(xy)/\\partial y=x$。转置形式 $\\begin{pmatrix}y&1\\x&2y\\end{pmatrix}$ 把偏导排成了列;$\\begin{pmatrix}y&x\\\\2y&1\\end{pmatrix}$ 把第二行的两个分量次序调换了;$\\begin{pmatrix}x&y\\\\1&2y\\end{pmatrix}$ 把第一行的 $x,y$ 位置写反了。" + }, + { + "run": "multilingual-20261025-01-id-r1", + "model": "openai/gpt-6-luna", + "language": "id", + "replicate": 1, + "chatId": "chat_mvo6q2lq_6af14437", + "source": "messages.7.content.1.arguments.explanation", + "raw": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$.", + "oldDisplay": "Pada $(x,y)=(1,0)$, $H=(1,1)$. Jacobian $G$ di $(1,1)$ adalah $\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}=\\begin{pmatrix}2&1\\\\1&1\\end{pmatrix}$, sedangkan $J_H=\\begin{pmatrix}1&1\\\\1&-1\\end{pmatrix}$. Aturan rantai dengan konvensi ini memberi $J_{G\\circ H}=J_G(H(x,y))J_H(x,y)$, yakni $\\begin{pmatrix}3&1\\\\2&0\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-id-r1", + "model": "z-ai/glm-5.3-flash", + "language": "id", + "replicate": 1, + "chatId": "chat_mvo77f3x_135f8d66", + "source": "messages.7.content.2.arguments.explanation", + "raw": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$.", + "oldDisplay": "Vektor eigen memenuhi $(A-\\lambda I)v=0$. Untuk $\\lambda=2$:\n$$(A-2I)v=\\begin{pmatrix}1&1\\\\0&0\\end{pmatrix}\\begin{pmatrix}v_1\\v_2\\end{pmatrix}=0\\;\\Rightarrow\\;v_1+v_2=0\\;\\Rightarrow\\;v=\\begin{pmatrix}1\\\\-1\\end{pmatrix}$$\n(bisa dikali skalar berapa pun). Cek: $Av=\\begin{pmatrix}3-1\\\\-2\\end{pmatrix}=\\begin{pmatrix}2\\\\-2\\end{pmatrix}=2v$. Perhatikan $v=(1,0)$ adalah vektor eigen untuk $\\lambda=3$, bukan $\\lambda=2$." + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7ei0l_9263330e", + "source": "messages.3.content.1.arguments.question", + "raw": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?", + "oldDisplay": "For a $2\\times2$ matrix $A=\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, which statement correctly links its determinant to area scaling and invertibility?" + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7exl3_e1fe93cd", + "source": "messages.1.content.1.arguments.explanation", + "raw": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid.", + "oldDisplay": "First evaluate the Jacobian of $F$ at $(1,2)$:\n$$\nJ_F(x,y)=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix},\\qquad J_F(1,2)=\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}.\n$$\nThe velocity is $\\gamma'(1)=(1,2)$. The chain rule gives\n$$\n\\frac{d}{dt}F(\\gamma(t))\\bigg|_{t=1}=J_F(1,2)\\begin{pmatrix}1\\\\2\\end{pmatrix}=\\begin{pmatrix}4\\\\4\\end{pmatrix}.\n$$\nWait, recomputing the component derivatives directly: $F(\\gamma(t))=(t^2+(1+t^2),\\ t(1+t^2))=(1+2t^2,t+t^3)$, whose derivative at $1$ is $(4,4)$. The listed options do not include this value, so this question is invalid." + }, + { + "run": "multilingual-20261025-01-en-r2", + "model": "openai/gpt-6-luna", + "language": "en", + "replicate": 2, + "chatId": "chat_mvo7exl3_e1fe93cd", + "source": "messages.3.content.1.arguments.explanation", + "raw": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$.", + "oldDisplay": "For $F=(F_1,F_2)$, each row contains the partial derivatives of one output component: $J_F=\\begin{pmatrix}\\partial F_1/\\partial x&\\partial F_1/\\partial y\\\\\\partial F_2/\\partial x&\\partial F_2/\\partial y\\end{pmatrix}=\\begin{pmatrix}2x&1\\y&x\\end{pmatrix}$. Substituting $(x,y)=(1,2)$ gives $\\begin{pmatrix}2&1\\\\2&1\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-pt-r2", + "model": "openai/gpt-6-luna", + "language": "pt", + "replicate": 2, + "chatId": "chat_mvo7wn6c_d63ef3f1", + "source": "messages.7.content.1.arguments.explanation", + "raw": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível.", + "oldDisplay": "Para uma matriz $2\\times2$, $\\det\\begin{pmatrix}a&b\\c&d\\end{pmatrix}=ad-bc$. Logo, $\\det(C)=2\\cdot3-1\\cdot5=1$. O determinante é o fator assinado de escala de área: valor absoluto $1$ preserva área, e sinal positivo indica que a orientação não é invertida. Como o determinante é diferente de zero, $C$ é invertível." + }, + { + "run": "multilingual-20261025-01-es-r2", + "model": "openai/gpt-6-luna", + "language": "es", + "replicate": 2, + "chatId": "chat_mvo8j89a_f6fc1cc0", + "source": "messages.3.content.1.arguments.explanation", + "raw": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$.", + "oldDisplay": "La regla de la cadena es $D(F\\circ g)=DF(g(s,t))Dg(s,t)$. Aquí $g(1,2)=(3,2)$, $DF(u,v)=\\begin{pmatrix}2u&1\\v&u\\end{pmatrix}$ y $Dg(s,t)=\\begin{pmatrix}1&1\\t&s\\end{pmatrix}$. Así, $\\begin{pmatrix}6&1\\\\2&3\\end{pmatrix}\\begin{pmatrix}1&1\\\\2&1\\end{pmatrix}=\\begin{pmatrix}8&7\\\\8&5\\end{pmatrix}$." + }, + { + "run": "multilingual-20261025-01-id-r2", + "model": "openai/gpt-6-luna", + "language": "id", + "replicate": 2, + "chatId": "chat_mvo9mesu_dbb56f1f", + "source": "messages.9.content.1.arguments.explanation", + "raw": "Untuk $\\begin{pmatrix}a&b\\\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$.", + "oldDisplay": "Untuk $\\begin{pmatrix}a&b\\c&d\\end{pmatrix}$, determinannya $ad-bc$. Jadi $\\det(C)=2\\cdot3-5\\cdot1=1$." + } + ] +} diff --git a/tests/fixtures/tool-rendering/mixed-escaping-regressions.json b/tests/fixtures/tool-rendering/mixed-escaping-regressions.json new file mode 100644 index 0000000..5489f6a --- /dev/null +++ b/tests/fixtures/tool-rendering/mixed-escaping-regressions.json @@ -0,0 +1,19 @@ +{ + "provenance": "multilingual-20261025-01; original collection findings, not the failed r1 rendering", + "cases": [ + { + "chatId": "chat_mvo6kxzl_540b570e", + "source": "messages.1.content.2.arguments.explanation", + "model": "z-ai/glm-5.3-flash", + "raw": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\\\cdot3+2\\\\cdot0 & 1\\\\cdot1+2\\\\cdot2\\\\\\\\ 0\\\\cdot3+1\\\\cdot0 & 0\\\\cdot1+1\\\\cdot2\\\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。", + "oldDisplay": "乘法规则是\"左行的元素依次乘右列的元素再相加\":\\n$$\\nAB=\\begin{pmatrix}1\\cdot3+2\\cdot0 & 1\\cdot1+2\\cdot2\\\\\\\\ 0\\cdot3+1\\cdot0 & 0\\cdot1+1\\cdot2\\end{pmatrix}=\\begin{pmatrix}3 & 5\\\\\\\\ 0 & 2\\end{pmatrix}\\n$$\\n选项 b 其实是 $BA$($\\begin{pmatrix}3 & 7\\\\\\\\ 0 & 2\\end{pmatrix}$)——顺序交换后结果不同,这正是矩阵乘法不交换的例子。选项 c 是对应位置直接相加,那是加法不是乘法。" + }, + { + "chatId": "chat_mvo9oquq_74bfd7f6", + "source": "messages.3.content.1.arguments.explanation", + "model": "xiaomi/mimo-v2.6-flash", + "raw": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\\\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\\\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\\\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\\\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\\\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif.", + "oldDisplay": "Perkalian matriks $AB$ = baris-kolom: elemen $(i,j)$ hasil adalah titik-antara baris $i$ dari $A$ dengan kolom $j$ dari $B$.\\n\\n- $(1,1): (1,2)\\cdot(2,0) = 1\\cdot2 + 2\\cdot1 = 4$\\n- $(1,2): (1,2)\\cdot(0,2) = 1\\cdot0 + 2\\cdot2 = 4$\\n- $(2,1): (3,4)\\cdot(2,0) = 3\\cdot2 + 4\\cdot1 = 10$\\n- $(2,2): (3,4)\\cdot(0,2) = 3\\cdot0 + 4\\cdot2 = 8$\\n\\n$$AB = \\begin{pmatrix} 4 & 4 \\\\\\\\ 10 & 8 \\end{pmatrix}$$\\n\\nPilihan lain adalah tiga kesalahan berbeda: $\\begin{pmatrix} 2 & 4 \\\\ 7 & 10 \\end{pmatrix}$ adalah $BA$ (urutan dibalik), $\\begin{pmatrix} 4 & 10 \\\\ 4 & 8 \\end{pmatrix}$ adalah transpos hasil (peletakan elemen tertukar), dan $\\begin{pmatrix} 2 & 0 \\\\ 3 & 8 \\end{pmatrix}$ adalah perkalian elemen-per-elemen, bukan baris-kolom. Perhatikan juga bahwa $AB \\neq BA$ — perkalian matriks tidak komutatif." + } + ] +} diff --git a/tests/repair-math-display.test.tsx b/tests/repair-math-display.test.tsx index 117f123..13673d0 100644 --- a/tests/repair-math-display.test.tsx +++ b/tests/repair-math-display.test.tsx @@ -12,7 +12,7 @@ import { PracticeDock } from "../components/practice-dock.tsx"; import { QuestionDock } from "../components/question-dock.tsx"; import { LatexGlyph } from "../components/latex-glyph.tsx"; import { ProblemCard, ConcludeCard } from "../components/practice-cards.tsx"; -import { MessageResponse } from "../components/ai-elements/message.tsx"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; import { repairMathForDisplay, healOverescapedCommands, restoreMathEscapes, parseQuizInput, parseAskInput, parseExplainInput, activeProblemOf, projectView, @@ -206,17 +206,17 @@ describe("practice display surfaces", () => { assert.ok(!plain.glyphs.includes("∇")); // Same unchanged assistant/user MessageResponse path. }); - it("teaching quiz/ask/explain still repair the same bytes without mutating their inputs", () => { + it("teaching shape parsing retains source bytes; presentation alone recovers them", () => { const quiz = { question: doubled, options: [{ label: "yes" }, { label: "no" }], correctAnswer: "yes", explanation: debris }; const ask = { question: doubled }; const explain = { question: doubled, correctAnswer: debris }; const before = structuredClone({ quiz, ask, explain }); - assert.equal(parseQuizInput(quiz)?.question, clean); - assert.equal(parseQuizInput(quiz)?.explanation, `$${repairedDebris}$`); - assert.equal(parseAskInput(ask)?.question, clean); - assert.equal(parseExplainInput(explain)?.question, clean); - assert.equal(parseExplainInput(explain)?.correctAnswer, `$${repairedDebris}$`); - assertMath(renderToStaticMarkup({parseAskInput(ask)!.question})); + assert.equal(parseQuizInput(quiz)?.question, doubled); + assert.equal(parseQuizInput(quiz)?.explanation, debris); + assert.equal(parseAskInput(ask)?.question, doubled); + assert.equal(parseExplainInput(explain)?.question, doubled); + assert.equal(parseExplainInput(explain)?.correctAnswer, debris); + assertMath(renderToStaticMarkup({parseAskInput(ask)!.question})); assert.deepEqual({ quiz, ask, explain }, before); }); diff --git a/tests/tool-content-interaction.test.tsx b/tests/tool-content-interaction.test.tsx new file mode 100644 index 0000000..b23f8c6 --- /dev/null +++ b/tests/tool-content-interaction.test.tsx @@ -0,0 +1,78 @@ +import React, { act } from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createRoot } from "react-dom/client"; +import { parseHTML } from "linkedom"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { newAnswerResult, type QuestionAnswerData } from "../src/core/index.ts"; + +const dom = parseHTML(""); +Object.assign(globalThis, { window: dom.window, document: dom.document, Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, IS_REACT_ACT_ENVIRONMENT: true }); + +test("actual select → submit → saved result → historical Why uses source data and presentation-only recovery", async () => { + const question = String.raw`Find $\\nabla f$ for $\begin{pmatrix}a&b\\c&d\end{pmatrix}$.`; + const label = String.raw`$\\nabla f$`; + const explanation = String.raw`First paragraph.\n\nThe matrix is $\begin{pmatrix}p&q\\r&s\end{pmatrix}$ and $\\nabla f$ is its gradient.`; + const input = { question, options: [{ label }, { label: "no" }], correctAnswer: label, explanation }; + const before = structuredClone(input); + let answer: QuestionAnswerData | undefined; + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render( {}} onSubmit={a => { answer = a; }} />)); + assert.equal(host.querySelectorAll("mtr").length, 2, "the live matrix retains two rows"); + const choice = [...host.querySelectorAll('[role="radio"]')].find(button => button.querySelector(".katex"))!; + assert.ok(choice); + await act(async () => choice.click()); + assert.equal(choice.getAttribute("aria-checked"), "true"); + const submit = [...host.querySelectorAll("button")].find(button => button.textContent?.includes("Submit answer"))!; + assert.equal(submit.disabled, false); + // linkedom does not implement the browser's default button→form + // action; dispatch its native submit event through React instead. + await act(async () => submit.closest("form")!.dispatchEvent(new window.Event("submit", { bubbles: true, cancelable: true }))); + assert.ok(answer); + assert.equal(answer.questionText, question); + assert.equal(answer.explanation, explanation); + assert.equal(answer.answers[0]!.value, label, "display recovery must not alter the submitted identity"); + assert.equal(answer.answers[0]!.label, label); + assert.equal(answer.correct, true); + const persisted = JSON.parse(JSON.stringify(newAnswerResult("interactive_source", "probe", answer))); + assert.deepEqual(persisted.details, JSON.parse(JSON.stringify(answer))); + assert.equal(persisted.details.explanation, explanation); + const saved = structuredClone(persisted.details); + await act(async () => root.render()); + const why = [...host.querySelectorAll("button")].find(button => button.textContent === "Why")!; + await act(async () => why.click()); + assert.equal(host.querySelectorAll("mtr").length, 4, "question and revealed explanation retain their rows"); + const paragraphs = host.querySelectorAll('[data-testid="tucked-explanation"] p'); + assert.equal(paragraphs.length, 2, "recovered paragraphs remain distinct in Why"); + assert.ok(paragraphs[0]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[0]!.parentElement!.classList.contains("[&_p]:inline")); + const annotations = [...host.querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + assert.equal(annotations.filter(t => t === String.raw`\nabla f`).length, 3); + assert.deepEqual(persisted.details, saved); + assert.deepEqual(input, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); + +test("committed-answer reveals keep the first paragraph inline without flattening later paragraphs", async () => { + const committed = String.raw`First paragraph.\n\nSecond paragraph with $\\nabla f$ and $\nu$.`; + const answer: QuestionAnswerData = { kind: "explain", questionText: "Explain the gradient.", answers: [], text: "Synthetic answer", committed, dontKnow: false, answeredAt: "2026-10-26T00:00:00.000Z" }; + const input = { question: answer.questionText, correctAnswer: committed }; + const before = structuredClone(answer); + const host = document.createElement("div"); document.body.append(host); + const root = createRoot(host); + try { + await act(async () => root.render()); + const paragraphs = host.querySelectorAll("p"); + assert.equal(paragraphs.length, 3, "question plus two committed-answer paragraphs"); + assert.ok(paragraphs[1]!.parentElement!.classList.contains("[&>p:first-child]:inline")); + assert.ok(!paragraphs[1]!.parentElement!.classList.contains("[&_p]:inline")); + assert.equal(host.querySelectorAll(".katex-error").length, 0); + assert.deepEqual(answer, before); + } finally { await act(async () => root.unmount()); host.remove(); } +}); diff --git a/tests/tool-content-presentation.test.tsx b/tests/tool-content-presentation.test.tsx new file mode 100644 index 0000000..35c13d0 --- /dev/null +++ b/tests/tool-content-presentation.test.tsx @@ -0,0 +1,164 @@ +import React from "react"; +(globalThis as { React?: typeof React }).React = React; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { parseHTML } from "linkedom"; +import { MessageResponse, ToolContentResponse } from "../components/ai-elements/message.tsx"; +import { QuestionDock } from "../components/question-dock.tsx"; +import { AnsweredCard } from "../components/answered-card.tsx"; +import { answerForPresentation, buildAnswer, normalizeOptions, parseAskInput, parseExplainInput, parseQuizInput, + repairMathForDisplay, resolveCorrect, type QuestionAnswerData, type QuestionData } from "../src/core/index.ts"; + +const fixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/matrix-regressions.json", import.meta.url), "utf8")) as { + cases: Array<{ chatId: string; source: string; raw: string; oldDisplay: string }>; +}; +const a = String.raw`$\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; +const b = String.raw`$\begin{pmatrix}p&q\\r&s\end{pmatrix}$`; +const brokenA = a.replace(String.raw`\\c`, String.raw`\c`); +const brokenB = b.replace(String.raw`\\r`, String.raw`\r`); +const doubled = String.raw`$\\nabla f$`; +const input = { question: a, options: [{ label: a, value: "a" }, { label: b, value: "b" }], correctAnswer: "a", explanation: a }; +const dom = (html: string) => parseHTML(`${html}`).document; +const render = (text: string, recovered = true) => renderToStaticMarkup( + recovered ? {text} : {text}); +const tex = (html: string) => [...dom(html).querySelectorAll('annotation[encoding="application/x-tex"]')].map(n => n.textContent); + +for (const fixture of fixtures.cases) test(`preserves corpus matrix rows: ${fixture.chatId} ${fixture.source}`, () => { + assert.notEqual(fixture.raw, fixture.oldDisplay); + assert.equal(repairMathForDisplay(fixture.raw), fixture.raw); + const expected = dom(render(fixture.raw, false)); + const actual = dom(render(fixture.raw)); + assert.deepEqual(tex(actual.body.innerHTML), tex(expected.body.innerHTML)); + assert.equal(actual.querySelectorAll("mtr").length, expected.querySelectorAll("mtr").length); + assert.ok(actual.querySelectorAll("mtr").length > dom(render(fixture.oldDisplay, false)).querySelectorAll("mtr").length); + assert.equal(actual.querySelectorAll(".katex-error").length, 0); +}); + +test("generic nested environments, comments, and bare row-leading command names are protected", () => { + for (const environment of ["matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "smallmatrix", "array", "aligned", "cases"]) { + const source = `$\\begin{${environment}}a&b\\\\beta&d\\end{${environment}}$`; + assert.equal(repairMathForDisplay(source), source); + } + const nested = "$$\n" + String.raw`\begin{aligned}x&=\begin{pmatrix}a&b\\c&d\end{pmatrix}\\% \end{aligned} is a comment` + "\n" + String.raw`r&=s\end{aligned}` + "\n$$"; + assert.equal(repairMathForDisplay(nested), nested); + const spaced = String.raw`$\begin {pmatrix}a&b\\c&d\end {pmatrix}$`; + assert.equal(repairMathForDisplay(spaced), spaced); + const mixed = String.raw`$\\lambda+\begin{pmatrix}a&b\\c&d\end{pmatrix}$`; + assert.equal(repairMathForDisplay(mixed), mixed.replace(String.raw`\\lambda`, String.raw`\lambda`)); +}); + +test("corpus document framing and final-display percentages recover without new errors", () => { + const cases = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/document-percent-regressions.json", import.meta.url), "utf8")); + for (const fixture of cases) { + const recovered = repairMathForDisplay(fixture.raw); + const document = dom(render(fixture.raw)); + assert.equal(document.querySelectorAll(".katex-error").length, 0, fixture.chatId); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + if (fixture.kind === "percent-at-display-end") { + assert.ok([...document.querySelectorAll(".katex-html")].some(node => node.textContent?.includes("%")), fixture.chatId); + assert.ok(!recovered.includes("\\\\%"), fixture.chatId); + } else { + assert.match(recovered, /\n\$\$\n/); + assert.match(recovered, /\n\$\$$/); + } + } +}); +test("mixed-escaped environment markers retain existing recovery rather than treating an opener as native", () => { + const mixedFixtures = JSON.parse(readFileSync(new URL("./fixtures/tool-rendering/mixed-escaping-regressions.json", import.meta.url), "utf8")) as typeof fixtures; + assert.equal(mixedFixtures.cases.length, 2); + for (const fixture of mixedFixtures.cases) { + const recovered = repairMathForDisplay(fixture.raw); + assert.equal(repairMathForDisplay(recovered), recovered, fixture.chatId); + assert.doesNotMatch(recovered, /\\\\end\{/); + assert.equal(dom(render(fixture.raw)).querySelectorAll(".katex-error").length, 0); + } +}); + +test("presentation-equivalent labels cannot make a source-invalid correctAnswer gradeable", () => { + const source = { question: "Which?", options: [{ label: String.raw`$\nabla f$` }, { label: "no" }], + correctAnswer: doubled, explanation: "A model-authored identity mismatch" }; + const before = structuredClone(source); + assert.equal(repairMathForDisplay(doubled), source.options[0]!.label); + assert.equal(parseQuizInput(source), undefined); + assert.deepEqual(source, before); +}); + +test("shape parsers retain source text and identities; recovery is presentation only", () => { + const quiz = { ...input, question: doubled, explanation: "$\forall x$", options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const ask = { question: doubled, options: [{ label: doubled, value: doubled, description: doubled }, { label: "no" }] }; + const explain = { question: doubled, correctAnswer: "$\forall x$" }; + const before = structuredClone({ quiz, ask, explain }); + assert.equal(parseQuizInput(quiz)!.question, doubled); + assert.equal(parseQuizInput(quiz)!.explanation, quiz.explanation); + assert.equal(parseQuizInput(quiz)!.correctAnswer, doubled); + assert.deepEqual(parseAskInput(ask), ask); + assert.deepEqual(parseExplainInput(explain), explain); + assert.deepEqual(tex(render(doubled)), [String.raw`\nabla f`]); + assert.deepEqual(tex(render(doubled, false)), [String.raw`\\nabla f`]); + assert.deepEqual({ quiz, ask, explain }, before); +}); + +test("the live dock recovers only its rendering, including question, context and option labels", () => { + const source = { question: doubled, details: doubled, options: [{ label: doubled }, { label: "no" }] }; + const before = structuredClone(source); + const html = renderToStaticMarkup( {}} onSubmit={() => {}} />); + assert.deepEqual(tex(html), Array(3).fill(String.raw`\nabla f`)); + assert.deepEqual(source, before); +}); + +test("answers snapshot source text, not recovered labels or identities", () => { + const source = { ...input, question: doubled, explanation: doubled, options: [{ label: doubled }, { label: "no" }], correctAnswer: doubled }; + const parsed = parseQuizInput(source)!; + const options = normalizeOptions(parsed.options); + const qd: QuestionData = { id: "source", kind: "quiz", question: parsed.question, mode: "single-select", options, + correctIndices: resolveCorrect(parsed.correctAnswer, options).indices, explanation: parsed.explanation, createdAt: "synthetic" }; + const answer = buildAnswer(qd, { values: [doubled], confidence: 70 }, "probe"); + assert.equal(answer.questionText, doubled); + assert.equal(answer.options![0]!.label, doubled); + assert.equal(answer.answers[0]!.value, doubled); + assert.equal(answer.answers[0]!.label, doubled); + assert.equal(answer.explanation, doubled); + assert.equal(answer.correct, true); + const before = structuredClone(answer); + assert.deepEqual(tex(renderToStaticMarkup()), Array(2).fill(String.raw`\nabla f`)); + assert.deepEqual(answer, before); +}); + +function historical(): QuestionAnswerData { + return { kind: "quiz", questionText: brokenA, options: [{ index: 1, label: brokenB }, { index: 2, label: brokenA }], + answers: [{ index: 1, label: brokenB, value: "b" }], correct: false, correctIndices: [2], dontKnow: false, + explanation: brokenA, confidence: 70, answeredAt: "synthetic" }; +} +test("historical source join restores matrices while retaining recorded order, answer and grade", () => { + const answer = historical(); + const before = structuredClone({ answer, input }); + const projection = answerForPresentation(answer, input); + assert.equal(projection.questionText, a); + assert.equal(projection.explanation, a); + assert.deepEqual(projection.options, [{ index: 1, label: b }, { index: 2, label: a }]); + assert.equal(projection.answers, answer.answers); + assert.equal(projection.correctIndices, answer.correctIndices); + assert.equal(projection.correct, false); + assert.equal(projection.confidence, 70); + const html = renderToStaticMarkup(); + assert.deepEqual(tex(html), [a.slice(1,-1), b.slice(1,-1), a.slice(1,-1)]); + assert.match(html, /incorrect/); + assert.deepEqual({ answer, input }, before); +}); + +test("historical ambiguous options and absent/invalid original calls retain their snapshots", () => { + const source = { ...input, options: [...input.options, { label: String.raw`$\begin{matrix}x\\y\end{matrix}$`, value: "c" }] }; + const answer = { ...historical(), answers: [], dontKnow: true, options: [ + { index: 1, label: brokenB }, { index: 2, label: brokenA }, { index: 3, label: String.raw`$\begin{matrix}x\y\end{matrix}$` }, + ] }; + assert.deepEqual(answerForPresentation(answer, source).options, answer.options); + assert.equal(answerForPresentation(answer, undefined), answer); + assert.equal(answerForPresentation(answer, { question: a }), answer); + const dismissed = { kind: "quiz", dismissed: true } as QuestionAnswerData; + assert.equal(answerForPresentation(dismissed, input), dismissed); + const before = structuredClone(answer); + renderToStaticMarkup(); + assert.deepEqual(answer, before); +}); diff --git a/tests/tool-parts.test.ts b/tests/tool-parts.test.ts index baccdfe..a1576f5 100644 --- a/tests/tool-parts.test.ts +++ b/tests/tool-parts.test.ts @@ -12,7 +12,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { quizInputProblems } from "../src/core/question-core.ts"; import { Value } from "typebox/value"; -import { restoreMathEscapes } from "../src/core/math-delims.ts"; +import { repairMathForDisplay, restoreMathEscapes } from "../src/core/math-delims.ts"; import { parseAskInput, parseProbeDoneInput, @@ -170,7 +170,7 @@ 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)", () => { + it("presentation restores debris from parsed source fields (question + options + explanation)", () => { const parsed = parseQuizInput({ question: "What does \\(\\forall x\\, \\varphi(x)\\) mean?", options: [ @@ -182,18 +182,18 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 }); 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."); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "\\(\\varphi\\) is true when every element satisfies it"); + assert.equal(repairMathForDisplay(parsed.options[1]!.label), "\\(\\forall\\) and \\(\\exists\\) are just shorthand"); + assert.equal(repairMathForDisplay(parsed.explanation), "\\(\\forall\\) quantifies over the domain."); }); - it("parseAskInput restores debris in ask option labels too", () => { + it("presentation restores debris from parsed 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"); + assert.equal(repairMathForDisplay(parsed.options![0]!.label), "\\(\\forall\\) rigor"); }); it("restores TAB debris (the \\to case) inside spans only", () => { @@ -204,7 +204,7 @@ describe("LaTeX-escape restoration (the JSON-debris fix, owner ruling 2026-09-26 explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p\\to q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p\\to q\\) valid?"); }); it("control chars OUTSIDE math spans are left alone (real prose/pasted text)", () => { @@ -253,7 +253,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Evaluate \\[\\left( a \\right)\\] carefully."); + assert.equal(repairMathForDisplay(parsed.question), "Evaluate \\[\\left( a \\right)\\] carefully."); }); it("sloppy \\neq in display math stays broken — the accepted cost (newline kept, byte-identical to a real line break)", () => { @@ -276,7 +276,7 @@ describe("display-math newline rule (Option A, owner ruling 2026-09-27)", () => explanation: "", }); assert.ok(parsed); - assert.equal(parsed.question, "Is \\(p \\neq q\\) valid?"); + assert.equal(repairMathForDisplay(parsed.question), "Is \\(p \\neq q\\) valid?"); }); }); @@ -289,9 +289,9 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( explanation: "Here $p\to q$ is an implication.", }); assert.ok(parsed); - assert.equal(parsed.question, "What does $\\forall x$ mean?"); - assert.equal(parsed.options[0]!.label, "$\\varphi$ holds"); - assert.equal(parsed.explanation, "Here $p\\to q$ is an implication."); + assert.equal(repairMathForDisplay(parsed.question), "What does $\\forall x$ mean?"); + assert.equal(repairMathForDisplay(parsed.options[0]!.label), "$\\varphi$ holds"); + assert.equal(repairMathForDisplay(parsed.explanation), "Here $p\\to q$ is an implication."); }); it("keeps the same six-character inline recovery map", () => { @@ -320,21 +320,21 @@ describe("dollar-delimiter recognition — existing escape restoration rules", ( } }); - it("preserves legacy replacement frequency when a span has multiple debris characters", () => { + it("restores all debris in one pass consistently across delimiter syntaxes", () => { const body = "p\to q, \rho"; const legacy = restoreMathEscapes(`\\(${body}\\)`).slice(2, -2); assert.equal(restoreMathEscapes(`$${body}$`), `$${legacy}$`); - assert.equal(legacy, "p\\to q, \rho", "the existing first-match-only behavior is unchanged"); + assert.equal(legacy, "p\\to q, \\rho", "recovery converges instead of requiring a second render"); }); it("shares dollar recovery with ask and free-recall fields", () => { const ask = parseAskInput({ question: "Goal?", options: [{ label: "$\x0corall$ rigor" }, { label: "Intuition" }] }); assert.ok(ask); - assert.equal(ask.options![0]!.label, "$\\forall$ rigor"); + assert.equal(repairMathForDisplay(ask.options![0]!.label), "$\\forall$ rigor"); const explain = parseExplainInput({ question: "Explain $p\to q$", correctAnswer: "$p\neq q$ is different." }); assert.ok(explain); - assert.equal(explain.question, "Explain $p\\to q$"); - assert.equal(explain.correctAnswer, "$p\\neq q$ is different."); + assert.equal(repairMathForDisplay(explain.question), "Explain $p\\to q$"); + assert.equal(repairMathForDisplay(explain.correctAnswer), "$p\\neq q$ is different."); }); it("leaves non-math debris, escaped opening dollars, and code examples untouched", () => { @@ -401,13 +401,9 @@ describe("parseExplainInput: the dock's display seam for explain calls", () => { }); }); -describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)", () => { - // xiaomi/mimo-v2.6-pro and deepseek-v4.1-flash stochastically DOUBLE - // LaTeX command backslashes inside JSON tool arguments — per-call and - // per-field (gradient-descent msg 3: question doubled, explanation and - // options in the SAME call clean). restoreMathEscapesDeep now rides - // healOverescapedCommands after the debris restore, so doubled spans - // heal at every display parse: live dock, settled cards, old records. +describe("over-escaped command recovery after shape parsing", () => { + // Keep these real authoring shapes, but recover only for presentation. + // Parsed source is also used to build durable answers and identities. const CLEAN = "Let $f : \\mathbb{R}^n \\to \\mathbb{R}$ be differentiable at $x$, with $\\nabla f(x) \\neq 0$. Among all unit directions $\\|u\\|_2 = 1$, which makes $D_u f(x)$ most negative?"; const DOUBLE = (s: string): string => s.replace(/\\/g, "\\\\"); @@ -420,19 +416,21 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" explanation: "By Cauchy\u2013Schwarz: $$u^\\star = -\\frac{\\nabla f(x)}{\\|\\nabla f(x)\\|_2}$$", }; - it("parseQuizInput heals a doubled question field (the gradient-descent msg-3 shape)", () => { + it("presentation heals a doubled question field (the gradient-descent msg-3 shape)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(CLEAN) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(CLEAN), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); // The CLEAN sibling fields of the same call stay byte-identical. assert.equal(parsed.options[0]!.label, BASE.options[0]!.label); assert.equal(parsed.explanation, BASE.explanation); }); - it("parseQuizInput heals a quadrupled field (the agent's over-correction, msg 6)", () => { + it("presentation heals a quadrupled field (the agent's over-correction, msg 6)", () => { const parsed = parseQuizInput({ ...BASE, question: DOUBLE(DOUBLE(CLEAN)) }); assert.ok(parsed, "parses"); - assert.equal(parsed.question, CLEAN); + assert.equal(parsed.question, DOUBLE(DOUBLE(CLEAN)), "shape parsing retains source"); + assert.equal(repairMathForDisplay(parsed.question), CLEAN); }); it("doubled option labels and explanations heal too (the inference-runtime msg-7 shape)", () => { @@ -448,10 +446,10 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" assert.ok(parsed, "parses"); assert.equal(parsed.options[0]!.label, "$mkn$"); assert.equal(parsed.options[1]!.label, "$2mkn$"); - assert.equal(parsed.explanation, "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); + assert.equal(repairMathForDisplay(parsed.explanation), "$$c_{ij} = \\sum_{\\ell=1}^{k} a_{i\\ell}\\, b_{\\ell j}$$"); }); - it("parseAskInput heals doubled ask-option descriptions the same way", () => { + it("presentation heals doubled ask-option descriptions the same way", () => { const parsed = parseAskInput({ question: "Depth?", options: [ @@ -460,7 +458,7 @@ describe("over-escaped command heal at the parse seam (owner ruling 2026-10-15)" ], }); assert.ok(parsed, "parses"); - assert.equal(parsed.options![0]!.description, "why $-\\nabla f$ is steepest descent"); + assert.equal(repairMathForDisplay(parsed.options![0]!.description!), "why $-\\nabla f$ is steepest descent"); }); it("clean records pass through byte-identical (no over-heal)", () => {