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