feat(memory-b): port V2 non-exact fusion weights

Also clear three stale counts the release train authorised: criteria A1
wording (sync + hybrid), criteria G1 counts (173/12), README test-site count.
This commit is contained in:
2026-11-01 12:01:22 +08:00
parent fc92db9f5b
commit d57c9dc798
4 changed files with 88 additions and 4 deletions

View File

@ -250,7 +250,7 @@ recallBlocksHybrid(store, embedder, query, opts) // async wrapper
```
Why the split rather than "make recallBlocks async": every existing caller keeps
calling a sync function, so the 9 test call sites and the whole phase-A surface
calling a sync function, so the existing test call sites and the whole phase-A surface
need no edit, and the golden fixture proves the sync path did not move.
With no embedder (or a failing one) the wrapper is exactly `recallBlocks`, and it

View File

@ -143,7 +143,7 @@ return ""
| # | 断言 | 依据 |
|---|---|---|
| A1 | `recallBlocks` 改为 `async` 后,phase A 的 26 个测试与 8 个 checkpoint **全部仍然通过**,一条不改语义 | 现在 `agent/pre-step` handler 已经是 `async`(`lib/index.js` 的 `toggle`),所以异步化不破坏调用方 |
| A1 | `recallBlocks` 保持同步,另新增异步 `recallBlocksHybrid`;phase A 的 26 个测试与 8 个 checkpoint **全部仍然通过**,一条不改语义 | 现有调用方保持同步,异步只出现在新 wrapper |
| A2 | 未配置 embedder 时,`recallBlocks` 的返回与 phase A **逐字节相同**(同序、同分、同 `why`) | 无向量是合法配置,不是降级 |
**A1 变异**:把 `recallBlocks` 改回同步并在内部 await 一个 Promise ⇒ 测试红(语法/类型层面即失败)。
@ -319,7 +319,7 @@ phase B 必须把向量放进 **[0..1) 带**,不得越到 exact 带。
| # | 断言 |
|---|---|
| G1 | phase A 的 26 测试 + 8 checkpoint **全绿**,语义不变(A1/A2 已覆盖) |
| G1 | phase A 的 173 tests + 12 checkpoints **全绿**,语义不变(A1/A2 已覆盖) |
| G2 | 未配置 embedder ⇒ 进程内无任何 HTTP 出站、无新依赖被加载 |
| G3 | `stablemem_commit` 在 embedder 不可用时**仍能写入**(块入库、vector 留空,`no_vector` 计数 +1),不报错 |
| G4 | 不引入 media/媒体块(`docs/feasibility.md` §5.4 的决定不变) |

View File

@ -407,9 +407,16 @@ export function recallBlocks(store, query, { topK = 5, poolSize = 50, fingerprin
const exactScore = vectorScores instanceof Map
? 1.0 + (vectorScores.get(candidate.id) ?? 0) * 0.01
: 1.0 + candidate.lexical * 0.01 + boundaryBonus * 1e-4
// Ported from Go: internal/memory/fusion.go:61-66 and :430-434.
// Non-exact candidates use vector*0.7 + symbol*0.3. A missing vector score
// is the symbol-only fallback and must not invent a vector contribution.
const hasVectorScore = vectorScores instanceof Map && vectorScores.has(candidate.id)
const vectorScore = hasVectorScore ? vectorScores.get(candidate.id) : 0
const score = exact
? exactScore
: nonExact + boundaryBonus * 1e-3
: vectorScores instanceof Map && hasVectorScore
? vectorScore * 0.7 + nonExact * 0.3
: nonExact + boundaryBonus * 1e-3
scored.push({
id: candidate.id,
text: candidate.text,

View File

@ -369,3 +369,80 @@ test('V1†: folding an exact hit into a weighted sum turns V1 red', async (t) =
} finally { store.close() }
} finally { rmSync(dir, { recursive: true, force: true }) }
})
// ── V2: non-exact records combine vector*0.7 + symbol*0.3 ───────────────
//
// Ported from Go `internal/memory/fusion.go:61-66` (defaultWeights) and
// `:430-434` (the non-exact weighted sum). V2 scores records that are ALREADY
// in the symbol pool: appending symbol-only records is V5, and the asymmetric
// MinScore gate is V5's too — neither is opened here.
//
// ★ The criterion below is shared verbatim by the positive case and the mutant,
// so a mutant can only pass by satisfying the same requirement. The V1† lesson
// was that re-writing the requirement inside the mutant's body tests the mutant,
// not the code.
function assertV2Weights(recall, store) {
// A query from the fixture corpus that yields non-exact records. `QQ 插件`
// does not: FTS returns nothing for it, so the assertion has no subject.
const query = '分机号'
const plain = recall(store, query, { topK: 5 })
const candidate = plain.find((hit) => hit.exact === false)
assert.ok(candidate, `precondition: a non-exact record exists for ${query}`)
const pick = (score) => recall(store, query, { topK: 5, vectorScores: new Map([[candidate.id, score]]) })
.find((hit) => hit.id === candidate.id)
const zero = pick(0)
const one = pick(1)
assert.ok(zero && one, 'the scored record must remain in the candidate pool')
// Go: f.Score = f.VectorHit*w.Vector + f.SymbolHit*w.Symbol with Vector = 0.7.
assert.equal(one.score - zero.score, 0.7,
'vector 0→1 must move the fused score by exactly Go Vector weight 0.7')
// The symbol term must still be present: with no vector score the record keeps
// its symbol-only score rather than collapsing to 0 (Go's `SymbolHit*w.Symbol`).
const noMap = recall(store, query, { topK: 5 }).find((hit) => hit.id === candidate.id)
const emptyMap = recall(store, query, { topK: 5, vectorScores: new Map() })
.find((hit) => hit.id === candidate.id)
assert.equal(emptyMap.score, noMap.score, 'a missing vector score must not invent a contribution')
}
test('V2: a non-exact record uses the Go 0.7/0.3 weighted score', () => {
const store = seededStore()
try {
assertV2Weights(recallBlocks, store)
} finally {
store.close()
}
})
test('V2†: changing the Go weights to 0.5/0.5 bites the unchanged criterion 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(), 'mv2-'))
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 = 'vectorScore * 0.7 + nonExact * 0.3'
assert.equal(source.split(anchor).length - 1, 1, 'M-V2 anchor must occur exactly once')
source = source.replace(anchor, 'vectorScore * 0.5 + nonExact * 0.5 // MUTATED: weights')
writeFileSync(target, source)
const mod = await import(pathToFileURL(target).href)
const store = seededStore()
try {
assert.throws(() => assertV2Weights(mod.recallBlocks, store), (err) => {
assert.equal(err.code, 'ERR_ASSERTION', 'only the criterion may bite')
t.diagnostic(`V2† RAW RED >>> ${String(err.message).split('\n')[0]}`)
return true
}, 'M-V2 must break the unchanged V2 criterion')
} finally {
store.close()
}
} finally {
rmSync(dir, { recursive: true, force: true })
}
})