Files
HologramAgent-DSH-Memory-Port/test/phase-b-h.test.js
jianf c6595d2cf0 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 次全绿
2026-10-30 10:35:41 +08:00

1051 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Phase B · group H — HTTP embedding adapter.
*
* The adapter is the only transport-aware file (`G5` enforces that). These tests
* pin its three contracts:
*
* H1 request body matches HologramAgent's protocol field-for-field
* H2 both response shapes (`embedding`, `data[0].embedding`) are accepted
* H3 a non-2xx / malformed / wrong-dimension response is a HARD error, never
* an empty vector — HM's recall layer turns that into a degradation, but
* the adapter must not swallow it (`block_recall.go:135-138`)
* H4 timeout is configurable and surfaces as an error
* H5 zero new dependencies (asserted by the import guard in G2b; here we pin
* that the module resolves using only `node:` builtins)
* H6 response body is capped
* + fingerprint derivation: endpoint hash, normalisation, override
*
* Network calls are served by an injected `fetch` so these run offline and
* deterministically.
*
* @module dsh-memory/test/phase-b-h
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { DatabaseSync } from 'node:sqlite'
import { createHttpEmbedder, deriveFingerprint, normalizeEndpoint } from '../lib/embed/http.js'
import { BlockStore } from '../lib/store.js'
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
/**
* Run `body` with `globalThis.fetch` replaced by a stub.
* @param {(calls: Array<{url: string, init: object}>) => Promise<Response>|Response} handler
* @param {(calls: Array<{url: string, init: object}>) => Promise<void>} body
*/
async function withFetchStub(handler, body) {
const original = globalThis.fetch
const calls = []
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), init })
return handler(calls)
}
try {
await body(calls)
} finally {
globalThis.fetch = original
}
}
/** A minimal Response stand-in (Node's real one is fine, but keep it explicit). */
function jsonResponse(payload, status = 200) {
const text = JSON.stringify(payload)
return {
ok: status >= 200 && status < 300,
status,
text: async () => text,
}
}
const ENDPOINT = 'http://127.0.0.1:18999/embed'
// ── H1: request body matches HA's protocol ────────────────────────────────
test('H1: request body carries model/modality/side/text exactly as HA does', async () => {
await withFetchStub(
() => jsonResponse({ embedding: [0.1, 0.2, 0.3] }),
async (calls) => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' })
await embedder.embedText('值班室分机号 4324')
assert.equal(calls.length, 1)
const body = JSON.parse(calls[0].init.body)
assert.equal(body.model, 'jina-v5')
assert.equal(body.modality, 'text', 'modality is always text in this slice')
assert.equal(body.side, 'query', 'query-side encoding')
assert.equal(body.text, '值班室分机号 4324')
// Fields HA omits for text input must not appear.
assert.equal(body.data, undefined, 'text input carries no base64 data')
assert.equal(body.mime, undefined)
assert.equal(calls[0].init.method, 'POST')
},
)
})
test('H1b: apiKey becomes an Authorization bearer header; absent key sends none', async () => {
await withFetchStub(
() => jsonResponse({ embedding: [1, 2, 3] }),
async (calls) => {
await createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, apiKey: 'sekret' }).embedText('x')
assert.equal(calls[0].init.headers.Authorization, 'Bearer sekret')
await createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }).embedText('x')
assert.equal(calls[1].init.headers.Authorization, undefined, 'no key → no header')
},
)
})
// ── H2: both response shapes ──────────────────────────────────────────────
test('H2: both response shapes are accepted', async () => {
await withFetchStub(
(calls) =>
calls.length === 1
? jsonResponse({ embedding: [1, 2, 3] })
: jsonResponse({ data: [{ embedding: [4, 5, 6] }] }),
async () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
assert.deepEqual(await embedder.embedText('a'), [1, 2, 3])
assert.deepEqual(await embedder.embedText('b'), [4, 5, 6])
},
)
})
// ── H3: failure is hard, never an empty vector ────────────────────────────
test('H3: non-2xx throws (does not return an empty vector)', async () => {
await withFetchStub(
() => jsonResponse({ error: 'nope' }, 503),
async () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
await assert.rejects(() => embedder.embedText('x'), /503/)
},
)
})
test('H3b: a wrong-dimension response throws rather than truncating or padding', async () => {
await withFetchStub(
() => jsonResponse({ embedding: [1, 2] }),
async () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
await assert.rejects(() => embedder.embedText('x'), /dimension 2, want 3/)
},
)
})
test('H3c: a payload with no embedding array throws', async () => {
await withFetchStub(
() => jsonResponse({ data: [] }),
async () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
await assert.rejects(() => embedder.embedText('x'), /no embedding array/)
},
)
})
test('H3d: the adapter failure surfaces as embed_error in hybrid recall', async () => {
await withFetchStub(
() => jsonResponse({}, 500),
async () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: '值班室分机号 4324' })
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
assert.equal(hits.degraded, 'embed_error', 'a dead endpoint degrades, never throws')
assert.ok(hits.length >= 1, 'and the symbol path still answers')
} finally {
store.close()
}
},
)
})
// ── H4: timeout ──────────────────────────────────────────────────────────
test('H4: a request that never settles is aborted by the configured timeout', async () => {
const original = globalThis.fetch
globalThis.fetch = (url, init) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener('abort', () => reject(new Error('aborted')))
})
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, timeoutMs: 25 })
await assert.rejects(() => embedder.embedText('x'), /aborted/)
} finally {
globalThis.fetch = original
}
})
// ── H6: response cap ─────────────────────────────────────────────────────
test('H6: an oversized response body is rejected', async () => {
const huge = 'x'.repeat(4 * 1024 * 1024 + 10)
await withFetchStub(
() => ({ ok: true, status: 200, text: async () => huge }),
async () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
await assert.rejects(() => embedder.embedText('x'), /response exceeds/)
},
)
})
// ── fingerprint ──────────────────────────────────────────────────────────
test('F-fp1: fingerprint is http:<model>:<dim>:<8 hex of the normalised endpoint>', () => {
const fp = deriveFingerprint({ endpoint: ENDPOINT, model: 'jina-v5', dimension: 2048 })
assert.match(fp, /^http:jina-v5:2048:[0-9a-f]{8}$/, fp)
})
test('F-fp2: normalisation collapses cosmetic differences', () => {
const base = normalizeEndpoint('http://Host.EXAMPLE:80/embed')
assert.equal(base, 'http://host.example/embed')
for (const variant of [
'HTTP://host.example/embed',
'http://host.example/embed/',
' http://host.example:80/embed ',
'http://HOST.example:80/embed',
]) {
assert.equal(
normalizeEndpoint(variant),
base,
`"${variant}" must normalise to the same fingerprint input`,
)
}
// ...and the fingerprint agrees.
const a = deriveFingerprint({ endpoint: 'http://host.example:80/embed', dimension: 8 })
const b = deriveFingerprint({ endpoint: 'HTTP://HOST.example/embed/', dimension: 8 })
assert.equal(a, b)
})
test('F-fp3: a different endpoint changes the fingerprint (the bug HA has)', () => {
// HA derives `http:<model>:<dim>` and would call these the same space.
const a = deriveFingerprint({ endpoint: 'http://10.0.0.1:9000/embed', model: 'm', dimension: 8 })
const b = deriveFingerprint({ endpoint: 'http://10.0.0.2:9000/embed', model: 'm', dimension: 8 })
assert.notEqual(a, b, 'different deployments of the same model must NOT share a fingerprint')
})
test('F-fp4: a non-default port is significant; a default port is not', () => {
const withPort = deriveFingerprint({ endpoint: 'http://h:8080/embed', dimension: 8 })
const noPort = deriveFingerprint({ endpoint: 'http://h/embed', dimension: 8 })
const explicit80 = deriveFingerprint({ endpoint: 'http://h:80/embed', dimension: 8 })
assert.notEqual(withPort, noPort)
assert.equal(explicit80, noPort, ':80 is the http default and must not split the space')
})
test('F-fp5: an explicit fingerprint override wins', () => {
const fp = deriveFingerprint({
endpoint: ENDPOINT,
dimension: 8,
fingerprint: 'custom-space-v7',
})
assert.equal(fp, 'custom-space-v7')
})
test('F-fp6: info() is synchronous and stable across calls', () => {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 8, model: 'm' })
const first = embedder.info()
assert.equal(typeof first.fingerprint, 'string')
assert.equal(first.dimension, 8)
assert.deepEqual(embedder.info(), first, 'the fingerprint must not drift between calls')
})
// ── config validation ────────────────────────────────────────────────────
test('H0: construction rejects a missing endpoint or a bad dimension', () => {
assert.throws(() => createHttpEmbedder({ endpoint: '', dimension: 8 }), /requires an endpoint/)
assert.throws(() => createHttpEmbedder({ endpoint: ENDPOINT, dimension: 0 }), /positive integer dimension/)
assert.throws(() => createHttpEmbedder({ endpoint: ENDPOINT, dimension: 1.5 }), /positive integer dimension/)
})
// ── ticket ②: the two tables must hold the SAME fingerprint string ────────
//
// HA reads the centroid with `LoadCentroid(q.Fingerprint, ...)`
// (block_recall.go:92). If `memory_blocks.fingerprint` and
// `graph_centroid.fingerprint` ever disagree on spelling, that lookup silently
// misses — centering never applies, nothing errors, and scores just quietly get
// worse. This is the hardest class of bug to notice, so it is pinned directly.
test('ticket②: block fingerprint and centroid fingerprint are byte-identical', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' })
const fp = embedder.info().fingerprint
for (let i = 0; i < 5; i += 1) {
store.putBlock({ text: `块 ${i}`, vector: [i, i + 1, i + 2], fingerprint: fp })
}
store.saveCentroid({
fingerprint: fp,
dim: 3,
vectorCount: 5,
mean: [0.1, 0.2, 0.3],
isAnomalous: true,
stats: { note: 'test' },
})
// Everything the blocks recorded ...
const spaces = store.blockFingerprints()
assert.equal(spaces.length, 1)
assert.equal(spaces[0].fingerprint, fp, 'blocks must carry the embedder fingerprint verbatim')
// ... must be exactly what the centroid row is keyed by.
const centroid = store.loadCentroid(spaces[0].fingerprint)
assert.ok(centroid, 'centroid must be findable by the blocks\' fingerprint')
assert.equal(centroid.fingerprint, spaces[0].fingerprint, 'the two tables must agree byte-for-byte')
} finally {
store.close()
}
})
test('ticket② mutation: a re-derived centroid fingerprint misses the lookup', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' })
const fp = embedder.info().fingerprint
store.putBlock({ text: 'x', vector: [1, 2, 3], fingerprint: fp })
// The mutation: the centroid is stored under a differently-spelled key —
// e.g. after a normalization change, or hand-built.
store.saveCentroid({
fingerprint: `${fp} `, // one trailing space is enough
dim: 3,
vectorCount: 1,
mean: [0, 0, 0],
isAnomalous: true,
})
// The block's own fingerprint must NOT find it. Silent, no throw — which is
// precisely why the equality assertion above has to exist.
assert.equal(store.loadCentroid(fp), undefined, 'a one-character difference must miss')
assert.ok(store.loadCentroid(`${fp} `), 'and the mis-spelled key does resolve')
} finally {
store.close()
}
})
test('ticket②: a vector without a fingerprint is refused at write time', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
assert.throws(
() => store.putBlock({ text: 'x', vector: [1, 2, 3] }),
/requires the vector-space fingerprint/,
'an unlabelled vector can never be safely compared',
)
// And a block with no vector is fine (phase A behaviour).
const { created } = store.putBlock({ text: 'y' })
assert.equal(created, true)
assert.deepEqual(store.blockFingerprints(), [], 'a vectorless block contributes no space')
} finally {
store.close()
}
})
test('ticket②: re-committing without a vector must not erase the stored one', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
const fp = embedder.info().fingerprint
store.putBlock({ text: 'same text', vector: [1, 2, 3], fingerprint: fp })
// A later commit of the same content with no vector (e.g. embedder offline).
store.putBlock({ text: 'same text' })
const spaces = store.blockFingerprints()
assert.equal(spaces.length, 1, 'the earlier vector and fingerprint must survive')
assert.equal(spaces[0].embedded, 1)
} finally {
store.close()
}
})
// ── migration: a phase-A database must keep working ───────────────────────
//
// Phase A shipped `CREATE TABLE IF NOT EXISTS` with no `vector`/`fingerprint`
// columns. `open()` re-runs that DDL, which is a SILENT no-op on an existing
// table. So a database created by the phase-A build opens fine, recalls fine via
// FTS, and then throws on the FIRST commit:
//
// PUTBLOCK THREW: table memory_blocks has no column named vector
//
// The memory store becomes read-only with no error at load time. Worse than the
// G5 bug: a false alarm gets noticed, this one does not.
/** Build a database with exactly the phase-A schema (no vector/fingerprint). */
function buildPhaseADatabase(path) {
const db = new DatabaseSync(path)
db.exec(`CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY,
modality TEXT NOT NULL,
text_content TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
scene TEXT NOT NULL DEFAULT '',
semantic_type TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL)`)
db.exec(`CREATE TABLE IF NOT EXISTS memory_block_edges (
id INTEGER PRIMARY KEY AUTOINCREMENT, source_kind TEXT NOT NULL, source_id TEXT NOT NULL,
target_kind TEXT NOT NULL, target_id TEXT NOT NULL, type TEXT NOT NULL,
confidence REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL)`)
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
db.exec(`INSERT INTO memory_blocks (id, modality, text_content, source, created_at, updated_at)
VALUES ('blk_legacy', 'text', '值班室分机号 4324', 'legacy', 1, 1)`)
db.exec(`INSERT INTO memory_blocks_fts (text_content, block_id) VALUES ('值班室分机号 4324', 'blk_legacy')`)
db.close()
}
test('MIG1: a phase-A database gains the vector columns and stays writable', () => {
const dir = mkdtempSync(join(tmpdir(), 'mig-'))
const path = join(dir, 'phase-a.db')
try {
buildPhaseADatabase(path)
const store = new BlockStore({ path }).open()
// 1. Columns exist after open.
const columns = store.columns('memory_blocks')
assert.ok(columns.includes('vector'), `missing vector column; have ${columns.join(',')}`)
assert.ok(columns.includes('fingerprint'), `missing fingerprint column; have ${columns.join(',')}`)
// 2. The legacy block is still readable, and still findable by FTS.
const legacy = store.getBlock('blk_legacy')
assert.ok(legacy, 'the phase-A block must survive')
assert.equal(legacy.text_content, '值班室分机号 4324')
assert.equal(legacy.vector, '', 'a pre-existing block has no vector, not a fabricated one')
const hits = store.searchFts('"4324"', 5)
assert.ok(hits.length >= 1, 'FTS must still find the legacy block')
// 3. Writing works — this is what used to throw.
const { created } = store.putBlock({ text: '迁移之后的新块 8499' })
assert.equal(created, true, 'commit must work after migration')
// 4. The version is stamped.
assert.equal(store.userVersion(), 1, 'schema version must be recorded')
// 5. Re-opening is idempotent (migration must not re-run destructively).
store.close()
const again = new BlockStore({ path }).open()
assert.equal(again.userVersion(), 1)
assert.equal(again.countBlocks(), 2)
again.close()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('MIG2: a fresh database is created at version 1', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
assert.equal(store.userVersion(), 1, 'a new database starts current, not at 0')
} finally {
store.close()
}
})
// NOTE on what used to be here: a test named "MIG1 mutation" that built a raw
// phase-A database and asserted on `DatabaseSync` directly. It touched no `lib/`
// code, so it could never turn red when the implementation changed — a
// demonstration, not a guard. It has been removed rather than kept as a test
// that inflates the count while verifying nothing.
//
// The real guard for the ALTER is MIG1 above: commenting out the
// `ADD COLUMN vector` in migrate() makes it fail with
// `missing vector column; have id,modality,...`. Verified by mutation, not
// asserted by a lookalike.
// ── MIG3: the centroid table must survive migration ──────────────────────
//
// The migration drops phase A's `graph_centroid` and relies on DDL to rebuild
// it — but DDL ran BEFORE the migration, so on a pre-existing database that
// `CREATE TABLE IF NOT EXISTS` was a no-op and nothing rebuilt the table.
// Result: open succeeds, `user_version` reads 1, `putBlock` writes, FTS recalls
// — and only `saveCentroid` throws `no such table`. The narrowest, latest-possible
// failure, which is why MIG1 (which only checks memory_blocks) could not see it.
//
// Fix: rebuild the centroid table inside the migration, right where it is dropped.
/** Build a phase-A-shaped database including the OLD centroid table. */
function buildPhaseAWithCentroid(path, centroidRows = 0) {
const db = new DatabaseSync(path)
db.exec(`CREATE TABLE IF NOT EXISTS memory_blocks (
id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '', scene TEXT NOT NULL DEFAULT '',
semantic_type TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`)
// Phase A's centroid shape: (fingerprint, dim, count, sum_json, updated_at)
db.exec(`CREATE TABLE IF NOT EXISTS graph_centroid (
fingerprint TEXT PRIMARY KEY, dim INTEGER NOT NULL, count INTEGER NOT NULL,
sum_json TEXT NOT NULL, updated_at INTEGER NOT NULL)`)
for (let i = 0; i < centroidRows; i += 1) {
db.exec(`INSERT INTO graph_centroid (fingerprint, dim, count, sum_json, updated_at)
VALUES ('fp${i}', 3, 1, '[0,0,0]', 1)`)
}
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
db.close()
}
test('MIG3: a phase-A database can write and read a centroid after migration', () => {
const dir = mkdtempSync(join(tmpdir(), 'mig3-'))
const path = join(dir, 'a.db')
try {
buildPhaseAWithCentroid(path, 0)
const store = new BlockStore({ path }).open()
// The table must exist with phase-B columns.
const cols = store.columns('graph_centroid')
for (const required of ['fingerprint', 'dim', 'vector_count', 'vector', 'stats', 'is_anomalous', 'built_at']) {
assert.ok(cols.includes(required), `graph_centroid missing "${required}"; have ${cols.join(',')}`)
}
// And it must be usable — this is what used to throw.
store.saveCentroid({
fingerprint: 'fp-p0',
dim: 3,
vectorCount: 2,
mean: [0.1, 0.2, 0.3],
isAnomalous: true,
})
const loaded = store.loadCentroid('fp-p0')
assert.ok(loaded, 'the centroid must round-trip')
assert.equal(loaded.fingerprint, 'fp-p0')
assert.equal(loaded.isAnomalous, true)
assert.deepEqual(loaded.mean, [0.1, 0.2, 0.3])
store.close()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
test('MIG3b: a phase-A centroid WITH rows is renamed aside, not fatal and not lost', () => {
// Phase A never wrote a centroid, so a populated old table is a "should not
// happen" branch. Refusing to open the whole database punishes the user for a
// shape the plugin itself could not have produced by normal use; dropping the
// rows silently would lose data. Rename aside keeps both properties.
const dir = mkdtempSync(join(tmpdir(), 'mig3b-'))
const path = join(dir, 'b.db')
try {
buildPhaseAWithCentroid(path, 2)
const store = new BlockStore({ path }).open()
assert.equal(store.userVersion(), 1, 'the database must still open')
// The new shape is usable immediately.
store.saveCentroid({ fingerprint: 'fp-new', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
assert.ok(store.loadCentroid('fp-new'))
// The old data is preserved under a renamed table.
const legacy = store.tables().filter((name) => name.startsWith('graph_centroid_phase_a'))
assert.equal(legacy.length, 1, `expected one renamed legacy table, saw ${JSON.stringify(store.tables())}`)
assert.equal(store.raw().prepare(`SELECT COUNT(*) AS n FROM "${legacy[0]}"`).get().n, 2, 'rows preserved')
store.close()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
// ── MIG4: a crash between RENAME and the version stamp must be recoverable ─
//
// The legacy name is second-resolution (`graph_centroid_phase_a_<14 digits>`).
// If the rename succeeds but the process dies before `PRAGMA user_version`
// commits, the next open() re-enters the rename branch and
// `ALTER TABLE ... RENAME TO <same name>` would throw `table already exists`,
// making the database unopenable forever. Improbable, but the recovery path is
// cheap and the failure is total.
test('MIG4: a leftover legacy table from an interrupted migration does not block open', () => {
// Mechanism (verified, not assumed): `pragma_table_info` returns zero rows for
// a table that does not exist, so once the old table has been renamed away the
// migration branch is skipped entirely on the retry — no second RENAME. Note
// this holds regardless of DDL ordering; reversing open()'s order keeps the
// suite green, which is how the DDL-ordering explanation was ruled out.
const dir = mkdtempSync(join(tmpdir(), 'mig4-'))
const path = join(dir, 'a.db')
try {
buildPhaseAWithCentroid(path, 2)
// Simulate the crash: the old table was already renamed, but the new one was
// never built and the version was never stamped.
const raw = new DatabaseSync(path)
raw.exec('ALTER TABLE graph_centroid RENAME TO graph_centroid_phase_a_20260101000000')
assert.equal(raw.prepare('PRAGMA user_version').get().user_version, 0)
raw.close()
// Now open normally: it must not throw, must rebuild the live table, and must
// not disturb the already-renamed rows.
const store = new BlockStore({ path }).open()
assert.equal(store.userVersion(), 1)
store.saveCentroid({ fingerprint: 'fp-r', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
assert.ok(store.loadCentroid('fp-r'), 'the rebuilt table must be usable')
const legacyTables = store.tables().filter((name) => name.startsWith('graph_centroid_phase_a'))
assert.equal(legacyTables.length, 1, `expected exactly one legacy table, saw ${JSON.stringify(legacyTables)}`)
assert.equal(
store.raw().prepare(`SELECT COUNT(*) AS n FROM "${legacyTables[0]}"`).get().n,
2,
'the pre-crash rows must be untouched',
)
store.close()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
// ── ticket② (strong form): ONE derivation, two tables ─────────────────────
//
// The test above passes a string to both write paths, so it proves only that
// identical inputs produce identical keys — not that the plugin is incapable of
// deriving two different ones. These tests go through the service surface, where
// the fingerprint comes from `space()` and cannot be supplied by the caller.
test('ticket②-strong: service-driven commit and centroid share one fingerprint', async () => {
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(), 'fp-strong-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
await withFetchStub(
() => jsonResponse({ embedding: [0.5, 0.25, 0.125] }),
async () => {
try {
const mem = applyMemory(ctx, {
path: join(dir, 'm.db'),
embed: { endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' },
})
// Commit through the service (embeds + stamps), three blocks.
for (let i = 0; i < 3; i += 1) {
const r = await mem.commit({ text: `服务路径块 ${i}` })
assert.equal(r.embedded, true, 'a configured space must embed')
}
// The centroid comes from the space object; no fingerprint is passed in.
const { fingerprint: centroidKey } = mem.saveSpaceCentroid({
vectorCount: 3,
mean: [0.5, 0.25, 0.125],
isAnomalous: true,
})
// Set assertion, exactly as agreed: the distinct block fingerprints and
// the centroid key must be the same single value.
const blockSide = mem.store.raw()
.prepare('SELECT DISTINCT fingerprint FROM memory_blocks')
.all()
.map((row) => row.fingerprint)
const centroidSide = mem.store.raw()
.prepare('SELECT fingerprint FROM graph_centroid')
.all()
.map((row) => row.fingerprint)
assert.deepEqual(blockSide, [centroidKey], 'blocks must carry exactly the centroid key')
assert.deepEqual(centroidSide, [centroidKey], 'the centroid table holds exactly that key')
assert.ok(mem.store.loadCentroid(blockSide[0]), 'lookup by the block-side string must hit')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
},
)
})
test('ticket②-strong mutation: re-deriving in the centroid path must break the key', async () => {
// The exact failure the constraint forbids: a second derivation with a
// different normalisation (here: a trailing slash survives) yields a different
// string, and the lookup silently misses.
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(), 'fp-mut-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
await withFetchStub(
() => jsonResponse({ embedding: [1, 2, 3] }),
async () => {
try {
const mem = applyMemory(ctx, {
path: join(dir, 'm.db'),
embed: { endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' },
})
await mem.commit({ text: 'a' })
// Mutation: the centroid path derives its own key from a *different*
// spelling that normalisation does NOT collapse — a different query
// string is a genuinely different endpoint (this slice deliberately does
// not reorder query params, because that difference is usually real).
const rawKey = deriveFingerprint({
endpoint: `${ENDPOINT}?tenant=b`,
dimension: 3,
model: 'jina-v5',
})
const spaceKey = mem.space().fingerprint
assert.notEqual(rawKey, spaceKey, 'a second derivation with different input must differ')
mem.store.saveCentroid({
fingerprint: rawKey,
dim: 3,
vectorCount: 1,
mean: [0, 0, 0],
isAnomalous: true,
})
// Blocks can no longer find it: silent, no throw. That is precisely why
// the single-source rule has to be structural.
assert.equal(mem.store.loadCentroid(spaceKey), undefined, 'the block-side key must miss')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
},
)
})
test('ticket②-strong: normalisation makes the trailing-slash variant a NON-issue', () => {
// The mutation above only works because it bypasses normalisation. Going
// through the real derivation, `…/embed` and `…/embed/` are one space.
const a = deriveFingerprint({ endpoint: 'http://h:9000/embed', dimension: 3 })
const b = deriveFingerprint({ endpoint: 'http://h:9000/embed/', dimension: 3 })
assert.equal(a, b, 'normalisation must collapse the slash so config edits do not split a space')
})
// ── ticket③ boundary: unknown dimension is null, never 0 ─────────────────
//
// When the centroid table has no row for a space that DOES have embedded blocks,
// the dimension is unknown. Encoding that as 0 would be a lie that reads like a
// real answer, and downstream code comparing `dim === 0` would take the wrong
// branch. The state belongs to diagnostics only: recall must be unaffected, and
// `loadCentroid` must not throw for a missing row (HA treats a missing /
// under-sampled centroid as a normal degradation, centroid_store.go:144-184).
test('ticket③: a missing centroid row yields undefined, and reports null — not 0', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
const fp = embedder.info().fingerprint
store.putBlock({ text: '有向量但没建中心', vector: [1, 2, 3], fingerprint: fp })
// loadCentroid must be a normal "absent", not an error path.
assert.equal(store.loadCentroid(fp), undefined, 'a missing centroid is undefined, not a throw')
// Diagnostic projection: the space is known, its dimension is NOT.
const spaces = store.blockFingerprints()
assert.equal(spaces.length, 1)
const centroid = store.loadCentroid(spaces[0].fingerprint)
const dim = centroid === undefined ? null : centroid.dim
assert.equal(dim, null, 'unknown dimension must be null')
assert.notEqual(dim, 0, 'never encode unknown as 0 — 0 reads like a real answer')
assert.equal(spaces[0].embedded, 1, 'the blocks are known to be embedded')
} finally {
store.close()
}
})
test('ticket③: a numeric dimension is reported only when a centroid row exists', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
const fp = embedder.info().fingerprint
store.putBlock({ text: 'x', vector: [1, 2, 3], fingerprint: fp })
store.saveCentroid({ fingerprint: fp, dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
const centroid = store.loadCentroid(fp)
assert.equal(centroid.dim, 3, 'a real dimension comes from the centroid metadata, not from length(vector)')
} finally {
store.close()
}
})
test('ticket③: a space with no centroid does not change recall behaviour', async () => {
// Group H must not leak vector logic into the recall path: a space whose
// centroid is missing behaves exactly like any other degraded case.
const store = new BlockStore({ path: ':memory:' }).open()
try {
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
store.putBlock({ text: '值班室分机号 4324' })
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
assert.ok(hits.length >= 1, 'symbol recall still answers')
assert.equal(store.loadCentroid('whatever'), undefined, 'no centroid row, no throw')
// And the synchronous phase-A entry point is untouched by any of this.
const sync = recallBlocks(store, '4324', { topK: 1 })
assert.equal(typeof sync?.then, 'undefined', 'recallBlocks must stay synchronous')
} finally {
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
}
})