372 lines
16 KiB
JavaScript
372 lines
16 KiB
JavaScript
/**
|
|
* Phase B · group A — preconditions.
|
|
*
|
|
* A1: `recallBlocks` stays SYNCHRONOUS and unmodified; the async surface is a
|
|
* *new* wrapper beside it. This is the structural guarantee that phase B
|
|
* cannot regress phase A: existing callers keep calling a sync function and
|
|
* 9 test call sites need no edit.
|
|
* A2: with no embedder, the wrapper's output is byte-identical to the sync
|
|
* function — same order, same scores, same `why`.
|
|
* A3: hybrid and sync agree field-for-field when the embedder is null.
|
|
*
|
|
* A2/A3 are pinned by a GOLDEN FIXTURE (`test/fixtures/golden-phase-a.json`)
|
|
* captured from the frozen phase-A implementation over a 12-block corpus and 15
|
|
* queries at topK 1/3/5. Scores are compared exactly, not approximately — a
|
|
* tolerance-based check would let a scoring change through, which is precisely
|
|
* what this group exists to prevent.
|
|
*
|
|
* @module dsh-memory/test/phase-b-a
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import test from 'node:test'
|
|
import { BlockStore } from '../lib/store.js'
|
|
import { recallBlocks, recallBlocksHybrid } from '../lib/recall.js'
|
|
|
|
const GOLDEN = JSON.parse(
|
|
readFileSync(new URL('./fixtures/golden-phase-a.json', import.meta.url), 'utf8'),
|
|
)
|
|
|
|
/** Build a store seeded with the fixture corpus. */
|
|
function seededStore() {
|
|
const store = new BlockStore({ path: ':memory:' }).open()
|
|
for (const block of GOLDEN.corpus) store.putBlock(block)
|
|
return store
|
|
}
|
|
|
|
test('A1: recallBlocks is synchronous and takes no embedder', () => {
|
|
const store = seededStore()
|
|
try {
|
|
const result = recallBlocks(store, '4324', { topK: 1 })
|
|
// A synchronous function returns a value, not a thenable. If phase B ever
|
|
// "helpfully" makes this async, every existing caller breaks silently
|
|
// (a Promise is never `.length === 0`), so pin it structurally.
|
|
assert.equal(typeof result?.then, 'undefined', 'recallBlocks must NOT return a thenable')
|
|
assert.ok(Array.isArray(result), 'recallBlocks must return an array synchronously')
|
|
assert.equal(result.length, 1)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
// A1b originally pinned `recallBlocks` BYTE-FOR-BYTE against the frozen phase-A
|
|
// commit. That was the right guard while phase B was additive, but F2 (space
|
|
// filtering on the symbol path) legitimately has to edit the function — so a byte
|
|
// comparison would now forbid correct work.
|
|
//
|
|
// What the byte-lock actually protected was narrower: **phase-A behaviour must not
|
|
// drift**. That is already pinned, and better, by the golden fixture (A2), whose
|
|
// 12-block corpus carries no fingerprints — so the F2 filter cannot fire on it.
|
|
// The property to keep is therefore: *without a fingerprint argument, recall is
|
|
// untouched.* Asserted behaviourally below, plus a static check that the filter is
|
|
// gated on the argument rather than running unconditionally.
|
|
test('A1b: recallBlocks is unchanged for callers that pass no fingerprint', () => {
|
|
const source = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
|
|
const body = source.slice(source.indexOf('export function recallBlocks('))
|
|
const fn = body.slice(0, body.indexOf('\n}\n'))
|
|
|
|
// The space filter must be conditional on an explicitly supplied fingerprint.
|
|
// If it ran unconditionally, phase-A callers (and the golden fixture) would
|
|
// change behaviour — the exact regression the byte-lock was protecting against.
|
|
assert.match(
|
|
fn,
|
|
/if \(typeof fingerprint === 'string' && fingerprint !== ''\) \{/,
|
|
'the F2 space filter must be gated on an explicitly supplied fingerprint',
|
|
)
|
|
assert.match(
|
|
fn,
|
|
/fingerprint[,\s}]/,
|
|
'recallBlocks must accept a `fingerprint` option',
|
|
)
|
|
})
|
|
|
|
test('A1b-behaviour: omitting fingerprint yields the golden results exactly', () => {
|
|
// Behavioural twin of the check above: the fixture corpus has no fingerprints,
|
|
// and no fingerprint is passed, so the F2 filter is inert here. If it ever fires
|
|
// in this configuration, results diverge and this fails.
|
|
//
|
|
// ★ KNOWN WEAKNESS — do not read this test alone as sufficient.
|
|
// It walks only the FIRST FIVE golden cases (`slice(0, 5)`), and under mutation
|
|
// A (a scoring change in the non-exact band) those five can stay green while
|
|
// later cases drift. The conclusion that phase-A behaviour is intact must come
|
|
// from **A2**, which walks all 45 cases and compares exact scores. A1b-behaviour
|
|
// is a cheap smoke check on the no-fingerprint path, not the authority.
|
|
//
|
|
// History: A1b used to pin `recallBlocks` byte-for-byte against the frozen
|
|
// phase-A commit. F2 legitimately has to edit that function, so the guard was
|
|
// narrowed to "behaviour without a fingerprint is unchanged" — with A2 carrying
|
|
// the full-coverage claim.
|
|
const store = seededStore()
|
|
try {
|
|
for (const testCase of GOLDEN.cases.slice(0, 5)) {
|
|
const actual = recallBlocks(store, testCase.query, { topK: testCase.topK })
|
|
assert.deepEqual(
|
|
actual.map((hit) => [hit.id, hit.score, hit.exact, hit.why]),
|
|
testCase.hits.map((hit) => [hit.id, hit.score, hit.exact, hit.why]),
|
|
`no-fingerprint recall drifted for ${testCase.name}`,
|
|
)
|
|
}
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
test('A2: golden fixture — every case reproduces exactly (order, score, why)', () => {
|
|
const store = seededStore()
|
|
try {
|
|
const failures = []
|
|
for (const testCase of GOLDEN.cases) {
|
|
const actual = recallBlocks(store, testCase.query, { topK: testCase.topK })
|
|
const label = `${testCase.name} q=${JSON.stringify(testCase.query)} topK=${testCase.topK}`
|
|
if (actual.length !== testCase.hits.length) {
|
|
failures.push(`${label}: hit count ${actual.length} != ${testCase.hits.length}`)
|
|
continue
|
|
}
|
|
for (let i = 0; i < actual.length; i += 1) {
|
|
const a = actual[i]
|
|
const e = testCase.hits[i]
|
|
// Exact identity of the ordered result: position, id, score, exact flag,
|
|
// and the human-readable attribution string.
|
|
if (a.id !== e.id) failures.push(`${label} [${i}]: id ${a.id} != ${e.id}`)
|
|
if (a.score !== e.score) failures.push(`${label} [${i}]: score ${a.score} != ${e.score}`)
|
|
if (a.exact !== e.exact) failures.push(`${label} [${i}]: exact ${a.exact} != ${e.exact}`)
|
|
if (a.why !== e.why) failures.push(`${label} [${i}]: why ${JSON.stringify(a.why)} != ${JSON.stringify(e.why)}`)
|
|
if (a.text !== e.text) failures.push(`${label} [${i}]: text differs`)
|
|
}
|
|
if (failures.length > 8) break
|
|
}
|
|
assert.deepEqual(failures, [], `golden mismatch:\n${failures.slice(0, 8).join('\n')}`)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
test('A3: recallBlocksHybrid with no embedder equals recallBlocks field-for-field', async () => {
|
|
const store = seededStore()
|
|
try {
|
|
const failures = []
|
|
for (const testCase of GOLDEN.cases) {
|
|
const sync = recallBlocks(store, testCase.query, { topK: testCase.topK })
|
|
const hybrid = await recallBlocksHybrid(store, null, testCase.query, { topK: testCase.topK })
|
|
if (JSON.stringify(sync) !== JSON.stringify(hybrid)) {
|
|
failures.push(`${testCase.name} topK=${testCase.topK}`)
|
|
}
|
|
if (failures.length > 5) break
|
|
}
|
|
assert.deepEqual(failures, [], `hybrid≠sync for: ${failures.join(', ')}`)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
test('A3b: hybrid reports why it degraded, so "no vectors" is visible not silent', async () => {
|
|
const store = seededStore()
|
|
try {
|
|
const noEmbedder = await recallBlocksHybrid(store, null, '4324', { topK: 1 })
|
|
// The result array carries an attached diagnosis rather than a second shape.
|
|
assert.equal(noEmbedder.degraded, 'no_embedder', 'null embedder must be reported')
|
|
assert.ok(noEmbedder.length >= 1, 'and must still recall via the symbol path')
|
|
|
|
const failing = {
|
|
async embedText() {
|
|
throw new Error('boom')
|
|
},
|
|
info: () => ({ fingerprint: 'x', dimension: 3 }),
|
|
}
|
|
const withError = await recallBlocksHybrid(store, failing, '4324', { topK: 1 })
|
|
assert.equal(withError.degraded, 'embed_error', 'a failing embedder must be reported')
|
|
assert.ok(withError.length >= 1, 'and must still fall back to FTS5')
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
// ── mutations ─────────────────────────────────────────────────────────────
|
|
test('A2 mutation: any scoring change must break the golden fixture', () => {
|
|
const store = seededStore()
|
|
try {
|
|
// Reproduce what a careless phase-B edit does: nudge the non-exact band.
|
|
// We cannot mutate lib code from a test, so instead verify the fixture is
|
|
// *sensitive* to a one-ULP change by comparing a deliberately altered score.
|
|
const testCase = GOLDEN.cases.find((c) => c.hits.length > 0)
|
|
const tampered = testCase.hits.map((h, i) =>
|
|
i === 0 ? { ...h, score: h.score + Number.EPSILON } : h,
|
|
)
|
|
const original = testCase.hits
|
|
assert.notEqual(
|
|
JSON.stringify(tampered),
|
|
JSON.stringify(original),
|
|
'a 1-ULP score change must be observable — otherwise exact comparison is meaningless',
|
|
)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
test('A3 mutation: a wrapper that adds any suffix to `why` must break A3', async () => {
|
|
// Simulate the classic drift: the hybrid path decorates `why` for debugging.
|
|
const store = seededStore()
|
|
try {
|
|
const sync = recallBlocks(store, '4324', { topK: 1 })
|
|
const decorated = sync.map((h) => ({ ...h, why: `${h.why}|via-hybrid` }))
|
|
assert.notEqual(
|
|
JSON.stringify(sync),
|
|
JSON.stringify(decorated),
|
|
'A3 compares `why` deliberately: a decorated copy must be detectable',
|
|
)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
// ── the SUCCESS path's label (group A: vector computed, not yet used) ──────
|
|
//
|
|
// The earlier A3b only pinned the two failure labels. Changing the success label
|
|
// back to 'hybrid' left all 76 tests green — a label nobody asserts is a label
|
|
// that will drift. It matters because group V must distinguish "vector computed
|
|
// but not in scoring" (group A) from "vector actually participating" (group V);
|
|
// if both say 'hybrid', nobody can tell which build they are looking at.
|
|
test('A3c: a healthy embedder reports "vector_ready_unused", NOT "hybrid"', async () => {
|
|
const store = seededStore()
|
|
try {
|
|
let embedCalls = 0
|
|
const healthy = {
|
|
async embedText() {
|
|
embedCalls += 1
|
|
return [0.1, 0.2, 0.3]
|
|
},
|
|
info: () => ({ fingerprint: 'fp-healthy', dimension: 3 }),
|
|
}
|
|
const hits = await recallBlocksHybrid(store, healthy, '4324', { topK: 1 })
|
|
|
|
assert.equal(embedCalls, 1, 'the embedder must actually be called')
|
|
assert.equal(
|
|
hits.degraded,
|
|
'vector_ready_unused',
|
|
'group A computes the vector but does not score with it — the label must say that, ' +
|
|
'because group V will need a different label once vectors really participate',
|
|
)
|
|
assert.notEqual(hits.degraded, 'hybrid', "calling it 'hybrid' would be a lie until group V lands")
|
|
// And the result is still the pure-symbol ordering.
|
|
assert.equal(hits[0].text.includes('4324'), true)
|
|
} finally {
|
|
store.close()
|
|
}
|
|
})
|
|
|
|
test('A3c mutation: labelling the success path "hybrid" must fail', () => {
|
|
// Mirrors the exact one-line edit that used to slip through: with no assertion
|
|
// on this value, reverting the rename was invisible.
|
|
const LABELS = { groupA: 'vector_ready_unused', groupV: 'hybrid' }
|
|
// The test above asserts hits.degraded === LABELS.groupA. Prove that swapping
|
|
// the constant would break it:
|
|
assert.notEqual(LABELS.groupA, LABELS.groupV)
|
|
const simulatedDeployed = LABELS.groupV // what the mutated lib returns
|
|
assert.throws(
|
|
() => assert.equal(simulatedDeployed, LABELS.groupA),
|
|
(err) => err.code === 'ERR_ASSERTION',
|
|
'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 }) }
|
|
})
|