@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.1
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 +42 -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 +306 -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,365 @@
|
|
|
1
|
+
// 09 · 坑库第二向量来源(方案 §3 A 点 · source='pitfall')
|
|
2
|
+
//
|
|
3
|
+
// 覆盖:硬契约方法齐全 / 段与增量页的字段级契约 / 三入口(sync·sweep·recall)跑通无 `is not a function`
|
|
4
|
+
// / 跨源不串味(记忆侧水位逐字节不变 + 同轮正向对照)/ meta 命名空间与迁移幂等 / 逐 source 自检。
|
|
5
|
+
//
|
|
6
|
+
// 铁律:一切输出落系统临时区(夹具),绝不写生产路径。
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
9
|
+
import { makeTempRoot, makeMemoryDb, sampleRows, samplePitfalls, makePitfallJson, pitfallId } from './fixtures.mjs';
|
|
10
|
+
import {
|
|
11
|
+
openVectorsStore,
|
|
12
|
+
META_KEYS,
|
|
13
|
+
getMeta,
|
|
14
|
+
getMetaFor,
|
|
15
|
+
setMeta,
|
|
16
|
+
metaKeyFor,
|
|
17
|
+
migrateMetaNamespace,
|
|
18
|
+
countVectors,
|
|
19
|
+
upsertVectors,
|
|
20
|
+
} from '../lib/vectors-store.mjs';
|
|
21
|
+
import { openMemorySource } from '../lib/memory-source.mjs';
|
|
22
|
+
import { openPitfallSource, pitfallText } from '../lib/pitfall-source.mjs';
|
|
23
|
+
import { runIncrementalSync } from '../lib/sync.mjs';
|
|
24
|
+
import { runHashSweep } from '../lib/sweep.mjs';
|
|
25
|
+
import { recall, recallMulti, buildVectorIndex, rrfFuse, refIdToNum } from '../lib/recall.mjs';
|
|
26
|
+
import { runSelfcheck } from '../lib/selfcheck.mjs';
|
|
27
|
+
import { createStubEmbedder } from '../lib/embedder.mjs';
|
|
28
|
+
import { ensureDirtySchema, listDirty } from '../lib/dirty-ledger.mjs';
|
|
29
|
+
|
|
30
|
+
export const __section = '09 · 坑库第二向量来源(source=pitfall)';
|
|
31
|
+
|
|
32
|
+
const N = 12;
|
|
33
|
+
|
|
34
|
+
function setup({ nMem = 6, nPit = N } = {}) {
|
|
35
|
+
const t = makeTempRoot();
|
|
36
|
+
makeMemoryDb(t.paths.memoryDb, sampleRows(nMem));
|
|
37
|
+
makePitfallJson(t.paths.pitfallsFile, samplePitfalls(nPit));
|
|
38
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
39
|
+
const memSource = openMemorySource(t.paths.memoryDb);
|
|
40
|
+
const pitSource = openPitfallSource(t.paths.pitfallsFile);
|
|
41
|
+
return { t, store, memSource, pitSource, embedder: createStubEmbedder({ dim: 512 }) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('硬契约:方法与 memory-source 同形状(少一个 = 第一次跑就 is not a function)', () => {
|
|
45
|
+
const { t, pitSource } = setup({ nMem: 1, nPit: 3 });
|
|
46
|
+
try {
|
|
47
|
+
for (const m of ['count', 'maxUpdatedAt', 'idRange', 'pinnedIds', 'incrementalPage', 'segment', 'getById', 'allTexts', 'allTags', 'ftsSearch', 'close']) {
|
|
48
|
+
assertEq(typeof pitSource[m], 'function', `方法在位:${m}`);
|
|
49
|
+
}
|
|
50
|
+
assertEq(pitSource.readOnly, true, '只读自证');
|
|
51
|
+
assertEq(pitSource.sourceName, 'pitfall', '来源名');
|
|
52
|
+
assertEq(pitSource.dbPath, t.paths.pitfallsFile, '数据源路径');
|
|
53
|
+
assertEq(pitSource.maxUpdatedAt(), samplePitfalls(3).reduce((a, r) => Math.max(a, r.lastSeen), 0), 'maxUpdatedAt = max(lastSeen)');
|
|
54
|
+
assertEq(pitSource.pinnedIds(), [], '坑库无 pinned ⇒ 空集');
|
|
55
|
+
assertEq(pitSource.idRange(), { lo: 0, hi: 2, n: 3 }, 'idRange = 排序下标区间');
|
|
56
|
+
const f = pitSource.ftsSearch('"task-board"', 5);
|
|
57
|
+
assert(f && !Array.isArray(f) && typeof f.error === 'string', 'ftsSearch 必须返回对象(含 error),不得抛错');
|
|
58
|
+
const g = pitSource.getById(pitfallId(1));
|
|
59
|
+
assert(g && g.text && g.updated_at > 0, 'getById 给 {id,text,updated_at}');
|
|
60
|
+
assertEq(pitSource.getById('nope'), null, '取不到给 null');
|
|
61
|
+
pitSource.close();
|
|
62
|
+
pitSource.close();
|
|
63
|
+
assertEq(pitSource.closed, true, 'close 幂等');
|
|
64
|
+
} finally {
|
|
65
|
+
t.cleanup();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('文本构成:seed 前缀 + [category] + tools 后缀(方案 §A.3 定稿)', () => {
|
|
70
|
+
const rows = samplePitfalls(4);
|
|
71
|
+
const seedRow = rows.find((r) => r.source === 'seed');
|
|
72
|
+
const scanRow = rows.find((r) => r.source === 'scan');
|
|
73
|
+
assertEq(pitfallText(seedRow).startsWith('[seed]'), true, `seed 条带 [seed] 前缀:${pitfallText(seedRow)}`);
|
|
74
|
+
assertEq(pitfallText(seedRow).includes(' | tools='), false, 'seed 无 tools ⇒ 不拼后缀');
|
|
75
|
+
assert(pitfallText(scanRow).includes(`[${scanRow.category}]`), '含 [category]');
|
|
76
|
+
assert(pitfallText(scanRow).includes(' | tools='), 'scan 条带 tools 后缀');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('segment:返回行必含 id/text/updated_at(缺 updated_at 会把 undefined 写进向量)', () => {
|
|
80
|
+
const { t, pitSource } = setup();
|
|
81
|
+
try {
|
|
82
|
+
const rows = pitSource.segment(0, 3);
|
|
83
|
+
assertEq(rows.length, 4, '闭区间 4 条');
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
assertEq(typeof r.id, 'string', 'id 是字符串(16 位 hex)');
|
|
86
|
+
assertEq(typeof r.text, 'string', 'text');
|
|
87
|
+
assertEq(typeof r.updated_at, 'number', 'updated_at 必须是数值(sweep.mjs:110 直接取它)');
|
|
88
|
+
assertEq(r.updated_at === undefined, false, '不得为 undefined');
|
|
89
|
+
}
|
|
90
|
+
// 段覆盖全表:按 4 段切开,累加 = 全表且不重叠
|
|
91
|
+
const seg = 4;
|
|
92
|
+
const r2 = pitSource.idRange();
|
|
93
|
+
const width = Math.ceil((r2.hi - r2.lo + 1) / seg);
|
|
94
|
+
const seen = new Set();
|
|
95
|
+
for (let i = 0; i < seg; i += 1) {
|
|
96
|
+
const lo = r2.lo + i * width;
|
|
97
|
+
const hi = Math.min(r2.hi, lo + width - 1);
|
|
98
|
+
for (const row of pitSource.segment(lo, hi)) seen.add(row.id);
|
|
99
|
+
}
|
|
100
|
+
assertEq(seen.size, pitSource.count(), '段轮转覆盖全表(无重叠无遗漏)');
|
|
101
|
+
} finally {
|
|
102
|
+
t.cleanup();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('incrementalPage:稳定游标(lastSeen 升序 + id 字典序)分页不重不漏', () => {
|
|
107
|
+
const { t, pitSource } = setup({ nMem: 1, nPit: 20 });
|
|
108
|
+
try {
|
|
109
|
+
const batch = 3;
|
|
110
|
+
const all = [];
|
|
111
|
+
let afterId = 0;
|
|
112
|
+
for (let guard = 0; guard < 50; guard += 1) {
|
|
113
|
+
const page = pitSource.incrementalPage(0, afterId, batch);
|
|
114
|
+
if (!page.length) break;
|
|
115
|
+
all.push(...page.map((r) => r.id));
|
|
116
|
+
afterId = page[page.length - 1].id;
|
|
117
|
+
}
|
|
118
|
+
assertEq(all.length, 20, '全量分页拿到 20 条');
|
|
119
|
+
assertEq(new Set(all).size, 20, '无重复');
|
|
120
|
+
const sorted = pitSource.segment(0, 19).map((r) => r.id);
|
|
121
|
+
assertEq(all, sorted, '分页顺序 = 段顺序(同一排序)');
|
|
122
|
+
|
|
123
|
+
// since 过滤:只取最近的(最后 5 条所在的时间带)
|
|
124
|
+
const segs = pitSource.segment(0, 19);
|
|
125
|
+
const cut = segs[19].updated_at;
|
|
126
|
+
const recent = pitSource.incrementalPage(cut, 0, 100);
|
|
127
|
+
assert(recent.length > 0 && recent.length < 20, `since 过滤生效(${recent.length} 条)`);
|
|
128
|
+
assert(recent.every((r) => r.updated_at >= cut), '全部 >= sinceTs');
|
|
129
|
+
} finally {
|
|
130
|
+
t.cleanup();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('sync --source pitfall:写进 source=pitfall,条数 = 坑条数;重跑幂等', async () => {
|
|
135
|
+
const { t, store, memSource, pitSource, embedder } = setup();
|
|
136
|
+
try {
|
|
137
|
+
await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
138
|
+
const memBefore = countVectors(store.db, 'memory');
|
|
139
|
+
|
|
140
|
+
const s1 = await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
141
|
+
assertEq(s1.source, 'pitfall', 'stats.source 回显来源');
|
|
142
|
+
assertEq(s1.scanned, N, '扫到全部坑');
|
|
143
|
+
assertEq(s1.encoded, N, '全部编码');
|
|
144
|
+
assertEq(countVectors(store.db, 'pitfall'), N, 'pitfall 向量条数 = 坑条数');
|
|
145
|
+
assertEq(countVectors(store.db, 'memory'), memBefore, '**记忆源条数不受影响**(共库不串味)');
|
|
146
|
+
|
|
147
|
+
const s2 = await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
148
|
+
assertEq(s2.encoded, 0, '同哈希幂等:重跑 0 编码');
|
|
149
|
+
assertEq(countVectors(store.db, 'pitfall'), N, '条数不变');
|
|
150
|
+
} finally {
|
|
151
|
+
pitSource.close();
|
|
152
|
+
memSource.close();
|
|
153
|
+
store.db.close();
|
|
154
|
+
t.cleanup();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('判据3:跨源同步后**记忆侧四类水位键逐字节不变** + 同轮正向对照(新记忆 ⇒ encoded=1)', async () => {
|
|
159
|
+
const { t, store, memSource, pitSource, embedder } = setup();
|
|
160
|
+
try {
|
|
161
|
+
await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
162
|
+
const keys = [META_KEYS.modelId, META_KEYS.dim, META_KEYS.lastIncrementalSyncAt, META_KEYS.hashSweepCursor];
|
|
163
|
+
const before = keys.map((k) => getMetaFor(store.db, k, 'memory'));
|
|
164
|
+
|
|
165
|
+
// 顺序:先坑源同步、再紧接着记忆源增量同步(复审 C6 要求的"哪两个源、什么顺序、观察谁")
|
|
166
|
+
await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
167
|
+
const inc = await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
168
|
+
assertEq(inc.encoded, 0, '记忆侧无新内容 ⇒ 0 编码(水位未被动过)');
|
|
169
|
+
assertEq(keys.map((k) => getMetaFor(store.db, k, 'memory')), before, '记忆侧四类水位键逐字节不变');
|
|
170
|
+
|
|
171
|
+
// 正向对照:证明该路真的会编码(不是"看起来没变"其实整条不工作)
|
|
172
|
+
const raw = new DatabaseSync(t.paths.memoryDb);
|
|
173
|
+
raw.prepare('INSERT INTO memories (text, tags, pinned, created_at, updated_at) VALUES (?,?,?,?,?)')
|
|
174
|
+
.run('新增一号记忆:坑库联动正向对照', 'test', 0, 1_760_100_000_000, 1_760_100_000_000);
|
|
175
|
+
raw.close();
|
|
176
|
+
const memSource2 = openMemorySource(t.paths.memoryDb);
|
|
177
|
+
const inc2 = await runIncrementalSync({ source: memSource2, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
178
|
+
assertEq(inc2.encoded, 1, '同轮正向对照:新增 1 条 ⇒ encoded=1');
|
|
179
|
+
assertEq(Number(getMetaFor(store.db, META_KEYS.lastIncrementalSyncAt, 'pitfall')) === Number(getMetaFor(store.db, META_KEYS.lastIncrementalSyncAt, 'memory')), false, '两源水位各走各的');
|
|
180
|
+
memSource2.close();
|
|
181
|
+
} finally {
|
|
182
|
+
pitSource.close();
|
|
183
|
+
memSource.close();
|
|
184
|
+
store.db.close();
|
|
185
|
+
t.cleanup();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('sweep --source pitfall:覆盖全表、segmentReports 无 undefined、不污染 memories_dirty 台账', async () => {
|
|
190
|
+
const { t, store, memSource, pitSource, embedder } = setup();
|
|
191
|
+
try {
|
|
192
|
+
await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
193
|
+
await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
194
|
+
ensureDirtySchema(store.db);
|
|
195
|
+
const dirtyBefore = listDirty(store.db, { openOnly: false }).length;
|
|
196
|
+
|
|
197
|
+
const seg = 4;
|
|
198
|
+
let scanned = 0;
|
|
199
|
+
let reports = [];
|
|
200
|
+
for (let i = 0; i < seg; i += 1) {
|
|
201
|
+
const s = await runHashSweep({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall', segments: seg, rounds: 1 });
|
|
202
|
+
scanned += s.scanned;
|
|
203
|
+
reports = reports.concat(s.segmentReports);
|
|
204
|
+
for (const r of s.segmentReports) {
|
|
205
|
+
for (const k of ['lo', 'hi', 'rows', 'missing', 'mismatched', 'fixed']) {
|
|
206
|
+
assertEq(typeof r[k], 'number', `segmentReport.${k} 必须是数值(不得 undefined)`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
assertEq(scanned, N, `一轮 ${seg} 段累计扫描 = 全表 ${N} 条`);
|
|
211
|
+
assertEq(JSON.stringify(reports).includes('undefined'), false, '整份报告无 undefined');
|
|
212
|
+
const dirtyAfter = listDirty(store.db, { openOnly: false }).length;
|
|
213
|
+
assertEq(dirtyAfter, dirtyBefore, '坑源兜底**不写** memories_dirty(那是"记忆待提炼"台账)');
|
|
214
|
+
// 记忆侧游标不受坑源影响
|
|
215
|
+
assertEq(getMetaFor(store.db, META_KEYS.hashSweepCursor, 'pitfall') !== null, true, '坑源自己的游标已落');
|
|
216
|
+
} finally {
|
|
217
|
+
pitSource.close();
|
|
218
|
+
memSource.close();
|
|
219
|
+
store.db.close();
|
|
220
|
+
t.cleanup();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('recall --source pitfall:命中带 refId 且 id 不为 NaN(hex 一律 null)', async () => {
|
|
225
|
+
const { t, store, pitSource, memSource, embedder } = setup();
|
|
226
|
+
try {
|
|
227
|
+
await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
228
|
+
const r = await recall({
|
|
229
|
+
query: '跨机同步挡推口径',
|
|
230
|
+
store,
|
|
231
|
+
source: pitSource,
|
|
232
|
+
embedder,
|
|
233
|
+
sourceName: 'pitfall',
|
|
234
|
+
resultTopK: 5,
|
|
235
|
+
});
|
|
236
|
+
assertEq(r.triggered, true, '已触发');
|
|
237
|
+
assert(r.hits.length > 0, '有命中');
|
|
238
|
+
for (const h of r.hits) {
|
|
239
|
+
assertEq(typeof h.refId, 'string', 'refId 是权威键(字符串)');
|
|
240
|
+
assertEq(h.id, null, '非数字 ref_id ⇒ id = null(**绝不写 NaN**)');
|
|
241
|
+
assertEq(Number.isNaN(h.id), false, 'id 不是 NaN');
|
|
242
|
+
assertEq(h.source, 'pitfall', '命中带来源归属');
|
|
243
|
+
}
|
|
244
|
+
assertEq(r.stats.indexSize, N, '索引规模 = 坑条数');
|
|
245
|
+
assertEq(r.stats.pinnedExcluded, 0, '坑库无 pinned');
|
|
246
|
+
} finally {
|
|
247
|
+
pitSource.close();
|
|
248
|
+
memSource.close();
|
|
249
|
+
store.db.close();
|
|
250
|
+
t.cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('refIdToNum / rrfFuse 平局:数字照旧按数值,hex 按字典序(不出现 NaN 乱序)', () => {
|
|
255
|
+
assertEq(refIdToNum('12'), 12, '数字串 → 数值');
|
|
256
|
+
assertEq(refIdToNum('0000000000000002'), null, '带前导零的 hex(全数字)也不当数字 id(防与记忆 id 撞号)');
|
|
257
|
+
assertEq(refIdToNum('4313289685e77e19'), null, 'hex → null');
|
|
258
|
+
const fused = rrfFuse(
|
|
259
|
+
[
|
|
260
|
+
{ source: 'vector:memory', ids: ['memory:b', 'memory:a'] },
|
|
261
|
+
{ source: 'vector:pitfall', ids: ['pitfall:b', 'pitfall:a'] },
|
|
262
|
+
],
|
|
263
|
+
60,
|
|
264
|
+
);
|
|
265
|
+
// b 组两路都是 rank1(并列最高分)⇒ 平局裁决走 refId 字符串比较:memory:b < pitfall:b;随后是 a 组
|
|
266
|
+
assertEq(fused.map((x) => x.id), ['memory:b', 'pitfall:b', 'memory:a', 'pitfall:a'], '平局按 refId 字符串稳定排序');
|
|
267
|
+
assertEq(fused.every((x) => Number.isNaN(Number(x.id)) === false || Number.isNaN(Number(x.id))), true, '不因 NaN 抛错');
|
|
268
|
+
// 纯数字的两条仍按**数值**排(与旧行为一致)
|
|
269
|
+
const numeric = rrfFuse([{ source: 'v', ids: ['10', '9'] }], 60);
|
|
270
|
+
assertEq(numeric.map((x) => x.id), ['10', '9'], '数字平局仍按名次(分数已降序)');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('recallMulti:跨来源融合,同 refId 不互相吃掉(融合键 = <source>:<refId>)', async () => {
|
|
274
|
+
const { t, store, memSource, pitSource, embedder } = setup();
|
|
275
|
+
try {
|
|
276
|
+
await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
277
|
+
await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
278
|
+
// 造一个与 memory 源 id 撞名的假来源(refId = '1')
|
|
279
|
+
const vec = new Float32Array(512).fill(0.01);
|
|
280
|
+
upsertVectors(store.db, [{ source: 'fake', refId: '1', model: 'stub-ngram', dim: 512, vec, textHash: 'h', updatedAt: 1 }]);
|
|
281
|
+
|
|
282
|
+
const r = await recallMulti({
|
|
283
|
+
query: '跨机同步挡推口径',
|
|
284
|
+
store,
|
|
285
|
+
entries: [
|
|
286
|
+
{ name: 'memory', source: memSource, index: buildVectorIndex(store.db, { source: 'memory' }) },
|
|
287
|
+
{ name: 'pitfall', source: pitSource, index: buildVectorIndex(store.db, { source: 'pitfall' }) },
|
|
288
|
+
{ name: 'fake', source: null, index: buildVectorIndex(store.db, { source: 'fake' }) },
|
|
289
|
+
],
|
|
290
|
+
embedder,
|
|
291
|
+
perSourceTopK: 3,
|
|
292
|
+
resultTopK: 12,
|
|
293
|
+
});
|
|
294
|
+
assertEq(r.triggered, true, '已触发');
|
|
295
|
+
const names = new Set(r.hits.map((h) => h.source));
|
|
296
|
+
assert(names.has('pitfall'), '坑源有命中');
|
|
297
|
+
assert(names.has('memory'), '记忆源有命中');
|
|
298
|
+
const keys = r.hits.map((h) => h.key);
|
|
299
|
+
assertEq(new Set(keys).size, keys.length, '融合键唯一(撞名不互吃)');
|
|
300
|
+
assert(keys.includes('memory:1') || keys.some((k) => k.startsWith('memory:')), '记忆源键带命名空间');
|
|
301
|
+
assert(keys.includes('fake:1'), '撞名的假来源条目独立存在(fake:1 与 memory:1 不互吃)');
|
|
302
|
+
assertEq(r.perSource.pitfall.indexSize, N, '逐来源读数:坑源索引规模');
|
|
303
|
+
assertEq(r.perSource.pitfall.error, null, '坑源无错');
|
|
304
|
+
// 坑库无 FTS ⇒ 该来源 FTS 路为 0,且**不影响**其它来源
|
|
305
|
+
assertEq(r.perSource.pitfall.ftsHits, 0, '坑源 FTS 命中 0(无 FTS 表)');
|
|
306
|
+
assertEq(r.stats.sources, ['memory', 'pitfall', 'fake'], '来源清单回显');
|
|
307
|
+
} finally {
|
|
308
|
+
pitSource.close();
|
|
309
|
+
memSource.close();
|
|
310
|
+
store.db.close();
|
|
311
|
+
t.cleanup();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('meta 命名空间:迁移幂等 + 兼容读 + 冗余写(回滚旧代码不会走全量重建)', () => {
|
|
316
|
+
const t = makeTempRoot();
|
|
317
|
+
try {
|
|
318
|
+
const s1 = openVectorsStore(t.paths.vectorsDb);
|
|
319
|
+
// 造"老库"形态:只有无后缀旧键
|
|
320
|
+
setMeta(s1.db, META_KEYS.lastIncrementalSyncAt, '123456');
|
|
321
|
+
setMeta(s1.db, META_KEYS.modelId, 'bge-small-zh-v1.5');
|
|
322
|
+
assertEq(getMeta(s1.db, metaKeyFor(META_KEYS.lastIncrementalSyncAt, 'memory')), null, '迁移前:命名空间键不存在');
|
|
323
|
+
s1.db.close();
|
|
324
|
+
|
|
325
|
+
const s2 = openVectorsStore(t.paths.vectorsDb);
|
|
326
|
+
assertEq(getMeta(s2.db, metaKeyFor(META_KEYS.lastIncrementalSyncAt, 'memory')), '123456', '迁移:旧值播种到 :memory');
|
|
327
|
+
assertEq(getMeta(s2.db, META_KEYS.lastIncrementalSyncAt), '123456', '旧键**保留**(冗余写 = 回滚护栏)');
|
|
328
|
+
assertEq(getMetaFor(s2.db, META_KEYS.lastIncrementalSyncAt, 'memory'), '123456', '兼容读');
|
|
329
|
+
assertEq(getMetaFor(s2.db, META_KEYS.lastIncrementalSyncAt, 'pitfall'), null, '坑源读不到记忆的水位(不串味)');
|
|
330
|
+
assertEq(migrateMetaNamespace(s2.db), [], '再迁一次 ⇒ 空(幂等)');
|
|
331
|
+
assertEq(getMeta(s2.db, META_KEYS.schemaVersion), '2', '迁移实际发生 ⇒ schemaVersion 升 2');
|
|
332
|
+
s2.db.close();
|
|
333
|
+
} finally {
|
|
334
|
+
t.cleanup();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('selfcheck:逐 source 报告覆盖度(判据不是"向量数 vs 记忆数"一种)', async () => {
|
|
339
|
+
const { t, store, memSource, pitSource, embedder } = setup();
|
|
340
|
+
try {
|
|
341
|
+
await runIncrementalSync({ source: memSource, store, embedder, modelId: 'stub-ngram', dim: 512 });
|
|
342
|
+
await runIncrementalSync({ source: pitSource, store, embedder, modelId: 'stub-ngram', dim: 512, sourceName: 'pitfall' });
|
|
343
|
+
const r = await runSelfcheck({
|
|
344
|
+
store,
|
|
345
|
+
sources: [
|
|
346
|
+
{ name: 'memory', source: memSource },
|
|
347
|
+
{ name: 'pitfall', source: pitSource },
|
|
348
|
+
],
|
|
349
|
+
embedder,
|
|
350
|
+
config: { dim: 512, hostId: 'TEST-HOST' },
|
|
351
|
+
});
|
|
352
|
+
const mem = r.checks.find((c) => c.name === '向量覆盖度(memory)');
|
|
353
|
+
const pit = r.checks.find((c) => c.name === '向量覆盖度(pitfall)');
|
|
354
|
+
assert(mem && pit, `逐 source 两条覆盖度:${r.checks.map((c) => c.name).join(',')}`);
|
|
355
|
+
assertEq(mem.vecCount, 6, '记忆覆盖度读数');
|
|
356
|
+
assertEq(pit.vecCount, N, '坑覆盖度读数 = 坑条数');
|
|
357
|
+
assertEq(mem.memCount, 6, '记忆源条数');
|
|
358
|
+
assertEq(pit.memCount, N, '坑源条数(对照物 = 坑库条数)');
|
|
359
|
+
} finally {
|
|
360
|
+
pitSource.close();
|
|
361
|
+
memSource.close();
|
|
362
|
+
store.db.close();
|
|
363
|
+
t.cleanup();
|
|
364
|
+
}
|
|
365
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// 10 · 坑库向量同步钩子(标记 → 消费):原子写 / stale 判定 / 判据 7 并发正确性
|
|
2
|
+
//
|
|
3
|
+
// 判据 7(方案 §A.7,2026-10-08 回修 C5 新增):并发「置标记 + 清标记」100 轮后
|
|
4
|
+
// ① 标记文件**不出现半截 JSON**(每轮读都 JSON.parse 成功)
|
|
5
|
+
// ② **无标记丢失**(更新的标记不会被旧消费方清掉)
|
|
6
|
+
// ③ 每次被清掉的标记,其 `updatedAt` **一律早于**清标记方的消费起点
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { test, assert, assertEq, assertThrows } from './harness.mjs';
|
|
12
|
+
import { makeTempRoot, samplePitfalls, makePitfallJson, makeFlakyEmbedder, makeMemoryDb, sampleRows } from './fixtures.mjs';
|
|
13
|
+
import { openVectorsStore, countVectors } from '../lib/vectors-store.mjs';
|
|
14
|
+
import { openPitfallSource } from '../lib/pitfall-source.mjs';
|
|
15
|
+
import { openMemorySource } from '../lib/memory-source.mjs';
|
|
16
|
+
import { createStubEmbedder } from '../lib/embedder.mjs';
|
|
17
|
+
import { readMarker, markDirty, clearMarkerIfStale, runPitfallHook, atomicWriteJson } from '../lib/tick-hook.mjs';
|
|
18
|
+
|
|
19
|
+
export const __section = '10 · 坑库向量钩子(标记/消费 · 并发正确性)';
|
|
20
|
+
|
|
21
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
function worker(mode, markerPath, ms) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const child = spawn(process.execPath, [path.join(HERE, '_tickhook-worker.mjs'), mode, markerPath, String(ms)], {
|
|
26
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
27
|
+
});
|
|
28
|
+
let out = '';
|
|
29
|
+
child.stdout.on('data', (d) => {
|
|
30
|
+
out += d;
|
|
31
|
+
});
|
|
32
|
+
child.on('close', () => {
|
|
33
|
+
try {
|
|
34
|
+
resolve(JSON.parse(out.trim().split('\n').pop()));
|
|
35
|
+
} catch (e) {
|
|
36
|
+
resolve({ error: String(e && e.message), raw: out.slice(-400) });
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
test('置标记 = 原子写 + 必带 updatedAt;读标记永不抛错', () => {
|
|
43
|
+
const t = makeTempRoot();
|
|
44
|
+
const p = t.paths.pitfallDirtyFile;
|
|
45
|
+
try {
|
|
46
|
+
assertEq(readMarker(p), { present: false, marker: null, error: null }, '文件不在 ⇒ present:false(不算错)');
|
|
47
|
+
const at = 1_760_000_000_123;
|
|
48
|
+
markDirty(p, { at, reason: '单测' });
|
|
49
|
+
const r = readMarker(p);
|
|
50
|
+
assertEq(r.present, true, '置完可读');
|
|
51
|
+
assertEq(r.marker.updatedAt, at, '必须写 updatedAt(stale 判定的唯一依据)');
|
|
52
|
+
assertEq(r.marker.schemaVersion, 1, 'schemaVersion');
|
|
53
|
+
assertEq(r.marker.reason, '单测', 'reason 落盘');
|
|
54
|
+
// 半截 JSON 给 error 而不是抛
|
|
55
|
+
fs.writeFileSync(p, '{"schemaVersion":1,"updat', 'utf8');
|
|
56
|
+
const bad = readMarker(p);
|
|
57
|
+
assertEq(bad.present, false, '半截 JSON ⇒ present:false');
|
|
58
|
+
assert(bad.error && /JSON/.test(bad.error), '带 error 说明(调用方据此 fail-closed)');
|
|
59
|
+
t.cleanup();
|
|
60
|
+
} finally {
|
|
61
|
+
t.cleanup();
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('clearMarkerIfStale:stale 清、fresh 留、缺失 no-marker', () => {
|
|
66
|
+
const t = makeTempRoot();
|
|
67
|
+
const p = t.paths.pitfallDirtyFile;
|
|
68
|
+
try {
|
|
69
|
+
assertEq(clearMarkerIfStale(p, 1000).reason, 'no-marker', '无标记');
|
|
70
|
+
markDirty(p, { at: 500 });
|
|
71
|
+
const keep = clearMarkerIfStale(p, 500);
|
|
72
|
+
assertEq(keep.cleared, false, 'updatedAt == 消费起点 ⇒ 保留(不是这次要消费的)');
|
|
73
|
+
assertEq(keep.reason, 'fresh-marker-kept', '保留原因');
|
|
74
|
+
assertEq(fs.existsSync(p), true, '文件仍在');
|
|
75
|
+
const ok = clearMarkerIfStale(p, 501);
|
|
76
|
+
assertEq(ok.cleared, true, 'updatedAt < 消费起点 ⇒ 清掉');
|
|
77
|
+
assertEq(ok.consumedUpdatedAt, 500, '记录被消费的 updatedAt');
|
|
78
|
+
assertEq(fs.existsSync(p), false, '清掉后文件不存在(正常态)');
|
|
79
|
+
} finally {
|
|
80
|
+
t.cleanup();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('runPitfallHook:无标记跳过;有标记跑坑源增量并清标记;失败保留标记', async () => {
|
|
85
|
+
const t = makeTempRoot();
|
|
86
|
+
try {
|
|
87
|
+
makePitfallJson(t.paths.pitfallsFile, samplePitfalls(8));
|
|
88
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
89
|
+
const source = openPitfallSource(t.paths.pitfallsFile);
|
|
90
|
+
const embedder = createStubEmbedder({ dim: 512 });
|
|
91
|
+
const base = { markerPath: t.paths.pitfallDirtyFile, store, source, embedder, modelId: 'stub-ngram', dim: 512 };
|
|
92
|
+
|
|
93
|
+
const r0 = await runPitfallHook(base);
|
|
94
|
+
assertEq(r0.skipped, 'no-marker', '没标记 ⇒ 不空转');
|
|
95
|
+
assertEq(countVectors(store.db, 'pitfall'), 0, '没写任何向量');
|
|
96
|
+
|
|
97
|
+
markDirty(t.paths.pitfallDirtyFile, { at: Date.now() - 10_000, reason: 'pitfall job' });
|
|
98
|
+
const r1 = await runPitfallHook(base);
|
|
99
|
+
assertEq(r1.ok, true, '跑通');
|
|
100
|
+
assertEq(r1.stats.encoded, 8, '编码 8 条坑');
|
|
101
|
+
assertEq(countVectors(store.db, 'pitfall'), 8, '落库 8 条');
|
|
102
|
+
assertEq(r1.cleared.cleared, true, '成功后清标记');
|
|
103
|
+
assertEq(fs.existsSync(t.paths.pitfallDirtyFile), false, '标记已摘除');
|
|
104
|
+
|
|
105
|
+
// dry-run:不清标记、不写向量
|
|
106
|
+
markDirty(t.paths.pitfallDirtyFile, { at: Date.now() - 10_000 });
|
|
107
|
+
const r2 = await runPitfallHook({ ...base, dryRun: true });
|
|
108
|
+
assertEq(r2.cleared.reason, 'dry-run', 'dry-run 不清标记');
|
|
109
|
+
assertEq(fs.existsSync(t.paths.pitfallDirtyFile), true, '标记仍在');
|
|
110
|
+
|
|
111
|
+
// 同步失败 ⇒ 保留标记(下轮重试)
|
|
112
|
+
const failEmbedder = makeFlakyEmbedder(embedder, { failOnBatchCall: 1 });
|
|
113
|
+
const r3 = await runPitfallHook({ ...base, embedder: failEmbedder, full: true });
|
|
114
|
+
assertEq(r3.ok, false, '编码崩溃 ⇒ 本轮不 ok');
|
|
115
|
+
assertEq(r3.cleared.reason, 'sync-failed-keep-marker', '失败**保留**标记');
|
|
116
|
+
assertEq(fs.existsSync(t.paths.pitfallDirtyFile), true, '标记仍在(下轮重试)');
|
|
117
|
+
source.close();
|
|
118
|
+
store.db.close();
|
|
119
|
+
} finally {
|
|
120
|
+
t.cleanup();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('判据7(顺序 100 轮):无半截 JSON / 无标记丢失 / 清除项一律早于消费起点', () => {
|
|
125
|
+
const t = makeTempRoot();
|
|
126
|
+
const p = t.paths.pitfallDirtyFile;
|
|
127
|
+
try {
|
|
128
|
+
const violations = { parse: 0, lost: 0, freshCleared: 0 };
|
|
129
|
+
let base = 1_760_000_000_000;
|
|
130
|
+
for (let i = 0; i < 100; i += 1) {
|
|
131
|
+
// 交替两种顺序,覆盖"先置后清"与"先清后置"
|
|
132
|
+
const produceFirst = i % 2 === 0;
|
|
133
|
+
const at = base + i * 7;
|
|
134
|
+
const consumeStart = base + i * 7 + 3; // 每轮窗口错开
|
|
135
|
+
if (produceFirst) {
|
|
136
|
+
markDirty(p, { at });
|
|
137
|
+
// ① 每轮读都必须是合法 JSON
|
|
138
|
+
try {
|
|
139
|
+
JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
140
|
+
} catch {
|
|
141
|
+
violations.parse += 1;
|
|
142
|
+
}
|
|
143
|
+
const r = clearMarkerIfStale(p, consumeStart);
|
|
144
|
+
if (r.cleared && !(Number(r.consumedUpdatedAt) < consumeStart)) violations.freshCleared += 1;
|
|
145
|
+
if (r.cleared && Number(r.consumedUpdatedAt) !== at) violations.lost += 1;
|
|
146
|
+
} else {
|
|
147
|
+
const r = clearMarkerIfStale(p, consumeStart);
|
|
148
|
+
if (r.cleared && !(Number(r.consumedUpdatedAt) < consumeStart)) violations.freshCleared += 1;
|
|
149
|
+
// 清完之后再置 ⇒ 标记必须存在(不能被上一步吞掉)
|
|
150
|
+
markDirty(p, { at });
|
|
151
|
+
const after = readMarker(p);
|
|
152
|
+
if (!after.present || after.marker.updatedAt !== at) violations.lost += 1;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
assertEq(violations, { parse: 0, lost: 0, freshCleared: 0 }, `100 轮不变量(实际 ${JSON.stringify(violations)})`);
|
|
156
|
+
assertEq(fs.existsSync(p), true, '最后一轮是"先清后置" ⇒ 标记留在位(无丢失)');
|
|
157
|
+
} finally {
|
|
158
|
+
t.cleanup();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('判据7(真并发 2 进程):置标记 × 清标记同时跑,父进程持续读,全程无半截 JSON', async () => {
|
|
163
|
+
const t = makeTempRoot();
|
|
164
|
+
const p = t.paths.pitfallDirtyFile;
|
|
165
|
+
try {
|
|
166
|
+
// 父进程:高频探测"每次都能 parse"
|
|
167
|
+
let reads = 0;
|
|
168
|
+
let parseErrors = 0;
|
|
169
|
+
let stop = false;
|
|
170
|
+
const reader = (async () => {
|
|
171
|
+
while (!stop) {
|
|
172
|
+
reads += 1;
|
|
173
|
+
try {
|
|
174
|
+
JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
175
|
+
} catch (e) {
|
|
176
|
+
if (e && e.code !== 'ENOENT') parseErrors += 1;
|
|
177
|
+
}
|
|
178
|
+
await new Promise((r) => setTimeout(r, 1));
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
|
|
182
|
+
const [mk, cn] = await Promise.all([worker('mark', p, 1500), worker('consume', p, 1500)]);
|
|
183
|
+
stop = true;
|
|
184
|
+
await reader;
|
|
185
|
+
|
|
186
|
+
assert(!mk.error && !cn.error, `子进程正常退出:${JSON.stringify({ mk: mk.error, cn: cn.error })}`);
|
|
187
|
+
assertEq(mk.errors, [], `置标记方零错误(原生 rename 竞态已由有限重试兜住):${JSON.stringify(mk.errors.slice(0, 3))}`);
|
|
188
|
+
assertEq(cn.errors, [], `清标记方零错误:${JSON.stringify(cn.errors.slice(0, 3))}`);
|
|
189
|
+
assert(mk.n > 5 && cn.n > 5, `两进程都真跑了(mark ${mk.n} 次 / consume ${cn.n} 次)`);
|
|
190
|
+
assertEq(cn.violations, 0, '不变量③:被清掉的标记 updatedAt 一律早于清标记方消费起点(子进程内计数)');
|
|
191
|
+
assertEq(parseErrors, 0, `不变量①:父进程 ${reads} 次读无一例半截 JSON`);
|
|
192
|
+
// 不变量②:每次"清除"消费的标记都必须落在置标记方的真实写入窗口内(无幽灵标记、无丢标记)
|
|
193
|
+
const cleared = cn.events.filter((e) => e.cleared);
|
|
194
|
+
for (const e of cleared) {
|
|
195
|
+
assertEq(e.consumedUpdatedAt < e.consumeStart, true, '③ 逐条:consumedUpdatedAt < consumeStart');
|
|
196
|
+
assert(
|
|
197
|
+
e.consumedUpdatedAt >= mk.minAt - 5 && e.consumedUpdatedAt <= mk.maxAt + 5,
|
|
198
|
+
`② 被清的标记必须来自置标记方的真实写入(${e.consumedUpdatedAt} 不在 [${mk.minAt}, ${mk.maxAt}] = 幽灵标记)`,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
console.log(
|
|
202
|
+
` ↳ 并发读数:mark ${mk.n} 次(${mk.minAt}~${mk.maxAt})/ consume ${cn.n} 次(清掉 ${cleared.length} 次)/ 父进程读 ${reads} 次`,
|
|
203
|
+
);
|
|
204
|
+
} finally {
|
|
205
|
+
t.cleanup();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('守卫:把非坑源传给 runPitfallHook ⇒ 拒跑(防把别的来源错标成 pitfall 向量)', async () => {
|
|
210
|
+
const t = makeTempRoot();
|
|
211
|
+
try {
|
|
212
|
+
makeMemoryDb(t.paths.memoryDb, sampleRows(3));
|
|
213
|
+
makePitfallJson(t.paths.pitfallsFile, samplePitfalls(3));
|
|
214
|
+
const store = openVectorsStore(t.paths.vectorsDb);
|
|
215
|
+
const mem = openMemorySource(t.paths.memoryDb);
|
|
216
|
+
const pit = openPitfallSource(t.paths.pitfallsFile);
|
|
217
|
+
const e = await assertThrows(
|
|
218
|
+
() =>
|
|
219
|
+
runPitfallHook({
|
|
220
|
+
markerPath: t.paths.pitfallDirtyFile,
|
|
221
|
+
store,
|
|
222
|
+
source: mem,
|
|
223
|
+
embedder: createStubEmbedder({ dim: 512 }),
|
|
224
|
+
modelId: 'stub-ngram',
|
|
225
|
+
dim: 512,
|
|
226
|
+
}),
|
|
227
|
+
'记忆源当坑源时必须拒跑',
|
|
228
|
+
);
|
|
229
|
+
assert(/只接受坑源/.test(e.message), `报错要写清原因:${e.message}`);
|
|
230
|
+
assertEq(countVectors(store.db, 'pitfall'), 0, '拒跑 ⇒ 一条都没写');
|
|
231
|
+
mem.close();
|
|
232
|
+
pit.close();
|
|
233
|
+
store.db.close();
|
|
234
|
+
} finally {
|
|
235
|
+
t.cleanup();
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('atomicWriteJson:同目录临时文件 + rename(读者看不到半截)', () => {
|
|
240
|
+
const t = makeTempRoot();
|
|
241
|
+
const p = path.join(t.root, 'x', 'm.json');
|
|
242
|
+
try {
|
|
243
|
+
atomicWriteJson(p, { a: 1 });
|
|
244
|
+
assertEq(JSON.parse(fs.readFileSync(p, 'utf8')).a, 1, '写入可读');
|
|
245
|
+
atomicWriteJson(p, { a: 2 });
|
|
246
|
+
assertEq(JSON.parse(fs.readFileSync(p, 'utf8')).a, 2, '覆盖写');
|
|
247
|
+
const leftovers = fs.readdirSync(path.dirname(p)).filter((f) => f.includes('.tmp-'));
|
|
248
|
+
assertEq(leftovers.length, 0, '不留临时文件');
|
|
249
|
+
} finally {
|
|
250
|
+
t.cleanup();
|
|
251
|
+
}
|
|
252
|
+
});
|