From 06155d4bcc0d9cefb4fd4b6716a5374e4aed36a4 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Sun, 1 Nov 2026 17:21:46 +0800 Subject: [PATCH] feat(memory-b): expose text document side as interface only --- lib/embed/http.js | 20 ++++++- test/phase-b-h.test.js | 119 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 4 deletions(-) diff --git a/lib/embed/http.js b/lib/embed/http.js index 66ce6ff..58dc1ac 100644 --- a/lib/embed/http.js +++ b/lib/embed/http.js @@ -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, * info: () => {fingerprint: string, dimension: number}, * close: () => Promise }} @@ -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, diff --git a/test/phase-b-h.test.js b/test/phase-b-h.test.js index 5f122d4..76a409e 100644 --- a/test/phase-b-h.test.js +++ b/test/phase-b-h.test.js @@ -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]}`) + }) + }) +}