feat(memory-b): expose V3 vector and symbol attribution

This commit is contained in:
2026-11-01 15:34:55 +08:00
parent b7eff5db66
commit 5b217c1f78
2 changed files with 115 additions and 1 deletions

View File

@ -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',
})
}

View File

@ -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 })
}
})
}