---
name: 2026-08-04-issue224-225-watchdog-margin
description: >
  两个 nightly 自动开的 p4 fuzz crasher（#224、#225）的处理轮，分支 `fix/224-225-fuzz-crashers`，
  3 个 commit（`4c89799`、`f844bf1`、`d3928f5`），**产品代码零改动，这一轮的价值全在诊断上**，
  而且**第一版的结论被一次独立审计推翻过一次**。两个 seed 都是 concat storm
  家族（#123–#167）的写法——一个约 90 字节的字面量在 `for i=1,777777776` 循环里拼接，而且赋值目标被
  写错（`qut` 而不是 `out`），所以累加器根本不增长，最小化之后的 seed 天然是轻的。两个都**既不崩也不
  分歧**，本地重放各约 1 秒就正常触发预算。**关键发现是「重放干净」这次不是全部答案**：我照惯例先做
  版本核对（两个 run 都在 `093f7d1`，早于 #222 那一轮的合并），但接着做了一件之前没做的事——**把 seed
  也拿到 `093f7d1` 上跑**，结果它们在**那里也已经被界住**，所以 #222 那一轮并不是修好它们的原因，
  「过期、已修复」这个惯常结论**不成立**；run 时间确实早于合并，但那个事实与本轮无关。**真正的问题是
  余量**：`1<<20` 的 step budget 允许约 64 MiB 的 concat，本地每个 fuzz 子测试花 0.7–1.3 秒，而 CI
  运行器约慢 10 倍（这一点**仓库自己早已记在** `internal/crescent/state.go` 的 `chargeBulkWork` 注释
  里），于是这一族最慢的 seed 投射到 CI 是 12–13 秒，撞上 go-fuzz 的 **10 秒 per-input 看门狗**——六个
  家族 seed 里**两个已经超过**、四个余量不到 1.4 倍；nightly 日志印证了机制而不是靠推断（`panic:
  deadlocked`，正是那个看门狗）。**我第一版的修法是把 step budget 减半到 `1<<19`，这个值被审计推翻
  了**：`1<<19` 只修好了 corpus 里那六个 seed，而它们的**邻域仍然在看门狗之上**——按四次 Run × CI 慢
  10 倍投射，`out=out.."x"` 循环 14 秒、`t[tostring(i)]=i` 循环 18 秒、`s:gsub("%a","x")` 循环
  **41 秒**（是看门狗的四倍，比我正在修的那两个 seed 还糟）。`gsub` 每次调用按大约两倍主串计费，可它
  实际做的工作远多于等额计费的 concat——**等额计费不等于等额 wall-clock**，所以预算要由「计费相同时
  最贵的写法」定，而不是由「恰好被开成 issue 的那个写法」定。最终取 `1<<16`：上面最坏那条降到
  **5.2 秒**、余量 **1.93 倍**，是第一个满足「与看门狗差一个数量级」这个我自己文档里写下的判据的值；
  corpus 全量重放 5.5 秒 → **0.85 秒**。新增 `internal/fuzzbudget` 包的 `Steps` 常量（带与
  消费方相同的 build tag），`fuzz_auto_test.go` 与 `fuzz_p4_test.go` 四处 `SetStepBudget` 改用它。
  **「不损失覆盖」这次是实测的而不是断言的**：harness 自己的 seed corpus 在 `1<<20` / `1<<19` /
  `1<<16` 三个预算下 `PromotionCount` **完全相同**，那些写法在几次调用之后就升层、从不接近任何一个
  上限。**检测能力也验证过**：注入一个真实的 P1-vs-P4 分歧（把升层侧的返回值截断），在 `1<<16` 下
  仍然 FAIL，恢复后通过；90 秒引导式 fuzz 干净。**回归测试原先并不防它声称防的东西**：审计实测把预算
  调回 `1<<20`（正是那个回归）时它照旧通过，因为它的上界写了 1 秒而它自己的注释里推导出的是 250 毫秒，
  而且它拿「四次 Run 的投射」去比「一次 Run 的测量」；现在上界改成推导出的 250 毫秒，并且加进了上面
  那三个真正约束预算的写法，因此能同时抓住 `1<<19` 与 `1<<20`（已用变异实测确认）。六条教训：版本
  核对之后还要把 seed 拿到那个旧 commit 上跑一遍以验证归因 / 一个上限只要「界住」还不够，它允许的量
  必须与外部看门狗差一个数量级 / **上限要由「计费相同时最贵的写法」定，不能由「恰好被开成 issue 的
  那个写法」定** / 降低 fuzz 预算不等于降低覆盖，但这句话要用注入缺陷加白盒计数器来证明 /
  **一个「防住某个数值」的回归测试必须用变异实测确认它真的会因为那个数值变化而变红** / 常量要与它的
  消费方共享 build tag。
metadata:
  type: reflection
  date: 2026-08-04
---

# 两个既不崩也不分歧的 crasher，与一个被审计推翻的余量数值（2026-08-04，分支 `fix/224-225-fuzz-crashers`）

> 范围：#224、#225 两个 issue，3 个 commit（`4c89799` 第一版减半、`f844bf1` 文档、`d3928f5` 审计之后
> 改按最贵写法定值）。**产品代码零改动**。改动落在 `internal/fuzzbudget`（新增 `fuzzbudget.Steps`
> 常量，终值 `1<<16`）、`fuzz_auto_test.go` 与 `fuzz_p4_test.go`（四处 `SetStepBudget` 改用它）；
> 回归 `test/regression/issue224_watchdog_margin_test.go`；
> 两个 seed 入 `testdata/fuzz/FuzzAutoPromote/6ed94d7f9fe6248a` 与 `841bbefecf338b0d`。

## 任务

nightly 在两个夜晚自动开了两个 p4 fuzz crasher issue（#224、#225）。

## 本轮做了什么

### 1. 两个 seed 都是这个家族的写法，而且都是轻的

两个 seed 的脚本形式一样：

```lua
local function cat(i) return "<约 90 字节的字面量>" .. i end
local out = ""
for i = 1, 777777776 do qut = out .. cat(i) end
return out
```

这是 concat storm 家族（#123–#167）的第七、第八次被开成 issue。注意 `qut`——赋值目标被 fuzzer
写错了，所以累加器 `out` 从头到尾是空串，每次迭代拼的都是约 90 字节而不是越来越长的串。这正是
**最小化之后的 seed 天然是轻的**那个机制：真正把 worker 弄死的输入更重，落盘的这一个是最小化过程
自己活得下来的那个。

两个都**既不崩也不分歧**，本地重放各约 1 秒，字节记账正常把它们界住、正确抬「instruction budget
exceeded」。

### 2.「重放干净」这次不是全部答案

上一轮（#221/#222）的教训 4 已经写过：这个家族「本地重放干净」是**必然属性**，不含任何缺陷信息。
所以我没有用它结案。按 [[unreproducible-crasher-triage]]「第一步永远是版本核对」核了版本：两个 run
的 headSha 都是 `093f7d1`，**早于**昨天 #222 那一轮的合并提交。按前几轮的流程，这里就该判第一档、
在当前 HEAD 上重放确认之后结案。

**但我这一轮多做了一步：把 seed 也拿到 `093f7d1` 上跑了一遍。** 结果是它们在**那里也已经被界住**
——`093f7d1` 上跑一样是约 1 秒正常触发预算，没有任何异常。

这一步把结论整个推翻了：**#222 那一轮并不是修好它们的原因**，所以「过期、已经被上一轮修好了」这个
惯常结论**不成立**。run 时间确实早于合并，但那个事实与这两个 issue 为什么被开出来无关——它们在那个
commit 上就已经不是「无界」了。

### 3. 真正的问题是余量，而仓库自己早就把关键数字写下来了

`1<<20` 的 step budget 在 1 步 / 64 字节的比率下允许约 **64 MiB** 的 concat。这个数是 #168 那轮定的，
当时的判据是「让 4 次 `prog.Run` 在 CI 上也舒服地待在 1 秒以内」。实测本地每个 fuzz 子测试
**0.7–1.3 秒**。

而 **CI 运行器比本地慢约 10 倍**——这一点不需要我去测，`internal/crescent/state.go` 的
`chargeBulkWork` 注释里早就写着（那一轮正是按最慢的 CI runner 把比率从 1 步/KiB 收紧到 1 步/64 字节
的）。把本地耗时乘上这个倍率：

| 量 | 值 |
|---|---|
| 本地最慢的家族子测试 | 1.34 秒 |
| 投射到 CI（×10） | 12–13 秒 |
| go-fuzz 的 per-input 看门狗 | **10 秒** |
| 六个家族 seed 的余量 | **两个已经超过**，另外四个不到 **1.4 倍** |

所以这两个 issue 不是产品缺陷，是 **harness 自己的余量不够**：输入已经从「无界」变成了「只是太慢」，
而看门狗分不出这两者。

**机制是日志印证的，不是推断的**：nightly 日志里就是 `panic: deadlocked`，那正是
`internal/fuzz/worker.go` 里 `time.AfterFunc(10*time.Second, panic)` 的输出（家族根因轮 #166/#167
已经定性过这一行的含义）。

### 4. 第一版修法（减半到 `1<<19`）被审计推翻

我第一版把预算减半，`internal/fuzzbudget` 新增常量 `fuzzbudget.Steps = 1 << 19`，`fuzz_auto_test.go`
（两处，`st1` 与 `stA`）与 `fuzz_p4_test.go`（两处，`st1` 与 `st4`）四处 `SetStepBudget(1 << 20)`
改用它。判据是「六个 corpus seed 都拿到 ≥1.8 倍余量」。

**一次独立审计推翻了这个值**：`1<<19` 只修好了 corpus 里那六个 seed，而**它们的邻域仍然在看门狗
之上**。按四次 Run × CI 慢 10 倍投射，在 `1<<19` 下实测：

| 写法 | 投射耗时 | 对 10 秒看门狗的余量 |
|---|---|---|
| `local out="" for i=1,777777776 do out=out.."x" end` | 14 秒 | 0.70 倍 |
| `local t={} for i=1,777777776 do t[tostring(i)]=i end` | 18 秒 | 0.56 倍 |
| `local s=string.rep("a",4096) for i=1,777777776 do s:gsub("%a","x") end` | **41 秒** | **0.24 倍** |

最后那条是看门狗的**四倍**，比我正在修的那两个 seed 还糟。根因判据也因此要改：**`gsub` 每次调用按
大约两倍主串计费，但它实际做的工作远多于等额计费的 concat——等额计费不等于等额 wall-clock。** 所以
预算该由「计费相同时最贵的写法」定，而不是由「恰好被开成 issue 的那个写法」定。

### 4b. 终值 `1<<16`：按最贵的那条写法定

`internal/fuzzbudget` 的常量最终是：

```go
//go:build (wangshu_p3 || wangshu_p4) && wangshu_profile

const fuzzbudget.Steps = 1 << 16
```

| 量 | `1<<20` | `1<<19`（被推翻） | `1<<16`（终值） |
|---|---|---|---|
| 最坏可达写法（`gsub` 循环）投射到 CI | — | 41 秒 | **5.2 秒** |
| 对 10 秒看门狗的余量 | — | 0.24 倍 | **1.93 倍** |
| corpus 全量重放 | 5.5 秒 | — | **0.85 秒** |

`1<<16` 是第一个满足「与外部看门狗差一个数量级」这个**我自己在这条分支的文档里写下的判据**的值。

### 5. 「不损失覆盖」这次是量出来的，不是断言的

减小 fuzz harness 的资源上限，直觉上的风险是「它现在还能不能发现真问题」。第一版我是用推理答的
（「这一族每种写法在两个预算下都会触发，所以路径一样」）。这一版改成实测：**harness 自己的 seed
corpus 在 `1<<20` / `1<<19` / `1<<16` 三个预算下 `PromotionCount` 完全相同**——那些写法在几次调用
之后就升层，从不接近这三个上限中的任何一个。缩小的只是「单个输入能消耗多少 wall-clock」，而那恰好是
看门狗量的那个量。

另外**注入了一个真实的分歧**：把升层侧的返回值截断，制造一个 P1-vs-P4 的真差异。在 `1<<16` 下
harness 仍然 FAIL；把注入撤掉之后通过。90 秒引导式 fuzz 干净。

**审计的第二条发现，据实记录**：`1<<20` 时 corpus 里有两个 seed 会把 arena 推到上限，`1<<16` 时
没有，所以那条 arena-cap 错误分支在这里覆盖到的写法变少了。这是**收窄而不是空洞**——
`test/regression/issue144_regression_test.go` 直接覆盖 arena cap；而且我实测两个预算下 step budget
都先于 arena cap 触发，所以这条路径本来就不是靠 step budget 到达的。

### 5b. 回归测试原先并不防它声称防的东西

`test/regression/issue224_watchdog_margin_test.go` 第一版覆盖两个被开成 issue 的 seed 写法（累加器
不增长的那一版与累加器真的二次增长的那一版），并且在 harness 自己的预算设定下量代价而不是量 corpus
的耗时。看起来是对的，**但审计实测把预算调回 `1<<20`（正是那个回归）时它照旧通过**。两个原因：

1. 它的上界写了 **1 秒**，而它自己的注释里推导出的是 **250 毫秒**（`10 秒 / 10 倍 / 4 次 Run`）——
   松了四倍。
2. 它拿「四次 Run 的投射」去比「一次 Run 的测量」，两个量不同量纲。

而且只用被开成 issue 的那两个 seed，是**分辨不出** `1<<16` 与 `1<<19` 的——那两个 seed 在 `1<<19`
下本来就够便宜。现在上界改成推导出的 250 毫秒，并且把上面那三个真正约束预算的写法加进用例表，因此
它能同时抓住 `1<<19` 与 `1<<20`，**已经用变异实测确认过**。

### 6. 一个过程细节：常量的 build tag

我第一版把 `fuzzbudget.Steps` 写成一个**没有 build tag** 的文件，golangci-lint 报 unused。原因是消费
它的两个文件都在 `(wangshu_p3 || wangshu_p4) && wangshu_profile` 之后，**默认构建看不见它们**，于是
默认构建里这个常量确实没有任何使用者。加上相同的 tag 之后，五种构建组合都干净。

## 期望与实际

- 期望：又是两个 nightly crasher，按上一轮的流程核版本 → 在当前 HEAD 重放 → 大概是过期的，结案。
- 实际：版本核对与重放都如期，而**多做的那一步（在旧 commit 上也重放）把「已被上一轮修好」这个归因
  证伪了**。真实原因在另一个维度上——不是「有没有被界住」，而是「界住之后允许的量与外部看门狗之间还
  剩多少余量」。产品代码一行没改，改的是 harness 的一个数字。
- 而且那个数字我第一次定错了：**我按被开成 issue 的 seed 定值，减半到 `1<<19` 就收工，而邻域里的
  `gsub` 循环比它糟四倍。** 是一次独立审计把它量出来的，同一次审计还发现我那个「防住这个数值」的回归
  测试在预算调回 `1<<20` 时照旧通过。所以这一轮真正的产出不是那个数字，而是「定上限要按最贵写法量」
  和「回归测试要用变异确认」这两条判据。

## 教训

### 教训 1（版本核对之后，还要把 seed 拿到那个「旧 commit」上跑一遍）

我此前几轮的流程是「run 的 sha 早于修复 → 在当前 HEAD 上重放确认已修 → 结案」。这一轮加了一步：
**也在旧 commit 上重放**，结果发现它们在那里就已经被界住，于是「已被某轮修好」这个因果是错的，真实
原因在别处。

**Why**：前一轮（#212–#219）已经把「三档是成本档不是缺陷档」写进 guide，那一条修的是「不要因为 sha
旧就跳过重放」。但它只要求在**当前 HEAD** 上重放，而当前 HEAD 上通过这件事，与「是哪个改动让它通过
的」是两个问题。只在 HEAD 上重放能得到「现在没问题」，却得不到「为什么现在没问题」——而后者决定还要
不要继续查。这一轮如果停在第一步，我会写一句「已被 #222 那轮修好」，然后六个 seed 的余量问题继续留在
仓库里，下一个夜晚再开一个 issue。

**How to apply**：判据 = 确认一个 issue「已被某个改动修好」时，要同时确认**修法与现象之间真的有
因果**——把 seed 也拿到那个「修复之前」的 commit 上跑一遍，在那里也不复现就说明归因错了，得继续查。
自查办法：说「这是上一轮修好的」之前，先问「我在那个修复之前的 commit 上量过吗」；没量过，这句话就
只是时间顺序的巧合，不是因果。这与 [[prove-the-path-under-test]] §9.6「双向验证」是同一条纪律换了
时点——那条讲验证一个**修复**要在 base 上确认 FAIL，本条讲判定一个 issue **过期**要在 base 上确认它
也不复现（如果在 base 上就不复现，那个修复不是原因）。

### 教训 2（一个上限只要「界住」还不够，它允许的量必须与外部看门狗差一个数量级）

`1<<20` 确实把 concat 界住了，但它允许的 64 MiB 在 CI 上要跑 12–13 秒，而看门狗是 10 秒。输入从
「无界」变成了「只是太慢」，而看门狗分不出这两者。

**Why**：上一轮（#222）的结论是「预算必须度量工作量而非指令条数」，那一条修的是**有没有界**。本条是
它的下半句：**界住之后，那个界允许的最坏耗时还得与真正会杀掉进程的那个超时差得够远。** 一个资源上限
不是孤立生效的，它上面还叠着若干外部超时（fuzz 的 per-input 看门狗、CI job timeout、`go test
-timeout`），而这些超时量的是 wall-clock，不是步数。上限与超时之间是一个隐含的换算：步数 × 每步的
真实代价 × 目标机器的慢速倍率 = wall-clock。这三个因子里只有第一个写在代码里，另外两个要自己去量或者
去翻文档——而这一轮那个倍率**仓库自己早已记下来了**（`chargeBulkWork` 的注释），我只需要读它。

**How to apply**：判据 = 定一个资源上限时，把「这个上限允许的最坏耗时」乘上目标机器的慢速倍率，再与
那台机器上所有外部超时（看门狗、job timeout、测试 timeout）比一遍；**余量小于一个数量级就等于没有
余量**。自查办法：写下一个三项乘法——上限允许的量 × 单位量的本地耗时 × 慢速倍率，得到投射耗时，然后
列出这个投射耗时要躲开的每一个超时；任何一项余量不到 10 倍，那个上限就还没定完。反过来，`test/`
里已有的 in-test deadline 分档纪律（余量 < 10× 立刻换成包级 timeout）是同一条判据在**测试侧**的形式，
见 [[unreproducible-crasher-triage]]「重 workload regression 测试的裁判机制」。

**注意我自己第一次用这条判据就用错了**：我把「上限允许的最坏耗时」当成了「被开成 issue 的那些 seed
里最慢那个的耗时」，于是 `1<<19` 看起来满足判据（六个 seed 全部 ≥1.8 倍），实际上邻域里最贵的写法
在同一个预算下是 41 秒。这条判据里的「最坏」指的是**这个上限之下可达的最坏**，不是**手里恰好有的
最坏**——见下面教训 3。

### 教训 3（上限要由「计费相同时最贵的写法」定，不能由「恰好被开成 issue 的那个写法」定）

我按被开成 issue 的 seed 把预算减半到 `1<<19` 就收工，而邻域里的 `gsub` 循环在同一个预算下投射到
41 秒，是看门狗的四倍、比我正在修的两个 seed 还糟。

**Why**：一个 step budget 的单位是**计费额度**，而看门狗量的是 **wall-clock**，两者之间的换算率
**逐算子不同**。`gsub` 每次调用按大约两倍主串计费，可它实际要跑模式匹配、按匹配次数改写、再拼接
结果，做的工作远多于等额计费的一次 concat——**等额计费不等于等额 wall-clock**。被 fuzzer 开成 issue
的那个 seed 只是这个预算之下的一个采样点，而且是被最小化过程削轻过的采样点，用它定上限等于用一个
偶然的样本去代表整个可达集合的上界。这与 [[prove-the-path-under-test]] §5「fuzz 探索空间任一维动一格
就是新一轮探索」是同一个思路的反向用法：既然 fuzzer 会继续在这个上限之下生成新写法，那么定值时就该
按**它能生成的最贵写法**算，而不是按它这次交上来的那一条。

**How to apply**：判据 = 定一个资源上限之前，把**同一计费额度之下最贵的几种写法**都量一遍，用最坏
那条定值；被开成 issue 的那个写法只是其中一个候选、通常还不是最贵的。自查办法：说「这个上限够了」
之前，先列出「同样吃掉这份额度的写法还有哪几种」，逐个量投射耗时；列不出来就说明还没找到那个上限之下
真正可达的最坏情况。找候选的办法是沿着**计费口**枚举——凡是走同一个计费器（本仓是 `ChargeBulkWork`）
的算子，每一个都写一个紧循环量一次。

### 教训 4（一个「防住某个数值」的回归测试，必须用变异实测确认它真的会因为那个数值变化而变红）

`test/regression/issue224_watchdog_margin_test.go` 第一版声称防住「预算被调回去」，而审计实测把预算
调回 `1<<20`（正是那个回归）时它照旧通过。

**Why**：它错在两处，而两处都是「宽了一点」而不是「写错了」，所以读起来完全正常。① 上界写了 1 秒，
而它自己的注释里推导出的是 250 毫秒——注释推导出一个值，代码用了它的四倍，我当时的理由是「怕共享
机器上不稳定」，可这个宽度正好把被防的回归放了进去。② 它拿「四次 Run 的投射」去比「一次 Run 的
测量」，两个量不同量纲，比较本身没有意义。再加上它只用被开成 issue 的那两个 seed，而那两个 seed 在
`1<<19` 下也够便宜，所以它连 `1<<19` 与 `1<<16` 都分辨不出来。这是
[[prove-the-path-under-test]] 那一族「绿色不等于在测你以为在测的」在**数值防护**上的形式：一个断言
只要比被防的值松，它就是恒真的，而恒真的断言永远是绿的。

**How to apply**：判据 = 写完一个「防住某个数值」的测试，立刻把被防的值改回去跑一次；**不变红就是
没有防住任何东西**。自查办法：变异一次之后再问两个问题——上界是不是就是推导出来的那个数（不是它的
若干倍），以及被比较的两个量是不是同一个量纲（投射比投射、测量比测量）。另外把「真正约束这个数值的
写法」放进用例表，而不是只放「恰好被开成 issue 的写法」（同教训 3）。写法参见
[[prove-the-path-under-test]] §4.5c。

### 教训 5（降低 fuzz 预算不等于降低覆盖，但这句话要用注入缺陷加白盒计数器来证明）

我一开始担心减半会削弱检测能力，于是注入了一个真实的 P1-vs-P4 分歧来验证——在新预算下仍然 FAIL。
第一版我只做到这一步，「覆盖不损失」那半句仍然是推理；这一版补上了白盒计数器：harness 自己的 seed
corpus 在 `1<<20` / `1<<19` / `1<<16` 三个预算下 `PromotionCount` 完全相同。

**Why**：调紧 harness 的资源限制时，「会不会漏掉东西」这个担心是对的，但**它不能靠推理消除**。
推理能给的只是「这一族每种写法在两个预算下都触发，所以路径一样」，而这句话本身也是一个需要检验的
断言：预算变小之后，某些输入可能在到达被测代码之前就退出，于是测试变绿的原因从「两侧一致」悄悄换成
「这个输入根本没跑完」——这正是 [[prove-the-path-under-test]] §9.2 那一类「判据自身消耗的资源也是它
的输入」的失效方式，而且它的表现是**测试照旧通过**。两个方向要分开证：**检测能力**用「让 harness
面对一个它必须发现的缺陷」证，**覆盖**用一个白盒计数器在几个预算下读同一个数证。

**How to apply**：判据 = 调紧 fuzz harness 的资源限制（step budget、arena cap、输入长度、超时）之后，
**注入一个必须被发现的缺陷**证明检测能力未受影响，并且**读一个白盒计数器**证明覆盖没变，而不是只看
现有用例是否仍然通过。自查办法：注入之后 harness 必须 FAIL、撤掉注入必须 PASS，两个方向都跑一遍；
只跑「仍然通过」这一半，等于用一个恒真的观测支撑一个具体结论。另外**如实记下变窄的地方**：本轮
`1<<20` 时有两个 seed 会把 arena 推到上限、`1<<16` 时没有，所以那条 arena-cap 错误分支在这里覆盖到的
写法确实变少了——这是收窄而不是空洞（`test/regression/issue144_regression_test.go` 直接覆盖 arena
cap，而且两个预算下 step budget 都先于 arena cap 触发，这条路径本来就不是靠 step budget 到达的），
但它该被写下来，不该被「覆盖不损失」一句话盖过去。

### 教训 6（常量要与它的消费方共享 build tag）

`fuzzbudget.Steps` 第一版写在一个没有 build tag 的文件里，而消费它的两个文件都在
`(wangshu_p3 || wangshu_p4) && wangshu_profile` 之后，于是默认构建里它没有任何使用者，golangci-lint
判它 unused。

**Why**：一个标识符的「有没有被用到」不是全仓库范围的事实，而是**每一种构建组合各自的事实**。tag 化
的文件在某些组合里整个不存在，所以只要常量的可见范围比消费方**宽**，就一定存在一种组合让它孤立。本仓
的构建组合不少（默认 / p3+profile / p4+profile / oracle cgo / trace），一次只跑其中一种 vet 不足以
覆盖。

**How to apply**：判据 = 新增一个只被 tag 化文件使用的标识符时，给它**同样的** tag；并且用**消费方
的每一种 tag 组合**各 vet 一次。自查办法：写完之后问「哪一种构建组合里这个标识符会没有使用者」，答得
出来就说明 tag 还不对；答不出来就把组合列全跑一遍。

## Promotion 决策

- **教训 1** 已补进 [[unreproducible-crasher-triage]]「第一步永远是版本核对」一节，作为版本核对的
  第四步（在旧 commit 上也重放以验证归因）。
- **教训 2** 已在同一篇 guide 新增一节「上限的余量：界住不等于够快」，紧接「concat storm 家族已根因
  定性并修复」那一族之后——那一族讲**有没有界**，本节讲**界住之后余量够不够**。
- **教训 3**（按最贵写法定上限）与**教训 4**（防数值的回归测试要用变异确认）已补进同一篇 guide 的
  「上限的余量」一节，紧接那条余量判据之后——它们是那条判据的两个使用前提：一个管「最坏」怎么取，
  一个管「防住了」怎么确认。
- **教训 5** 已补进 [[prove-the-path-under-test]] §9.2（判据自身消耗的资源也是它的输入）作为一个
  补记：调紧 harness 资源上限时用注入缺陷证明检测能力、用白盒计数器证明覆盖。
- **教训 6** 已入 [[prove-the-path-under-test]] §4.10d（一个标识符的 unused 是按构建组合判定的），
  紧接 §4.10c 之后——§4.10a/b/c 都是「测试自己的前提在新位置不成立」这一族。
- 文档对齐：`docs/design/p1-interpreter/12-testing-difftest.md` 新增 §4.9a2（一个预算「界住」还不够，
  它允许的量必须与外部看门狗差一个数量级，紧接 §4.9a）、
  `docs/design/p4-method-jit/08-testing-strategy.md` §3.4 记 harness 的预算设定、
  `docs/design/engineering.md` §1.1 给那两处历史 `1<<20` 加数值补记、
  `internal/crescent/state.go` 的 `chargeBulkWork` 注释与本轮互相指引（代码本轮不改，指引写在文档侧），
  `README.md` 与 `docs/design/p1-interpreter/implementation-progress.md` 各追加本轮台账。

## 触发场景

- **判定一个 nightly crasher「已被上一轮修好」之前**：把 seed 也拿到那个修复之前的 commit 上跑一遍；
  在那里也不复现就说明归因错了（教训 1）。
- **给 fuzz harness 或任何跑在 CI 上的东西定资源上限时**：算「上限允许的量 × 单位耗时 × 慢速倍率」，
  与那台机器上每一个外部超时比余量，小于一个数量级就是没有余量（教训 2）；而且那个「上限允许的量」
  要按**同一计费额度之下最贵的几种写法**量，不能只量被开成 issue 的那一条（教训 3）。
- **写一个「防住某个数值」的测试之后**：立刻把被防的值改回去跑一次，不变红就是没有防住任何东西
  （教训 4）。
- **调紧 harness 的资源限制之后**：注入一个必须被发现的缺陷证明检测能力还在，再读一个白盒计数器证明
  覆盖没变；变窄的地方如实记下（教训 5）。
- **把一个字面量提成常量、而消费方在 build tag 之后时**：给常量同样的 tag，按消费方的每一种组合各
  vet 一次（教训 6）。
- **看到 `panic: deadlocked` 时**：那是 go-fuzz 的 10 秒 per-input 看门狗，不是内存；下一步是量
  「harness 每输入跑几次 Run × 单次耗时 × CI 慢速倍率」，不是找内存峰值。

## 关联

[[unreproducible-crasher-triage]]（「第一步永远是版本核对」本轮补第四步；新增「上限的余量」一节；
「concat storm 家族已根因定性并修复」与「那三个候选算子已结算」是本轮的上文，本轮是那一族的下半句）·
[[prove-the-path-under-test]]（§9.2 判据自身消耗的资源本轮补记教训 5；§9.6 双向验证是教训 1 的同族；
§4.5c 写法是教训 4 的上文——那条讲一个结构性决定要有执行体，本条讲那个执行体自己也要被变异确认；
§5「探索空间任一维动一格就是新一轮探索」是教训 3 的同族；新增 §4.10d 是教训 6 的落点；§4.5d 是上一轮
的落点，本轮不动）·
[[2026-08-03-issue221-222-bulk-builder-budget]]（上一轮：三个批量算子按字节记账，教训 4「本地重放
干净对这个家族天然无效」是本轮没有用重放结案的直接理由；本轮补上它的下半句——重放干净之后还要问
「是什么让它干净的」）·
[[2026-07-20-concat-storm-root-cause-round]]（家族根因定性轮：`panic: deadlocked` 的含义、10 秒
看门狗、`chargeBulkWork` 的 1 步 / 64 字节比率与「CI 比本地慢约 10 倍」这个数字的出处）·
[[2026-08-02-issue212-219-fuzz-crasher-batch]]（版本核对三档是成本档不是缺陷档——本轮教训 1 是它的
延伸）·
`internal/fuzzbudget.Steps`（终值 `1<<16`，注释里记了 `1<<19` 为什么不够）·
`fuzz_auto_test.go` · `fuzz_p4_test.go` ·
`test/regression/issue224_watchdog_margin_test.go`（五个用例，后三个是真正约束预算的写法）·
`test/regression/issue144_regression_test.go`（arena cap 的直接覆盖，本轮 arena 变窄那一条靠它兜）·
`internal/crescent/state.go::chargeBulkWork`（CI 慢 10 倍这件事记在它的注释里）·
`docs/design/p1-interpreter/12-testing-difftest.md` §4.9a2 ·
`docs/design/p4-method-jit/08-testing-strategy.md` §3.4 ·
`docs/design/engineering.md` §1.1（那两处 `1<<20` 是历史数值，已加补记）

## 第三次审计:我新加的计费把 lua 毫秒级完成的程序拒了,而且盲点扫得不彻底

八条发现,分三类。

**一、三处计费数量错(过度计费,拒掉了合法程序)。** 这是我第二次在同一个方向犯错:
- `string.byte` 按 `len(s)` 而不是请求区间计费,于是 `for k=1,#s do n=n+s:byte(k) end` 这种逐字符扫描
  每次都为整个主串付费——4200 字节就被拒,而 lua5.1 是毫秒级。
- `table.insert`/`remove` 只要带位置参数就按整表计费,包括 `#t+1` 追加(完全不移位)和越界直接返回。
  同一份工作因为「有没有传位置参数」被区别对待。
- `table.sort` 按 `n*log2(n)*8` 计费——那个 `*8` 把每次**比较**当成一次机器字拷贝,于是单次 50 万元素
  排序要花掉预算的 17 倍,而 lua5.1 0.15 秒完成。**比较不是字节拷贝**,计量器的单位是字节,
  所以每种操作都要按**它自己的换算率**折算,而不是按 memcpy 的率。

**二、三处仍在看门狗之上,而且都在我上一轮声称扫过的范围里。**
- `table.maxn` 对**纯 hash 键**的表:我上一轮按 `RawBorder`(数组部分长度)计费,而那类表 border 是 0——
  **计费恰好漏掉了让它变贵的那类输入**。改成在 `RawNext` 遍历**里面**按访问到的键计费,
  并且按 64 字节等价(一次哈希探测不是一次字拷贝)而不是 8。368 毫秒 → 61 毫秒。
- `loadstring` 大源码:编译器路径完全不经过任何计费口。按源码长度计费,26 秒 → 149 毫秒。
- `collectgarbage`:全量回收要遍历活堆,代价随堆增长而调用只记一步。按 `GCCountKB()` 计费,
  30 秒以上不终止 → 10 毫秒。(lua5.1 在这里也慢——2 万次 6.3 秒——但它会终止,而我这边不会。)

**三、文档指向已删除的文件。** 我把常量搬进 `internal/fuzzbudget` 之后,九个文件二十一处仍写着
`fuzz_budget_test.go::fuzzStepBudget`。

追加三条教训:

7. **计费要按「这次调用实际处理的那一部分」,而不是按「它能看到的那个容器有多大」。** `string.byte` 按主串、
   `insert`/`remove` 按整表——三处都是拿容器规模代替了实际工作量。判据:计费表达式里出现「整个 x 的长度」时
   问一句「这次调用真的碰了整个 x 吗」;答案是否,就把表达式换成实际区间。
8. **计量器的单位是字节,所以每种操作都要按它自己的换算率折算。** 我把「一次比较」和「一次哈希探测」都
   按 8 字节算,前者高了(排序被拒)、后者低了(maxn 仍超时)。判据:给一种新操作定换算率时,
   量一次「N 次这个操作耗时多少」,再除以「N 字节拷贝耗时多少」,用比值定;不要默认它等于一个机器字。
9. **一个「按容器规模计费」的判据,天然漏掉「容器规模看起来是 0 但工作量很大」的输入。**
   `table.maxn` 按 `RawBorder` 计费,而纯 hash 键表的 border 是 0——计费漏掉的正好是最贵的那类。
   判据:写完一个按规模计费的判据,构造一个「那个规模指标为 0 或很小、而实际工作量很大」的输入;
   构造得出来就说明计费口选错了位置,应该挪到真正做工作的那个循环里面。

## 第四次审计:同一个错误在「用来修它的那次改动」里又犯了四次

六条发现,三条过度计费、三条不足计费。而**其中四条是第三次审计那条教训的原样复发**——
「拿容器规模代替实际工作量」——出现在**我为了修那一条而新加的计费里**:

- `unpack` 按 `RawBorder` 计费:纯 hash 键表 border 是 0,于是 `unpack(t,1,7990)` 记零、跑 19 秒;
  反过来 `unpack(a,1,1)` 在 4000 元素表上要付 500 步才读一个值。**这与 `maxn` 那条是同一个 bug,
  而且在同一个 commit 里。**
- `string.byte` 在**夹取之前**按请求区间计费:`("abc"):byte(1,1000000)` 按一百万次读收费而实际只读三个,
  而且在 oracle harness 更大的预算下**也**被拒——于是 `FuzzOracleDiff` 把它当成 wangshu 的限制跳过了。
- `table.insert` 只在 `1 <= p <= n` 时计费,把 `pos < 1` 排除在外——而那是 5.1 里**最贵**的插入写法
  (整表移位,2000 次迭代 35 秒)。
- `insert`/`remove` 读的是**未窄化**的浮点位置,而移位循环用的是窄化后的 int32,于是 `pos = 2^32+1`
  跳过计费、照旧移位。

最后这条点出了一个比「规模 vs 工作量」更准确的说法:**计费和工作必须读同一个量**。
`maxn` 读 border 而工作遍历所有键、`byte` 读未夹取的区间而循环读夹取后的、
`insert` 读原始浮点而移位读窄化后的——三种表现,同一个病。

追加两条教训:

10. **计费与它所计量的工作必须读同一个量,逐字面地同一个。** 不是「同一个概念」而是同一个变量:
    工作用夹取后的 `j` 就不能用夹取前的、工作用窄化后的位置就不能用原始浮点、工作遍历哈希部分就不能读
    数组 border。判据:把计费表达式里每个变量与工作循环里对应的变量逐一对照,不同名就是 bug;
    最省事的写法是**把计费挪到工作循环内部**,那里没有第二个量可选。
11. **一条刚记下的教训,要在同一次改动的所有新代码上自查一遍,而不只是在被指出的那处。**
    第三次审计教我「别拿容器规模代替工作量」,我修了 `byte`/`insert`/`sort`,却在同一个 commit 新加的
    `unpack` 里又犯一次。判据:写下一条教训之后,grep 自己这一轮**新增**的所有同类代码,
    按那条教训逐个检查——教训的价值在于被用来复查本轮的其余部分,而不只是修好被点到的那一处。
