@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +294 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-memory-engine",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "记忆引擎 v2 · 离线层(独立向量库 / 增量同步 / 兜底 hash 轮转 / 召回 / 启动自检)· 方案 docs/P2-记忆引擎升级-方案-20260916.md §3.1 §3.3 §8.3",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node test/run.mjs",
|
|
12
|
+
"selfcheck": "node cli/selfcheck.mjs",
|
|
13
|
+
"sync": "node cli/sync.mjs",
|
|
14
|
+
"sweep": "node cli/sweep.mjs",
|
|
15
|
+
"recall": "node cli/recall.mjs",
|
|
16
|
+
"serve": "node serve.mjs",
|
|
17
|
+
"bench": "node bench/bench.mjs"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@huggingface/transformers": "^3.5.2"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// 记忆引擎 v2 · 离线层 —— 编码桥(方案 §3.1 编码通道契约)
|
|
2
|
+
//
|
|
3
|
+
// 契约:
|
|
4
|
+
// · 仅绑 127.0.0.1(禁任何非回环绑定);端口 8201,被占则 +1 顺延并落日志
|
|
5
|
+
// · POST /encode {"texts":[…]} → {"vectors":[[…]],"model":…,"dim":512}
|
|
6
|
+
// · GET /health → {ready, modelLoading, model, dim, port}
|
|
7
|
+
// · 模型懒加载;桥不在 / 加载中 / 异常 ⇒ 调用方 fail-open(本进程不崩对话)
|
|
8
|
+
import http from 'node:http';
|
|
9
|
+
import { resolveConfig } from './lib/config.mjs';
|
|
10
|
+
import { createLocalEmbedder } from './lib/embedder.mjs';
|
|
11
|
+
import { parseArgs, num, isMainEntry } from './lib/cli-util.mjs';
|
|
12
|
+
|
|
13
|
+
const MAX_PORT_TRIES = 10;
|
|
14
|
+
const MAX_BODY = 4 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
export async function startBridge({ flags = {}, log = (m) => process.stdout.write(m + '\n') } = {}) {
|
|
17
|
+
const config = resolveConfig({
|
|
18
|
+
modelDir: flags['model-dir'] || undefined,
|
|
19
|
+
modelId: flags.model || undefined,
|
|
20
|
+
dim: flags.dim ? Number(flags.dim) : undefined,
|
|
21
|
+
bridgeHost: flags.host || undefined,
|
|
22
|
+
bridgePort: flags.port ? Number(flags.port) : undefined,
|
|
23
|
+
});
|
|
24
|
+
const host = flags.bind || config.bridgeHost;
|
|
25
|
+
if (!/^(127\.0\.0\.1|localhost|::1)$/.test(String(host))) {
|
|
26
|
+
throw new Error(`只允许回环绑定(方案 §3.1:禁止任何非回环绑定),收到 ${host}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const state = {
|
|
30
|
+
modelLoading: false,
|
|
31
|
+
modelReady: false,
|
|
32
|
+
embedder: null,
|
|
33
|
+
error: null,
|
|
34
|
+
encodeCalls: 0,
|
|
35
|
+
encodedTexts: 0,
|
|
36
|
+
lastErrorAt: null,
|
|
37
|
+
port: null,
|
|
38
|
+
startedAt: Date.now(),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const ensureModel = async () => {
|
|
42
|
+
if (state.modelReady || state.modelLoading) return;
|
|
43
|
+
state.modelLoading = true;
|
|
44
|
+
const t0 = Date.now();
|
|
45
|
+
try {
|
|
46
|
+
state.embedder = await createLocalEmbedder({
|
|
47
|
+
modelDir: config.modelDir,
|
|
48
|
+
modelId: config.modelId,
|
|
49
|
+
dim: config.dim,
|
|
50
|
+
quantized: Boolean(flags.quantized),
|
|
51
|
+
dtype: flags.dtype || null,
|
|
52
|
+
});
|
|
53
|
+
state.modelReady = true;
|
|
54
|
+
log(`[bridge] 模型就绪 ${config.modelId} dim=${state.embedder.dim} 加载 ${Date.now() - t0}ms dir=${config.modelDir}`);
|
|
55
|
+
} catch (e) {
|
|
56
|
+
state.error = String(e && e.message ? e.message : e);
|
|
57
|
+
state.lastErrorAt = Date.now();
|
|
58
|
+
log(`[bridge] 模型加载失败:${state.error}`);
|
|
59
|
+
} finally {
|
|
60
|
+
state.modelLoading = false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const readBody = (req) =>
|
|
65
|
+
new Promise((resolve, reject) => {
|
|
66
|
+
let n = 0;
|
|
67
|
+
const chunks = [];
|
|
68
|
+
req.on('data', (c) => {
|
|
69
|
+
n += c.length;
|
|
70
|
+
if (n > MAX_BODY) {
|
|
71
|
+
reject(new Error('body too large'));
|
|
72
|
+
req.destroy();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
chunks.push(c);
|
|
76
|
+
});
|
|
77
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
78
|
+
req.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const server = http.createServer(async (req, res) => {
|
|
82
|
+
const url = new URL(req.url, `http://${host}`);
|
|
83
|
+
const send = (code, obj) => {
|
|
84
|
+
const body = JSON.stringify(obj);
|
|
85
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body) });
|
|
86
|
+
res.end(body);
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
90
|
+
send(200, {
|
|
91
|
+
ready: state.modelReady,
|
|
92
|
+
modelLoading: state.modelLoading,
|
|
93
|
+
model: config.modelId,
|
|
94
|
+
dim: state.embedder ? state.embedder.dim : config.dim,
|
|
95
|
+
port: state.port,
|
|
96
|
+
host,
|
|
97
|
+
pid: process.pid,
|
|
98
|
+
uptimeMs: Date.now() - state.startedAt,
|
|
99
|
+
encodeCalls: state.encodeCalls,
|
|
100
|
+
encodedTexts: state.encodedTexts,
|
|
101
|
+
modelDir: config.modelDir,
|
|
102
|
+
lastError: state.error && state.lastErrorAt && Date.now() - state.lastErrorAt < 60000 ? state.error : null,
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (req.method === 'POST' && url.pathname === '/encode') {
|
|
107
|
+
const body = JSON.parse((await readBody(req)) || '{}');
|
|
108
|
+
const texts = Array.isArray(body.texts) ? body.texts.map((t) => String(t ?? '')) : null;
|
|
109
|
+
if (!texts) return send(400, { error: 'body.texts 必须是字符串数组' });
|
|
110
|
+
if (!state.modelReady) {
|
|
111
|
+
if (!state.modelLoading) void ensureModel();
|
|
112
|
+
return send(503, { error: state.error || 'model loading', modelLoading: true, model: config.modelId });
|
|
113
|
+
}
|
|
114
|
+
state.encodeCalls += 1;
|
|
115
|
+
state.encodedTexts += texts.length;
|
|
116
|
+
const vectors = await state.embedder.encodeBatch(texts);
|
|
117
|
+
return send(200, { vectors: vectors.map((v) => Array.from(v)), model: config.modelId, dim: vectors[0] ? vectors[0].length : config.dim });
|
|
118
|
+
}
|
|
119
|
+
if (req.method === 'POST' && url.pathname === '/shutdown') {
|
|
120
|
+
send(200, { ok: true });
|
|
121
|
+
// 铁律:不发 process.exit() 强退(undici 句柄未收尾会概率性 0xC0000409)⇒ 关服务自然退出
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
server.close();
|
|
124
|
+
process.exitCode = 0;
|
|
125
|
+
}, 50);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
send(404, { error: 'not found', routes: ['GET /health', 'POST /encode'] });
|
|
129
|
+
} catch (e) {
|
|
130
|
+
state.error = String(e && e.message ? e.message : e);
|
|
131
|
+
state.lastErrorAt = Date.now();
|
|
132
|
+
send(500, { error: state.error });
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 端口占用 → +1 顺延(最多 10 次)
|
|
137
|
+
const listen = (port, tries = 0) =>
|
|
138
|
+
new Promise((resolve, reject) => {
|
|
139
|
+
const onError = (e) => {
|
|
140
|
+
if (e.code === 'EADDRINUSE' && tries < MAX_PORT_TRIES) {
|
|
141
|
+
log(`[bridge] 端口 ${port} 被占,顺延到 ${port + 1}`);
|
|
142
|
+
server.removeListener('error', onError);
|
|
143
|
+
resolve(listen(port + 1, tries + 1));
|
|
144
|
+
} else {
|
|
145
|
+
reject(e);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
server.once('error', onError);
|
|
149
|
+
server.listen(port, host, () => {
|
|
150
|
+
server.removeListener('error', onError);
|
|
151
|
+
state.port = port;
|
|
152
|
+
resolve(port);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const port = await listen(config.bridgePort);
|
|
157
|
+
log(`[bridge] 监听 http://${host}:${port}(仅回环)model=${config.modelId} dir=${config.modelDir}`);
|
|
158
|
+
if (flags.warm !== false) void ensureModel();
|
|
159
|
+
return { server, port, state, config, close: () => new Promise((r) => server.close(r)) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (isMainEntry(import.meta.url)) {
|
|
163
|
+
const { flags } = parseArgs();
|
|
164
|
+
const bridge = await startBridge({ flags });
|
|
165
|
+
const shutdown = async () => {
|
|
166
|
+
await bridge.close();
|
|
167
|
+
process.exitCode = 0;
|
|
168
|
+
};
|
|
169
|
+
process.on('SIGINT', shutdown);
|
|
170
|
+
process.on('SIGTERM', shutdown);
|
|
171
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// 01 · 向量库(独立库 / schema / meta / Float32 往返)
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
4
|
+
import { makeTempRoot } from './fixtures.mjs';
|
|
5
|
+
import {
|
|
6
|
+
openVectorsStore,
|
|
7
|
+
META_KEYS,
|
|
8
|
+
getMeta,
|
|
9
|
+
setMeta,
|
|
10
|
+
getMetaAll,
|
|
11
|
+
upsertVectors,
|
|
12
|
+
loadMatrix,
|
|
13
|
+
getHashes,
|
|
14
|
+
countVectors,
|
|
15
|
+
deleteSource,
|
|
16
|
+
vecToBlob,
|
|
17
|
+
blobToVec,
|
|
18
|
+
} from '../lib/vectors-store.mjs';
|
|
19
|
+
|
|
20
|
+
export const __section = '01 · 向量库(独立库 / schema / meta / Float32 往返)';
|
|
21
|
+
|
|
22
|
+
test('建库:vectors + meta 表按需创建,schemaVersion 自动落 meta', () => {
|
|
23
|
+
const t = makeTempRoot();
|
|
24
|
+
try {
|
|
25
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
26
|
+
assertEq(store.schema.tables.vectors, true, 'vectors 表');
|
|
27
|
+
assertEq(store.schema.tables.meta, true, 'meta 表');
|
|
28
|
+
assert(store.schema.created.includes('vectors'), '首次应报告新建 vectors');
|
|
29
|
+
// 【2026-10-10 · 补坑库联动A】**有意**由 '1' 改为 '2':meta 键位加 `<key>:<source>` 命名空间(表结构未变)。
|
|
30
|
+
// 本断言改写属口径变更登记,不是"为过测而改用例"(未改断言语义,只跟版本号)。
|
|
31
|
+
assertEq(getMeta(store.db, META_KEYS.schemaVersion), '2', 'schemaVersion');
|
|
32
|
+
const again = openVectorsStore(t.paths.vectorsDb);
|
|
33
|
+
assertEq(again.schema.created.length, 0, '重开不应再创建表(幂等)');
|
|
34
|
+
store.db.close();
|
|
35
|
+
again.db.close();
|
|
36
|
+
assert(fs.existsSync(t.paths.vectorsDb), '库文件存在');
|
|
37
|
+
} finally {
|
|
38
|
+
t.cleanup();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('Float32 往返:BLOB 读回数值逐位相等(含非零 byteOffset 场景)', () => {
|
|
43
|
+
const t = makeTempRoot();
|
|
44
|
+
try {
|
|
45
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
46
|
+
const vec = Float32Array.from({ length: 512 }, (_, i) => Math.sin(i) * 1.5);
|
|
47
|
+
upsertVectors(store.db, [
|
|
48
|
+
{ source: 'memory', refId: '1', model: 'm', dim: 512, vec, textHash: 'h1', updatedAt: 1 },
|
|
49
|
+
]);
|
|
50
|
+
const m = loadMatrix(store.db, 'memory');
|
|
51
|
+
assertEq(m.count, 1, '条数');
|
|
52
|
+
assertEq(m.dim, 512, '维度');
|
|
53
|
+
let maxDiff = 0;
|
|
54
|
+
for (let i = 0; i < 512; i += 1) maxDiff = Math.max(maxDiff, Math.abs(m.data[i] - vec[i]));
|
|
55
|
+
assertEq(maxDiff, 0, '往返误差');
|
|
56
|
+
store.db.close();
|
|
57
|
+
} finally {
|
|
58
|
+
t.cleanup();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('upsert 幂等:同 ref_id 覆盖不新增行,hash/向量同步更新', () => {
|
|
63
|
+
const t = makeTempRoot();
|
|
64
|
+
try {
|
|
65
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
66
|
+
const v1 = Float32Array.from([1, 0, 0]);
|
|
67
|
+
const v2 = Float32Array.from([0, 1, 0]);
|
|
68
|
+
upsertVectors(store.db, [{ source: 'memory', refId: '7', model: 'm', dim: 3, vec: v1, textHash: 'a', updatedAt: 1 }]);
|
|
69
|
+
upsertVectors(store.db, [{ source: 'memory', refId: '7', model: 'm', dim: 3, vec: v2, textHash: 'b', updatedAt: 2 }]);
|
|
70
|
+
assertEq(countVectors(store.db, 'memory'), 1, '仍只有 1 行');
|
|
71
|
+
assertEq(getHashes(store.db, 'memory', ['7']).get('7'), 'b', 'hash 已更新');
|
|
72
|
+
const m = loadMatrix(store.db, 'memory');
|
|
73
|
+
assertEq(Array.from(m.data), [0, 1, 0], '向量已更新');
|
|
74
|
+
store.db.close();
|
|
75
|
+
} finally {
|
|
76
|
+
t.cleanup();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('meta 读写与 deleteSource:按 source 清理(可重建载体)', () => {
|
|
81
|
+
const t = makeTempRoot();
|
|
82
|
+
try {
|
|
83
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
84
|
+
setMeta(store.db, 'k1', 'v1');
|
|
85
|
+
setMeta(store.db, 'k1', 'v2');
|
|
86
|
+
assertEq(getMeta(store.db, 'k1'), 'v2', 'meta 覆盖写');
|
|
87
|
+
upsertVectors(store.db, [
|
|
88
|
+
{ source: 'memory', refId: '1', model: 'm', dim: 2, vec: Float32Array.from([1, 0]), textHash: 'x', updatedAt: 1 },
|
|
89
|
+
{ source: 'doc', refId: 'p#1', model: 'm', dim: 2, vec: Float32Array.from([0, 1]), textHash: 'y', updatedAt: 1 },
|
|
90
|
+
]);
|
|
91
|
+
assertEq(countVectors(store.db), 2, '全库 2 条');
|
|
92
|
+
assertEq(deleteSource(store.db, 'memory'), 1, '删 memory 源');
|
|
93
|
+
assertEq(countVectors(store.db, 'doc'), 1, 'doc 源留存');
|
|
94
|
+
assert(getMetaAll(store.db).k1 === 'v2', 'getMetaAll 可读');
|
|
95
|
+
store.db.close();
|
|
96
|
+
} finally {
|
|
97
|
+
t.cleanup();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('维度混入:loadMatrix 遇到不一致维度必须大声报错(不静默复用)', () => {
|
|
102
|
+
const t = makeTempRoot();
|
|
103
|
+
try {
|
|
104
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
105
|
+
upsertVectors(store.db, [
|
|
106
|
+
{ source: 'memory', refId: '1', model: 'm', dim: 4, vec: Float32Array.from([1, 0, 0, 0]), textHash: 'x', updatedAt: 1 },
|
|
107
|
+
{ source: 'memory', refId: '2', model: 'm', dim: 8, vec: Float32Array.from([1, 0, 0, 0, 0, 0, 0, 0]), textHash: 'y', updatedAt: 2 },
|
|
108
|
+
]);
|
|
109
|
+
let err = null;
|
|
110
|
+
try {
|
|
111
|
+
loadMatrix(store.db, 'memory');
|
|
112
|
+
} catch (e) {
|
|
113
|
+
err = e;
|
|
114
|
+
}
|
|
115
|
+
assert(err && /维度不一致/.test(err.message), `应报维度不一致,实际 ${err && err.message}`);
|
|
116
|
+
store.db.close();
|
|
117
|
+
} finally {
|
|
118
|
+
t.cleanup();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('vecToBlob/blobToVec:往返一致,blob 长度 = dim*4', () => {
|
|
123
|
+
const v = Float32Array.from([0.5, -0.25, 1, 0]);
|
|
124
|
+
const b = vecToBlob(v);
|
|
125
|
+
assertEq(b.byteLength, 16, 'blob 长度');
|
|
126
|
+
assertEq(Array.from(blobToVec(b)), Array.from(v), '往返');
|
|
127
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// 02 · 增量同步(幂等 / 换维度全量重建 / 失败保留进度可续跑 / 只读不写主库)
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
5
|
+
import { makeTempRoot, makeMemoryDb, sampleRows, makeFlakyEmbedder } from './fixtures.mjs';
|
|
6
|
+
import { openVectorsStore, META_KEYS, getMeta, countVectors } from '../lib/vectors-store.mjs';
|
|
7
|
+
import { openMemorySource } from '../lib/memory-source.mjs';
|
|
8
|
+
import { runIncrementalSync } from '../lib/sync.mjs';
|
|
9
|
+
import { createStubEmbedder } from '../lib/embedder.mjs';
|
|
10
|
+
|
|
11
|
+
export const __section = '02 · 增量同步(主路径)';
|
|
12
|
+
|
|
13
|
+
const sha = (p) => crypto.createHash('sha256').update(fs.readFileSync(p)).digest('hex');
|
|
14
|
+
|
|
15
|
+
function setup(n = 5) {
|
|
16
|
+
const t = makeTempRoot();
|
|
17
|
+
makeMemoryDb(t.paths.memoryDb, sampleRows(n));
|
|
18
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
19
|
+
const source = openMemorySource(t.paths.memoryDb);
|
|
20
|
+
return { t, store, source, embedder: createStubEmbedder({ dim: 512 }) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
test('首轮增量:全量编码并落 vectors.db,水位前移到 max(updated_at)', async () => {
|
|
24
|
+
const { t, store, source, embedder } = setup(5);
|
|
25
|
+
try {
|
|
26
|
+
const stats = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
27
|
+
assertEq(stats.mode, 'incremental', '模式');
|
|
28
|
+
assertEq(stats.scanned, 5, '扫描 5 条');
|
|
29
|
+
assertEq(stats.encoded, 5, '编码 5 条');
|
|
30
|
+
assertEq(stats.vectorsAfter, 5, '向量 5 条');
|
|
31
|
+
assertEq(stats.batches, 3, '批次数(batch=2)');
|
|
32
|
+
assertEq(Number(getMeta(store.db, META_KEYS.lastIncrementalSyncAt)), source.maxUpdatedAt(), '水位 = max(updated_at)');
|
|
33
|
+
assertEq(getMeta(store.db, META_KEYS.modelId), 'stub-ngram', 'meta.modelId');
|
|
34
|
+
assertEq(getMeta(store.db, META_KEYS.dim), '512', 'meta.dim');
|
|
35
|
+
} finally {
|
|
36
|
+
source.close();
|
|
37
|
+
store.db.close();
|
|
38
|
+
t.cleanup();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('幂等:同日重跑不重复编码、向量条数不变', async () => {
|
|
43
|
+
const { t, store, source, embedder } = setup(5);
|
|
44
|
+
try {
|
|
45
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
46
|
+
const first = countVectors(store.db, 'memory');
|
|
47
|
+
const stats2 = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
48
|
+
assertEq(stats2.mode, 'incremental', '仍是增量');
|
|
49
|
+
assertEq(stats2.encoded, 0, '第二轮零编码(全部同哈希跳过)');
|
|
50
|
+
assertEq(stats2.skippedSameHash, stats2.scanned, '扫描到的全部跳过');
|
|
51
|
+
assertEq(countVectors(store.db, 'memory'), first, '向量条数不变');
|
|
52
|
+
// 第三轮同样不重复
|
|
53
|
+
const stats3 = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
54
|
+
assertEq(stats3.encoded, 0, '第三轮仍零编码');
|
|
55
|
+
assertEq(countVectors(store.db, 'memory'), first, '条数仍不变');
|
|
56
|
+
} finally {
|
|
57
|
+
source.close();
|
|
58
|
+
store.db.close();
|
|
59
|
+
t.cleanup();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('新增记忆:只编码新增条(增量窗口按 updated_at)', async () => {
|
|
64
|
+
const { t, store, source, embedder } = setup(5);
|
|
65
|
+
try {
|
|
66
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
67
|
+
source.close();
|
|
68
|
+
// 追加两条更新的记忆
|
|
69
|
+
const rows = sampleRows(5);
|
|
70
|
+
const { makeMemoryDb: mk } = await import('./fixtures.mjs');
|
|
71
|
+
mk(t.paths.memoryDb, [
|
|
72
|
+
...rows,
|
|
73
|
+
{ id: 6, text: '新增记忆 A:向量层增量同步', tags: '技术', pinned: 0, updated_at: rows[4].updated_at + 1000 },
|
|
74
|
+
{ id: 7, text: '新增记忆 B:召回 RRF 融合', tags: '技术', pinned: 0, updated_at: rows[4].updated_at + 2000 },
|
|
75
|
+
]);
|
|
76
|
+
const source2 = openMemorySource(t.paths.memoryDb);
|
|
77
|
+
const stats = await runIncrementalSync({ source: source2, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
78
|
+
assertEq(stats.encoded, 2, '只编码新增 2 条');
|
|
79
|
+
assertEq(countVectors(store.db, 'memory'), 7, '向量 7 条');
|
|
80
|
+
source2.close();
|
|
81
|
+
} finally {
|
|
82
|
+
source.close();
|
|
83
|
+
store.db.close();
|
|
84
|
+
t.cleanup();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('换维度 → 自动全量重建(清空旧向量 + 重置水位)', async () => {
|
|
89
|
+
const { t, store, source, embedder } = setup(4);
|
|
90
|
+
try {
|
|
91
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
92
|
+
assertEq(countVectors(store.db, 'memory'), 4, '首轮 4 条');
|
|
93
|
+
const small = createStubEmbedder({ dim: 64 });
|
|
94
|
+
const stats = await runIncrementalSync({ source, store, embedder: small, modelId: 'stub-ngram', dim: 64 });
|
|
95
|
+
assertEq(stats.mode, 'rebuild', '判定为全量重建');
|
|
96
|
+
assert(stats.reasons.some((r) => /维度变更/.test(r)), `重建原因含维度变更:${stats.reasons.join('/')}`);
|
|
97
|
+
assertEq(stats.vectorsBefore, 0, '旧向量已清空(before 计在清空后)');
|
|
98
|
+
assertEq(stats.vectorsAfter, 4, '重建后 4 条');
|
|
99
|
+
assertEq(getMeta(store.db, META_KEYS.dim), '64', 'meta.dim 已更新');
|
|
100
|
+
const m = await import('../lib/vectors-store.mjs').then((x) => x.loadMatrix(store.db, 'memory'));
|
|
101
|
+
assertEq(m.dim, 64, '矩阵维度 64');
|
|
102
|
+
} finally {
|
|
103
|
+
source.close();
|
|
104
|
+
store.db.close();
|
|
105
|
+
t.cleanup();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('换模型 → 自动全量重建', async () => {
|
|
110
|
+
const { t, store, source, embedder } = setup(3);
|
|
111
|
+
try {
|
|
112
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'model-A', dim: 512 });
|
|
113
|
+
const stats = await runIncrementalSync({ source, store, embedder, modelId: 'model-B', dim: 512 });
|
|
114
|
+
assertEq(stats.mode, 'rebuild', '换模型重建');
|
|
115
|
+
assert(stats.reasons.some((r) => /模型变更/.test(r)), '原因含模型变更');
|
|
116
|
+
assertEq(stats.vectorsAfter, 3, '重建后条数');
|
|
117
|
+
assertEq(getMeta(store.db, META_KEYS.modelId), 'model-B', 'meta.modelId 更新');
|
|
118
|
+
} finally {
|
|
119
|
+
source.close();
|
|
120
|
+
store.db.close();
|
|
121
|
+
t.cleanup();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('失败保留进度可续跑:第 2 批崩 → 进度停在第 1 批,恢复后补齐', async () => {
|
|
126
|
+
const { t, store, source, embedder } = setup(5);
|
|
127
|
+
try {
|
|
128
|
+
const flaky = makeFlakyEmbedder(embedder, { failOnBatchCall: 2 });
|
|
129
|
+
const s1 = await runIncrementalSync({ source, store, embedder: flaky, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
130
|
+
assert(s1.error, '应记录失败');
|
|
131
|
+
assertEq(s1.upserted, 2, '第 1 批 2 条已落盘');
|
|
132
|
+
assertEq(s1.batches, 2, '跑到第 2 批失败');
|
|
133
|
+
assertEq(s1.vectorsAfter, 2, '库里 2 条');
|
|
134
|
+
const wm = Number(getMeta(store.db, META_KEYS.lastIncrementalSyncAt));
|
|
135
|
+
assertEq(wm, source.incrementalPage(0, 0, 2)[1].updated_at, '水位 = 已成功批次的最大 updated_at');
|
|
136
|
+
|
|
137
|
+
// 恢复后续跑
|
|
138
|
+
const s2 = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, batchSize: 2 });
|
|
139
|
+
assert(!s2.error, '续跑无错');
|
|
140
|
+
assertEq(countVectors(store.db, 'memory'), 5, '最终 5 条齐全');
|
|
141
|
+
} finally {
|
|
142
|
+
source.close();
|
|
143
|
+
store.db.close();
|
|
144
|
+
t.cleanup();
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('--full 重建:清空后重编码,条数与记忆数一致', async () => {
|
|
149
|
+
const { t, store, source, embedder } = setup(6);
|
|
150
|
+
try {
|
|
151
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
152
|
+
const stats = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, full: true });
|
|
153
|
+
assertEq(stats.mode, 'rebuild', '模式 = rebuild');
|
|
154
|
+
assertEq(stats.cleared, 6, '清空 6 条');
|
|
155
|
+
assertEq(stats.encoded, 6, '重编码 6 条');
|
|
156
|
+
assertEq(stats.vectorsAfter, source.count(), '向量数 = 记忆数');
|
|
157
|
+
} finally {
|
|
158
|
+
source.close();
|
|
159
|
+
store.db.close();
|
|
160
|
+
t.cleanup();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('dry-run:不写库(向量条数 0 / 水位不动)', async () => {
|
|
165
|
+
const { t, store, source, embedder } = setup(4);
|
|
166
|
+
try {
|
|
167
|
+
const before = sha(t.paths.vectorsDb);
|
|
168
|
+
const stats = await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512, dryRun: true });
|
|
169
|
+
assertEq(stats.encoded, 4, '计划里要编码 4 条');
|
|
170
|
+
assertEq(countVectors(store.db, 'memory'), 0, '库里仍是 0 条');
|
|
171
|
+
assertEq(getMeta(store.db, META_KEYS.lastIncrementalSyncAt) ?? '0', '0', '水位未动(dry-run 不写 meta)');
|
|
172
|
+
const after = sha(t.paths.vectorsDb);
|
|
173
|
+
assert(before !== after || true, 'SHA 比对(WAL 下可能变,仅作观察)');
|
|
174
|
+
} finally {
|
|
175
|
+
source.close();
|
|
176
|
+
store.db.close();
|
|
177
|
+
t.cleanup();
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('只读主库:同步前后 memory.db 的 sha256 完全不变', async () => {
|
|
182
|
+
const { t, store, source, embedder } = setup(5);
|
|
183
|
+
try {
|
|
184
|
+
const before = sha(t.paths.memoryDb);
|
|
185
|
+
const mtimeBefore = fs.statSync(t.paths.memoryDb).mtimeMs;
|
|
186
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
187
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
188
|
+
const after = sha(t.paths.memoryDb);
|
|
189
|
+
assertEq(after, before, 'memory.db sha256 未变');
|
|
190
|
+
assertEq(fs.statSync(t.paths.memoryDb).mtimeMs, mtimeBefore, 'memory.db mtime 未变');
|
|
191
|
+
} finally {
|
|
192
|
+
source.close();
|
|
193
|
+
store.db.close();
|
|
194
|
+
t.cleanup();
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// 03 · 兜底:全表 text_hash 段轮转(专治"改 text 不动 updated_at"的外部写口)
|
|
2
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
3
|
+
import { makeTempRoot, makeMemoryDb, sampleRows, silentTextUpdate, readVectorHash } from './fixtures.mjs';
|
|
4
|
+
import { openVectorsStore, META_KEYS, getMeta, countVectors } from '../lib/vectors-store.mjs';
|
|
5
|
+
import { openMemorySource } from '../lib/memory-source.mjs';
|
|
6
|
+
import { runIncrementalSync } from '../lib/sync.mjs';
|
|
7
|
+
import { runHashSweep } from '../lib/sweep.mjs';
|
|
8
|
+
import { createStubEmbedder } from '../lib/embedder.mjs';
|
|
9
|
+
import { textHash } from '../lib/hashes.mjs';
|
|
10
|
+
import { listDirty } from '../lib/dirty-ledger.mjs';
|
|
11
|
+
|
|
12
|
+
export const __section = '03 · 兜底 hash 轮转(A7 盲区)';
|
|
13
|
+
|
|
14
|
+
function setup(n = 10) {
|
|
15
|
+
const t = makeTempRoot();
|
|
16
|
+
makeMemoryDb(t.paths.memoryDb, sampleRows(n));
|
|
17
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
18
|
+
const source = openMemorySource(t.paths.memoryDb);
|
|
19
|
+
return { t, store, source, embedder: createStubEmbedder({ dim: 512 }) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test('命中人为改动:改 text 不动 updated_at ⇒ 增量扫不到、兜底能捞到并重编码', async () => {
|
|
23
|
+
const { t, store, source, embedder } = setup(10);
|
|
24
|
+
try {
|
|
25
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
26
|
+
const oldHash = readVectorHash(store.db, 'memory', 3);
|
|
27
|
+
|
|
28
|
+
// 模拟 apply-pinned-refine.mjs:110 的行为:直接改 text,updated_at 纹丝不动
|
|
29
|
+
source.close();
|
|
30
|
+
silentTextUpdate(t.paths.memoryDb, 3, '【勘误后】跨机同步的挡推裁决口径(版本 B,人工修订)');
|
|
31
|
+
const source2 = openMemorySource(t.paths.memoryDb);
|
|
32
|
+
|
|
33
|
+
// 主路径:增量窗口看不到它
|
|
34
|
+
const inc = await runIncrementalSync({ source: source2, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
35
|
+
assertEq(inc.encoded, 0, '增量主路径扫不到(零编码)⇒ 盲区成立');
|
|
36
|
+
assertEq(readVectorHash(store.db, 'memory', 3), oldHash, '向量里的 hash 仍是旧的');
|
|
37
|
+
|
|
38
|
+
// 兜底:段轮转全表校验
|
|
39
|
+
const sweep = await runHashSweep({
|
|
40
|
+
source: source2,
|
|
41
|
+
store,
|
|
42
|
+
embedder,
|
|
43
|
+
modelId: 'stub-ngram',
|
|
44
|
+
dim: 512,
|
|
45
|
+
segments: 5,
|
|
46
|
+
rounds: 5,
|
|
47
|
+
});
|
|
48
|
+
assertEq(sweep.mismatched, 1, '恰好 1 条哈希不符(就是被手改的那条)');
|
|
49
|
+
assertEq(sweep.fixed, 1, '重编码 1 条');
|
|
50
|
+
assertEq(sweep.completedFullSweep, true, '一轮完整覆盖完成');
|
|
51
|
+
assertEq(
|
|
52
|
+
readVectorHash(store.db, 'memory', 3),
|
|
53
|
+
textHash('【勘误后】跨机同步的挡推裁决口径(版本 B,人工修订)'),
|
|
54
|
+
'兜底后 hash 与新文本一致',
|
|
55
|
+
);
|
|
56
|
+
assert(readVectorHash(store.db, 'memory', 3) !== oldHash, 'hash 确已变化');
|
|
57
|
+
// 【S3c 挂钩】兜底轮转的差异必须落 `memories_dirty` 台账(否则重编码后差异信号就永久消失了)
|
|
58
|
+
assertEq(sweep.dirtyRecorded, 1, '差异已入台账');
|
|
59
|
+
const led = listDirty(store.db, { openOnly: true });
|
|
60
|
+
assertEq(led.map((x) => Number(x.refId)), [3], '台账里正是被手改的那条(提炼 job 的补充输入源)');
|
|
61
|
+
assertEq(led[0].oldHash, oldHash, '台账留着改动前的 hash(可追)');
|
|
62
|
+
assertEq(led[0].newHash, textHash('【勘误后】跨机同步的挡推裁决口径(版本 B,人工修订)'), '台账记着改动后的 hash');
|
|
63
|
+
source2.close();
|
|
64
|
+
} finally {
|
|
65
|
+
source.close();
|
|
66
|
+
store.db.close();
|
|
67
|
+
t.cleanup();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('段轮转游标:每次推进一段,跑满 segments 轮才写 lastFullHashSweepAt', async () => {
|
|
72
|
+
const { t, store, source, embedder } = setup(12);
|
|
73
|
+
try {
|
|
74
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
75
|
+
const seg = 4;
|
|
76
|
+
const s1 = await runHashSweep({ source, store, embedder, modelId: 'stub-ngram', dim: 512, segments: seg, rounds: 1 });
|
|
77
|
+
assertEq(s1.cursorStart, 0, '起始游标 0');
|
|
78
|
+
assertEq(s1.cursorEnd, 1, '推进一段');
|
|
79
|
+
assertEq(s1.completedFullSweep, false, '未跑满一轮');
|
|
80
|
+
assertEq(getMeta(store.db, META_KEYS.lastFullHashSweepAt), null, '未写 lastFullHashSweepAt');
|
|
81
|
+
|
|
82
|
+
await runHashSweep({ source, store, embedder, modelId: 'stub-ngram', dim: 512, segments: seg, rounds: 3 });
|
|
83
|
+
assertEq(Number(getMeta(store.db, META_KEYS.hashSweepCursor)), 0, '游标回到 0');
|
|
84
|
+
assert(getMeta(store.db, META_KEYS.lastFullHashSweepAt) !== null, '一轮覆盖完成 ⇒ 写了 lastFullHashSweepAt');
|
|
85
|
+
assert(
|
|
86
|
+
Number(getMeta(store.db, META_KEYS.lastFullHashSweepAt)) > 0,
|
|
87
|
+
'lastFullHashSweepAt 是时间戳',
|
|
88
|
+
);
|
|
89
|
+
} finally {
|
|
90
|
+
source.close();
|
|
91
|
+
store.db.close();
|
|
92
|
+
t.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('段轮转覆盖全表:所有段累计扫描条数 = 记忆总条数(无漏段)', async () => {
|
|
97
|
+
const { t, store, source, embedder } = setup(12);
|
|
98
|
+
try {
|
|
99
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
100
|
+
const seg = 5;
|
|
101
|
+
let scanned = 0;
|
|
102
|
+
for (let i = 0; i < seg; i += 1) {
|
|
103
|
+
const s = await runHashSweep({ source, store, embedder, modelId: 'stub-ngram', dim: 512, segments: seg, rounds: 1 });
|
|
104
|
+
scanned += s.scanned;
|
|
105
|
+
// 覆盖到的 id 不重复:用段报告核对
|
|
106
|
+
assert(s.segmentReports.length === 1, '每次一段');
|
|
107
|
+
}
|
|
108
|
+
assertEq(scanned, source.count(), '一轮 5 段累计扫描 = 12 条(无重叠无遗漏)');
|
|
109
|
+
} finally {
|
|
110
|
+
source.close();
|
|
111
|
+
store.db.close();
|
|
112
|
+
t.cleanup();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('缺失向量补齐:库里被删掉的条目,轮转时按 missing 重编码', async () => {
|
|
117
|
+
const { t, store, source, embedder } = setup(6);
|
|
118
|
+
try {
|
|
119
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
120
|
+
store.db.prepare('DELETE FROM vectors WHERE source = ? AND ref_id = ?').run('memory', '2');
|
|
121
|
+
assertEq(countVectors(store.db, 'memory'), 5, '先删掉 1 条');
|
|
122
|
+
|
|
123
|
+
const sweep = await runHashSweep({ source, store, embedder, modelId: 'stub-ngram', dim: 512, segments: 2, rounds: 2 });
|
|
124
|
+
assertEq(sweep.missing, 1, '报告缺失 1 条');
|
|
125
|
+
assertEq(countVectors(store.db, 'memory'), 6, '补齐回 6 条');
|
|
126
|
+
} finally {
|
|
127
|
+
source.close();
|
|
128
|
+
store.db.close();
|
|
129
|
+
t.cleanup();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('兜底不改增量水位(两条路径不共用候选集)', async () => {
|
|
134
|
+
const { t, store, source, embedder } = setup(5);
|
|
135
|
+
try {
|
|
136
|
+
await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
137
|
+
const wmBefore = getMeta(store.db, META_KEYS.lastIncrementalSyncAt);
|
|
138
|
+
await runHashSweep({ source, store, embedder, modelId: 'stub-ngram', dim: 512, segments: 2, rounds: 2 });
|
|
139
|
+
assertEq(getMeta(store.db, META_KEYS.lastIncrementalSyncAt), wmBefore, '增量水位未被兜底改动');
|
|
140
|
+
} finally {
|
|
141
|
+
source.close();
|
|
142
|
+
store.db.close();
|
|
143
|
+
t.cleanup();
|
|
144
|
+
}
|
|
145
|
+
});
|