一篇文档在发布前被合并——内容里的数字是过时的。改代码的人不会记得去更新文档;写文档的人不会知道代码变了。哨兵不解决这个问题的根源,但它让「忘了更新」变成「不让你合」。于无声处。——题记
FIBEMATE 系列。此前讲过 CBOM(盘点用了哪些算法)、密码学时间账本(证据在何时存在)、密码时间机器(查 git 历史)。这一篇解决最后一个问题:文档里写的数字,和产出物对不对得上。 这不是抽查,是把文档传唤到证据面前——每条声明要么对上产出物,要么交代自己为什么对不上。
工具叫 verifact,事实哨兵。已开源,Apache-2.0,零依赖(Node ≥ 18)。
仓库里到处是硬数字:性能基准、测试计数、状态数、文件行数。文档写在某个时刻,产出物在另一个时刻重测,两边的数字就开始各走各的。
真实例子,2026-09-27 实测:
PROGRESS.md:79 写「ML-KEM-768 keygen 103µs」。产出物 pqc-hw-bench/README.md 实测 262μs。score=0.66,value_differs。RELEASE_NOTES.md 写 TLA+ 不变式 3 条。模型文件里是 7 条。p95=78.5ms,在全部 5369 条产出物里找不到来源——它出现在主仓 README 和官网 pqc-readiness、FAQ、architecture 四处,出处一栏全是空的。前两条是文档落后于代码。第三条更麻烦:一个没有出处的数字,挂在最显眼的位置。
人工核对这类问题靠 grep 和肉眼。文档 341 份、声明 2251 条,肉眼核对每轮发稿都来一遍不现实。这就是工具要占的位置。
工作分三步。
抽取。扫描 markdown(默认也查代码块外的正文),把带单位的数字连同键名抽出来:103µs、26,115 states、654 行。每条声明记文件、行号、键名、数值。
绑定。把声明和产出物对接。产出物来源在配置里登记:benchmark 的 JSON/日志、主仓 scripts、时间戳目录、核心库。绑定按键名相似度和数值容差打分(阈值 minScore: 0.45,相对容差 0.5%)。
判定。六种结果,只有一种是红的:
| 判定 | 含义 | 处置 |
|---|---|---|
| verified | 文档值和产出物在容差内一致 | 绿 |
| drift | 绑定成功但数值超出容差 | 红,门禁拦截 |
| ambiguous | 多个产出物候选冲突,或跨模块同名键 | 交人工 |
| unbound | 找不到产出物依据 | 交人工 |
| 引用存在 / 缺失 | 声明引用 lg-XXX 这类实体,查它在不在扫描范围 | 存在绿 / 缺失交人工 |
这套分级的关键是不把「没证据」当「错」。unbound 1836 条不等于文档在说谎——可能证据没导出成机器可读格式,可能是叙述性文字。把 unbound 也算失败,工具会在第一天就被噪音淹没。红只留给一种情况:找到了依据,但数字对不上。
2026-09-27,对 fibemate 全仓 + 待发布文章 + benchmark 产出物跑全量:
文档 341 份 产出物 5369 条 声明 2251 条
verified 145 drift 24 ambiguous 227 unbound 1836
引用型 19(存在 17 / 缺失 2) 另有 17 条声明经 ignore 规则收敛、不计入六态
核验率 7.2%
用语线索 high 5 medium 117 low 10
耗时 4.1s(workers=6,冷缓存)
核验率的计算口径:(verified 145 + 引用存在 17)÷ 可判定声明 2251。可判定 = 六态合计,被 ignore 规则收敛的 17 条不计入。计算在 bin/verifact.js:117,读者可以自己验。
核验率 7.2%,不高。这是诚实的上限:能自动核验的前提是产出物机器可读,而大多数声明(叙事、对比、非数值)没有对应格式。工具的作用是守住有依据的那部分,不是假装能核全部。
回归测试 19 项(test/run.js),全部 PASS。
本地跑工具只是第一步——人不一定记得跑。真正的门禁在 CI。
主仓新增 sentinel.yml:任何 PR 改了 www/docs/**、packages/pqc-kem/**、packages/fml-dsa/**,自动触发。作业内容一条命令:
node bin/verifact.js verify --docs ../../fibemate/www/docs --fail-on drift --no-cache
双仓 checkout(主仓 + fibemate-tools),两个 Action 都用 40 位 SHA 钉死版本;--no-cache 保证每次全量、不吃缓存红利;--fail-on drift 把红锁定在唯一一种该红的判定上。比成功,CI 绿;比出 drift,CI 红——不让你合。
ignore 机制在这里发挥作用:当前 12 条规则、收敛 17 条声明,每条规则带 note 说明为什么——比如「RSA-2048 那一行的 60ms,与 ML-KEM keygen 无关」「文档引的是 JS 基准,verifact 查到的是硬件基准,两套数据都对,口径不同」。ignore 不是掩盖,是把「口径差异」和「真过期」分开登记,将来口径统一了再撤。
哨兵本来还有一个作业——xdiff(实现差分检测:同一个算法,两个实现各跑一遍,看输出是否逐字节一致)。它在本地跑是对的,但 CI 里硬编码了本地绝对路径 D:/FIBEMATE/...,CI 上找不到实现文件,一直假红。
两个选项:
continue-on-error——门禁炸了当看不见选了后者。xdiff 从 sentinel.yml 拆除(2026-09-27,workflow 里留了注释说明原因),等它支持相对路径再挂回来。有的门禁挂一个 continue-on-error: true 看起来体面,实际上更不体面。CI 门禁要么拦,要么不存在,中间状态是噪音。
三条守门线,各自不重叠。
工具投产两周,实际拦下的:
p95=78.5ms 出现在主仓 README 和官网 pqc-readiness、FAQ、architecture 四处,产出物里无依据。处理方式二选一:补实测证据,或改写口径——发稿前必须处理。PROGRESS.md 的 103µs 对不上产出物的 262μs,「提升 15-31x」的倍数声明跟着动摇。用语线索单独说。抽取器顺带查绝对化措辞,今天的 high 级 5 条:「全球唯一」「绝对安全」「零风险」。这类词在任何审计语境都站不住,位置和上下文一起列出来,改稿时逐条清。
minScore: 0.45 和容差按本项目调的,换仓库要重新调。serve 起的看板只监听 127.0.0.1:8787。--out 指定的路径和 .verifact/ 缓存目录。扫描显式跳过 .git/、node_modules/。git clone https://github.com/Lennonhaha/fibemate-tools
cd fibemate-tools/verifact
node bin/verifact.js init # 生成配置样例
node bin/verifact.js verify # 全量核验,exit 0=过 / 1=有drift / 2=配置错
node bin/verifact.js verify --fail-on unbound # 收紧门禁——慎用
node bin/verifact.js explain <文件>:<行>:<键> # 单条溯源
node bin/verifact.js diff # 和上一轮对比
node bin/verifact.js serve # 本地看板
配置里 deny 和 ignore 两段是人工复核后的沉淀:deny 拦已知误绑,ignore 收敛同名键的语义冲突。每条带 note,说明为什么。
verifact/(19 项回归,node test/run.js).github/workflows/sentinel.yml(双仓 checkout,Action SHA pin)verifact/verifact.json.example(roots / deny / ignore 三段结构)node bin/verifact.js verify --format md --out report.md