fix(memory): let the write path refresh a drifted centroid (C4 follow-up)

0a6adff 里我自己留了一个洞,而且正是这一整轮反复抓的同一个病:逻辑写了,但没被调到。

`#ensureCentroid` 当时的形状是「没有中心就建一个,有就 return」:

    if (this.store.loadCentroid(fingerprint) !== undefined) return
    this.store.rebuildCentroid(fingerprint)

于是中心永远停在第一次 commit 的快照上。`rebuildCentroid` 只算一次,
之后块数一直涨,均值却再不动 —— 块数翻八倍,它还描述着 1 条块。
召回侧只 `loadCentroid` 纯读,永远不会自己纠正。

根子在于 `centroidStatus`:它内部早写好了完整判据(块数漂移 > 20% ⇒ stale,
10 分钟节流 + 异步重建),而在 C4 之前它在 lib/ 里**没有任何生产调用者**。
我接线时只接了「建第一次」,让它继续当着死代码 —— 这跟 P1/C4 要修的
「代码写了没接线」是同一类错误,只是换了一层。

修法:有中心时不再 return,交给 `centroidStatus` 判。它自己决定要不要重建。
仍然放在 commit 之后而不是召回路径,正是因为它第一行是 `countVectored`,
C3-k 明令该查询不得进每轮 pre-step 的热路径。

判据 C4e:写入侧必须让漂移的中心重新跟上

    commit 1 条  → 中心 vectorCount = 1
    再写 7 条    → 漂移 700% >> 20%
    假时钟拨过 2s → 中心 vectorCount 必须 = 8

M4 变异(退回 0a6adff 的「有中心就 return」)红原文:

    C4e: a centroid that drifted past the 20% ratio must be rebuilt by the
    write path. It is still describing 1 blocks while 8 carry vectors
    —— nothing in lib/ ever consults the stale judgement, so the mean stays
    frozen on whatever the first commit happened to see.

★ C4e 第一版是假绿,一并记下来

第一版在断言前调了 `mem.store.centroidStatus(SPACE)` 来验 stale 前置。
但 `centroidStatus` 自己就会调度重建 —— 于是不管写入侧接没接,重建都会发生,
判据在自证。M4 变异下它 6/6 全绿,才把这个问题揪出来。
改成手工算漂移比(不碰 centroidStatus)之后,M4 才咬得住。

同一类陷阱会在任何「被测函数恰好也能触发被测行为」的地方复现,
所以判据里宁可自己算前置常量,也不去调那个会把副作用带进来的接口。

★ What this does NOT establish

C4e 证的是「漂移会被发现并触发重建」,不证重建的时机对用户体验无感 ——
10 分钟节流窗口内中心仍是旧的,这段时间召回用的还是上一个快照的均值。
窗口长度与漂移比的关系需要实测分布,属效果范畴,不在本片。

三门:npm test 278/278(0 skipped)、npm run check 12/12、npm pack 10 files 79.3 kB。
冻结面:docs/、README.md、scripts/、lib/loop* 零改动。
This commit is contained in:
2026-11-05 10:47:43 +08:00
parent 0a6adff5cb
commit 05e93b2f5c
2 changed files with 89 additions and 2 deletions

View File

@ -446,8 +446,19 @@ export class StableMem extends Service {
#ensureCentroid(fingerprint) {
if (typeof fingerprint !== 'string' || fingerprint === '') return
try {
if (this.store.loadCentroid(fingerprint) !== undefined) return
this.store.rebuildCentroid(fingerprint)
if (this.store.loadCentroid(fingerprint) === undefined) {
this.store.rebuildCentroid(fingerprint)
return
}
// ★ 已有中心时不能直接 return。
// `rebuildCentroid` 只算一次,之后块数会一直长;如果这里只守「没有就建」,
// 中心就永远停在第一桶快照上,越用越偏。交给 `centroidStatus`:它内部
// 已经写好块数漂移 > 20% 判 stale 与 10 分钟节流异步重建,是这套判据
// 唯一的正统入口 —— 而在 C4 之前它在 lib/ 里没有任何生产调用者。
//
// 放在 commit 之后而不是召回路径,正是因为它第一行是 `countVectored`:
// C3-k 明令该查询不得进每轮 pre-step 的热路径。
this.store.centroidStatus(fingerprint)
} catch {
// 吞掉:写入已经成功,中心可以下次再建。
}

View File

@ -281,3 +281,79 @@ test('C4d: the production commit path builds the centroid it later reads', async
rmSync(dir, { recursive: true, force: true })
}
})
// ── C4e: 块数漂移超 20% 后,写入侧必须让中心重新跟上 ──────────────────────
//
// ★ 这条守的是我在 0a6adff 里自己漏掉的那一半。
// 那时的 `#ensureCentroid` 是「没有中心就建一个,有就 return」——
// 于是中心永远停在第一次 commit 的快照上,块数翻多少倍它都不动。
// 这正是这一整轮反复抓的同一个病:**逻辑写了,但没有被调到**。
// `centroidStatus` 里那套 stale 判定(漂移 > 20% + 10 分钟节流 + 异步重建)
// 原本在 lib/ 里零生产调用者,不接上的话它只是文件里的一段死代码。
//
// C4a/b/c 都手工 `rebuildCentroid`,C4d 只验证「首次建」,三条都照不到这里:
// 中心存在且过期,与中心不存在,是两个不同的失效形态。
test('C4e: a drifted centroid is refreshed by the write path, not left stale forever', async (t) => {
// 异步重建靠 setTimeout,用假时钟把 2s 拨过去,免得测试真的睡两秒。
t.mock.timers.enable({ apis: ['setTimeout'] })
const { Context } = await import('@deepseek-ai/cordis')
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
const { apply: applyMemory } = await import('../lib/index.js')
const { mkdtempSync, rmSync } = await import('node:fs')
const { tmpdir } = await import('node:os')
const { join } = await import('node:path')
const dir = mkdtempSync(join(tmpdir(), 'c4e-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), embedder: makeEmbedder() })
// 第一条:写入侧顺手建出中心,此时它只见过 1 条块
await mem.commit({ text: CORPUS[0].text })
const born = mem.store.loadCentroid(SPACE)
assert.ok(born !== undefined, 'precondition: the first commit must build a centroid')
const bornCount = born.vectorCount
assert.ok(bornCount >= 1, `precondition: the centroid must have counted blocks, got ${bornCount}`)
// 继续写,直到块数相对建中心时漂移超过 20%
for (const block of CORPUS.slice(1)) await mem.commit({ text: block.text })
// ★ 这里刻意**不**调 centroidStatus。
// 它自己就会调度重建 —— 无论写入侧接没接,只要测试里调了它,
// 重建就会发生,这条判据就变成了自己证明自己。第一次写这条时
// 就是这么写的,M4 变异(退回 0a6adff 的「有中心就 return」)
// 居然 6/6 全绿,才把这个问题缴出来。
const drift = (CORPUS.length - bornCount) / bornCount
assert.ok(
drift > 0.2,
`precondition: this fixture must drift past the 20% ratio; got ${(drift * 100).toFixed(0)}%`,
)
// 假时钟把重建的 2s 拨过去。
// 只有写入侧真的调过 centroidStatus,这里才会有回调可跑。
t.mock.timers.tick(2000)
const healed = mem.store.loadCentroid(SPACE)
assert.ok(healed !== undefined, 'C4e: the centroid must still exist after the scheduled rebuild')
assert.ok(
healed.vectorCount > bornCount,
'C4e: a centroid that drifted past the 20% ratio must be rebuilt by the write path. ' +
`It is still describing ${healed.vectorCount} blocks while ${CORPUS.length} carry vectors — ` +
'that means nothing in lib/ ever consults the stale judgement, so the mean stays frozen ' +
'on whatever the first commit happened to see, and centering slowly stops describing ' +
'the space it is supposed to correct.',
)
assert.equal(
healed.vectorCount,
CORPUS.length,
'C4e: the rebuilt centroid must cover every vectored block in this space',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})