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:
@ -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
|
||||
|
||||
@ -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 的决定不变) |
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user