Files
HologramAgent-DSH-Memory-Port/test/phase-b-g.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

233 lines
8.7 KiB
JavaScript

/**
* Phase B · group G — guards.
*
* These exist so that adding vectors cannot quietly damage what phase A shipped,
* and so that the vector *transport* cannot leak into the layers that must stay
* provider-agnostic.
*
* G1 phase A's whole surface still passes (unit + checkpoints) — the golden
* fixture and the checkpoint script are the enforcement, this file only
* pins the invariant that they are wired up.
* G2 no embedder ⇒ no outbound HTTP, no extra module loaded.
* G3 `stablemem_commit` still writes with a dead/absent embedder.
* G4 media stays out of scope (one modality, `text`).
* G5 provider-specific identifiers may exist in exactly one file.
*
* @module dsh-memory/test/phase-b-g
*/
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory } from '../lib/index.js'
import { BlockStore, MODALITY_TEXT } from '../lib/store.js'
const LIB = new URL('../lib/', import.meta.url).pathname
/** Every .js file under lib/, recursively. */
function libSources() {
const out = []
const walk = (dir) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) walk(full)
else if (entry.name.endsWith('.js')) out.push(full)
}
}
walk(LIB)
return out
}
/** A real cordis app carrying the services the plugin injects. */
function makeApp() {
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return ctx
}
// ── G2: no embedder ⇒ no outbound HTTP, no extra deps ─────────────────────
/**
* Install a `fetch` tripwire for the duration of `body`, then restore it.
* @param {(count: () => number) => Promise<void>} body
*/
async function withFetchTripwire(body) {
const original = globalThis.fetch
let calls = 0
globalThis.fetch = (...args) => {
calls += 1
return Promise.reject(new Error(`unexpected fetch: ${args[0]}`))
}
try {
await body(() => calls)
} finally {
globalThis.fetch = original
}
}
test('G2: with no embedder, recall performs no network I/O', async () => {
// Any vector transport that tries to work without an embedder goes through here.
await withFetchTripwire(async (calls) => {
const store = new BlockStore({ path: ':memory:' }).open()
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, null, '4324', { topK: 3 })
assert.ok(hits.length >= 1, 'symbol path still recalls')
assert.equal(calls(), 0, `no embedder must mean zero network calls; saw ${calls()}`)
store.close()
})
})
// G2† — every early-degradation path must stay network-free.
//
// Rationale: G5 shipped a guard whose allowlist was silently always-false, and it
// stayed green because nothing ever exercised it. The same shape applies here —
// `recallBlocksHybrid` has FOUR early exits, and a hand-run check of one of them
// proves nothing about the other three. Enumerated as data so a fifth branch
// added later fails this test until it is covered.
const DEGRADATION_PATHS = [
{
name: 'embedder is null',
embedder: () => null,
expect: 'no_embedder',
},
{
name: 'embedder is undefined',
embedder: () => undefined,
expect: 'no_embedder',
},
{
name: 'embedder has no embedText',
embedder: () => ({ info: () => ({ fingerprint: 'fp', dimension: 3 }) }),
expect: 'no_embedder',
},
{
// ★ The branch this test was added for: non-null embedder, but it cannot
// name its own vector space. It must degrade WITHOUT calling embedText —
// and therefore without any transport being touched.
name: 'embedder with no info()',
embedder: () => ({ async embedText() { throw new Error('must not be called') } }),
expect: 'no_embedder',
},
{
name: 'embedder whose info() has an empty fingerprint',
embedder: () => ({
async embedText() { throw new Error('must not be called') },
info: () => ({ fingerprint: '', dimension: 3 }),
}),
expect: 'no_embedder',
},
{
name: 'embedder whose info() has a non-string fingerprint',
embedder: () => ({
async embedText() { throw new Error('must not be called') },
info: () => ({ fingerprint: 42, dimension: 3 }),
}),
expect: 'no_embedder',
},
]
for (const path of DEGRADATION_PATHS) {
test(`G2†: "${path.name}" degrades to ${path.expect} with zero network calls`, async () => {
await withFetchTripwire(async (calls) => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, path.embedder(), '4324', { topK: 3 })
assert.equal(hits.degraded, path.expect, `expected degraded=${path.expect}`)
assert.ok(hits.length >= 1, 'symbol path must still recall on every degradation path')
assert.equal(calls(), 0, `"${path.name}" must not touch the network; saw ${calls()}`)
} finally {
store.close()
}
})
})
}
test('G2†: the fingerprint check runs BEFORE embedText is called', async () => {
// Ordering, pinned directly: a failing-fingerprint embedder must never be
// asked for a vector. If a refactor moved the call earlier, this fails even
// though the degraded value would still be 'no_embedder'.
let embedCalls = 0
const embedder = {
async embedText() {
embedCalls += 1
return [0, 0, 0]
},
info: () => ({ fingerprint: '', dimension: 3 }),
}
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: '值班室分机号 4324' })
const { recallBlocksHybrid } = await import('../lib/recall.js')
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
assert.equal(hits.degraded, 'no_embedder')
assert.equal(embedCalls, 0, 'embedText must not be called when the space is unnamed')
} finally {
store.close()
}
})
// Scans SOURCE TEXT, not the loaded module. If it imported the module it would
// crash on a missing package and report "load failed" — which is a different
// finding than "a disallowed dependency was added", and the distinction matters
// when triaging.
// ── G3: commit must work with a dead embedder ────────────────────────────
test('G3: stablemem_commit writes even when the embedder throws', async () => {
const ctx = makeApp()
const dir = await import('node:fs').then((fs) => fs.mkdtempSync(join('/tmp', 'g3-')))
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
mem.embedder = {
async embedText() {
throw new Error('vector service down')
},
info: () => ({ fingerprint: 'fp', dimension: 4 }),
}
const result = await ctx.tools.execute({
callId: 'g3',
name: 'stablemem_commit',
arguments: { text: '向量服务挂了也要能写入:端口 2379', source: 'g3' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, `commit must not fail: ${JSON.stringify(result)}`)
assert.equal(mem.store.countBlocks(), 1, 'the block must be stored')
// Recall still works through the symbol path.
const hits = await mem.recall('2379')
assert.ok(hits.length >= 1, 'symbol recall must still find it')
} finally {
await ctx.fiber.dispose()
const fs = await import('node:fs')
fs.rmSync(dir, { recursive: true, force: true })
}
})
// ── G4: media stays out ──────────────────────────────────────────────────
test('G4: only the text modality is produced', () => {
const store = new BlockStore({ path: ':memory:' }).open()
try {
store.putBlock({ text: 'a note' })
const rows = store.allBlocks(10)
assert.equal(rows.length, 1)
for (const row of rows) {
assert.equal(row.modality, MODALITY_TEXT, 'no non-text modality may be written')
}
// No media payload columns exist at all in this slice, so there is nowhere
// for bytes to be stored even accidentally.
const columns = store.columns('memory_blocks')
for (const forbidden of ['data', 'mime', 'width', 'height']) {
assert.ok(
!columns.includes(forbidden),
`memory_blocks must have no media column; found "${forbidden}" in ${columns.join(',')}`,
)
}
} finally {
store.close()
}
})