diff --git a/.github/workflows/twin-files-consistency.yml b/.github/workflows/twin-files-consistency.yml
new file mode 100644
index 000000000..dba900bc7
--- /dev/null
+++ b/.github/workflows/twin-files-consistency.yml
@@ -0,0 +1,43 @@
+name: Twin Files Consistency
+
+# 孪生文件(逐字节副本)一致性校验 —— 横切关注点,正交于各 app CI。
+#
+# 某些模块因构建边界无法提为共享包,只能以逐字节副本形式在多处持有。
+# 副本的固有风险是单边漂移:改了一处忘了另一处,缺陷从此静默分叉。
+# 本 job 校验登记表内每组副本精确字节相等,漂移时打印统一 diff 指明同步方向。
+#
+# 登记表与判据详见 scripts/check_twin_files.py。pre-commit 侧有同名钩子做本地前置拦截;
+# CI 侧是兜底 —— 未装 hooks 或 --no-verify 绕过的提交仍会在此被拦下。
+#
+# fail-only:检测到漂移即阻塞合并,不自动同步(哪份权威取决于改动意图,机器不该代猜)。
+
+on:
+ pull_request:
+ paths:
+ - "scripts/check_twin_files.py"
+ - "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts"
+ - "apps/negentropy-ui/utils/remark-math-sanitize.ts"
+ - ".github/workflows/twin-files-consistency.yml"
+ push:
+ branches: [master, main]
+ paths:
+ - "scripts/check_twin_files.py"
+ - "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts"
+ - "apps/negentropy-ui/utils/remark-math-sanitize.ts"
+
+permissions:
+ contents: read
+
+jobs:
+ twin-files-consistency:
+ name: Twin Files Check
+ runs-on: ubuntu-latest
+ timeout-minutes: 3
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up uv
+ uses: astral-sh/setup-uv@v6
+
+ - name: Check twin files are byte-identical
+ run: uv run --no-project scripts/check_twin_files.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index f47806c7a..3082ec1de 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -122,6 +122,24 @@ repos:
files: ^(VERSION|scripts/sync_versions\.py|package\.json|apps/negentropy/(pyproject\.toml|uv\.lock)|apps/negentropy-ui/package\.json|apps/negentropy-wiki/package\.json|apps/negentropy-perceives/(pyproject\.toml|uv\.lock)|packages/agents-chat-core/package\.json)$
pass_filenames: false
+ # ── 孪生文件(逐字节副本)一致性执法 ─────────────────────────────────────
+ # 某些模块因构建边界无法提为共享包,只能以逐字节副本形式多处持有(登记表见脚本)。
+ # 本钩子把「改一处必须同步另一处」从人工纪律升格为机器保证,漂移时打印统一 diff。
+ # ⚠️ files: 覆盖登记表内全部路径 + 脚本自身;新增副本组时须同步扩充此正则,
+ # 否则钩子对新组**静默失效**(pre-commit 跳过不报错)。改动后须验证:
+ # 暂存一处副本改动,确认钩子 Passed 而非 Skipped。
+ # 注:pre-commit 会 stash 未暂存改动、仅对暂存内容执行,故同组副本**必须整组
+ # 一同 git add**;只暂存一侧会因另一侧仍是 HEAD 版本而判定漂移 —— 这正是
+ # 期望语义:它连「部分同步的提交」也一并拦住。
+ - repo: local
+ hooks:
+ - id: twin-files-consistency
+ name: Twin files consistency
+ language: system
+ entry: uv run --no-project scripts/check_twin_files.py
+ files: ^(scripts/check_twin_files\.py|apps/negentropy-wiki/src/components/markdown/remark-math-sanitize\.ts|apps/negentropy-ui/utils/remark-math-sanitize\.ts)$
+ pass_filenames: false
+
# ── 科普视频系列一致性(series.json 执法) ───────────────────────────────
# 规则:口播反串线(自身标题排除)/多标题顺序/序号绑定/清单完整性/相对链接死链。
# 注意:挂在内容修复之后落地;顺序调整类变更须先改 series.json 再动散文。
diff --git a/apps/negentropy-ui/package.json b/apps/negentropy-ui/package.json
index 6439e2264..b8626f009 100644
--- a/apps/negentropy-ui/package.json
+++ b/apps/negentropy-ui/package.json
@@ -69,6 +69,8 @@
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.1",
"three-spritetext": "^1.10.0",
+ "unified": "^11.0.5",
+ "unist-util-visit": "^5.0.0",
"zod": "3.24.4"
},
"devDependencies": {
diff --git a/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx b/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx
index 3d168c9c7..db9607833 100644
--- a/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx
+++ b/apps/negentropy-ui/tests/unit/knowledge/DocumentMarkdownRenderer.test.tsx
@@ -108,4 +108,30 @@ describe("DocumentMarkdownRenderer", () => {
expect(figcaption).not.toBeNull();
expect(figcaption?.textContent).toContain("Figure 1");
});
+
+ it("PDF 语料的病态公式经 remarkMathSanitize 净化后零 KaTeX 告警", () => {
+ // 与 wiki 端 MarkdownRenderer 共用同一份提取语料,插件链须对齐(判据见
+ // utils/remark-math-sanitize.ts 的孪生副本说明)。
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const content = [
+ "按输入 $3/ 百万 token 、输出 $15/ 百万 token 的示例价格计算",
+ "$11.5%\\to15.0%$",
+ "KL $(P‖Q) \\neq$ KL $(Q‖P)$",
+ "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2}\n$$",
+ ].join("\n\n");
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".katex-error")).toBeNull();
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
});
diff --git a/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx b/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx
new file mode 100644
index 000000000..f05cb6b65
--- /dev/null
+++ b/apps/negentropy-ui/tests/unit/utils/markdown-plugins.test.tsx
@@ -0,0 +1,92 @@
+import { render } from "@testing-library/react";
+import ReactMarkdown from "react-markdown";
+import { defaultRemarkPlugins, defaultRehypePlugins } from "@/utils/markdown-plugins";
+
+/**
+ * remarkMathSanitize 接入 defaultRemarkPlugins 的回归护栏。
+ *
+ * 该插件与 `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts`
+ * 是孪生副本,本文件用例与 wiki 端 MarkdownRenderer.test.tsx 的对应用例同步维护。
+ */
+function renderMd(md: string) {
+ return render(
+
+
+ {md}
+
+
,
+ );
+}
+
+describe("defaultRemarkPlugins · remarkMathSanitize", () => {
+ it("相邻货币 $ 误配对降级回正文,文本不被打乱重复", () => {
+ // 未净化时 remark-math 把两个 $ 配成 inlineMath,实测正文被打乱、
+ // `$15` 段渲染成 `$3` 段并触发多次 KaTeX 告警。LLM 回复中报价文本极常见。
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const { container } = renderMd("输入 $3/ 百万 token ,输出 $15/ 百万 token 。");
+
+ expect(container.querySelector(".katex")).toBeNull();
+ const text = container.textContent ?? "";
+ expect(text).toContain("$3/ 百万 token");
+ expect(text).toContain("$15/ 百万 token");
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("正常行内公式不受影响", () => {
+ const { container } = renderMd("质能方程 $E=mc^2$ 广为人知。");
+
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+
+ it("未转义的 % 被转义,其后内容不被当注释吞掉", () => {
+ const { container } = renderMd("$11.5%\\to15.0%$");
+
+ const katex = container.querySelector(".katex");
+ expect(katex).not.toBeNull();
+ expect(katex?.textContent).toContain("15.0");
+ expect(katex?.textContent).toContain("%");
+ });
+
+ it("\\text{} 内的裸 % 同样被转义(catcode 14 不分模式)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const { container } = renderMd("$\\text{增长 50%}$");
+
+ expect(container.querySelector(".katex")).not.toBeNull();
+ expect(container.querySelector(".katex-error")).toBeNull();
+ // KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。
+ expect(container.querySelector(".katex")?.textContent?.replace(/\u00A0/g, " "))
+ .toContain("增长 50%");
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("‖ 归一为 \\Vert,公式正常渲染", () => {
+ const { container } = renderMd("KL 散度不对称:KL $(P‖Q) \\neq$ KL $(Q‖P)$");
+
+ expect(container.querySelectorAll(".katex").length).toBe(2);
+ });
+
+ it("\\textrm 等 KaTeX 文本宏内的 CJK 不触发误降级", () => {
+ const { container } = renderMd(
+ "梯度 $g = \\textrm{方向导数}$ 与 $h = \\textnormal{常规}$ 及 $k = \\textsf{无衬线}$",
+ );
+
+ expect(container.querySelectorAll(".katex").length).toBe(3);
+ });
+
+ it("下标裸 CJK 包进 \\text{},非 CJK(谚文)不被误包", () => {
+ const { container } = renderMd("$$\nd_{常数 2} + e_{한글}\n$$");
+
+ const ann = container.querySelector("annotation")?.textContent ?? "";
+ expect(ann).toContain("_{\\text{常数 2}}");
+ expect(ann).toContain("e_{한글}");
+ expect(ann).not.toContain("\\text{한글}");
+ });
+});
diff --git a/apps/negentropy-ui/utils/markdown-plugins.ts b/apps/negentropy-ui/utils/markdown-plugins.ts
index fb5936d1d..0bc1fd6c8 100644
--- a/apps/negentropy-ui/utils/markdown-plugins.ts
+++ b/apps/negentropy-ui/utils/markdown-plugins.ts
@@ -7,9 +7,18 @@
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex";
+import { remarkMathSanitize } from "./remark-math-sanitize";
-/** remark 插件链:GFM 扩展 + 数学公式语法解析 */
-export const defaultRemarkPlugins = [remarkGfm, remarkMath];
+/**
+ * remark 插件链:GFM 扩展 + 数学公式语法解析 + 病态公式净化。
+ *
+ * remarkMathSanitize 须紧随 remarkMath 之后。它修复 remark-math 的通用缺陷,
+ * 不限 PDF 语料:形如「输入 $3/ 百万 token ,输出 $15/ 百万 token」的相邻货币符
+ * 会被配成 inlineMath,实测正文被打乱重复(`$15` 段渲染成 `$3` 段)并触发 5 次
+ * KaTeX 告警——LLM 回复中报价文本极常见,故挂在全局链而非仅文档渲染处。
+ * 与 wiki 端 MarkdownRenderer 的插件链保持一致。
+ */
+export const defaultRemarkPlugins = [remarkGfm, remarkMath, remarkMathSanitize];
/** rehype 插件链:KaTeX 渲染 */
export const defaultRehypePlugins = [rehypeKatex];
diff --git a/apps/negentropy-ui/utils/remark-math-sanitize.ts b/apps/negentropy-ui/utils/remark-math-sanitize.ts
new file mode 100644
index 000000000..bbcf09891
--- /dev/null
+++ b/apps/negentropy-ui/utils/remark-math-sanitize.ts
@@ -0,0 +1,166 @@
+import type { Plugin } from "unified";
+import { visit } from "unist-util-visit";
+
+/**
+ * remarkMathSanitize —— 净化 PDF 提取语料中 remark-math 产生的病态公式节点,
+ * 消除 rehype-katex 构建期告警与实际渲染缺陷。须挂在 `remarkMath` 之后。
+ *
+ * ⚠️ 本文件是**逐字节孪生副本**,同时存在于:
+ * - `apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts`
+ * - `apps/negentropy-ui/utils/remark-math-sanitize.ts`
+ * 两端渲染同一份 PDF 提取语料(wiki 静态站 / UI 知识库文档详情页),判据一旦单边漂移
+ * 即产生渲染不对称。未提为共享包是因 wiki 无 workspace 依赖且以 GitHub Pages 为出口,
+ * 为单个插件引入构建链的爆炸半径过大(参照 rehype-notranslate 亦为单端持有)。
+ * **改动任一份时必须同步另一份,并同步两端回归用例。**
+ * 一致性由 `scripts/check_twin_files.py` 在 pre-commit 与 CI 双侧执法(精确字节相等)。
+ *
+ * 三类病灶与处置:
+ * 1. 货币 `$` 误配对:正文「按输入 $3/ 百万 token 、输出 $15/ 百万 token」的相邻
+ * 货币符被 remark-math 配成 inlineMath,中间中文全进 math mode(KaTeX 逐字告警
+ * unicodeTextInMathMode)。处置:值内含 `\text{...}` 之外的 CJK 字符 → 判定
+ * 误配对,降级回正文 text 节点(补回字面 `$`)。仅作用于 inlineMath——真实
+ * display 公式(`$$` 围栏)无货币配对形态,且误降级代价高(丢公式渲染)。
+ * 2. `%` 未转义:`$11.5%\to15.0%$` 中 `%` 是 TeX 注释符,渲染吞掉其后内容
+ * (commentAtEnd 告警即其末态)。处置:未转义的 `%` → `\%`,作用于整串——
+ * `%` 是 KaTeX 词法层 catcode 14,文本模式内不豁免(见 escapePercent)。
+ * 3. Unicode 符号无度量:`‖`/`∥`/`•` KaTeX 无字形度量(unknownSymbol + No
+ * character metrics,渲染为 tofu)。处置:映射为 LaTeX 等价命令
+ * `\Vert`/`\parallel`/`\bullet`(Pandoc texmath 同款归一化思路)。
+ *
+ * 判据均先剥离 `\text{...}` 片段再检验——`\text{中文}` 是合法用法(正文注释进公式),
+ * 不得触发 CJK 误判,`\text` 内的 Unicode 符号也保持原样(走浏览器字体)。
+ * 例外是 `%`:它在词法层被吞,`\text{}` 内外一律转义。
+ */
+
+/** 最小 mdast 节点结构(仅用到 type / value / data.hChildren);避免直接依赖 `mdast` 类型包。 */
+interface MathNode {
+ type: "inlineMath" | "math";
+ value: string;
+ data?: {
+ hChildren?: HastLike[];
+ [key: string]: unknown;
+ };
+}
+
+function isMathNode(node: unknown): node is MathNode {
+ return (
+ typeof node === "object" &&
+ node !== null &&
+ ((node as { type?: unknown }).type === "inlineMath" ||
+ (node as { type?: unknown }).type === "math") &&
+ typeof (node as { value?: unknown }).value === "string"
+ );
+}
+
+/** 最小 hast 内容节点(text 或 element,后者含 children)。 */
+interface HastLike {
+ type?: string;
+ value?: unknown;
+ children?: HastLike[];
+}
+
+/**
+ * 同步写入公式值:`node.value` 与 `node.data.hChildren` 中的 hast text 副本须一并
+ * 更新——mdast-util-math 解析时把值存了两份,remark-rehype 转 hast 走 `data.hChildren`
+ * (rehype-katex 从 hast 取文本),只改 `node.value` 不会到达 KaTeX。副本层级不定
+ * (inlineMath 是 `hChildren[0].value`,display 是 `hChildren[0](code).children[0].value`),
+ * 递归替换其中的 text 节点。
+ */
+function setMathValue(node: MathNode, value: string): void {
+ node.value = value;
+ const sync = (n: HastLike): void => {
+ if (n.type === "text" && typeof n.value === "string") {
+ n.value = value;
+ return;
+ }
+ for (const child of n.children ?? []) sync(child);
+ };
+ for (const child of node.data?.hChildren ?? []) sync(child);
+}
+
+/**
+ * CJK 字符类源串:统一表意文字扩展 A 起至基本区(U+3400–U+9FFF)+ 兼容表意文字
+ * (U+F900–U+FAFF)。以转义码位书写——兼容区 U+F900 与普通汉字 U+8C48 字形相同码位
+ * 不同,字面量易混淆致区间误跨谚文段与代理项。下方两处判据共用此串,避免双处漂移。
+ */
+const CJK_CLASS = "[\\u3400-\\u9FFF\\uF900-\\uFAFF]";
+const CJK_RE = new RegExp(CJK_CLASS);
+
+/**
+ * KaTeX 文本模式宏全集(`src/functions/text.ts` 的 `names`)+ `\mathrm` / `\mbox`。
+ * 遗漏项会使其内的 CJK 逃过剥离,被货币误配对分支判为误配对而整条降级丢渲染。
+ */
+const TEXT_MACRO_SOURCE =
+ "\\\\(?:text|textrm|textsf|texttt|textnormal|textbf|textmd|textit|textup|emph|mathrm|mbox)\\s*\\{[^{}]*\\}";
+
+/** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */
+function stripTextMacros(src: string): string {
+ return src.replace(new RegExp(TEXT_MACRO_SOURCE, "g"), "");
+}
+
+/**
+ * 转义未转义的 `%`(TeX 注释符)。按前导反斜杠奇偶判定——`\\%`(换行符紧邻裸 `%`)
+ * 中的 `%` 实为未转义,单字符回看 `(?
+ slashes.length % 2 === 0 ? `${slashes}\\%` : `${slashes}%`,
+ );
+}
+
+/** 数学体中 Unicode 符号 → LaTeX 等价命令(KaTeX 有度量,消除 tofu)。 */
+function normalizeSymbols(src: string): string {
+ return src
+ .replace(/‖/g, "\\Vert ")
+ .replace(/∥/g, "\\parallel ")
+ .replace(/•/g, "\\bullet ")
+ // 下标裸 CJK(`\underbrace{…}_{常数 2}` 这类 PDF 提取的标注)包进 `\text{}`,
+ // 消除逐字 unicodeTextInMathMode 告警(KaTeX 对 \text 内 Unicode 走文本模式)。
+ .replace(
+ new RegExp(`(_\\{|\\^\\{)([^{}]*${CJK_CLASS}[^{}]*)\\}`, "g"),
+ "$1\\text{$2}}",
+ );
+}
+
+/** 可写父节点结构(demote 时按索引替换 child)。 */
+interface ParentLike {
+ children: { type?: string; value?: string }[];
+}
+
+export const remarkMathSanitize: Plugin = () => (tree) => {
+ visit(tree, (node, index, parent) => {
+ if (!isMathNode(node)) return;
+ // visit 回调的 parent 经泛型推断为 never(与 rehype-notranslate 的 hast 类型坑同款),
+ // 经 unknown 最小收窄为可写结构。
+ const holder = parent as unknown as ParentLike | undefined;
+ if (index == null || !holder) return;
+
+ // 1. 货币误配对降级:数学体含 CJK → 还原为正文文本(含字面 `$` 定界符)。
+ if (node.type === "inlineMath" && CJK_RE.test(stripTextMacros(node.value))) {
+ holder.children[index] = { type: "text", value: `$${node.value}$` };
+ return;
+ }
+
+ // 2. `%` 转义作用于整串(catcode 14 不分模式);3. 符号归一仅限数学体
+ // (`\text{...}` 内的 Unicode 符号保持原样,走浏览器字体)。
+ const cleaned = escapePercent(replaceOutsideTextMacros(node.value, normalizeSymbols));
+ if (cleaned !== node.value) setMathValue(node, cleaned);
+ });
+};
+
+/** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */
+function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string {
+ const TEXT_MACRO_RE = new RegExp(TEXT_MACRO_SOURCE, "g");
+ let out = "";
+ let last = 0;
+ for (const m of src.matchAll(TEXT_MACRO_RE)) {
+ const at = m.index ?? 0;
+ out += transform(src.slice(last, at)) + m[0];
+ last = at + m[0].length;
+ }
+ return out + transform(src.slice(last));
+}
+
+export default remarkMathSanitize;
diff --git a/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx b/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx
index b27bba93f..dcfba8097 100644
--- a/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx
+++ b/apps/negentropy-wiki/src/components/markdown/MarkdownRenderer.tsx
@@ -7,6 +7,7 @@ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import rehypeKatex from "rehype-katex";
import rehypeHighlight from "rehype-highlight";
import { rehypeNotranslate } from "./rehype-notranslate";
+import { remarkMathSanitize } from "./remark-math-sanitize";
import { CodeBlock } from "./CodeBlock";
import { AnchorHeading } from "./AnchorHeading";
import { ResponsiveTable } from "./ResponsiveTable";
@@ -59,7 +60,7 @@ export function MarkdownRenderer({ content }: MarkdownRendererProps) {
// Snapshot 锚定 + MutationObserver 自动重应用 + CSS Highlight API 渲染。
{
+ if (n.type === "text" && typeof n.value === "string") {
+ n.value = value;
+ return;
+ }
+ for (const child of n.children ?? []) sync(child);
+ };
+ for (const child of node.data?.hChildren ?? []) sync(child);
+}
+
+/**
+ * CJK 字符类源串:统一表意文字扩展 A 起至基本区(U+3400–U+9FFF)+ 兼容表意文字
+ * (U+F900–U+FAFF)。以转义码位书写——兼容区 U+F900 与普通汉字 U+8C48 字形相同码位
+ * 不同,字面量易混淆致区间误跨谚文段与代理项。下方两处判据共用此串,避免双处漂移。
+ */
+const CJK_CLASS = "[\\u3400-\\u9FFF\\uF900-\\uFAFF]";
+const CJK_RE = new RegExp(CJK_CLASS);
+
+/**
+ * KaTeX 文本模式宏全集(`src/functions/text.ts` 的 `names`)+ `\mathrm` / `\mbox`。
+ * 遗漏项会使其内的 CJK 逃过剥离,被货币误配对分支判为误配对而整条降级丢渲染。
+ */
+const TEXT_MACRO_SOURCE =
+ "\\\\(?:text|textrm|textsf|texttt|textnormal|textbf|textmd|textit|textup|emph|mathrm|mbox)\\s*\\{[^{}]*\\}";
+
+/** 剥离 `\text{...}` / `\textbf{...}` 等文本宏片段,仅在剩余「数学体」部分做判据检验。 */
+function stripTextMacros(src: string): string {
+ return src.replace(new RegExp(TEXT_MACRO_SOURCE, "g"), "");
+}
+
+/**
+ * 转义未转义的 `%`(TeX 注释符)。按前导反斜杠奇偶判定——`\\%`(换行符紧邻裸 `%`)
+ * 中的 `%` 实为未转义,单字符回看 `(?
+ slashes.length % 2 === 0 ? `${slashes}\\%` : `${slashes}%`,
+ );
+}
+
+/** 数学体中 Unicode 符号 → LaTeX 等价命令(KaTeX 有度量,消除 tofu)。 */
+function normalizeSymbols(src: string): string {
+ return src
+ .replace(/‖/g, "\\Vert ")
+ .replace(/∥/g, "\\parallel ")
+ .replace(/•/g, "\\bullet ")
+ // 下标裸 CJK(`\underbrace{…}_{常数 2}` 这类 PDF 提取的标注)包进 `\text{}`,
+ // 消除逐字 unicodeTextInMathMode 告警(KaTeX 对 \text 内 Unicode 走文本模式)。
+ .replace(
+ new RegExp(`(_\\{|\\^\\{)([^{}]*${CJK_CLASS}[^{}]*)\\}`, "g"),
+ "$1\\text{$2}}",
+ );
+}
+
+/** 可写父节点结构(demote 时按索引替换 child)。 */
+interface ParentLike {
+ children: { type?: string; value?: string }[];
+}
+
+export const remarkMathSanitize: Plugin = () => (tree) => {
+ visit(tree, (node, index, parent) => {
+ if (!isMathNode(node)) return;
+ // visit 回调的 parent 经泛型推断为 never(与 rehype-notranslate 的 hast 类型坑同款),
+ // 经 unknown 最小收窄为可写结构。
+ const holder = parent as unknown as ParentLike | undefined;
+ if (index == null || !holder) return;
+
+ // 1. 货币误配对降级:数学体含 CJK → 还原为正文文本(含字面 `$` 定界符)。
+ if (node.type === "inlineMath" && CJK_RE.test(stripTextMacros(node.value))) {
+ holder.children[index] = { type: "text", value: `$${node.value}$` };
+ return;
+ }
+
+ // 2. `%` 转义作用于整串(catcode 14 不分模式);3. 符号归一仅限数学体
+ // (`\text{...}` 内的 Unicode 符号保持原样,走浏览器字体)。
+ const cleaned = escapePercent(replaceOutsideTextMacros(node.value, normalizeSymbols));
+ if (cleaned !== node.value) setMathValue(node, cleaned);
+ });
+};
+
+/** 仅对 `\text{...}` 片段之外的部分应用 transform,文本宏片段原样保留。 */
+function replaceOutsideTextMacros(src: string, transform: (s: string) => string): string {
+ const TEXT_MACRO_RE = new RegExp(TEXT_MACRO_SOURCE, "g");
+ let out = "";
+ let last = 0;
+ for (const m of src.matchAll(TEXT_MACRO_RE)) {
+ const at = m.index ?? 0;
+ out += transform(src.slice(last, at)) + m[0];
+ last = at + m[0].length;
+ }
+ return out + transform(src.slice(last));
+}
+
+export default remarkMathSanitize;
diff --git a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx
index aceda09c0..88c504bde 100644
--- a/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx
+++ b/apps/negentropy-wiki/tests/components/MarkdownRenderer.test.tsx
@@ -126,4 +126,152 @@ describe("MarkdownRenderer", () => {
expect(katex?.classList.contains("notranslate")).toBe(true);
});
});
+
+ // remark-math-sanitize:净化 PDF 提取语料的病态公式节点(货币误配对/未转义 %/Unicode 符号)。
+ describe("remark-math-sanitize", () => {
+ it("货币 $ 误配对(数学体含 CJK)降级回正文文本,$ 字面可见", () => {
+ // 真实语料形态:相邻货币 $ 被 remark-math 配成 inlineMath,中间中文全进 math mode。
+ const md = "按输入 $3/ 百万 token 、输出 $15/ 百万 token 的示例价格计算";
+ const { container } = render();
+
+ expect(container.querySelector(".katex")).toBeNull();
+ const text = container.querySelector(".wiki-markdown-body")?.textContent ?? "";
+ expect(text).toContain("$3/ 百万 token 、输出 $15/ 百万 token");
+ });
+
+ it("正常行内公式(数学体无 CJK)不受影响", () => {
+ const md = "质能方程 $E=mc^2$ 广为人知。";
+ const { container } = render();
+
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+
+ it("\\text{中文} 是合法用法,不触发 CJK 误降级", () => {
+ const md = "向量投影 $\\text{A 在 B 上的投影} = \\frac{\\text{点积}}{\\text{B 的长度}}$";
+ const { container } = render();
+
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+
+ it("未转义的 % 被转义,渲染不再吞掉其后内容(commentAtEnd 修复)", () => {
+ const md = "$11.5%\\to15.0%$";
+ const { container } = render();
+
+ const katex = container.querySelector(".katex");
+ expect(katex).not.toBeNull();
+ // 修复前 % 后内容被当 TeX 注释吞掉,只剩 11.5;修复后应完整可见。
+ expect(katex?.textContent).toContain("15.0");
+ expect(katex?.textContent).toContain("%");
+ });
+
+ it("‖ 归一为 \\Vert,公式正常渲染(unknownSymbol 修复)", () => {
+ const md = "KL 散度不对称:KL $(P‖Q) \\neq$ KL $(Q‖P)$";
+ const { container } = render();
+
+ const maths = container.querySelectorAll(".katex");
+ expect(maths.length).toBe(2);
+ });
+
+ it("• 归一为 \\bullet,公式正常渲染", () => {
+ const md = "$•$ Action model: Primary execution model for tool-";
+ const { container } = render();
+
+ expect(container.querySelector(".katex")).not.toBeNull();
+ });
+
+ it("病态公式净化后渲染零 KaTeX 告警(循证验证)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const md = [
+ "三轮调用总共 $0.022 ——看似很便宜。如果完全没有缓存,三轮输入成本约为 $0.029 ,加上输出后合计约 $0.036",
+ "$11.5%\\to15.0%$",
+ "KL $(P‖Q) \\neq$ KL $(Q‖P)$",
+ "$•$ Action model",
+ "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2}\n$$",
+ ].join("\n\n");
+ const { container } = render();
+ expect(container.querySelector(".wiki-markdown-body")).not.toBeNull();
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("display 公式下标裸 CJK 包进 \\text{},渲染为文本模式", () => {
+ // mdast-util-math 对 display 公式把值存两份(node.value + data.hChildren 内
+ // code>text 副本),须同步更新才能到达 KaTeX——本用例锁住该路径。
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const md = "$$\n d_{L2}^2 = \\underbrace{\\|a\\|^2 + \\|b\\|^2}_{常数 2} - 2\\underbrace{(a\\cdot b)}_{\\text{点积/Cos}}\n$$";
+ const { container } = render();
+ const anns = [...container.querySelectorAll("annotation")].map((a) => a.textContent ?? "");
+ const target = anns.find((t) => t.includes("d_{L2}"));
+ expect(target).toContain("_{\\text{常数 2}}");
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("\\text{} 内的裸 % 同样被转义(catcode 14 不分模式,否则抛 ParseError)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ const md = "$\\text{增长 50%}$";
+ const { container } = render();
+
+ const katex = container.querySelector(".katex");
+ expect(katex).not.toBeNull();
+ // 未转义时 % 会注释掉闭合 },KaTeX 抛错并渲染 .katex-error。
+ expect(container.querySelector(".katex-error")).toBeNull();
+ // KaTeX 文本模式把空格输出为 NBSP(U+00A0),归一后再比对。
+ expect(katex?.textContent?.replace(/\u00A0/g, " ")).toContain("增长 50%");
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("换行符 \\\\ 紧邻的裸 % 被识别为未转义(奇偶判定,非单字符回看)", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ try {
+ // aligned 环境内 \\ 是合法换行(裸 display 模式下才有 newLineInDisplayMode 告警)。
+ const md = "$$\n\\begin{aligned} a \\\\% b \\end{aligned}\n$$";
+ const { container } = render();
+
+ // 单字符回看会把 \\% 的 % 误判为已转义,导致 % 及其后内容被当注释吞掉。
+ const ann = container.querySelector("annotation")?.textContent ?? "";
+ expect(ann).toContain("\\\\\\%");
+ expect(container.querySelector(".katex")?.textContent).toContain("%");
+ expect(warn).not.toHaveBeenCalled();
+ } finally {
+ warn.mockRestore();
+ }
+ });
+
+ it("已转义的 \\% 不被二次转义(幂等)", () => {
+ const md = "$11.5\\% \\to 15.0\\%$";
+ const { container } = render();
+
+ const ann = container.querySelector("annotation")?.textContent ?? "";
+ expect(ann).not.toContain("\\\\%");
+ expect(container.querySelector(".katex")?.textContent).toContain("11.5%");
+ });
+
+ it("\\textrm 等 KaTeX 文本宏内的 CJK 不触发误降级", () => {
+ // 文本宏白名单若漏项,其内 CJK 会逃过剥离而被货币误配对分支整条降级。
+ const md = "梯度 $g = \\textrm{方向导数}$ 与 $h = \\textnormal{常规}$ 及 $k = \\textsf{无衬线}$";
+ const { container } = render();
+
+ expect(container.querySelectorAll(".katex").length).toBe(3);
+ });
+
+ it("下标非 CJK 文本(谚文/emoji)不被包进 \\text{}", () => {
+ // CJK 区间若因字形混淆误写下界,会跨越谚文段与代理项而误纳非 CJK 字符。
+ const md = "$$\nd_{한글} + e_{L2}\n$$";
+ const { container } = render();
+
+ const ann = container.querySelector("annotation")?.textContent ?? "";
+ expect(ann).toContain("d_{한글}");
+ expect(ann).not.toContain("\\text{한글}");
+ });
+ });
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 68378a36c..f9bc4c7ec 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -411,6 +411,12 @@ importers:
three-spritetext:
specifier: ^1.10.0
version: 1.10.0(three@0.184.0)
+ unified:
+ specifier: ^11.0.5
+ version: 11.0.5
+ unist-util-visit:
+ specifier: ^5.0.0
+ version: 5.1.0
zod:
specifier: 3.24.4
version: 3.24.4
diff --git a/scripts/check_twin_files.py b/scripts/check_twin_files.py
new file mode 100644
index 000000000..298e77a76
--- /dev/null
+++ b/scripts/check_twin_files.py
@@ -0,0 +1,157 @@
+# /// script
+# requires-python = ">=3.11"
+# dependencies = []
+# ///
+"""孪生文件(逐字节副本)一致性校验。
+
+某些模块因构建边界无法提为共享包,只能以「逐字节副本」形式在多处持有。
+副本的固有风险是**单边漂移**:改了一处忘了另一处,缺陷从此静默分叉。
+本脚本把「必须同步」从人工纪律升格为机器保证。
+
+设计要点(贴合 AGENTS.md「单一事实源 + 最小干预 + 正交分解」):
+
+* **登记表显式列出** —— 不用 glob 或内容嗅探猜测谁是副本,避免误纳与漏纳。
+ 新增一组副本只需往 ``TWIN_GROUPS`` 加一条,正交于校验逻辑本身。
+* **精确字节相等** —— 不做注释剥离、空白归一等启发式处理。副本的交叉标注写成
+ 两端对称(同时列出全部路径、文字一致),故连文档注释的漂移也在射程内。
+ 启发式比对会放过「注释说 A 代码做 B」这类最危险的漂移。
+* **可诊断输出** —— 漂移时打印统一 diff(含行号)而非仅「哈希不一致」,
+ 使开发者无需自行 diff 即知何处分叉、往哪个方向同步。
+* **只读** —— 不自动同步。哪份是权威取决于改动意图,机器不该替开发者猜;
+ 自动覆盖有静默丢失改动的风险。
+
+用法::
+
+ uv run --no-project scripts/check_twin_files.py # 任一组漂移则 exit 1
+
+供 pre-commit 钩子与 CI(.github/workflows/twin-files-consistency.yml)双侧调用。
+"""
+
+from __future__ import annotations
+
+import difflib
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+# ── 孪生文件登记表 ──────────────────────────────────────────────────────────
+# 每组为 (简称, 无法共享的理由, 必须逐字节相同的仓库相对路径列表[≥2])。
+# 简称用于通过态的单行输出;理由仅在漂移时打印,供后来者判断该不该合并成共享包。
+TWIN_GROUPS: list[tuple[str, str, list[str]]] = [
+ (
+ "remark-math-sanitize",
+ (
+ "wiki 与 ui 渲染同一份 PDF 提取语料,净化判据单边漂移即产生渲染不对称。"
+ "wiki 无 workspace 依赖且以 GitHub Pages 为出口,为单个插件引入构建链的"
+ "爆炸半径过大,故采副本而非共享包。"
+ ),
+ [
+ "apps/negentropy-wiki/src/components/markdown/remark-math-sanitize.ts",
+ "apps/negentropy-ui/utils/remark-math-sanitize.ts",
+ ],
+ ),
+]
+
+
+def check_group(rel_paths: list[str]) -> list[str]:
+ """校验一组孪生文件,返回问题描述列表(空表示该组通过)。"""
+ problems: list[str] = []
+
+ missing = [p for p in rel_paths if not (REPO_ROOT / p).is_file()]
+ if missing:
+ # 副本被删除/改名亦是漂移的一种 —— 报错而非静默跳过。
+ problems.append(f"以下路径不存在(副本被删除或改名?):{', '.join(missing)}")
+ return problems
+
+ # 以第一条为比对基准(仅作 diff 的左侧,不含「它更权威」的语义)。
+ base_rel = rel_paths[0]
+ base_bytes = (REPO_ROOT / base_rel).read_bytes()
+
+ for other_rel in rel_paths[1:]:
+ other_bytes = (REPO_ROOT / other_rel).read_bytes()
+ if other_bytes == base_bytes:
+ continue
+
+ diff = "".join(
+ difflib.unified_diff(
+ base_bytes.decode("utf-8", errors="replace").splitlines(keepends=True),
+ other_bytes.decode("utf-8", errors="replace").splitlines(keepends=True),
+ fromfile=base_rel,
+ tofile=other_rel,
+ )
+ )
+ problems.append(f"{base_rel}\n ≠ {other_rel}\n\n{diff}")
+
+ return problems
+
+
+def check_trigger_coverage() -> list[str]:
+ """自检:登记表内每条路径都须出现在 pre-commit 的 files 正则与 CI 的 paths 里。
+
+ 二者是独立于登记表的第二/第三份清单,漏配会使执法对新增副本组**静默失效**
+ (pre-commit 跳过、CI 不触发,均不报错)。此自检把该风险闭合在本脚本内。
+ """
+ problems: list[str] = []
+ watchers = {
+ ".pre-commit-config.yaml": REPO_ROOT / ".pre-commit-config.yaml",
+ ".github/workflows/twin-files-consistency.yml": (REPO_ROOT / ".github/workflows/twin-files-consistency.yml"),
+ }
+
+ for label, path in watchers.items():
+ if not path.is_file():
+ problems.append(f"{label} 不存在,无法确认触发覆盖")
+ continue
+ text = path.read_text(encoding="utf-8")
+ for _name, _rationale, rel_paths in TWIN_GROUPS:
+ for rel in rel_paths:
+ # pre-commit 正则里 `.` 写作 `\.`,故两种写法均视为已覆盖。
+ if rel not in text and rel.replace(".", r"\.") not in text:
+ problems.append(f"{label} 未覆盖登记路径:{rel}")
+
+ return problems
+
+
+def main() -> int:
+ failed = False
+
+ for name, rationale, rel_paths in TWIN_GROUPS:
+ if len(rel_paths) < 2:
+ print(f"[配置错误] 分组「{name}」少于 2 条路径,无从比对", file=sys.stderr)
+ failed = True
+ continue
+
+ problems = check_group(rel_paths)
+ if not problems:
+ print(f"✓ {name}:{len(rel_paths)} 份副本逐字节一致")
+ continue
+
+ failed = True
+ print(f"\n✗ {name}:孪生文件漂移", file=sys.stderr)
+ print(f" 副本存在的理由:{rationale}", file=sys.stderr)
+ for problem in problems:
+ print(f"\n {problem}", file=sys.stderr)
+
+ if failed:
+ print(
+ "\n孪生文件出现漂移。请判断哪份体现了本次改动的意图,"
+ "将其同步到同组其余路径(含文档注释),并同步两端回归用例。",
+ file=sys.stderr,
+ )
+
+ coverage_problems = check_trigger_coverage()
+ if coverage_problems:
+ failed = True
+ print("\n✗ 执法触发覆盖不全(新增副本组后忘了扩充触发清单?):", file=sys.stderr)
+ for problem in coverage_problems:
+ print(f" - {problem}", file=sys.stderr)
+ print(
+ " 未覆盖的路径不会触发 pre-commit 钩子 / CI job —— 执法对其静默失效。",
+ file=sys.stderr,
+ )
+
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())