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:
15
lib/index.js
15
lib/index.js
@ -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 {
|
||||
// 吞掉:写入已经成功,中心可以下次再建。
|
||||
}
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user