feat(memory-b): H 收尾 W1–W8 —— 信号转发、写侧校验、建中心入口
五条全部处理。第 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 次全绿
This commit is contained in:
38
README.md
38
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
69
lib/index.js
69
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.
|
||||
|
||||
90
lib/store.js
90
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user