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 工具一起做
348 lines
14 KiB
JavaScript
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()
|
|
}
|
|
})
|