> 为保住行号而做的填充,让一个宏的值能不能到达下游取决于宏名有多长

Research Tools Advanced 2026-10-12 06:18 2026-10-12
#latex #text-processing #silent-failure #offsets #pipelines #failure-modes

一个 LaTeX 抽取器有两个下游:

// TABLE_OF_CONTENTS

结论先行

一个 LaTeX 抽取器有两个下游:

  • check,机械检查,每条发现都要报 file.tex:行号;
  • read,把正文交给一个"冷读者"去读,读者看不到行号。

两个下游共用一个宏展开函数。为了让 check 能报行号,它填充: 替换值比调用长就会把后面所有偏移推走,所以长于调用的值一律不展开。

def sub(m):
    val = macros.get(m.group(1))
    if val is None or (pad and len(val) > len(m.group(0))):
        return m.group(0)
    return val + " " * (len(m.group(0)) - len(val)) if pad else val

这个判据是字符数,于是一个数字能不能到达读者,取决于作者给宏起的名字有多长。 我拿出厂函数量了一遍边界,调用 \dsCell{} 共 9 字符:

len(值) pad=True(给 check) pad=False(给 reader)
8 x VVVVVVVV y x VVVVVVVV y
9 x VVVVVVVVV y x VVVVVVVVV y
10 x \dsCell{} y ← 不展开 x VVVVVVVVVV y
12 x \dsCell{} y ← 不展开 x VVVVVVVVVV​VV y

截断点正好在 len(值) ≤ len(调用)。项目自己在真实宏上量到的是 7/7 全部 按长度分流:\Ubayes{}(9)→ Bayesian(8)活,\Uill{}(7)→ Ill-posed(9)死。 而真正造成损失的那个是 \dsCellZero{}(13)→ 0.60/0.20/0.20(14), 差一个字符,一组结果数字没有到达读者。

为什么

填充这条约束对 check 是正确的:没有它,check 报的 file.tex:行号 会在第一个长宏之后全部错位,而一个指不到可编辑位置的发现等于没有。 对 read 它是错的,而且错得看不出来 —— 读者从没有行号, 所以这条约束在那一侧只有代价没有收益。

两件事让它长期隐形:

一、损失是内容相关的,不是均匀的。 短值全部活下来,长值全部死掉。 看输出像是"这篇论文有些宏没定义",像数据特性,不像 bug。 如果它是均匀丢 10%,第一天就会被发现。

二、症状落在第三个地方。 读者拿到一个空洞(早期版本连名字都不留), 而读者不会把空洞留空。实际发生的事是:它给两处缺口填了数 (-0.115 到 0.076),然后以"论文自相矛盾"为由给了一条致命评价 —— 两个数字在论文里都不存在,真值是 -0.100 到 -1.000,原句完全自洽。 一份有洞的文件比没有文件更糟:没有文件会报错,有洞的文件会得到一份流畅的报告。

还有一个容易混的点:这里有两个根因,缓解方式不同。 一个是预载宏的范围 —— 在导言区 \input 进来的 \newcommand 从没被读到, 而导言区在每一遍里都被整体涂白。另一个就是上面这条长度约束。 项目里有一篇论文七个宏全定义在主文件、没有任何 \input, 把定义挪进被 \input 的文件对它毫无帮助 —— 先分清是哪一个, 再谈修法,否则会照着无关的那条去改。

怎么用

一个变换服务两个需求相反的下游时,不要用布尔开关,要问"谁需要这条约束"。 这里的正解是 read 不带约束地展开(已经这么改了), 因为"保住偏移"是 check 的需求,不是数据的性质。 布尔开关能工作,但默认值会决定哪一侧静默受损 —— 默认 pad=True 意味着新加的下游默认继承一条和它无关的约束。

凡是"条件性地不做"的分支,都要留痕。 这个函数在拒绝展开时 返回原文 m.group(0),不留任何记录。只要在拒绝时计数一次, "本篇有 7 个宏因长度未展开"就会出现在报告里。 和 优雅降级设计好了,却没人被告知——退化必须在产物上留痕 同一条规矩: 降级必须在产物上看得见。

交给模型的输入要先过一道"形状完整"的检查。 这个项目的修法是 read 拒绝写出含空洞形状的文件。这比"叮嘱模型不要猜"可靠得多 —— 模型的输入薄了就会补,补出来的东西很流畅 (见 try/except 挡不住段错误,signal.alarm 送不进 C 代码——进程池会静默卡死 旁边那条: 机械核验比指令可靠)。

偏移保持型的文本处理,测试要带一个"比调用长的替换"。 这是最小的那个反例,两行就能写,而它是这一类流水线的通用故障点: 涂白、脱敏、宏展开、模板替换,全都有人为了对齐偏移而偷偷放弃替换。

出处

~/Tools/nonexpert-review:nonexpert_review.py:385-402(_expand_macros 与它自己写下的理由)、:1375(read 的 pad=False 调用点)、 fixtures/holes/numbers.tex(两个形状:一行两个 \newcommand、值比调用长)、 CALIBRATION.md 「最后那个宏 bug,和造成它的那条约束」与「虚构的那一条」两节。 上面那张边界表是我自己在 2026-10-11 导入 _expand_macros 跑出来的。