Files
HologramAgent-DSH-Memory-Port/scripts/checkpoints.js
jianf c6595d2cf0 feat(memory-b): H 收尾 W1–W8 —— 信号转发、写侧校验、建中心入口
五条全部处理。第 5 条确认属实:saveSpaceCentroid 在 lib/ 里零调用点,
真实使用下中心表永久空着,票③ 的「dim unknown」会从边角变成常态。

W1/W5 信号转发(最硬的一条)
- execute(args, exec) → commit(input, {signal}) → embedText(text, signal)
- abort 时按「向量化失败」降级落盘:块写入、vector 留空
- 依据 dsh-tools/lib/types/index.d.ts:120-122

★ 一处我判错了,据实记录
W5 我原本断言「abort 时 isError === false」,实测是 true。查了实现:
dsh-tools/lib/index.js:3408 在工具成功后**再检查一次 signal**,
已 abort 就用 toolAbortedResult 覆盖成功结果。所以 isError 不归本插件控制,
断言它等于和 harness 对抗。改为断言**副作用**(块已写入、vector 为空),
并额外断言 error.info.code === 'ABORTED'。

W2 九个调用点补 await
scripts/checkpoints.js ×4、test/plugin.test.js ×5、test/loader.test.js ×2
(loader 两处保持并发语义用 Promise.all)。
原本绿是因为「无 embedder 时第一个 await 之前就写完 putBlock」——
这个隐式事实一旦被一行 await 打破,九处会静默变成「块没写」,
而且红会出现在离原因很远的地方(靠后面能否召回间接断言)。

W3 空指纹先判后嵌
原来顺序是 await embedText → putBlock 抛 → catch → 再 putBlock,
语义终点对但白付一次网络往返,且与 recall.js:430 的口径不一致。
改为 embed 之前判 space.fingerprint === ''。

W4 写侧维度校验
commit 现在检查 vector.length === space.dimension,不符按 embed failure 处理
(无向量落盘)。不落坏向量的理由:召回侧会按 skippedDim 静默跳过,
用户看到的是「记忆里有这条,但永远召不回来」。

W8 建中心入口(第 5 条)
新增 store.rebuildCentroid(fingerprint),移植 RebuildCentroid
(centroid_store.go:246-320):
  · 只取该空间的带向量行
  · 维度不符跳过不强制
  · 按 L2 归一化后累加均值
  · 均值范数 >= 0.6 才判 anomalous(centroid.go:87)
  · 计入 stats:meanLength / avgPairCosine / skippedRows
并新增 MEAN_THRESHOLD 常量。

★ W8 第一版判据是错的:我用完全共线的向量(meanLength = 1.0000)
  去测 0.6 阈值,把阈值改成 0.95 仍然通过。已改用 ±35° 锥
  (meanLength ≈ 0.879,落在 0.6 与 0.95 之间),并加断言
  「语料必须落在两阈值之间」,否则测试自身就失去区分力。

判据与变异(全部在 /tmp 干净副本上跑)
  W4  维度不符 → 块在、vector=''、fingerprint=''      | 删维度检查 → 红
  W5  abort → 块在、vector=''、elapsed<2s              | 不传 signal → 红(实测 5003ms)
  W6  无 embedder 时 {id,created} 与 putBlock 同形      | —
  W7  空指纹 → embedText 调用 0 次                      | 改为事后判 → 红
  W8  同空间重建、跨空间隔离、坏维度跳过、阈值判定      | 去空间过滤 → 3 条红
                                                        | 去维度过滤 → 1 条红
                                                        | 阈值 0.6→0.95 → 1 条红

未越界
- 四态计数(noVector/unknownSpace/spaceMismatch/embedFailure)留给 F/D
- recallBlocks 仍然同步、未接入 vectorScores(V 组的事)
- 票③「dim 来自中心元数据而非配置」继续有效;saveSpaceCentroid 仍在用
  dim ?? space.dimension,已在注释里标记为 C 组要区分「没建过」与
  「建过但维度取自配置」——声明不是证据

顺序按你的调整记下:H(完工) → V → C → F → D。
C 组准入条件已满足一半:机制(rebuildCentroid)有了,
「谁触发建中心」待 C 组定。

验证
- npm test 99/99(90 + W4/W5/W6/W7 六条 + W8 三条)
- npm run check 10/10
- 提交前连跑 3 次 test + 3 次 check,6 次全绿
2026-10-30 10:35:41 +08:00

428 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* Checkpoint verification for dsh-memory phase A (docs/feasibility.md §7.1).
*
* Runs against a **real cordis app** with the real `@deepseek-ai` services, not
* mocks: V3/V3b/V4/V5 are only meaningful if the actual `SystemPrompt`,
* `ToolRuntime` and event dispatch are in the loop.
*
* Usage: node scripts/checkpoints.js
* Exit: 0 = every checkpoint holds, 1 = at least one failed.
*
* @module dsh-memory/scripts/checkpoints
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, existsSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { BlockStore } from '../lib/store.js'
import { recallBlocks } from '../lib/recall.js'
import { apply as applyMemory, name as pluginName, inject as pluginInject } from '../lib/index.js'
const results = []
let failed = 0
/**
* Run one checkpoint and record its outcome.
* @param {string} id
* @param {string} title
* @param {() => Promise<unknown> | unknown} body
* @returns {Promise<void>}
*/
async function checkpoint(id, title, body) {
try {
const detail = await body()
results.push({ id, title, ok: true, detail })
console.log(` ✔ ${id} ${title}${detail ? `\n ${detail}` : ''}`)
} catch (error) {
failed += 1
results.push({ id, title, ok: false, detail: error.message })
console.log(` ✘ ${id} ${title}\n ${error.message}`)
}
}
/** A fresh temp dir per run, removed at the end. */
const workspace = mkdtempSync(join(tmpdir(), 'dsh-memory-cp-'))
/**
* Build a real cordis app with the services this plugin injects.
*
* `ToolRuntime` needs `systemPrompt` first (dsh-tools/lib/index.js:2761), and our
* plugin needs both — so construction order mirrors a real composition.
* @returns {{ ctx: Context, dispose: () => Promise<void> }}
*/
function makeApp() {
const ctx = new Context()
new SystemPrompt(ctx, { includeHarnessIdentity: false })
new ToolRuntime(ctx, {})
return { ctx, dispose: async () => {} }
}
console.log('\ndsh-memory · phase A checkpoints\n')
// ── V1: node:sqlite usable, WAL, concurrent writes ────────────────────────
await checkpoint('V1', 'node:sqlite opens, WAL applies, 100 concurrent writes round-trip', () => {
const path = join(workspace, 'v1.db')
const store = new BlockStore({ path }).open()
assert.equal(store.journalMode(), 'wal', 'journal_mode should be wal')
for (let i = 0; i < 100; i += 1) {
store.putBlock({ text: `并发写入测试块 编号 ${i} 端口 ${10000 + i}`, source: 'v1' })
}
assert.equal(store.countBlocks(), 100, 'expected 100 blocks')
const readBack = store.getBlock(
(() => {
// Look one up by content to prove the row is intact, not just counted.
const rows = store.allBlocks(100)
return rows.find((row) => row.text_content.includes('端口 10050')).id
})(),
)
assert.ok(readBack, 'the block written mid-batch must be readable')
assert.ok(readBack.text_content.includes('10050'), 'text_content intact')
store.close()
assert.ok(existsSync(path), 'db file exists on disk')
return `journal=wal, rows=100, mid-batch row=${readBack.id}, file on disk`
})
// ── V2: FTS5 + symbol-pin recall quality on a 50-block corpus ─────────────
//
// The corpus is split by HologramAgent's own capability tiers
// (docs/zh/recall-capability-tiers.md), because they have different mechanisms
// and must be scored separately rather than averaged into one misleading number:
//
// 形态 1 带精确串(端口/IP/路径/版本) symbol path MUST pass
// 形态 2 领域词 + 上下文 FTS + CJK window MUST pass
// 形态 3 纯泛化描述("XX 是什么") needs vectors KNOWN GAP
// 形态 4 库里没有 needs abstention KNOWN GAP
//
// 形态 3/4 are asserted to be gaps, not hidden: this slice has no embedding
// provider (docs/feasibility.md §5.1), so they are phase B/C work. Encoding the
// gap as an assertion means the day someone adds vectors, this test tells them.
await checkpoint('V2', 'recall quality per capability tier (tiers 1–2 must pass; 3–4 are pinned gaps)', () => {
const store = new BlockStore({ path: join(workspace, 'v2.db') }).open()
/** @type {Array<{q: string, text: string, want: string, tier: number}>} */
const corpus = []
// 形态 1 — exact-string queries. 30 notes.
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `billing${i} 端口是多少`, text: `billing${i}服务 端口=${8499 + i}`, want: String(8499 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `值班室分机号 43${20 + i}`, text: `值班室分机号 43${20 + i},值班人 老周`, want: String(4320 + i), tier: 1 })
}
for (let i = 0; i < 10; i += 1) {
corpus.push({ q: `脚本 /opt/deploy/run${i}.sh 在哪`, text: `部署脚本 路径=/opt/deploy/run${i}.sh`, want: `/opt/deploy/run${i}.sh`, tier: 1 })
}
// 形态 2 — domain word + context, including the CJK-question form that needs
// the sliding-window fallback (query run ≠ note run). 20 notes.
//
// ★ Each note must have a DISTINCT subject. An earlier draft of this corpus
// used ten notes differing only by a number hidden in the query's parentheses,
// and scored 55% — but that was the corpus being unfair, not recall being bad:
// ten notes reading "值班室分机号 43xx,值班人 老周" are genuinely
// indistinguishable unless the query names the number, so top1 is arbitrary.
// Distinct subjects is the shape a real corpus has (one note per thing).
const services = ['admin', 'billing', 'oauth', 'gateway', 'search', 'report', 'notify', 'audit', 'cache', 'proxy']
for (const [i, svc] of services.entries()) {
corpus.push({
// Question form: names the service, not the port.
q: `${svc} 服务的端口是多少`,
text: `${svc}服务 端口=${8800 + i}`,
want: String(8800 + i),
tier: 2,
})
}
for (const [i, batch] of ['第112批', '第115批', '第132批', '第140批', '第151批'].entries()) {
corpus.push({
q: `发布 ${batch} 的版本号是什么`,
text: `发布记录 ${batch} 的版本=v2.3${i}.0`,
want: `v2.3${i}.0`,
tier: 2,
})
}
for (const [i, dept] of ['财务', '运维', '客服', '仓储', '采购'].entries()) {
corpus.push({
q: `${dept}部门的负责人是谁`,
text: `${dept}部门 负责人=员工${100 + i}`,
want: `员工${100 + i}`,
tier: 2,
})
}
for (const entry of corpus) store.putBlock({ text: entry.text, source: 'v2' })
/** @param {number} tier */
const scoreTier = (tier) => {
const rows = corpus.filter((entry) => entry.tier === tier)
const misses = []
let hit = 0
for (const entry of rows) {
const got = recallBlocks(store, entry.q, { topK: 1 })[0]?.text ?? ''
if (got.includes(entry.want)) hit += 1
else misses.push({ q: entry.q, want: entry.want, got: got.slice(0, 40) })
}
return { hit, total: rows.length, accuracy: hit / rows.length, misses }
}
const tier1 = scoreTier(1)
const tier2 = scoreTier(2)
// Known gaps, asserted as such (they must NOT silently start "passing" by
// accident, which would mean the corpus lost its discriminating power).
const gapGeneral = recallBlocks(store, '公网地址是什么', { topK: 1 }).length
const gapAbsent = recallBlocks(store, 'kafka broker 的地址', { topK: 1 })
store.close()
assert.ok(tier1.accuracy >= 0.95, `tier1 ${(tier1.accuracy * 100).toFixed(0)}% < 95%; ${JSON.stringify(tier1.misses.slice(0, 3))}`)
assert.ok(tier2.accuracy >= 0.9, `tier2 ${(tier2.accuracy * 100).toFixed(0)}% < 90%; ${JSON.stringify(tier2.misses.slice(0, 3))}`)
// 形态 3/4 是已知缺口:这里不断言"答对",只记录实测行为,防止有人误以为已支持。
return (
`tier1(exact) ${tier1.hit}/${tier1.total} = ${(tier1.accuracy * 100).toFixed(0)}% · ` +
`tier2(domain) ${tier2.hit}/${tier2.total} = ${(tier2.accuracy * 100).toFixed(0)}% · ` +
`known gaps: 泛化查询→${gapGeneral} hit, 库里没有→${gapAbsent.length} hit (需向量,phase B)`
)
})
// ── V3: systemPrompt.context gets agent.session, NOT the step messages ────
await checkpoint('V3', 'systemPrompt.context: sees agent, cannot see this step\u2019s messages', async () => {
const app = makeApp()
let seenKeys = []
app.ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
seenKeys = Object.keys(context).sort()
return 'probe'
},
})
const fakeSession = { marker: 'session-present' }
const assembly = await app.ctx.systemPrompt.assemble({
agent: { session: fakeSession, id: 'v3-agent' },
})
const assembled = app.ctx.systemPrompt.getContextOrder ? assembly.contexts : assembly.contexts
assert.deepEqual(seenKeys, ['agent', 'scope', 'signal'].filter((key) => seenKeys.includes(key)),
`unexpected AssembleContext keys: ${seenKeys.join(',')}`)
assert.ok(seenKeys.includes('agent'), 'AssembleContext must expose agent')
assert.ok(
!seenKeys.includes('messages') && !seenKeys.includes('input') && !seenKeys.includes('text'),
`AssembleContext must NOT expose step messages; got ${seenKeys.join(',')}`,
)
assert.ok(assembled.some((entry) => entry.name === 'probe:v3'), 'context was assembled')
return `AssembleContext keys = [${seenKeys.join(', ')}] — no messages/input field`
})
// ── V3b: agent/pre-step injection reaches the model-visible batch ─────────
await checkpoint('V3b', 'agent/pre-step: injected recall lands in the accepted message batch', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.kind, 'enter', 'step must be admitted')
assert.ok(decision.messages.length > decision.messages.length - 1, 'sanity')
assert.equal(decision.messages.length, 2, `expected input + injected recall, got ${decision.messages.length}`)
const injected = decision.messages[1]
assert.equal(injected.source.kind, pluginName, 'injected message carries our source kind')
assert.equal(injected.source.form, 'snapshot', 'injected message declares form=snapshot')
const text = injected.content.map((block) => block.text ?? '').join('')
assert.ok(text.includes('4324'), `injected text must carry the recalled value; got ${text.slice(0, 120)}`)
assert.ok(text.includes('PAST notes'), 'injected text must state these are historical')
return `batch ${decision.messages.length} msgs; injected kind=${injected.source.kind}, carries "4324"`
})
// ── V3b mutation: recall returning '' must make V3b's assertion fail ──────
await checkpoint('V3b†', 'MUTATION: silencing recall must break V3b (the test can turn red)', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3b-mut.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
// Mutate: no recall, ever.
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号是多少' }],
source: { kind: 'user' },
})
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: [input], turn: 1, step: 1, signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'mutation must leave the batch un-augmented')
return 'with recall disabled the batch stays at 1 message ⇒ the V3b assertion would fail'
})
// ── V3c: REALISTIC batch (question + runtime-context + skill-catalog) ─────
//
// V3b builds a 2-message batch, which is exactly the blind spot that let a
// 100%-dead auto-recall ship. A real dsh step batch appends user-ROLE injected
// context after the question, so "last role=user" picks up the skill catalog.
await checkpoint('V3c', 'realistic 3-part batch: recall uses the question, not the injected context', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v3c.db'), topK: 3 })
await mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const batch = [
{ role: 'user', source: { kind: 'user' },
content: [{ type: 'text', text: '值班室分机号是多少' }] },
{ role: 'user', source: { kind: 'runtime-context', form: 'snapshot' },
content: [{ type: 'text', text: '[Working directory]\n/home/agent/project' }] },
{ role: 'user', source: { kind: 'skill-catalog', form: 'catalog' },
content: [{ type: 'text', text: '[Skills] git-release-discipline, android-cli, shared-browser' }] },
]
const decision = await app.ctx.waterfall(
'agent/pre-step',
{ agent: { id: 'a', session: {} }, messages: batch, turn: 1, step: 1,
signal: new AbortController().signal },
() => Promise.resolve({ kind: 'enter', messages: batch }),
)
assert.equal(decision.messages.length, 4, '3 batch + 1 injected')
const text = decision.messages[3].content.map((b) => b.text ?? '').join('')
assert.ok(text.includes('4324'), `recall must hit; injected=${JSON.stringify(text.slice(0, 80))}`)
// The mutation, measured in the same run: the old rule must score 0.
const oldRuleQuery = batch[batch.length - 1].content[0].text
// `recall` is async since phase B group A — a bare Promise has no `.length`.
const mutated = (await mem.recall(oldRuleQuery)).length
const fixed = (await mem.recall('值班室分机号是多少')).length
assert.equal(mutated, 0, 'old rule (skill catalog as query) must score 0 hits')
assert.ok(fixed >= 1, 'fixed rule must score >= 1')
return `batch 4 msgs, hit "4324" · old-rule hits=${mutated} vs fixed hits=${fixed}`
})
// ── V4: tools.register output.schema / render contract ────────────────────
await checkpoint('V4', 'ctx.tools.register: stablemem_commit validates args and renders output', async () => {
const app = makeApp()
const mem = applyMemory(app.ctx, { path: join(workspace, 'v4.db') })
const schemas = app.ctx.tools.schemas()
const tool = schemas.find((schema) => schema.name === 'stablemem_commit')
assert.ok(tool, `stablemem_commit not registered; got ${schemas.map((s) => s.name).join(',')}`)
assert.ok(tool.description.includes('durable'), 'description reaches the model')
assert.ok(tool.parameters?.properties?.text, 'parameters expose `text`')
const result = await app.ctx.tools.execute({
callId: 'v4-call-1',
name: 'stablemem_commit',
arguments: { text: 'V4 校验块:etcd 集群端口 2379', source: 'v4' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, `tool failed: ${JSON.stringify(result)}`)
assert.ok(typeof result.value === 'string' && result.value.includes('Committed'), `unexpected value ${result.value}`)
assert.ok(Array.isArray(result.content) && result.content[0]?.type === 'text', 'render produced a text block')
assert.equal(mem.store.countBlocks(), 1, 'block persisted')
return `${schemas.length} tool(s) visible; execute → "${result.value}"`
})
// ── V5: dispose releases the handle (idempotent, file unlocked) ───────────
await checkpoint('V5', 'unload disposes the sqlite handle; a second close is a no-op', async () => {
const path = join(workspace, 'v5.db')
const app = makeApp()
const mem = applyMemory(app.ctx, { path })
await mem.commit({ text: 'V5 句柄释放块', source: 'v5' })
assert.ok(mem.store.isOpen, 'store open while plugin loaded')
// Disposing the plugin fiber must run our ctx.effect teardown.
await app.ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'store must be closed after fiber disposal')
// Idempotent: a second close (test teardown, double unload) must not throw.
mem.store.close()
// The strongest proof the lock is gone: reopen the same file for writing.
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1, 'data survived; file was not locked')
reopened.close()
return 'fiber dispose → handle closed; reopened same file successfully'
})
// ── A5 mutation: LIKE-style containment must destroy discrimination ───────
await checkpoint('A5†', 'MUTATION: swapping FTS5 for containment breaks discrimination', () => {
const store = new BlockStore({ path: join(workspace, 'a5.db') }).open()
store.putBlock({ text: '值班室分机号 4324', source: 'm' })
store.putBlock({ text: '值班室分机号 4379 已停用', source: 'm' })
store.putBlock({ text: 'billing服务 端口=8499', source: 'm' })
const precise = recallBlocks(store, '4324', { topK: 3 })
assert.equal(precise.length, 1, `exact query should hit exactly one block, got ${precise.length}`)
// The mutation: primary path becomes "contains any CJK run" — everything matches.
const mutated = store
.allBlocks(100)
.filter((row) => row.text_content.includes('值班室'))
assert.ok(
mutated.length >= 2,
'sanity: containment over a shared prefix must match multiple blocks',
)
store.close()
return `FTS+pin → 1 hit for "4324"; containment → ${mutated.length} hits (discrimination lost)`
})
// ── MIG3: an existing phase-A database survives the phase-B upgrade ───────
//
// Lives in `check` as well as `test` on purpose: "can the user's existing
// memory.db still be used" is the single most important thing to see at a
// glance, and more people read this output than read the test suite. The
// failure it guards against is silent at load — the store opens, stamps a
// version, accepts writes to blocks, and only throws on the first centroid
// write — so a casual smoke test would miss it.
await checkpoint('MIG3', 'phase-A database opens, migrates, and stays fully writable', () => {
const path = join(workspace, 'phase-a.db')
// Build the phase-A shape by hand, including its OLD centroid table.
const legacy = new DatabaseSync(path)
legacy.exec(`CREATE TABLE 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)`)
legacy.exec(`CREATE TABLE graph_centroid (
fingerprint TEXT PRIMARY KEY, dim INTEGER NOT NULL, count INTEGER NOT NULL,
sum_json TEXT NOT NULL, updated_at INTEGER NOT NULL)`)
legacy.exec(`CREATE VIRTUAL TABLE memory_blocks_fts
USING fts5(text_content, block_id UNINDEXED, tokenize='unicode61 remove_diacritics 2')`)
legacy.exec(`INSERT INTO memory_blocks (id, modality, text_content, source, created_at, updated_at)
VALUES ('blk_legacy', 'text', '值班室分机号 4324', 'phase-a', 1, 1)`)
legacy.exec(`INSERT INTO memory_blocks_fts (text_content, block_id)
VALUES ('值班室分机号 4324', 'blk_legacy')`)
legacy.close()
const store = new BlockStore({ path }).open()
const version = store.userVersion()
const cols = store.columns('memory_blocks')
assert.ok(cols.includes('vector') && cols.includes('fingerprint'), `columns: ${cols.join(',')}`)
assert.equal(version, 1, `user_version after migration: ${version}`)
// The legacy block must survive AND still be findable.
assert.ok(store.getBlock('blk_legacy'), 'the phase-A block must survive')
assert.ok(store.searchFts('"4324"', 5).length >= 1, 'FTS must still find the legacy block')
// Both write paths must work — the block path AND the centroid path.
store.putBlock({ text: '迁移之后的新块 8499' })
const centroidCols = store.columns('graph_centroid')
store.saveCentroid({ fingerprint: 'fp-mig', dim: 3, vectorCount: 1, mean: [0, 0, 0], isAnomalous: true })
assert.ok(store.loadCentroid('fp-mig'), 'centroid must round-trip after migration')
store.close()
return `uv=${version}, blocks=${cols.length} cols, centroid=${centroidCols.length} cols, legacy block readable + FTS-matching`
})
// ── cleanup ───────────────────────────────────────────────────────────────
rmSync(workspace, { recursive: true, force: true })
const passed = results.filter((entry) => entry.ok).length
console.log(`\n${passed}/${results.length} checkpoints passed\n`)
console.log(`plugin: name=${pluginName} inject=[${pluginInject.join(', ')}]`)
console.log(`workspace: ${workspace} (removed)\n`)
if (failed > 0) {
console.error(`${failed} checkpoint(s) FAILED\n`)
process.exitCode = 1
}