Meet Modi
Back to Blog
·6 min

A dollar sign with two jobs

"Solve $3x + 7 = 22$" and "a shirt costs $20" use the same character to mean two different things. The renderer couldn't tell them apart, and neither could our regex on the first try.

By Meet Modi
Text SanitizationLLM OutputRendering

A batch of math quiz questions came back from the generation service and about one in twenty rendered as garbage: half a fraction, a stray brace, or a number wrapped in dollar signs that displayed as literal dollar signs instead of an equation. One question read "solve $3x + 7 = 22" with no closing delimiter in sight, just a currency-looking fragment sitting where an equation should be.

The model writes both currency ("a shirt costs $20") and math delimiters ("$3x + 7 = 22$") using the same character. From the raw text alone, our renderer had no way to know which job a given dollar sign was doing. Making it worse: JSON-escaping in transit sometimes ate backslash sequences, turning \frac into a broken fragment, and unescaped braces inside math blocks broke rendering outright.

The fix: three passes, in order, none of them clever

We didn't try to get the model to stop doing this. The fix is a small sanitizer that runs on every generated question, in a fixed sequence, each pass narrow enough to reason about on its own.

function sanitizeMathText(raw: string): string {
  let text = restoreEscapedBackslashes(raw);
  text = disambiguateCurrency(text);
  text = stripExcessBraces(text);
  return text;
}

function restoreEscapedBackslashes(text: string): string {
  // JSON transit sometimes collapses \\frac into \frac then into frac.
  // Restore common LaTeX command names that lost their backslash.
  const knownCommands = ["frac", "sqrt", "cdot", "pi", "times", "leq", "geq"];
  let result = text;
  for (const cmd of knownCommands) {
    result = result.replace(new RegExp(`(?<!\\\\)\\b${cmd}\\b`, "g"), `\\${cmd}`);
  }
  return result;
}

function disambiguateCurrency(text: string): string {
  // A bare $number is currency only if there's no matching closing $
  // on the same line. Real math delimiters come in pairs.
  return text
    .split("\n")
    .map((line) => {
      const dollarCount = (line.match(/\$/g) || []).length;
      if (dollarCount % 2 !== 0) {
        return line.replace(/\$(\d)/g, "\\$$1");
      }
      return line;
    })
    .join("\n");
}

function stripExcessBraces(text: string): string {
  // Inside detected math blocks, collapse runs of unescaped braces
  // that have no matching pair down to nothing rather than let them
  // break the renderer.
  return text.replace(/\$([^$]*)\$/g, (match, inner) => {
    const cleaned = inner.replace(/(?<!\\)[{}](?![^{}]*[{}])/g, "");
    return `$${cleaned}$`;
  });
}

Order matters here. Backslash restoration has to run first because the currency check and the brace stripper both need to see real LaTeX commands, not mangled fragments, to make correct decisions. Currency disambiguation runs before brace stripping because it operates on whole lines, deciding whether a dollar sign is even part of a math block at all before the brace logic starts touching what's inside one.

We tested this against a real collected set of every failure mode we'd actually seen in production output, not synthetic examples. That set kept growing as new edge cases showed up, and each new failure became a fixture in the test before it became a fix in the code.

Questions people actually ask

why not just tell the model to always escape dollar signs

We tried a version of this in the prompt, asking the model to escape currency dollar signs as \$ and leave math delimiters bare. It followed the instruction inconsistently, maybe 70% of the time, and the failures were silent: a plain $20 that looked fine until it hit the renderer. Prompt instructions are a request, not a guarantee, and this needed to be a guarantee.

how do you tell currency apart from math with regex alone

Parity. Real math delimiters always come in pairs on the same line, one opening $ and one closing $. A currency mention is structurally just one unpaired $ followed by a number. So we count dollar signs per line: an even count means it's plausibly a matched math block, an odd count means at least one of them is standing alone and gets treated as currency.

what happens to a currency value that has no closing delimiter

It gets escaped to \$ so the renderer treats it as a literal character instead of a delimiter start. That's exactly the "solve $3x + 7 = 22" case from earlier, if a stray unpaired $ shows up near a number outside a real equation, we'd rather show a literal dollar sign than have the renderer wait forever for a closing delimiter that doesn't exist.

do you still need this if you switch model providers

Yes. We tested this assumption directly when evaluating a second provider for a fallback path, and the same ambiguity showed up in its output, just at a different rate. The dollar sign overloading isn't a quirk of one model, it's a property of the training data every general-purpose model learns from, which uses $ for both purposes constantly. The sanitizer stays regardless of which provider is generating the text.

What I'd do differently

I'd build the failure-mode test set before writing the first regex, not after the second production incident. Every pass in the sanitizer exists because a specific real example broke, and if I'd collected ten of those examples up front instead of one at a time, the three-pass order would have been obvious from the start instead of something I backed into.

More Posts