From c6595d2cf00cf656b754b81fb4b03b8932eb05ee Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Fri, 30 Oct 2026 10:35:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory-b):=20H=20=E6=94=B6=E5=B0=BE=20W1?= =?UTF-8?q?=E2=80=93W8=20=E2=80=94=E2=80=94=20=E4=BF=A1=E5=8F=B7=E8=BD=AC?= =?UTF-8?q?=E5=8F=91=E3=80=81=E5=86=99=E4=BE=A7=E6=A0=A1=E9=AA=8C=E3=80=81?= =?UTF-8?q?=E5=BB=BA=E4=B8=AD=E5=BF=83=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点, 真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。 W1/W5 信号转发(最硬的一条) - execute(args, exec) → commit(input, {signal}) → embedText(text, signal) - abort 时按「向量化失败」降级落盘:块写入、vector 留空 - 依据 dsh-tools/lib/types/index.d.ts:120-122 ★ 一处我判错了,据实记录 W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现: dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**, 已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制, 断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空), 并额外断言 error.info.code === 'ABORTED'。 W2 九个调用点补 await scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2 (loader 两处保持并发语义用 Promise.all)。 原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」—— 这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」, 而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。 W3 空指纹先判后嵌 原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock, 语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。 改为 embed 之前判 space.fingerprint === ''。 W4 写侧维度校验 commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理 (无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过, 用户看到的是「记忆里有这条,但永远召不回来」。 W8 建中心入口(第 5 条) 新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid (centroid_store.go:246-320): · 只取该空间的带向量行 · 维度不符跳过不强制 · 按 L2 归一化后累加均值 · 均值范数 >= 0.6 才判 anomalous(centroid.go:87) · 计入 stats:meanLength / avgPairCosine / skippedRows 并新增 MEAN_THRESHOLD 常量。 ★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000) 去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥 (meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言 「语料必须落在两阈值之间」,否则测试自身就失去区分力。 判据与变异(全部在 /tmp 干净副本上跑) W4 维度不符 → 块在、vector=''、fingerprint='' | 删维度检查 → 红 W5 abort → 块在、vector=''、elapsed<2s | 不传 signal → 红(实测 5003ms) W6 无 embedder 时 {id,created} 与 putBlock 同形 | — W7 空指纹 → embedText 调用 0 次 | 改为事后判 → 红 W8 同空间重建、跨空间隔离、坏维度跳过、阈值判定 | 去空间过滤 → 3 条红 | 去维度过滤 → 1 条红 | 阈值 0.6→0.95 → 1 条红 未越界 - 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D - recallBlocks 仍然同步、未接入 vectorScores(V 组的事) - 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用 dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与 「建过但维度取自配置」——声明不是证据 顺序按你的调整记下:H(完工) → V → C → F → D。 C 组准入条件已满足一半:机制(rebuildCentroid)有了, 「谁触发建中心」待 C 组定。 验证 - npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条) - npm run check 10/10 - 提交前连跑 3 次 test + 3 次 check,6 次全绿 --- README.md | 38 +++++- lib/index.js | 69 +++++++--- lib/store.js | 90 +++++++++++++ scripts/checkpoints.js | 8 +- test/loader.test.js | 10 +- test/phase-b-h.test.js | 279 +++++++++++++++++++++++++++++++++++++++++ test/plugin.test.js | 10 +- 7 files changed, 472 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index eaf03c4..1e7f1f7 100644 --- a/README.md +++ b/README.md @@ -365,6 +365,40 @@ full re-embed): trim, lowercase scheme+host, drop trailing `/`, drop an explicit default port. It deliberately does **not** reorder query strings or resolve `..`: those differences are usually real. +### Write path + +`commit()` embeds before writing, which adds four obligations the synchronous +path did not have. Each has a one-line mutation that turns its test red: + +| obligation | behaviour | why | +|---|---|---| +| **W4** dimension | a vector whose length ≠ `space().dimension` is treated as an embed failure — the block lands with `vector: ''` | recall already skips mismatched vectors, so storing one yields a block that exists, looks fine, and can never be retrieved | +| **W5** cancellation | `exec.signal` is forwarded into `embedText`; aborting stores the block with no vector | otherwise a cancelled turn holds the call for the adapter's full 30 s timeout, and `isConcurrencySafe` siblings queue behind it | +| **W6** result shape | with no embedder, `commit()` returns the same `{id, created}` as `putBlock` and the block is immediately FTS-recallable | nine call sites relied on "no embedder means it happens synchronously" — true today, silently false the moment an `await` is added | +| **W7** empty fingerprint | checked **before** embedding, so an unnamed space costs zero round-trips | the previous order embedded first and threw afterwards, paying for a result it could never label | + +> On **W5**, `isError` is not the assertion: after a body returns, the tool +> registry re-checks the signal and replaces a success with its canonical abort +> result (`dsh-tools/lib/index.js:3408`). What this plugin controls is the *side +> effect* — the block is written — so that is what the test asserts. + +### Building the centroid (W8) + +`rebuildCentroid(fingerprint)` is the entry point that had no caller: without it +blocks carried vectors while `graph_centroid` stayed empty forever, `loadCentroid` +always missed, and centering silently never applied. + +Ported from HA's `RebuildCentroid` (`centroid_store.go:246-320`): only rows from +that space, a mismatched dimension is skipped rather than coerced, the mean is +accumulated over L2-normalised vectors, and the mean's norm decides whether to +centre at all (`MEAN_THRESHOLD = 0.6`, `centroid.go:87`). + +**Who triggers a rebuild is still an open C-group decision** (post-commit ratio vs +lazy pre-step check vs an explicit tool). What exists now is the mechanism plus a +test corpus that sits *between* 0.6 and 0.95 — collinear vectors give mean length +1.0 and would pass any threshold, which is exactly how the first version of that +test failed to distinguish the mutation. + Failures are **hard at the adapter** and **soft at the recall layer**: | layer | behaviour | @@ -513,7 +547,7 @@ because they will bite anyone writing a second dsh service plugin. ## Verify ```bash -npm test # 90 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G + H +npm test # 99 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) ``` @@ -569,7 +603,7 @@ dsh-memory · phase A checkpoints 10/10 checkpoints passed plugin: name=dsh-memory inject=[systemPrompt, tools] -workspace: /tmp/dsh-memory-cp-q2w6z3 (removed) +workspace: /tmp/dsh-memory-cp-kRn1UI (removed) ``` --- diff --git a/lib/index.js b/lib/index.js index 3c2e30b..f2541f3 100644 --- a/lib/index.js +++ b/lib/index.js @@ -67,6 +67,8 @@ export const inject = ['systemPrompt', 'tools'] * @property {string} [embed.apiKey] bearer credential * @property {string} [embed.fingerprint] explicit space identity (overrides derivation) * @property {number} [embed.timeoutMs] request timeout + * @property {object} [embedder] a pre-built Embedder, bypassing `embed`. Used by + * tests and by any adapter that is not the bundled HTTP one. */ /** @@ -146,31 +148,50 @@ export class StableMem extends Service { * vector?: number[], fingerprint?: string }} input * @returns {Promise<{ id: string, created: boolean, embedded: boolean }>} */ - async commit(input) { + async commit(input, options = {}) { // An explicit vector+fingerprint pair (tests, migrations, or a caller that // already embedded) is written verbatim. if (Array.isArray(input.vector) && input.vector.length > 0) { const written = this.store.putBlock(input) return { ...written, embedded: true } } + const space = this.space() - if (space === null) { + // No space configured, or the space cannot name itself: store the block with + // no vector. Checking the fingerprint BEFORE calling embedText avoids paying + // for a round-trip whose result could never be labelled with a space. + if (space === null || typeof space.fingerprint !== 'string' || space.fingerprint === '') { return { ...this.store.putBlock(input), embedded: false } } + + let vector try { - const vector = await this.embedder.embedText(input.text) - const written = this.store.putBlock({ - ...input, - vector, - // ★ From the space object, never re-derived and never from input. - fingerprint: space.fingerprint, - }) - return { ...written, embedded: true } + // `options.signal` is the tool call's cancellation. Forwarding it is + // required by the tool contract (dsh-tools/lib/types/index.d.ts:120-122) + // and it bounds latency: without it the adapter would hold the call for up + // to its own timeout while the user has already cancelled the turn. + vector = await this.embedder.embedText(input.text, options.signal) } catch { - // Vector side down ⇒ the block still lands (decision §0.4). The failure is - // counted in group D, not swallowed silently forever. + // Cancelled, or the vector side is down: the block still lands with no + // vector (decision §0.4). An abort must not turn into "nothing was written". return { ...this.store.putBlock(input), embedded: false } } + + // Write-side dimension check. Recall already skips mismatched vectors + // (recall.js), so storing one would produce a block that exists, looks fine, + // and can never be recalled — the exact silent failure group F exists to + // eliminate. Treat it as an embed failure instead of storing a bad vector. + if (!Array.isArray(vector) || vector.length !== space.dimension) { + return { ...this.store.putBlock(input), embedded: false } + } + + const written = this.store.putBlock({ + ...input, + vector, + // ★ From the space object, never re-derived and never from input. + fingerprint: space.fingerprint, + }) + return { ...written, embedded: true } } /** @@ -349,12 +370,16 @@ function registerTools(ctx, mem, toolName) { // Commits are independent and idempotent (content-addressed id), so they // regroup safely with sibling calls. isConcurrencySafe: () => true, - async execute(args) { - const { id, created, embedded } = await mem.commit({ - text: args.text, - source: args.source ?? 'tool', - scene: args.scene ?? '', - }) + async execute(args, exec) { + const { id, created, embedded } = await mem.commit( + { + text: args.text, + source: args.source ?? 'tool', + scene: args.scene ?? '', + }, + // Forward the call's cancellation into the embedding request. + { signal: exec.signal }, + ) const suffix = embedded ? '' : ' (stored without a vector)' return `${created ? 'Committed' : 'Already present'} block ${id}${suffix}.` }, @@ -373,7 +398,13 @@ export function apply(ctx, config = {}) { const store = new BlockStore({ path, wal: config.wal ?? true }).open() // The adapter lives in exactly one file (lib/embed/http.js) so swapping the // transport never touches recall, scoring, or storage (guarded by G5). - const embedder = config.embed?.endpoint !== undefined ? createHttpEmbedder(config.embed) : null + // + // `config.embedder` accepts an already-built Embedder. It exists so tests can + // inject a failing/slow/nameless provider without standing up an HTTP server — + // and so a future local-ONNX adapter can be mounted without touching this file. + const embedder = + config.embedder ?? + (config.embed?.endpoint !== undefined ? createHttpEmbedder(config.embed) : null) const mem = new StableMem(ctx, { store, topK: config.topK ?? 3, embedder }) // Injection surface 1 — constant index. Right for input-independent text. diff --git a/lib/store.js b/lib/store.js index a95a2f6..88de3f9 100644 --- a/lib/store.js +++ b/lib/store.js @@ -434,6 +434,96 @@ export class BlockStore { isAnomalous ? 1 : 0, Date.now()) } + /** Mean-vector L2 norm at or above which centering is worth enabling. + * Ported from `meanThreshold` (internal/memory/centroid.go:87). Measured + * rationale: at norm 0.9374 de-meaning lifts separation 7x; near 0 the space is + * already isotropic and centering only amplifies noise. */ + static get MEAN_THRESHOLD() { + return 0.6 + } + + /** + * Rebuild the centroid for one vector space FROM ITS BLOCKS. + * + * This is the missing entry point: without it `graph_centroid` stays empty + * forever while blocks keep carrying vectors, `loadCentroid` always misses, and + * centering silently never applies — the票③ "dim unknown" state would become + * the permanent normal case rather than an edge case. + * + * Port of `RebuildCentroid` (internal/memory/centroid_store.go:246-320): + * · only rows that carry a vector in THIS space take part + * · a row whose dimension differs from the first is skipped, not coerced + * · the mean is accumulated over normalised vectors, then its norm decides + * whether the space is anomalous enough to centre at all + * + * @param {string} fingerprint the space to rebuild + * @returns {{ fingerprint: string, dim: number, vectorCount: number, + * meanLength: number, isAnomalous: boolean, saved: boolean }} + * @throws when the space has no embedded blocks to estimate from + */ + rebuildCentroid(fingerprint) { + if (typeof fingerprint !== 'string' || fingerprint === '') { + throw new Error('dsh-memory: rebuildCentroid requires a fingerprint') + } + const rows = this.#handle() + .prepare( + `SELECT vector FROM memory_blocks + WHERE fingerprint = ? AND vector != '' AND vector != 'null'`, + ) + .all(fingerprint) + if (rows.length === 0) { + throw new Error(`dsh-memory: no embedded blocks for space ${fingerprint}`) + } + + let dim = 0 + const vectors = [] + for (const row of rows) { + let vec + try { + vec = JSON.parse(row.vector) + } catch { + continue // a corrupt row must not poison the estimate + } + if (!Array.isArray(vec) || vec.length === 0) continue + if (dim === 0) dim = vec.length + if (vec.length !== dim) continue // mixed provider: skip, never coerce + vectors.push(vec) + } + if (vectors.length === 0) { + throw new Error(`dsh-memory: no usable vectors for space ${fingerprint}`) + } + + // Mean over L2-normalised vectors, so a long vector cannot dominate. + const sum = new Array(dim).fill(0) + for (const vec of vectors) { + let norm = 0 + for (const value of vec) norm += value * value + norm = Math.sqrt(norm) + if (norm === 0) continue + for (let i = 0; i < dim; i += 1) sum[i] += vec[i] / norm + } + const mean = sum.map((value) => value / vectors.length) + let meanLength = 0 + for (const value of mean) meanLength += value * value + meanLength = Math.sqrt(meanLength) + const isAnomalous = meanLength >= BlockStore.MEAN_THRESHOLD + + this.saveCentroid({ + fingerprint, + dim, + vectorCount: vectors.length, + mean, + isAnomalous, + stats: { + source: 'rebuildCentroid', + meanLength, + avgPairCosine: meanLength * meanLength, + skippedRows: rows.length - vectors.length, + }, + }) + return { fingerprint, dim, vectorCount: vectors.length, meanLength, isAnomalous, saved: true } + } + /** * Read one centroid, or undefined. * @param {string} fingerprint diff --git a/scripts/checkpoints.js b/scripts/checkpoints.js index 7df298e..890b4f9 100644 --- a/scripts/checkpoints.js +++ b/scripts/checkpoints.js @@ -220,7 +220,7 @@ await checkpoint('V3', 'systemPrompt.context: sees agent, cannot see this step\u await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted message batch', async () => { const app = makeApp() const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) const input = createUserMessage({ content: [{ type: 'text', text: '值班室分机号是多少' }], @@ -247,7 +247,7 @@ await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted m await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test can turn red)', async () => { const app = makeApp() const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b-mut.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) // Mutate: no recall, ever. mem.contextForStep = () => '' const input = createUserMessage({ @@ -271,7 +271,7 @@ await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test await checkpoint('V3c', 'realistic 3-part batch: recall uses the question, not the injected context', async () => { const app = makeApp() const mem = applyMemory(app.ctx, { path: join(workspace, 'v3c.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) const batch = [ { role: 'user', source: { kind: 'user' }, @@ -329,7 +329,7 @@ await checkpoint('V5', 'unload disposes the sqlite handle; a second close is a n const path = join(workspace, 'v5.db') const app = makeApp() const mem = applyMemory(app.ctx, { path }) - mem.commit({ text: 'V5 句柄释放块', source: 'v5' }) + await mem.commit({ text: 'V5 句柄释放块', source: 'v5' }) assert.ok(mem.store.isOpen, 'store open while plugin loaded') // Disposing the plugin fiber must run our ctx.effect teardown. diff --git a/test/loader.test.js b/test/loader.test.js index 40febea..4a05358 100644 --- a/test/loader.test.js +++ b/test/loader.test.js @@ -81,8 +81,14 @@ test('an isolated realm can host its own stablemem (the preset route)', async () toolName: 'stablemem_commit_alt', }) - outer.commit({ text: 'outer-only 端口 1111' }) - inner.commit({ text: 'inner-only 端口 2222' }) + // Both awaits are required: commit() embeds (async) before writing, and + // relying on "no embedder means it happens synchronously" is exactly the + // fragile assumption this guards against. Promise.all keeps them concurrent, + // which is what this test is about (two isolated realms writing in parallel). + await Promise.all([ + outer.commit({ text: 'outer-only 端口 1111' }), + inner.commit({ text: 'inner-only 端口 2222' }), + ]) assert.equal(outer.store.countBlocks(), 1, 'outer store isolated') assert.equal(inner.store.countBlocks(), 1, 'inner store isolated') diff --git a/test/phase-b-h.test.js b/test/phase-b-h.test.js index bf0d2e1..5f27905 100644 --- a/test/phase-b-h.test.js +++ b/test/phase-b-h.test.js @@ -769,3 +769,282 @@ test('ticket③: a space with no centroid does not change recall behaviour', asy store.close() } }) + +// ── W4–W7: write-path hardening ─────────────────────────────────────────── +// +// The commit path became async, which means it now has a failure surface it did +// not have before: it can be cancelled, it can be slow, and it can be handed a +// vector that does not match the space. Each of those has a required behaviour, +// and each is pinned by a test whose mutation is a one-line revert. + +/** A minimal app with the services the plugin injects. */ +async function makeMem(config) { + const { apply: applyMemory } = await import('../lib/index.js') + 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 { mkdtempSync, rmSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + + const dir = mkdtempSync(join(tmpdir(), 'w-')) + const ctx = new Context() + new SystemPrompt(ctx, {}) + new ToolRuntime(ctx, {}) + const mem = applyMemory(ctx, { path: join(dir, 'm.db'), ...config }) + return { + ctx, + mem, + dir, + async done() { + await ctx.fiber.dispose() + rmSync(dir, { recursive: true, force: true }) + }, + } +} + +/** Invoke the tool with an explicit abort signal. */ +function runCommit(ctx, args, signal) { + return ctx.tools.execute({ callId: 'w', name: 'stablemem_commit', arguments: args, signal }) +} + +// W4 — a wrong-dimension vector must not be stored. +test('W4: a wrong-length vector is treated as an embed failure, not stored', async () => { + const failing = { + // Declares 3, returns 2 — the shape a misconfigured provider produces. + async embedText() { + return [0.1, 0.2] + }, + info: () => ({ fingerprint: 'fp-w4', dimension: 3 }), + } + const { ctx, mem, done } = await makeMem({ embedder: failing }) + try { + const result = await runCommit(ctx, { text: '维度不对的块 4324' }, new AbortController().signal) + assert.equal(result.isError, false, `commit must not fail: ${JSON.stringify(result.value)}`) + + const rows = mem.store.allBlocks(10) + assert.equal(rows.length, 1, 'the block must still land') + assert.equal(rows[0].vector, '', 'and must carry NO vector rather than a bad one') + assert.equal(rows[0].fingerprint, '', 'nor a fingerprint claiming a space it cannot join') + } finally { + await done() + } +}) + +test('W4†: removing the dimension check lets a bad vector through', async () => { + // Demonstrated at the store level: the store accepts any vector it is given, + // so the check can only live in commit(). This asserts the store's tolerance so + // that a future "the store will catch it" assumption fails here. + const store = new BlockStore({ path: ':memory:' }).open() + try { + store.putBlock({ text: 'x', vector: [1, 2], fingerprint: 'fp' }) + assert.equal(JSON.parse(store.getBlock(store.allBlocks(1)[0].id).vector).length, 2, + 'the store does NOT enforce dimension — commit() must') + } finally { + store.close() + } +}) + +// W5 — cancellation must not turn into "block not written". +test('W5: an aborted embed still writes the block, without a vector', async () => { + const slow = { + async embedText(_text, signal) { + // A provider that honours abort. If the caller does not pass a signal, this + // hangs until the adapter's 30s timeout — which is the bug W5 guards. + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve([1, 2, 3]), 5000) + signal?.addEventListener('abort', () => { + clearTimeout(timer) + reject(new Error('aborted')) + }) + }) + }, + info: () => ({ fingerprint: 'fp-w5', dimension: 3 }), + } + const { ctx, mem, done } = await makeMem({ embedder: slow }) + try { + const controller = new AbortController() + const started = Date.now() + setTimeout(() => controller.abort(), 50) + + const result = await runCommit(ctx, { text: '被取消的块 4324' }, controller.signal) + const elapsed = Date.now() - started + + assert.ok(elapsed < 2000, `must not wait for the provider/settle after abort; took ${elapsed}ms`) + + // ★ The registry owns the final word on an aborted call: after a body returns + // it re-checks the signal and replaces a success with a canonical abort result + // (dsh-tools/lib/index.js:3408 -> toolAbortedResult). So `isError` is NOT the + // assertion here — and asserting `false` would be asserting against the + // harness. What this plugin controls is the SIDE EFFECT: the block is written. + assert.equal(result.isError, true, 'the registry reports the call as aborted') + assert.equal(result.error?.info?.code, 'ABORTED', 'and uses the canonical abort code') + + const rows = mem.store.allBlocks(10) + assert.equal(rows.length, 1, 'the block must still be written') + assert.equal(rows[0].vector, '', 'with no vector') + assert.notEqual(rows[0].text_content, '', 'and with its text intact') + } finally { + await done() + } +}) + +test('W5†: ignoring exec.signal makes the call wait for the provider', async () => { + // The mutation is "execute(args) with no signal plumbing". Emulated by calling + // the embedder without a signal: it runs to completion, which is what the bug + // looked like (up to the adapter's 30 s timeout in production). + const slow = { + async embedText(_text, signal) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => resolve([1, 2, 3]), 300) + signal?.addEventListener('abort', () => { + clearTimeout(timer) + reject(new Error('aborted')) + }) + }) + }, + info: () => ({ fingerprint: 'fp', dimension: 3 }), + } + const started = Date.now() + await slow.embedText('x', undefined) // no signal ⇒ cannot be cancelled + const elapsed = Date.now() - started + assert.ok(elapsed >= 250, `without a signal the call is uncancellable; took ${elapsed}ms`) +}) + +// W6 — the async wrapper must not change the write result. +test('W6: with no embedder, commit returns the same shape as putBlock', async () => { + const { mem, done } = await makeMem({}) + try { + const result = await mem.commit({ text: 'W6 块 7788' }) + assert.equal(typeof result.id, 'string') + assert.match(result.id, /^blk_[0-9a-f]{12}$/) + assert.equal(result.created, true) + assert.equal(result.embedded, false) + // Immediately recallable — the write really happened. + const hits = mem.recall('7788') + assert.ok((await hits).length >= 1, 'the block must be findable right after commit') + // Idempotent re-commit reports created=false, exactly like putBlock. + const again = await mem.commit({ text: 'W6 块 7788' }) + assert.equal(again.created, false) + assert.equal(again.id, result.id) + } finally { + await done() + } +}) + +// W7 — an empty fingerprint must degrade BEFORE spending a network call. +test('W7: an empty fingerprint means zero embed calls, not one wasted round-trip', async () => { + let calls = 0 + const nameless = { + async embedText() { + calls += 1 + return [1, 2, 3] + }, + info: () => ({ fingerprint: '', dimension: 3 }), + } + const { mem, done } = await makeMem({ embedder: nameless }) + try { + const result = await mem.commit({ text: 'W7 块' }) + assert.equal(calls, 0, `an unnamed space must not be asked for a vector; saw ${calls} call(s)`) + assert.equal(result.embedded, false) + const row = mem.store.allBlocks(1)[0] + assert.equal(row.vector, '') + assert.equal(row.fingerprint, '') + } finally { + await done() + } +}) + +// ── W8: something must actually BUILD the centroid ──────────────────────── +// +// `saveSpaceCentroid` had no caller in `lib/`, so in real use blocks carried +// vectors while `graph_centroid` stayed empty forever: `loadCentroid` always +// missed, centering never applied, and the票③ "unknown dimension" state became +// the permanent normal case. The entry point is `rebuildCentroid`, ported from +// HA's `RebuildCentroid` (centroid_store.go:246-320). +test('W8: rebuildCentroid derives the centroid from the blocks of one space', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + const fp = 'http:m:3:deadbeef' + // Two near-identical directions plus one different: anisotropic enough to + // cross the 0.6 threshold. + const vectors = [ + [1, 0.02, 0.01], + [1, 0.01, 0.02], + [0.99, 0.03, 0.02], + ] + for (const [i, v] of vectors.entries()) { + store.putBlock({ text: `块${i}`, vector: v, fingerprint: fp }) + } + // A second space must not contaminate the estimate. + store.putBlock({ text: '别空间', vector: [0, 1, 0], fingerprint: 'other:3:ffff' }) + // And a mismatched-dimension row in the SAME space must be skipped, not coerced. + store.putBlock({ text: '坏维度', vector: [1, 2], fingerprint: fp }) + + const result = store.rebuildCentroid(fp) + assert.equal(result.dim, 3, 'dimension comes from the rows, not from configuration') + assert.equal(result.vectorCount, 3, 'only same-space, same-dimension rows count') + assert.ok(result.meanLength > 0) + + const saved = store.loadCentroid(fp) + assert.ok(saved, 'the centroid must be persisted') + assert.equal(saved.dim, 3) + assert.equal(saved.vectorCount, 3) + assert.equal(saved.isAnomalous, result.isAnomalous, 'the stored flag matches the verdict') + // The other space must be untouched. + assert.equal(store.loadCentroid('other:3:ffff'), undefined) + } finally { + store.close() + } +}) + +test('W8: a space with no embedded blocks refuses rather than inventing a centroid', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + assert.throws( + () => store.rebuildCentroid('http:none:3:00000000'), + /no embedded blocks/, + 'an empty space has nothing to estimate from', + ) + assert.equal(store.loadCentroid('http:none:3:00000000'), undefined) + } finally { + store.close() + } +}) + +test('W8: the anisotropy verdict uses the 0.6 threshold', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + const fp = 'http:m:2:aniso' + // A cone of ±35° gives mean length ~0.879 — anomalous at 0.6, but NOT at + // 0.95. Perfectly collinear vectors (mean length 1.0) would pass any + // threshold, which is how the first version of this test failed to + // distinguish the mutation. + const rad = (35 * Math.PI) / 180 + const cone = [[1, 0], [Math.cos(rad), Math.sin(rad)], [Math.cos(-rad), Math.sin(-rad)]] + for (const [i, v] of cone.entries()) { + store.putBlock({ text: `a${i}`, vector: v, fingerprint: fp }) + } + const verdict = store.rebuildCentroid(fp) + assert.ok( + verdict.meanLength > 0.6 && verdict.meanLength < 0.95, + `test corpus must sit BETWEEN the thresholds to discriminate; got ${verdict.meanLength.toFixed(4)}`, + ) + assert.equal(verdict.isAnomalous, true, 'a ±35° cone is anomalous at the 0.6 threshold') + store.close() + + const iso = new BlockStore({ path: ':memory:' }).open() + try { + const fp2 = 'http:m:2:iso' + // Orthogonal directions ⇒ mean length ~0 ⇒ isotropic ⇒ do NOT centre. + for (const [i, v] of [[1, 0], [0, 1], [-1, 0], [0, -1]].entries()) { + iso.putBlock({ text: `i${i}`, vector: v, fingerprint: fp2 }) + } + assert.equal(iso.rebuildCentroid(fp2).isAnomalous, false, 'an isotropic space must not be centred') + } finally { + iso.close() + } + } finally { + // outer store already closed above + } +}) diff --git a/test/plugin.test.js b/test/plugin.test.js index 5169b5b..709e393 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -309,7 +309,7 @@ test('V3b: pre-step injection adds ONE attributed message after a real batch', a const { ctx, dir, done } = await app() try { const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) // Uses realBatch, not a hand-built single message: the whole lesson of the // regression above is that a 1- or 2-message batch is a fake batch. @@ -341,7 +341,7 @@ test('V3b mutation: with recall silenced the batch stays un-augmented', async () const { ctx, dir, done } = await app() try { const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周' }) mem.contextForStep = () => '' const input = createUserMessage({ content: [{ type: 'text', text: '值班室分机号 4324' }], @@ -414,7 +414,7 @@ test('V5: disposing the fiber closes the store and releases the file', async () const { ctx, dir, done } = await app() const path = join(dir, 'm.db') const mem = applyMemory(ctx, { path }) - mem.commit({ text: 'V5 句柄释放块' }) + await mem.commit({ text: 'V5 句柄释放块' }) assert.equal(mem.store.isOpen, true) await ctx.fiber.dispose() @@ -484,7 +484,7 @@ test('REGRESSION: auto-recall hits on a realistic batch (the bug was 0 hits)', a const { ctx, dir, done } = await app() try { const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) const decision = await ctx.waterfall( 'agent/pre-step', @@ -513,7 +513,7 @@ test('REGRESSION mutation: falling back to "last role=user" must drop recall to const { ctx, dir, done } = await app() try { const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 }) - mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) + await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' }) // The mutation: reintroduce the old extraction rule. const oldRule = (messages) => {