From 07f8438964aa0ee618ca5355e0a2dabf122b139d Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Thu, 5 Nov 2026 11:42:06 +0800 Subject: [PATCH] fix(memory): count every dropped candidate, not just the two upstream ones (D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recallBlocks` could make a candidate disappear in three downstream places, and none of them incremented anything: · MinScore gate it scored, but below the threshold · structural gate neither a usable vector score nor a symbol hit · caller topK survived everything, fell outside the requested count Measured before this change, 6 blocks in one space, `minScore: 0.99`: hits=0 skipped=0 skippedDim=0 That reading is indistinguishable from "the space holds one block". An operator whose threshold is swallowing the whole corpus is told "nothing was excluded", and goes looking at the wrong subsystem. Only the space filter and the width filter were ever counted — the two that are decided before scoring. ## What this adds Three non-enumerable counters, each named after its own remedy: skippedScore lower the threshold skippedNoEvidence re-embed: there was nothing to score truncated not a defect — reported so the totals reconcile Plus the invariant that makes the next silent path impossible: **hits + skipped + skippedDim + skippedScore + skippedNoEvidence + truncated === the candidate pool.** A future `continue` that forgets a counter breaks it. ## Criterion `test/recall-accounting.test.js`, one fixture per path, asserting each counter's own value (not a merged total — "scored too low" and "nothing to score" have opposite remedies). The fourth test is the reconciliation above. Mutations, both in a scratch copy: remove `gatedByScore += 1` -> criteria 1 and 4 red remove `gatedNoEvidence += 1` -> criterion 2 red ## Collateral: two static mutation anchors moved `test/phase-b-a.test.js` pins the V5 gate and the caller-topK truncation by rewriting `lib/recall.js` text. Making the gate multi-line (to hold a counter) and threading `drops` through `withSkipped` changed those exact strings, so the anchors are updated to the new text — same mutations, same properties pinned. Running the file confirms 38/38 afterwards; without the update three V5† mutations failed on a stale anchor rather than on their subject. Gates: npm test 298/298 (0 skipped), npm run check 12/12. Frozen faces untouched. --- lib/recall.js | 48 +++++++++++- package.json | 2 +- test/phase-b-a.test.js | 16 ++-- test/recall-accounting.test.js | 133 +++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 test/recall-accounting.test.js diff --git a/lib/recall.js b/lib/recall.js index f894976..1a7c763 100644 --- a/lib/recall.js +++ b/lib/recall.js @@ -401,6 +401,21 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin } const scored = [] + // ── D2: every drop must be countable ───────────────────────────────── + // + // Three ways a candidate can disappear without becoming a hit, and until now + // only the first two (space / width, both upstream) were reported: + // + // · gatedByScore failed the V5 MinScore gate on its own vector score + // · gatedNoEvidence neither a usable vector score nor a symbol match + // · truncated survived every gate, fell outside the caller's topK + // + // Without these, "6 candidates, 1 returned" is indistinguishable from "the + // space only holds 1 block" — the operator sees skipped=0 and concludes + // nothing was dropped. Measured before this change: 6 blocks, minScore 0.99 → + // hits=0, skipped=0, skippedDim=0. + let gatedByScore = 0 + let gatedNoEvidence = 0 for (const candidate of pool.values()) { const haystack = candidate.text.toLowerCase() // Port of `memory.SymbolScore` (internal/memory/fusion.go:284-308): @@ -511,8 +526,12 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin if (fusedCandidates) { if (!exact) { if (hasVectorScore && vectorScore > 0) { - if (minScore > 0 && vectorScore < minScore) continue + if (minScore > 0 && vectorScore < minScore) { + gatedByScore += 1 + continue + } } else if (!(symbolHit > 0)) { + gatedNoEvidence += 1 continue } } @@ -535,7 +554,8 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin scored.sort((a, b) => (b.score === a.score ? a.id.localeCompare(b.id) : b.score - a.score)) // ported from go: internal/memory/fusion.go:557-559 — final caller topK, // after candidate union, scoring, filtering and the asymmetric gate. - return withSkipped(scored.slice(0, topK), skipped, skippedDim) + const drops = { gatedByScore, gatedNoEvidence, truncated: Math.max(0, scored.length - topK) } + return withSkipped(scored.slice(0, topK), skipped, skippedDim, drops) } /** @@ -549,9 +569,17 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin * * @param {Array} hits * @param {number} skipped blocks excluded by the space filter + * @param {number} [skippedDim] blocks excluded because their width did not match + * @param {{gatedByScore?: number, gatedNoEvidence?: number, truncated?: number}} [drops] + * the three downstream ways a candidate can disappear. Separate numbers on + * purpose: "failed the threshold" and "had nothing to score" have opposite + * remedies (lower the threshold vs. re-embed), and a merged count could not be + * acted on. `truncated` is the caller's own topK, which is not a defect — it is + * reported so that `hits + all counter fields` reconciles with the candidate + * pool. * @returns {Array & {skipped: number}} */ -function withSkipped(hits, skipped, skippedDim = 0) { +function withSkipped(hits, skipped, skippedDim = 0, drops = {}) { Object.defineProperty(hits, 'skipped', { value: skipped, enumerable: false, @@ -564,6 +592,20 @@ function withSkipped(hits, skipped, skippedDim = 0) { enumerable: false, configurable: true, }) + // ── D2: the three downstream drop counts ───────────────────────────── + // Also non-enumerable, for the same reason as `skipped`: the phase-A golden + // fixture compares result arrays. + for (const [key, label] of [ + ['gatedByScore', 'skippedScore'], + ['gatedNoEvidence', 'skippedNoEvidence'], + ['truncated', 'truncated'], + ]) { + Object.defineProperty(hits, label, { + value: Number.isInteger(drops[key]) ? drops[key] : 0, + enumerable: false, + configurable: true, + }) + } return hits } diff --git a/package.json b/package.json index ccaa72d..549bf80 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "node": ">=22.5.0" }, "scripts": { - "test": "node --test test/store.test.js test/packaging.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/phase-b-p1-vector-wiring.test.js test/phase-b-c4-centering.test.js test/phase-b-c5-reasons.test.js test/phase-b-v6-sides.test.js test/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js", + "test": "node --test test/store.test.js test/packaging.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js test/phase-b-p1-vector-wiring.test.js test/phase-b-c4-centering.test.js test/phase-b-c5-reasons.test.js test/phase-b-v6-sides.test.js test/recall-accounting.test.js test/memory-log.test.js test/loop.test.js test/loop-ports.test.js test/loop-system-e2e.test.js", "check": "node scripts/checkpoints.js" }, "peerDependencies": { diff --git a/test/phase-b-a.test.js b/test/phase-b-a.test.js index ea347b3..e3f13e9 100644 --- a/test/phase-b-a.test.js +++ b/test/phase-b-a.test.js @@ -711,13 +711,13 @@ async function v5Mutant(anchor, replacement, body) { for (const [name, anchor, replacement] of [ [ 'vector-side', - ` if (hasVectorScore && vectorScore > 0) {\n if (minScore > 0 && vectorScore < minScore) continue`, - ` if (false) {\n if (minScore > 0 && vectorScore < minScore) continue // MUTATED: vector gate disabled`, + ` if (hasVectorScore && vectorScore > 0) {\n if (minScore > 0 && vectorScore < minScore) {`, + ` if (false) {\n if (minScore > 0 && vectorScore < minScore) { // MUTATED: vector gate disabled`, ], [ 'symbol-side', - ` } else if (!(symbolHit > 0)) {\n continue`, - ` } else if (false) {\n continue // MUTATED: symbol-side gate removed`, + ` } else if (!(symbolHit > 0)) {\n gatedNoEvidence += 1\n continue`, + ` } else if (false) {\n gatedNoEvidence += 1\n continue // MUTATED: symbol-side gate removed`, ], [ 'symbol-threshold', @@ -726,8 +726,8 @@ for (const [name, anchor, replacement] of [ ], [ 'fused-score', - ` if (minScore > 0 && vectorScore < minScore) continue`, - ` if (score < minScore) continue // MUTATED: gate on the fused score`, + ` if (minScore > 0 && vectorScore < minScore) {`, + ` if (score < minScore) { // MUTATED: gate on the fused score`, ], ]) { test(`V5† ${name}: the unchanged asymmetric-gate criterion bites red`, async (t) => { @@ -1065,8 +1065,8 @@ for (const [name, anchor, replacement, criterion] of [ ], [ 'result-not-truncated', - ' return withSkipped(scored.slice(0, topK), skipped, skippedDim)', - ' return withSkipped(scored, skipped, skippedDim) // MUTATED: caller topK never applied', + ' return withSkipped(scored.slice(0, topK), skipped, skippedDim, drops)', + ' return withSkipped(scored, skipped, skippedDim, drops) // MUTATED: caller topK never applied', assertV7FinalTruncation, ], ]) { diff --git a/test/recall-accounting.test.js b/test/recall-accounting.test.js new file mode 100644 index 0000000..f7f3a9a --- /dev/null +++ b/test/recall-accounting.test.js @@ -0,0 +1,133 @@ +/** + * D2 · 每一次丢弃都必须可计数。 + * + * ## 病 + * + * 一条候选可以从三个地方消失,而在本次改动之前只有前两个(空间过滤、宽度 + * 不符)会被报告: + * + * · MinScore 门 —— 它自己的向量分不够 + * · 结构性门 —— 既没有可用的向量分,也没有符号命中 + * · topK 截断 —— 过了所有门,但排在调用方要的条数之外 + * + * 后三个都是裸 `continue`,不进任何计数器。于是「6 条候选留下 1 条」与 + * 「库里只有 1 条」在对外读数上完全一样:`skipped=0 skippedDim=0`。 + * 实测(改动前):6 条块、minScore=0.99 → hits=0,两个计数都是 0。 + * + * 运维看 `skipped=0` 会得出「没有任何东西被排除」的结论,而真相可能是阈值 + * 把整批都挡了 —— 一个应该调阈值的信号被读成了「库是空的」。 + * + * ## 判据形态 + * + * 三条路径各造一个 fixture,断**各自**的计数。另加一条不变式: + * 候选池 = 命中 + 三个下游计数 + 上游两个计数,等式必须成立 —— 否则将来再 + * 加一条静默丢弃路径,等式就会破。 + * + * @module dsh-memory/test/recall-accounting + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import { BlockStore } from '../lib/store.js' +import { recallBlocks } from '../lib/recall.js' + +const DIM = 8 +const FP = 'd2:accounting' +const unit = (v) => { const n = Math.hypot(...v); return n === 0 ? v : v.map((x) => x / n) } +const axis = (i) => unit(Array.from({ length: DIM }, (_, k) => (k === i ? 1 : 0))) + +/** 6 条块,各占一轴;query 落在轴 0,所以 cosine 已知且互不相同。 */ +function seeded() { + const store = new BlockStore({ path: ':memory:' }).open() + const ids = [] + for (let i = 0; i < 6; i += 1) { + ids.push(store.putBlock({ text: `块 ${i} 的正文`, vector: axis(i), fingerprint: FP }).id) + } + const query = unit([1, 0.5, 0.2, 0, 0, 0, 0, 0]) + const scores = new Map(ids.map((id, i) => [id, i === 0 ? 0.95 : 0.1 + i * 0.01])) + return { store, ids, query, scores } +} + +test('D2: candidates rejected by MinScore are counted, not silently dropped', () => { + const { store, query, scores } = seeded() + try { + const hits = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.9 }) + assert.equal(hits.length, 1, 'precondition: only the 0.95 candidate clears 0.9') + assert.equal( + hits.skippedScore, + 5, + 'D2: five candidates failed the score gate and must be REPORTED as such. ' + + 'Before this counter existed the result was indistinguishable from a ' + + 'one-block space, which sends the operator looking at the wrong subsystem.', + ) + } finally { + store.close() + } +}) + +test('D2: candidates with neither vector score nor symbol hit are counted separately', () => { + const { store, ids, query } = seeded() + try { + // A candidate reaches the STRUCTURAL gate when it IS in the pool but has no + // usable vector score (absent, or <= 0) and no symbol match. A cosine of + // exactly 0 is the realistic trigger: it is what a candidate orthogonal to + // the query gets, and also what centring produces in a degenerate space. + const scores = new Map([[ids[0], 0.95], [ids[1], 0.8], [ids[2], 0]]) + const hits = recallBlocks(store, 'zzz-no-symbol-match', { + topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.5, + }) + assert.equal(hits.length, 2, 'precondition: only the two positive scores survive') + assert.equal(hits.skippedScore, 0, 'precondition: nothing failed the threshold') + assert.equal( + hits.skippedNoEvidence, + 1, + 'D2: a candidate that matched neither route must be counted under its own name — ' + + '"nothing to score" and "scored too low" have opposite remedies (re-embed vs. ' + + 'lower the threshold). Blocks that never entered the pool are a different, ' + + 'upstream fact and must not be folded in here.', + ) + } finally { + store.close() + } +}) + +test('D2: candidates cut by the caller topK are counted as truncated', () => { + const { store, query, scores } = seeded() + try { + const all = recallBlocks(store, query, { topK: 6, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 }) + const top2 = recallBlocks(store, query, { topK: 2, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.05 }) + assert.equal(all.length, 6, 'precondition: all six clear a 0.05 threshold') + assert.equal(top2.length, 2, 'precondition: topK truncates') + assert.equal(top2.skippedScore, 0, 'nothing failed the score gate here') + assert.equal( + top2.truncated, + 4, + 'D2: the four that fell outside topK must be reported. Truncation is not a ' + + 'defect, but leaving it uncounted breaks the reconciliation of ' + + 'candidates = hits + counters.', + ) + } finally { + store.close() + } +}) + +test('D2: hits plus every counter reconciles with the candidate pool', () => { + const { store, query, scores } = seeded() + try { + const hits = recallBlocks(store, query, { topK: 3, fingerprint: FP, dimension: DIM, vectorScores: scores, minScore: 0.15 }) + const accounted = hits.length + + hits.skippedScore + + hits.skippedNoEvidence + + hits.truncated + + hits.skipped + + hits.skippedDim + assert.equal( + accounted, + 6, + `D2: every candidate must land in exactly one bucket; ${accounted} of 6 accounted for. ` + + 'A shortfall means a new silent drop path was added without a counter.', + ) + } finally { + store.close() + } +})