> 派生产物写回输入的命名空间,这一步就不再幂等——而删掉产物不是修法
两个脚本读同一批输入:
结论先行¶
两个脚本读同一批输入:
for capj in sorted(STATE.glob("capability_*.json")): # 两个脚本都这么写
而其中一个脚本的输出叫 capability_summary.json,就写在同一个目录里。
>>> fnmatch('capability_summary.json', 'capability_*.json')
True
于是:
- 汇总脚本吃自己的输出,已有 summary 时再跑一次就不幂等;
- 审计脚本崩了。summary 的顶层按模型分组,没有
model/seed/checkpoints, 于是rep['model']抛KeyError: 'model'。
崩在中途这件事比崩本身更糟:那一次审计没有得到完整的 FAIL 总数, 后面的 freshness 检查也从来没跑。一个在中途崩掉的审计, 它已经打印出来的那些 PASS 既不是"全部通过"也不是"全部失败",是一段不知道有多长的前缀。
而最诱人的那个修法是错的,当时就被写进了验收标准里:
不要把删除 summary 当作永久修复。 验收必须包含"已有 summary 时连跑两次"和"全部预期输入缺失时应当失败"。
为什么¶
幂等不是一个性能性质,是一个正确性性质,而破坏它最常见的方式就是 让一步的产物落进它自己的输入命名空间。一旦落进去,这一步的行为就取决于 "之前跑过没有",而那是最难复现、最容易在别人机器上不一样的状态。
这里的运气在于schema 不同,所以它崩了。
如果 summary 恰好带着 model 和 seed 字段,
它就会被当成第 N+1 个输入静默计进汇总,于是:
| 症状 | |
|---|---|
| schema 不同(实际情况) | KeyError,崩在中途 — 吵,但看得见 |
| schema 兼容(很容易发生) | 多算一份、或者把上一轮的汇总当这一轮的输入 — 静默错数 |
崩掉是这个 bug 最好的结局。 所以修法不能只是"让它别崩"。
实际的修补是在两个脚本里各加一道 schema 过滤:
if "checkpoints" not in rep: # e.g. our own summary output
continue
这确实恢复了幂等,而且那句注释把问题说得很准("our own summary output")。 但要诚实地看清它修了什么、没修什么:
命名空间冲突还在。 修的是两个消费者,没修那个布局。 同一道四个 token 的过滤现在在两个文件里各有一份, 而第三个消费者忘了写它的代价,就是回到崩溃或者静默错数。
真正的修法有两档,按成本选:
把产物写到另一个目录或换一个不匹配的前缀(summary_capability.json),
这样 glob 在构造上就吃不到它;或者放弃 glob,用一份显式的输入清单
(这也正好修掉"glob 可能一项都不命中"那条,见
0 FAIL 不等于那些条件都成立——一份严格审计里八处用廉价代理冒充实质检查)。
怎么用¶
- 产物不要写进自己的输入命名空间。 换目录,或者换一个和输入模式不匹配的名字。 这是零成本的,而它消掉的是一整类 bug。
- 已经冲突了而暫时不想改布局时,schema 过滤要写在一个共享的加载函数里, 不要在每个消费者里抄一遍。抄一遍就等于把正确性托付给下一个人的记性。
- 幂等要进验收标准,而且形态是固定的两条: "在已有产物的情况下连跑两次" 和 "预期输入全缺时必须失败"。 前者抓自噬,后者抓空 glob。
- 一个会中途崩掉的审计,要么把"已检查到哪一步"也打印出来, 要么把总数的计算放在最后并显式声明"不完整"。 一段不知长度的 PASS 前缀会被当成完整报告读。
- 相关:被引用的产物要有一个任何运行都生成不出来的名字,
见 被引用的产物要有一个"任何一次运行都生成不出来"的文件名;
exists()当成有效性判据的同类坑见 参照语料坏掉不会让检查器报错,会让它放行一切——而 exists() 当缓存判据会把损坏永久化。
出处¶
自己的 audit_maintrack_artifacts.py 与 capability_summary.py:
两者都用 STATE.glob("capability_*.json"),而后者把 capability_summary.json
写在同一目录。KeyError: 'model' 与"没拿到完整 FAIL 总数"出自当时的复现记录;
"不要把删除 summary 当作永久修复"和那两条验收标准是原话。
glob 确实匹配自身输出、以及两处 schema 过滤各有一份,是我这次核的。