diff --git a/test/recall-accounting.test.js b/test/recall-accounting.test.js index 92077bc..d7d799b 100644 --- a/test/recall-accounting.test.js +++ b/test/recall-accounting.test.js @@ -189,3 +189,67 @@ test('D2: a vector that centring collapses is counted, not silently lost', async store.close() } }) + +// ── D1: 召回主路径不得因向量侧故障抛错 ────────────────────────────────────── +// +// 实测出来的真缺口:把「损坏向量」的处理从 skip 改成 throw 之后, +// phase-b-h 137 条、phase-b-a 38 条、recall-accounting 5 条 —— 145 条**全绿**。 +// 也就是说「损坏的向量载荷不能把整轮召回打挂」这件事,此前没有任何判据守着。 +// +// (父早期的覆盖率矩阵按条目 ID grep 测试名,把 C6/W1/F1 也标成零覆盖;实测 +// 那些其实有判据,只是没按 ID 命名。D1 是四项里唯一真的零覆盖。) +// +// 决策 §0.4 的原话是「向量侧的故障不得让主路径抛错」:一块脏数据不能把整轮 +// 记忆检索变成异常,模型那一侧看到的就是完全失忆。所以这里直接往库里塞一行 +// 坏 payload,然后要求召回照常返回。 +test('D1: a corrupt vector payload cannot take the recall path down', async () => { + const DIM = 4 + const FP = 'd1:corrupt' + const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) } + + const store = new BlockStore({ path: ':memory:' }).open() + try { + const healthy = store.putBlock({ text: '值班室的分机号是 4324', vector: unit([1, 0.1, 0, 0]), fingerprint: FP }) + const broken = store.putBlock({ text: '一条被写坏向量的块', vector: unit([1, 0.2, 0, 0]), fingerprint: FP }) + // ★ Two guards narrow what "corrupt" can even mean here, both measured: + // · the DDL enforces `json_valid(vector)` — a syntactically broken payload is + // refused outright (`ERR_SQLITE_ERROR: malformed JSON`), so it cannot reach + // the read path; + // · a trigger re-derives `vector_dim` from the payload, so a value of the wrong + // LENGTH is filtered out in SQL before scoring ever sees it. + // What survives both guards and still cannot be scored is an array of the right + // length whose elements are not numbers — exactly what a provider that switched + // its encoding (or a migration that stringified values) would leave behind. + store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?') + .run('["a","b","c","d"]', broken.id) + + const embedder = { + async embedText() { return unit([1, 0.15, 0, 0]) }, + info: () => ({ fingerprint: FP, dimension: DIM }), + } + + let hits + try { + hits = await recallBlocksHybrid(store, embedder, '分机号是多少', { topK: 5 }) + } catch (error) { + assert.fail( + 'D1: a single corrupt vector payload threw out of the recall path ' + + `(${error.message}). Decision §0.4: the vector side failing must not take ` + + 'the main path down — one bad row would otherwise cost the whole turn its memory.', + ) + } + + assert.ok( + hits.some((hit) => hit.id === healthy.id), + 'D1: the healthy block must still be recalled while a corrupt row sits next to it', + ) + assert.equal( + hits.skippedUnscoreable, + 1, + 'D1/D2: the corrupt row is skipped AND counted — silent skipping is the other half ' + + 'of this defect.', + ) + } finally { + store.close() + } +})