@kira905/dsh-memory-toolkit 0.1.2 → 0.2.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +130 -1
  2. package/README.en.md +225 -2
  3. package/README.md +208 -2
  4. package/cordis.patch.yml +32 -0
  5. package/lib/deploy-guard.mjs +463 -275
  6. package/lib/guard-http.mjs +59 -62
  7. package/lib/pitfall-form-map.mjs +94 -84
  8. package/lib/pitfall-score.mjs +262 -185
  9. package/lib/session-file-name.mjs +116 -100
  10. package/lib/zstd-frames.mjs +88 -67
  11. package/memory-db-tools/README.md +29 -1
  12. package/memory-db-tools/audit-analyze.mjs +1 -1
  13. package/memory-db-tools/audit-export.mjs +60 -16
  14. package/memory-db-tools/audit-struct-probe.mjs +29 -8
  15. package/memory-db-tools/lib/known-devices.mjs +137 -0
  16. package/memory-engine/README.md +58 -4
  17. package/memory-engine/bench/bench.mjs +1 -1
  18. package/memory-engine/cli/band-sentinel.mjs +164 -0
  19. package/memory-engine/cli/distill-rollback.mjs +112 -44
  20. package/memory-engine/cli/distill-sessions.mjs +237 -0
  21. package/memory-engine/cli/distill.mjs +196 -95
  22. package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
  23. package/memory-engine/cli/doc-index.mjs +47 -38
  24. package/memory-engine/cli/domain-tags.mjs +246 -162
  25. package/memory-engine/cli/layer-migrate.mjs +176 -0
  26. package/memory-engine/cli/layer-move.mjs +189 -0
  27. package/memory-engine/cli/pitfall-hook.mjs +136 -70
  28. package/memory-engine/cli/recall.mjs +243 -110
  29. package/memory-engine/cli/selfcheck.mjs +93 -44
  30. package/memory-engine/cli/serve-start.mjs +221 -140
  31. package/memory-engine/cli/sweep.mjs +83 -44
  32. package/memory-engine/cli/sync.mjs +93 -57
  33. package/memory-engine/config/audit-devices.json +6 -0
  34. package/memory-engine/config/bands.json +7 -0
  35. package/memory-engine/config/layers.json +7 -0
  36. package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
  37. package/memory-engine/dict/lexicon-config.json +11 -0
  38. package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
  39. package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
  40. package/memory-engine/lib/band-sentinel.mjs +417 -0
  41. package/memory-engine/lib/cli-strict.mjs +128 -0
  42. package/memory-engine/lib/cli-util.mjs +147 -125
  43. package/memory-engine/lib/config.mjs +83 -96
  44. package/memory-engine/lib/dirty-ledger.mjs +127 -94
  45. package/memory-engine/lib/distill-rollback.mjs +173 -133
  46. package/memory-engine/lib/distill.mjs +878 -585
  47. package/memory-engine/lib/doc-index-store.mjs +208 -235
  48. package/memory-engine/lib/doc-index.mjs +351 -350
  49. package/memory-engine/lib/doc-scan.mjs +178 -156
  50. package/memory-engine/lib/doc-summarize.mjs +220 -205
  51. package/memory-engine/lib/domain-tags.mjs +358 -277
  52. package/memory-engine/lib/embedder.mjs +175 -194
  53. package/memory-engine/lib/hashes.mjs +22 -7
  54. package/memory-engine/lib/layer-migrate.mjs +330 -0
  55. package/memory-engine/lib/layer-move.mjs +307 -0
  56. package/memory-engine/lib/layers.mjs +191 -0
  57. package/memory-engine/lib/lexicon-recall.mjs +473 -0
  58. package/memory-engine/lib/memory-source.mjs +78 -80
  59. package/memory-engine/lib/pitfall-source.mjs +115 -114
  60. package/memory-engine/lib/policy-source.mjs +279 -261
  61. package/memory-engine/lib/recall-log.mjs +285 -0
  62. package/memory-engine/lib/recall.mjs +565 -240
  63. package/memory-engine/lib/selfcheck.mjs +142 -106
  64. package/memory-engine/lib/session-distill.mjs +603 -0
  65. package/memory-engine/lib/session-source.mjs +421 -0
  66. package/memory-engine/lib/sweep.mjs +107 -130
  67. package/memory-engine/lib/sync.mjs +110 -140
  68. package/memory-engine/lib/tick-hook.mjs +123 -148
  69. package/memory-engine/lib/vectors-store.mjs +213 -188
  70. package/memory-engine/test/12-layering.test.mjs +539 -0
  71. package/memory-engine/test/13-session-distill.test.mjs +616 -0
  72. package/memory-engine/tools/bridge-smoke.mjs +94 -55
  73. package/memory-engine/tools/fetch-model.mjs +167 -87
  74. package/memory-engine/tools/smoke-recall.mjs +94 -38
  75. package/memory-engine/tools/verify-recall-chain.mjs +413 -0
  76. package/package.json +11 -1
@@ -0,0 +1,413 @@
1
+ // 召回链验收夹具(本版新增能力的一次性现跑证据,不参与单测)。
2
+ //
3
+ // node tools/verify-recall-chain.mjs # 只跑本版十项判据
4
+ // node tools/verify-recall-chain.mjs --baseline <旧版 lib> # 追加「开关关掉后与旧版逐字一致」对拍
5
+ // node tools/verify-recall-chain.mjs --keep # 保留夹具目录(排查用)
6
+ //
7
+ // 全部夹具落系统临时目录;对生产库、生产向量库**零读写**。
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { fileURLToPath, pathToFileURL } from 'node:url';
13
+
14
+ import { makeTempRoot, makeMemoryDb, sampleRows, setPinned } from '../test/fixtures.mjs';
15
+ import { openVectorsStore } from '../lib/vectors-store.mjs';
16
+ import { openMemorySource } from '../lib/memory-source.mjs';
17
+ import { runIncrementalSync } from '../lib/sync.mjs';
18
+ import { createStubEmbedder } from '../lib/embedder.mjs';
19
+ import {
20
+ recall,
21
+ recallMulti,
22
+ buildVectorIndex,
23
+ truncateQuery,
24
+ QUERY_CHAR_LIMIT,
25
+ FALLBACK_TOP_K,
26
+ } from '../lib/recall.mjs';
27
+ import {
28
+ buildLexiconIndex,
29
+ rankByLexicon,
30
+ loadWordTable,
31
+ resetLexiconIndex,
32
+ resolveLexiconSwitch,
33
+ lexiconAssets,
34
+ } from '../lib/lexicon-recall.mjs';
35
+ import { createRecallLog, openRecallLog, recordRecall } from '../lib/recall-log.mjs';
36
+
37
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
38
+ const ENGINE = path.join(HERE, '..');
39
+ const CLI = path.join(ENGINE, 'cli', 'recall.mjs');
40
+
41
+ const argv = process.argv.slice(2);
42
+ const argOf = (name) => {
43
+ const at = argv.indexOf(name);
44
+ return at >= 0 && argv[at + 1] && !argv[at + 1].startsWith('--') ? argv[at + 1] : null;
45
+ };
46
+ const BASELINE = argOf('--baseline');
47
+ const KEEP = argv.includes('--keep');
48
+
49
+ const results = [];
50
+ let failed = 0;
51
+
52
+ async function check(title, body) {
53
+ const started = Date.now();
54
+ try {
55
+ const detail = await body();
56
+ results.push({ ok: true, title, detail, ms: Date.now() - started });
57
+ console.log(`✅ ${title}${detail ? `\n ↳ ${detail}` : ''}`);
58
+ } catch (err) {
59
+ failed += 1;
60
+ results.push({ ok: false, title, detail: String(err && err.message ? err.message : err), ms: Date.now() - started });
61
+ console.log(`❌ ${title}\n ↳ ${String(err && err.stack ? err.stack : err).split('\n').slice(0, 3).join('\n ')}`);
62
+ }
63
+ }
64
+
65
+ const ensure = (condition, message) => {
66
+ if (!condition) throw new Error(message);
67
+ };
68
+ const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
69
+ const idsOf = (hits) => hits.map((hit) => String(hit.refId));
70
+
71
+ // 编码器三态:正常 / 必超时 / 必抛错 / 空向量
72
+ const hangingEmbedder = { kind: 'hanging', encode: () => new Promise(() => {}) };
73
+ const brokenEmbedder = {
74
+ kind: 'broken',
75
+ encode: async () => {
76
+ const err = new Error('bridge down');
77
+ err.name = 'BridgeError';
78
+ throw err;
79
+ },
80
+ };
81
+ const emptyEmbedder = { kind: 'empty', encode: async () => new Float32Array(0) };
82
+
83
+ const embedder = createStubEmbedder({ dim: 512 });
84
+ const temp = makeTempRoot('dsh-k4-recall-');
85
+ const dirs = { root: temp.root, log: path.join(temp.root, 'recall-log'), dict: path.join(temp.root, 'empty-dict') };
86
+ fs.mkdirSync(dirs.dict, { recursive: true });
87
+
88
+ makeMemoryDb(temp.paths.memoryDb, sampleRows(10));
89
+ const store = openVectorsStore(temp.paths.vectorsDb);
90
+ let source = openMemorySource(temp.paths.memoryDb);
91
+ await runIncrementalSync({ source, store, embedder, modelId: 'stub-ngram', dim: 512 });
92
+
93
+ const index = buildVectorIndex(store.db, { source: 'memory' });
94
+ const ALL = 10;
95
+ const QUERY = '跨机同步的挡推裁决口径 task-board 3080';
96
+ const SHORT = '挡推裁决口径';
97
+
98
+ const close = () => {
99
+ try { source.close(); } catch { /* ignore */ }
100
+ try { store.db.close(); } catch { /* ignore */ }
101
+ };
102
+
103
+ console.log(`召回链验收夹具 · 引擎目录 = ${ENGINE}`);
104
+ console.log(`夹具根 = ${temp.root}${BASELINE ? ` · 基线(旧版 lib)= ${BASELINE}` : ''}\n`);
105
+
106
+ try {
107
+ await check('① 查询截断:300 字 → 256 字(码点计),短查询不截断', () => {
108
+ const long = truncateQuery(`召回 ${'长'.repeat(300)}`);
109
+ ensure(long.truncated && long.text.length === QUERY_CHAR_LIMIT, `截断后长度异常:${long.text.length}`);
110
+ ensure(long.chars === 303, `原长应记为 303,实际 ${long.chars}`);
111
+ const emoji = truncateQuery('🐟'.repeat(400));
112
+ ensure(emoji.text.length === QUERY_CHAR_LIMIT * 2, `emoji 按码点截断应得 ${QUERY_CHAR_LIMIT} 个字符,实际 UTF-16 长度 ${emoji.text.length}`);
113
+ ensure(truncateQuery('短查询').truncated === false, '短查询不该被标截断');
114
+ return `长查询 chars=303 → used=${QUERY_CHAR_LIMIT};emoji 400 → ${emoji.text.length / 2} 个码点、无半个代理对`;
115
+ });
116
+
117
+ await check('①-b 截断贯通到召回:超长 query 的结果里 queryTruncated=true', async () => {
118
+ const longQuery = `${QUERY} ${'补充说明'.repeat(60)}`;
119
+ const out = await recall({ query: longQuery, store, source, embedder, index, resultTopK: 3, lexiconEnabled: false });
120
+ ensure(out.stats.queryTruncated === true, 'stats.queryTruncated 应为 true');
121
+ ensure(out.stats.queryChars > out.stats.queryCharsUsed, '原长应大于实际用到的长度');
122
+ ensure(out.hits.length > 0, '截断后仍应有命中');
123
+ return `queryChars=${out.stats.queryChars} / used=${out.stats.queryCharsUsed} / 命中 ${out.hits.length} 条`;
124
+ });
125
+
126
+ await check(`①-c 必修时编码器 → 固定 Top-${FALLBACK_TOP_K}(不返回空、标降级)`, async () => {
127
+ const lines = [];
128
+ const out = await recall({
129
+ query: QUERY, store, source, embedder: hangingEmbedder, index,
130
+ encodeTimeoutMs: 30, fallbackTopK: FALLBACK_TOP_K, resultTopK: FALLBACK_TOP_K,
131
+ log: (line) => lines.push(String(line)),
132
+ });
133
+ ensure(out.triggered === true, '仍应判定触发');
134
+ ensure(out.hits.length === FALLBACK_TOP_K, `应得 ${FALLBACK_TOP_K} 条,实际 ${out.hits.length}`);
135
+ ensure(out.degraded && out.degraded.mode === 'fixed-topk', 'degraded.mode 应为 fixed-topk');
136
+ ensure(out.encodeError && out.encodeError.name === 'TimeoutError', `encodeError.name 应为 TimeoutError,实际 ${out.encodeError && out.encodeError.name}`);
137
+ ensure(out.hits.every((hit) => hit.sources.join('+') === 'fallback'), '兜底命中来源应全为 fallback');
138
+ ensure(out.hits[0].fallbackRank === 1 && out.hits.at(-1).fallbackRank === FALLBACK_TOP_K, 'fallbackRank 应从 1 起连续');
139
+ const expected = [10, 9, 8, 7, 6, 5, 4, 3];
140
+ ensure(same(idsOf(out.hits), expected.map(String)), `兜底应按最近改动降序,期望 ${expected.join(',')},实际 ${idsOf(out.hits).join(',')}`);
141
+ ensure(lines.some((line) => line.includes('编码降级')), '日志应留一行「编码降级」痕迹');
142
+ return `hits=${idsOf(out.hits).join(',')} · degraded=${out.degraded.mode}/${out.degraded.reason} · 日志:「${lines.find((l) => l.includes('编码降级'))}」`;
143
+ });
144
+
145
+ await check('①-d 抛错 / 空向量同样降级(错误名分别如实记录)', async () => {
146
+ const broken = await recall({ query: QUERY, store, source, embedder: brokenEmbedder, index, resultTopK: 8, fallbackTopK: 8 });
147
+ ensure(broken.hits.length === 8, `抛错时应给 8 条,实际 ${broken.hits.length}`);
148
+ ensure(broken.encodeError.name === 'BridgeError', `错误名应如实,实际 ${broken.encodeError.name}`);
149
+ const empty = await recall({ query: QUERY, store, source, embedder: emptyEmbedder, index, resultTopK: 8, fallbackTopK: 8 });
150
+ ensure(empty.hits.length === 8 && empty.encodeError.name === 'EmptyVectorError', `空向量应降级为 EmptyVectorError,实际 ${empty.encodeError && empty.encodeError.name}`);
151
+ return `BridgeError → ${broken.hits.length} 条 · EmptyVectorError → ${empty.hits.length} 条`;
152
+ });
153
+
154
+ await check('①-e 兜底仍排除 pinned(pin 掉最新那条,兜底第一条换成 9)', async () => {
155
+ source.close();
156
+ setPinned(temp.paths.memoryDb, 10, 1);
157
+ source = openMemorySource(temp.paths.memoryDb);
158
+ const out = await recall({ query: QUERY, store, source, embedder: hangingEmbedder, index, encodeTimeoutMs: 20, fallbackTopK: 8, resultTopK: 8 });
159
+ ensure(!idsOf(out.hits).includes('10'), 'pinned 条不该出现在兜底里');
160
+ ensure(idsOf(out.hits)[0] === '9', `兜底第一条应为 9,实际 ${idsOf(out.hits)[0]}`);
161
+ ensure(out.stats.pinnedExcluded === 1, `应记录排除 1 条 pinned,实际 ${out.stats.pinnedExcluded}`);
162
+ setPinned(temp.paths.memoryDb, 10, 0);
163
+ source.close();
164
+ source = openMemorySource(temp.paths.memoryDb);
165
+ return `兜底前八 = ${idsOf(out.hits).join(',')}(已剔 pinned #10)`;
166
+ });
167
+
168
+ await check('②-a 词表路:BM25 给出候选,且排序与向量路不同', async () => {
169
+ const assets = lexiconAssets();
170
+ ensure(assets.bytes.words > 0 && assets.bytes.freq > 0, '词典/词频素材应存在');
171
+ const lexIndex = buildLexiconIndex(source);
172
+ ensure(lexIndex && lexIndex.docCount === ALL, `词表索引应覆盖 ${ALL} 行,实际 ${lexIndex && lexIndex.docCount}`);
173
+ const ranked = rankByLexicon(SHORT, lexIndex, { topK: 20, floor: 0.3 });
174
+ ensure(ranked.length > 0, '词表路应有候选(查询词在库里存在)');
175
+ const { cosineTopK } = await import('../lib/recall.mjs');
176
+ const vectorRanked = cosineTopK(index, await embedder.encode(SHORT), 20);
177
+ ensure(!same(ranked.map((hit) => String(hit.id)), vectorRanked.map((hit) => String(hit.refId))), '两路排序不应完全相同');
178
+ return `词表去 ${ranked.length} 条(${ranked.slice(0, 4).map((h) => h.id).join(',')}…)· 向量 top4(${vectorRanked.slice(0, 4).map((h) => h.refId).join(',')}…)· 排序有差异`;
179
+ });
180
+
181
+ await check('②-b 开关开 → 融合结果里出现 lexicon 来源(真起作用)', async () => {
182
+ const lexIndex = buildLexiconIndex(source);
183
+ const on = await recall({
184
+ query: SHORT, store, source, embedder, index, resultTopK: 5,
185
+ lexiconEnabled: true, lexiconIndex: lexIndex,
186
+ });
187
+ ensure(on.stats.lexiconEnabled === true, 'stats.lexiconEnabled 应为 true');
188
+ ensure(on.stats.lexiconStatus === 'ok', `词表状态应为 ok,实际 ${on.stats.lexiconStatus}`);
189
+ ensure(on.stats.lexiconHits > 0, '词表路应有命中');
190
+ ensure(on.hits.some((hit) => hit.sources.includes('lexicon')), '融合命中里应出现 lexicon 来源');
191
+ return `词表候选 ${on.stats.lexiconHits} 条 · 命中里带 lexicon 的 ${on.hits.filter((h) => h.sources.includes('lexicon')).length} 条 · id=${idsOf(on.hits).join(',')}`;
192
+ });
193
+
194
+ await check('②-c 开关关 → 与「默认(出厂关)」逐字一致', async () => {
195
+ const explicitOff = await recall({ query: QUERY, store, source, embedder, index, resultTopK: 8, lexiconEnabled: false });
196
+ const byDefault = await recall({ query: QUERY, store, source, embedder, index, resultTopK: 8 });
197
+ ensure(byDefault.stats.lexiconEnabled === false, '出厂默认应为关');
198
+ ensure(same(byDefault.hits, explicitOff.hits), '默认与显式关的结果应逐字一致');
199
+ return `两条结果逐字相同(${idsOf(byDefault.hits).join(',')})· 出厂开关来源=${byDefault.stats.lexiconSwitchSource}`;
200
+ });
201
+
202
+ if (BASELINE) {
203
+ await check('②-d 开关关 → 与改动前旧版逐字对拍(同一夹具、同一索引)', async () => {
204
+ const oldModule = await import(pathToFileURL(path.join(BASELINE, 'recall.mjs')).href);
205
+ const project = (out) => ({
206
+ triggered: out.triggered,
207
+ reason: out.reason,
208
+ hits: out.hits.map((hit) => ({
209
+ refId: hit.refId, id: hit.id, score: hit.score, sources: hit.sources,
210
+ vectorRank: hit.vectorRank, ftsRank: hit.ftsRank,
211
+ })),
212
+ stats: {
213
+ indexSize: out.stats.indexSize,
214
+ pinnedExcluded: out.stats.pinnedExcluded,
215
+ vectorCandidates: out.stats.vectorCandidates,
216
+ vectorHits: out.stats.vectorHits,
217
+ ftsTokens: out.stats.ftsTokens,
218
+ ftsMatchExpr: out.stats.ftsMatchExpr,
219
+ ftsMatched: out.stats.ftsMatched,
220
+ ftsHits: out.stats.ftsHits,
221
+ fused: out.stats.fused,
222
+ },
223
+ });
224
+ const queries = [QUERY, SHORT, '记忆库的 FTS5 分词实测结论', 'ledger 并回'];
225
+ const report = [];
226
+ for (const query of queries) {
227
+ const before = await oldModule.recall({ query, store, source, embedder, index, resultTopK: 8 });
228
+ const after = await recall({ query, store, source, embedder, index, resultTopK: 8, lexiconEnabled: false });
229
+ ensure(same(project(before), project(after)), `对拍不一致:${query}`);
230
+ report.push(`${query.slice(0, 8)}… ${idsOf(before.hits).length} 条一致`);
231
+ }
232
+ return `4 条查询逐字节一致 · ${report.join(' / ')}`;
233
+ });
234
+ }
235
+
236
+ await check('②-e 词典缺失 → 优雅退化(不抛错、结果与纯向量一致)', async () => {
237
+ const off = await recall({ query: SHORT, store, source, embedder, index, resultTopK: 5, lexiconEnabled: false });
238
+ const previous = process.env.MEMORY_ENGINE_DICT_DIR;
239
+ process.env.MEMORY_ENGINE_DICT_DIR = dirs.dict; // 空目录 = 没有词典
240
+ resetLexiconIndex();
241
+ try {
242
+ const table = loadWordTable({ fresh: true });
243
+ ensure(table.available === false, '空目录下应判定为「没有词表」');
244
+ ensure(buildLexiconIndex(source) === null, '没有词典时建索引应返回 null');
245
+ const on = await recall({ query: SHORT, store, source, embedder, index, resultTopK: 5, lexiconEnabled: true });
246
+ ensure(same(on.hits, off.hits), '没有词典时结果应与纯向量完全一致');
247
+ ensure(on.stats.lexiconStatus === 'warming' || on.stats.lexiconStatus === 'ok', `状态应为 warming/ok,实际 ${on.stats.lexiconStatus}`);
248
+ ensure(on.stats.lexiconHits === 0, '没有词典时词表路应零命中');
249
+ return `状态=${on.stats.lexiconStatus} · 命中 ${on.hits.length} 条,与纯向量逐字一致`;
250
+ } finally {
251
+ if (previous === undefined) delete process.env.MEMORY_ENGINE_DICT_DIR;
252
+ else process.env.MEMORY_ENGINE_DICT_DIR = previous;
253
+ loadWordTable({ fresh: true });
254
+ resetLexiconIndex();
255
+ }
256
+ });
257
+
258
+ await check('③ 留痕开 → JSONL 一行、字段齐(含降级轮)', async () => {
259
+ const logger = createRecallLog({ dir: dirs.log, maxBytes: 1024 * 1024 });
260
+ const normal = await recall({ query: QUERY, store, source, embedder, index, resultTopK: 4, recallLog: logger });
261
+ const degraded = await recall({ query: QUERY, store, source, embedder: brokenEmbedder, index, resultTopK: 4, recallLog: logger });
262
+ const lines = fs.readFileSync(logger.file, 'utf8').trim().split('\n');
263
+ ensure(lines.length === 2, `应有 2 行,实际 ${lines.length}`);
264
+ const [first, second] = lines.map((line) => JSON.parse(line));
265
+ ensure(typeof first.t === 'string' && !Number.isNaN(Date.parse(first.t)), '时刻应可解析');
266
+ ensure(first.query === QUERY && first.queryChars === QUERY.length, 'query 与长度应如实');
267
+ ensure(Array.isArray(first.hits) && first.hits.length === normal.hits.length, '命中条数应与结果一致');
268
+ ensure(typeof first.hits[0].score === 'number' && typeof first.hits[0].refId === 'string', '命中应有 id/refId/score');
269
+ ensure(first.degraded === null, '正常轮 degraded 应为 null');
270
+ ensure(second.degraded && second.degraded.mode === 'fixed-topk', '降级轮应如实标记');
271
+ ensure(second.encodeError && second.encodeError.name === 'BridgeError', '降级轮应记错误名');
272
+ ensure(same(first.hits.map((hit) => hit.refId), normal.hits.map((hit) => String(hit.refId))), '留痕顺序应与结果一致');
273
+ return `${lines.length} 行 · 正常轮 hitCount=${first.hitCount} · 降级轮 degraded=${second.degraded.mode} · 文件 ${path.basename(logger.file)}`;
274
+ });
275
+
276
+ await check('③-b 留痕关 → 一个字节都不写', () => {
277
+ const silentDir = path.join(dirs.root, 'log-off');
278
+ ensure(openRecallLog({ dir: null, env: {} }) === null, '没给目录应返回 null');
279
+ ensure(openRecallLog({ dir: silentDir, enabled: false }) === null, '开关关应返回 null');
280
+ ensure(openRecallLog({ dir: silentDir, env: { DSH_MEMORY_RECALL_LOG: 'off' } }) === null, '环境变量 off 应返回 null');
281
+ const instance = createRecallLog({ dir: silentDir, enabled: false });
282
+ ensure(instance.record({ query: 'x' }) === false, '关掉的实例 record 应返回 false');
283
+ ensure(!fs.existsSync(silentDir), '关掉时不该创建目录');
284
+ ensure(recordRecall(null, { hits: [] }) === false, 'null logger 应空操作');
285
+ return '四种关法(无目录 / enabled:false / 环境变量 off / null)均零写入,目录未创建';
286
+ });
287
+
288
+ await check('③-c 上限 → 按字节轮转,留 N 代不无限增长;超天数的旧代被清', () => {
289
+ const rotateDir = path.join(dirs.root, 'log-rotate');
290
+ const logger = createRecallLog({ dir: rotateDir, maxBytes: 900, keepFiles: 2, keepDays: 14 });
291
+ for (let n = 0; n < 80; n += 1) logger.record({ event: 'recall', n, pad: 'x'.repeat(40) });
292
+ const files = fs.readdirSync(rotateDir).sort();
293
+ const active = fs.statSync(logger.file).size;
294
+ ensure(active <= 900, `当前文件应不超过 900 B,实际 ${active}`);
295
+ ensure(files.length <= 3, `最多应有 3 份(当前 + 2 代),实际 ${files.length}:${files.join(',')}`);
296
+ ensure(files.includes('recall.1.jsonl'), '应出现第一代轮转文件');
297
+
298
+ // 天数闸:把两代改成 30 天前,再让一个 keepDays=14 的实例 prune 一次
299
+ for (const name of files) {
300
+ if (name === 'recall.jsonl') continue;
301
+ const full = path.join(rotateDir, name);
302
+ const old = new Date(Date.now() - 30 * 86400000);
303
+ fs.utimesSync(full, old, old);
304
+ }
305
+ const sweeper = createRecallLog({ dir: rotateDir, maxBytes: 900, keepFiles: 2, keepDays: 14 });
306
+ sweeper.prune();
307
+ const left = fs.readdirSync(rotateDir);
308
+ ensure(!left.some((name) => name !== 'recall.jsonl'), `30 天前的旧代应被清掉,实际剩 ${left.join(',')}`);
309
+ return `80 行写入 → 当前 ${active} B + ${files.filter((n) => n !== 'recall.jsonl').length} 代;旧代(30 天前)prune 后清空`;
310
+ });
311
+
312
+ await check('③-d keepFiles=0 → 只留当前文件(超限清空重开,不产生代)', () => {
313
+ const truncDir = path.join(dirs.root, 'log-truncate');
314
+ const logger = createRecallLog({ dir: truncDir, maxBytes: 500, keepFiles: 0, keepDays: 0 });
315
+ for (let n = 0; n < 40; n += 1) logger.record({ n, pad: 'y'.repeat(40) });
316
+ const files = fs.readdirSync(truncDir);
317
+ ensure(files.length === 1 && files[0] === 'recall.jsonl', `只该有当前文件,实际 ${files.join(',')}`);
318
+ ensure(fs.statSync(logger.file).size <= 500, '当前文件应不超上限');
319
+ return `40 行写入后仅 1 份文件(${fs.statSync(logger.file).size} B)`;
320
+ });
321
+
322
+ await check('④ 多来源:编码失败也降级(每源固定 Top-K,不抛错)', async () => {
323
+ const pitfallPath = temp.paths.pitfallsFile;
324
+ const { makePitfallJson, samplePitfalls } = await import('../test/fixtures.mjs');
325
+ const { openPitfallSource } = await import('../lib/pitfall-source.mjs');
326
+ makePitfallJson(pitfallPath, samplePitfalls(4));
327
+ const pitfall = openPitfallSource(pitfallPath);
328
+ await runIncrementalSync({ source: pitfall, store, embedder, modelId: 'stub-ngram', dim: 512 });
329
+ const out = await recallMulti({
330
+ query: QUERY, store, embedder: brokenEmbedder,
331
+ entries: [
332
+ { name: 'memory', source, index: buildVectorIndex(store.db, { source: 'memory' }) },
333
+ { name: 'pitfall', source: pitfall, index: buildVectorIndex(store.db, { source: 'pitfall' }) },
334
+ ],
335
+ perSourceTopK: 3, resultTopK: 6,
336
+ });
337
+ ensure(out.degraded && out.degraded.mode === 'fixed-topk', '多来源降级标记应存在');
338
+ ensure(out.hits.length > 0, '多来源降级不该返回空');
339
+ ensure(out.hits.every((hit) => String(hit.sources.join('+')).includes('fallback')), '命中来源应标 fallback');
340
+ pitfall.close();
341
+ return `perSource=${Object.entries(out.perSource).map(([name, rec]) => `${name}:${rec.fallbackHits}`).join(' / ')} · 命中 ${out.hits.length} 条`;
342
+ });
343
+
344
+ await check('⑤ CLI 端到端:新开关生效 + 旧参数照旧 + 编码失败仍出结果', () => {
345
+ const cliLogDir = path.join(dirs.root, 'cli-log');
346
+ const common = [
347
+ '--memory-db', temp.paths.memoryDb,
348
+ '--vectors-db', temp.paths.vectorsDb,
349
+ '--stub-embedder',
350
+ '--query', QUERY,
351
+ '--json',
352
+ ];
353
+ const run = (extra) => {
354
+ const out = spawnSync(process.execPath, [CLI, ...common, ...extra], { cwd: ENGINE, encoding: 'utf8' });
355
+ ensure(out.status === 0, `CLI 退出码应为 0,实际 ${out.status};stderr=${String(out.stderr).slice(0, 400)}`);
356
+ return JSON.parse(out.stdout);
357
+ };
358
+
359
+ const plain = run([]);
360
+ ensure(plain.gate.trigger === true && plain.hits.length > 0, '旧参数(不加任何新开关)应照旧出结果');
361
+ ensure(plain.lexicon && plain.lexicon.enabled === false, '默认应为词表关');
362
+ ensure(plain.degraded == null, '正常轮不该有降级标记');
363
+
364
+ const withLexicon = run(['--lexicon', 'on', '--recall-log', cliLogDir]);
365
+ ensure(withLexicon.lexicon.enabled === true, '--lexicon on 应生效');
366
+ ensure(withLexicon.stats.lexiconStatus === 'ok', `词表状态应为 ok,实际 ${withLexicon.stats.lexiconStatus}`);
367
+ ensure(withLexicon.recallLog && fs.existsSync(withLexicon.recallLog.file), '应写出留痕文件');
368
+ ensure(fs.readFileSync(withLexicon.recallLog.file, 'utf8').trim().split('\n').length >= 1, '留痕文件应至少 1 行');
369
+
370
+ const degraded = spawnSync(process.execPath, [
371
+ CLI, '--memory-db', temp.paths.memoryDb, '--vectors-db', temp.paths.vectorsDb,
372
+ '--http-embedder', '--bridge-port', '1', '--query', QUERY, '--json',
373
+ '--fallback-top-k', '6',
374
+ ], { cwd: ENGINE, encoding: 'utf8' });
375
+ ensure(degraded.status === 0, `编码不可用时 CLI 仍应退出 0,实际 ${degraded.status}`);
376
+ const degradedJson = JSON.parse(degraded.stdout);
377
+ ensure(degradedJson.degraded && degradedJson.degraded.mode === 'fixed-topk', 'CLI 应带降级标记');
378
+ ensure(degradedJson.hits.length === 6, `应给 6 条兜底,实际 ${degradedJson.hits.length}`);
379
+ return `默认 ${plain.hits.length} 条 / 词表开 ${withLexicon.stats.lexiconHits} 候选 / 桥不可用降级 ${degradedJson.hits.length} 条(rc 0)`;
380
+ });
381
+
382
+ if (BASELINE) {
383
+ await check('⑤-b CLI 旧新对拍:旧版与新版在默认开关下 hits 完全一致', () => {
384
+ const OLD_CLI = path.join(BASELINE, '..', 'cli', 'recall.mjs');
385
+ ensure(fs.existsSync(OLD_CLI), `旧版 CLI 不在:${OLD_CLI}`);
386
+ const common = [
387
+ '--memory-db', temp.paths.memoryDb,
388
+ '--vectors-db', temp.paths.vectorsDb,
389
+ '--stub-embedder',
390
+ '--query', SHORT,
391
+ '--json',
392
+ ];
393
+ const grab = (cli) => {
394
+ const out = spawnSync(process.execPath, [cli, ...common], { cwd: ENGINE, encoding: 'utf8' });
395
+ ensure(out.status === 0, `CLI ${cli} 退出码 ${out.status}`);
396
+ const json = JSON.parse(out.stdout);
397
+ return json.hits.map((hit) => ({ refId: hit.refId, id: hit.id, score: hit.score, sources: hit.sources, vectorRank: hit.vectorRank, ftsRank: hit.ftsRank }));
398
+ };
399
+ const before = grab(OLD_CLI);
400
+ const after = grab(CLI);
401
+ ensure(same(before, after), '旧新 CLI 的 hits 应逐字一致');
402
+ return `${before.length} 条命中逐字一致(${before.map((hit) => hit.refId).join(',')})`;
403
+ });
404
+ }
405
+ } finally {
406
+ close();
407
+ if (!KEEP) temp.cleanup();
408
+ }
409
+
410
+ const passed = results.filter((row) => row.ok).length;
411
+ console.log(`\n—— 合计 ${results.length} 项:通过 ${passed} / 失败 ${failed} ——`);
412
+ if (KEEP) console.log(`夹具目录保留在 ${temp.root}`);
413
+ process.exitCode = failed ? 1 : 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kira905/dsh-memory-toolkit",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -30,12 +30,22 @@
30
30
  "engines": {
31
31
  "node": ">=22"
32
32
  },
33
+ "exports": {
34
+ "./dsh-memory-local": "./dsh-memory-local/lib/index.js",
35
+ "./package.json": "./package.json"
36
+ },
37
+ "dsh": {
38
+ "bundle": {
39
+ "patch": "./cordis.patch.yml"
40
+ }
41
+ },
33
42
  "bin": {
34
43
  "dsh-memory-audit": "./persona-memory-audit.mjs",
35
44
  "dsh-memory-summary": "./memory-db-tools/list-memory-summary.mjs",
36
45
  "dsh-memory-export": "./memory-db-tools/audit-export.mjs"
37
46
  },
38
47
  "files": [
48
+ "cordis.patch.yml",
39
49
  "memory-engine/",
40
50
  "memory-db-tools/",
41
51
  "dsh-memory-local/",