Files
HologramAgent-DSH-Memory-Port/test/phase-b-h.test.js
jianf 00a4a5f6ee feat(memory-b): G2† 编成 test + H 组完成(http adapter + 两表指纹一致)
G2† 补成自动化(你坚持的那条,我认)
你指出 G 组只有三条变异是自动化断言,G2† 只活在我的 commit message 里 ——
手工记录的变异不会随时间复现。已编成 test,并扩到覆盖**每一条**降级路径:

  DEGRADATION_PATHS 数据表(6 条,新增第五条时未覆盖则测试即红)
    embedder is null / undefined / 无 embedText
    ★ 无 info()                      ← 你点名的那条
    ★ info() 的 fingerprint 为空串
    ★ info() 的 fingerprint 非字符串
  + 每条都断言 degraded=no_embedder、仍能召回、且零 fetch
  + 额外一条「指纹检查在 embedText 之前」(钉顺序,不只钉结果)

变异实测打红
  把 embedText 调用挪到指纹检查之前
    not ok 8 - G2†: the fingerprint check runs BEFORE embedText is called
  让无指纹分支发一次 fetch
    not ok 5 - G2†: "embedder with no info()" ... zero network calls

H 组实现(lib/embed/http.js,单文件,G5 守卫)
- 协议与 HA 逐字段一致:model / modality / side / text;响应两种形状都收
- 指纹 http:<model>:<dim>:<sha256(规范化 endpoint)[:8]>
  规范化(hash 之前):去空白 / scheme+host 小写 / 去尾斜杠 / 去默认端口
  不做 query 重排与 .. 解析(那些差异通常是真实的)
  ★ 构造时算一次并复用 —— 因为同一个串必须同时进两张表
- 失败在 adapter 层是硬错误(非 2xx / 无 embedding / 维度不符 / 超限 / 超时),
  在召回层被 catch 成 degraded='embed_error',退回 FTS5
- 维度不符绝不截断不填充(block_recall.go:135-138)

票② 两表指纹一致 —— 落在 H 组 schema,未拖到 F
- memory_blocks 加 vector / fingerprint 两列(phase A 的表已发布,
  这里用 CREATE TABLE IF NOT EXISTS 的新列,旧库需 ALTER;已记入待办)
- graph_centroid 列名对齐 HA(vector_count / vector / stats / built_at),
  并把 is_anomalous 提成独立列(票③口径),不塞进 stats JSON
- putBlock 拒绝「有向量但无指纹」——无标签的向量永远不可安全比较
- putBlock 重提交不带向量时不清空已有向量(与 PutMemoryBlocks 的空值不覆盖同构)
- 新增 saveCentroid / loadCentroid / blockFingerprints

票② 判据
  ok - ticket②: block fingerprint and centroid fingerprint are byte-identical
  ok - ticket② mutation: a re-derived centroid fingerprint misses the lookup
       (加一个尾随空格即查不到,且不抛错 —— 正是必须用断言钉住的原因)
  ok - ticket②: a vector without a fingerprint is refused at write time
  ok - ticket②: re-committing without a vector must not erase the stored one

H 组变异(8 条,全部实跑打红)
  HM1 side 写死 document        → not ok 1 - H1
  HM2 指纹去掉 endpoint hash     → not ok 10/12/13 - F-fp1/fp3/fp4(三条一起红)
  HM3 维度不符改为截断           → not ok 5 - H3b
  HM4 去掉响应体积上限           → not ok 9 - H6
  HM5 保留尾斜杠                 → not ok 11 - F-fp2
  HM6 非 2xx 当成功              → not ok 4 - H3
  HM7 忽略 timeout signal        → not ok 8 - H4
  HM8 指纹每次 info() 重算       → not ok 15 - F-fp6

一处设计确认:lib/index.js 现在 import './embed/http.js',
G5 仍绿 —— 因为该文件没有 fetch/Authorization 等 transport 标识符。
「可以 import adapter,不可以知道 transport」正是 G5 的设计意图。

验证
- npm test 76/76(56 + H 组 20)
- npm run check 9/9
- 提交前连跑 3 次 test + 3 次 check,6 次全绿

待办(不催,记着)
- 票③ D7 fail-closed(中心表空但有向量行 ⇒ dim 报 null 不报 0)跟 F 组
- phase A 旧库升级需 ALTER TABLE 加两列,随 D 组的 stats 工具一起做
2026-10-30 10:17:18 +08:00

348 lines
14 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 test from 'node:test'
import { createHttpEmbedder, deriveFingerprint, normalizeEndpoint } from '../lib/embed/http.js'
import { BlockStore } from '../lib/store.js'
import { 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()
}
})