Skip to content

fix: stop KaTeX swallowing currency in assistant messages - #1135

Open
philmerrell wants to merge 2 commits into
developfrom
claude/katex-markdown-dollar-conflict-a4948e
Open

philmerrell wants to merge 2 commits into
developfrom
claude/katex-markdown-dollar-conflict-a4948e

Conversation

@philmerrell

Copy link
Copy Markdown
Contributor

The bug

Reported on a real conversation. The assistant wrote:

A table slide showing Q1 ($100K), Q2 ($115K), and the total ($215K)

and the SPA rendered:

Q1 (100K),Q2(115K), total ($215K)

The prose between the amounts disappeared, not just the numbers.

Cause

streaming-text.component.ts enabled ngx-markdown's katex plugin with no
katexOptions, so its DEFAULT_KATEX_OPTIONS applied. Those add
{ left: '$', right: '$' } — which KaTeX upstream deliberately leaves
commented out in auto-render.js, with the reason attached:

// LaTeX uses $…$, but it ruins the display of normal `$` in text:
// {left: "$", right: "$", display: false},

renderMathInElement pairs delimiters positionally within a text node, so
any two currency amounts on one line become one formula.

Why the obvious fixes don't work

Three things looked like answers and are not. Each is documented in the code
so nobody re-derives them:

$ does nothing. The system prompt already told the model to escape
$ as an HTML entity. marked passes the entity into innerHTML, the browser
decodes it to a literal $ in the text node, and KaTeX walks the DOM after
that — the entity form breaks identically. Verified against the app's own
KaTeX build. It only ever succeeded at leaking $100K into generated
.pptx/.xlsx cells (#1126's follow-on). Rule deleted.

\(…\) was never an available fallback. ( and [ are
CommonMark-escapable, so marked strips the backslash before KaTeX runs:

marked.parse('Let \\(x^2\\) hold.')  ->  '<p>Let (x^2) hold.</p>'

So $…$ and $$…$$ were the only working delimiters. Dropping $…$ alone
would have left no inline math at all.

Telling the model to write \(…\) doesn't hold. Measured, with the new
prompt confirmed live: Haiku 4.5 wrote $e^{i\pi} + 1 = 0$ anyway. $…$ is
too dominant in training data for one prompt line to override.

The fix

shared/utils/katex-math-markdown.ts — marked inline tokenizers registered at
bootstrap, which claim math spans before marked's escape and emphasis rules
reach them:

rule what it does
\(…\) / \[…\] re-emitted verbatim, so they reach the DOM at all
$…$ rewritten to \(…\), only where it cannot be currency
$$…$$ body passed through untouched
\begin{env}…\end{env} body passed through untouched

shared/utils/katex-delimiters.ts binds explicit delimiters with bare $
removed. That is what makes the guarded rule safe: KaTeX never re-pairs
dollars in the DOM, so it cannot undo the decision made with full context.

The currency test is Pandoc's plus a digit check — opening $ not followed by
whitespace or a digit, closing $ not preceded by whitespace nor followed by
a digit, no line break. Revenue rose from $1M to $2M, i.e. $r = 2$. resolves
exactly right: both amounts skipped, only $r = 2$ becomes math.

Two more breakages found while hunting edge cases

Both pre-existing, both inside $$…$$ — the delimiter survived markdown, its
contents did not:

input before after
$$a*b*c$$ $$a<em>b</em>c$$ → renders nothing renders
$$\begin{pmatrix} a \\ b \end{pmatrix}$$ \\\ → broken matrix renders

The emphasis case is the subtle one: <em> splits the text node in three, and
splitAtDelimiters works within a single text node, so the $$ stop pairing
entirely. The \\ case broke every matrix and every multi-row alignment.

Verification

Reproduced and re-verified in the running app against dev data — the reported
conversation renders correctly, and a fresh turn produced inline math, display
math, a 2×2 matrix and two currency amounts all correct in one response
(2 inline + 2 display KaTeX nodes, 0 errors, 0 leftover literal $…$).

streaming-text.katex.spec.ts renders end-to-end through the real pipeline
(marked → Angular sanitizer → KaTeX) rather than a mock. It includes a guard
test asserting the KaTeX globals are loaded, because angular.json's scripts
are emitted but not evaluated in the test build — without it every "no math
rendered" expectation would pass for the wrong reason.

  • SPA: 3357 passed
  • Backend: 8820 passed, 3 skipped
  • tsc -p tsconfig.app.json --noEmit clean

Prompt-cache note

DEFAULT_SYSTEM_PROMPT changes, so the cacheable prefix re-writes once per
session on rollout. It is four lines shorter afterwards.

🤖 Generated with Claude Code

philmerrell and others added 2 commits September 16, 2026 09:28
A message reading "Q1 ($100K), Q2 ($115K), and the total ($215K)" rendered
as "Q1 (100K),Q2(115K), total ($215K)" -- the sentence, not just the numbers.

streaming-text set ngx-markdown's `katex` attribute with no `katexOptions`,
so its DEFAULT_KATEX_OPTIONS applied. Those add `{ left: '$', right: '$' }`,
which KaTeX upstream deliberately leaves commented out in auto-render.js:

    // LaTeX uses $...$, but it ruins the display of normal `$` in text

`renderMathInElement` pairs delimiters positionally inside a text node, so
any two amounts on one line become a formula. Bind explicit delimiters that
drop it (katex-delimiters.ts).

Removing it alone would have left NO working inline math, because `\(...\)`
never worked here either: `(` and `[` are CommonMark-escapable, so marked
strips the backslash before KaTeX ever runs --

    marked.parse('Let \\(x^2\\) hold.')  ->  '<p>Let (x^2) hold.</p>'

and a system-prompt line asking the model for `\(...\)` does not reliably
override `$...$` (measured: Haiku 4.5 wrote `$e^{i\pi} + 1 = 0$` on the turn
the new instruction was live). So katex-math-markdown.ts adds marked inline
tokenizers that claim these spans before the escape rule can:

- `\(...\)` / `\[...\]`   re-emitted verbatim so they reach the DOM at all
- `$...$`                 rewritten to `\(...\)`, but only where it cannot be
                          currency -- Pandoc's rule plus a digit check, so
                          "from $1M to $2M, i.e. $r = 2$" resolves exactly
                          right. Bare `$` stays out of KATEX_DELIMITERS, which
                          is what keeps KaTeX from re-pairing dollars in the
                          DOM and undoing that decision.

Two further pre-existing breakages, found while hunting edge cases: `$$`
survived markdown but its CONTENTS did not.

- `$$a*b*c$$` -> `$$a<em>b</em>c$$`. Emphasis deletes the asterisks and splits
  the text node in three; splitAtDelimiters works within one text node, so the
  `$$` stop pairing and nothing renders at all.
- `$$\begin{pmatrix} a \\ b \end{pmatrix}$$` -> `\\` collapses to `\`, breaking
  every matrix and every multi-row alignment. Same for a bare `\begin{...}`.

Both fixed by giving `$$...$$` and `\begin{env}...\end{env}` their own
tokenizers, so marked's inline rules never touch the body.

Note that escaping is NOT an alternative: a `&#36;` is decoded to a literal
`$` when marked's output is assigned to innerHTML, which happens before KaTeX
walks the DOM. The entity form breaks identically -- verified against the
app's own KaTeX build.

Tests: streaming-text.katex.spec.ts renders end-to-end through the real
pipeline (marked -> Angular sanitizer -> KaTeX), with a guard test asserting
the globals are loaded so a "no math rendered" expectation cannot pass for the
wrong reason. angular.json's `scripts` are emitted but not evaluated in the
test build, hence the explicit imports. SPA suite: 3280 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RESPONSE GUIDELINES told the model to write non-math uses of `$` as `&#36;`.
That rule cost tokens on every turn and did nothing: marked passes the entity
through to innerHTML, the browser decodes it to a literal `$` in the text
node, and KaTeX walks the DOM after that -- so the entity form produced
byte-identical breakage. Verified against the app's own KaTeX build.

What it did accomplish was leaking the 9-character string "&#36;100K" into
generated .pptx/.xlsx cells (fixed in an earlier commit by scoping the rule
to chat markdown, which left the useless rule itself in place).

The real fix is in the SPA, where `$...$` now resolves by context rather than
by positional pairing. So the guidance simply states what renders: `$...$` or
`\(...\)` inline, `$$...$$` or `\[...\]` for display, currency as a plain `$`.
Deliberately permissive rather than prescriptive -- steering the model off
`$...$` was measured and does not hold, so the renderer handles it instead.

Net effect on the cacheable prefix: four lines shorter, and it re-writes once
per session on rollout.

Tests: TestKatexGuidance pins the entity out of the prompt. Backend suite:
8633 passed, 3 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant