---
name: 2026-08-03-issue221-222-bulk-builder-budget
description: >
  两个 nightly 自动开的 fuzz crasher（#221、#222）的处理轮，分支 `fix/221-222-fuzz-crashers`，
  1 个 commit（`7119391`）。**#221 过期**：seed 是 `print(pcall(math.mod))`，正是上一轮
  （PR #220 / `ac21b91`）给 `__wrapArgOrder` 补上 `math.mod` 别名之后修掉的那个，它的 run 跑在
  `947fbda` 上、早于那个修复；但没有因为 sha 旧就下结论，实际重放确认 PASS。**#222 是真的**，
  而且是 concat storm 家族（#123–#167）的下一批：seed 是一个 777777776 次迭代的字符串拼接循环，
  target `FuzzAutoPromote`，run 跑在上一轮的合并提交 `093f7d1` 上。**我先走错了两次方向**——
  seed 本地重放只要 0.77 秒、在自己 corpus 里只是第三重的（1.22s / 1.21s / 0.77s），所以「太重」
  解释不了；又量了内存，单 seed 峰值 RSS 只有 106 MB 而 CI 用 `GOMEMLIMIT=512MiB`、整个 corpus
  并行重放峰值 525 MB，也不构成死因。**真正定下来的方式是去读 guide 里这个家族自己的结论**：
  #166 那轮已经查清死因是 CPU wall-clock 撞 go-fuzz 的 10 秒 per-input 看门狗（不是内存 OOM），
  而那份 guide **点名列出了剩下的候选**：`string.rep` / `string.format` / `table.concat`「尚未按
  工作量记账，是同类风险的候选」。实测证实了这个预测：在 1<<20 step budget 内、且完全没有触发
  预算的情况下，三者各自的紧循环分别跑了 **21 秒 / 20 秒 / 53 秒**，单次 `prog.Run` 就已经超过
  10 秒看门狗，而 `FuzzAutoPromote` 每个输入要跑**四次** Run。修法：`internal/crescent/state.go`
  导出 `ChargeBulkWork`（包装既有的 `chargeBulkWork`），三个 stdlib 函数各自按**产出字节数**记账，
  21/20/53 秒变成 46/90/70 毫秒且预算正确触发，八种普通写法（含 1 MiB 的 `string.rep`、10000 元素
  的 `table.concat`）与 lua5.1 逐字节一致。`table.concat` 有个细节：记账必须按**字节**而不是元素
  个数——它的遍历本来就被表的长度界住，所以「256 个 2KB 的元素」按个数看很便宜、实际要 53 秒。
  四条教训：一个家族的第 N 次复发先去读那个家族自己的结论 / 记账的计量单位要和真实成本同量纲 /
  新增的资源判据要复用既有的计量器而不是自建第二个阈值 / 「本地重放干净」对这个家族天然无效。
metadata:
  type: reflection
  date: 2026-08-03
---

# 一个过期的 crasher，与 guide 早就写好的那份答案（2026-08-03，分支 `fix/221-222-fuzz-crashers`）

> 范围：#221、#222 两个 issue，1 个 commit（`7119391`）。产品侧改动落在
> `internal/crescent/state.go`（导出 `ChargeBulkWork`）、`internal/stdlib/stdlib.go`
> （`string.rep`）、`internal/stdlib/stringlib.go`（`string.format`）、
> `internal/stdlib/tablelib.go`（`table.concat`）；测试落在
> `test/regression/issue222_bulk_builder_test.go`；#222 的 seed 入
> `testdata/fuzz/FuzzAutoPromote/ad96f441153507ff`。

## 任务

nightly 在两个夜晚自动开了两个 crasher issue（#221、#222）。

## 本轮做了什么

### 1. #221 过期，但仍然实际重放了一遍

按 [[unreproducible-crasher-triage]]「第一步永远是版本核对」先核版本。#221 的 seed 是
`print(pcall(math.mod))`，正是上一轮（PR #220，commit `ac21b91`）给 `__wrapArgOrder` 补上
`math.mod` 别名之后修掉的那一个；它的 fuzz run 跑在 `947fbda` 上，`git merge-base --is-ancestor`
确认那个 commit 早于修复。

**但我没有因为 sha 旧就下结论。** 上一轮（#212–#219）的教训正是「三档是关于要不要复现一遍的
分类，它不回答有没有缺陷」，所以把 seed 在当前 HEAD 上实际重放了一遍确认 PASS。这一次结论与
版本核对一致，属于第一档，一行代码没改。

### 2. #222 是 concat storm 家族的下一批，而答案早就写在 guide 里

#222 的 seed 是一个 777777776 次迭代的字符串拼接循环（`local out = ""; for i = 1, 777777776 do
qut = out .. cat(i) end`，`cat` 内返回一段约 100 字节的字面量拼上 `i`），target 是
`FuzzAutoPromote`（p4 tier），run 跑在 `093f7d1`——那就是上一轮的合并提交本身，所以这是真的新
问题。

**我一开始走错了两次方向。**

第一次怀疑「这个 seed 太重」：本地重放只要 **0.77 秒**，而且在自己那个 corpus 里只是**第三重**的
（1.22 秒 / 1.21 秒 / 0.77 秒）。如果 0.77 秒能把 worker 弄死，那两个更重的早就该先死。「太重」
解释不了。

第二次怀疑内存：量了单 seed 的峰值 RSS，只有 **106 MB**，而 CI 用的是 `GOMEMLIMIT=512MiB`；整个
corpus 并行重放的峰值是 **525 MB**，刚刚擦到限制而那是一个软限制（`SetMemoryLimit` 只影响 GC
节奏，不会主动 fatal）。都不构成死因。

**真正定下来的方式是去读 `llmdoc/guides/unreproducible-crasher-triage.md` 里这个家族自己的
结论。** 那份 guide 的「concat storm 家族已根因定性并修复」一节写着：该家族的死因在 #166 那轮
已经查清——**不是内存 OOM，而是 CPU wall-clock 撞 go-fuzz 的 10 秒 per-input 看门狗**
（`internal/fuzz/worker.go` 里的 `time.AfterFunc(10*time.Second, panic)`），表现为
`hung or terminated unexpectedly: exit status 2`；CONCAT 当时通过 `chargeBulkWork`（按
`len>>6`，1 步 / 64 字节）把字节工作量折进 step budget 修好了。而那一节的「范围」段**明确点名**
了剩下的候选：

> 已知的下一批无界单指令算子（string.rep / string.format / table.concat）尚未按工作量记账，
> 是同类风险的候选。

也就是说：我怀疑的两件事都被自己的测量否掉了，而答案在 issue 被开出来之前就已经写在文档里。

### 3. 实测证实了那个预测

在 1<<20 step budget 内、**并且完全没有触发预算**的情况下，三个算子各自的紧循环耗时：

| 算子 | 写法 | 记账前 | 记账后 |
|---|---|---|---|
| `string.rep` | `for i=1,1000000 do out=string.rep("abcdefgh",4096) end` | **21 秒** | 46 毫秒（预算正确触发） |
| `string.format` | 4 个 4 KiB 的 `%s`，循环一百万次 | **20 秒** | 90 毫秒（预算正确触发） |
| `table.concat` | 256 个 2 KiB 元素的表，拼十万次 | **53 秒** | 70 毫秒（预算正确触发） |

单次 `prog.Run` 就已经超过 10 秒看门狗，而 `FuzzAutoPromote` harness 每个输入要跑**四次** Run
（2 个 State × 2 轮）。这与 #166 那轮的机制逐字一致。

### 4. 修法：三个算子走同一个计量器

`internal/crescent/state.go` 导出 `ChargeBulkWork`（一行包装既有的 `chargeBulkWork`），三个
stdlib 函数各自按**产出的字节数**记账：

- `string.rep`（`internal/stdlib/stdlib.go::stringFnRep`）：`len(s) * n`，在既有的 1 GiB
  hardening 上限检查之后；
- `string.format`（`internal/stdlib/stringlib.go::stringFnFormat`）：`len(out)`，在格式解析完成、
  intern 之前；
- `table.concat`（`internal/stdlib/tablelib.go::tableFnConcat`）：分隔符字节 + 各元素字节之和。

**没有各自定一个新阈值**，三个都走 CONCAT 那一个计量器，于是「批量工作」在 step budget 里只有
一个定义。

普通写法不受影响：八种常规写法（`string.rep("ab",3)`、**1 MiB 的 `string.rep`**、
`string.format("%s-%d",...)`、`table.concat({1,2,3},",")`、1000 元素与 **10000 元素**的
`table.concat` 等）与 lua5.1 逐字节一致——比率是 1 步 / 64 字节，1 MiB 的产出约记 16K 步，占
1<<20 预算约 1.5%。

### 5. `table.concat` 必须按字节记账，不能按元素个数

这是本轮唯一一处「顺手写下来的第一版是错的」：`table.concat` 的遍历本来就被表的长度界住，所以
按元素个数看它永远便宜——**256 个元素**听起来微不足道，而每个元素 2 KiB 时实际要 **53 秒**。代价
是拼出来的字节数，不是走过的元素个数，所以记账要读的就是那个字节数。

## 期望与实际

- 期望：又是两个 nightly crasher，一个大概过期，另一个按现象顺着查根因。
- 实际：过期那个如期收工；另一个我用两次测量分别否掉了自己的两个假设，之后才想起去读那个家族
  在 guide 里的既有结论——而那份结论不但已经定性（CPU wall-clock 撞看门狗、不是内存），还把
  下一批候选算子按名字列了出来。**顺着现象重新推导的那两步，是可以整段跳过的。**

## 教训

### 教训 1（一个家族的第 N 次复发，先去读那个家族自己的结论，而不是从现象重新推）

我先怀疑「这个 seed 太重」（0.77 秒，corpus 里只是第三重的）、再怀疑内存（峰值 106 MB，CI 限制
512 MiB），两条都被自己的测量否掉；而 guide 里 #166 那轮不但已经定性，还点名列出了下一批候选
算子。

**Why**：一个跨越数周、累计十几例的 crasher 家族，它的既有结论是**别人（包括过去的自己）已经付
过调查代价**的产物，而现象是每一例各自的表面。从现象重新推导等于把那份代价再付一遍，而且推导的
起点通常就是那一例最显眼的那个量（这一次是「重」和「内存」），恰好是家族历史上已经被证伪过的
两条。这与 [[unreproducible-crasher-triage]]「第一步永远是版本核对」是同一个层级的判据：都是**先
用最便宜的检查排除掉最便宜的解释**，而「读一遍这个家族的结论」比跑任何一条测量都便宜。

**How to apply**：判据 = 处理一个明显属于已知家族的新 issue 时，第一步是 grep 那个家族在 guide /
反思里的既有结论，特别是「范围」「后续候选」「已知未覆盖」这类小节——答案可能在 issue 被开出来
之前就写好了。自查办法：动手做第一次测量之前，先说出这个家族上一次的定性结论是什么；说不出来就
说明还没读。

### 教训 2（记账的计量单位要和真实成本同量纲，别用「个数」代替「字节」）

`table.concat` 的遍历被表长界住，按元素个数看永远便宜（256 个），而成本是拼出来的字节数
（256 × 2 KiB，53 秒）。

**Why**：一个批量操作的「规模」往往有好几个可读的量（元素个数、循环次数、输入长度、产出长度），
它们只有一个与真实代价成正比，而最容易拿到的那个常常不是它。挑错量的后果不是记账偏小一点，而是
**整类输入完全绕过记账**：只要那个量被别的东西界住（这里是表的长度），预算就再也拦不住成本沿另
一个维度增长。这与 [[prove-the-path-under-test]] §8「度量单位选错，把从没生效的优化读成生效但
太贵」是同一个错误在**限流侧**的形式：那条讲读性能数字时选错单位，本条讲写预算时选错单位。

**How to apply**：判据 = 给一个批量操作定预算前，先写下一句「这次调用的代价 ≈ 什么的函数」，再
让记账直接读那个量。自查办法：构造一个让「便宜的那个量」保持很小、而「真实成本的那个量」推到很大
的输入（256 个 2 KiB 的元素就是这样构造的），看记账有没有跟着涨；不涨就说明量错了东西。

### 教训 3（一个新增的资源判据要复用既有的那个计量器，而不是自建第二个阈值）

三个函数都走 `chargeBulkWork`，于是「批量工作」在 step budget 里只有一个定义。

**Why**：如果各自定一个阈值，它们迟早会互相漂移——三个数字分别在三次不同的调优里被改动，而**哪个
先触发**会变得难以预测，于是「这个脚本为什么超预算」这句话不再有单一答案。更要紧的是预算本身是一个
**可加的量**，多个阈值表达不了「几种批量操作叠加起来超了」这件事，而这恰恰是 fuzzer 会构造的写法。
这与 [[prove-the-path-under-test]] §4.5b「一个上限必须区分它约束的是正确性还是资源」互补：那条讲
两个**目的不同**的阈值不该共用一个数，本条讲两个**目的相同**的判据不该拆成两个数。两条一起就是
「按目的分组，同组共用一个计量器」。

**How to apply**：判据 = 给一个新算子加资源限制前，先 grep 同一类资源已经有没有计量器；有就接上去
（必要时把它导出），不要在新算子里写一个自己的阈值。自查办法：问「这个新限制与既有那个，同时被
一个脚本触碰时谁先触发」——答不出来就说明这里应该只有一个计量器。

### 教训 4（「本地重放干净」对这个家族天然无效，不构成「已修复」的证据）

#222 的 seed 本地重放 0.77 秒干净，而它确实对应一个真实的产品缺口。

**Why**：这个家族的死因是**单次 Run 的 wall-clock 撞 10 秒看门狗**，而落盘的是**最小化之后的轻
输入**（单次 Run 远低于 10 秒，否则最小化过程自己就会被打死）。所以「重放干净」是这类 artifact 的
**必然属性**，它不含任何关于有没有缺陷的信息——这一点 guide 里早已记下（「minimized 输入本身往往
不是死因」）。把它读成「已修复」，等于用一个恒真的观测去支撑一个具体结论。

**How to apply**：判据 = 遇到 `hung or terminated unexpectedly` 这类死因时，不要用「单 seed 本地
重放通过」来结案；要去量「harness 每个输入跑几次 Run × 单次耗时」是否逼近看门狗，以及那个脚本的
写法里有没有单条操作能做与字节数成正比的无界工作。自查办法：把 seed 的写法抽成一个**不最小化**的
紧循环（本轮就是这样把 21/20/53 秒量出来的），在 harness 一样的 budget 下量单次 Run 的耗时；轻输入
量不出问题，重写法一量就出来。

## Promotion 决策

- **教训 1** 与 **教训 4** 已补进 [[unreproducible-crasher-triage]]「concat storm 家族已根因定性
  并修复」一节：那一节的「范围」段原本写着三个算子「尚未按工作量记账，是同类风险的候选」，本轮把
  它结算成已完成，并把「预测被验证」这件事本身作为判据写下来（先读家族结论、再从现象推）。
- **教训 2** 与 **教训 3** 已补进 [[prove-the-path-under-test]] 新增的 §4.5d（一个资源预算的计量
  单位要与真实成本同量纲，且同类资源只该有一个计量器），紧接 §4.5c 之后——§4.5/§4.5b/§4.5c 讲阈值
  的**数值**与**关系**，本节讲那个数**量的是什么**。§10 触发场景速查同步加了一条。
- VM 行为侧的对账已入 `docs/design/p1-interpreter/implementation-progress.md`（把「已知下一批候选」
  那句改成已结算，并追加本轮的 crasher 台账条目）。

## 触发场景

- **拿到一个明显属于已知 crasher 家族的新 issue 时**：先 grep 那个家族在 guide / 反思里的既有结论，
  尤其看「范围」「后续候选」小节，再决定要不要从现象查（教训 1）。
- **给一个批量操作（拼接 / 重复 / 格式化 / 序列化）定预算时**：先写清「代价 ≈ 什么的函数」，让记账
  读那个量；用「便宜的量小、真实成本的量大」的输入自查（教训 2）。
- **给一个新算子加资源限制时**：先 grep 同类资源已有的计量器，接上去而不是自建阈值；问「两个限制
  同时被触碰时谁先触发」（教训 3）。
- **看到 `hung or terminated unexpectedly` / `panic: deadlocked!` 类死因时**：别用「单 seed 本地
  重放干净」结案；把写法抽成不最小化的紧循环，在 harness 一样的 budget 下量单次 Run 耗时（教训 4）。
- **给 stdlib 加一个新的字符串构造函数时**：它的产出字节数要经 `ChargeBulkWork` 记账，否则它就是
  这个家族的下一个候选。

## 关联

[[unreproducible-crasher-triage]]（第一步永远是版本核对——#221 是第一档的又一个实例；
「concat storm 家族已根因定性并修复」一节是本轮的主场，它的「后续候选」预测在本轮被结算）·
[[prove-the-path-under-test]]（§4.5b 阈值按目的分开是教训 3 的对偶；§8 度量单位选错是教训 2 的
同族；新增 §4.5d 是教训 2/3 的落点）·
[[2026-07-20-concat-storm-root-cause-round]]（家族根因定性轮：10 秒看门狗、`chargeBulkWork` 按
`len>>6` 记账、教训 3「指令预算必须度量工作量而非条数」——本轮是那条教训的第二次消费）·
[[2026-08-02-issue212-219-fuzz-crasher-batch]]（上一轮：`math.mod` 别名漏网就是 #221 的那个修复；
「三档不回答有没有缺陷」是本轮仍然实际重放 #221 的理由）·
`internal/crescent/state.go::ChargeBulkWork` · `internal/stdlib/stdlib.go::stringFnRep` ·
`internal/stdlib/stringlib.go::stringFnFormat` · `internal/stdlib/tablelib.go::tableFnConcat` ·
`test/regression/issue222_bulk_builder_test.go` ·
`docs/design/p1-interpreter/10-stdlib.md` §3.1a（三个批量构造函数的字节记账）·
`docs/design/p1-interpreter/12-testing-difftest.md` §4.9a（hardening 上限与 step budget 记账是两件事）·
`docs/embedding-tiers.md` §5（step budget 的语义现在含三个 stdlib 构造函数）

## 审计发现:我只补了三个,而仓库自己已经知道有八个

审计指出 `string.upper` / `lower` / `reverse` / `sub` / `gsub` 同样是无界的字节搬运,而**最有力的
证据来自仓库自己**:`internal/oracle/prelude.go` 早就给 upper / lower / reverse / sub 记了
`__chargeBulk`,注释里还写着「20000 次 `string.sub` 在 1 MiB 串上花 12.5 秒」。

所以我这一轮的疏漏有两层:一是漏洞本身(实测这五个单次 `prog.Run` 分别 23 / 12 / 12 / 12 / 11 秒、
预算完全没触发),二是**差分两侧不对称**——oracle 那边抬 sentinel 跳过、引擎这边跑满整个循环,
而不对称正好出现在 oracle 早已点名的算子上。

我为什么漏了:我照 triage guide「范围」那节点名的三个候选去补,补完就认为收口了。而 guide 那句话
写的是「已知的下一批」,并没有承诺穷举;真正的清单在另一个文件里、而且是我自己两轮前写的。

追加一条教训:

5. **补一族缺口时,除了读那一族的结论,还要看仓库里有没有**别处**已经维护着更完整的清单。**
   这一轮 triage guide 点了三个,而 oracle prelude 里已经按同一性质挡了四个——两份清单不一致,
   长的那份才是真相。判据:给一类操作补判据前,grep 全仓有没有同类的既有判据(这里是
   `__chargeBulk` / `chargeBulkWork`),把两边的清单**对齐**再动手;差分测试的 harness 尤其值得看,
   因为它常常比产品先一步遇到并挡掉这些写法。

## 第二次审计:我给 gsub 估了个上界,而精确值就在几行之后

两条发现都在我新加的那一个「靠估算」的记账上,另外七个都没问题。我用的公式是
`len(s) + replLen*(len(s)+1)`——即「每个字节位置都匹配」的最坏情况。它**两个方向都错**:

- **多收费**:一个 16 KiB 的模板、只有一个 `%BODY%` 占位、替换串 4 KiB,被记成约 67 MB 直接拒掉,
  而 lua5.1 就是返回那 20 KiB 结果。公式忽略了 `maxN`、忽略了 anchored 的提前退出、也忽略了
  pattern 自身的长度,于是「只匹配一次」的 gsub 付了「匹配一百万次」的钱。
- **少收费**:替换是**函数或表**时长度无从预知,我的回退分支只记主串,于是一个每次返回 200 KB 的
  闭包跑了 15 秒、预算完全没触发。表形式 21.7 / 23.6 秒。

**改法根本不需要估算**:在循环内部、每次匹配处,替换串的真实长度就在手上——不管它是字符串、数字、
函数还是表。改成按匹配计费之后:模板返回 20480(与 lua5.1 一致),函数与表形式约 100 毫秒触发预算,
而最初那个 20 万次迭代的 storm 仍然在 273 毫秒触发。那个专门用来猜长度的 helper 一并删掉了。

追加两条教训:

6. **精确值在几行之后就能拿到时,不要在前面先记一个上界。** 我在循环外估算,而循环内每次匹配都持有
   真实长度。判据:写资源记账时先问「这个量什么时候变成已知」——如果是在做那件昂贵工作的同一个循环里,
   就在那里记,而不是在入口处按最坏情况记。
7. **在「超限即跳过」的 harness 里,多收费不是保守的选择,它和少收费一样有害。** 我下意识觉得
   「估大一点更安全」,但超限等于这个输入退出比较,于是多收费会静默删掉合法输入的覆盖。判据:凡是
   超限后果是「跳过 / 不比较」而不是「报错」的地方,过度计费与漏计费要同等严肃地防——而且过度计费
   更难发现,因为它表现为测试通过。

## 第三次审计:「记在昂贵工作之前」还得再问一句「之前」是相对谁

第二次审计我把 gsub 从「循环外估上界」改成「每次匹配记真实长度」,以为这就对了。第三次审计指出
**同一个缺陷形式还在**:那次记账落在 `st2gsubRepl` **返回之后**,于是「构造一次替换」本身仍然无界——
`s:gsub("(a+)", string.rep("%1",20000))` 在 1 MiB 主串上只匹配一次,却要展开 20000 个反向引用,
实测 **46 秒**才触发。`string.format` 一模一样:`ChargeBulkWork(len(out))` 在整个 verb 循环**之后**,
7000 个 `%s` 各带 1 MiB 参数跑了 11 秒。两者都超过 10 秒看门狗,也就是 #222 本来要修的那个签名。

而且这**是我引入的回归**:被删掉的那个上界估算,虽然会误拒普通模板,却恰好在 0.00 秒就拒了这个脚本。

改法是在**产出的过程中**按已产出字节的增量记账——gsub 在 `%n` 展开循环里、format 在 verb 循环里。
46 秒 → 129 毫秒,11 秒 → 90 毫秒,而十三种普通写法(含捕获、`%%`、nil 替换、空匹配、锚定、`%q`)
与 lua5.1 逐字节一致。

审计还点出**我的测试为什么抓不到**:那些用例都是「很多次小匹配」,per-match 记账在那种形式下触发得
足够频繁;缺的是「单次匹配放大」这一形式。现在两种都钉住了。

追加一条教训:

8. **「在昂贵工作之前记账」这句话要问清「之前」是相对哪一层。** 我三次都以为自己已经做到了:
   第一次记在函数入口(估算,错的量)、第二次记在每次匹配之后(单次匹配仍无界)、第三次才落到产出循环
   内部。判据:找到那个**真正把字节写出来的循环**,记账要在它的每次迭代里;如果记账和那个循环之间还
   隔着一层函数调用或一次完整迭代,那一层就是无界窗口。自查办法:构造一个「只迭代一次、但那一次极大」
   的输入——它专门打这种错位。

## 第四次审计:时机对了,但我一直在记「产出」,而成本是「消耗」

第三次审计把记账挪进了产出循环内部,时机确实修好了。第四次审计指出**单位**还是错的:三处记的都是
「产出字节」,而真实成本是「消耗字节」——一次调用可以复制一兆字节却几乎不产出任何东西,
按产出计费就完全看不见它。

- **`gsub` 的 `capVal`**:每个 `%n` 都重新调 `capsToValues`、把**所有**捕获重新 intern 一遍。
  20000 个 `%1` 引用一个 1 MiB 捕获 = intern 了 20 GiB,11.5 秒、预算没触发,而 lua5.1 是 5 毫秒。
  改法不只是记账——是**记忆化**,让那份工作根本不发生(现在 6 毫秒且正确地**不**触发预算);
  同时每次引用按捕获字节记账。
- **`tostring`**:对字符串做整串拷贝加重新 intern,而 lua5.1 对字符串是恒等操作。1 MiB 串十万次
  = 60 秒、预算没触发。它和已记账的 upper/lower/reverse 是同一类算子,我那轮扫查漏了它,
  因为 `tostring` 读起来像「转换」而不像「构造」。
- **`string.format` 的 `%s` 精度**:截断发生在整参拷贝**之后**,于是 `%.1s` 对 1 MiB 参数看起来免费
  却照旧复制了一兆;`%.0s` 产出为零、代价一样。20 万次 34 秒、预算没触发。

三处都改成按**输入**长度、在复制**之前**记账。60/34/11.5 秒 → 41/13/6 毫秒,
而十四种普通写法(含 `%0`/`%1`/`%2` 捕获、`__tostring`、`%.0s`)与 lua5.1 逐字节一致。

追加两条教训:

9. **资源记账的单位要问「这次调用复制了多少」,而不是「产出了多少」。** 这两个量在大多数算子上相近,
   所以「按产出记」能过大部分用例;而它们分叉的地方恰好是最危险的输入——`%.0s` 产出为零、
   `tostring` 产出等于输入却做了完整拷贝、`%n` 引用产出一份却 intern 了 N 份。判据:构造一个
   「产出极小、输入极大」的用例;如果记账不涨,单位就选错了。
10. **发现「按字节记账仍然不触发」时,先问这份工作是否根本不必做。** `capVal` 那一处我第一反应是
    补记账,但真正的缺陷是重复劳动——同一份捕获被 intern 了 20000 次。判据:记账拦住一个操作之前,
    先看它的绝对代价是否合理;如果同一份数据被反复处理,先消除重复,再决定还需不需要记账。

## 自查:按第 9 条教训扫「输入大、产出小」,又找出两个

四轮审计每轮都找出缺陷,所以第五轮之前我先照第 9 条教训自己扫了一遍——专门构造「输入极大、产出极小」
的写法,量了 `#s`、`s:byte(1)`、`s:sub(1,1)`、`s:find` 平凡查找、`tonumber` 长数字、长字符串相等比较:

- **`s:sub(1,1)`** 在 1 MiB 主串上跑 30 万次 = **43 秒**、预算没触发。根因不是记账而是**多余的拷贝**:
  `s := string(sb)` 把整个主串复制一遍,只为从里面切一个字节出来。而我给 sub 记的是**提取长度**
  (这里 1 字节),所以按字节记账永远看不见它。改成直接在字节视图上切片:43 秒 → 42 毫秒,
  而真正提取整串的写法照旧正确触发预算。
- **`tonumber`** 对 10 万位数字跑 5 万次 = 16.6 秒、预算没触发。解析代价与长度成正比而产出是一个数,
  与 `tostring` / `%.1s` 同一个单位问题。按输入长度记账后 224 毫秒。

`#s`、`s:byte(1)`、字符串相等比较都是常数或已被界住;`s:find` 平凡查找 3.1 秒是匹配器本身的性能差距
(lua5.1 瞬间完成),不是无界窗口,四次也仍在看门狗以内——记为已知性能差异,不在本轮范围。

追加一条教训:

11. **「按字节记账仍不触发」有两种原因:记错了单位,或者存在本不该有的拷贝。** `s:sub(1,1)` 属于后者——
    记账没错(提取长度确实是 1 字节),错的是实现复制了整个主串。判据:一个操作按它**应有**的代价
    记账之后仍然慢,先去看实现有没有做超出语义的工作;这时候加大计费额度是错的,因为它会把合法用法
    一起拦掉。

## 第五次审计:又三个「输入大产出小」,而且我把上一轮判过的一条判错了

审计找出三个仍然无界的:`string.find` 平凡查找(43 秒)、`string.gmatch` 构造器(27 秒)、
`tonumber(s, 16)`(21 秒)。前两个又是**多余拷贝**:`find` 用 `strings.Index(string(s[init:]), string(pat))`
把整个主串转成 string,`gmatch` 必须给迭代器做快照(这一份是必要工作,但没有记账)。
第三个是**只修了两条路径中的一条**——上一轮我把记账加在 `baseToNumberStandard`,而显式 base 走的是
另一个数字循环。

**其中 `find` 那一条我上一轮亲手判错了**:我量到 3.1 秒、看到 `strArg` 返回视图,就断定是「匹配器性能差距、
不是无界窗口」。实际那 3.1 秒里绝大部分是 `string()` 转换的拷贝——审计用「1 KB 主串同样 30 万次只要 110
毫秒」这个对照实验定死了因果,而我当时没做这个对照。改成 `bytes.Index` 之后 43 秒 → 40 毫秒。

第四条是**重复计费**:format 与 gsub 把同一批字节记了 3 次(消耗侧 + 产出侧 + 尾部余量)。
我先去追精确 1 倍,把 gsub 的 per-match 记账**按替换类型做了门控**——结果**捅出一个无界洞**:
字符串替换的 200000 次匹配整体不再被界住,storm 用例直接挂住 10 分钟,是**我自己的回归测试抓到的**。
改回「每次匹配都记」之后恢复。最终 format 从 3 倍降到 2 倍,而我**停在 2 倍并改了文档**:
1 MiB 在 2 倍下占预算 3.1%,合法程序不受影响,而追精确 1 倍已经花了两次尝试、还引入了一个更糟的缺陷。

追加两条教训:

12. **判定「这是性能差距而不是缺陷」之前,必须做规模对照实验。** 我看 `find` 用了字节视图就下结论,
    而真正的证据是「把输入缩小 1000 倍,耗时是否等比缩小」——审计做了这个对照,我没做。判据:
    说「这是固有成本」之前,先量两个规模;耗时随**输入**规模线性变化而语义上不该如此,就是多余工作。
13. **消除重复计费时,宁可留着重复,也不要把某条路径的记账整个去掉。** 我为了去重给 gsub 的
    per-match 记账加了类型门控,结果字符串替换整体不再被界住——2 倍的过度计费是数量级内的偏差,
    而漏掉一条路径是无界。判据:动一个已经生效的资源判据时,先问「这个改动会不会让某类输入完全不经过
    任何记账」;会的话就不要动,重复计费用文档说明即可。

## 第六次审计:整数右移把小额计费全部抹平了

审计只找到一条,但它把前几轮的修法**部分作废**了:`chargeBulkWork` 里 `bytes >> 6` 是整数右移,
而第三轮我让 format 在循环内部「按每次迭代的产出增量」记账——对**字面文本**来说每次迭代只前进
**一个字节**,`1 >> 6 == 0`,于是每一笔都被抹平。1 MiB 的纯字面 format 跑 4000 次 = 25 秒、预算完全没触发;
`%%` 形式更糟,59 秒。

**修在计量器里而不是修在调用方**:`chargeBulkWork` 现在累积不足 64 字节的**余数**。
这样任何调用方按小块调用都不会丢字节——而「在真正写字节的循环里记账」这条纪律本身就要求调用方
按小块调用。25 秒 → 401 毫秒,59 秒 → 392 毫秒,而计费倍率没变(`rep` 仍恰好 16385)。

追加一条教训:

14. **一个「按增量记账」的接口,必须能正确处理最小增量;整数除法会把小额调用全部抹平。**
    我第三轮定下「在产出循环内部按增量记账」这条纪律,却没检查计量器本身能不能承受「每次 1 字节」
    这种调用方式——纪律和实现互相矛盾了三轮。判据:定下一个「小步多次调用」的约定时,立刻用
    **最小步长**测一遍那个被调用方;`x >> k` / `x / k` 这类实现天然对小于 k 的输入返回 0,
    必须显式累积余数。

## 第七次审计:我给 stdlib 记了账,却把它在 VM 侧的孪生路径漏了

这一轮结论是 COMMENT 而非阻塞,但第一条指出的是**我这一轮亲手造成的不对称**:我给 `tonumber` 两条路径
都记了账,而 VM 做同样解析的 `toNumberCoerce`(`s+1`、`s*2`、`-s`、`for i="1","3"`)完全没记——
10 万位数字上 `s+1` 跑 5 万次 = 16.4 秒、预算没触发,而 `tonumber(s)` 同样循环 221 毫秒就触发。
标度对照严格线性(10B→4ms,100KB→16.5s)。

第二条同类:`error()` 加位置前缀时把整条消息重拷一遍并重新 intern,1 MiB 消息抛 2 万次 = 16 秒;
同脚本用 `error(s,0)`(不加前缀、不拷贝)只要 2.9 秒,因果清楚。

两处都记账之后:16.4 秒 → 225 毫秒,16 秒 → 70 毫秒。

第三条是我上一轮写的文档数字错了:我写「format 与 gsub 约 2 倍」,实测 `gsub("(a+)","%1")` 是
**4 倍**(算子本身 3 倍——主串、每次匹配的替换、每个 `%n` 引用的捕获各一笔)。已改成实测值。

追加一条教训:

15. **给一个操作补资源判据时,要找出它在别处的孪生实现,而不只是它自己那个入口。** `tonumber` 与 VM 的
    `toNumberCoerce` 做同一件事(解析数字字面量),我补了前者、漏了后者,于是**这一轮自己造出了不对称**。
    判据:补完一处之后,搜索这段逻辑的**核心动作**(这里是 `parseLuaNumberBytes`)有几个调用点,
    逐个确认;stdlib 与 VM 常常各有一份入口,而只补 stdlib 那一份会让 fuzzer 从 VM 那侧进来。

## 第八次审计:`find` / `match` 只被循环界住,而不是被记账界住

审计只找一条:`string.match` 与 `string.find` 的扫描完全没记账。**第五轮我修掉了 `find` 的多余拷贝,
但没给它加记账**——于是它此后一直只被「外层循环的普通指令计数」界住。

这一条我一开始复现不出来:我的探针(1 MiB 主串、2 万次循环)都在 20 多毫秒就触发了预算。
差别在于**触发者是谁**——那些脚本里预算是被循环本身的指令计数打满的,而不是被 match 记账打满的。
把迭代次数降到 500、主串放大到 4 MiB,`s:find("b",1,true)` 就不触发了,而单次调用的绝对代价随主串线性增长。
这是「我复现不出来」与「不存在」之间的区别,而我一开始把前者当成了后者的证据——所以我去要了对方的
原始脚本,并同时自己去查「是不是 `string.rep` 在替 match 触发」这个假设。

改法是给两者按「扫描的主串 + 模式」记账。7 毫秒触发,而十一种普通写法(含 `find` 的多值返回、
位置捕获 `()`、空模式、`%` 结尾的畸形模式报错)与 lua5.1 一致。

追加一条教训:

16. **「我复现不出来」不等于「不存在」;要先确认自己的探针里是谁在触发。** 我的 match 探针 20 毫秒就
    触发预算,于是我以为审计报错了;实际是循环的指令计数先打满了预算,把 match 自己没记账这件事**掩盖**了。
    判据:验证一个「某操作未受限」的报告时,把**周围的循环次数降到最低**、把**单次调用的规模拉到最大**——
    这样触发只能来自被质疑的那个操作;如果这时不触发,报告就是对的。
17. **修掉一个多余拷贝之后,要再问一次「它现在需要记账吗」。** 第五轮我把 `find` 的 `string()` 拷贝删了、
    耗时从 43 秒降到 40 毫秒,就认为这一条结案了——但删掉拷贝只是让它**不再做多余的事**,
    并没有让它**变成有界的**。判据:性能修复与资源判据是两件独立的事,一个操作既可能需要前者也需要后者;
    修完前者要重新按「单次调用代价随输入线性增长吗」判一次后者。

## 第九次审计:我上一轮新加的记账把合法写法拒了,而且还有第三个解析器

两条都是我自己的。

**第一条**:上一轮给 `find` / `match` 加的记账**按整个主串计费**,于是最常见的推进式扫描
`local a,b = s:find(pat, pos)` 被按 O(n²) 收费——32 KiB 主串在 1<<20 预算下就被拒,而 lua5.1 处理
256 KiB 只要 2 毫秒。

我第一次改成 `len(s) - init` 仍然不对,而且**我的算术也错了**:我算「每次扫描到末尾,总量 n²/2 = 8 GiB」,
结论是「工作量真的是 O(n²)、只是预算小」。但 lua5.1 那 0.00 秒直接否掉了这个模型——搜索停在**第一个匹配**,
在 `abab...` 上每次只看约两个字节,总量是 O(n)。**是参照实现的实测数字纠正了我的模型,而不是反过来。**

正确的量只有搜索**返回之后**才知道:实际检查过的字节数(命中则 `start-init`,未命中则整段剩余)。
改成事后记账之后,扫描循环返回 131072(与 lua5.1 一致),而两种无界写法照旧触发。

**第二条**:`toNumberStr` 是同一批数字的**第三个解析器**——我此前补了 `tonumber` 的两条路径与 VM 的
`toNumberCoerce`,而这个 stdlib 共享助手背着约三十个调用点(`math.floor`/`abs`/`max`、`%d`/`%x`、
`sub` 的索引、`gsub` 的次数、`table.insert` 的位置)。`math.floor` 对 10 万位数字跑 6 万次 = 19.8 秒、
预算没触发,而两个已记账的兄弟在同样写法上 222 毫秒就触发。补在**助手里**而不是每个调用点,一处覆盖全部。

追加两条教训:

18. **成本模型要用参照实现的实测数字校准,不能靠推导。** 我推出「扫描总量 O(n²)」并据此认为「工作量真的
    很大」,而 lua5.1 的 0.00 秒说明模型错了——搜索停在第一个匹配。判据:算完一个复杂度模型之后,
    拿参照实现量一次同样的脚本;两者数量级不符时,**先怀疑模型**,因为参照实现是事实。
19. **同一件工作可能有第三个、第四个实现入口;补完两个不等于补完。** 数字解析在这个仓库里有三处:
    `tonumber` 的两条路径、VM 的 `toNumberCoerce`、以及 stdlib 的 `toNumberStr`。判据:
    补完某处之后,搜索**最内层那个动作**(这里是 `ParseLuaNumber` / `parseLuaNumberBytes`)的全部调用点,
    并优先补在**最内层的共享助手**里——那是唯一能一次覆盖所有调用点的位置。

## 第十次审计:上一轮那两行我又写错了——量的是「跳过」而不是「检查」

两条都在我第九轮新加的那两行里,而且**两个方向各错一次**:

- **少收费**:我记 `start - init`,那只是**跳过**的字节数。一个在 `init` 处就匹配、然后**吃掉整个主串**的
  模式(`s:match("a*")`)被记成 **0**——1 MiB 主串跑 8000 次 = 19 秒、预算没触发。
- **多收费**:未命中时我默认记「整段剩余」,但**锚定**模式(`^`)只在 `init` 处探一次、绝不往后扫,
  于是标准的锚定词法器被按 O(n²) 收费——24 KB 源码就被拒,而 lua5.1 处理它只要毫秒级。

正确的量是「跳过的 + 吃掉的」= `end - init`,而锚定未命中记 1。改完之后:19 秒 → 155 毫秒并正确触发,
锚定词法器返回 8000,推进式扫描返回 131072,三种真实工作负载(锚定词法、gmatch 词频、CSV 切分)
与 lua5.1 一致。

这一轮的教训是前面几条的合并印证:**同一处代码可以同时犯两个方向的错**,而我第九轮只想着「别过度计费」,
就把不足计费的那一半写错了。

追加一条教训:

20. **一个资源判据要同时用「最便宜的合法写法」和「最昂贵的恶意写法」两组用例夹住。** 我第九轮只拿
    「合法的推进式扫描不该被拒」来验证,于是把「匹配吃掉整个主串」这种恶意写法记成了 0;第八轮反过来
    只拿恶意写法验证,于是拒了合法写法。判据:每加一处计费,**成对**写用例——一个证明它不拦合法代码、
    一个证明它拦住恶意代码;只有一半的验证会让另一半反复回归。

## 自查:把「成对验证」做成常驻测试

第八、九、十轮各栽在同一件事的一半上——只验证一个方向。所以我把第 20 条教训做成了常驻测试
`TestEveryChargePairedBothDirections`:十二处计费,每处两个用例(一个合法写法必须**不**被拒、
一个恶意写法必须被界住),失败时直接报出**是哪个方向**坏了——而那正是前三轮各花一整轮才恢复出来的信息。

实测有效:把 `string.rep` 的计费改成 0 之后它报 4 条失败,改回来即通过。全部十二处当前双向都通过
(合法 1-25 毫秒不触发,恶意 7-236 毫秒触发)。

这条比任何单个修复都重要:它把「审计发现 → 我修 → 下一轮发现我修反了」这个循环换成了一次运行 1.5 秒的检查。

## 第十一次审计:零发现,但量出了那个常驻测试的灵敏度上限

十一轮之后第一次零发现。两条附带信息值得记下来。

**一、那个常驻测试的灵敏度是 8 倍到 64 倍之间。** 审计把 `chargeBulkWork` 全局改错再跑:
64 倍的少收费或多收费会被抓到,而**8 倍不会**。我自己复现确认(`/8` 通过、`/64` 失败)。
这不是缺陷而是「触发/不触发」这种二值信号的固有上限——它只报告工作量落在预算的哪一侧,不报告差多少。
所以那个测试守的是「计费被删掉、放错位置、或量纲错了」,而**量级**由 10 §3.1a 里用二分法实测出来的
每算子倍率来固定。这一条已经写进测试自己的注释里,免得下一次有人以为它能抓住小倍数偏差。

**二、两个仍然无界的写法都是既有问题,不属于本轮。** 审计在 base 上逐一复现确认:
pattern 引擎的次阈值回溯(`maxMatchSteps` 是每次 `patternFind` 重置的,所以把主串调到阈值以下就能
反复免费地贵下去;lua5.1 在同一脚本上也只快约 2 倍,是引擎预算设计的既有问题);
以及 `table.insert(t,1,·)` / `table.remove(t,1)` 的元素移位在引擎侧未计量(oracle prelude 那边
用 `__shiftTotal` 计了)。两者都与字节记账不是同一维度——前者的成本是回溯步数,后者是元素移动数。
记为已知缺口,不在本轮范围。
