test(memory): pin D1 — a corrupt vector cannot take the recall path down (D1)
D1 was the one item on the coverage list with genuinely no criterion. Measured by
mutation, not by grepping test names:
corrupt vector handling: skip -> throw
phase-b-h 137/137 green, phase-b-a 38/38 green, recall-accounting 5/5 green
-> 145 criteria, zero red
That is decision §0.4 unguarded: "the vector side failing must not take the main
path down". One bad row would cost the whole turn its memory, and nothing noticed.
## The same probe found three items that ARE covered
Recorded because the parent's coverage matrix derived "zero hits" from grepping
criteria IDs against test names, and three of those were false negatives — the
criteria exist, they are just named after their subject rather than their ID:
C6 centroid partitioned by fingerprint loadCentroid ignoring it -> 3 red
(ticket②, ticket②-strong, W8)
W1 exec.signal reaches embedText dropping it -> 2 red (W5, W9c)
F1 fingerprint from provider info() deriving locally -> 5 red
So the matrix under-reports as well as over-reports. Only mutation distinguishes
"labelled" from "enforced".
## What the criterion does
Writes a corrupt payload straight into the row and requires the recall to survive:
store.raw().prepare('UPDATE memory_blocks SET vector = ? WHERE id = ?')
.run('["a","b","c","d"]', broken.id) // right length, non-numeric elements
Two guards narrowed what "corrupt" can mean, both measured and documented in the
criterion:
· 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`, so a wrong-LENGTH payload is filtered in SQL
before scoring sees it.
An array of the right length with non-numeric elements clears both and still cannot
be scored — which is exactly what a provider that changed its encoding, or a
migration that stringified values, would leave behind.
Assertions: no throw, the healthy neighbour is still recalled, and the bad row is
counted in `skippedUnscoreable` (D1's other half is D2's rule: skipping silently is
the same defect).
Mutation — a non-numeric component throws instead of skipping:
not ok 6 - D1: a corrupt vector payload cannot take the recall path down
error: D1: a single corrupt vector payload threw out of the recall path
(M-D1: non-numeric vector component). Decision §0.4: ...
Gates: npm test 304/304 (0 skipped), npm run check 12/12.
This commit is contained in:
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user