From 5b217c1f782b985bc0d5fd4d78a2f1a2addeea23 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Sun, 1 Nov 2026 15:34:55 +0800 Subject: [PATCH] feat(memory-b): expose V3 vector and symbol attribution --- lib/recall.js | 16 ++++++- test/phase-b-a.test.js | 100 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/lib/recall.js b/lib/recall.js index ca0b834..4c01062 100644 --- a/lib/recall.js +++ b/lib/recall.js @@ -417,13 +417,27 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin : vectorScores instanceof Map && hasVectorScore ? vectorScore * 0.7 + nonExact * 0.3 : nonExact + boundaryBonus * 1e-3 + // ── V3: attribution ────────────────────────────────────────────── + // Ported from Go: internal/memory/fusion.go:34-46 (FusedHit's VectorHit / + // SymbolHit / Why) and :394-398 (the symbol branch that fills them). + // + // ★ Every value comes from the branch that already ran — nothing is a + // constant decided per shape. Go's own note (`:38-39`) is explicit that 0 + // means "did not hit", so the two signals stay independently observable: + // a block can carry a vector score and no symbol match, or the reverse. + const vectorHit = vectorScore + const symbolHit = exact ? 1.0 : coverage scored.push({ id: candidate.id, text: candidate.text, source: candidate.source, score, exact, - why: exact ? `exact:${matched.filter(isNumericSymbol).join(',')}` : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical', + vectorHit, + symbolHit, + why: vectorScores instanceof Map + ? `vector:${vectorHit};symbol:${symbolHit};${exact ? 'exact' : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical'}` + : exact ? `exact:${matched.filter(isNumericSymbol).join(',')}` : matched.length > 0 ? `word:${matched.join(',')}` : 'lexical', }) } diff --git a/test/phase-b-a.test.js b/test/phase-b-a.test.js index ed28b39..114dd0c 100644 --- a/test/phase-b-a.test.js +++ b/test/phase-b-a.test.js @@ -446,3 +446,103 @@ test('V2†: changing the Go weights to 0.5/0.5 bites the unchanged criterion re rmSync(dir, { recursive: true, force: true }) } }) + +// ── V3: vector and symbol contributions are separately attributable ────── +// +// Ported from Go `internal/memory/fusion.go:34-46`: `FusedHit` keeps `VectorHit`, +// `SymbolHit` and `Why` as separate facts, with 0 meaning "this path did not hit". +// V3 therefore requires the two numbers to vary INDEPENDENTLY — a single label +// (or one constant per shape) would make the three cases indistinguishable. +// +// Scope: attribution only. No symbol-only appending (V5), no topK reordering (V7). +function assertV3Attribution(recall, store) { + // `billing 端口` matches the Latin token `billing`, so the symbol path DOES hit + // (precondition for attribution) while no exact numeric run is present. + const query = 'billing 端口' + const plain = recall(store, query, { topK: 10 }) + const symbolOnly = plain.find((hit) => hit.text === 'billing服务 端口=8499') + assert.ok(symbolOnly, 'precondition: the real billing block is in the result pool') + + // (1) symbol-only: vector hit is exactly 0, symbol hit is not. + assert.equal(symbolOnly.vectorHit, 0, 'no vector score ⇒ VectorHit is 0 (go :38)') + assert.equal(symbolOnly.symbolHit, 1, 'the billing token actually matched: SymbolHit is 1') + + // Existing CJK/lexical candidate, no high-information symbol match. Supply its + // vector score without appending a new record (candidate expansion remains V5). + const lexical = recall(store, '分机号', { topK: 5 })[0] + assert.ok(lexical, 'precondition: an existing lexical candidate is present') + const vectorOnly = recall(store, '分机号', { + topK: 5, vectorScores: new Map([[lexical.id, 0.9]]), + }).find((hit) => hit.id === lexical.id) + assert.equal(vectorOnly.vectorHit, 0.9, 'vector-only contribution comes from the supplied score') + assert.equal(vectorOnly.symbolHit, 0, 'no extracted symbol matched this lexical candidate') + assert.match(vectorOnly.why, /vector:0\.9;symbol:0;/) + + + // (2) exact hit: symbol score is the boolean 1.0 (go :300-305), still separate + // from whatever the vector says. + const exact = recall(store, '4324', { topK: 5 }).find((hit) => hit.exact === true) + assert.ok(exact, 'precondition: an exact hit exists') + assert.equal(exact.symbolHit, 1.0, 'exact hit carries SymbolHit 1.0') + assert.equal(exact.vectorHit, 0, 'and no vector score was supplied') + + // (3) same record WITH a vector score: both non-zero, and the two numbers are + // different facts rather than one value copied into both fields. + const withVector = recall(store, query, { + topK: 10, vectorScores: new Map([[symbolOnly.id, 0.6]]), + }).find((hit) => hit.id === symbolOnly.id) + assert.equal(withVector.vectorHit, 0.6, 'VectorHit echoes the supplied vector score') + assert.equal(withVector.symbolHit, symbolOnly.symbolHit, 'SymbolHit is unchanged by the vector') + assert.notEqual(withVector.vectorHit, withVector.symbolHit, 'the two signals are distinct facts') + + // (4) the attribution is readable in the existing `why` field. + assert.match(withVector.why, /vector:0\.6/, 'why carries the vector score') + assert.match(withVector.why, /symbol:/, 'why carries the symbol score') +} + +test('V3: vector and symbol contributions are separately attributable', () => { + const store = seededStore() + try { + assertV3Attribution(recallBlocks, store) + } finally { + store.close() + } +}) + +for (const [name, anchor, replacement] of [ + ['symbol', ' const symbolHit = exact ? 1.0 : coverage', ' const symbolHit = vectorHit // MUTATED: collapsed signals'], + ['vector', ' const vectorHit = vectorScore', ' const vectorHit = 0 // MUTATED: lost vector attribution'], + ['why', '? `vector:${vectorHit};symbol:${symbolHit};', '? `opaque;'], +]) { + test(`V3† ${name}: the unchanged attribution criterion bites red`, async (t) => { + const { readFileSync, writeFileSync, mkdtempSync, rmSync, cpSync } = await import('node:fs') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const { fileURLToPath, pathToFileURL } = await import('node:url') + const libDir = fileURLToPath(new URL('../lib/', import.meta.url)) + const pkgRoot = fileURLToPath(new URL('..', import.meta.url)) + const dir = mkdtempSync(join(tmpdir(), 'mv3-')) + try { + cpSync(libDir, join(dir, 'lib'), { recursive: true }) + cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json')) + const target = join(dir, 'lib', 'recall.js') + let source = readFileSync(target, 'utf8') + assert.equal(source.split(anchor).length - 1, 1, 'M-V3 anchor must occur exactly once') + source = source.replace(anchor, replacement) + writeFileSync(target, source) + const mod = await import(pathToFileURL(target).href) + const store = seededStore() + try { + assert.throws(() => assertV3Attribution(mod.recallBlocks, store), (err) => { + assert.equal(err.code, 'ERR_ASSERTION', 'only the criterion may bite') + t.diagnostic(`V3† ${name} RAW RED >>> ${String(err.message).split('\n')[0]}`) + return true + }, 'M-V3 must break the unchanged V3 criterion') + } finally { + store.close() + } + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) +}