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() + } +})