Files
HologramAgent-DSH-Memory-Port/docs/dev-notes-mutation-criteria.md
jianf 99d0670ebe fix(memory-b): 返工 ③④ —— trigger 直判据 + skipped_dim 不再是假字段
③ 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
- 干净副本实数见后续消息
2026-10-30 17:07:19 +08:00

364 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 变异判据的四类失效模式 + 两条纪律
> 来源: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` 断言,等于没测。 |