r"""Check: math-notation rendering issues that break Pandoc/Quarto output. Five known failure modes from prior copyedit findings (commits bcc4527f4a "Normalize math rendering across QMD files" and 42209c08b0 "wave 4 — audit backlog cleanup"): 1. **Adjacent math spans** (`$\sim$$15%$`): two abutting inline-math spans render with a visible seam at print scale and the closing-`$` plus the opening-`$` collapse into a literal `$$` in some renderers. Should be a single span: `$\sim 15\%$`. Confidence: high. 2. **`100s W` literal**: "100s W" or "10s W" in body prose reads as a plural-s genitive ("hundreds of watts" → "100s W") instead of the intended approximation ("~100 watts"). The print convention is `$\sim 100$~W` or `\mathord{\sim}100`~W. Confidence: medium — could legitimately mean "hundreds" in some colloquial passages, so this needs operator review. 3. **Missing `\mu`**: literal `u` in `uJ`/`us`/`uW`/`uA`/`uF`/`uV`/`um` unit contexts in body prose (these should be `\mu`J etc.). Skip code blocks and Python cells (where `u`-prefixed names are computed values). Confidence: medium — `us` can be the English word in unrelated passages; the regex anchors on digit-adjacency to reduce false hits. 4. **Spacing around `\times`**: `5 \times 5` is fine, `5\times 5` is wrong; also `\times5` (no space after) is wrong. Per book-prose-merged.md §2 the canonical dimension form is `$N{\times}M$` (with braces) and the canonical multiplier form is `N$\times$` (number outside math). Both bare `\d\\times` and `\\times\d` patterns escape those forms. Confidence: high. 5. **Bare `%` inside math** (`$15%$`): a literal `%` inside `$...$` should be escaped as `\%`. Pandoc renders `%` as LaTeX comment start, so everything to end-of-line silently disappears in PDF. Confidence: high. Auto-fixable: NO for all five. Each pattern has a context-dependent fix (merge spans, choose `\sim` form, decide on `\mu` vs literal `u`, decide on dimension vs multiplier form, escape `%`). Operator review required. Protected contexts: code fences (```), inline-Python (`{python}`) chunks, TikZ blocks (```{.tikz}), `.callout-code` divs, YAML, display math, HTML comments, inline code, `\index{}`, protected attributes. """ from __future__ import annotations import re from pathlib import Path from audit.ledger import Issue, make_issue_id from audit.protected_contexts import ( LineWalker, default_line_skip, inline_protected_spans, position_in_spans, ) CATEGORY = "math-notation-render" RULE = "book-prose-merged.md section 2 (Quantitative Rigor & Math)" RULE_TEXT = ( "Math notation must render correctly: single math span per expression, " "use \\sim/\\mu for ~/u, space and brace \\times per house style, " "escape % as \\% inside math." ) # ── Pattern regexes ───────────────────────────────────────────────────────── # Pattern 1: Adjacent inline math spans. The literal collision `$A$$B$` # is parsed as `$A$` then `$B$` but renders as `∼$15%$` in some Pandoc # paths (and breaks the display-math heuristic in others). We look for # `$...$` immediately followed (no whitespace) by `$...$` on the same # line. Both spans must be non-empty and not contain `$` (so we don't # match display math `$$...$$`). _ADJACENT_MATH_RE = re.compile( r"(\$(?!\$)[^$\n]+?\$)(\$(?!\$)[^$\n]+?\$)" ) # Pattern 2: `100s W` / `10s W` / `1000s W` literal. A digit run followed # by lowercase `s`, whitespace, then `W` (uppercase, often the watts # unit). Anchored to word boundaries so we don't catch e.g. "items W..." # (no leading digit) or "1sW" (no space). _LITERAL_S_W_RE = re.compile(r"\b(\d+s)\s+W\b") # Pattern 3: Literal `u` in place of `\mu` for unit prefixes. We match a # digit run (optional decimal), optional whitespace or tilde, then `u` # immediately followed by one of the SI-prefixable units: J, s, W, A, F, # V, C, m. Word boundary after to avoid `100us`+`er` etc. The literal # `us` is the most ambiguous (could be English "us"); we require the # digit-adjacency, which eliminates most false hits. Negative lookbehind # on `\\` skips `\u...` LaTeX commands (none in our corpus but safe). _MISSING_MU_RE = re.compile( r"(? bool: """True if we're inside a `.callout-code` div. The LineWalker tracks tip/checkpoint/definition callouts by name but not `.callout-code`. We approximate by checking callout_depth > 0 AND none of the known protection flags being set — i.e. we're inside some Quarto div that the walker is counting but not specifically flagging. This is a loose check; the canonical signal is the code fence inside the div, which the walker's `in_code_fence` already catches. Kept here for documentation. """ return False # rely on in_code_fence; .callout-code wrapping isn't # itself protective — its contents are protected via the code fence. def _is_python_chunk_line(line: str) -> bool: """True if this line is a Quarto inline-Python expression line. Matches lines that contain `` `{python} ... ` `` — these are protected because the expression body is dynamic content (computed values), and patterns like `1uJ` in a Python f-string variable name are legitimate code, not prose typos. The inline-code span detector in `inline_protected_spans` already covers per-character protection within the line; this is a coarser line-level skip for lines that are predominantly inline-Python. Currently unused — we rely on per-span protection — but documented for future tuning. """ return "`{python}" in line # ── Main check ────────────────────────────────────────────────────────────── def check( file_path: Path, text: str, scope: str, start_counter: int = 0, ) -> tuple[list[Issue], int]: """Scan for math-notation rendering issues in body prose. Returns (issues, next_counter). """ issues: list[Issue] = [] counter = start_counter walker = LineWalker(text) for line, state, line_num in walker: if default_line_skip(line, state): continue # Compute protected spans once per line — used by all five # patterns to filter out matches inside inline code, inline math # (for non-math patterns), index entries, protected attributes, # and citations. spans = inline_protected_spans(line) # ── Pattern 1: Adjacent inline math spans ── # `$A$$B$` — should be one span. High confidence; mechanical. # We allow the match position to be inside a protected attr or # inline code, in which case we skip — those are body-prose- # adjacent contexts (fig-cap with `$A$$B$` is also a bug, but # the prose pattern catches the same source line if it's # rendered text). for m in _ADJACENT_MATH_RE.finditer(line): # Skip if the match starts inside inline code or an index # entry; protected attributes are still in-scope (a caption # with `$A$$B$` renders just as poorly). if position_in_spans(m.start(), [ s for s in spans if line[s[0]:s[1]].startswith("`") or line[s[0]:s[1]].startswith("\\index") ]): continue before_text = m.group(0) issues.append( Issue( id=make_issue_id(scope, CATEGORY, counter), category=CATEGORY, rule=RULE, rule_text=RULE_TEXT, file=str(file_path), line=line_num, col=m.start(), before=line, suggested_after="", auto_fixable=False, needs_subagent=False, confidence="high", reason=( f"adjacent math spans {before_text!r} should be a " "single span (e.g. `$\\sim 15\\%$`)" ), ) ) counter += 1 # ── Pattern 2: `100s W` / `10s W` literal ── # Medium confidence: could be intentional in colloquial passages. for m in _LITERAL_S_W_RE.finditer(line): if position_in_spans(m.start(), spans): continue before_text = m.group(0) issues.append( Issue( id=make_issue_id(scope, CATEGORY, counter), category=CATEGORY, rule=RULE, rule_text=RULE_TEXT, file=str(file_path), line=line_num, col=m.start(), before=line, suggested_after="", auto_fixable=False, needs_subagent=False, confidence="medium", reason=( f"{before_text!r} reads as plural-s, not " "approximation; use `$\\sim N$~W` or " "`\\mathord{\\sim}N`~W" ), ) ) counter += 1 # ── Pattern 3: Missing `\mu` (literal `u` for micro-) ── # Medium confidence: skip code/Python lines (per default_line_skip # which already filters code fences and `#|` chunk options). # Inline code spans are also skipped via `spans`. for m in _MISSING_MU_RE.finditer(line): if position_in_spans(m.start(), spans): continue num, u, unit = m.group(1), m.group(2), m.group(3) before_text = m.group(0) issues.append( Issue( id=make_issue_id(scope, CATEGORY, counter), category=CATEGORY, rule=RULE, rule_text=RULE_TEXT, file=str(file_path), line=line_num, col=m.start(), before=line, suggested_after="", auto_fixable=False, needs_subagent=False, confidence="medium", reason=( f"{before_text!r} likely missing \\mu; should be " f"`{num} $\\mu${unit}` or `${num}\\,\\mu\\text{{{unit}}}$`" ), ) ) counter += 1 # ── Pattern 4: `\times` without space ── # High confidence: `\d\times` and `\times\d` both escape the # canonical `$N{\times}M$` (braced dimension) and `N$\times$` # (multiplier-outside-math) forms. for m in _TIMES_NO_SPACE_RE.finditer(line): # Skip if the match falls inside inline code (e.g. a `4x` in # a model-option label that got typed as `4\times` for # rendering reasons). if position_in_spans(m.start(), [ s for s in spans if line[s[0]:s[1]].startswith("`") or line[s[0]:s[1]].startswith("\\index") ]): continue # Skip the canonical braced form `{\times}` — the byte # before `\times` is `{`, not a digit. Our regex already # requires `\d\\times` so the brace form doesn't match; # this is documentation. before_text = m.group(0) issues.append( Issue( id=make_issue_id(scope, CATEGORY, counter), category=CATEGORY, rule=RULE, rule_text=RULE_TEXT, file=str(file_path), line=line_num, col=m.start(), before=line, suggested_after="", auto_fixable=False, needs_subagent=False, confidence="high", reason=( f"{before_text!r} lacks space around \\times; " "use `$N{\\times}M$` for dimensions or " "`N$\\times$` for multipliers" ), ) ) counter += 1 # ── Pattern 5: Bare `%` inside actual inline math ── # High confidence: `%` inside `$...$` starts a LaTeX comment. # This must inspect real inline-math spans, not arbitrary pairs # of dollar signs. A table row such as `$C_2$ ... 30% ... $\eta$` # contains a percent sign between two math spans, but not inside # math; a broad `$...%...$` regex would pair the closing dollar of # `$C_2$` with the opening dollar of `$\eta$` and flag a false # positive. for math_span in _INLINE_MATH_SPAN_RE.finditer(line): # Skip if the math span itself is inside inline code or an # index entry; otherwise flag, including matches inside # protected attributes like fig-cap, where math still renders. if position_in_spans(math_span.start(), [ s for s in spans if line[s[0]:s[1]].startswith("`") or line[s[0]:s[1]].startswith("\\index") ]): continue body = math_span.group(0)[1:-1] percent = _UNESCAPED_PERCENT_RE.search(body) if percent is None: continue issues.append( Issue( id=make_issue_id(scope, CATEGORY, counter), category=CATEGORY, rule=RULE, rule_text=RULE_TEXT, file=str(file_path), line=line_num, col=math_span.start() + 1 + percent.start(), before=line, suggested_after="", auto_fixable=False, needs_subagent=False, confidence="high", reason=( f"bare `%` inside math span `${body}$` starts a " "LaTeX comment; escape as `\\%`" ), ) ) counter += 1 return issues, counter