③ trigger 补直写判据(此前只有间接覆盖)
你的复核准确:摘掉 trigger 的 WHEN 会让 F3/D3 红,所以「间接有守卫」——
但没有一条盯 raw-writer。而 trigger 存在的全部理由就是防 raw-writer
(修复脚本、迁移、运维手敲 sqlite3)。putBlock 自己就能把列写对,
所以真实风险是「trigger 全没了也没人发现」。
MIG5 三条:
- raw UPDATE 把 vector 改成长度不符 ⇒ vector_dim 被拉回真值;清空则归 0
- raw INSERT 带错误 vector_dim ⇒ 被 trigger 覆盖为真值
- MIG5† 分别 DROP 两个 trigger,各自断言「谎言能存活」
真变异:从 DDL 里删掉整段 trigger ⇒ MIG5 前两条红(MIG5† 仍绿,因为它
主动 DROP,不依赖 DDL 是否存在 —— 这是两者分工正确的标志)。
④ skipped_dim 恒 0 是假字段,选 (a) 补维度过滤
你的诊断对,且比我的注释更狠:一个永远不能非零的字段,配合
`typeof === 'number'` 的断言,把 F4「维度失配与指纹失配分开计数」
做成了摆设。这与更早那处 assert.equal(skipped, 2) 是同一类。
实现:
- searchFts / 包含兜底都携带 dimension(来自 vector_dim 列)
- recallBlocks 新增 dimension 选项,在**空间过滤之后**做维度过滤
★ 顺序有意:先空间后维度,保证一块同时 stale 且窄时只计一次(记 space),
否则两个计数会重复计同一行、谁也加不出「被丢弃的块数」
- skippedDim 与 skipped 分别挂在返回数组上(F4 要分开)
- recallBlocksHybrid 把 space.dimension 注入 scoped,六个降级分支自动继承
- lastRecall.skipped_dim 改为读真实值
判据 F4b 四条:
- 同指纹不同宽度 ⇒ skippedDim=1 且 skipped=0(不串桶)
- 同时存在旧空间块与窄向量块 ⇒ skipped=1、skippedDim=1,不双计
- last_recall.skipped_dim 能非零
- F4b† 变异:删维度过滤 ⇒ skippedDim 归 0、窄向量被召回 ⇒ 红
实测:hits=[C 正常],skipped=1,skippedDim=1,两者都能非零。
一处测试构造的调整:窄向量块用 raw 写入而非 commit —— F8(正确地)
拒绝显式传错的宽度,所以「库里存在窄向量行」在现实中只能来自
历史数据或修复脚本,与 trigger 那条判据的假设一致。
文档
- criteria: F4 补「两个都能非零」与恒 0 字段的教训
- dev-notes 增第 6 类「恒为 0 / 恒为真的字段」,含自查问句
「这个字段的取值集合里,有没有一个能让我这条断言失败?」
验证
- npm test 162/162(155 + MIG5 三条 + F4b 四条)
- npm run check 10/10
- 干净副本实数见后续消息
364 lines
14 KiB
Markdown
364 lines
14 KiB
Markdown
# 变异判据的四类失效模式 + 两条纪律
|
||
|
||
> 来源:phase B(H 组 W1–W12)三轮返工中实际踩到、并由评审独立复现的坑。
|
||
> 四条都不是本任务特有的,**任何写变异判据的人都会再踩**。第五、六类是纪律,不是失效模式。
|
||
>
|
||
> 共同点:**它们都让判据绿着,而判据守的东西已经坏了。** 这类问题比被测代码坏了更难发现,
|
||
> 因为失败的信号本身就是「一切正常」。
|
||
|
||
---
|
||
|
||
## 0. 一句话总纲
|
||
|
||
> **变异体必须让 `lib/` 下的代码变坏。**
|
||
> 一旦变异动的是 `test/`、或者断言的是测试里自己拼的局部函数,它就不是变异,是自测。
|
||
|
||
把这一条当硬规则执行,下面四条里前两条会被自动拦住。
|
||
|
||
---
|
||
|
||
## 1. 自测型假变异(犯得最多:四次)
|
||
|
||
### 形态
|
||
|
||
"变异"测试在 `test/` 里自己拼一个替身,再断言替身的行为:
|
||
|
||
```js
|
||
// ✗ 这不是变异
|
||
test('W11d†a: restoring the short-circuit breaks it', () => {
|
||
const withShortCircuit = (hits) => (hits.length === 0 ? '' : renderRecall(hits)) // 自己拼的
|
||
assert.equal(withShortCircuit(empty), '', 'the short-circuit does silence it')
|
||
})
|
||
```
|
||
|
||
这段**一行 `lib/` 代码都没碰**。它永远不会随实现回滚而变红,却按「已覆盖一条变异」计数。
|
||
|
||
### 实测证据
|
||
|
||
| 变异(真改 `lib/`) | 期望 | 实测 |
|
||
|---|---|---|
|
||
| M-a 只回退 `renderRecall` 短路 | `W11d†a` 红 | **`W11d†a` 绿**,只有 `W11d` 红 |
|
||
| M-b 只加回 `contextForStep` 短路 | `W11d†b` 红 | **`W11d†b` 绿**,只有 `W11d` 红 |
|
||
|
||
### 正确形态
|
||
|
||
真改源码 → 重新 import → 断言行为翻转 → 还原。
|
||
|
||
```js
|
||
await withMutatedCopy('recall.js', [
|
||
[' if (lines.length === 0 && notice === \'\') return \'\'',
|
||
' if (lines.length === 0) return \'\' // MUTATED'],
|
||
], async (mod) => {
|
||
const hits = []; hits.degraded = 'embed_error'
|
||
assert.equal(mod.renderRecall(hits, 'x'), '', 'mutation makes it render nothing')
|
||
})
|
||
```
|
||
|
||
三条要求:
|
||
|
||
1. **改的是 `lib/`**,不是 `test/`。
|
||
2. **锚点找不到时显式失败**(`assert.ok(text.includes(from))`),否则源码一改名,变异静默失效。
|
||
3. **改在副本上**,见 §3。
|
||
|
||
### 自查问句
|
||
|
||
> 把 `lib/` 完全回滚到上一个版本,这条测试会红吗?
|
||
> 不会 → 它是自测,删掉,别留在计数里。
|
||
|
||
---
|
||
|
||
## 2. 清单写死(判据的输入与代码脱节)
|
||
|
||
### 形态
|
||
|
||
判据遍历一份**手写**的清单,而清单本该覆盖的东西由代码决定:
|
||
|
||
```js
|
||
const MODES = ['no_embedder', 'embed_error', 'aborted', 'vector_ready_unused',
|
||
'future_mode_a', 'future_mode_b'] // ✗ 手写
|
||
```
|
||
|
||
`future_mode_a/b` 这类名字有误导性:它们测的是「**掉进 `default:` 的**未知模式」,
|
||
永远测不到「**有人新写了一个 `case`**」。而正常演进偏偏就是新写 `case` ——
|
||
那种时候 `default:` 和手写清单**两头都管不到**。
|
||
|
||
### 实测证据
|
||
|
||
在 `lib/recall.js` 里植入一个**会撒谎**的新分支:
|
||
|
||
```js
|
||
case 'brand_new_mode':
|
||
return '[Memory] New mode degraded; matches below may be incomplete.' // 零命中时是谎话
|
||
```
|
||
|
||
| 版本 | `W11f` | `W11f†` | `W11f†b` |
|
||
|---|---|---|---|
|
||
| 写死 `MODES` | **绿** | 绿 | 绿 |
|
||
| 源码扫描 | **红** | 绿 | 绿 |
|
||
|
||
写死版全绿,因为 `brand_new_mode` 不在数组里。
|
||
|
||
### 正确形态
|
||
|
||
**清单从被测代码推导**,而不是手抄:
|
||
|
||
```js
|
||
const src = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
|
||
const body = src.slice(src.indexOf('export function degradationNotice('))
|
||
const declared = [...body.slice(0, body.indexOf('\n}\n')).matchAll(/case '([^']+)':/g)]
|
||
.map((m) => m[1])
|
||
```
|
||
|
||
### ★ 但「自动推导」不能替代「提取本身也要被守卫」
|
||
|
||
推导源必须是判据真正要管的那件事。`case 'literal'` 恰好就是「有哪些模式」的唯一事实源;
|
||
一旦 `degradationNotice` 改成表驱动(模式放进映射表),扫描就会**静默扫到空数组而照样绿**。
|
||
|
||
所以必须配两道:
|
||
|
||
| 守卫 | 作用 | 实测 |
|
||
|---|---|---|
|
||
| `declared.length >= 4` + 必需 mode 存在性 | 提取失效时**响亮失败** | 把 `case 'x'` 全改成常量引用(语法合法、仍可导入)→ `expected at least 4 case literals, found 0` |
|
||
| `W11f-a`(guards the guard) | 用变异塞一个新 `case`,断言扫描**看得见**它 | 塞 `mode_added_by_the_future_test` → 能取到 |
|
||
|
||
`W11f-a` 不是冗余:**「扫描器坏掉」是这套判据唯一能静默失效的方式。**
|
||
|
||
### 同一族的另一种:README 里的数字
|
||
|
||
README 曾写着「N tests / M checkpoints」,被手改过五次,两次是在 commit message 里漂的。
|
||
加守卫来盯着这个数字,成本高于数字本身的价值。
|
||
|
||
**结论:删掉,不守着。** 留下的守卫反过来断言「README 不得出现计数」:
|
||
|
||
```js
|
||
[/\b\d+\s+tests\b/i, 'a test count'],
|
||
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
|
||
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],
|
||
```
|
||
|
||
三种写法实测都能抓住。**一个没有人验证的数字,给人一种精确的假象。**
|
||
|
||
> 注意区分两类数字:
|
||
> - **手抄的运行计数**(「119 tests」)——会漂,删。
|
||
> - **制品自身的内容量**(「fixture 里有 45 个 case」)——由文件决定,但同样没有守卫,
|
||
> 所以也不要写进文档,改为指向文件本身。
|
||
|
||
---
|
||
|
||
## 3. 变异共享状态与并发撞车(ESM / 测试并行)
|
||
|
||
### 形态
|
||
|
||
`node --test` **默认并行跑测试文件**(每个文件一个进程)。变异若直接改写共享的 `lib/*.js`:
|
||
|
||
```js
|
||
// ✗ 与其它测试文件并发时不安全
|
||
writeFileSync(src, mutated) // 别的文件此刻可能正在 import 它
|
||
const mod = await import(`${url}?mut=1`)
|
||
writeFileSync(src, original)
|
||
```
|
||
|
||
别的测试文件在变异窗口内 import,就读到中间态。
|
||
|
||
### 实测证据
|
||
|
||
- 症状:**约 1/30** 偶发失败,看起来像「断言 flaky」而不是 harness 竞态。
|
||
- 具体表现:`W11d†` 抓到的是**未变异**的模块,于是断言失败但原因完全错位。
|
||
- 更隐蔽的一层:`A1b`(逐字节比对 `recallBlocks` 源码)、`G2b`/`G5`(扫 `lib/` 全目录)
|
||
这三条**专门读源码的判据**,在就地变异下会读到被改坏的中间态。
|
||
|
||
### 正确形态
|
||
|
||
**在副本上变异**:
|
||
|
||
```js
|
||
const dir = mkdtempSync(join(tmpdir(), 'mut-'))
|
||
cpSync(libDir, join(dir, 'lib'), { recursive: true })
|
||
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
|
||
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), { recursive: true, dereference: false })
|
||
// 改 dir/lib/<file>,import dir/lib/<file>
|
||
// finally: rmSync(dir, ...)
|
||
```
|
||
|
||
要点:
|
||
|
||
1. 复制 **`lib/` 整体**(保留目录形状,相对 import 才能在副本内解析到被变异的兄弟模块)。
|
||
2. 复制 `package.json`(`"type": "module"`)。
|
||
3. `node_modules` **软链/保留符号链接**(`dereference: false`),否则 peer 依赖解析不到。
|
||
4. `finally` 里 `rmSync`,否则 `/tmp` 堆副本。
|
||
5. 锚点匹配不到时**显式失败**,别静默跳过。
|
||
|
||
### 验证方式
|
||
|
||
- 连续 30 次全量跑,0 失败(改前约 1/30 偶发)。
|
||
- 跑完 `ls -d /tmp/mut-?????? | wc -l` 应为 0(无残留)。
|
||
- 并行压力:`a + g-static + h` 三文件同跑 5 轮 0 红。
|
||
|
||
> 附带的 ESM 坑:`package.json` 里 `"type": "module"` 时,测试文件不能用 `require()`,
|
||
> 一律 `await import('node:fs')`。用 `require` 会报
|
||
> `Cannot determine intended module format`(顶层 await 与 require 并存)。
|
||
|
||
---
|
||
|
||
## 4. 判据被替换时,旧判据的覆盖静默消失
|
||
|
||
### 形态
|
||
|
||
一条测试同时承担两件事。你删掉其中一件时,另一件的覆盖跟着没了——
|
||
**而没有任何东西会因为少了这个覆盖而变红。**
|
||
|
||
本阶段真实发生:旧的 README 守卫同时含两条断言:
|
||
|
||
```js
|
||
// ① README 的计数与实现一致(后判定不值得守)
|
||
assert.equal(Number(claim[1]), declared, ...)
|
||
// ② checkpoints.js 至少注册了一条 ← 值得守
|
||
assert.ok(declared > 0, 'no checkpoint calls found — did the helper get renamed?')
|
||
```
|
||
|
||
删①时把整条 test 换掉了,②一起消失。
|
||
|
||
### 实测后果(很安静)
|
||
|
||
把 helper 换成一个什么都不做的空操作(调用点全保留):
|
||
|
||
```
|
||
$ npm run check
|
||
0/0 checkpoints passed
|
||
$ echo $?
|
||
0 ← 退出码 0
|
||
$ npm test
|
||
# tests 119 # pass 119 # fail 0 ← 全套仍全绿
|
||
```
|
||
|
||
**checkpoint 全废了,`npm run check` 报「通过」,119 条测试一条都不红。**
|
||
|
||
### 正确形态:守卫要守结果,不要守成因
|
||
|
||
补回一条静态计数只能抓「helper 改名」。放在 runner 里守**结果**,能覆盖所有成因:
|
||
|
||
```js
|
||
if (results.length === 0) {
|
||
console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
|
||
process.exitCode = 1
|
||
}
|
||
```
|
||
|
||
| 成因 | runner 内守卫 | 静态计数 |
|
||
|---|---|---|
|
||
| helper 改名 | ✔ | ✔ |
|
||
| helper 被 stub 成空操作 | ✔ | ✘(调用点还在,计数不变) |
|
||
| 条件跳过(`if (skip) return`) | ✔ | ✘ |
|
||
| 提前 `process.exit(0)` | ✘(**拦不住**) | ✘ |
|
||
|
||
最后一行是关键:**`process.exit(0)` 绕过任何脚本内的检查**。所以还要一条外部断言,
|
||
证明 runner 里仍然含有那道守卫:
|
||
|
||
```js
|
||
assert.match(script, /if \(results\.length === 0\)/)
|
||
assert.match(script, /results\.length === 0[\s\S]{0,200}process\.exitCode = 1/)
|
||
```
|
||
|
||
### 自查问句
|
||
|
||
> **这条判据除了它名字里说的那件事,还顺手守了什么?**
|
||
>
|
||
> 删/改之前,把它的断言逐条过一遍,对每一条问:
|
||
> 「这条如果不守了,谁会红?」——答案是「没人红」的那些,要么补新守卫,要么明确记下放弃。
|
||
|
||
> 附一条探针经验:验证「零注册」不要用「把 `await checkpoint(` 注释掉」——
|
||
> 那会留下孤立的 `return`,报 `SyntaxError: Illegal return statement`,测的是语法。
|
||
> 用「换成一个仍存在但什么都不做的函数」,调用点保持不变,才是有效探针。
|
||
|
||
---
|
||
|
||
## 5. 判据降级时,覆盖面必须一起交代
|
||
|
||
不是失效模式,是一条**记账纪律**:判据被削弱时,削弱本身要写进判据注释,
|
||
否则后来人会把它当成完整证据。
|
||
|
||
本阶段实例:`A1b` 原本逐字节锁死 `recallBlocks` 源码,F2 合法地要改这个函数,
|
||
于是降级为「不传 fingerprint 时行为不变」。但降级后的 `A1b-behaviour`
|
||
只跑 golden 的**前 5 个** case —— 变异 A(非精确带分数微调)下它可能仍是绿的。
|
||
|
||
```
|
||
所以注释里必须写明:
|
||
结论不由 A1b 单独给出,而由 A2 兜(A2 走全部 45 例、比精确分数)。
|
||
A1b-behaviour 只是 no-fingerprint 路径的廉价烟测,不是权威。
|
||
```
|
||
|
||
自查问句:
|
||
|
||
> **这条判据降级后,原本由它兜住的结论现在由谁兜?**
|
||
> 答案必须是一个具体的、覆盖更广的判据名;答不出来就说明降级降过头了。
|
||
|
||
**反例(同一纪律缺失的样子)**:只把测试改窄、不写「为什么不影响结论」,
|
||
后来人看到绿就以为全绿 —— 与「自测型假变异」的最终效果一样,只是路径不同。
|
||
|
||
---
|
||
|
||
## 6. 恒为 0 / 恒为真 的字段(判据咬不住的占位符)
|
||
|
||
**形态**:字段存在、被赋值、有测试,但那个值永远不变。
|
||
|
||
本阶段两次实例,都是同一类:
|
||
|
||
```js
|
||
// ① 手抄常量
|
||
assert.equal(hits.skipped, 2) // fixture 一改就悄悄不再描述事实
|
||
// ② 占位符冒充度量
|
||
last_recall.skipped_dim = 0 // 注释:"reported as 0 rather than guessed"
|
||
assert.equal(typeof last.skipped_dim, 'number') // 常量永远满足
|
||
```
|
||
|
||
②比①更隐蔽:①至少写了个具体数字,②连数字都没有,只有一个类型断言,
|
||
看起来像在测、实际什么都没测。而它顶着的是一条真实需求
|
||
(F4「维度失配与指纹失配分开计数」)—— 于是那条需求在文档上成立、
|
||
在代码里是摆设。
|
||
|
||
**修法二选一,不许两头留**:
|
||
|
||
| 选项 | 做法 |
|
||
|---|---|
|
||
| 让它能非零 | 补上产生该值的路径(本例:召回路径加维度过滤),并加判据断言非零 |
|
||
| 认账删掉 | 从对外形状里删掉该字段,并同步改文档,写明它不在这一层管 |
|
||
|
||
**自查问句**:
|
||
|
||
> **这个字段的取值集合里,有没有一个能让我这条断言失败?**
|
||
> 没有 → 它不是度量,是占位符。
|
||
|
||
**同族的第三种**:`typeof x === 'number'` / `x !== undefined` / `x.length >= 0`
|
||
这类断言对任何实现都成立,等于没写。它们和「恒 0 字段」是一回事:
|
||
断言存在,但取值空间被断言本身排除了失败的可能。
|
||
|
||
---
|
||
|
||
## 通用自查清单
|
||
|
||
写/改一条变异判据前,逐条过:
|
||
|
||
- [ ] 变异体**只改 `lib/`**(改 `test/` 的是自测)
|
||
- [ ] 把 `lib/` 回滚到上一版,这条测试**会红**
|
||
- [ ] 锚点找不到时**显式失败**,不是静默 pass
|
||
- [ ] 清单/枚举从**代码推导**,不手抄
|
||
- [ ] 推导本身有**长度下限 + 必需项**断言;另有一条 guards-the-guard
|
||
- [ ] 变异在**副本**上做,`finally` 清理,不碰共享树
|
||
- [ ] 跑完确认**无残留**(`.orig` / `.rej` / `/tmp/mut-*`)
|
||
- [ ] 连跑 **≥5 次**(间歇性问题 3 次绿证明不了什么)
|
||
- [ ] 文档里**没有**手抄的运行计数
|
||
- [ ] 替换/删除旧判据时,逐条问过「这条覆盖现在谁守」
|
||
- [ ] 判据被降级/收窄时,注释里写明「结论现在由谁兜」
|
||
- [ ] 没有恒为 0 / 恒为真的字段;每个被断言的度量都至少能取一个让断言失败的值
|
||
|
||
## 一句话记住六条
|
||
|
||
| 坑 | 一句话 |
|
||
|---|---|
|
||
| 自测型假变异 | 变异不碰 `lib/`,它就永远不会红。 |
|
||
| 清单写死 | 判据的输入和代码脱节时,绿的是记忆不是事实。 |
|
||
| 并发撞车 | 变异共享状态不能与代码并发跑 —— 改副本。 |
|
||
| 替换带走覆盖 | 一条判据往往守两件事;删一件时另一件不会有人提醒你。 |
|
||
| 降级不记账 | 判据收窄后,原本由它兜的结论必须有新名字接手,且写进注释。 |
|
||
| 恒值字段 | 一个永远不变的值配一个 `typeof` 断言,等于没测。 |
|