五条全部处理。第 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 次全绿
1051 lines
46 KiB
JavaScript
1051 lines
46 KiB
JavaScript
/**
|
||
* 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
|
||
}
|
||
})
|