> 参照语料坏掉不会让检查器报错,会让它放行一切——而 exists() 当缓存判据会把损坏永久化
一个判定脚本要先去 arXiv 拉 9 篇参照论文的源码包,算出阈值区间,再用区间判定。 那天 arXiv 慢,于是:
结论先行¶
一个判定脚本要先去 arXiv 拉 9 篇参照论文的源码包,算出阈值区间,再用区间判定。 那天 arXiv 慢,于是:
curl --max-time 90把两个约 5 MB 的包截断在半路;subprocess.run(..., check=False)把失败吞掉了;- 截断的包只解出两个
.tex,挑主文件的判据是"带\documentclass", 于是挑中了唯一满足它的old_notes.tex—— 一份过期笔记; - 那份笔记量出 0 条断言,把区间下限拉到 0.0。
区间下限 0.0 的后果不是报错,是每一篇被测论文都"落在参照区间内"。 一个本该挑刺的工具变成了一个永远点头的工具,而且它的输出格式完全正常。
还有第四层:缓存判据是 if not tgz.exists()。
截断的包存在,所以它永远不会被重下。 一次网络抖动被固化成了一个常驻状态。
为什么¶
这件事的核心不是"下载会失败",是失败的方向。 参照语料是分母。分母坏掉的典型表现是分母变小/变松, 于是判定变宽松 —— 坏数据让检查器失效的方式是放行,不是拒绝。 而放行没有任何症状:没有异常、没有空输出、没有异常值告警, 因为"所有论文都合格"在格式上和"所有论文确实都合格"一模一样。
四层里每一层单独看都是合理的工程选择:给下载设超时是对的;
check=False 是为了让一篇拉不到不至于中断整轮;
按 \documentclass 挑主文件是 LaTeX 项目的标准做法;
用文件存在判缓存是最常见的写法。它们串起来才构成失效,
这也是为什么这类缺陷不会在 code review 里被看出来。
最值得抄的是修法的分层。三道守卫各管一层,而不是把超时调大:
| 守卫 | 管什么 | 做法 |
|---|---|---|
| 包完整 | 下载 | gzip 读到尾 + tar 目录走到底,不完整就重下(三次,超时递增) |
| 行可信 | 抽取 | 正文 < 2000 词视为抽取失败,跳过(9 篇里最短的正文是 3790 词) |
| 参照集不静默缩小 | 聚合 | 篇数少于现有区间的篇数就拒绝写入 |
第三道是最关键的那道,因为它不假设前两道有效。 前两道是针对已知失效模式的,第三道是针对未知的 —— 不管因为什么原因少了一篇,它都不许悄悄用一个更小的参照集覆盖旧区间。
还有一个细节值得单独抄:解压目录和包同生同死。
目录里存一个 .from_size 戳记,和包的字节数不符就整个删掉重解。
exists() 的毛病不只在包这一层,只要有缓存,就有"半成品被当成成品复用"。
存在不等于有效,而缓存判据写成存在是默认选项。
怎么用¶
凡是用外部数据算出来的阈值,先问"这份数据坏掉的时候,判定会变松还是变紧"。 变松的那些要有独立的完整性守卫;变紧的那些可以靠报错暴露。 这个方向判断不需要读代码,一分钟就能做完。
聚合层加一条"不许静默缩小"的断言。 样本数、参照篇数、 语料行数这类分母,和上一次的产物比一下,少了就拒绝写入并报出来。 这是最通用的一道,因为它不要求你预见失效模式。 参考 覆盖率这类比值能抓到不抛异常的缺陷——而"每页小节数"抓不到:比值和计数能抓到不抛异常的缺陷。
缓存判据用内容,不用存在。 大小、校验和、或者"完整读一遍"。 网络产物尤其如此:半截文件存在是常态,不是异常。
check=False 要么记下来,要么别写。 真的需要容忍单项失败时,
把失败项记进产物("本轮跳过 2 篇,原因:下载不完整"),
让下游能看见覆盖率。见
核验报告必须先报自己的覆盖率,再报结论——否则一次失败的核验看起来像核过了 和
优雅降级设计好了,却没人被告知——退化必须在产物上留痕。
出处¶
~/Tools/final-reviewer:fixtures/calibrate.py:55-100
(BODY_WORDS_FLOOR、_archive_ok、_fetch 的三次重试与递增超时、
.from_size 戳记,以及写在代码里的那段事故记录)、
CALIBRATION.md 2026-10-04「重标定本身也出了一次"没检查却报干净"」一节。