fix(memory-b): W11f —— aborted / default 零命中时不再承诺条目

接你备好的四个 patch(ready-lib / ready-test / README / docs),
应用前逐个审过内容,dry-run 干净,应用后 md5 与你给的期望值一致:
  lib/recall.js          c8fdffd25d5340291fadf6cb26e1741f
  test/phase-b-h.test.js c06685abb7672fa806ca37e0d1cb8947
  README.md              3c183b045dd7de4a54338ed9d01b3738

问题(我上一轮修 embed_error 时只修了那一个分支)
  aborted 与 default: 在零命中时仍说「matches below are …」,而下面没有条目。
  aborted + 零命中不是边角 —— 用户点停止、且这条查询本来就查不到东西,
  是取消最常见的形态。
  default: 更糟:它是未来任何未知模式**唯一**会走的路,
  最不该在这里开始编。

修法(补丁采用)
  把「有没有条目」只问一次,四个分支共用:
    const nothing = !Array.isArray(hits) || hits.length === 0
  default 用「nothing was found, so treat this as "unknown", not "absent"」——
  把「没搜到 ≠ 没有」这条口径落在**兜底分支**上,
  而不是每加一个模式才记得补一次。

W11f 判据:表驱动、双向
  遍历 [undefined, no_embedder, embed_error, aborted, vector_ready_unused,
        future_mode_a, future_mode_b]
  零命中:notice 非空时不得含 matches below|below are|entries below
  有条目:notice 非空时必须指向条目,且不得说 found nothing
  第二条以前完全没人管 —— W11/W11c 只查「出现 FAILED / cancelled」,
  不查它是否指向了真实存在的条目。

两条变异各自独立(实测,不是「都红就算数」)
  M-A 只删 aborted 分叉
    not ok 57 - W11f
    not ok 58 - W11f†
    ok     59 - W11f†b        ← default 那半仍绿
  M-B 只删 default 分叉
    not ok 57 - W11f
    ok     58 - W11f†         ← aborted 那半仍绿
    not ok 59 - W11f†b
  若两条变异下红的是同一批,那就是重复计数(W11d†a/†b 的老毛病)。

验证
- npm test 118/118
- npm run check 10/10
- 连跑 8 次全量,0 失败(按你的要求超过 5 次)
- 变异 runner 零残留:/tmp/mut-?????? 前后均为 0
  (/tmp/mut-probe 是 10:17 的历史副本,与本轮无关,未动)

未 amend / 未 force push,80d188e 之后是新提交。
This commit is contained in:
2026-10-30 14:00:07 +08:00
parent 80d188efc1
commit d972a2d135
4 changed files with 106 additions and 5 deletions

View File

@ -431,6 +431,7 @@ chain (not by calling the seam directly): **54 ms**.
| **W11d** | degraded **and** zero hits still injects the notice (no entries) | restore either short-circuit ⇒ `W11d` red |
| **W11e** | default config (no embedder) injects **nothing** on ordinary turns | make `no_embedder` speak again ⇒ `W11e` red |
| **W12** | `aborted` and `embed_error` carry different labels and different wording | collapse the two labels ⇒ red |
| **W11f** | no mode promises "matches below" when there is nothing below; every mode points at the entries when there are | drop the `aborted` split ⇒ red; drop the `default:` split ⇒ red |
| **W11b** | a healthy recall renders no notice | — |
| **W11c** | end-to-end: aborted pre-step text still carries the hit *and* the notice | remove the notice line ⇒ red |
@ -640,7 +641,7 @@ because they will bite anyone writing a second dsh service plugin.
## Verify
```bash
npm test # 115 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G + H
npm test # 118 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G + H
npm run check # 10 checkpoints with human-readable evidence (V1–V5 + V3c + MIG3 + mutations)
```

View File

@ -290,6 +290,7 @@ phase B 必须把向量放进 **[0..1) 带**,不得越到 exact 带。
| **W11d** | 降级 + 零命中仍要注入提示,且无条目 | **两处短路**(contextForStep 与 renderRecall)各自都能吃掉它 | 恢复任一短路 ⇒ `W11d` 红(两条独立变异 W11d† / W11d†ctx) |
| **W11e** | 无 embedder(出货默认)+ 零命中 ⇒ 注入必须为 `''` | **P0**:每轮白灌 ~213 字符恒定无条目的记忆头,训练模型跳过该段,废掉 W11 用意 | `no_embedder` 改回返回文案 ⇒ 红 |
| **W12** | `aborted` 与 `embed_error` 标签与文案必须分开 | 取消被描述成「搜索失败」,会诱导无谓重试 | 合并标签 ⇒ 红 |
| **W11f** | 零命中时任何模式都不许说「matches below」;有条目时必须指向条目(表驱动,覆盖未来未知模式) | **`aborted` 与 `default:` 兜底在零命中时仍承诺条目**;`default` 是未来任何新模式唯一会走的路,最不该编 | 删 `aborted` 分叉 ⇒ 红;删 `default:` 分叉 ⇒ 红 |
★ 这组里 W9/W10/W11d 的教训是同一个:**判据用替身/短路/自造形状时,
验的是替身而不是真代码**。W9 之前用假 embedder(它自己监听 abort),

View File

@ -528,6 +528,10 @@ export function renderRecall(hits, sourceLabel = 'stablemem') {
export function degradationNotice(hits) {
const mode = hits?.degraded
if (mode === undefined || mode === null) return ''
// One question, asked once: are there entries below? Every branch shares it.
// Wording contract (pinned by W11f, table-driven): nothing === true => no branch
// may say "matches below"; nothing === false => the branch must point at them.
const nothing = !Array.isArray(hits) || hits.length === 0
switch (mode) {
case 'no_embedder':
// Permanent configuration state, not a per-turn failure. The constant index
@ -542,21 +546,30 @@ export function degradationNotice(hits) {
// Two wordings, because "matches below are …" is a lie when nothing was
// found. The distinction matters: with zero entries the model must know the
// search was incomplete AND empty, not merely incomplete.
const nothing = !Array.isArray(hits) || hits.length === 0
return nothing
? '[Memory] Semantic search FAILED for this turn and the lexical fallback found nothing. Absence of a memory here does not prove it is unrecorded.'
: '[Memory] Semantic search FAILED for this turn; matches below are lexical/symbolic only. Absence of a memory here does not prove it is unrecorded.'
}
case 'aborted':
// Deliberate stop, not a fault: say so plainly and do NOT imply the memory
// is incomplete, which would invite a pointless retry.
return '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'
// is incomplete, which would invite a pointless retry. Same two-wording rule
// as embed_error — a cancelled turn that ALSO found nothing is the commonest
// shape of an abort (the user stops, and their query matches nothing), so
// "matches below are …" is a lie in exactly the case that happens most.
return nothing
? '[Memory] Semantic search was cancelled for this turn and the lexical fallback found nothing.'
: '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'
case 'vector_ready_unused':
// Group A state: the vector was computed but does not take part in scoring
// yet. Not something the model needs to hear about — but keeping the case
// explicit means a future mode cannot silently fall through to ''.
return ''
default:
return `[Memory] Recall degraded (${mode}); matches below may be incomplete.`
// A mode nobody anticipated. "Not found" must not read as "not recorded":
// this is the ONLY path a future mode can take, so it is the worst possible
// place to start lying. Carries the same obligation as embed_error.
return nothing
? `[Memory] Recall degraded (${mode}); nothing was found, so treat this as "unknown", not "absent".`
: `[Memory] Recall degraded (${mode}); matches below may be incomplete.`
}
}

View File

@ -1704,3 +1704,89 @@ test('W11d†: restoring the renderRecall short-circuit turns W11d red', async (
},
)
})
// ── W11f: the notice must never promise entries it does not have ──────────
//
// Two wordings exist per degradation mode because "matches below are …" is a lie
// when the lexical fallback found nothing. `embed_error` got that split first;
// `aborted` and the `default:` fallback still said it unconditionally.
//
// `aborted` + zero hits is not an edge case, it is the COMMONEST shape of a
// cancellation: the user hits stop, and their query matched nothing to begin
// with. The `default:` branch is worse — it is the only path a FUTURE mode can
// take, so it is the worst possible place to start lying.
//
// Table-driven on purpose: a per-mode assertion has to be extended every time a
// mode is added, and the one that gets forgotten is always the fallback.
test('W11f: no mode promises entries below when there are none, and all point at them when there are', () => {
const MODES = [undefined, 'no_embedder', 'embed_error', 'aborted',
'vector_ready_unused', 'future_mode_a', 'future_mode_b']
for (const mode of MODES) {
const empty = []
if (mode !== undefined) empty.degraded = mode
const noticeEmpty = degradationNotice(empty)
if (noticeEmpty !== '') {
assert.ok(
!/matches below|entries below|below are/i.test(noticeEmpty),
`${mode} + zero hits must not promise entries below; got: ${noticeEmpty}`,
)
}
const withHit = [{ text: '值班室分机号 4324', exact: true, score: 1.01 }]
if (mode !== undefined) withHit.degraded = mode
const noticeHit = degradationNotice(withHit)
if (noticeHit !== '') {
assert.ok(
/matches below|below are|entry|entries/i.test(noticeHit),
`${mode} + one hit must point at the entries; got: ${noticeHit}`,
)
assert.ok(
!/found nothing/i.test(noticeHit),
`${mode} + one hit must not claim nothing was found; got: ${noticeHit}`,
)
}
}
})
test('W11f†: dropping the aborted split makes W11f red', async () => {
await withMutatedCopy(
'recall.js',
[[
` return nothing
? '[Memory] Semantic search was cancelled for this turn and the lexical fallback found nothing.'
: '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'`,
` return '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'`,
]],
async (mod) => {
const empty = []
empty.degraded = 'aborted'
assert.match(
mod.degradationNotice(empty),
/matches below/i,
'the mutation restores unconditional wording, so W11f must go red on aborted+zero',
)
},
)
})
test('W11f†b: dropping the default-branch split makes W11f red', async () => {
await withMutatedCopy(
'recall.js',
[[
` return nothing
? \`[Memory] Recall degraded (\${mode}); nothing was found, so treat this as "unknown", not "absent".\`
: \`[Memory] Recall degraded (\${mode}); matches below may be incomplete.\``,
` return \`[Memory] Recall degraded (\${mode}); matches below may be incomplete.\``,
]],
async (mod) => {
const empty = []
empty.degraded = 'future_mode_a'
assert.match(
mod.degradationNotice(empty),
/matches below/i,
'without the split the fallback lies again, so W11f must go red',
)
},
)
})