feat(memory-b): expose text document side as interface only

This commit is contained in:
2026-11-01 17:21:46 +08:00
parent 8a87c973d2
commit 06155d4bcc
2 changed files with 135 additions and 4 deletions

View File

@ -118,6 +118,15 @@ export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint
* @param {string} [config.apiKey]
* @param {string} [config.fingerprint] override
* @param {number} [config.timeoutMs]
* @param {'query'|'document'} [config.side] prompt side for TEXT embedding
* (default `query`).
*
* ★ V6 GAP — TEXT + `document` is INTERFACE-ONLY.
* Go assigns `Side: "query"` for text (`internal/memory/vector/http_embedder.go:105`)
* and `Side: "document"` only for images (`:109`). It never sends text with
* `document`, so there is no kernel precedent for what a text-document vector
* means, and no criterion here claims one. This option pins the WIRE SHAPE the
* adapter can produce; the SEMANTICS are deliberately unasserted.
* @returns {{ embedText: (text: string, signal?: AbortSignal) => Promise<number[]>,
* info: () => {fingerprint: string, dimension: number},
* close: () => Promise<void> }}
@ -130,8 +139,15 @@ export function createHttpEmbedder(config) {
apiKey = '',
fingerprint,
timeoutMs = DEFAULT_TIMEOUT_MS,
side = 'query',
} = config ?? {}
// Only the two sides Go defines are accepted (`pkg/embedding/embedding.go:34-35`).
// Anything else would be an invented protocol value the provider cannot map.
if (side !== 'query' && side !== 'document') {
throw new Error(`dsh-memory: embed side must be 'query' or 'document', got ${String(side)}`)
}
if (typeof endpoint !== 'string' || endpoint.trim() === '') {
throw new Error('dsh-memory: http embedder requires an endpoint')
}
@ -178,7 +194,9 @@ export function createHttpEmbedder(config) {
body: JSON.stringify({
model,
modality: 'text',
side: 'query',
// go: http_embedder.go:105 (`query`) / :109 (`document`, image only).
// The value travels verbatim; V6 pins the shape, not the semantics.
side,
text: String(text ?? ''),
}),
signal: fused,

View File

@ -1720,9 +1720,15 @@ async function withMutatedCopy(relPath, replacements, body) {
const target = join(dir, 'lib', relPath)
let text = readFileSync(target, 'utf8')
for (const [from, to] of replacements) {
assert.ok(
text.includes(from),
`mutation anchor not found in lib/${relPath} — did the source move?\n${from.slice(0, 100)}`,
// ★ Count BEFORE replacing: `String.replace` substitutes only the first
// match, so a duplicated anchor would silently mutate one site while the
// caller believes all of them changed. V5's `v5Mutant` established this
// check; the shared helper now carries it too.
assert.equal(
text.split(from).length - 1,
1,
`mutation anchor must occur exactly once in lib/${relPath} (found ` +
`${text.split(from).length - 1}): ${from.slice(0, 100)}`,
)
text = text.replace(from, to)
}
@ -3948,3 +3954,110 @@ test('C3-j†: swallowing corrupt metadata bites the unchanged error criterion',
}, 'C3-j† must break the unchanged corrupt-metadata criterion')
})
})
// ── V6: TEXT + `document` is INTERFACE-ONLY (semantics are a declared GAP) ──
//
// Go assigns `Side: "query"` for text (`internal/memory/vector/http_embedder.go:105`)
// and `Side: "document"` only for images (`:109`). It never sends TEXT with
// `document`, so there is no kernel precedent for what such a vector means.
//
// This slice pins only the WIRE SHAPE the adapter can produce: the value travels
// verbatim, and only Go's two enum members are accepted
// (`pkg/embedding/embedding.go:34-35`). Nothing here asserts that a text-document
// vector is semantically right, comparable to a query-side vector, or a separate
// space. Declared GAP — not semantic coverage.
async function captureEmbedRequest(createEmbedder, config, text = 'probe') {
const calls = []
const originalFetch = globalThis.fetch
globalThis.fetch = async (url, init) => {
calls.push({ url: String(url), body: JSON.parse(init.body) })
return new Response(JSON.stringify({ embedding: [1, 0, 0] }), { status: 200 })
}
try {
const embedder = createEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3, ...config })
const vector = await embedder.embedText(text)
await embedder.close()
return { calls, vector }
} finally {
globalThis.fetch = originalFetch
}
}
/** (1) side=document reaches the wire with modality still text. */
async function assertV6DocumentShape(createEmbedder) {
const { calls, vector } = await captureEmbedRequest(createEmbedder, { side: 'document' }, 'document probe')
assert.deepEqual(vector, [1, 0, 0], 'the response vector is returned verbatim')
assert.equal(calls.length, 1, 'one request was sent')
assert.equal(calls[0].body.side, 'document', 'the configured side travels on the wire')
assert.equal(calls[0].body.modality, 'text', 'and the modality stays text')
assert.equal(calls[0].body.text, 'document probe', 'the text payload is unchanged')
}
/** (2) omitting the option keeps the frozen query behaviour. */
async function assertV6DefaultQuery(createEmbedder) {
const { calls } = await captureEmbedRequest(createEmbedder, {}, 'q')
assert.equal(calls[0].body.side, 'query', 'omitting side must keep the frozen query behaviour')
}
/** (3) a value outside Go's enum is refused, never silently sent. */
function assertV6RejectsUnknownSide(createEmbedder) {
assert.throws(
() => createEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3, side: 'passage' }),
(err) => {
assert.match(String(err.message), /embed side must be 'query' or 'document'/)
return true
},
'inventing a protocol value Go does not define must fail loudly',
)
}
test('V6: text embedding can emit side=document on the wire (shape only, semantics GAP)', async () => {
const { createHttpEmbedder } = await import('../lib/embed/http.js')
await assertV6DocumentShape(createHttpEmbedder)
})
test('V6: the default side stays query — accepted behaviour unchanged', async () => {
const { createHttpEmbedder } = await import('../lib/embed/http.js')
await assertV6DefaultQuery(createHttpEmbedder)
})
test("V6: a side outside Go's enum is refused at construction", async () => {
const { createHttpEmbedder } = await import('../lib/embed/http.js')
assertV6RejectsUnknownSide(createHttpEmbedder)
})
for (const [name, anchor, replacement, criterion] of [
[
'side-hardcoded',
' side,\n text: String(text ?? \'\'),',
" side: 'query',\n text: String(text ?? ''), // MUTATED: side ignored",
assertV6DocumentShape,
],
[
'side-default-changed',
" side = 'query',",
" side = 'document', // MUTATED: default flipped",
assertV6DefaultQuery,
],
[
'side-unvalidated',
' if (side !== \'query\' && side !== \'document\') {',
' if (false) { // MUTATED: accept any side value',
assertV6RejectsUnknownSide,
],
]) {
test(`V6† ${name}: the unchanged shape criterion bites red`, async (t) => {
await withMutatedCopy('embed/http.js', [[anchor, replacement]], async (mod) => {
let failure = null
try {
await criterion(mod.createHttpEmbedder)
} catch (error) {
failure = error
}
assert.ok(failure !== null, `V6† ${name} must break the unchanged criterion`)
assert.equal(failure.code, 'ERR_ASSERTION', 'only the criterion may bite')
t.diagnostic(`V6† ${name} RAW RED >>> ${String(failure.message).split('\n')[0]}`)
})
})
}