feat(memory-b): port V1 exact vector pin adjustment

This commit is contained in:
2026-11-01 11:51:13 +08:00
parent 8bdd9f5ce0
commit fc92db9f5b
2 changed files with 108 additions and 2 deletions

View File

@ -217,10 +217,11 @@ function allIndexesOf(haystack, needle) {
* @param {object} [options]
* @param {number} [options.topK] max hits returned (default 5)
* @param {number} [options.poolSize] FTS candidate pool before truncation (default 50)
* @param {Map<string, number>} [options.vectorScores] optional vector scores for V1 exact-band tie adjustment
* @returns {Array<{id: string, text: string, source: string, score: number,
* exact: boolean, why: string}>}
*/
export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprint, dimension } = {}) {
export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprint, dimension, vectorScores } = {}) {
const text = String(query ?? '').trim()
if (text === '') return []
@ -399,8 +400,15 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin
// [1.0+) exact numeric pin — must always outrank everything else
// [0..1) non-exact match — lexical / word / CJK coverage
const nonExact = Math.max(candidate.lexical, coverage * 0.5, cjkCoverage * 0.75)
// Ported from Go: internal/memory/fusion.go:425-428.
// ExactHit is a boolean pin; an explicitly supplied vector score only makes
// a same-band tie adjustment. Keep the old no-vector path byte-compatible
// for A2: it has no VectorHit to apply.
const exactScore = vectorScores instanceof Map
? 1.0 + (vectorScores.get(candidate.id) ?? 0) * 0.01
: 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4
const score = exact
? 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4
? exactScore
: nonExact + boundaryBonus * 1e-3
scored.push({
id: candidate.id,

View File

@ -271,3 +271,101 @@ test('A3c mutation: labelling the success path "hybrid" must fail', () => {
'if lib returns "hybrid", the A3c assertion must throw',
)
})
// ── V1: exact hits stay BOOLEAN-pinned, vector only adjusts inside the band ─
//
// Ported from Go `internal/memory/fusion.go:425-428`:
//
// if f.ExactHit.mutated { f.Score = 1.0 + f.VectorHit*0.01 }
//
// The blocked copy that must never come back is the one HA measured and
// recorded (`fusion.go:42-44`): folding a 1.0 exact hit into the weighted sum
// gives it 0.30 and lets a noise candidate at 0.534 win.
test('V1: an exact hit keeps the pin band and only takes a vector tie adjustment', () => {
const store = seededStore()
try {
// A query with an exact numeric symbol, so it is a real exact-band hit.
const query = '4324'
const plain = recallBlocks(store, query, { topK: 5 })
const exact = plain.filter((hit) => hit.exact === true)
assert.ok(exact.length > 0, `the corpus must yield an exact hit for ${query}; got ${JSON.stringify(plain.map((h) => [h.id, h.exact]))}`)
// Vector scores supplied for the exact hits only: same band, tiny nudge.
const scores = new Map(exact.map((hit) => [hit.id, 1]))
const withVector = recallBlocks(store, query, { topK: 5, vectorScores: scores })
const before = new Map(exact.map((hit) => [hit.id, hit.score]))
for (const hit of withVector.filter((h) => h.exact === true)) {
const previous = before.get(hit.id)
if (previous === undefined) continue
assert.equal(hit.score, 1.0 + 1 * 0.01, 'exact score must be 1.0 + vector*0.01 (go:fusion.go:427)')
assert.ok(hit.score >= 1.0, 'and stays in the pin band, never below 1.0')
}
// The vector nudge must never outrank an exact hit: every exact hit still
// sorts above every non-exact hit.
const firstNonExact = withVector.findIndex((hit) => hit.exact !== true)
if (firstNonExact !== -1) {
for (let i = firstNonExact; i < withVector.length; i += 1) {
assert.ok(withVector[i].score < 1.0, 'non-exact hits stay strictly below the pin band')
}
}
} finally {
store.close()
}
})
test('V1: a low vector score cannot pull an exact hit out of the pin band', () => {
const store = seededStore()
try {
const query = '4324'
const plain = recallBlocks(store, query, { topK: 5 })
const exact = plain.find((hit) => hit.exact === true)
assert.ok(exact, 'precondition: the corpus must produce an exact hit')
const zero = recallBlocks(store, query, { topK: 5, vectorScores: new Map([[exact.id, 0]]) })
const hit = zero.find((row) => row.id === exact.id)
assert.equal(hit.score, 1.0, 'vector 0 still pins at 1.0 — the pin never depends on the vector')
} finally {
store.close()
}
})
// M-V1 mutation: fold exact into weights; the unchanged positive predicate must bite.
test('V1†: folding an exact hit into a weighted sum turns V1 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(), 'mv1-'))
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')
const anchor = ` const exactScore = vectorScores instanceof Map
? 1.0 + (vectorScores.get(candidate.id) ?? 0) * 0.01
: 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4`
assert.equal(source.split(anchor).length - 1, 1, 'M-V1 anchor must occur exactly once')
source = source.replace(anchor,
' const exactScore = 0.7 * (vectorScores instanceof Map ? (vectorScores.get(candidate.id) ?? 0) : 0) + 0.3 * (exact ? 1 : 0) // MUTATED')
writeFileSync(target, source)
const mod = await import(pathToFileURL(target).href)
const store = seededStore()
try {
const plain = mod.recallBlocks(store, '4324', { topK: 5 })
const exact = plain.filter((hit) => hit.exact === true)
assert.ok(exact.length > 0, 'precondition: mutant corpus has an exact hit')
assert.throws(() => {
assert.ok(exact.every((hit) => hit.score >= 1.0), 'exact must stay in the pin band')
}, (err) => {
assert.equal(err.code, 'ERR_ASSERTION')
t.diagnostic(`V1† RAW RED >>> ${err.message}`)
return true
}, 'M-V1 must break the unchanged V1 positive predicate')
} finally { store.close() }
} finally { rmSync(dir, { recursive: true, force: true }) }
})