# Making model output usable: sanitizing and automatic repair

> The model returns LaTeX that looks perfectly correct — and xelatex fails with no legal \end found. Using real debugging chains from LLM-generated LaTeX, this article dissects the deterministic sanitizing layer every LLM application needs between model output and usable artifact: fragment cleanup, a pre-compile pit catalog, figure placeholders, injection stripping, and error humanization — with measured gains (Extended mathchar 13→0) and the limits of what this layer should attempt.

- Canonical (HTML): https://kaguc.com/blog/output-sanitizing/
- Date: 2026-07-29


*Also in [中文](/blog/output-sanitizing-zh/). Part 3 of the Engineering LLM Applications series. The evidence base is a production-grade AI writing agent that generates LaTeX lab reports; every error message and number in this article comes from its source code and real debugging records.*

## The problem: the distance between "looks correct" and "usable"

You ask the model to generate a LaTeX lab report. The source it returns is structurally complete, every command spelled right, nothing visibly wrong; you feed it to xelatex and get `no legal \end found` — even though `\end{document}` is plainly there at the end of the file. The real cause hides in the header: the model wrote `\def\partnerID{% TODO...}`, and the `%` inside the `\def` line comments out the closing `}`. A runaway definition swallows everything after `\begin{document}`. The error location and the cause location are an entire document apart.

This is the most typical gap you hit when starting out with LLM applications: **model output and usable artifact are not the same thing**. Taking "generate LaTeX → produce a PDF" as the running example, at least three failure layers sit in between:

1. **Won't compile**: syntax looks fine, but it trips corner-case behavior of the engine-and-package combination (the `%` above; `\mathbf` below);
2. **Compiles but unusable**: compilation "succeeds", yet the artifact has 0 pages, or figures are missing and every reference renders as [?];
3. **Usable but unsafe**: under `-shell-escape`, one `\write18` in the generated body is arbitrary command execution.

The common first reaction is to tighten the prompt or retry the model. Our measured conclusion: prompt constraints lower the incidence but cannot suppress it (the verbatim source comment reads "the model routinely omits these; the prompt can't hold it down"); retries cost another generation and don't guarantee convergence. Another counter-intuitive result came from root-causing three classes of generation-quality problems: 2 of the 3 root causes were in our own backend code and template, not in the model. The right place for the fix is a layer of **deterministic sanitizing and repair** between model output and artifact — zero tokens, unit-testable, predictable behavior. It is also the output-side concretization of the [deterministic boundary](/blog/deterministic-boundary/) principle from Part 11 of this series: no fragment enters system state without passing a deterministic gate.

## Entry cleanup: strip_fragment and check_fragment

The first gate sits before a fragment enters the document, and handles domain-independent shape problems:

```python
def strip_fragment(text: str) -> str:
    """Clean a model-returned section fragment: strip Markdown code fences;
    if a full document came back by mistake, cut from the first heading to just
    before \end{document} (keeps preamble / top-level end out of the body)."""
```

Models love wrapping code in Markdown fences; asked to "return only this section", they may still disobey and return the whole document. The former gets unwrapped; the latter is cut from the first `\section`-family command to just before `\end{document}`. The companion `check_fragment` runs a health check: balanced braces, balanced `\begin`/`\end`, no `\documentclass`, non-empty — all **soft signals** that warn rather than block, because the check rules themselves can produce false positives.

## The pit catalog: deterministic rewriting in sanitize_tex

The second gate runs before compilation and is domain-specific: a **pit catalog**, every entry earned through a real debugging chain — fixing one exposed the next:

| Symptom (real error) | Root cause | Deterministic fix |
|---|---|---|
| `no legal \end found` (with `\end{document}` present) | `{%` inside a `\def` line comments out the closing `}`; runaway definition swallows the rest | `{%`→`{}%` on `\def` lines only; the multi-line `\abstract{%` idiom is left alone |
| `\textfont 11/12 undefined` (`\mathbf`); `Extended mathchar used as mathchar` (`\boldsymbol`) | both conflict with the xelatex + unicode-math combination | rewrite uniformly to unicode-math's `\symbf` |
| `Extended mathchar` / `A number should have been here` | bold inside accents (`\hat{\boldsymbol{x}}`) | drop the bold, keep the accent; both nesting orders handled |
| A string of Undefined errors | template commands like `\makeEngPage` called with their prerequisite variables unset | inject empty defaults after `\begin{document}` |
| A missing-`\item` / `\noalign` cascade | the two-column table environment instrmlist used as an itemize with `\item` | rewrite `\item Name (Model)` inside the block to `Name & Model \\`; already-correct rows untouched |

Two entries deserve expansion.

**We fixed the `\mathbf` entry in the wrong direction once.** The first fix was `\mathbf`→`\boldsymbol` (the template loads the bm package — seemingly reasonable). Only an end-to-end rerun revealed the direction was wrong: `\boldsymbol` triggers the same "Extended mathchar used as mathchar" under xelatex + unicode-math. The correct target is unicode-math's own `\symbf`. After the correction, recompiling a historical version containing 13 occurrences of `\mathbf` measured: Extended mathchar 13→0, total errors 48→35. The lesson is twofold: sanitizing rules need empirical verification of their own, or the fix itself introduces regressions; and the rewrite target must be a form that is *provably compatible with the current engine combination*, not one that is "usually equivalent".

**What value to backfill is a product judgment, not just a technical one.** The template-variable backfill carries an exception table: when the English-page author name is missing, the value of the corresponding Chinese variable is copied — author names are shared across both language pages, and that beats an author line reduced to a lone "and". But the English abstract `abstractEng` is deliberately left empty: an abstract requires translation, and pushing Chinese text onto the English page would inject wrong information. The red line for automatic repair: **backfill only defaults that are provably harmless; never guess semantics**.

## Artifact and safety: figure placeholders, font substitution, injection stripping

Some problems live not at the syntax layer but at whether the artifact is usable and safe to distribute.

**A missing figure takes down the whole document.** Models routinely `\includegraphics` figures that don't exist in the provided material. A missing figure doesn't just report `File not found` — it cascades into a truncated .aux: the whole document renders 0 pages and the PDF won't open. The fix is a deterministic substitution: any `\includegraphics` referencing a file absent from the figure directory is replaced with an `\fbox` placeholder stating "this figure was not provided — supply it or remove the reference". Zero LLM calls, two error classes eliminated at once, and a readable multi-page PDF comes out.

**Platform substitution for a hard-coded font.** The template's cls hard-codes `\setmainfont{Times New Roman}` (a Windows-bundled font); a Linux container doesn't have it, producing 100+ font errors per report. The compile step edits only the **working-directory copy**: on non-Windows platforms the font is replaced with the always-available Latin Modern Roman; the template itself is untouched. The dev machine (WSL2) taught a related lesson: Liberation's font aliases do not satisfy XeTeX/fontspec's exact-name matching — installing the real MS fonts took font errors from 6 to 0.

**Injection stripping.** The template's minted 2.x forces full `-shell-escape` (`minted.sty:1233` checks `\pdf@shellescape=1` and refuses restricted mode), and `-shell-escape` lets LaTeX run arbitrary shell commands — which makes LLM-generated body text a genuine attack surface. The sanitizing layer strips command-execution primitives from the body: `\write18`, `\ShellEscape`, `\directlua`, and the piped form `\input{|cmd}`. minted's own pygmentize invocation lives in the package layer, not the body, so syntax highlighting is unaffected. End-to-end verification: after injecting `\immediate\write18{touch ...}` and compiling, the target file was not created. The standalone distribution drops `-shell-escape` entirely — code blocks still typeset via listings, losing only syntax coloring.

## After compilation: diagnose and humanize

"Compilation succeeded" lies: under nonstopmode, missing figures and undefined references don't stop a PDF from being produced. `diagnose` deterministically tallies four items from the log — error-line count (`^!`), the list of missing figures, undefined citations, undefined cross-references — so that "succeeded" no longer masks a degraded artifact.

The tally serves two audiences. For the model, `extract_errors` picks the `!`-prefixed error lines plus context as input to the [compile self-repair](/blog/compile-self-repair/) loop (Part 6). For the user, `humanize` maps diagnostics and common errors into "one plain sentence + one clickable action" — along the lines of "2 figures missing: a.png, b.png" with a button "supply the figures, or remove the corresponding \includegraphics"; "Undefined control sequence" becomes "an undefined command was used (possibly a typo or a missing package)". The raw log is for engineers; it has no place in the product UI.

## The pipeline, and the measurements

```mermaid
flowchart TD
    A[Model output] --> B[strip_fragment: unfence / cut fragment from full doc]
    B --> C[check_fragment: balance checks, soft signals]
    C --> D[sanitize_tex: pit-catalog rewrites + exec-primitive stripping]
    D --> E[Assemble workdir: placeholder missing figures; swap font off Windows]
    E --> F[xelatex + bibtex]
    F --> G[diagnose: error count / missing figures / undefined refs]
    G -->|for the user| H[humanize: one sentence + one clickable action]
    G -->|for the model| I[extract_errors → compile self-repair loop]
```

| Measured item | Before | After |
|---|---|---|
| `\mathbf`→`\symbf` (recompiling a version with `\mathbf`×13) | Extended mathchar 13, errors 48 | 0, errors 35 |
| Photonic-crystal auto report (after the first three fixes: `%` in `\def`, math bold, bold-in-accent) | compile failure (runaway) | valid 131KB PDF |
| Real MS fonts installed on WSL2 (aliases fail exact-name matching) | 6 font errors | 0 |
| Injected `\immediate\write18{touch ...}` | — | file not created (RCE blocked) |

The sanitizing layer is pure functions throughout; when the `\symbf` fix landed, the full `pytest` run was 37 passed — every rewrite rule, every backfill exception, and the injection stripping is pinned by an assertion.

## Where not to do this

1. **The pit catalog only fixes known pits.** Every rule is bound to a specific combination (GPE template + xelatex + unicode-math + minted 2.x); a new template or engine means walking the debugging chain again. It is not a general-purpose LaTeX fixer, and should not try to become one.
2. **Regex rewriting has an inherent collateral-damage surface.** Every rule must be narrowed to the lesion: only `\def` lines, only inside the instrmlist block, already-correct rows preserved verbatim — and pinned by unit tests. One notch too wide and the rule breaks legitimate input; anything that cannot be narrowed to provably-safe should not be auto-fixed, and belongs to the model-driven compile self-repair loop instead.
3. **The ceiling of automatic repair is introducing no wrong information.** Deterministically decidable defaults (empty variables, placeholder boxes) may be backfilled; semantic gaps (the English abstract) must be left to a human or the model. Cross that line and repair becomes contamination.
4. **Sanitizing replaces neither upstream constraints nor the downstream loop.** The "LaTeX compilability rules" still belong in the system prompt (they lower incidence), and compile self-repair handles the long tail; the sanitizing layer's role is zero-token interception of high-frequency known pits. The three layers are a union, not substitutes.
5. **Injection stripping is one layer of defense in depth, not all of it.** The regex layer does not cover extreme bypasses such as catcode tricks — an acceptable trade-off for a local single-user tool; a multi-tenant server must stack harder layers (a restricted shell-escape baseline, sandboxed execution environments).

