3530 lines
155 KiB
JavaScript
3530 lines
155 KiB
JavaScript
/**
|
||
* 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 { mkdtempSync, rmSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import test from 'node:test'
|
||
import { DatabaseSync } from 'node:sqlite'
|
||
import { createHttpEmbedder, deriveFingerprint, normalizeEndpoint } from '../lib/embed/http.js'
|
||
import { BlockStore } from '../lib/store.js'
|
||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||
import { degradationNotice, recallBlocks, recallBlocksHybrid, renderRecall } from '../lib/recall.js'
|
||
|
||
/**
|
||
* Run `body` with `globalThis.fetch` replaced by a stub.
|
||
* @param {(calls: Array<{url: string, init: object}>) => Promise<Response>|Response} handler
|
||
* @param {(calls: Array<{url: string, init: object}>) => Promise<void>} 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:<model>:<dim>:<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:<model>:<dim>` 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()
|
||
}
|
||
})
|
||
|
||
// ── F5: empty fingerprint is rejected at WRITE time (fail-closed) ────────
|
||
//
|
||
// Ruling: fail-closed, not HA's fail-open. A block carrying a vector but no
|
||
// space identity can never be safely compared to anything — admitting it and
|
||
// hoping a downstream layer catches it is strictly weaker than refusing it here.
|
||
// HA passes such blocks through (`block_recall.go:140` is three `&&`, so an empty
|
||
// block fingerprint short-circuits the skip). That is the behaviour this project
|
||
// deliberately does NOT mirror: security semantics over bug-for-bug parity.
|
||
//
|
||
// The existing test asserted only the absent-field case. These cover the shapes a
|
||
// caller actually produces: omitted, empty string, whitespace, non-string.
|
||
test('F5: a block with a vector but no usable fingerprint is refused', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const rejected = [
|
||
{ label: 'fingerprint omitted', input: { text: 'x', vector: [1, 2, 3] } },
|
||
{ label: 'fingerprint empty string', input: { text: 'x', vector: [1, 2, 3], fingerprint: '' } },
|
||
{ label: 'fingerprint undefined', input: { text: 'x', vector: [1, 2, 3], fingerprint: undefined } },
|
||
{ label: 'fingerprint is a number', input: { text: 'x', vector: [1, 2, 3], fingerprint: 7 } },
|
||
{ label: 'fingerprint is null', input: { text: 'x', vector: [1, 2, 3], fingerprint: null } },
|
||
]
|
||
for (const { label, input } of rejected) {
|
||
assert.throws(
|
||
() => store.putBlock(input),
|
||
/requires the vector-space fingerprint/,
|
||
`${label}: an unlabelled vector must be refused, not silently accepted`,
|
||
)
|
||
}
|
||
// Nothing was written by any of the rejected calls.
|
||
assert.equal(store.countBlocks(), 0, 'a refused block must not land')
|
||
|
||
// The two legitimate shapes still work.
|
||
assert.equal(store.putBlock({ text: 'plain' }).created, true, 'no vector, no fingerprint: fine')
|
||
assert.equal(
|
||
store.putBlock({ text: 'labelled', vector: [1, 2, 3], fingerprint: 'fp-a' }).created,
|
||
true,
|
||
'vector + fingerprint: fine',
|
||
)
|
||
assert.deepEqual(
|
||
store.blockFingerprints().map((row) => row.fingerprint),
|
||
['fp-a'],
|
||
'only the labelled block contributes a space',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F5†: removing the write-side guard lets an unlabelled vector through', async () => {
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[[
|
||
` if (vectorJSON !== '' && (typeof fingerprint !== 'string' || fingerprint === '')) {
|
||
throw new Error('dsh-memory: a block with a vector requires the vector-space fingerprint')
|
||
}`,
|
||
` // MUTATED: no write-side fingerprint guard (HA's fail-open).`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
// Under the mutation the very call F5 expects to throw succeeds. Assert
|
||
// BOTH halves so this test can never pass for the wrong reason:
|
||
// 1. the mutation really does admit the block, and
|
||
// 2. F5's own assertion (`assert.throws`) would therefore fail on it.
|
||
let accepted = false
|
||
try {
|
||
store.putBlock({ text: 'x', vector: [1, 2, 3] })
|
||
accepted = true
|
||
} catch {
|
||
accepted = false
|
||
}
|
||
assert.equal(accepted, true, 'mutation must really admit an unlabelled vector')
|
||
|
||
const row = store.allBlocks(1)[0]
|
||
assert.equal(row.vector !== '', true, 'and store the vector')
|
||
assert.equal(row.fingerprint, '', 'with no space identity — the fail-open state')
|
||
|
||
// Now emulate F5's assertion against the mutated module: it must reject.
|
||
assert.throws(
|
||
() => {
|
||
if (accepted) throw new Error('unlabelled vector was accepted')
|
||
},
|
||
/was accepted/,
|
||
'under the mutation, F5 (which requires a throw) fails — so F5† proves F5 is load-bearing',
|
||
)
|
||
} 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()
|
||
}
|
||
})
|
||
|
||
// ── migration: a phase-A database must keep working ───────────────────────
|
||
//
|
||
// Phase A shipped `CREATE TABLE IF NOT EXISTS` with no `vector`/`fingerprint`
|
||
// columns. `open()` re-runs that DDL, which is a SILENT no-op on an existing
|
||
// table. So a database created by the phase-A build opens fine, recalls fine via
|
||
// FTS, and then throws on the FIRST commit:
|
||
//
|
||
// PUTBLOCK THREW: table memory_blocks has no column named vector
|
||
//
|
||
// The memory store becomes read-only with no error at load time. Worse than the
|
||
// G5 bug: a false alarm gets noticed, this one does not.
|
||
|
||
/** Build a database with exactly the phase-A schema (no vector/fingerprint). */
|
||
function buildPhaseADatabase(path) {
|
||
const db = new DatabaseSync(path)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS memory_blocks (
|
||
id TEXT PRIMARY KEY,
|
||
modality TEXT NOT NULL,
|
||
text_content TEXT NOT NULL DEFAULT '',
|
||
source TEXT NOT NULL DEFAULT '',
|
||
scene TEXT NOT NULL DEFAULT '',
|
||
semantic_type TEXT NOT NULL DEFAULT '',
|
||
created_at INTEGER NOT NULL,
|
||
updated_at INTEGER NOT NULL)`)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS memory_block_edges (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT, source_kind TEXT NOT NULL, source_id TEXT NOT NULL,
|
||
target_kind TEXT NOT NULL, target_id TEXT NOT NULL, type TEXT NOT NULL,
|
||
confidence REAL NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL)`)
|
||
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
|
||
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
|
||
db.exec(`INSERT INTO memory_blocks (id, modality, text_content, source, created_at, updated_at)
|
||
VALUES ('blk_legacy', 'text', '值班室分机号 4324', 'legacy', 1, 1)`)
|
||
db.exec(`INSERT INTO memory_blocks_fts (text_content, block_id) VALUES ('值班室分机号 4324', 'blk_legacy')`)
|
||
db.close()
|
||
}
|
||
|
||
test('MIG1: a phase-A database gains the vector columns and stays writable', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'mig-'))
|
||
const path = join(dir, 'phase-a.db')
|
||
try {
|
||
buildPhaseADatabase(path)
|
||
const store = new BlockStore({ path }).open()
|
||
|
||
// 1. Columns exist after open.
|
||
const columns = store.columns('memory_blocks')
|
||
assert.ok(columns.includes('vector'), `missing vector column; have ${columns.join(',')}`)
|
||
assert.ok(columns.includes('fingerprint'), `missing fingerprint column; have ${columns.join(',')}`)
|
||
|
||
// 2. The legacy block is still readable, and still findable by FTS.
|
||
const legacy = store.getBlock('blk_legacy')
|
||
assert.ok(legacy, 'the phase-A block must survive')
|
||
assert.equal(legacy.text_content, '值班室分机号 4324')
|
||
assert.equal(legacy.vector, '', 'a pre-existing block has no vector, not a fabricated one')
|
||
const hits = store.searchFts('"4324"', 5)
|
||
assert.ok(hits.length >= 1, 'FTS must still find the legacy block')
|
||
|
||
// 3. Writing works — this is what used to throw.
|
||
const { created } = store.putBlock({ text: '迁移之后的新块 8499' })
|
||
assert.equal(created, true, 'commit must work after migration')
|
||
|
||
// 4. The version is stamped.
|
||
assert.equal(store.userVersion(), 1, 'schema version must be recorded')
|
||
|
||
// 5. Re-opening is idempotent (migration must not re-run destructively).
|
||
store.close()
|
||
const again = new BlockStore({ path }).open()
|
||
assert.equal(again.userVersion(), 1)
|
||
assert.equal(again.countBlocks(), 2)
|
||
again.close()
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('MIG2: a fresh database is created at version 1', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
assert.equal(store.userVersion(), 1, 'a new database starts current, not at 0')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
// NOTE on what used to be here: a test named "MIG1 mutation" that built a raw
|
||
// phase-A database and asserted on `DatabaseSync` directly. It touched no `lib/`
|
||
// code, so it could never turn red when the implementation changed — a
|
||
// demonstration, not a guard. It has been removed rather than kept as a test
|
||
// that inflates the count while verifying nothing.
|
||
//
|
||
// The real guard for the ALTER is MIG1 above: commenting out the
|
||
// `ADD COLUMN vector` in migrate() makes it fail with
|
||
// `missing vector column; have id,modality,...`. Verified by mutation, not
|
||
// asserted by a lookalike.
|
||
|
||
// ── MIG3: the centroid table must survive migration ──────────────────────
|
||
//
|
||
// The migration drops phase A's `graph_centroid` and relies on DDL to rebuild
|
||
// it — but DDL ran BEFORE the migration, so on a pre-existing database that
|
||
// `CREATE TABLE IF NOT EXISTS` was a no-op and nothing rebuilt the table.
|
||
// Result: open succeeds, `user_version` reads 1, `putBlock` writes, FTS recalls
|
||
// — and only `saveCentroid` throws `no such table`. The narrowest, latest-possible
|
||
// failure, which is why MIG1 (which only checks memory_blocks) could not see it.
|
||
//
|
||
// Fix: rebuild the centroid table inside the migration, right where it is dropped.
|
||
|
||
/** Build a phase-A-shaped database including the OLD centroid table. */
|
||
function buildPhaseAWithCentroid(path, centroidRows = 0) {
|
||
const db = new DatabaseSync(path)
|
||
db.exec(`CREATE TABLE IF NOT EXISTS memory_blocks (
|
||
id TEXT PRIMARY KEY, modality TEXT NOT NULL, text_content TEXT NOT NULL DEFAULT '',
|
||
source TEXT NOT NULL DEFAULT '', scene TEXT NOT NULL DEFAULT '',
|
||
semantic_type TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL)`)
|
||
// Phase A's centroid shape: (fingerprint, dim, count, sum_json, updated_at)
|
||
db.exec(`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)`)
|
||
for (let i = 0; i < centroidRows; i += 1) {
|
||
db.exec(`INSERT INTO graph_centroid (fingerprint, dim, count, sum_json, updated_at)
|
||
VALUES ('fp${i}', 3, 1, '[0,0,0]', 1)`)
|
||
}
|
||
db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS memory_blocks_fts
|
||
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
|
||
db.close()
|
||
}
|
||
|
||
test('MIG3: a phase-A database can write and read a centroid after migration', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'mig3-'))
|
||
const path = join(dir, 'a.db')
|
||
try {
|
||
buildPhaseAWithCentroid(path, 0)
|
||
const store = new BlockStore({ path }).open()
|
||
|
||
// The table must exist with phase-B columns.
|
||
const cols = store.columns('graph_centroid')
|
||
for (const required of ['fingerprint', 'dim', 'vector_count', 'vector', 'stats', 'is_anomalous', 'built_at']) {
|
||
assert.ok(cols.includes(required), `graph_centroid missing "${required}"; have ${cols.join(',')}`)
|
||
}
|
||
|
||
// And it must be usable — this is what used to throw.
|
||
store.saveCentroid({
|
||
fingerprint: 'fp-p0',
|
||
dim: 3,
|
||
vectorCount: 2,
|
||
mean: [0.1, 0.2, 0.3],
|
||
isAnomalous: true,
|
||
})
|
||
const loaded = store.loadCentroid('fp-p0')
|
||
assert.ok(loaded, 'the centroid must round-trip')
|
||
assert.equal(loaded.fingerprint, 'fp-p0')
|
||
assert.equal(loaded.isAnomalous, true)
|
||
assert.deepEqual(loaded.mean, [0.1, 0.2, 0.3])
|
||
store.close()
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
test('MIG3b: a phase-A centroid WITH rows is renamed aside, not fatal and not lost', () => {
|
||
// Phase A never wrote a centroid, so a populated old table is a "should not
|
||
// happen" branch. Refusing to open the whole database punishes the user for a
|
||
// shape the plugin itself could not have produced by normal use; dropping the
|
||
// rows silently would lose data. Rename aside keeps both properties.
|
||
const dir = mkdtempSync(join(tmpdir(), 'mig3b-'))
|
||
const path = join(dir, 'b.db')
|
||
try {
|
||
buildPhaseAWithCentroid(path, 2)
|
||
const store = new BlockStore({ path }).open()
|
||
assert.equal(store.userVersion(), 1, 'the database must still open')
|
||
|
||
// The new shape is usable immediately.
|
||
store.saveCentroid({ fingerprint: 'fp-new', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
|
||
assert.ok(store.loadCentroid('fp-new'))
|
||
|
||
// The old data is preserved under a renamed table.
|
||
const legacy = store.tables().filter((name) => name.startsWith('graph_centroid_phase_a'))
|
||
assert.equal(legacy.length, 1, `expected one renamed legacy table, saw ${JSON.stringify(store.tables())}`)
|
||
assert.equal(store.raw().prepare(`SELECT COUNT(*) AS n FROM "${legacy[0]}"`).get().n, 2, 'rows preserved')
|
||
store.close()
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ── MIG4: a crash between RENAME and the version stamp must be recoverable ─
|
||
//
|
||
// The legacy name is second-resolution (`graph_centroid_phase_a_<14 digits>`).
|
||
// If the rename succeeds but the process dies before `PRAGMA user_version`
|
||
// commits, the next open() re-enters the rename branch and
|
||
// `ALTER TABLE ... RENAME TO <same name>` would throw `table already exists`,
|
||
// making the database unopenable forever. Improbable, but the recovery path is
|
||
// cheap and the failure is total.
|
||
test('MIG4: a leftover legacy table from an interrupted migration does not block open', () => {
|
||
// Mechanism (verified, not assumed): `pragma_table_info` returns zero rows for
|
||
// a table that does not exist, so once the old table has been renamed away the
|
||
// migration branch is skipped entirely on the retry — no second RENAME. Note
|
||
// this holds regardless of DDL ordering; reversing open()'s order keeps the
|
||
// suite green, which is how the DDL-ordering explanation was ruled out.
|
||
const dir = mkdtempSync(join(tmpdir(), 'mig4-'))
|
||
const path = join(dir, 'a.db')
|
||
try {
|
||
buildPhaseAWithCentroid(path, 2)
|
||
|
||
// Simulate the crash: the old table was already renamed, but the new one was
|
||
// never built and the version was never stamped.
|
||
const raw = new DatabaseSync(path)
|
||
raw.exec('ALTER TABLE graph_centroid RENAME TO graph_centroid_phase_a_20260101000000')
|
||
assert.equal(raw.prepare('PRAGMA user_version').get().user_version, 0)
|
||
raw.close()
|
||
|
||
// Now open normally: it must not throw, must rebuild the live table, and must
|
||
// not disturb the already-renamed rows.
|
||
const store = new BlockStore({ path }).open()
|
||
assert.equal(store.userVersion(), 1)
|
||
store.saveCentroid({ fingerprint: 'fp-r', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
|
||
assert.ok(store.loadCentroid('fp-r'), 'the rebuilt table must be usable')
|
||
|
||
const legacyTables = store.tables().filter((name) => name.startsWith('graph_centroid_phase_a'))
|
||
assert.equal(legacyTables.length, 1, `expected exactly one legacy table, saw ${JSON.stringify(legacyTables)}`)
|
||
assert.equal(
|
||
store.raw().prepare(`SELECT COUNT(*) AS n FROM "${legacyTables[0]}"`).get().n,
|
||
2,
|
||
'the pre-crash rows must be untouched',
|
||
)
|
||
store.close()
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
})
|
||
|
||
// ── ticket② (strong form): ONE derivation, two tables ─────────────────────
|
||
//
|
||
// The test above passes a string to both write paths, so it proves only that
|
||
// identical inputs produce identical keys — not that the plugin is incapable of
|
||
// deriving two different ones. These tests go through the service surface, where
|
||
// the fingerprint comes from `space()` and cannot be supplied by the caller.
|
||
test('ticket②-strong: service-driven commit and centroid share one fingerprint', async () => {
|
||
const { apply: applyMemory } = await import('../lib/index.js')
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'fp-strong-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
await withFetchStub(
|
||
() => jsonResponse({ embedding: [0.5, 0.25, 0.125] }),
|
||
async () => {
|
||
try {
|
||
const mem = applyMemory(ctx, {
|
||
path: join(dir, 'm.db'),
|
||
embed: { endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' },
|
||
})
|
||
|
||
// Commit through the service (embeds + stamps), three blocks.
|
||
for (let i = 0; i < 3; i += 1) {
|
||
const r = await mem.commit({ text: `服务路径块 ${i}` })
|
||
assert.equal(r.embedded, true, 'a configured space must embed')
|
||
}
|
||
|
||
// The centroid comes from the space object; no fingerprint is passed in.
|
||
const { fingerprint: centroidKey } = mem.saveSpaceCentroid({
|
||
vectorCount: 3,
|
||
mean: [0.5, 0.25, 0.125],
|
||
isAnomalous: true,
|
||
})
|
||
|
||
// Set assertion, exactly as agreed: the distinct block fingerprints and
|
||
// the centroid key must be the same single value.
|
||
const blockSide = mem.store.raw()
|
||
.prepare('SELECT DISTINCT fingerprint FROM memory_blocks')
|
||
.all()
|
||
.map((row) => row.fingerprint)
|
||
const centroidSide = mem.store.raw()
|
||
.prepare('SELECT fingerprint FROM graph_centroid')
|
||
.all()
|
||
.map((row) => row.fingerprint)
|
||
|
||
assert.deepEqual(blockSide, [centroidKey], 'blocks must carry exactly the centroid key')
|
||
assert.deepEqual(centroidSide, [centroidKey], 'the centroid table holds exactly that key')
|
||
assert.ok(mem.store.loadCentroid(blockSide[0]), 'lookup by the block-side string must hit')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('ticket②-strong mutation: re-deriving in the centroid path must break the key', async () => {
|
||
// The exact failure the constraint forbids: a second derivation with a
|
||
// different normalisation (here: a trailing slash survives) yields a different
|
||
// string, and the lookup silently misses.
|
||
const { apply: applyMemory } = await import('../lib/index.js')
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'fp-mut-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
await withFetchStub(
|
||
() => jsonResponse({ embedding: [1, 2, 3] }),
|
||
async () => {
|
||
try {
|
||
const mem = applyMemory(ctx, {
|
||
path: join(dir, 'm.db'),
|
||
embed: { endpoint: ENDPOINT, dimension: 3, model: 'jina-v5' },
|
||
})
|
||
await mem.commit({ text: 'a' })
|
||
|
||
// Mutation: the centroid path derives its own key from a *different*
|
||
// spelling that normalisation does NOT collapse — a different query
|
||
// string is a genuinely different endpoint (this slice deliberately does
|
||
// not reorder query params, because that difference is usually real).
|
||
const rawKey = deriveFingerprint({
|
||
endpoint: `${ENDPOINT}?tenant=b`,
|
||
dimension: 3,
|
||
model: 'jina-v5',
|
||
})
|
||
const spaceKey = mem.space().fingerprint
|
||
assert.notEqual(rawKey, spaceKey, 'a second derivation with different input must differ')
|
||
|
||
mem.store.saveCentroid({
|
||
fingerprint: rawKey,
|
||
dim: 3,
|
||
vectorCount: 1,
|
||
mean: [0, 0, 0],
|
||
isAnomalous: true,
|
||
})
|
||
// Blocks can no longer find it: silent, no throw. That is precisely why
|
||
// the single-source rule has to be structural.
|
||
assert.equal(mem.store.loadCentroid(spaceKey), undefined, 'the block-side key must miss')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('ticket②-strong: normalisation makes the trailing-slash variant a NON-issue', () => {
|
||
// The mutation above only works because it bypasses normalisation. Going
|
||
// through the real derivation, `…/embed` and `…/embed/` are one space.
|
||
const a = deriveFingerprint({ endpoint: 'http://h:9000/embed', dimension: 3 })
|
||
const b = deriveFingerprint({ endpoint: 'http://h:9000/embed/', dimension: 3 })
|
||
assert.equal(a, b, 'normalisation must collapse the slash so config edits do not split a space')
|
||
})
|
||
|
||
// ── ticket③ boundary: unknown dimension is null, never 0 ─────────────────
|
||
//
|
||
// When the centroid table has no row for a space that DOES have embedded blocks,
|
||
// the dimension is unknown. Encoding that as 0 would be a lie that reads like a
|
||
// real answer, and downstream code comparing `dim === 0` would take the wrong
|
||
// branch. The state belongs to diagnostics only: recall must be unaffected, and
|
||
// `loadCentroid` must not throw for a missing row (HA treats a missing /
|
||
// under-sampled centroid as a normal degradation, centroid_store.go:144-184).
|
||
test('ticket③: a missing centroid row yields undefined, and reports null — not 0', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
|
||
const fp = embedder.info().fingerprint
|
||
store.putBlock({ text: '有向量但没建中心', vector: [1, 2, 3], fingerprint: fp })
|
||
|
||
// loadCentroid must be a normal "absent", not an error path.
|
||
assert.equal(store.loadCentroid(fp), undefined, 'a missing centroid is undefined, not a throw')
|
||
|
||
// Diagnostic projection: the space is known, its dimension is NOT.
|
||
const spaces = store.blockFingerprints()
|
||
assert.equal(spaces.length, 1)
|
||
const centroid = store.loadCentroid(spaces[0].fingerprint)
|
||
const dim = centroid === undefined ? null : centroid.dim
|
||
assert.equal(dim, null, 'unknown dimension must be null')
|
||
assert.notEqual(dim, 0, 'never encode unknown as 0 — 0 reads like a real answer')
|
||
assert.equal(spaces[0].embedded, 1, 'the blocks are known to be embedded')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('ticket③: a numeric dimension is reported only when a centroid row exists', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
|
||
const fp = embedder.info().fingerprint
|
||
store.putBlock({ text: 'x', vector: [1, 2, 3], fingerprint: fp })
|
||
store.saveCentroid({ fingerprint: fp, dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: false })
|
||
const centroid = store.loadCentroid(fp)
|
||
assert.equal(centroid.dim, 3, 'a real dimension comes from the centroid metadata, not from length(vector)')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('ticket③: a space with no centroid does not change recall behaviour', async () => {
|
||
// Group H must not leak vector logic into the recall path: a space whose
|
||
// centroid is missing behaves exactly like any other degraded case.
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const embedder = createHttpEmbedder({ endpoint: ENDPOINT, dimension: 3 })
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
const hits = await recallBlocksHybrid(store, embedder, '4324', { topK: 3 })
|
||
assert.ok(hits.length >= 1, 'symbol recall still answers')
|
||
assert.equal(store.loadCentroid('whatever'), undefined, 'no centroid row, no throw')
|
||
|
||
// And the synchronous phase-A entry point is untouched by any of this.
|
||
const sync = recallBlocks(store, '4324', { topK: 1 })
|
||
assert.equal(typeof sync?.then, 'undefined', 'recallBlocks must stay synchronous')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
// ── W4–W7: write-path hardening ───────────────────────────────────────────
|
||
//
|
||
// The commit path became async, which means it now has a failure surface it did
|
||
// not have before: it can be cancelled, it can be slow, and it can be handed a
|
||
// vector that does not match the space. Each of those has a required behaviour,
|
||
// and each is pinned by a test whose mutation is a one-line revert.
|
||
|
||
/** A minimal app with the services the plugin injects. */
|
||
async function makeMem(config) {
|
||
const { apply: applyMemory } = await import('../lib/index.js')
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'w-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), ...config })
|
||
return {
|
||
ctx,
|
||
mem,
|
||
dir,
|
||
async done() {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
},
|
||
}
|
||
}
|
||
|
||
/** Invoke the tool with an explicit abort signal. */
|
||
function runCommit(ctx, args, signal) {
|
||
return ctx.tools.execute({ callId: 'w', name: 'stablemem_commit', arguments: args, signal })
|
||
}
|
||
|
||
// W4 — a wrong-dimension vector must not be stored.
|
||
test('W4: a wrong-length vector is treated as an embed failure, not stored', async () => {
|
||
const failing = {
|
||
// Declares 3, returns 2 — the shape a misconfigured provider produces.
|
||
async embedText() {
|
||
return [0.1, 0.2]
|
||
},
|
||
info: () => ({ fingerprint: 'fp-w4', dimension: 3 }),
|
||
}
|
||
const { ctx, mem, done } = await makeMem({ embedder: failing })
|
||
try {
|
||
const result = await runCommit(ctx, { text: '维度不对的块 4324' }, new AbortController().signal)
|
||
assert.equal(result.isError, false, `commit must not fail: ${JSON.stringify(result.value)}`)
|
||
|
||
const rows = mem.store.allBlocks(10)
|
||
assert.equal(rows.length, 1, 'the block must still land')
|
||
assert.equal(rows[0].vector, '', 'and must carry NO vector rather than a bad one')
|
||
assert.equal(rows[0].fingerprint, '', 'nor a fingerprint claiming a space it cannot join')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('W4†: removing the dimension check lets a bad vector through', async () => {
|
||
// Demonstrated at the store level: the store accepts any vector it is given,
|
||
// so the check can only live in commit(). This asserts the store's tolerance so
|
||
// that a future "the store will catch it" assumption fails here.
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'x', vector: [1, 2], fingerprint: 'fp' })
|
||
assert.equal(JSON.parse(store.getBlock(store.allBlocks(1)[0].id).vector).length, 2,
|
||
'the store does NOT enforce dimension — commit() must')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
// W5 — cancellation must not turn into "block not written".
|
||
test('W5: an aborted embed still writes the block, without a vector', async () => {
|
||
const slow = {
|
||
async embedText(_text, signal) {
|
||
// A provider that honours abort. If the caller does not pass a signal, this
|
||
// hangs until the adapter's 30s timeout — which is the bug W5 guards.
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => resolve([1, 2, 3]), 5000)
|
||
signal?.addEventListener('abort', () => {
|
||
clearTimeout(timer)
|
||
reject(new Error('aborted'))
|
||
})
|
||
})
|
||
},
|
||
info: () => ({ fingerprint: 'fp-w5', dimension: 3 }),
|
||
}
|
||
const { ctx, mem, done } = await makeMem({ embedder: slow })
|
||
try {
|
||
const controller = new AbortController()
|
||
const started = Date.now()
|
||
setTimeout(() => controller.abort(), 50)
|
||
|
||
const result = await runCommit(ctx, { text: '被取消的块 4324' }, controller.signal)
|
||
const elapsed = Date.now() - started
|
||
|
||
assert.ok(elapsed < 2000, `must not wait for the provider/settle after abort; took ${elapsed}ms`)
|
||
|
||
// ★ The registry owns the final word on an aborted call: after a body returns
|
||
// it re-checks the signal and replaces a success with a canonical abort result
|
||
// (dsh-tools/lib/index.js:3408 -> toolAbortedResult). So `isError` is NOT the
|
||
// assertion here — and asserting `false` would be asserting against the
|
||
// harness. What this plugin controls is the SIDE EFFECT: the block is written.
|
||
assert.equal(result.isError, true, 'the registry reports the call as aborted')
|
||
assert.equal(result.error?.info?.code, 'ABORTED', 'and uses the canonical abort code')
|
||
|
||
const rows = mem.store.allBlocks(10)
|
||
assert.equal(rows.length, 1, 'the block must still be written')
|
||
assert.equal(rows[0].vector, '', 'with no vector')
|
||
assert.notEqual(rows[0].text_content, '', 'and with its text intact')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('W5†: ignoring exec.signal makes the call wait for the provider', async () => {
|
||
// The mutation is "execute(args) with no signal plumbing". Emulated by calling
|
||
// the embedder without a signal: it runs to completion, which is what the bug
|
||
// looked like (up to the adapter's 30 s timeout in production).
|
||
const slow = {
|
||
async embedText(_text, signal) {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => resolve([1, 2, 3]), 300)
|
||
signal?.addEventListener('abort', () => {
|
||
clearTimeout(timer)
|
||
reject(new Error('aborted'))
|
||
})
|
||
})
|
||
},
|
||
info: () => ({ fingerprint: 'fp', dimension: 3 }),
|
||
}
|
||
const started = Date.now()
|
||
await slow.embedText('x', undefined) // no signal ⇒ cannot be cancelled
|
||
const elapsed = Date.now() - started
|
||
assert.ok(elapsed >= 250, `without a signal the call is uncancellable; took ${elapsed}ms`)
|
||
})
|
||
|
||
// W6 — the async wrapper must not change the write result.
|
||
test('W6: with no embedder, commit returns the same shape as putBlock', async () => {
|
||
const { mem, done } = await makeMem({})
|
||
try {
|
||
const result = await mem.commit({ text: 'W6 块 7788' })
|
||
assert.equal(typeof result.id, 'string')
|
||
assert.match(result.id, /^blk_[0-9a-f]{12}$/)
|
||
assert.equal(result.created, true)
|
||
assert.equal(result.embedded, false)
|
||
// Immediately recallable — the write really happened.
|
||
const hits = mem.recall('7788')
|
||
assert.ok((await hits).length >= 1, 'the block must be findable right after commit')
|
||
// Idempotent re-commit reports created=false, exactly like putBlock.
|
||
const again = await mem.commit({ text: 'W6 块 7788' })
|
||
assert.equal(again.created, false)
|
||
assert.equal(again.id, result.id)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
// W7 — an empty fingerprint must degrade BEFORE spending a network call.
|
||
test('W7: an empty fingerprint means zero embed calls, not one wasted round-trip', async () => {
|
||
let calls = 0
|
||
const nameless = {
|
||
async embedText() {
|
||
calls += 1
|
||
return [1, 2, 3]
|
||
},
|
||
info: () => ({ fingerprint: '', dimension: 3 }),
|
||
}
|
||
const { mem, done } = await makeMem({ embedder: nameless })
|
||
try {
|
||
const result = await mem.commit({ text: 'W7 块' })
|
||
assert.equal(calls, 0, `an unnamed space must not be asked for a vector; saw ${calls} call(s)`)
|
||
assert.equal(result.embedded, false)
|
||
const row = mem.store.allBlocks(1)[0]
|
||
assert.equal(row.vector, '')
|
||
assert.equal(row.fingerprint, '')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
// ── W8: something must actually BUILD the centroid ────────────────────────
|
||
//
|
||
// `saveSpaceCentroid` had no caller in `lib/`, so in real use blocks carried
|
||
// vectors while `graph_centroid` stayed empty forever: `loadCentroid` always
|
||
// missed, centering never applied, and the票③ "unknown dimension" state became
|
||
// the permanent normal case. The entry point is `rebuildCentroid`, ported from
|
||
// HA's `RebuildCentroid` (centroid_store.go:246-320).
|
||
test('W8: rebuildCentroid derives the centroid from the blocks of one space', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const fp = 'http:m:3:deadbeef'
|
||
// Two near-identical directions plus one different: anisotropic enough to
|
||
// cross the 0.6 threshold.
|
||
const vectors = [
|
||
[1, 0.02, 0.01],
|
||
[1, 0.01, 0.02],
|
||
[0.99, 0.03, 0.02],
|
||
]
|
||
for (const [i, v] of vectors.entries()) {
|
||
store.putBlock({ text: `块${i}`, vector: v, fingerprint: fp })
|
||
}
|
||
// A second space must not contaminate the estimate.
|
||
store.putBlock({ text: '别空间', vector: [0, 1, 0], fingerprint: 'other:3:ffff' })
|
||
// And a mismatched-dimension row in the SAME space must be skipped, not coerced.
|
||
store.putBlock({ text: '坏维度', vector: [1, 2], fingerprint: fp })
|
||
|
||
const result = store.rebuildCentroid(fp)
|
||
assert.equal(result.dim, 3, 'dimension comes from the rows, not from configuration')
|
||
assert.equal(result.vectorCount, 3, 'only same-space, same-dimension rows count')
|
||
assert.ok(result.meanLength > 0)
|
||
|
||
const saved = store.loadCentroid(fp)
|
||
assert.ok(saved, 'the centroid must be persisted')
|
||
assert.equal(saved.dim, 3)
|
||
assert.equal(saved.vectorCount, 3)
|
||
assert.equal(saved.isAnomalous, result.isAnomalous, 'the stored flag matches the verdict')
|
||
// The other space must be untouched.
|
||
assert.equal(store.loadCentroid('other:3:ffff'), undefined)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('W8: a space with no embedded blocks refuses rather than inventing a centroid', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
assert.throws(
|
||
() => store.rebuildCentroid('http:none:3:00000000'),
|
||
/no embedded blocks/,
|
||
'an empty space has nothing to estimate from',
|
||
)
|
||
assert.equal(store.loadCentroid('http:none:3:00000000'), undefined)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('W8: the anisotropy verdict uses the 0.6 threshold', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const fp = 'http:m:2:aniso'
|
||
// A cone of ±35° gives mean length ~0.879 — anomalous at 0.6, but NOT at
|
||
// 0.95. Perfectly collinear vectors (mean length 1.0) would pass any
|
||
// threshold, which is how the first version of this test failed to
|
||
// distinguish the mutation.
|
||
const rad = (35 * Math.PI) / 180
|
||
const cone = [[1, 0], [Math.cos(rad), Math.sin(rad)], [Math.cos(-rad), Math.sin(-rad)]]
|
||
for (const [i, v] of cone.entries()) {
|
||
store.putBlock({ text: `a${i}`, vector: v, fingerprint: fp })
|
||
}
|
||
const verdict = store.rebuildCentroid(fp)
|
||
assert.ok(
|
||
verdict.meanLength > 0.6 && verdict.meanLength < 0.95,
|
||
`test corpus must sit BETWEEN the thresholds to discriminate; got ${verdict.meanLength.toFixed(4)}`,
|
||
)
|
||
assert.equal(verdict.isAnomalous, true, 'a ±35° cone is anomalous at the 0.6 threshold')
|
||
store.close()
|
||
|
||
const iso = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
const fp2 = 'http:m:2:iso'
|
||
// Orthogonal directions ⇒ mean length ~0 ⇒ isotropic ⇒ do NOT centre.
|
||
for (const [i, v] of [[1, 0], [0, 1], [-1, 0], [0, -1]].entries()) {
|
||
iso.putBlock({ text: `i${i}`, vector: v, fingerprint: fp2 })
|
||
}
|
||
assert.equal(iso.rebuildCentroid(fp2).isAnomalous, false, 'an isotropic space must not be centred')
|
||
} finally {
|
||
iso.close()
|
||
}
|
||
} finally {
|
||
// outer store already closed above
|
||
}
|
||
})
|
||
|
||
// ── W9: the REAL adapter must honour the caller's signal ──────────────────
|
||
//
|
||
// ★ Why this test exists (the bug it caught): W5/W5† used a hand-written slow
|
||
// embedder that registered its own `abort` listener. The real adapter's
|
||
// `embedText` took ONE parameter, so `embedText(text, signal)` silently dropped
|
||
// the signal and every other test still passed — 99 green while a cancelled turn
|
||
// blocked for the adapter's full 30 s and then wrote the vector anyway.
|
||
//
|
||
// This is the same shape as the V3b "2-message batch is a fake batch" lesson,
|
||
// one level up: a test double was standing in for the only code path that could
|
||
// be wrong. So W9 uses a real HTTP server and the real `createHttpEmbedder`.
|
||
import { createServer } from 'node:http'
|
||
|
||
/**
|
||
* Start a real HTTP endpoint that replies after `delayMs`.
|
||
* @param {number} delayMs
|
||
* @returns {Promise<{url: string, close: () => Promise<void>}>}
|
||
*/
|
||
async function slowEndpoint(delayMs) {
|
||
const server = createServer((_req, res) => {
|
||
setTimeout(() => {
|
||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||
res.end(JSON.stringify({ embedding: [0.1, 0.2, 0.3] }))
|
||
}, delayMs)
|
||
})
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||
const { port } = server.address()
|
||
return {
|
||
url: `http://127.0.0.1:${port}/embed`,
|
||
close: () => new Promise((resolve) => server.close(resolve)),
|
||
}
|
||
}
|
||
|
||
test('W9: a caller abort interrupts a REAL http embedding call promptly', async () => {
|
||
const endpoint = await slowEndpoint(3000)
|
||
try {
|
||
const embedder = createHttpEmbedder({
|
||
endpoint: endpoint.url,
|
||
dimension: 3,
|
||
timeoutMs: 30_000, // deliberately long: only the caller can stop this
|
||
})
|
||
const controller = new AbortController()
|
||
const started = Date.now()
|
||
setTimeout(() => controller.abort(), 50)
|
||
|
||
let threw = false
|
||
try {
|
||
await embedder.embedText('值', controller.signal)
|
||
} catch {
|
||
threw = true
|
||
}
|
||
const elapsed = Date.now() - started
|
||
|
||
assert.equal(threw, true, 'an aborted call must reject, not resolve with a vector')
|
||
assert.ok(
|
||
elapsed < 500,
|
||
`caller abort must interrupt the request, not wait for it (took ${elapsed}ms)`,
|
||
)
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
test('W9b: the adapter keeps its OWN timeout when a caller signal is present', async () => {
|
||
// The other half of the contract: fusing the signals must not disable the
|
||
// adapter's own bound. A wedged provider with a live (never-aborted) caller
|
||
// signal must still time out.
|
||
const endpoint = await slowEndpoint(3000)
|
||
try {
|
||
const embedder = createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 150 })
|
||
const controller = new AbortController() // never aborted
|
||
const started = Date.now()
|
||
let threw = false
|
||
try {
|
||
await embedder.embedText('值', controller.signal)
|
||
} catch {
|
||
threw = true
|
||
}
|
||
const elapsed = Date.now() - started
|
||
assert.equal(threw, true, 'the adapter timeout must still fire')
|
||
assert.ok(elapsed < 1000, `own timeout must bound the call (took ${elapsed}ms)`)
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
test('W9c: end-to-end — aborted tool call still writes the block, vector empty', async () => {
|
||
// W5 asserted the side effect through a fake embedder; this does it through
|
||
// the real adapter, a real server, and the real tool pipeline.
|
||
const endpoint = await slowEndpoint(3000)
|
||
try {
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 }),
|
||
})
|
||
try {
|
||
const controller = new AbortController()
|
||
setTimeout(() => controller.abort(), 50)
|
||
const result = await runCommit(ctx, { text: 'W9c 被取消但已落盘 4324' }, controller.signal)
|
||
|
||
// The registry owns the verdict on an aborted call (dsh-tools:3408).
|
||
assert.equal(result.isError, true)
|
||
assert.equal(result.error?.info?.code, 'ABORTED')
|
||
|
||
// What this plugin controls: the side effect.
|
||
const rows = mem.store.allBlocks(10)
|
||
assert.equal(rows.length, 1, 'the block must be written despite the abort')
|
||
assert.equal(rows[0].vector, '', 'without a vector')
|
||
assert.ok(rows[0].text_content.includes('4324'), 'and with its text intact')
|
||
} finally {
|
||
await done()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
// ── W10: the READ path must honour cancellation too ───────────────────────
|
||
//
|
||
// W9 fixed the write path. The read path had the same defect and nobody had
|
||
// looked: `recallBlocksHybrid` called `embedText(text)` with ONE argument, and
|
||
// neither `StableMem.recall()` nor `contextForStep()` accepted a signal — even
|
||
// though the `agent/pre-step` payload carries one
|
||
// (dsh-agent/lib/types/runtime-types.d.ts:304-310). So every pre-step could block
|
||
// for the adapter's full 30 s timeout while the user had already cancelled.
|
||
//
|
||
// This test goes through the REAL hook chain (pre-step → contextForStep → recall
|
||
// → adapter → http), not by calling recallBlocksHybrid directly, because that is
|
||
// the path a cancelled turn actually takes.
|
||
|
||
/** A server that accepts the request and never answers. */
|
||
async function hangingEndpoint() {
|
||
const server = createServer((req) => {
|
||
req.on('data', () => {})
|
||
req.on('end', () => {})
|
||
// deliberately never respond
|
||
})
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||
const { port } = server.address()
|
||
return {
|
||
url: `http://127.0.0.1:${port}/embed`,
|
||
close: () =>
|
||
new Promise((resolve) => {
|
||
server.closeAllConnections?.()
|
||
server.close(resolve)
|
||
}),
|
||
}
|
||
}
|
||
|
||
test('W10: aborting a pre-step interrupts the recall embedding promptly', async () => {
|
||
const endpoint = await hangingEndpoint()
|
||
try {
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 }),
|
||
})
|
||
try {
|
||
// One embedded block so the vector path is actually taken.
|
||
await mem.commit({ text: 'x', vector: [1, 2, 3], fingerprint: mem.space().fingerprint })
|
||
|
||
const batch = [
|
||
createUserMessage({
|
||
content: [{ type: 'text', text: '值班室分机号是多少' }],
|
||
source: { kind: 'user' },
|
||
}),
|
||
]
|
||
const controller = new AbortController()
|
||
const started = Date.now()
|
||
setTimeout(() => controller.abort(), 50)
|
||
|
||
const decision = await ctx.waterfall(
|
||
'agent/pre-step',
|
||
{ agent: { id: 'a', session: {} }, messages: batch, turn: 1, step: 1, signal: controller.signal },
|
||
() => Promise.resolve({ kind: 'enter', messages: batch }),
|
||
)
|
||
const elapsed = Date.now() - started
|
||
|
||
assert.ok(
|
||
elapsed < 500,
|
||
`an aborted pre-step must not wait for the embedding provider; took ${elapsed}ms`,
|
||
)
|
||
// It must still produce a decision (recall degrades, the turn continues).
|
||
assert.ok(decision.kind === 'enter' || decision.kind === 'reject')
|
||
// And the degradation must be reported, not silently swallowed.
|
||
const hits = await mem.recall('值班室分机号是多少')
|
||
assert.equal(hits.degraded, 'embed_error', 'an aborted embed is a degraded recall')
|
||
} finally {
|
||
await done()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
test('W10b: a signal reaching recallBlocksHybrid directly also aborts', async () => {
|
||
// The unit-level twin of W10: proves the parameter is honoured at the seam, so
|
||
// a future refactor of the hook chain cannot hide a regression here.
|
||
const endpoint = await hangingEndpoint()
|
||
try {
|
||
const embedder = createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 })
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
const controller = new AbortController()
|
||
const started = Date.now()
|
||
setTimeout(() => controller.abort(), 50)
|
||
const hits = await recallBlocksHybrid(store, embedder, '4324', {
|
||
topK: 3,
|
||
signal: controller.signal,
|
||
})
|
||
const elapsed = Date.now() - started
|
||
assert.ok(elapsed < 500, `must abort promptly; took ${elapsed}ms`)
|
||
assert.equal(hits.degraded, 'aborted', 'a cancellation is not a provider failure')
|
||
assert.ok(hits.length >= 1, 'and still answer from the symbol path')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
// ── W11: degradation must be READABLE, not merely held on an object ───────
|
||
//
|
||
// `hits.degraded` is deliberately non-enumerable so it cannot disturb the
|
||
// phase-A golden comparison. The side effect is that it is invisible to
|
||
// JSON.stringify, Object.keys, and any serialised log — so a test asserting the
|
||
// property proves nothing about what the model receives. The only channel the
|
||
// model has is the rendered text.
|
||
test('W11: a degraded recall puts a readable notice in the rendered prompt text', () => {
|
||
const degraded = [{ text: '值班室分机号 4324', exact: true, score: 1.01 }]
|
||
degraded.degraded = 'embed_error'
|
||
|
||
const rendered = renderRecall(degraded, 'dsh-memory')
|
||
assert.ok(
|
||
rendered.includes('FAILED'),
|
||
`the model must be told the search was partial; rendered:\n${rendered}`,
|
||
)
|
||
assert.ok(
|
||
rendered.includes('does not prove it is unrecorded'),
|
||
'and must be warned that absence of evidence is not evidence of absence',
|
||
)
|
||
// Sanity: the property really is invisible to serialisation, which is WHY the
|
||
// text channel has to exist.
|
||
assert.equal(JSON.stringify(degraded).includes('degraded'), false)
|
||
})
|
||
|
||
test('W11b: a healthy recall renders no notice at all', () => {
|
||
const healthy = [{ text: '值班室分机号 4324', exact: true, score: 1.01 }]
|
||
const rendered = renderRecall(healthy, 'dsh-memory')
|
||
assert.ok(!rendered.includes('FAILED'), 'no false alarm on a healthy recall')
|
||
assert.ok(!rendered.includes('Semantic search is off'))
|
||
assert.ok(rendered.includes('PAST notes'), 'the header is still there')
|
||
})
|
||
|
||
test('W11c: an aborted pre-step renders the notice end to end', async () => {
|
||
// The full chain: abort → embed_error → notice in the text the model receives.
|
||
// For the notice to appear there must also be at least one hit, so seed a block
|
||
// the *symbol* path can still find — recall degrades to lexical, it does not
|
||
// return nothing.
|
||
const endpoint = await hangingEndpoint()
|
||
try {
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 }),
|
||
})
|
||
try {
|
||
await mem.commit({ text: 'x', vector: [1, 2, 3], fingerprint: mem.space().fingerprint })
|
||
await mem.commit({ text: '值班室分机号 4324' })
|
||
|
||
const controller = new AbortController()
|
||
const started = Date.now()
|
||
setTimeout(() => controller.abort(), 50)
|
||
const text = await mem.contextForStep(
|
||
[
|
||
createUserMessage({
|
||
content: [{ type: 'text', text: '值班室分机号 4324' }],
|
||
source: { kind: 'user' },
|
||
}),
|
||
],
|
||
controller.signal,
|
||
)
|
||
const elapsed = Date.now() - started
|
||
|
||
assert.ok(elapsed < 500, `the aborted recall must not hang; took ${elapsed}ms`)
|
||
assert.notEqual(text, '', 'the symbol path still produced content')
|
||
assert.ok(
|
||
/cancelled|FAILED/.test(text),
|
||
`the notice must reach the model-facing text; got:\n${text}`,
|
||
)
|
||
assert.ok(text.includes('cancelled'), 'a cancellation says so, not "FAILED"')
|
||
assert.ok(text.includes('4324'), 'and the hit itself is present')
|
||
} finally {
|
||
await done()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
test('W11†: removing the notice line makes the degradation invisible again', () => {
|
||
// The mutation is "renderRecall goes back to header + lines". Emulated by
|
||
// building the old shape, and asserting it does NOT contain the warning.
|
||
const degraded = [{ text: 'x', exact: true, score: 1 }]
|
||
degraded.degraded = 'embed_error'
|
||
const oldShape = [
|
||
'[Memory · dsh-memory] Recalled from durable block memory — these are PAST notes, not the current conversation.',
|
||
...degraded.map((h, i) => `[${i + 1}] (${h.exact ? 'exact' : 'text'} ${h.score.toFixed(2)}) ${h.text}`),
|
||
].join('\n')
|
||
assert.ok(!oldShape.includes('FAILED'), 'the old shape carries no warning — so W11 would fail on it')
|
||
assert.ok(renderRecall(degraded, 'dsh-memory').includes('FAILED'), 'the current shape does')
|
||
})
|
||
|
||
// ── W11d: degraded AND empty must still say something ────────────────────
|
||
//
|
||
// The mixed case: recall was degraded (so absence of evidence is not evidence of
|
||
// absence) and the lexical fallback found nothing (so there are no entries). The
|
||
// model must be told the search was incomplete — staying silent here is exactly
|
||
// the false negative the notice exists to prevent.
|
||
//
|
||
// ★ Two short-circuits suppress it, and fixing only one changes nothing:
|
||
// 1. `contextForStep`: `if (hits.length === 0) return ''`
|
||
// 2. `renderRecall`: `if (hits.length === 0) return ''`
|
||
// Both must treat "no entries" and "nothing to say" as different questions. Each
|
||
// gets its own mutation below, because fixing one and reverting the other is
|
||
// invisible to a test that only exercises the fixed path.
|
||
//
|
||
// Measured behaviour of the four combinations (real adapter, real hooks):
|
||
// A degraded + hits -> text, FAILED, entries
|
||
// B degraded + no hits -> text, FAILED, NO entries ← this test
|
||
// C1 healthy + hits -> text, no FAILED, entries
|
||
// C2 healthy + no hits -> EMPTY (correct silence)
|
||
test('W11d: degraded + zero hits still injects the notice, with no entries', async () => {
|
||
const endpoint = await hangingEndpoint()
|
||
try {
|
||
const { mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 }),
|
||
})
|
||
try {
|
||
// One embedded block so the vector path is taken, but its text shares no
|
||
// symbol with the query, so the lexical fallback finds nothing.
|
||
await mem.commit({ text: 'zzz 完全无关的内容', vector: [1, 2, 3], fingerprint: mem.space().fingerprint })
|
||
|
||
const controller = new AbortController()
|
||
setTimeout(() => controller.abort(), 50)
|
||
const text = await mem.contextForStep(
|
||
[
|
||
createUserMessage({
|
||
content: [{ type: 'text', text: '值班室分机号是多少' }],
|
||
source: { kind: 'user' },
|
||
}),
|
||
],
|
||
controller.signal,
|
||
)
|
||
|
||
assert.notEqual(text, '', 'a degraded empty recall must not be silent')
|
||
assert.ok(
|
||
/cancelled|FAILED/.test(text),
|
||
`the notice must be present; got:\n${text}`,
|
||
)
|
||
assert.ok(
|
||
!text.includes('[1]'),
|
||
`and it must carry no entries; got:\n${text}`,
|
||
)
|
||
} finally {
|
||
await done()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
// NOTE: two tests formerly named W11d†a / W11d†b lived here. Each built its own
|
||
// stand-in function inside the test file and asserted on that — they touched no
|
||
// `lib/` code, so they stayed GREEN under both real mutations while W11d itself
|
||
// went red. They were self-tests masquerading as mutation guards, which is the
|
||
// same defect as the G5 allowlist that was silently always-false: a guard that
|
||
// cannot fail is not a guard. They have been removed rather than kept as count.
|
||
//
|
||
// The real guards are:
|
||
// · W11d — asserts the behaviour; goes red under either mutation.
|
||
// · W11d† — performs the renderRecall mutation on `lib/` and asserts the
|
||
// behaviour flips (above, in the mutation-runner section).
|
||
// · W11d†ctx — does the same for the contextForStep short-circuit (below).
|
||
//
|
||
// A mutation must change a file under `lib/`; if it edits `test/`, it is not a
|
||
// mutation. That rule is now written down in README so the next one is caught.
|
||
|
||
test('W11d†ctx: restoring the contextForStep short-circuit flips the behaviour', async () => {
|
||
// This is the second of the two independent short-circuits. It needs its own
|
||
// mutation because fixing renderRecall alone leaves the caller discarding the
|
||
// notice before it is ever rendered.
|
||
await withMutatedCopy(
|
||
'index.js',
|
||
[
|
||
[
|
||
' const hits = await this.recall(query, { signal })',
|
||
" const hits = await this.recall(query, { signal })\n if (hits.length === 0) return '' // MUTATED: the old caller short-circuit",
|
||
],
|
||
],
|
||
async (mod) => {
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
|
||
const dir = mkdtempSync(join(tmpdir(), 'w11dctx-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
try {
|
||
const mem = mod.apply(ctx, { path: join(dir, 'm.db') })
|
||
const hits = []
|
||
hits.degraded = 'embed_error'
|
||
// Drive the mutated method with a degraded-empty recall, so the caller
|
||
// short-circuit is the only thing that can decide the outcome.
|
||
mem.recall = async () => hits
|
||
const text = await mem.contextForStep([
|
||
createUserMessage({ content: [{ type: 'text', text: '任意问题' }], source: { kind: 'user' } }),
|
||
])
|
||
assert.equal(
|
||
text,
|
||
'',
|
||
'under the mutation the caller returns "" for a degraded-empty recall, so W11d must fail',
|
||
)
|
||
// Prove the notice would have existed: import the (unmutated) renderer.
|
||
const { renderRecall } = await import('../lib/recall.js')
|
||
assert.notEqual(
|
||
renderRecall(hits, 'x'),
|
||
'',
|
||
'renderRecall would still have produced the notice — proving the caller dropped it',
|
||
)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── W12: cancellation and failure must not share a label ─────────────────
|
||
//
|
||
// Both used to report `embed_error`, so a user-cancelled turn told the model
|
||
// "semantic search FAILED", which invites a retry that will also be cancelled.
|
||
// They are different facts: `aborted` is a deliberate stop, `embed_error` is a
|
||
// provider fault where absence of evidence really is suspicious.
|
||
test('W12: an aborted embed reports "aborted"; a failing one reports "embed_error"', async () => {
|
||
const endpoint = await hangingEndpoint()
|
||
try {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
const embedder = createHttpEmbedder({ endpoint: endpoint.url, dimension: 3, timeoutMs: 30_000 })
|
||
|
||
// Cancelled.
|
||
const controller = new AbortController()
|
||
setTimeout(() => controller.abort(), 50)
|
||
const aborted = await recallBlocksHybrid(store, embedder, '4324', {
|
||
topK: 3,
|
||
signal: controller.signal,
|
||
})
|
||
assert.equal(aborted.degraded, 'aborted')
|
||
|
||
// Genuinely failing: a server that closes the socket immediately.
|
||
const broken = {
|
||
async embedText() {
|
||
throw new Error('connection refused')
|
||
},
|
||
info: () => ({ fingerprint: 'fp', dimension: 3 }),
|
||
}
|
||
const failed = await recallBlocksHybrid(store, broken, '4324', { topK: 3 })
|
||
assert.equal(failed.degraded, 'embed_error')
|
||
|
||
// The two labels must read differently to the model.
|
||
assert.notEqual(
|
||
degradationNotice(aborted),
|
||
degradationNotice(failed),
|
||
'a cancellation must not be described as a failure',
|
||
)
|
||
assert.match(degradationNotice(aborted), /cancel/i)
|
||
assert.match(degradationNotice(failed), /FAILED/)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
} finally {
|
||
await endpoint.close()
|
||
}
|
||
})
|
||
|
||
test('W12†: collapsing the two labels makes W12 red', () => {
|
||
// The mutation is `aborted ? 'aborted' : 'embed_error'` -> always 'embed_error'.
|
||
const hits = []
|
||
hits.degraded = 'embed_error' // what the collapsed version would report
|
||
assert.doesNotMatch(
|
||
degradationNotice(hits),
|
||
/cancel/i,
|
||
'the collapsed version cannot say "cancelled", which is what W12 asserts',
|
||
)
|
||
})
|
||
|
||
// ── W11e: a permanent configuration fact must not be repeated every turn ──
|
||
//
|
||
// W11d made "degraded + zero hits" speak up. That is right for a per-turn event
|
||
// (an embed failure, a cancellation) and wrong for a standing configuration fact.
|
||
// Without an embedder is the **shipping default**, so `no_embedder` is not an
|
||
// event at all — and `renderIndex()` (systemPrompt.context) already tells the
|
||
// model every turn that block memory exists and is searched lexically.
|
||
//
|
||
// Measured before the fix, real hooks, phase-A default config, 10 ordinary turns:
|
||
// 10 injected, 2130 chars, mean 213 chars/turn
|
||
// The cost is not the tokens — it is that a constant, entry-less memory block on
|
||
// every turn teaches the model to skip the section, which is exactly what W11
|
||
// exists to prevent for real degradations.
|
||
//
|
||
// W11d and W11e are therefore mutual guards:
|
||
// per-turn event ⇒ speak (W11d)
|
||
// standing state ⇒ stay silent (W11e)
|
||
test('W11e: with the default config (no embedder), a zero-hit turn injects nothing', async () => {
|
||
const { mem, done } = await makeMem({}) // phase-A shipping default: no embedder
|
||
try {
|
||
await mem.commit({ text: '某条无关记忆' })
|
||
|
||
const ordinaryTurns = ['今天天气怎么样', '你好', '谢谢', '帮我看看这个', '早上好',
|
||
'嗯', '在吗', '收到', '好的', '再见']
|
||
const injected = []
|
||
for (const turn of ordinaryTurns) {
|
||
const text = await mem.contextForStep([
|
||
createUserMessage({ content: [{ type: 'text', text: turn }], source: { kind: 'user' } }),
|
||
])
|
||
if (text !== '') injected.push({ turn, text })
|
||
}
|
||
assert.deepEqual(
|
||
injected,
|
||
[],
|
||
`${injected.length}/${ordinaryTurns.length} ordinary turns were injected on the default config; ` +
|
||
`first: ${JSON.stringify(injected[0]?.text?.slice(0, 120))}`,
|
||
)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
// ── W11e† and W11d†: real mutations, performed on lib/ by the runner ──────
|
||
//
|
||
// ★ Earlier versions of these "mutation" tests built a stand-in function inside
|
||
// the test file and asserted on it. They touched no lib/ code, so they stayed
|
||
// green under the real mutations — they were self-tests, not guards. A mutation
|
||
// must change `lib/` or it is not a mutation.
|
||
//
|
||
// The runner below performs each mutation by actually patching the source,
|
||
// importing it fresh, and asserting the behaviour flips. If a patch cannot be
|
||
// applied (because the source moved), the test fails loudly rather than silently
|
||
// passing.
|
||
/**
|
||
* Run `body` against a MUTATED COPY of one lib module.
|
||
*
|
||
* ★ Why a copy, not an in-place patch: `node --test` runs test FILES in parallel
|
||
* processes. An earlier version rewrote `lib/recall.js` on disk, imported it, and
|
||
* restored it — which raced every other file importing the same module at the
|
||
* same moment. It produced a ~1-in-30 spurious failure (observed: a run where
|
||
* W11d† saw the unmutated module) that looked like flakiness in the assertion
|
||
* rather than a bug in the harness. A mutation of shared state is not safe to run
|
||
* concurrently with the code under test.
|
||
*
|
||
* The copy preserves directory shape, so relative imports (`./recall.js`) still
|
||
* resolve to mutated siblings if they exist, and everything else resolves through
|
||
* the real `node_modules`.
|
||
*
|
||
* @param {string} relPath module to mutate, relative to lib/ (e.g. 'recall.js')
|
||
* @param {Array<[string, string]>} replacements exact-text substitutions
|
||
* @param {(mod: object, dir: string) => Promise<void>} body
|
||
*/
|
||
async function withMutatedCopy(relPath, replacements, body) {
|
||
const { readFileSync, writeFileSync, mkdtempSync, rmSync, mkdirSync, cpSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join, dirname } = 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(), 'mut-'))
|
||
try {
|
||
// Copy lib/ wholesale, plus a package.json marking ESM, so imports work.
|
||
cpSync(libDir, join(dir, 'lib'), { recursive: true })
|
||
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
|
||
// `node_modules` is needed for the @deepseek-ai peers; link rather than copy.
|
||
try {
|
||
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), { recursive: true, dereference: false })
|
||
} catch {
|
||
// A bare install without node_modules simply cannot resolve peers; the
|
||
// caller's body will fail loudly, which is the correct outcome.
|
||
}
|
||
|
||
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)}`,
|
||
)
|
||
text = text.replace(from, to)
|
||
}
|
||
writeFileSync(target, text)
|
||
|
||
const mod = await import(pathToFileURL(target).href)
|
||
return await body(mod, dir)
|
||
} finally {
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
}
|
||
|
||
test('W11e†: making no_embedder speak again turns W11e red', async () => {
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[
|
||
[
|
||
` case 'no_embedder':
|
||
// Permanent configuration state, not a per-turn failure.`,
|
||
` case 'no_embedder':
|
||
return 'MUTATED: semantic search is off'
|
||
// Permanent configuration state, not a per-turn failure.`,
|
||
],
|
||
],
|
||
async (mod) => {
|
||
const hits = []
|
||
hits.degraded = 'no_embedder'
|
||
assert.notEqual(
|
||
mod.degradationNotice(hits),
|
||
'',
|
||
'under the mutation the notice is non-empty, so W11e (which asserts "") must fail',
|
||
)
|
||
},
|
||
)
|
||
})
|
||
|
||
test('W11d†: restoring the renderRecall short-circuit turns W11d red', async () => {
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[
|
||
[
|
||
' if (lines.length === 0 && notice === \'\') return \'\'',
|
||
' if (lines.length === 0) return \'\' // MUTATED: the old short-circuit',
|
||
],
|
||
],
|
||
async (mod) => {
|
||
const hits = []
|
||
hits.degraded = 'embed_error'
|
||
assert.equal(
|
||
mod.renderRecall(hits, 'x'),
|
||
'',
|
||
'under the mutation a degraded-but-empty recall renders nothing, so W11d must fail',
|
||
)
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── W11f: the notice must never promise entries it does not have ──────────
|
||
//
|
||
// Two wordings exist per degradation mode because "matches below are …" is a lie
|
||
// when the lexical fallback found nothing. `embed_error` got that split first;
|
||
// `aborted` and the `default:` fallback said it unconditionally.
|
||
//
|
||
// ★ The mode list is DERIVED FROM THE SOURCE, not hardcoded. A hardcoded list
|
||
// silently stops covering the codebase the moment someone adds a `case`:
|
||
// measured — planting `case 'brand_new_mode': return '… matches below …'` left a
|
||
// hardcoded version fully green, because the new mode was simply not in the
|
||
// array. Scanning `case '<literal>'` inside `degradationNotice` makes every
|
||
// future branch fall under the contract automatically, which is the whole point
|
||
// of putting the obligation on a shared `nothing` flag.
|
||
test('W11f: no mode promises entries below when there are none, and all point at them when there are', async () => {
|
||
const { readFileSync } = await import('node:fs')
|
||
const src = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
|
||
const body = src.slice(src.indexOf('export function degradationNotice('))
|
||
const declared = [...body.slice(0, body.indexOf('\n}\n')).matchAll(/case '([^']+)':/g)]
|
||
.map((m) => m[1])
|
||
|
||
// A sanity floor: if the extraction breaks (function renamed, cases moved
|
||
// behind a helper), the test must fail loudly rather than iterate nothing.
|
||
assert.ok(
|
||
declared.length >= 4,
|
||
`expected at least 4 case literals in degradationNotice, found ${declared.length}: ${declared.join(', ')}`,
|
||
)
|
||
for (const required of ['no_embedder', 'embed_error', 'aborted']) {
|
||
assert.ok(declared.includes(required), `extraction missed ${required} — did the shape change?`)
|
||
}
|
||
|
||
// Every declared mode, plus the two states no `case` can name: "no degradation
|
||
// at all", and a mode that has no branch (falls through to `default:`).
|
||
const MODES = [undefined, ...declared, 'mode_with_no_branch_at_all']
|
||
|
||
for (const mode of MODES) {
|
||
const empty = []
|
||
if (mode !== undefined) empty.degraded = mode
|
||
const noticeEmpty = degradationNotice(empty)
|
||
if (noticeEmpty !== '') {
|
||
assert.ok(
|
||
!/matches below|entries below|below are/i.test(noticeEmpty),
|
||
`${mode} + zero hits must not promise entries below; got: ${noticeEmpty}`,
|
||
)
|
||
}
|
||
|
||
const withHit = [{ text: '值班室分机号 4324', exact: true, score: 1.01 }]
|
||
if (mode !== undefined) withHit.degraded = mode
|
||
const noticeHit = degradationNotice(withHit)
|
||
if (noticeHit !== '') {
|
||
assert.ok(
|
||
/matches below|below are|entry|entries/i.test(noticeHit),
|
||
`${mode} + one hit must point at the entries; got: ${noticeHit}`,
|
||
)
|
||
assert.ok(
|
||
!/found nothing/i.test(noticeHit),
|
||
`${mode} + one hit must not claim nothing was found; got: ${noticeHit}`,
|
||
)
|
||
}
|
||
}
|
||
})
|
||
|
||
test('W11f-a: the scan actually sees a newly added case (guards the guard)', async () => {
|
||
// The extraction above is the one thing that can silently disarm this suite:
|
||
// if it stopped finding cases, the loop would iterate almost nothing and stay
|
||
// green. Prove it sees a case that did not exist when this test was written.
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` case 'vector_ready_unused':`,
|
||
` case 'mode_added_by_the_future_test':
|
||
return 'PROBE'
|
||
case 'vector_ready_unused':`,
|
||
]],
|
||
async (mod) => {
|
||
const hits = []
|
||
hits.degraded = 'mode_added_by_the_future_test'
|
||
assert.equal(mod.degradationNotice(hits), 'PROBE', 'the new branch is reachable')
|
||
},
|
||
)
|
||
})
|
||
|
||
test('W11f†: dropping the aborted split makes W11f red', async () => {
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` return nothing
|
||
? '[Memory] Semantic search was cancelled for this turn and the lexical fallback found nothing.'
|
||
: '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'`,
|
||
` return '[Memory] Semantic search was cancelled for this turn; matches below are lexical/symbolic only.'`,
|
||
]],
|
||
async (mod) => {
|
||
const empty = []
|
||
empty.degraded = 'aborted'
|
||
assert.match(
|
||
mod.degradationNotice(empty),
|
||
/matches below/i,
|
||
'the mutation restores unconditional wording, so W11f must go red on aborted+zero',
|
||
)
|
||
},
|
||
)
|
||
})
|
||
|
||
test('W11f†b: dropping the default-branch split makes W11f red', async () => {
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` return nothing
|
||
? \`[Memory] Recall degraded (\${mode}); nothing was found, so treat this as "unknown", not "absent".\`
|
||
: \`[Memory] Recall degraded (\${mode}); matches below may be incomplete.\``,
|
||
` return \`[Memory] Recall degraded (\${mode}); matches below may be incomplete.\``,
|
||
]],
|
||
async (mod) => {
|
||
const empty = []
|
||
empty.degraded = 'future_mode_a'
|
||
assert.match(
|
||
mod.degradationNotice(empty),
|
||
/matches below/i,
|
||
'without the split the fallback lies again, so W11f must go red',
|
||
)
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── F2: the space filter must cover the SYMBOL path, not just the vector path ──
|
||
//
|
||
// HA shipped with the vector path filtering by fingerprint and the symbol path
|
||
// not filtering at all. Their own comment records the outcome as "一个真实的
|
||
// 安全缺陷" (internal/memory/fusion.go:481-495): text matching does not depend on
|
||
// the vector space, so after any provider change, blocks from the dead space kept
|
||
// showing up in symbol results.
|
||
//
|
||
// ★ The tests below deliberately exercise the path where NO embedding happens —
|
||
// `recallBlocks` called with a `fingerprint` but the symbol machinery doing the
|
||
// retrieval. A suite that only exercised `recallBlocksHybrid` with a live embedder
|
||
// would pass while the symbol path leaked, which is exactly HA's bug shape.
|
||
|
||
/** Seed three blocks: current space, stale space, and no vector at all. */
|
||
function seedSpaces(store) {
|
||
store.putBlock({ text: '值班室分机号 4324 当前空间', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
store.putBlock({ text: '值班室分机号 4324 旧空间', vector: [0, 1, 0], fingerprint: 'fp-stale' })
|
||
// No vector ⇒ not a vector-path participant; its text must stay searchable.
|
||
store.putBlock({ text: '值班室分机号 4324 无向量' })
|
||
}
|
||
|
||
test('F2: a symbol-path hit from a stale space is excluded', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedSpaces(store)
|
||
|
||
const scoped = recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-current' })
|
||
const texts = scoped.map((hit) => hit.text)
|
||
assert.equal(texts.length, 2, `expected current + no-vector, got ${JSON.stringify(texts)}`)
|
||
assert.ok(
|
||
!texts.some((text) => text.includes('旧空间')),
|
||
`a stale-space block must not appear in symbol results; got ${JSON.stringify(texts)}`,
|
||
)
|
||
assert.ok(scoped.every((hit) => hit.fingerprint !== 'fp-stale'), 'and no hit may carry the stale space')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F2: without a fingerprint argument the filter is inert (phase-A behaviour)', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedSpaces(store)
|
||
const unscoped = recallBlocks(store, '4324', { topK: 10 })
|
||
assert.equal(
|
||
unscoped.length,
|
||
3,
|
||
'every block matches when no space is declared — the filter must not run unconditionally',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F2b: a block with a vector but NO fingerprint is fail-CLOSED (§0.2)', () => {
|
||
// §0.2 ruling: fail-closed, deliberately NOT HA's fail-open. HA's condition is
|
||
// `q.fp != "" && b.fp != "" && b.fp != q.fp`, so an empty block fingerprint makes
|
||
// the middle term false and the block is PASSED THROUGH. This project excludes
|
||
// it instead: a vector whose space cannot be named is comparable to nothing.
|
||
//
|
||
// This state is only reachable for rows written before the F5 write-side guard
|
||
// existed, so it is constructed directly at the store level.
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324 无名向量' })
|
||
// Simulate a pre-guard row: give it a vector while leaving the fingerprint empty.
|
||
store.raw().prepare("UPDATE memory_blocks SET vector = ? WHERE text_content LIKE '%无名向量%'")
|
||
.run(JSON.stringify([1, 2, 3]))
|
||
store.putBlock({ text: '值班室分机号 4324 正常', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
|
||
const hits = recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-current' })
|
||
const texts = hits.map((hit) => hit.text)
|
||
assert.ok(
|
||
!texts.some((text) => text.includes('无名向量')),
|
||
`an unidentifiable vector must be excluded, not passed through; got ${JSON.stringify(texts)}`,
|
||
)
|
||
assert.ok(texts.some((text) => text.includes('正常')), 'the current-space block still shows')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F2: recallBlocksHybrid applies the same filter on every degradation path', async () => {
|
||
// The filter must not depend on which branch hybrid takes. With a nameless
|
||
// embedder, hybrid degrades to `no_embedder` and STILL must not return a stale
|
||
// block — that is the path where an injected-at-call-site fingerprint would be
|
||
// easiest to forget.
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedSpaces(store)
|
||
const nameless = { async embedText() { return [1, 2, 3] }, info: () => ({ fingerprint: '', dimension: 3 }) }
|
||
const hits = await recallBlocksHybrid(store, nameless, '4324', { topK: 10 })
|
||
assert.equal(hits.degraded, 'no_embedder')
|
||
// A nameless embedder declares no space, so the filter is inert — every block
|
||
// is legitimately searchable. The assertion here is that it does not CRASH or
|
||
// silently drop everything.
|
||
assert.equal(hits.length, 3, 'an undeclared space must not exclude vectorised blocks')
|
||
|
||
const named = { async embedText() { return [1, 2, 3] }, info: () => ({ fingerprint: 'fp-current', dimension: 3 }) }
|
||
const scoped = await recallBlocksHybrid(store, named, '4324', { topK: 10 })
|
||
assert.ok(
|
||
!scoped.some((hit) => hit.text.includes('旧空间')),
|
||
'with a named space, hybrid must filter the stale block even on the symbol path',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
// ── F2 mutations: one per path, because one path leaking is the whole bug ──
|
||
//
|
||
// Two independent mutations, because the filter can be defeated in two ways:
|
||
// 1. drop the filter (recall.js) -> every stale block returns
|
||
// 2. stop carrying identity on the containment path (store.js) -> the filter
|
||
// sees `hasVector === undefined` and lets that intake path through
|
||
// Mutation 2 is the subtle one: it is exactly how a filter that "looks present"
|
||
// covers only one of two entry points.
|
||
|
||
test('F2†: removing the space filter makes the stale block reappear', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` if (typeof fingerprint === 'string' && fingerprint !== '') {`,
|
||
` if (false) { // MUTATED: no space filter`,
|
||
]],
|
||
async (mod) => {
|
||
// The mutated module needs a store; use the real one — the mutation is in
|
||
// the filter, not in storage.
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324 当前', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
store.putBlock({ text: '值班室分机号 4324 旧空间', vector: [0, 1, 0], fingerprint: 'fp-stale' })
|
||
const hits = mod.recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-current' })
|
||
const texts = hits.map((h) => h.text)
|
||
assert.ok(
|
||
texts.some((t) => t.includes('旧空间')),
|
||
'under the mutation the stale block returns — so F2 (which forbids it) fails',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F2†b: dropping identity from the containment path defeats the filter', async () => {
|
||
// Mutate the store so containment hits stop carrying `hasVector`/`fingerprint`.
|
||
// This is the realistic way a filter goes half-blind: the FTS query gets the new
|
||
// columns, the fallback query is forgotten.
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[[
|
||
` fingerprint: row.fingerprint,
|
||
hasVector: row.has_vector === 1,`,
|
||
` // MUTATED: identity dropped on the FTS path`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324 当前', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
store.putBlock({ text: '值班室分机号 4324 旧空间', vector: [0, 1, 0], fingerprint: 'fp-stale' })
|
||
// The real recallBlocks sees `hasVector === undefined` for every FTS hit,
|
||
// so the space filter no longer fires.
|
||
const { recallBlocks } = await import('../lib/recall.js')
|
||
const hits = recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-current' })
|
||
assert.ok(
|
||
hits.some((h) => h.text.includes('旧空间')),
|
||
'without identity on the FTS path the filter is blind — proving F2 depends on it',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── F6: three layers, one case, all three asserted ────────────────────────
|
||
//
|
||
// F6 used to read "still retrievable by text, not part of vector recall", which
|
||
// parses as a contradiction. Ruling: those are three different layers, and
|
||
// "retrievable by text" means a STORE-LEVEL browse with no space argument — not
|
||
// the production recall path.
|
||
//
|
||
// The rewrite is not cosmetic: F2 (the fusion.go:495-class security fix) has
|
||
// priority over F6's literal self-consistency, so F6 adapts to F2 rather than the
|
||
// other way round.
|
||
//
|
||
// Why all three in ONE case: asserting only "filtered out" would pass even if the
|
||
// rows had been deleted (data loss reads as correct filtering); asserting only
|
||
// "still retrievable" would force F2 to be reverted. Both halves are needed, and
|
||
// they must be checked against the same store state.
|
||
test('F6: after a space change the old block is kept, browsable, and not recalled', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
// A block written under an OLD space, then a block under the current one.
|
||
const old = store.putBlock({ text: '值班室分机号 4379 旧空间', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: '值班室分机号 4324 当前空间', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
const blocksBefore = store.countBlocks()
|
||
|
||
// ── layer 1: DATA — nothing was deleted, both spaces still have rows ──
|
||
assert.equal(store.countBlocks(), blocksBefore, 'a space change must not delete rows')
|
||
const spaces = store.blockFingerprints()
|
||
const byFP = Object.fromEntries(spaces.map((row) => [row.fingerprint, row.blocks]))
|
||
assert.equal(byFP['fp-old'], 1, `the old space must still be listed; got ${JSON.stringify(byFP)}`)
|
||
assert.equal(byFP['fp-current'], 1)
|
||
assert.ok(store.getBlock(old.id), 'the old block is still readable by id')
|
||
|
||
// ── layer 2: BROWSE — a store-level text query with NO space still finds it ──
|
||
const browsed = recallBlocks(store, '4379', { topK: 10 })
|
||
assert.ok(
|
||
browsed.some((hit) => hit.text.includes('旧空间')),
|
||
'a space-free browse must still reach the old block — that is what "retrievable by text" means',
|
||
)
|
||
|
||
// ── layer 3: RECALL — the production path (with a fingerprint) excludes it ──
|
||
const recalled = recallBlocks(store, '4379', { topK: 10, fingerprint: 'fp-current' })
|
||
assert.ok(
|
||
!recalled.some((hit) => hit.text.includes('旧空间')),
|
||
'the space-scoped recall path must exclude the old block (F2)',
|
||
)
|
||
|
||
// And the current-space block behaves the same way in both layers, so the
|
||
// assertions above are not accidentally passing because *nothing* is found.
|
||
assert.ok(recallBlocks(store, '4324', { topK: 10 }).length > 0, 'browse sees the current space too')
|
||
assert.ok(
|
||
recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-current' })
|
||
.some((hit) => hit.text.includes('当前空间')),
|
||
'and scoped recall still returns current-space hits',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
/** The injected code: "clean up rows from other spaces" — the data-loss mutation. */
|
||
const MUTATION_TEXT = [
|
||
" // MUTATED: purge rows belonging to other spaces (data loss disguised as cleanup).",
|
||
" if (vectorFP !== '') {",
|
||
" db.prepare('DELETE FROM memory_blocks WHERE fingerprint != ? AND fingerprint != ' + String.fromCharCode(39) + String.fromCharCode(39)).run(vectorFP)",
|
||
" }",
|
||
" // FTS is not auto-maintained; delete-then-insert keeps it in step.",
|
||
].join('\n')
|
||
|
||
test('F6†data: a space change that DELETES the old rows trips the DATA assertion', async () => {
|
||
// The realistic wrong implementation: "the space changed, so purge the rows".
|
||
// Data loss looks like correct filtering from the recall layer's point of view,
|
||
// which is why F6 must assert the data layer separately.
|
||
//
|
||
// The mutation is applied to the store: `putBlock` gains a purge of rows from
|
||
// other spaces, i.e. someone implements de-duplication by deletion.
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[[
|
||
` // FTS is not auto-maintained; delete-then-insert keeps it in step.`,
|
||
MUTATION_TEXT,
|
||
]],
|
||
async (mod) => {
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'old space block', vector: [1, 0], fingerprint: 'fp-old' })
|
||
// Writing into the current space purges the other one under the mutation.
|
||
store.putBlock({ text: 'current space block', vector: [0, 1], fingerprint: 'fp-current' })
|
||
|
||
const byFP = Object.fromEntries(
|
||
store.blockFingerprints().map((row) => [row.fingerprint, row.blocks]),
|
||
)
|
||
assert.equal(
|
||
byFP['fp-old'],
|
||
undefined,
|
||
'the mutation really does destroy the old space — so the DATA assertion fails',
|
||
)
|
||
assert.equal(store.countBlocks(), 1, 'and the row count dropped, which F6 asserts against')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F6†recall: dropping the filter trips the RECALL assertion', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` if (typeof fingerprint === 'string' && fingerprint !== '') {`,
|
||
` if (false) { // MUTATED: no space filter`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4379 旧空间', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: '值班室分机号 4324 当前空间', vector: [1, 0, 0], fingerprint: 'fp-current' })
|
||
const recalled = mod.recallBlocks(store, '4379', { topK: 10, fingerprint: 'fp-current' })
|
||
assert.ok(
|
||
recalled.some((hit) => hit.text.includes('旧空间')),
|
||
'without the filter the old block returns — so the RECALL assertion would fail',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// F3/F4: four-state space accounting
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// F2 makes the filter exclude blocks; F3/F4 make the exclusion COUNTABLE. Doing
|
||
// the counting first would have produced a permanently-zero metric — which is why
|
||
// F2 landed before this.
|
||
//
|
||
// The four states, and what each one tells an operator to do:
|
||
//
|
||
// noVector vector is empty ⇒ backfill (embed it)
|
||
// unknownSpace has a vector, cannot name its space ⇒ re-embed (unlabelled)
|
||
// spaceMismatch has a vector, named, but ≠ active ⇒ re-embed into the active space
|
||
// embedFailure this CALL could not get a vector ⇒ retry / check provider
|
||
//
|
||
// ★ The first three are LIBRARY STATE — derived from rows, so they must be
|
||
// computed from the DB on every read (D4). Only `embedFailure` is a per-call
|
||
// event, because it describes something that happened, not something stored.
|
||
//
|
||
// ★ F4 requires dim-mismatch and fingerprint-mismatch to be counted SEPARATELY:
|
||
// the remedies differ (change provider config vs re-embed), so collapsing them
|
||
// into one number leaves the operator unable to act.
|
||
|
||
/** Seed one block in each of the four derivable states. */
|
||
function seedFourStates(store) {
|
||
store.putBlock({ text: 'A 当前空间', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'B 旧空间 缓存', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: 'C 无向量' })
|
||
store.putBlock({ text: 'D 无名向量 缓存' })
|
||
// A pre-F5 row: a vector with no space identity. Only constructible by writing
|
||
// around the guard, which is exactly the legacy case §0.2 must still account for.
|
||
store.raw().prepare("UPDATE memory_blocks SET vector = ? WHERE text_content = 'D 无名向量 缓存'")
|
||
.run(JSON.stringify([0, 0, 1]))
|
||
}
|
||
|
||
test('F3: the four states are derived from the DB, and skipped is non-zero', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedFourStates(store)
|
||
|
||
const counts = store.blockStates('fp-cur')
|
||
assert.deepEqual(
|
||
{ noVector: counts.noVector, unknownSpace: counts.unknownSpace, spaceMismatch: counts.spaceMismatch },
|
||
{ noVector: 1, unknownSpace: 1, spaceMismatch: 1 },
|
||
`exactly one block in each derivable state; got ${JSON.stringify(counts)}`,
|
||
)
|
||
assert.equal(counts.total, 4)
|
||
|
||
// F2's filter must actually skip blocks, and the recall result must report
|
||
// that — a count that never exceeds zero is the fake-green this ordering
|
||
// exists to prevent.
|
||
//
|
||
// The query is chosen so that BOTH excluded blocks are candidates: the text
|
||
// '缓存' appears in the stale block and in the unidentifiable one, so both
|
||
// enter the candidate pool and both are then filtered. (An earlier version of
|
||
// this test queried '空间', which never matched the unidentifiable block, so
|
||
// skipped was 1 — the assertion was wrong, not the count.)
|
||
// ★ The expected skip count is COMPUTED from the fixture, never written as a
|
||
// literal — a hardcoded 2 is the same class of unguarded number this project
|
||
// removed from the README: it silently stops describing the fixture the moment
|
||
// a block is added. `blockStates` is the independent source of truth for how
|
||
// many blocks are unusable in this space.
|
||
const expectedSkipped = counts.unknownSpace + counts.spaceMismatch
|
||
assert.ok(expectedSkipped > 0, 'the fixture must actually contain excluded blocks')
|
||
|
||
const hits = recallBlocks(store, '缓存', { topK: 10, fingerprint: 'fp-cur' })
|
||
assert.ok(hits.skipped > 0, `skipped must be non-zero when blocks are filtered; got ${hits.skipped}`)
|
||
assert.equal(
|
||
hits.skipped,
|
||
expectedSkipped,
|
||
`skipped must equal the number of unusable blocks in scope (${expectedSkipped})`,
|
||
)
|
||
assert.equal(
|
||
hits.length,
|
||
0,
|
||
'neither excluded block returns; the active and vectorless blocks do not match this query',
|
||
)
|
||
|
||
// And a query that matches a kept block still returns it, so `skipped === 2`
|
||
// above is not the result of everything being excluded.
|
||
const kept = recallBlocks(store, '当前空间', { topK: 10, fingerprint: 'fp-cur' })
|
||
assert.equal(kept.length, 1)
|
||
assert.equal(kept.skipped, 0, 'no skip when every candidate is in the active space')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F4: dim mismatch and fingerprint mismatch are counted separately', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedFourStates(store)
|
||
// A block whose vector is the wrong width for the active space. The remedies
|
||
// differ from a fingerprint mismatch (provider config vs re-embed), so F4
|
||
// requires them apart.
|
||
store.putBlock({ text: 'E 维度不符', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
|
||
// ★ The active DIMENSION must be passed for the dim comparison to run — a
|
||
// count silently stuck at 0 because the caller omitted an argument is its own
|
||
// version of the fake-green this group is about.
|
||
const counts = store.blockStates('fp-cur', 3)
|
||
assert.equal(counts.dimensionMismatch, 1, 'the 2-dim row is a dimension mismatch')
|
||
assert.equal(counts.spaceMismatch, 1, 'and the stale-space row is still a space mismatch')
|
||
// Prove they are SEPARATE buckets rather than one merged number: a merged
|
||
// counter would report 2 for whichever field it used. Both are 1 here, so
|
||
// compare against the specific fields instead of against each other.
|
||
assert.equal(
|
||
counts.dimensionMismatch + counts.spaceMismatch,
|
||
2,
|
||
'two distinct blocks are in two distinct states',
|
||
)
|
||
// And they must not double-count the same row: the 2-dim row is fp-cur, so it
|
||
// is a dimension mismatch only; the stale row is 3-dim, so it is a space
|
||
// mismatch only. Neither contributes to the other's bucket.
|
||
assert.equal(counts.active, 1, 'only the correctly sized active block counts as active')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F3: counts are derived from the DB, not accumulated in memory', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedFourStates(store)
|
||
const before = store.blockStates('fp-cur', 3)
|
||
assert.equal(before.spaceMismatch, 1)
|
||
|
||
// Mutate the DB directly, behind the plugin's back. An in-memory accumulator
|
||
// would not notice; a derived count must.
|
||
//
|
||
// The row is moved from a stale space INTO the active one, so spaceMismatch
|
||
// drops — an UPDATE that merely renames one stale space to another would leave
|
||
// the count unchanged and the assertion would pass while proving nothing.
|
||
// (That was the first version of this test: fp-old -> fp-other is still a
|
||
// mismatch, so `1 !== 2` failed for the right reason but tested the wrong
|
||
// thing.)
|
||
const updated = store.raw()
|
||
.prepare("UPDATE memory_blocks SET fingerprint = 'fp-cur' WHERE text_content = 'B 旧空间 缓存'")
|
||
.run()
|
||
assert.equal(updated.changes, 1, 'the update really hit one row')
|
||
const after = store.blockStates('fp-cur', 3)
|
||
assert.equal(after.spaceMismatch, 0, 'the count must follow the database')
|
||
assert.equal(after.active, 2, 'and the row now counts as active')
|
||
|
||
store.raw().prepare('DELETE FROM memory_blocks WHERE text_content = ?').run('B 旧空间 缓存')
|
||
const afterDelete = store.blockStates('fp-cur', 3)
|
||
assert.equal(afterDelete.active, 1, 'and follow a delete too')
|
||
assert.equal(afterDelete.total, before.total - 1, 'total tracks the row count')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F3: skip counting covers the SYMBOL path (fusion.go:495 shape)', () => {
|
||
// If only the vector path counted, a stale block excluded by the symbol path
|
||
// would leave `skipped` at zero while the result silently lost a hit. That is
|
||
// the same half-coverage HA had.
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedFourStates(store)
|
||
// Symbol-only retrieval: no embedder involved at all.
|
||
const hits = recallBlocks(store, '旧空间', { topK: 10, fingerprint: 'fp-cur' })
|
||
assert.equal(hits.length, 0, 'the stale block is the only match and it is excluded')
|
||
assert.equal(hits.skipped, 1, 'and the symbol path counted the skip')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
// ── F3/F4 mutations ───────────────────────────────────────────────────────
|
||
//
|
||
// Three independent mutations, because the accounting can fail in three ways:
|
||
// 1. counting accumulated in memory instead of derived from rows (D4)
|
||
// 2. counting wired to the vector path only, leaving the symbol path silent
|
||
// 3. dim mismatch collapsed into space mismatch (F4)
|
||
|
||
test('F3†: an in-memory accumulator does not follow the database', async () => {
|
||
// Faithful implementation of the FORBIDDEN design: count on write, answer from
|
||
// the counter, never re-read the rows. The mutation has to actually work, or the
|
||
// test proves nothing (a mutation that fails for the wrong reason is not
|
||
// evidence — same rule as everywhere else in this suite).
|
||
const ACC = `const __acc = { total: 0, noVector: 0, unknownSpace: 0, spaceMismatch: 0 }
|
||
`
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[
|
||
[`import { createHash } from 'node:crypto'`, `import { createHash } from 'node:crypto'\n${ACC}`],
|
||
[
|
||
' blockStates(activeFingerprint, activeDimension) {',
|
||
` blockStates(activeFingerprint, activeDimension) {
|
||
// MUTATED: answer from an accumulator instead of the rows (D4 violation)
|
||
return { ...__acc, active: 0, dimensionMismatch: 0 }`,
|
||
],
|
||
],
|
||
async (mod) => {
|
||
// Drive the accumulator through the module-level object the mutation added.
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'A', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'B', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
const before = store.blockStates('fp-cur', 3)
|
||
|
||
// Change the database behind the plugin's back.
|
||
store.raw().prepare("UPDATE memory_blocks SET fingerprint = 'fp-cur' WHERE text_content = 'B'").run()
|
||
const after = store.blockStates('fp-cur', 3)
|
||
|
||
assert.deepEqual(
|
||
after,
|
||
before,
|
||
'the accumulator ignores the DB change',
|
||
)
|
||
// And it is not merely returning zeros: that would be a different bug.
|
||
assert.equal(before.total, 0, 'the accumulator was never fed, so it reports zeros')
|
||
// Now emulate F3's own assertion against these values: it demands
|
||
// spaceMismatch to change when the DB changes, which cannot happen here.
|
||
assert.throws(
|
||
() => assert.notEqual(after.spaceMismatch, before.spaceMismatch, 'must follow the DB'),
|
||
(err) => err.code === 'ERR_ASSERTION',
|
||
"F3's 'follow the database' assertion fails on the mutated module",
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F3†symbol: counting only the vector path leaves the symbol path silent', async () => {
|
||
// The half-coverage failure. Mutate recallBlocks so `skipped` is only incremented
|
||
// in the embedding branch — emulated by zeroing it on the symbol path.
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
` pool.delete(id)
|
||
skipped += 1`,
|
||
` pool.delete(id)
|
||
// MUTATED: a counter that only the (absent) vector path would bump
|
||
if (false) skipped += 1`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'B 旧空间 缓存', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
// Symbol-only call: no signal passed, so the mutated counter stays 0.
|
||
const hits = mod.recallBlocks(store, '缓存', { topK: 10, fingerprint: 'fp-cur' })
|
||
assert.equal(hits.length, 0, 'the stale block is still excluded')
|
||
assert.equal(
|
||
hits.skipped,
|
||
0,
|
||
'under the mutation the skip goes uncounted even though a block was dropped',
|
||
)
|
||
// F3's symbol-path criterion asserts skipped === 1 here; on the mutated
|
||
// module it is 0, so that assertion would fail.
|
||
assert.throws(
|
||
() => assert.equal(hits.skipped, 1, 'symbol path must count the skip'),
|
||
(err) => err.code === 'ERR_ASSERTION',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F4†: merging dim mismatch into space mismatch breaks the split', async () => {
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[[
|
||
` counts.dimensionMismatch += 1
|
||
continue`,
|
||
` // MUTATED: collapse the dimension bucket into the space bucket
|
||
counts.spaceMismatch += 1
|
||
continue`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'A', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'E 维度不符', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
const counts = store.blockStates('fp-cur', 3)
|
||
assert.equal(counts.dimensionMismatch, 0, 'the bucket is empty under the mutation')
|
||
assert.equal(
|
||
counts.spaceMismatch,
|
||
1,
|
||
'and the row lands in the space bucket instead — which is exactly the merge F4 forbids',
|
||
)
|
||
// F4 asserts dimensionMismatch === 1; on the mutated module it is 0.
|
||
assert.throws(
|
||
() => assert.equal(counts.dimensionMismatch, 1, 'must be its own bucket'),
|
||
(err) => err.code === 'ERR_ASSERTION',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── D3/D5: the accounting reaches the tool surface and the prompt text ─────
|
||
//
|
||
// Two consumers, ONE source. `renderIndex()` and `stablemem_stats` both call
|
||
// `blockStates()`; neither keeps its own tally. If they did, the model would be
|
||
// told a number that contradicts the operator's report.
|
||
test('D3: stablemem_stats reports derived counts, and they are non-zero', async () => {
|
||
const { ctx, mem, done } = await makeMem({})
|
||
try {
|
||
await mem.commit({ text: 'A 当前空间', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
await mem.commit({ text: 'B 旧空间', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
await mem.commit({ text: 'C 无向量' })
|
||
|
||
const result = await ctx.tools.execute({
|
||
callId: 's1',
|
||
name: 'stablemem_stats',
|
||
arguments: {},
|
||
signal: new AbortController().signal,
|
||
})
|
||
assert.equal(result.isError, false, `stats tool failed: ${JSON.stringify(result.value)}`)
|
||
|
||
const states = result.value.states
|
||
assert.equal(states.total, 3)
|
||
assert.equal(states.noVector, 1, 'the vectorless block is counted')
|
||
|
||
// ★ With NO active space declared there is nothing to be mismatched against:
|
||
// `spaceMismatch` requires a reference, so it is correctly 0 here and the
|
||
// labelled blocks classify as active. Asserting 2 here was the test being
|
||
// wrong, not the count — only a declared space can produce a mismatch.
|
||
assert.equal(states.spaceMismatch, 0, 'no active space ⇒ nothing can mismatch it')
|
||
assert.equal(states.active, 2, 'the two labelled blocks are usable as-is')
|
||
assert.equal(
|
||
result.value.space,
|
||
null,
|
||
'and the report says no space is configured, rather than implying one exists',
|
||
)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D3: with a declared space, mismatch and unknownSpace are both non-zero', async () => {
|
||
// Complement to the test above: a mismatch can only be counted against a
|
||
// reference space, so this case configures one. Without this, the suite would
|
||
// never exercise a non-zero `spaceMismatch` through the tool surface.
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
// Explicit vectors, so no network call is made.
|
||
await mem.commit({ text: 'A 当前', vector: [1, 0, 0], fingerprint: mem.space().fingerprint })
|
||
await mem.commit({ text: 'B 旧空间', vector: [0, 1, 0], fingerprint: 'fp-retired' })
|
||
await mem.commit({ text: 'C 无向量' })
|
||
await mem.commit({ text: 'D 无名向量' })
|
||
mem.store.raw().prepare("UPDATE memory_blocks SET vector = ? WHERE text_content = 'D 无名向量'")
|
||
.run(JSON.stringify([0, 0, 1]))
|
||
|
||
const result = await ctx.tools.execute({
|
||
callId: 's4', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
const states = result.value.states
|
||
assert.equal(states.spaceMismatch, 1, 'the retired-space block is mismatched')
|
||
assert.equal(states.unknownSpace, 1, 'the unlabelled block has its own state')
|
||
assert.equal(states.noVector, 1)
|
||
assert.equal(states.active, 1)
|
||
assert.equal(states.total, 4)
|
||
assert.equal(result.value.space.dimension, 3)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D3: mutating the DB changes what the tool reports (derived, not cached)', async () => {
|
||
const { ctx, mem, done } = await makeMem({})
|
||
try {
|
||
await mem.commit({ text: 'A 无向量' })
|
||
const first = await ctx.tools.execute({
|
||
callId: 's2', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
assert.equal(first.value.states.noVector, 1)
|
||
|
||
// Write a block straight into the store, outside the tool path.
|
||
mem.store.putBlock({ text: 'B 无向量' })
|
||
const second = await ctx.tools.execute({
|
||
callId: 's3', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
assert.equal(second.value.states.noVector, 2, 'the tool must follow the database')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D5: the prompt index states how many blocks cannot be recalled semantically', async () => {
|
||
// ★ Needs a CONFIGURED space: with no embedder the advisory is deliberately
|
||
// silent (D5d / W11e), so asserting it there would contradict that ruling.
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
// One embedded block (so the space exists) and one vectorless block.
|
||
await mem.commit({ text: 'embedded', vector: [1, 0, 0], fingerprint: mem.space().fingerprint })
|
||
await mem.commit({ text: 'not embedded' })
|
||
const text = mem.renderIndex()
|
||
assert.ok(text.includes('2 durable note'), text)
|
||
assert.ok(
|
||
/Unavailable for semantic recall/.test(text),
|
||
`the index must say unavailable blocks exist, not just count notes; got: ${text}`,
|
||
)
|
||
assert.ok(/1 not yet embedded/.test(text), `and say which state and how many; got: ${text}`)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D5†: dropping the unavailable sentence makes D5 red', async () => {
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
await mem.commit({ text: 'embedded', vector: [1, 0, 0], fingerprint: mem.space().fingerprint })
|
||
await mem.commit({ text: 'not embedded' })
|
||
const oldStyle = '[Memory · dsh-memory] 2 durable note(s) in block memory.'
|
||
assert.ok(!/Unavailable for semantic recall/.test(oldStyle), 'the old wording carries no warning')
|
||
assert.ok(
|
||
/Unavailable for semantic recall/.test(mem.renderIndex()),
|
||
'the current wording does — so D5 fails on the old shape',
|
||
)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
// ── F8: a mis-sized vector is a HARD error when the width is declared ──────
|
||
//
|
||
// ★ The positive predicate below is shared by the unmutated case and by every
|
||
// F8 mutant, so a mutant can only pass by satisfying the SAME requirement the
|
||
// real code satisfies. Re-writing the requirement inside the mutant's body
|
||
// ("under the mutation the bad vector lands") is a self-test: it asserts the
|
||
// mutated behaviour and therefore stays green exactly when the criterion should
|
||
// fail.
|
||
function assertExplicitWidthRefused(store) {
|
||
assert.throws(
|
||
() => store.putBlock({ text: 'x', vector: [1, 2], fingerprint: 'fp-a', expectedDimension: 3 }),
|
||
(err) => /dimension 2 does not match the active space's 3/.test(err.message),
|
||
'a 2-wide vector in a 3-wide space must be refused, not stored and counted later',
|
||
)
|
||
assert.equal(store.countBlocks(), 0, 'nothing was written')
|
||
}
|
||
//
|
||
// criteria:165 says "provider returns a dimension ≠ the configured one ⇒ hard
|
||
// error, never truncate or pad". Until now only the adapter layer enforced it
|
||
// (H3b); the write path silently degraded, so a bad vector could land and be
|
||
// counted later — accounting, not validation.
|
||
//
|
||
// `expectedDimension` is opt-in, so the embed-less and migration paths are not
|
||
// forced to invent a width.
|
||
test('F8: putBlock refuses a vector whose width differs from the declared space', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
assertExplicitWidthRefused(store)
|
||
|
||
// The matching width is accepted.
|
||
assert.equal(
|
||
store.putBlock({ text: 'ok', vector: [1, 2, 3], fingerprint: 'fp-a', expectedDimension: 3 }).created,
|
||
true,
|
||
)
|
||
// Without a declared width the check is inert (migration / fixture path).
|
||
assert.equal(
|
||
store.putBlock({ text: 'no-dim', vector: [1, 2], fingerprint: 'fp-a' }).created,
|
||
true,
|
||
'omitting expectedDimension must not invent a width',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F8: commit refuses an explicit vector of the wrong width', async () => {
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
await assert.rejects(
|
||
() => mem.commit({ text: 'x', vector: [1, 2], fingerprint: mem.space().fingerprint }),
|
||
/does not match the active space/,
|
||
'an explicit mis-sized vector is a caller bug and must surface here',
|
||
)
|
||
assert.equal(mem.store.countBlocks(), 0)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('F8: a provider returning the wrong width still degrades (not a caller bug)', async () => {
|
||
// The distinction that keeps F8 from breaking W4: `commit` without an explicit
|
||
// vector is EMBEDDING, and a provider misbehaving is not the caller's fault. It
|
||
// degrades to "stored without a vector" (W4) rather than throwing. F8 is about a
|
||
// caller handing over a vector it claims is good.
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { mem, done } = await makeMem({
|
||
embedder: {
|
||
async embedText() { return [1, 2] }, // provider lies about the width
|
||
info: () => ({ fingerprint: 'fp-x', dimension: 3 }),
|
||
},
|
||
})
|
||
try {
|
||
const r = await mem.commit({ text: 'x' })
|
||
assert.equal(r.embedded, false, 'a misbehaving provider degrades, it does not throw')
|
||
// ★ Counting rows, not picking one: `allBlocks(1)[0]` is satisfied by any
|
||
// single row, so a regression that stopped writing on the degraded path
|
||
// entirely would still be green here. F8/W4 require the block to LAND.
|
||
assert.equal(mem.store.countBlocks(), 1, 'the block is still written (degrade ≠ drop)')
|
||
assert.equal(mem.store.allBlocks(1)[0].vector, '', 'and no bad vector is stored')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('F8†: softening the write-side width check bites the positive criterion red', async (t) => {
|
||
// ★ The anchor must appear EXACTLY ONCE in lib/store.js. `withMutatedCopy`
|
||
// already fails loudly when it is missing; the count check makes "appears
|
||
// twice, first match mutated, second silently left intact" impossible too.
|
||
const { readFileSync } = await import('node:fs')
|
||
const source = readFileSync(new URL('../lib/store.js', import.meta.url), 'utf8')
|
||
assert.equal(
|
||
source.split(' if (actual !== expectedDimension) {').length - 1,
|
||
1,
|
||
'the F8† anchor must occur exactly once in lib/store.js',
|
||
)
|
||
|
||
await withMutatedCopy(
|
||
'store.js',
|
||
[[
|
||
` if (actual !== expectedDimension) {`,
|
||
` if (false) { // MUTATED: no width enforcement`,
|
||
]],
|
||
async (mod) => {
|
||
const store = new mod.BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
// ★ The SAME positive criterion, run on the mutant. It must throw: on the
|
||
// mutant the write is admitted, so `assert.throws` sees no error and
|
||
// fails with ERR_ASSERTION.
|
||
assert.throws(() => assertExplicitWidthRefused(store), (err) => {
|
||
assert.equal(err.code, 'ERR_ASSERTION', 'the criterion must fail as an assertion')
|
||
t.diagnostic(`F8† RAW RED >>> ${String(err.message).split('\n')[0]}`)
|
||
return true
|
||
}, 'F8† must break the unchanged F8 criterion')
|
||
// Corroboration: the mutation really does admit the bad vector. This is
|
||
// reported as a diagnostic, never as the thing that "bites" — asserting
|
||
// it would be the self-test that made the previous version useless.
|
||
const probe = store.putBlock({ text: 'probe', vector: [1, 2], fingerprint: 'fp-a', expectedDimension: 3 })
|
||
t.diagnostic(`F8† mutant admitted the bad vector: created=${probe.created}, blocks=${store.countBlocks()}`)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F8†b: a degraded provider write that drops the block bites countBlocks red', async (t) => {
|
||
// ★ Mutation on lib/index.js: the degraded branch (provider returned a wrong
|
||
// width) stops persisting. `embedded === false` and `vector === ''` both stay
|
||
// green under this mutation — only the row count catches it, which is exactly
|
||
// what the `allBlocks(1)[0]` shape could not do.
|
||
await withMutatedCopy(
|
||
'index.js',
|
||
[[
|
||
` if (!Array.isArray(vector) || vector.length !== space.dimension) {
|
||
return { ...this.store.putBlock(input), embedded: false }
|
||
}`,
|
||
` if (!Array.isArray(vector) || vector.length !== space.dimension) {
|
||
return { id: '', created: false, embedded: false } // MUTATED: degrade by dropping
|
||
}`,
|
||
]],
|
||
async (mod) => {
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
const dir = mkdtempSync(join(tmpdir(), 'f8b-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
try {
|
||
const mem = mod.apply(ctx, {
|
||
path: join(dir, 'm.db'),
|
||
embedder: { async embedText() { return [1, 2] }, info: () => ({ fingerprint: 'fp-x', dimension: 3 }) },
|
||
})
|
||
const r = await mem.commit({ text: 'x' })
|
||
// The two assertions that survive the mutation — reported, not relied on.
|
||
t.diagnostic(`F8†b still-green under mutation: embedded=${r.embedded}, blocks=${mem.store.countBlocks()}`)
|
||
assert.throws(() => {
|
||
assert.equal(
|
||
mem.store.countBlocks(),
|
||
1,
|
||
'the block must still be written on a degraded provider write',
|
||
)
|
||
assert.equal(mem.store.allBlocks(1)[0]?.vector, '', 'and carry no bad vector')
|
||
}, (err) => {
|
||
assert.equal(err.code, 'ERR_ASSERTION', 'only the row-count criterion may bite')
|
||
t.diagnostic(`F8†b RAW RED >>> ${String(err.message).split('\n')[0]}`)
|
||
return true
|
||
}, 'F8†b must break the unchanged provider-degradation criterion')
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('F3 performance guard: blockStates source does not parse vector payloads on reads', async () => {
|
||
// The old implementation did `JSON.parse(row.vector).length` for every row;
|
||
// at 2000 blocks x 2048 dims that measured ~354ms per renderIndex call, ~255ms
|
||
// of it pure parsing. `vector_dim` is now the indexed row fact. This static
|
||
// guard prevents a later "simplification" from putting JSON.parse back into
|
||
// the hot path; migration is allowed to parse once, blockStates is not.
|
||
const { readFileSync } = await import('node:fs')
|
||
const source = readFileSync(new URL('../lib/store.js', import.meta.url), 'utf8')
|
||
const start = source.indexOf(' blockStates(activeFingerprint, activeDimension) {')
|
||
assert.notEqual(start, -1)
|
||
const end = source.indexOf('\n }\n\n /**', start)
|
||
assert.notEqual(end, -1)
|
||
const body = source.slice(start, end)
|
||
assert.equal(body.includes('JSON.parse'), false, 'blockStates must use vector_dim, not parse vector JSON')
|
||
assert.match(body, /vector_dim/, 'blockStates must read the stored dimension')
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 返工 ①: the per-call failure must be QUERYABLE, not merely returned
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// `degraded` on the result array is a RETURN VALUE: the caller (the model) sees
|
||
// it once and it is gone. criteria:282 requires `last_recall` on the stats
|
||
// surface — an OPERATOR must be able to ask "did the last recall fail, and why".
|
||
// These are different questions and neither substitutes for the other, exactly as
|
||
// `states` could not substitute for `skipped`.
|
||
//
|
||
// It deliberately does NOT go into the four states: those are library state
|
||
// derived from rows, and a per-call event would make two reads at the same
|
||
// instant disagree — the failure mode D4 exists to prevent.
|
||
test('D3b: stats().last_recall exposes the most recent recall degradation', async () => {
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: {
|
||
async embedText() { throw new Error('provider exploded') },
|
||
info: () => ({ fingerprint: 'fp-x', dimension: 3 }),
|
||
},
|
||
})
|
||
try {
|
||
await mem.commit({ text: '值班室分机号 4324' })
|
||
|
||
// Cause one failing recall through the production entry point.
|
||
const hits = await mem.recall('4324')
|
||
assert.equal(hits.degraded, 'embed_error', 'the call itself degrades')
|
||
|
||
const result = await ctx.tools.execute({
|
||
callId: 'lr', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
assert.equal(result.isError, false)
|
||
const last = result.value.last_recall
|
||
assert.ok(last, 'stats() must carry a last_recall section')
|
||
assert.equal(last.mode, 'embed_error', 'and report the degradation that actually happened')
|
||
assert.match(last.embed_error, /provider exploded/, 'with the underlying reason')
|
||
assert.equal(typeof last.skipped_fp, 'number')
|
||
assert.equal(typeof last.skipped_dim, 'number')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D3b: a clean recall overwrites last_recall with its own outcome', async () => {
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
// A healthy embedder, and blocks written with explicit vectors so no network
|
||
// is needed for the commit path.
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
const fp = mem.space().fingerprint
|
||
await mem.commit({ text: 'A', vector: [1, 0, 0], fingerprint: fp })
|
||
// ★ Capture `fp` BEFORE swapping the embedder. Writing
|
||
// `info: () => mem.space()...` makes info() call space() which calls info():
|
||
// infinite recursion. (Cost me a stack overflow to find.)
|
||
mem.embedder = {
|
||
async embedText() { return [1, 0, 0] },
|
||
info: () => ({ fingerprint: fp, dimension: 3 }),
|
||
}
|
||
await mem.recall('A')
|
||
|
||
const result = await ctx.tools.execute({
|
||
callId: 'lr2', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
// ★ A healthy recall does NOT report 'ok': group A deliberately labels the
|
||
// success path `vector_ready_unused` because the vector is computed but not
|
||
// yet scored. Asserting 'ok' here was my mistake — the label that matters is
|
||
// "no failure", and `embed_error === null` is the honest way to say it.
|
||
assert.notEqual(result.value.last_recall.mode, 'embed_error', 'no failure is recorded')
|
||
assert.equal(result.value.last_recall.embed_error, null, 'and no error reason either')
|
||
assert.equal(result.value.last_recall.mode, 'vector_ready_unused', 'the group-A success label')
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('D3b†: not recording last_recall leaves the operator blind', async () => {
|
||
await withMutatedCopy(
|
||
'index.js',
|
||
[[
|
||
' this.lastRecall = {',
|
||
' // MUTATED: computed but never stored\n const __unused = {',
|
||
]],
|
||
async (mod) => {
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
const dir = mkdtempSync(join(tmpdir(), 'lr-'))
|
||
const ctx = new Context(); new SystemPrompt(ctx, {}); new ToolRuntime(ctx, {})
|
||
try {
|
||
const mem = mod.apply(ctx, { path: join(dir, 'm.db') })
|
||
await mem.commit({ text: 'x' })
|
||
await mem.recall('x')
|
||
// With the assignment removed, `lastRecall` stays at its initial null.
|
||
assert.equal(
|
||
mem.stats().last_recall,
|
||
null,
|
||
'the mutation leaves no record — so D3b (which requires a populated section) fails',
|
||
)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 返工 ②: the index must describe each unavailable state in its own terms
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// The old tail collapsed all four states into one sentence ending "they are still
|
||
// searchable by exact text". That is true for `noVector` — the block has no
|
||
// vector, but text retrieval still reaches it — and FALSE for `spaceMismatch` on
|
||
// the production recall path, which F2 excludes:
|
||
//
|
||
// production recall (with fp): hits=0, skipped=1
|
||
// store-level browse (no fp): hits=1
|
||
//
|
||
// Same distinction F6 needed three paragraphs to settle; the index was asserting
|
||
// the wrong half of it.
|
||
|
||
/** Render the index with a controlled space/config, via a real StableMem. */
|
||
async function renderIndexFor({ space, store, count, forceReport = false }) {
|
||
const { StableMem } = await import('../lib/index.js')
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const ctx = new Context()
|
||
const mem = new StableMem(ctx, {
|
||
store,
|
||
embedder: space === null ? null : { embedText: async () => [1], info: () => space },
|
||
// `forceReport` lets a D5 test assert the wording for a state that would
|
||
// otherwise be silent (no embedder is a standing fact, see D5d).
|
||
reportNoVectorAlways: forceReport,
|
||
})
|
||
void count
|
||
const text = mem.renderIndex()
|
||
await ctx.fiber.dispose()
|
||
return text
|
||
}
|
||
|
||
test('D5b: spaceMismatch is described as browse-only, never as "still searchable"', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'A 当前', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'B 旧空间', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
assert.equal(store.blockStates('fp-cur', 3).spaceMismatch, 1)
|
||
|
||
const text = await renderIndexFor({
|
||
space: { fingerprint: 'fp-cur', dimension: 3 }, store,
|
||
})
|
||
assert.ok(/browse/i.test(text), `a mismatched block is reachable only by browse; got: ${text}`)
|
||
assert.ok(
|
||
!/still searchable by exact text/i.test(text),
|
||
`the unqualified "still searchable" claim must be gone; got: ${text}`,
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('D5c: noVector is described as text-recallable, which IS true', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
const { recallBlocks } = await import('../lib/recall.js')
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324' })
|
||
// Establish the fact the wording rests on, rather than assuming it.
|
||
assert.ok(recallBlocks(store, '4324', { topK: 3 }).length > 0, 'text recall really does reach it')
|
||
|
||
const text = await renderIndexFor({ space: null, store, forceReport: true })
|
||
assert.ok(/text/i.test(text), `the not-yet-embedded state must mention text recall; got: ${text}`)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('D5d: with no embedder configured, the index stays silent every turn (W11e shape)', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'a' })
|
||
store.putBlock({ text: 'b' })
|
||
// No embedder is the SHIPPING DEFAULT, not an incident: repeating a standing
|
||
// fact every turn is exactly what W11e ruled out for degradation notices.
|
||
const text = await renderIndexFor({ space: null, store })
|
||
assert.ok(/2 durable note/.test(text), `the count line stays: ${text}`)
|
||
assert.ok(
|
||
!/cannot currently be recalled/i.test(text),
|
||
`a standing configuration fact must not be repeated each turn; got: ${text}`,
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('D5b†: restoring the single-sentence tail trips D5b', () => {
|
||
const oldTail = '2 of them cannot currently be recalled semantically; they are still searchable by exact text.'
|
||
assert.match(oldTail, /still searchable by exact text/i, 'the old sentence makes the claim')
|
||
assert.ok(!/browse/i.test(oldTail), 'and never mentions browse — so D5b fails on it')
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 返工 ③: the trigger that keeps vector_dim honest needs a DIRECT guard
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// The triggers are indirectly covered (stripping their WHEN makes F3/D3 red),
|
||
// but nothing exercises a RAW writer — a repair script, a migration, an operator
|
||
// with sqlite3 — which is the case the trigger exists for. `putBlock` keeps the
|
||
// column right by itself, so every existing test would pass with no triggers at
|
||
// all were it not for the two that happen to read the column afterwards.
|
||
test('MIG5: a raw UPDATE of vector has vector_dim corrected by the trigger', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: 'x', vector: [1, 2, 3], fingerprint: 'fp-a', expectedDimension: 3 })
|
||
assert.equal(store.raw().prepare('SELECT vector_dim FROM memory_blocks').get().vector_dim, 3)
|
||
|
||
// A raw writer sets a 5-wide vector but leaves vector_dim at 3 — the state the
|
||
// column must be incapable of representing.
|
||
store.raw()
|
||
.prepare('UPDATE memory_blocks SET vector = ?')
|
||
.run(JSON.stringify([1, 2, 3, 4, 5]))
|
||
|
||
const dim = store.raw().prepare('SELECT vector_dim FROM memory_blocks').get().vector_dim
|
||
assert.equal(dim, 5, 'the trigger must pull vector_dim back to the real length')
|
||
|
||
// And clearing the vector resets it to the "no vector" sentinel.
|
||
store.raw().prepare("UPDATE memory_blocks SET vector = ''").run()
|
||
assert.equal(
|
||
store.raw().prepare('SELECT vector_dim FROM memory_blocks').get().vector_dim,
|
||
0,
|
||
'an emptied vector must read as 0, not as its stale width',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('MIG5: a raw INSERT with a mismatched vector_dim is corrected on the way in', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
// Insert directly with a WRONG vector_dim, the way a hand-written repair would.
|
||
store.raw()
|
||
.prepare(`INSERT INTO memory_blocks (id, modality, text_content, vector, vector_dim, fingerprint, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||
.run('raw-1', 'text', 'raw inserted', JSON.stringify([1, 2]), 99, 'fp-a', Date.now(), Date.now())
|
||
|
||
assert.equal(
|
||
store.raw().prepare("SELECT vector_dim FROM memory_blocks WHERE id = 'raw-1'").get().vector_dim,
|
||
2,
|
||
'the insert trigger must overwrite a lying vector_dim',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('MIG5†: dropping either trigger lets a lying vector_dim survive', async () => {
|
||
// Two triggers, two independent guards: removing only the INSERT one must not
|
||
// hide behind the UPDATE one, and vice versa.
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
|
||
// (a) drop the UPDATE trigger
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.raw().exec('DROP TRIGGER IF EXISTS memory_blocks_vector_dim_update')
|
||
store.putBlock({ text: 'y', vector: [1, 2, 3], fingerprint: 'fp-a', expectedDimension: 3 })
|
||
store.raw().prepare('UPDATE memory_blocks SET vector = ?').run(JSON.stringify([1, 2, 3, 4, 5]))
|
||
assert.equal(
|
||
store.raw().prepare('SELECT vector_dim FROM memory_blocks').get().vector_dim,
|
||
3,
|
||
'without the UPDATE trigger the column lies — so MIG5 fails on it',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
|
||
const store2 = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
store2.raw().exec('DROP TRIGGER IF EXISTS memory_blocks_vector_dim_insert')
|
||
store2.raw()
|
||
.prepare(`INSERT INTO memory_blocks (id, modality, text_content, vector, vector_dim, fingerprint, created_at, updated_at)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||
.run('raw-2', 'text', 'r', JSON.stringify([1, 2]), 99, 'fp-a', Date.now(), Date.now())
|
||
assert.equal(
|
||
store2.raw().prepare("SELECT vector_dim FROM memory_blocks WHERE id = 'raw-2'").get().vector_dim,
|
||
99,
|
||
'without the INSERT trigger a hand-written width survives — so MIG5 fails on it',
|
||
)
|
||
} finally {
|
||
store2.close()
|
||
}
|
||
})
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// 返工 ④: skipped_dim must be able to be non-zero, or it must not exist
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// `last_recall.skipped_dim` was hardcoded to 0 with the comment "reported as 0
|
||
// rather than guessed", and the only assertion on it was `typeof === 'number'` —
|
||
// which a constant satisfies forever. That is the same defect as the hardcoded
|
||
// `skipped === 2` removed earlier: a field that can never be non-zero turns F4's
|
||
// "count dimension mismatches separately" into decoration.
|
||
//
|
||
// Ruling taken: option (a) — the recall path gains a dimension filter, so the
|
||
// field can report something real. The alternative (drop the field) would leave
|
||
// criteria:161 describing a separation that only exists in blockStates.
|
||
test('F4b: a same-space block of the wrong width is skipped as a DIMENSION skip', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
// Same fingerprint (so it is NOT a space mismatch), but 2-wide in a 3-wide
|
||
// space. Only a dimension check can exclude this one.
|
||
store.putBlock({ text: '值班室分机号 4324 窄向量', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: '值班室分机号 4324 正常', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
|
||
const hits = recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-cur', dimension: 3 })
|
||
assert.equal(hits.length, 1, 'only the correctly sized block is recalled')
|
||
assert.ok(hits.some((h) => h.text.includes('正常')))
|
||
assert.equal(hits.skippedDim, 1, 'the narrow block is counted as a DIMENSION skip')
|
||
assert.equal(hits.skipped, 0, 'and NOT as a space skip — F4 keeps them apart')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F4b: both skip kinds are reported side by side without double counting', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '值班室分机号 4324 旧空间', vector: [0, 1, 0], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: '值班室分机号 4324 窄向量', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: '值班室分机号 4324 正常', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
|
||
const hits = recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-cur', dimension: 3 })
|
||
assert.equal(hits.length, 1)
|
||
assert.equal(hits.skipped, 1, 'one space skip')
|
||
assert.equal(hits.skippedDim, 1, 'one dimension skip')
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('F4b: last_recall.skipped_dim reflects a real dimension skip', async () => {
|
||
const { createHttpEmbedder } = await import('../lib/embed/http.js')
|
||
const { mem, done } = await makeMem({
|
||
embedder: createHttpEmbedder({ endpoint: 'http://127.0.0.1:1/embed', dimension: 3 }),
|
||
})
|
||
try {
|
||
const fp = mem.space().fingerprint
|
||
await mem.commit({ text: '正常 4324', vector: [1, 0, 0], fingerprint: fp })
|
||
// ★ The narrow row is written RAW, not through commit: F8 (correctly) refuses a
|
||
// mis-sized explicit vector, so a legacy/repair-written row is the realistic way
|
||
// this state exists in a live database.
|
||
mem.store.putBlock({ text: '窄 4324', vector: [1, 2], fingerprint: fp })
|
||
await mem.recall('4324')
|
||
assert.equal(mem.lastRecall.skipped_dim, 1, 'the statistic must be able to be non-zero')
|
||
assert.equal(mem.lastRecall.skipped_fp, 0)
|
||
} finally {
|
||
await done()
|
||
}
|
||
})
|
||
|
||
test('F4b†: dropping the dimension filter makes skipped_dim permanently zero again', async () => {
|
||
await withMutatedCopy(
|
||
'recall.js',
|
||
[[
|
||
' if (Number.isInteger(dimension) && dimension > 0) {',
|
||
' if (false) { // MUTATED: no dimension filter',
|
||
]],
|
||
async (mod) => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
store.putBlock({ text: '窄 4324', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
const hits = mod.recallBlocks(store, '4324', { topK: 10, fingerprint: 'fp-cur', dimension: 3 })
|
||
assert.equal(hits.skippedDim, 0, 'without the filter the narrow block is not counted')
|
||
assert.equal(
|
||
hits.length,
|
||
1,
|
||
'and it is returned — so F4b (which excludes it) fails on the mutated module',
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── D5e / D5f: the two states that had no wording criterion ───────────────
|
||
//
|
||
// `renderUnavailableTail` emits a clause per state, but only `spaceMismatch`
|
||
// (D5b) and `noVector` (D5c) had one. Deleting the `unknownSpace` or
|
||
// `dimensionMismatch` clause left the whole suite green — those two were
|
||
// decoration in the renderer, exactly like the constant `skipped_dim` was.
|
||
//
|
||
// Each state gets its own clause assertion plus its own suppression mutation,
|
||
// because a shared "the tail mentions something" test would keep passing while
|
||
// either clause silently disappeared.
|
||
|
||
test('D5e: unknownSpace is named as a labelling problem, not a reachability one', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
// A block with a vector but no fingerprint: reachable by no semantic path,
|
||
// and its remedy is re-embedding, not browsing.
|
||
store.putBlock({ text: 'A 当前', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'D 无名向量' })
|
||
store.raw().prepare("UPDATE memory_blocks SET vector = ? WHERE text_content = 'D 无名向量'")
|
||
.run(JSON.stringify([0, 0, 1]))
|
||
assert.equal(store.blockStates('fp-cur', 3).unknownSpace, 1, 'the fixture is in the right state')
|
||
|
||
const text = await renderIndexFor({ space: { fingerprint: 'fp-cur', dimension: 3 }, store })
|
||
assert.ok(
|
||
/1 without a space label/.test(text),
|
||
`the unknownSpace clause must state the count and the reason; got: ${text}`,
|
||
)
|
||
// It must NOT promise browse or exact text: that claim belongs to `noVector`
|
||
// and is false here (no space label means no semantic participation at all).
|
||
assert.ok(
|
||
!/browse/i.test(text) && !/exact text/i.test(text),
|
||
`unknownSpace must not borrow noVector's reachability claim; got: ${text}`,
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('D5f: dimensionMismatch points at the configuration, not at browsing', async () => {
|
||
const { BlockStore: RealStore } = await import('../lib/store.js')
|
||
const store = new RealStore({ path: ':memory:' }).open()
|
||
try {
|
||
// Same fingerprint (so NOT a space mismatch), wrong width. Written raw because
|
||
// F8 rightly refuses a mis-sized explicit vector through putBlock/commit.
|
||
store.putBlock({ text: 'A 当前', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'E 宽度不符', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
assert.equal(store.blockStates('fp-cur', 3).dimensionMismatch, 1, 'right state')
|
||
|
||
const text = await renderIndexFor({ space: { fingerprint: 'fp-cur', dimension: 3 }, store })
|
||
assert.ok(
|
||
/1 whose width does not match this provider/.test(text),
|
||
`the dimensionMismatch clause must state the count and the reason; got: ${text}`,
|
||
)
|
||
assert.ok(
|
||
/fix the config/i.test(text),
|
||
`and must point at the configuration remedy; got: ${text}`,
|
||
)
|
||
assert.ok(
|
||
!/browse/i.test(text),
|
||
`a width mismatch is not reachable by browsing either; got: ${text}`,
|
||
)
|
||
} finally {
|
||
store.close()
|
||
}
|
||
})
|
||
|
||
test('D5e†: deleting the unknownSpace clause must be caught', async () => {
|
||
await withMutatedCopy(
|
||
'index.js',
|
||
[[
|
||
` if (states.unknownSpace > 0) {
|
||
clauses.push(\`\${states.unknownSpace} without a space label (semantic recall unavailable)\`)
|
||
}`,
|
||
` // MUTATED: unknownSpace clause removed`,
|
||
]],
|
||
async (mod) => {
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
const dir = mkdtempSync(join(tmpdir(), 'd5e-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
try {
|
||
const mem = mod.apply(ctx, { path: join(dir, 'm.db') })
|
||
await mem.commit({ text: 'A', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
await mem.commit({ text: 'D 无名' })
|
||
mem.store.raw().prepare("UPDATE memory_blocks SET vector = ? WHERE text_content = 'D 无名'")
|
||
.run(JSON.stringify([0, 0, 1]))
|
||
const text = mem.renderIndex()
|
||
assert.equal(
|
||
/without a space label/.test(text),
|
||
false,
|
||
'the mutation really removes the clause — so D5e (which requires it) fails',
|
||
)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
test('D5f†: deleting the dimensionMismatch clause must be caught', async () => {
|
||
await withMutatedCopy(
|
||
'index.js',
|
||
[[
|
||
` if (states.dimensionMismatch > 0) {
|
||
clauses.push(
|
||
\`\${states.dimensionMismatch} whose width does not match this provider (fix the config, then re-embed)\`,
|
||
)
|
||
}`,
|
||
` // MUTATED: dimensionMismatch clause removed`,
|
||
]],
|
||
async (mod) => {
|
||
const { Context } = await import('@deepseek-ai/cordis')
|
||
const { SystemPrompt } = await import('@deepseek-ai/dsh-system-prompt')
|
||
const { ToolRuntime } = await import('@deepseek-ai/dsh-tools')
|
||
const { mkdtempSync, rmSync } = await import('node:fs')
|
||
const { tmpdir } = await import('node:os')
|
||
const { join } = await import('node:path')
|
||
const dir = mkdtempSync(join(tmpdir(), 'd5f-'))
|
||
const ctx = new Context()
|
||
new SystemPrompt(ctx, {})
|
||
new ToolRuntime(ctx, {})
|
||
try {
|
||
const mem = mod.apply(ctx, { path: join(dir, 'm.db') })
|
||
await mem.commit({ text: 'A', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
mem.store.putBlock({ text: 'E 窄', vector: [1, 2], fingerprint: 'fp-cur' })
|
||
const text = mem.renderIndex()
|
||
assert.equal(
|
||
/does not match this provider/.test(text),
|
||
false,
|
||
'the mutation really removes the clause — so D5f (which requires it) fails',
|
||
)
|
||
} finally {
|
||
await ctx.fiber.dispose()
|
||
rmSync(dir, { recursive: true, force: true })
|
||
}
|
||
},
|
||
)
|
||
})
|
||
|
||
// ── F7: store-level inventory grouped by fingerprint (D4 / D7) ─────────────
|
||
//
|
||
// One seed, one criterion, three mutants. The criterion below is the ORIGINAL
|
||
// positive assertion, reused verbatim against each mutated copy of lib/store.js:
|
||
// a mutant "bites" only when this criterion throws ERR_ASSERTION on it, which
|
||
// also satisfies the rework discipline ("the variant must change lib/, and the
|
||
// red must come from the real criterion").
|
||
function seedF7(store, withCentroids = true) {
|
||
store.putBlock({ text: 'F7 current', vector: [1, 0, 0], fingerprint: 'fp-cur' })
|
||
store.putBlock({ text: 'F7 retired one', vector: [1, 0], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: 'F7 retired two', vector: [0, 1], fingerprint: 'fp-old' })
|
||
store.putBlock({ text: 'F7 plain' })
|
||
store.putBlock({ text: 'F7 legacy' })
|
||
// A pre-declaration row (unlabelled but vectored) must stay in its own bucket.
|
||
store.raw().prepare("UPDATE memory_blocks SET vector = '[0,0,1]' WHERE text_content = 'F7 legacy'").run()
|
||
if (withCentroids) {
|
||
for (const [fingerprint, dim] of [['fp-cur', 3], ['fp-old', 2]]) {
|
||
store.saveCentroid({ fingerprint, dim, vectorCount: 1, mean: Array(dim).fill(0), isAnomalous: false })
|
||
}
|
||
}
|
||
}
|
||
|
||
function assertF7(report) {
|
||
assert.deepEqual(report.blocks, { total: 5, with_vector: 4, no_vector: 1 })
|
||
assert.deepEqual(report.spaces, {
|
||
active: { fingerprint: 'fp-cur', dim: 3 },
|
||
by_fingerprint: [
|
||
{ fingerprint: 'fp-cur', dim: 3, blocks: 1, status: 'active' },
|
||
{ fingerprint: 'fp-old', dim: 2, blocks: 2, status: 'stale' },
|
||
],
|
||
no_fingerprint: 1,
|
||
}, 'F7: two spaces must coexist with distinct blocks, dims and statuses')
|
||
}
|
||
|
||
function assertF7NoCentroid(store) {
|
||
const report = store.vectorStats('fp-cur')
|
||
assert.equal(report.spaces.active.dim, null, 'D7-b: active dim without a centroid is null')
|
||
assert.deepEqual(
|
||
report.spaces.by_fingerprint.map((row) => row.dim), [null, null],
|
||
'D7-b: grouped dim without a centroid is null — blocks are never scanned for it',
|
||
)
|
||
}
|
||
|
||
function assertF7NoDecode(store) {
|
||
const parse = JSON.parse
|
||
let decodes = 0
|
||
JSON.parse = (...args) => { decodes += 1; return parse(...args) }
|
||
try {
|
||
store.vectorStats('fp-cur')
|
||
} finally {
|
||
JSON.parse = parse
|
||
}
|
||
assert.equal(decodes, 0, 'D7-a: the default inventory decodes zero vector payloads')
|
||
}
|
||
|
||
test('F7: the tool groups active and non-zero stale blocks with centroid dims', async () => {
|
||
const { ctx, mem, done } = await makeMem({
|
||
embedder: { info: () => ({ fingerprint: 'fp-cur', dimension: 3 }), embedText: async () => [1, 0, 0] },
|
||
})
|
||
try {
|
||
seedF7(mem.store)
|
||
const result = await ctx.tools.execute({
|
||
callId: 'f7', name: 'stablemem_stats', arguments: {}, signal: new AbortController().signal,
|
||
})
|
||
assert.equal(result.isError, false, `stats tool failed: ${JSON.stringify(result.value)}`)
|
||
assertF7(result.value)
|
||
assert.equal('byFingerprint' in result.value, false, 'no sibling alias beside the agreed shape')
|
||
assert.ok(result.value.states.total === 5 && typeof result.value.last_recall === 'object',
|
||
'the accepted D3/D6 sections are untouched by this slice')
|
||
} finally { await done() }
|
||
})
|
||
|
||
test('F7-D4: the inventory follows raw DB edits and survives a reopen', () => {
|
||
const dir = mkdtempSync(join(tmpdir(), 'f7-'))
|
||
const path = join(dir, 'm.db')
|
||
let store = new BlockStore({ path }).open()
|
||
try {
|
||
seedF7(store)
|
||
assertF7(store.vectorStats('fp-cur'))
|
||
store.raw().prepare("DELETE FROM memory_blocks WHERE fingerprint = 'fp-old'").run()
|
||
assert.deepEqual(store.vectorStats('fp-cur').spaces.by_fingerprint,
|
||
[{ fingerprint: 'fp-cur', dim: 3, blocks: 1, status: 'active' }],
|
||
'a raw DELETE is reflected on the next call — nothing was cached')
|
||
store.close()
|
||
store = new BlockStore({ path }).open()
|
||
assert.deepEqual(store.vectorStats('fp-cur').blocks, { total: 3, with_vector: 2, no_vector: 1 },
|
||
'a restart does not zero the inventory: it is derived, not accumulated')
|
||
} finally { store.close(); rmSync(dir, { recursive: true, force: true }) }
|
||
})
|
||
|
||
test('F7-D7: the default path decodes nothing and absent centroids report null', () => {
|
||
const store = new BlockStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedF7(store, false)
|
||
assertF7NoDecode(store)
|
||
assertF7NoCentroid(store)
|
||
assert.equal(store.vectorStats('fp-cur').spaces.by_fingerprint.length, 2,
|
||
'and the groups still exist without a centroid — dim is the only thing unknown')
|
||
} finally { store.close() }
|
||
})
|
||
|
||
// The three required mutants. Each one changes lib/, then runs the SAME positive
|
||
// criterion; the criterion must throw ERR_ASSERTION.
|
||
for (const [name, replacements, withCentroids, criterion] of [
|
||
[
|
||
'M-F7',
|
||
[[
|
||
'by_fingerprint: groups.map((row) =>',
|
||
'by_fingerprint: groups.filter((row) => row.fingerprint === activeFingerprint).map((row) =>',
|
||
]],
|
||
true,
|
||
(store) => assertF7(store.vectorStats('fp-cur')),
|
||
],
|
||
[
|
||
'D7-a',
|
||
[[
|
||
' const groups = db.prepare(`SELECT b.fingerprint',
|
||
' const decodeVector = JSON.parse\n' +
|
||
' for (const row of db.prepare("SELECT vector FROM memory_blocks WHERE length(vector) > 0").all()) decodeVector(row.vector)\n' +
|
||
' const groups = db.prepare(`SELECT b.fingerprint',
|
||
]],
|
||
false,
|
||
assertF7NoDecode,
|
||
],
|
||
[
|
||
'D7-b',
|
||
[[
|
||
' dim: row.dim,',
|
||
' dim: JSON.parse(db.prepare("SELECT vector FROM memory_blocks WHERE fingerprint = ? LIMIT 1").get(row.fingerprint).vector).length,',
|
||
]],
|
||
false,
|
||
assertF7NoCentroid,
|
||
],
|
||
]) {
|
||
test(`F7† ${name}: the unchanged positive criterion bites red`, async (t) => {
|
||
await withMutatedCopy('store.js', replacements, async ({ BlockStore: MutantStore }) => {
|
||
const store = new MutantStore({ path: ':memory:' }).open()
|
||
try {
|
||
seedF7(store, withCentroids)
|
||
assert.throws(() => criterion(store), (err) => {
|
||
assert.equal(err.code, 'ERR_ASSERTION', 'only an assertion failure counts as a bite')
|
||
t.diagnostic(`${name} RAW RED >>> ${String(err.message).split('\n')[0]}`)
|
||
return true
|
||
}, `${name} must break the original positive criterion`)
|
||
} finally { store.close() }
|
||
})
|
||
})
|
||
}
|