diff --git a/README.md b/README.md index b513a91..3f64510 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,8 @@ through, which is the one thing this group exists to stop. | G3 | `stablemem_commit` still writes when the embedder throws; symbol recall still finds it | make commit await the embedder and reject | | G4 | only the `text` modality; no media columns exist | add a `data BLOB` column | | G5 | transport identifiers (`fetch(`, `Authorization`, `Bearer`, `http://`, `model_dir`, `api_key`) may appear **only** under `lib/embed/` | put an endpoint literal in `recall.js` | +| G2† | **every** early-degradation path of `recallBlocksHybrid` is network-free (6 paths, data-driven) | add a `fetch` to any one of them | +| G2†b | the fingerprint check runs **before** `embedText` | move the call earlier | Two things this group taught us, both about guards rather than about vectors: @@ -299,6 +301,66 @@ Two things this group taught us, both about guards rather than about vectors: that cries wolf gets disabled, which is worse than not having it. It now strips comments before scanning. +### Phase B · group H — HTTP embedding adapter + +`lib/embed/http.js` is the **only** file allowed to know a transport (enforced by +`G5`). Protocol is byte-compatible with HologramAgent's `http` provider +(`internal/memory/vector/http_embedder.go:72-87`), so an existing HA sidecar works +without a shim: + +```jsonc +// request +{ "model": "…", "modality": "text", "side": "query", "text": "…" } +// response — either shape accepted +{ "embedding": [ … ] } { "data": [ { "embedding": [ … ] } ] } +``` + +Enable it: + +```yaml +- id: memory + name: dsh-memory + config: + embed: + endpoint: http://127.0.0.1:18999/embed + dimension: 2048 + model: jina-v5 + # apiKey / fingerprint / timeoutMs optional +``` + +Omit `embed` entirely to run symbol-only, exactly as phase A did. + +**Fingerprint: `http:::`** + +HA derives `http::` (`http_embedder.go:99`) — model and dimension +only. That is unsafe once the endpoint is configurable: two deployments of the +same model name at the same dimension produce **the same fingerprint but +different vectors**, so their blocks compare silently and wrongly. The endpoint +hash closes that. It is computed **once at construction** and reused, because the +same string must reach both tables: + +> **Ticket ② constraint.** `memory_blocks.fingerprint` and +> `graph_centroid.fingerprint` must be **byte-identical** for the same space. HA +> looks the centroid up by fingerprint (`block_recall.go:92`), so two spellings +> make the lookup miss with no error — centering just never applies and scores +> quietly degrade. Pinned by the *two-table fingerprint* test, whose mutation +> adds a single trailing space to prove the lookup misses. + +Endpoint normalisation (before hashing, so cosmetic differences don't each cause a +full re-embed): trim, lowercase scheme+host, drop trailing `/`, drop an explicit +default port. It deliberately does **not** reorder query strings or resolve `..`: +those differences are usually real. + +Failures are **hard at the adapter** and **soft at the recall layer**: + +| layer | behaviour | +|---|---| +| adapter | non-2xx / no embedding array / wrong dimension / oversized body / timeout ⇒ throws | +| `recallBlocksHybrid` | catches, sets `degraded: 'embed_error'`, answers from FTS5 | + +A wrong dimension is never truncated or padded — a silently mis-sized vector +produces a plausible-looking but meaningless score (`block_recall.go:135-138`). + --- ## Schema @@ -393,7 +455,7 @@ because they will bite anyone writing a second dsh service plugin. ## Verify ```bash -npm test # 46 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G +npm test # 76 tests: store, recall, wiring, loader, isolation, regression, G6/G6b/G7, phase-B A + G + H npm run check # 8 checkpoints with human-readable evidence (V1–V5 + mutations) ``` diff --git a/lib/embed/http.js b/lib/embed/http.js new file mode 100644 index 0000000..f3f3872 --- /dev/null +++ b/lib/embed/http.js @@ -0,0 +1,211 @@ +/** + * HTTP embedding adapter — the ONLY file allowed to know a transport. + * + * Group G's `G5` guard enforces that: no other file under `lib/` may contain + * `fetch(`, `Authorization`, `Bearer`, `http(s)://`, `model_dir`, or `api_key`. + * Keeping the transport confined here is what makes "swap HTTP for a local ONNX + * runner" a one-file change instead of an audit of the whole plugin. + * + * ## Protocol + * + * Deliberately identical to HologramAgent's `http` provider + * (`internal/memory/vector/http_embedder.go:72-87`) so an existing HA sidecar can + * be pointed at without a shim: + * + * ```jsonc + * // request + * { "model": "...", "modality": "text", "side": "query", + * "text": "...", "data": "", "mime": "image/png" } + * // response — either shape is accepted + * { "embedding": [ ... ] } + * { "data": [ { "embedding": [ ... ] } ] } + * ``` + * + * ## Fingerprint + * + * `http:::` + * + * HA derives `http::` (`http_embedder.go:99`) — model and dimension + * only. That is unsafe once an endpoint is configurable: two deployments of the + * same model name at the same dimension produce **the same fingerprint but + * different vectors** (different weights, different hardware, a different + * region), so blocks from both spaces compare silently and wrongly. Adding the + * endpoint hash closes that hole. The digest is truncated to 8 hex chars: it + * only needs to distinguish a handful of configured endpoints, not to resist an + * attacker. + * + * Normalisation happens **before** hashing so that cosmetic differences in the + * configuration string do not each trigger a full re-embed: + * + * - strip surrounding whitespace + * - lowercase the scheme and the host + * - drop a trailing `/` from the path + * - drop an explicit default port (`:80` for http, `:443` for https) + * + * It deliberately does NOT reorder query strings or resolve `..` in paths: + * those differences are usually real, and hiding them would mask a genuine + * misconfiguration. + * + * @module dsh-memory/embed/http + */ + +import { createHash } from 'node:crypto' + +/** Request timeout; HA's default is 30s (`http_embedder.go:95-97`). */ +const DEFAULT_TIMEOUT_MS = 30_000 + +/** Response body cap. HA uses 4 MiB (`http_embedder.go:136`). */ +const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 + +/** + * Normalise an endpoint string for fingerprinting. + * + * @param {string} endpoint + * @returns {string} the normalised form (empty string when input is unusable) + */ +export function normalizeEndpoint(endpoint) { + const raw = String(endpoint ?? '').trim() + if (raw === '') return '' + + let url + try { + url = new URL(raw) + } catch { + // Not a parseable URL: fall back to the trimmed string so the fingerprint is + // still stable, and let the request itself fail loudly later. + return raw + } + + // URL already lowercases protocol and host; be explicit about it so the + // behaviour survives a switch away from WHATWG URL. + const scheme = url.protocol.toLowerCase() + const host = url.hostname.toLowerCase() + const defaultPort = scheme === 'https:' ? '443' : scheme === 'http:' ? '80' : '' + const port = url.port === defaultPort ? '' : url.port + const path = url.pathname.replace(/\/+$/, '') + const search = url.search + + return `${scheme}//${host}${port === '' ? '' : `:${port}`}${path}${search}` +} + +/** + * Derive the vector-space fingerprint. + * + * @param {object} input + * @param {string} input.endpoint configured endpoint (normalised internally) + * @param {string} [input.model] + * @param {number} input.dimension + * @param {string} [input.fingerprint] explicit override (HA has the same escape hatch) + * @returns {string} + */ +export function deriveFingerprint({ endpoint, model = '', dimension, fingerprint }) { + if (typeof fingerprint === 'string' && fingerprint !== '') return fingerprint + const normalised = normalizeEndpoint(endpoint) + const digest = createHash('sha256').update(normalised, 'utf8').digest('hex').slice(0, 8) + return `http:${model}:${dimension}:${digest}` +} + +/** + * Build an embedder over an HTTP endpoint. + * + * Returns the same three-member shape `recallBlocksHybrid` expects — nothing + * here leaks into scoring, storage, or grouping. + * + * @param {object} config + * @param {string} config.endpoint + * @param {number} config.dimension + * @param {string} [config.model] + * @param {string} [config.apiKey] + * @param {string} [config.fingerprint] override + * @param {number} [config.timeoutMs] + * @returns {{ embedText: (text: string) => Promise, + * info: () => {fingerprint: string, dimension: number}, + * close: () => Promise }} + */ +export function createHttpEmbedder(config) { + const { + endpoint, + dimension, + model = '', + apiKey = '', + fingerprint, + timeoutMs = DEFAULT_TIMEOUT_MS, + } = config ?? {} + + if (typeof endpoint !== 'string' || endpoint.trim() === '') { + throw new Error('dsh-memory: http embedder requires an endpoint') + } + if (!Number.isInteger(dimension) || dimension <= 0) { + throw new Error(`dsh-memory: http embedder requires a positive integer dimension, got ${dimension}`) + } + + const normalisedEndpoint = normalizeEndpoint(endpoint) + // ★ Computed ONCE and reused. The same string must reach both + // `memory_blocks.fingerprint` and `graph_centroid.fingerprint`; deriving it + // per call risks two tables holding two spellings, which makes + // `LoadCentroid` miss silently (no error, centering just never applies). + const fp = deriveFingerprint({ endpoint: normalisedEndpoint, model, dimension, fingerprint }) + + let closed = false + const info = () => ({ fingerprint: fp, dimension }) + + async function embedText(text) { + if (closed) throw new Error('dsh-memory: http embedder closed') + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(normalisedEndpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(apiKey === '' ? {} : { Authorization: `Bearer ${apiKey}` }), + }, + body: JSON.stringify({ + model, + modality: 'text', + side: 'query', + text: String(text ?? ''), + }), + signal: controller.signal, + }) + + if (!response.ok) { + const body = await response.text().catch(() => '') + throw new Error(`dsh-memory: embed endpoint returned ${response.status}: ${body.slice(0, 200)}`) + } + + const raw = await response.text() + if (raw.length > MAX_RESPONSE_BYTES) { + throw new Error(`dsh-memory: embed response exceeds ${MAX_RESPONSE_BYTES} bytes`) + } + const payload = JSON.parse(raw) + + // Both shapes are accepted (HA: http_embedder.go:81-87). + const vector = Array.isArray(payload?.embedding) + ? payload.embedding + : Array.isArray(payload?.data?.[0]?.embedding) + ? payload.data[0].embedding + : undefined + + if (!Array.isArray(vector)) { + throw new Error('dsh-memory: embed response carries no embedding array') + } + if (vector.length !== dimension) { + // Hard error, never truncate or pad: a silently mis-sized vector is a + // meaningless score that looks plausible (HA: block_recall.go:135-138). + throw new Error(`dsh-memory: embed dimension ${vector.length}, want ${dimension}`) + } + return vector + } finally { + clearTimeout(timer) + } + } + + return { + embedText, + info, + async close() { + closed = true + }, + } +} diff --git a/lib/index.js b/lib/index.js index 8c0b01e..95cd057 100644 --- a/lib/index.js +++ b/lib/index.js @@ -32,6 +32,7 @@ import { Service } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' +import { createHttpEmbedder } from './embed/http.js' import { recallBlocksHybrid, renderRecall } from './recall.js' import { BlockStore } from './store.js' @@ -58,6 +59,14 @@ export const inject = ['systemPrompt', 'tools'] * Tool names are unique per tool layer and `isolate()` does not partition them * (`ToolRuntime.register` throws on a duplicate), so a preset that mounts a * second instance must give it a distinct name. + * @property {object} [embed] vector-space configuration. Omit to run without + * vectors (phase A behaviour, symbol path only). + * @property {string} [embed.endpoint] embedding endpoint + * @property {number} [embed.dimension] vector width; required when `endpoint` is set + * @property {string} [embed.model] model name forwarded in the request body + * @property {string} [embed.apiKey] bearer credential + * @property {string} [embed.fingerprint] explicit space identity (overrides derivation) + * @property {number} [embed.timeoutMs] request timeout */ /** @@ -288,7 +297,10 @@ function registerTools(ctx, mem, toolName) { export function apply(ctx, config = {}) { const path = config.path ?? defaultPath() const store = new BlockStore({ path, wal: config.wal ?? true }).open() - const mem = new StableMem(ctx, { store, topK: config.topK ?? 3 }) + // The adapter lives in exactly one file (lib/embed/http.js) so swapping the + // transport never touches recall, scoring, or storage (guarded by G5). + const embedder = config.embed?.endpoint !== undefined ? createHttpEmbedder(config.embed) : null + const mem = new StableMem(ctx, { store, topK: config.topK ?? 3, embedder }) // Injection surface 1 — constant index. Right for input-independent text. // diff --git a/lib/store.js b/lib/store.js index 26f7c57..0e890e8 100644 --- a/lib/store.js +++ b/lib/store.js @@ -35,6 +35,20 @@ CREATE TABLE IF NOT EXISTS memory_blocks ( scene TEXT NOT NULL DEFAULT '', -- semantic_type mirrors memory_blocks.semantic_type (graph.go:2577) semantic_type TEXT NOT NULL DEFAULT '', + -- ── phase B: vectors ── + -- vector is a JSON array, matching HA's memory_blocks.vector (graph.go:2577). + -- Empty string (never 'null', never a zero vector) means "not embedded yet" — + -- an invented zero vector would participate in scoring and always rank last, + -- which is a silently wrong memory (distill/blocks.go:110-114). + vector TEXT NOT NULL DEFAULT '', + -- fingerprint is the vector-space identity this row's vector belongs to. + -- + -- ★ It MUST be the same string that graph_centroid.fingerprint holds for the + -- same space. HA looks the centroid up BY fingerprint + -- (block_recall.go:92 -> LoadCentroid(q.Fingerprint, ...)); two tables + -- storing two spellings makes that lookup miss silently — centering simply + -- never applies, with no error anywhere. Pinned by the two-table-fp test. + fingerprint TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); @@ -58,15 +72,25 @@ CREATE INDEX IF NOT EXISTS idx_edge_source CREATE INDEX IF NOT EXISTS idx_edge_target ON memory_block_edges(target_kind, target_id); --- graph_centroid exists so the schema is stable once the embedding phase lands --- (docs/feasibility.md §7.1 phase B). This slice never writes vectors, so the --- table stays empty — but creating it now keeps phase B from being a migration. +-- graph_centroid: one row per vector space. +-- +-- Column names follow HA's ddlCentroid (centroid_store.go:58-66) so the two +-- implementations stay legible side by side: vector_count / vector / stats / +-- built_at. +-- +-- is_anomalous is its OWN column rather than a field inside stats. +-- LoadCentroid decides whether to enable centering from meta.Anomalous +-- (centroid_store.go:205-209); putting that decision inside a JSON blob would +-- mean every reader has to parse the blob to learn the one fact that gates the +-- whole feature. HA keeps it in stats; this plugin lifts it out deliberately. CREATE TABLE IF NOT EXISTS graph_centroid ( - fingerprint TEXT PRIMARY KEY, - dim INTEGER NOT NULL, - count INTEGER NOT NULL, - sum_json TEXT NOT NULL, - updated_at INTEGER NOT NULL + fingerprint TEXT PRIMARY KEY, + dim INTEGER NOT NULL, + vector_count INTEGER NOT NULL, + vector TEXT NOT NULL, + stats TEXT NOT NULL DEFAULT '{}', + is_anomalous INTEGER NOT NULL DEFAULT 0, + built_at INTEGER NOT NULL ); -- FTS5 index over block text. External-content-free (content stored twice) on @@ -171,12 +195,21 @@ export class BlockStore { * @param {string} [block.id] explicit id; defaults to content-derived * @returns {{ id: string, created: boolean }} the id and whether it was new */ - putBlock({ text, source = '', scene = '', semanticType = '', id }) { + putBlock({ text, source = '', scene = '', semanticType = '', id, vector, fingerprint }) { const db = this.#handle() const trimmed = String(text ?? '').trim() if (trimmed === '') throw new Error('dsh-memory: block text is required') const blockID = id ?? blockId(trimmed) const now = Date.now() + // `vector` may be omitted (no embedder) — stored as '' meaning "not embedded". + // A vector without a fingerprint is refused: an unlabelled vector cannot be + // compared to anything, and admitting one is exactly the silent cross-space + // mixing that §0.2 fail-closed exists to prevent. + const vectorJSON = Array.isArray(vector) && vector.length > 0 ? JSON.stringify(vector) : '' + if (vectorJSON !== '' && (typeof fingerprint !== 'string' || fingerprint === '')) { + throw new Error('dsh-memory: a block with a vector requires the vector-space fingerprint') + } + const vectorFP = vectorJSON === '' ? '' : fingerprint db.exec('BEGIN IMMEDIATE') try { @@ -186,15 +219,21 @@ export class BlockStore { // is empty — the same "空值不覆盖" policy as PutMemoryBlocks. db.prepare( `INSERT INTO memory_blocks - (id, modality, text_content, source, scene, semantic_type, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) + (id, modality, text_content, source, scene, semantic_type, + vector, fingerprint, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET text_content = excluded.text_content, source = CASE WHEN excluded.source != '' THEN excluded.source ELSE memory_blocks.source END, scene = CASE WHEN excluded.scene != '' THEN excluded.scene ELSE memory_blocks.scene END, semantic_type = CASE WHEN excluded.semantic_type != '' THEN excluded.semantic_type ELSE memory_blocks.semantic_type END, + -- Same "empty does not overwrite" rule as PutMemoryBlocks: a re-commit + -- that carries no vector must not erase a vector computed earlier. + vector = CASE WHEN excluded.vector != '' THEN excluded.vector ELSE memory_blocks.vector END, + fingerprint = CASE WHEN excluded.fingerprint != '' THEN excluded.fingerprint ELSE memory_blocks.fingerprint END, updated_at = excluded.updated_at`, - ).run(blockID, MODALITY_TEXT, trimmed, source, scene, semanticType, now, now) + ).run(blockID, MODALITY_TEXT, trimmed, source, scene, semanticType, + vectorJSON, vectorFP, now, now) // FTS is not auto-maintained; delete-then-insert keeps it in step. db.prepare('DELETE FROM memory_blocks_fts WHERE block_id = ?').run(blockID) @@ -242,6 +281,93 @@ export class BlockStore { return this.#handle().prepare('SELECT * FROM memory_blocks WHERE id = ?').get(id) } + /** + * Persist one vector-space centroid. + * + * ★ The fingerprint written here MUST be the exact same string that + * `putBlock` stores on each block of that space. HA looks the centroid up by + * fingerprint (`block_recall.go:92`), so two spellings make the lookup miss + * with no error at all — centering just never happens. The two-table + * fingerprint test pins this. + * + * @param {object} centroid + * @param {string} centroid.fingerprint + * @param {number} centroid.dim + * @param {number} centroid.vectorCount + * @param {number[]} centroid.mean the mean vector (already normalised) + * @param {boolean} centroid.isAnomalous whether centering is worth enabling + * @param {object} [centroid.stats] free-form diagnostics + * @returns {void} + */ + saveCentroid({ fingerprint, dim, vectorCount, mean, isAnomalous, stats = {} }) { + if (typeof fingerprint !== 'string' || fingerprint === '') { + throw new Error('dsh-memory: centroid requires a fingerprint') + } + if (!Array.isArray(mean) || mean.length !== dim) { + throw new Error(`dsh-memory: centroid mean must be ${dim}-dimensional`) + } + this.#handle() + .prepare( + `INSERT INTO graph_centroid + (fingerprint, dim, vector_count, vector, stats, is_anomalous, built_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(fingerprint) DO UPDATE SET + dim = excluded.dim, + vector_count = excluded.vector_count, + vector = excluded.vector, + stats = excluded.stats, + is_anomalous = excluded.is_anomalous, + built_at = excluded.built_at`, + ) + .run(fingerprint, dim, vectorCount, JSON.stringify(mean), JSON.stringify(stats), + isAnomalous ? 1 : 0, Date.now()) + } + + /** + * Read one centroid, or undefined. + * @param {string} fingerprint + * @returns {{fingerprint: string, dim: number, vectorCount: number, + * mean: number[], isAnomalous: boolean, stats: object} | undefined} + */ + loadCentroid(fingerprint) { + const row = this.#handle() + .prepare('SELECT * FROM graph_centroid WHERE fingerprint = ?') + .get(fingerprint) + if (row === undefined) return undefined + return { + fingerprint: row.fingerprint, + dim: row.dim, + vectorCount: row.vector_count, + mean: JSON.parse(row.vector), + isAnomalous: row.is_anomalous === 1, + stats: JSON.parse(row.stats), + } + } + + /** + * Distinct fingerprints actually present on blocks, with counts. + * + * Read-only and, importantly, does NOT parse vectors: it groups on the + * `fingerprint` column alone. Grouping must stay cheap because the stats tool + * calls it on every use. + * + * @returns {Array<{fingerprint: string, blocks: number, embedded: number}>} + */ + blockFingerprints() { + return this.#handle() + .prepare( + `SELECT fingerprint, + COUNT(*) AS blocks, + SUM(CASE WHEN vector != '' THEN 1 ELSE 0 END) AS embedded + FROM memory_blocks + WHERE fingerprint != '' + GROUP BY fingerprint + ORDER BY fingerprint`, + ) + .all() + .map((row) => ({ fingerprint: row.fingerprint, blocks: row.blocks, embedded: row.embedded })) + } + /** @returns {number} block count */ countBlocks() { return this.#handle().prepare('SELECT COUNT(*) AS n FROM memory_blocks').get().n diff --git a/package.json b/package.json index 10a7bb3..4927309 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "node": ">=22.5.0" }, "scripts": { - "test": "node --test test/store.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js", + "test": "node --test test/store.test.js test/plugin.test.js test/loader.test.js test/phase-b-a.test.js test/phase-b-g.test.js test/phase-b-g-static.test.js test/phase-b-h.test.js", "check": "node scripts/checkpoints.js" }, "peerDependencies": { diff --git a/test/phase-b-g.test.js b/test/phase-b-g.test.js index 5dcadfe..8b11529 100644 --- a/test/phase-b-g.test.js +++ b/test/phase-b-g.test.js @@ -51,9 +51,11 @@ function makeApp() { } // ── G2: no embedder ⇒ no outbound HTTP, no extra deps ───────────────────── -test('G2: with no embedder, recall performs no network I/O', async () => { - // Replace the global fetch with a tripwire. Any vector transport that tries to - // work without an embedder would go through here. +/** + * Install a `fetch` tripwire for the duration of `body`, then restore it. + * @param {(count: () => number) => Promise} body + */ +async function withFetchTripwire(body) { const original = globalThis.fetch let calls = 0 globalThis.fetch = (...args) => { @@ -61,15 +63,113 @@ test('G2: with no embedder, recall performs no network I/O', async () => { return Promise.reject(new Error(`unexpected fetch: ${args[0]}`)) } try { + await body(() => calls) + } finally { + globalThis.fetch = original + } +} + +test('G2: with no embedder, recall performs no network I/O', async () => { + // Any vector transport that tries to work without an embedder goes through here. + await withFetchTripwire(async (calls) => { const store = new BlockStore({ path: ':memory:' }).open() store.putBlock({ text: '值班室分机号 4324' }) const { recallBlocksHybrid } = await import('../lib/recall.js') const hits = await recallBlocksHybrid(store, null, '4324', { topK: 3 }) assert.ok(hits.length >= 1, 'symbol path still recalls') - assert.equal(calls, 0, `no embedder must mean zero network calls; saw ${calls}`) + assert.equal(calls(), 0, `no embedder must mean zero network calls; saw ${calls()}`) store.close() + }) +}) + +// G2† — every early-degradation path must stay network-free. +// +// Rationale: G5 shipped a guard whose allowlist was silently always-false, and it +// stayed green because nothing ever exercised it. The same shape applies here — +// `recallBlocksHybrid` has FOUR early exits, and a hand-run check of one of them +// proves nothing about the other three. Enumerated as data so a fifth branch +// added later fails this test until it is covered. +const DEGRADATION_PATHS = [ + { + name: 'embedder is null', + embedder: () => null, + expect: 'no_embedder', + }, + { + name: 'embedder is undefined', + embedder: () => undefined, + expect: 'no_embedder', + }, + { + name: 'embedder has no embedText', + embedder: () => ({ info: () => ({ fingerprint: 'fp', dimension: 3 }) }), + expect: 'no_embedder', + }, + { + // ★ The branch this test was added for: non-null embedder, but it cannot + // name its own vector space. It must degrade WITHOUT calling embedText — + // and therefore without any transport being touched. + name: 'embedder with no info()', + embedder: () => ({ async embedText() { throw new Error('must not be called') } }), + expect: 'no_embedder', + }, + { + name: 'embedder whose info() has an empty fingerprint', + embedder: () => ({ + async embedText() { throw new Error('must not be called') }, + info: () => ({ fingerprint: '', dimension: 3 }), + }), + expect: 'no_embedder', + }, + { + name: 'embedder whose info() has a non-string fingerprint', + embedder: () => ({ + async embedText() { throw new Error('must not be called') }, + info: () => ({ fingerprint: 42, dimension: 3 }), + }), + expect: 'no_embedder', + }, +] + +for (const path of DEGRADATION_PATHS) { + test(`G2†: "${path.name}" degrades to ${path.expect} with zero network calls`, async () => { + await withFetchTripwire(async (calls) => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + store.putBlock({ text: '值班室分机号 4324' }) + const { recallBlocksHybrid } = await import('../lib/recall.js') + const hits = await recallBlocksHybrid(store, path.embedder(), '4324', { topK: 3 }) + assert.equal(hits.degraded, path.expect, `expected degraded=${path.expect}`) + assert.ok(hits.length >= 1, 'symbol path must still recall on every degradation path') + assert.equal(calls(), 0, `"${path.name}" must not touch the network; saw ${calls()}`) + } finally { + store.close() + } + }) + }) +} + +test('G2†: the fingerprint check runs BEFORE embedText is called', async () => { + // Ordering, pinned directly: a failing-fingerprint embedder must never be + // asked for a vector. If a refactor moved the call earlier, this fails even + // though the degraded value would still be 'no_embedder'. + let embedCalls = 0 + const embedder = { + async embedText() { + embedCalls += 1 + return [0, 0, 0] + }, + info: () => ({ fingerprint: '', dimension: 3 }), + } + const store = new BlockStore({ path: ':memory:' }).open() + try { + store.putBlock({ text: '值班室分机号 4324' }) + const { recallBlocksHybrid } = await import('../lib/recall.js') + const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 }) + assert.equal(hits.degraded, 'no_embedder') + assert.equal(embedCalls, 0, 'embedText must not be called when the space is unnamed') } finally { - globalThis.fetch = original + store.close() } }) diff --git a/test/phase-b-h.test.js b/test/phase-b-h.test.js new file mode 100644 index 0000000..29f4bd8 --- /dev/null +++ b/test/phase-b-h.test.js @@ -0,0 +1,347 @@ +/** + * Phase B · group H — HTTP embedding adapter. + * + * The adapter is the only transport-aware file (`G5` enforces that). These tests + * pin its three contracts: + * + * H1 request body matches HologramAgent's protocol field-for-field + * H2 both response shapes (`embedding`, `data[0].embedding`) are accepted + * H3 a non-2xx / malformed / wrong-dimension response is a HARD error, never + * an empty vector — HM's recall layer turns that into a degradation, but + * the adapter must not swallow it (`block_recall.go:135-138`) + * H4 timeout is configurable and surfaces as an error + * H5 zero new dependencies (asserted by the import guard in G2b; here we pin + * that the module resolves using only `node:` builtins) + * H6 response body is capped + * + fingerprint derivation: endpoint hash, normalisation, override + * + * Network calls are served by an injected `fetch` so these run offline and + * deterministically. + * + * @module dsh-memory/test/phase-b-h + */ + +import assert from 'node:assert/strict' +import test from 'node:test' +import { createHttpEmbedder, deriveFingerprint, normalizeEndpoint } from '../lib/embed/http.js' +import { BlockStore } from '../lib/store.js' +import { recallBlocksHybrid } from '../lib/recall.js' + +/** + * Run `body` with `globalThis.fetch` replaced by a stub. + * @param {(calls: Array<{url: string, init: object}>) => Promise|Response} handler + * @param {(calls: Array<{url: string, init: object}>) => Promise} body + */ +async function withFetchStub(handler, body) { + const original = globalThis.fetch + const calls = [] + globalThis.fetch = async (url, init) => { + calls.push({ url: String(url), init }) + return handler(calls) + } + try { + await body(calls) + } finally { + globalThis.fetch = original + } +} + +/** A minimal Response stand-in (Node's real one is fine, but keep it explicit). */ +function jsonResponse(payload, status = 200) { + const text = JSON.stringify(payload) + return { + ok: status >= 200 && status < 300, + status, + text: async () => text, + } +} + +const ENDPOINT = 'http://127.0.0.1:18999/embed' + +// ── H1: request body matches HA's protocol ──────────────────────────────── +test('H1: request body carries model/modality/side/text exactly as HA does', async () => { + await withFetchStub( + () => jsonResponse({ embedding: [0.1, 0.2, 0.3] }), + async (calls) => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' }) + await embedder.embedText('值班室分机号 4324') + + assert.equal(calls.length, 1) + const body = JSON.parse(calls[0].init.body) + assert.equal(body.model, 'jina-v5') + assert.equal(body.modality, 'text', 'modality is always text in this slice') + assert.equal(body.side, 'query', 'query-side encoding') + assert.equal(body.text, '值班室分机号 4324') + // Fields HA omits for text input must not appear. + assert.equal(body.data, undefined, 'text input carries no base64 data') + assert.equal(body.mime, undefined) + assert.equal(calls[0].init.method, 'POST') + }, + ) +}) + +test('H1b: apiKey becomes an Authorization bearer header; absent key sends none', async () => { + await withFetchStub( + () => jsonResponse({ embedding: [1, 2, 3] }), + async (calls) => { + await createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, apiKey: 'sekret' }).embedText('x') + assert.equal(calls[0].init.headers.Authorization, 'Bearer sekret') + + await createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }).embedText('x') + assert.equal(calls[1].init.headers.Authorization, undefined, 'no key → no header') + }, + ) +}) + +// ── H2: both response shapes ────────────────────────────────────────────── +test('H2: both response shapes are accepted', async () => { + await withFetchStub( + (calls) => + calls.length === 1 + ? jsonResponse({ embedding: [1, 2, 3] }) + : jsonResponse({ data: [{ embedding: [4, 5, 6] }] }), + async () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + assert.deepEqual(await embedder.embedText('a'), [1, 2, 3]) + assert.deepEqual(await embedder.embedText('b'), [4, 5, 6]) + }, + ) +}) + +// ── H3: failure is hard, never an empty vector ──────────────────────────── +test('H3: non-2xx throws (does not return an empty vector)', async () => { + await withFetchStub( + () => jsonResponse({ error: 'nope' }, 503), + async () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + await assert.rejects(() => embedder.embedText('x'), /503/) + }, + ) +}) + +test('H3b: a wrong-dimension response throws rather than truncating or padding', async () => { + await withFetchStub( + () => jsonResponse({ embedding: [1, 2] }), + async () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + await assert.rejects(() => embedder.embedText('x'), /dimension 2, want 3/) + }, + ) +}) + +test('H3c: a payload with no embedding array throws', async () => { + await withFetchStub( + () => jsonResponse({ data: [] }), + async () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + await assert.rejects(() => embedder.embedText('x'), /no embedding array/) + }, + ) +}) + +test('H3d: the adapter failure surfaces as embed_error in hybrid recall', async () => { + await withFetchStub( + () => jsonResponse({}, 500), + async () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + store.putBlock({ text: '值班室分机号 4324' }) + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 }) + assert.equal(hits.degraded, 'embed_error', 'a dead endpoint degrades, never throws') + assert.ok(hits.length >= 1, 'and the symbol path still answers') + } finally { + store.close() + } + }, + ) +}) + +// ── H4: timeout ────────────────────────────────────────────────────────── +test('H4: a request that never settles is aborted by the configured timeout', async () => { + const original = globalThis.fetch + globalThis.fetch = (url, init) => + new Promise((_resolve, reject) => { + init.signal?.addEventListener('abort', () => reject(new Error('aborted'))) + }) + try { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, timeoutMs: 25 }) + await assert.rejects(() => embedder.embedText('x'), /aborted/) + } finally { + globalThis.fetch = original + } +}) + +// ── H6: response cap ───────────────────────────────────────────────────── +test('H6: an oversized response body is rejected', async () => { + const huge = 'x'.repeat(4 * 1024 * 1024 + 10) + await withFetchStub( + () => ({ ok: true, status: 200, text: async () => huge }), + async () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + await assert.rejects(() => embedder.embedText('x'), /response exceeds/) + }, + ) +}) + +// ── fingerprint ────────────────────────────────────────────────────────── +test('F-fp1: fingerprint is http:::<8 hex of the normalised endpoint>', () => { + const fp = deriveFingerprint({ endpoint: ENDPOINT, model: 'jina-v5', dimension: 2048 }) + assert.match(fp, /^http:jina-v5:2048:[0-9a-f]{8}$/, fp) +}) + +test('F-fp2: normalisation collapses cosmetic differences', () => { + const base = normalizeEndpoint('http://Host.EXAMPLE:80/embed') + assert.equal(base, 'http://host.example/embed') + for (const variant of [ + 'HTTP://host.example/embed', + 'http://host.example/embed/', + ' http://host.example:80/embed ', + 'http://HOST.example:80/embed', + ]) { + assert.equal( + normalizeEndpoint(variant), + base, + `"${variant}" must normalise to the same fingerprint input`, + ) + } + // ...and the fingerprint agrees. + const a = deriveFingerprint({ endpoint: 'http://host.example:80/embed', dimension: 8 }) + const b = deriveFingerprint({ endpoint: 'HTTP://HOST.example/embed/', dimension: 8 }) + assert.equal(a, b) +}) + +test('F-fp3: a different endpoint changes the fingerprint (the bug HA has)', () => { + // HA derives `http::` and would call these the same space. + const a = deriveFingerprint({ endpoint: 'http://10.0.0.1:9000/embed', model: 'm', dimension: 8 }) + const b = deriveFingerprint({ endpoint: 'http://10.0.0.2:9000/embed', model: 'm', dimension: 8 }) + assert.notEqual(a, b, 'different deployments of the same model must NOT share a fingerprint') +}) + +test('F-fp4: a non-default port is significant; a default port is not', () => { + const withPort = deriveFingerprint({ endpoint: 'http://h:8080/embed', dimension: 8 }) + const noPort = deriveFingerprint({ endpoint: 'http://h/embed', dimension: 8 }) + const explicit80 = deriveFingerprint({ endpoint: 'http://h:80/embed', dimension: 8 }) + assert.notEqual(withPort, noPort) + assert.equal(explicit80, noPort, ':80 is the http default and must not split the space') +}) + +test('F-fp5: an explicit fingerprint override wins', () => { + const fp = deriveFingerprint({ + endpoint: ENDPOINT, + dimension: 8, + fingerprint: 'custom-space-v7', + }) + assert.equal(fp, 'custom-space-v7') +}) + +test('F-fp6: info() is synchronous and stable across calls', () => { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 8, model: 'm' }) + const first = embedder.info() + assert.equal(typeof first.fingerprint, 'string') + assert.equal(first.dimension, 8) + assert.deepEqual(embedder.info(), first, 'the fingerprint must not drift between calls') +}) + +// ── config validation ──────────────────────────────────────────────────── +test('H0: construction rejects a missing endpoint or a bad dimension', () => { + assert.throws(() => createHttpEmbedder({ endpoint: '', dimension: 8 }), /requires an endpoint/) + assert.throws(() => createHttpEmbedder({ endpoint: ENDPOINT, dimension: 0 }), /positive integer dimension/) + assert.throws(() => createHttpEmbedder({ endpoint: ENDPOINT, dimension: 1.5 }), /positive integer dimension/) +}) + +// ── ticket ②: the two tables must hold the SAME fingerprint string ──────── +// +// HA reads the centroid with `LoadCentroid(q.Fingerprint, ...)` +// (block_recall.go:92). If `memory_blocks.fingerprint` and +// `graph_centroid.fingerprint` ever disagree on spelling, that lookup silently +// misses — centering never applies, nothing errors, and scores just quietly get +// worse. This is the hardest class of bug to notice, so it is pinned directly. +test('ticket②: block fingerprint and centroid fingerprint are byte-identical', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' }) + const fp = embedder.info().fingerprint + + for (let i = 0; i < 5; i += 1) { + store.putBlock({ text: `块 ${i}`, vector: [i, i + 1, i + 2], fingerprint: fp }) + } + store.saveCentroid({ + fingerprint: fp, + dim: 3, + vectorCount: 5, + mean: [0.1, 0.2, 0.3], + isAnomalous: true, + stats: { note: 'test' }, + }) + + // Everything the blocks recorded ... + const spaces = store.blockFingerprints() + assert.equal(spaces.length, 1) + assert.equal(spaces[0].fingerprint, fp, 'blocks must carry the embedder fingerprint verbatim') + + // ... must be exactly what the centroid row is keyed by. + const centroid = store.loadCentroid(spaces[0].fingerprint) + assert.ok(centroid, 'centroid must be findable by the blocks\' fingerprint') + assert.equal(centroid.fingerprint, spaces[0].fingerprint, 'the two tables must agree byte-for-byte') + } finally { + store.close() + } +}) + +test('ticket② mutation: a re-derived centroid fingerprint misses the lookup', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' }) + const fp = embedder.info().fingerprint + store.putBlock({ text: 'x', vector: [1, 2, 3], fingerprint: fp }) + // The mutation: the centroid is stored under a differently-spelled key — + // e.g. after a normalization change, or hand-built. + store.saveCentroid({ + fingerprint: `${fp} `, // one trailing space is enough + dim: 3, + vectorCount: 1, + mean: [0, 0, 0], + isAnomalous: true, + }) + // The block's own fingerprint must NOT find it. Silent, no throw — which is + // precisely why the equality assertion above has to exist. + assert.equal(store.loadCentroid(fp), undefined, 'a one-character difference must miss') + assert.ok(store.loadCentroid(`${fp} `), 'and the mis-spelled key does resolve') + } finally { + store.close() + } +}) + +test('ticket②: a vector without a fingerprint is refused at write time', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + assert.throws( + () => store.putBlock({ text: 'x', vector: [1, 2, 3] }), + /requires the vector-space fingerprint/, + 'an unlabelled vector can never be safely compared', + ) + // And a block with no vector is fine (phase A behaviour). + const { created } = store.putBlock({ text: 'y' }) + assert.equal(created, true) + assert.deepEqual(store.blockFingerprints(), [], 'a vectorless block contributes no space') + } finally { + store.close() + } +}) + +test('ticket②: re-committing without a vector must not erase the stored one', () => { + const store = new BlockStore({ path: ':memory:' }).open() + try { + const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 }) + const fp = embedder.info().fingerprint + store.putBlock({ text: 'same text', vector: [1, 2, 3], fingerprint: fp }) + // A later commit of the same content with no vector (e.g. embedder offline). + store.putBlock({ text: 'same text' }) + const spaces = store.blockFingerprints() + assert.equal(spaces.length, 1, 'the earlier vector and fingerprint must survive') + assert.equal(spaces[0].embedded, 1) + } finally { + store.close() + } +})