feat(memory-b): expose V3 vector and symbol attribution
This commit is contained in:
@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user