@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,686 @@
|
|
|
1
|
+
// 记忆引擎 v2 · S3c —— 夜间提炼单测(零依赖;夹具一律在系统临时区,**不碰任何真实库/不联网**)
|
|
2
|
+
//
|
|
3
|
+
// 覆盖卡面验收的可离线部分:
|
|
4
|
+
// ① 内容级去重(跨机改 id 仍不重复)② 不自我指涉 ③ 成本闸超限即停 ④ dry-run 零写入
|
|
5
|
+
// ⑤ 标签/owner/visibility 落库形态 ⑥ memories_dirty 台账(补 A7 盲区)⑦ 回滚(内容摘要回到原状)
|
|
6
|
+
// ⑧ D2 从严(family / 非本机 owner / 缺列归属不可判定 ⇒ 0 调用)⑨ 日报行字段
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import crypto from 'node:crypto';
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
import { test, section, assert, assertEq } from './harness.mjs';
|
|
13
|
+
import * as D from '../lib/distill.mjs';
|
|
14
|
+
import * as L from '../lib/dirty-ledger.mjs';
|
|
15
|
+
import * as R from '../lib/distill-rollback.mjs';
|
|
16
|
+
import { openVectorsStore, upsertVectors } from '../lib/vectors-store.mjs';
|
|
17
|
+
|
|
18
|
+
export const __section = 'S3c · 夜间提炼(内容级去重 / 不自我指涉 / 成本闸 / 台账 / 回滚 / D2 从严)';
|
|
19
|
+
|
|
20
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 's3c-distill-'));
|
|
21
|
+
const OWNER = 'LiJianXiao';
|
|
22
|
+
const DIM = 8;
|
|
23
|
+
const NOW = Date.parse('2026-09-21T04:10:00+08:00');
|
|
24
|
+
|
|
25
|
+
/** 造 8 列临时库(含 owner_user/visibility)。 */
|
|
26
|
+
function makeDb(name, rows, { withOwner = true } = {}) {
|
|
27
|
+
const dir = path.join(TMP, name);
|
|
28
|
+
fs.mkdirSync(path.join(dir, 'memory'), { recursive: true });
|
|
29
|
+
const p = path.join(dir, 'memory', 'memory.db');
|
|
30
|
+
const db = new DatabaseSync(p);
|
|
31
|
+
db.exec(`CREATE TABLE memories (
|
|
32
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, text TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '',
|
|
33
|
+
pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
34
|
+
${withOwner ? ", owner_user TEXT NOT NULL DEFAULT 'LiJianXiao', visibility TEXT NOT NULL DEFAULT 'private_owner'" : ''}
|
|
35
|
+
)`);
|
|
36
|
+
const sql = withOwner
|
|
37
|
+
? 'INSERT INTO memories (id, text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,?,?,?,?,?,?)'
|
|
38
|
+
: 'INSERT INTO memories (id, text, tags, pinned, created_at, updated_at) VALUES (?,?,?,?,?,?)';
|
|
39
|
+
const ins = db.prepare(sql);
|
|
40
|
+
for (const r of rows) {
|
|
41
|
+
const base = [r.id ?? null, r.text, r.tags ?? '', r.pinned ?? 0, r.created_at ?? r.updated_at ?? NOW, r.updated_at ?? NOW];
|
|
42
|
+
if (withOwner) ins.run(...base, r.owner ?? OWNER, r.visibility ?? 'private_owner');
|
|
43
|
+
else ins.run(...base);
|
|
44
|
+
}
|
|
45
|
+
db.close();
|
|
46
|
+
return { dir, dbPath: p, home: dir, vectorsDb: path.join(dir, 'memory-vectors', 'vectors.db'), stateFile: path.join(dir, 'butler-distill-state.json') };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 写向量(unit 向量便于控制余弦相似度)。 */
|
|
50
|
+
function writeVectors(vectorsDb, entries) {
|
|
51
|
+
const store = openVectorsStore(vectorsDb, { create: true });
|
|
52
|
+
const items = entries.map(([refId, vec, text]) => ({
|
|
53
|
+
source: 'memory', refId, model: 'bge-small-zh-v1.5', dim: vec.length,
|
|
54
|
+
vec: Float32Array.from(vec), textHash: crypto.createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex'),
|
|
55
|
+
updatedAt: NOW,
|
|
56
|
+
}));
|
|
57
|
+
upsertVectors(store.db, items);
|
|
58
|
+
store.db.close();
|
|
59
|
+
return vectorsDb;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 假模型:从 prompt 里抠组编号并逐组回结论(**不联网**)。 */
|
|
63
|
+
function makeFakeFetch({ promptTokens = 120, completionTokens = 40, conclusion = (key) => `长期结论 ${key}:这条提炼结论用于单测断言,不超过二十字。`, failTimes = 0, emptyFor = () => false } = {}) {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
const seen = [];
|
|
66
|
+
const fn = async (_url, init) => {
|
|
67
|
+
calls += 1;
|
|
68
|
+
const body = JSON.parse(init.body);
|
|
69
|
+
const user = body.messages.find((m) => m.role === 'user').content;
|
|
70
|
+
seen.push({ model: body.model, user, thinking: body.thinking });
|
|
71
|
+
if (calls <= failTimes) return { ok: false, status: 500, text: async () => 'boom' };
|
|
72
|
+
const keys = [...user.matchAll(/【组 ([0-9a-f]+)】/g)].map((m) => m[1]);
|
|
73
|
+
const items = keys.map((k) => ({ key: k, conclusion: emptyFor(k) ? '' : conclusion(k) }));
|
|
74
|
+
return {
|
|
75
|
+
ok: true,
|
|
76
|
+
status: 200,
|
|
77
|
+
text: async () => JSON.stringify({ choices: [{ message: { content: JSON.stringify({ items }) } }], usage: { prompt_tokens: promptTokens, completion_tokens: completionTokens } }),
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
fn.calls = () => calls;
|
|
81
|
+
fn.seen = seen;
|
|
82
|
+
return fn;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 读库里某条。 */
|
|
86
|
+
function row(dbPath, id) {
|
|
87
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
88
|
+
try { return db.prepare('SELECT * FROM memories WHERE id = ?').get(Number(id)); } finally { db.close(); }
|
|
89
|
+
}
|
|
90
|
+
function allDistilled(dbPath) {
|
|
91
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
92
|
+
try { return db.prepare("SELECT id, text, tags, owner_user, visibility, pinned FROM memories WHERE tags LIKE 'distilled%' ORDER BY id").all(); } finally { db.close(); }
|
|
93
|
+
}
|
|
94
|
+
function countRows(dbPath) {
|
|
95
|
+
const db = new DatabaseSync(dbPath, { readOnly: true });
|
|
96
|
+
try { return Number(db.prepare('SELECT COUNT(*) n FROM memories').get().n); } finally { db.close(); }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sampleRows = (baseTs, n = 4) => Array.from({ length: n }, (_, i) => ({
|
|
100
|
+
id: i + 1,
|
|
101
|
+
text: `样本 ${i + 1}:本地生图栈的启动脚本与模型切换口径、参考图一致性配方与排障入口(第 ${i + 1} 版,正文留够长度以便聚类与提炼)`,
|
|
102
|
+
tags: '技术 生图',
|
|
103
|
+
updated_at: baseTs - i * 1000,
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
/** 够长的正文(默认 `minClusterChars: 120` 之下会被判"太短不提炼"⇒ 需要真调模型的用例要够长)。 */
|
|
107
|
+
const long = (head) => `${head}:${'本条正文用于撑够最小提炼长度并让聚类有区分度,包含条件、做法与阈值等可长期复用的信息。'.repeat(5)}`;
|
|
108
|
+
|
|
109
|
+
// ── 01 内容级去重键 ─────────────────────────────────────────────────────────
|
|
110
|
+
section('01 去重键 = 源正文哈希集合(**与 id 无关**)');
|
|
111
|
+
test('srcKeyOf:同正文、**换 id** ⇒ 键不变(跨机合并灌高位 id 也挡得住)', () => {
|
|
112
|
+
const a = [{ id: 6, text: '甲' }, { id: 7, text: '乙' }];
|
|
113
|
+
const b = [{ id: 5001, text: '乙' }, { id: 5002, text: '甲' }];
|
|
114
|
+
assertEq(D.srcKeyOf(a), D.srcKeyOf(b), 'id 变、正文同 ⇒ 同一键');
|
|
115
|
+
assertEq(D.srcKeyOf(a), D.srcKeyOf([...a].reverse()), '顺序无关(内部排序)');
|
|
116
|
+
});
|
|
117
|
+
test('srcKeyOf:正文变一个字 ⇒ 键变(该重提炼就得重提炼)', () => {
|
|
118
|
+
assert(D.srcKeyOf([{ id: 1, text: '甲' }]) !== D.srcKeyOf([{ id: 1, text: '甲。' }]), '正文不同 ⇒ 键不同');
|
|
119
|
+
});
|
|
120
|
+
test('distilledTagOf / srcKeyInTags:标签形态可往返解析', () => {
|
|
121
|
+
const tags = D.distilledTagOf({ srcKey: 'd0123456789abcdef', srcIds: [9, 3], now: NOW });
|
|
122
|
+
assert(tags.startsWith('distilled '), '必须**以 distilled 开头**(回滚/排除自我指涉的判据)');
|
|
123
|
+
assert(tags.includes('src=d0123456789abcdef'), '带 src 键');
|
|
124
|
+
assert(tags.includes('srcids=3,9'), '带源 id 清单(升序)');
|
|
125
|
+
assert(/\sdate=\d{8}$/.test(tags), '带日期');
|
|
126
|
+
assertEq(D.srcKeyInTags(tags), 'd0123456789abcdef', '能解析回来');
|
|
127
|
+
assertEq(D.srcKeyInTags('技术 工作记录'), null, '普通标签解析为 null');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// ── 02 输入挑选:窗口 ∪ 台账 + 不自我指涉 + D2 从严 ─────────────────────────
|
|
131
|
+
section('02 输入挑选(窗口 + 台账 ∪;排除 distilled;D2 从严)');
|
|
132
|
+
test('窗口内条被选入;窗口外且不在台账的条被排除', () => {
|
|
133
|
+
const f = makeDb('in-window', [
|
|
134
|
+
{ id: 1, text: '窗口内', updated_at: NOW - 3600_000 },
|
|
135
|
+
{ id: 2, text: '窗口外', updated_at: NOW - 30 * 3600_000 },
|
|
136
|
+
]);
|
|
137
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
138
|
+
assertEq(sel.accepted.map((x) => x.id), [1], '只选窗口内的');
|
|
139
|
+
assertEq(sel.stats.windowRows, 1, '窗口行数');
|
|
140
|
+
});
|
|
141
|
+
test('台账里的 id 即使 updated_at 很老也被并进输入(治 A7 盲区)', () => {
|
|
142
|
+
const f = makeDb('dirty-in', [{ id: 7, text: '被外部改过 text 但没动 updated_at', updated_at: NOW - 100 * 3600_000 }]);
|
|
143
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, dirtyIds: [7], localOwner: OWNER });
|
|
144
|
+
assertEq(sel.accepted.map((x) => x.id), [7], '台账是补充输入源');
|
|
145
|
+
assertEq(sel.accepted[0].source, 'dirty-ledger', '标注来源可追溯');
|
|
146
|
+
});
|
|
147
|
+
test('**不自我指涉**:`tags LIKE distilled%` 的条永不进输入', () => {
|
|
148
|
+
const f = makeDb('self-ref', [
|
|
149
|
+
{ id: 1, text: '普通条', updated_at: NOW - 1000 },
|
|
150
|
+
{ id: 2, text: '提炼条', tags: D.distilledTagOf({ srcKey: 'd0123456789abcdef', srcIds: [1], now: NOW }), updated_at: NOW - 1000 },
|
|
151
|
+
]);
|
|
152
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
153
|
+
assertEq(sel.accepted.map((x) => x.id), [1], '提炼条被排除');
|
|
154
|
+
assertEq(sel.stats.distilledExcluded, 1, '记账:排除 1 条 distilled');
|
|
155
|
+
});
|
|
156
|
+
test('D2 从严:visibility=family 的条**不进输入**(不送第三方)', () => {
|
|
157
|
+
const f = makeDb('family', [
|
|
158
|
+
{ id: 1, text: '主人条', updated_at: NOW - 1000 },
|
|
159
|
+
{ id: 2, text: '家人投喂的私事', visibility: 'family', updated_at: NOW - 1000 },
|
|
160
|
+
]);
|
|
161
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
162
|
+
assertEq(sel.accepted.map((x) => x.id), [1], 'family 被排除');
|
|
163
|
+
assertEq(sel.family.map((x) => x.id), [2], '记账在 family 清单');
|
|
164
|
+
assertEq(sel.stats.skippedFamily, 1, '统计可见');
|
|
165
|
+
});
|
|
166
|
+
test('D2 从严:**非本机 owner** 的条不进输入', () => {
|
|
167
|
+
const f = makeDb('other-owner', [
|
|
168
|
+
{ id: 1, text: '主人条', updated_at: NOW - 1000 },
|
|
169
|
+
{ id: 2, text: '别人投喂的条', owner: 'SomeoneElse', updated_at: NOW - 1000 },
|
|
170
|
+
]);
|
|
171
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
172
|
+
assertEq(sel.accepted.map((x) => x.id), [1], '非本机 owner 排除');
|
|
173
|
+
assertEq(sel.unknownOwner.map((x) => x.id), [2], '记账');
|
|
174
|
+
});
|
|
175
|
+
test('D2 从严:**库无 owner 列** ⇒ 归属不可判定 ⇒ 一条都不送(宁可漏)', () => {
|
|
176
|
+
const f = makeDb('no-owner-col', [{ id: 1, text: '老库形态的条', updated_at: NOW - 1000 }], { withOwner: false });
|
|
177
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
178
|
+
assertEq(sel.accepted.length, 0, '缺列 ⇒ 0 条可送');
|
|
179
|
+
assertEq(sel.unknownOwner.length, 1, '记账:归属不可判定');
|
|
180
|
+
});
|
|
181
|
+
test('D2 从严:**本机 owner 未解析出来** ⇒ 也不送(不猜)', () => {
|
|
182
|
+
const f = makeDb('no-owner-resolved', [{ id: 1, text: '条', updated_at: NOW - 1000 }]);
|
|
183
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: null });
|
|
184
|
+
assertEq(sel.accepted.length, 0, '未解析 owner ⇒ 不送');
|
|
185
|
+
assertEq(sel.unknownOwner.length, 1, '记账');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── 03 语义聚类 ─────────────────────────────────────────────────────────────
|
|
189
|
+
section('03 语义向量聚类(不用 tags)');
|
|
190
|
+
test('同向量的两条聚成一组;正交的另起一组;无向量的自成一组', () => {
|
|
191
|
+
const f = makeDb('cluster', [
|
|
192
|
+
{ id: 1, text: 'A1 生图栈', updated_at: NOW - 1000 },
|
|
193
|
+
{ id: 2, text: 'A2 生图栈', updated_at: NOW - 1000 },
|
|
194
|
+
{ id: 3, text: 'B1 同步仓', updated_at: NOW - 1000 },
|
|
195
|
+
{ id: 4, text: 'C1 尚未编码的条', updated_at: NOW - 1000 },
|
|
196
|
+
]);
|
|
197
|
+
writeVectors(f.vectorsDb, [
|
|
198
|
+
[1, [1, 0, 0, 0, 0, 0, 0, 0], 'A1 生图栈'],
|
|
199
|
+
[2, [0.99, 0.05, 0, 0, 0, 0, 0, 0], 'A2 生图栈'],
|
|
200
|
+
[3, [0, 1, 0, 0, 0, 0, 0, 0], 'B1 同步仓'],
|
|
201
|
+
]);
|
|
202
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
203
|
+
const cl = D.clusterRows(sel.accepted, { vectorsDbPath: f.vectorsDb, simThreshold: 0.6 });
|
|
204
|
+
assertEq(cl.stats.clusters, 3, 'A(2 条) + B(1) + 无向量(1) = 3 组');
|
|
205
|
+
const sizes = cl.clusters.map((c) => c.members.length).sort();
|
|
206
|
+
assertEq(sizes, [1, 1, 2], '组大小分布符合预期');
|
|
207
|
+
assertEq(cl.stats.noVector, 1, '无向量计数可见(不按 tags 兜底猜)');
|
|
208
|
+
});
|
|
209
|
+
test('聚类键 = 组内容的 srcKey(与组内 id 无关)', () => {
|
|
210
|
+
const rows = [{ id: 3, text: 'x' }, { id: 4, text: 'y' }];
|
|
211
|
+
const cl = D.clusterRows(rows.map((r) => ({ ...r, textForModel: r.text })), { vectorsDbPath: null });
|
|
212
|
+
assertEq(cl.clusters.length, 2, '无向量 ⇒ 各自成组');
|
|
213
|
+
assertEq(cl.clusters[0].key, D.srcKeyOf([rows[0]]), '单条组的键 = 该条正文的键');
|
|
214
|
+
assertEq(cl.clusters[1].key, D.srcKeyOf([rows[1]]), '第二条同理');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// ── 04 台账(memories_dirty)────────────────────────────────────────────────
|
|
218
|
+
section('04 memories_dirty 台账(兜底轮转的产物,补 A7 盲区)');
|
|
219
|
+
test('recordDirty:首次入账 open;重复扫到 hits+1 但**不清 consumed_at**', () => {
|
|
220
|
+
const f = makeDb('ledger', [{ id: 1, text: 'v1', updated_at: NOW }]);
|
|
221
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], 'v1']]);
|
|
222
|
+
const db = new DatabaseSync(f.vectorsDb);
|
|
223
|
+
try {
|
|
224
|
+
L.recordDirty(db, [{ refId: 1, kind: 'mismatch', oldHash: 'a', newHash: 'b' }], { now: NOW });
|
|
225
|
+
L.markConsumed(db, [1], { now: NOW });
|
|
226
|
+
// 同一差异被再扫到(hash 没变)⇒ 不得复活
|
|
227
|
+
L.recordDirty(db, [{ refId: 1, kind: 'mismatch', oldHash: 'a', newHash: 'b' }], { now: NOW + 1000 });
|
|
228
|
+
const rows = L.listDirty(db, { includeConsumed: true });
|
|
229
|
+
assertEq(rows.length, 1, '同一 ref_id 只有一行');
|
|
230
|
+
assertEq(rows[0].hits, 2, 'hits 累加');
|
|
231
|
+
assert(rows[0].consumedAt != null, '重复扫到**不复活**(否则每轮轮转都会重新消费一遍)');
|
|
232
|
+
} finally { db.close(); }
|
|
233
|
+
});
|
|
234
|
+
test('recordDirty:正文**又变了** ⇒ consumed_at 清空(重新待消费)', () => {
|
|
235
|
+
const f = makeDb('ledger2', [{ id: 1, text: 'v1', updated_at: NOW }]);
|
|
236
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], 'v1']]);
|
|
237
|
+
const db = new DatabaseSync(f.vectorsDb);
|
|
238
|
+
try {
|
|
239
|
+
L.recordDirty(db, [{ refId: 1, oldHash: 'a', newHash: 'b' }], { now: NOW });
|
|
240
|
+
L.markConsumed(db, [1], { now: NOW });
|
|
241
|
+
const r = L.recordDirty(db, [{ refId: 1, oldHash: 'b', newHash: 'c' }], { now: NOW + 1 });
|
|
242
|
+
assertEq(r.reopened, 1, '复活 1 条');
|
|
243
|
+
assertEq(L.listDirty(db, { openOnly: true }).length, 1, '重新变成待消费');
|
|
244
|
+
} finally { db.close(); }
|
|
245
|
+
});
|
|
246
|
+
test('detectDirty:**改 text 不动 updated_at** 能被抓出来并落台账(A7 复刻)', () => {
|
|
247
|
+
const f = makeDb('detect-dirty', [{ id: 1, text: '原始正文', updated_at: NOW - 10 * 3600_000 }]);
|
|
248
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], '原始正文']]);
|
|
249
|
+
// 复刻 apply-pinned-refine.mjs:110:只改 text
|
|
250
|
+
const db = new DatabaseSync(f.dbPath);
|
|
251
|
+
db.prepare('UPDATE memories SET text = ? WHERE id = ?').run('被外部勘误后的正文', 1);
|
|
252
|
+
db.close();
|
|
253
|
+
const d = D.detectDirty({ dbPath: f.dbPath, vectorsDbPath: f.vectorsDb, record: true, now: NOW });
|
|
254
|
+
assertEq(d.mismatched, [1], '抓到差异');
|
|
255
|
+
const led = D.readDirtyIds(f.vectorsDb);
|
|
256
|
+
assertEq(led.ids, [1], '台账里出现该条 ⇒ 提炼 job 日后会补上它');
|
|
257
|
+
});
|
|
258
|
+
test('markConsumed 幂等(第二次调用不再改 consumed_at)', () => {
|
|
259
|
+
const f = makeDb('ledger3', [{ id: 1, text: 'x', updated_at: NOW }]);
|
|
260
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], 'x']]);
|
|
261
|
+
const db = new DatabaseSync(f.vectorsDb);
|
|
262
|
+
try {
|
|
263
|
+
L.recordDirty(db, [{ refId: 1, oldHash: 'a', newHash: 'b' }], { now: NOW });
|
|
264
|
+
assertEq(L.markConsumed(db, [1], { now: NOW }), 1, '第一次标记成功');
|
|
265
|
+
assertEq(L.markConsumed(db, [1], { now: NOW + 5 }), 0, '第二次 0 变更(幂等)');
|
|
266
|
+
} finally { db.close(); }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── 05 dry-run 零写入 ──────────────────────────────────────────────────────
|
|
270
|
+
section('05 dry-run:不调模型、不写库、不写 state');
|
|
271
|
+
test('dry-run:库文件 sha 不变 + 不产生 state 文件 + 假模型 0 调用', async () => {
|
|
272
|
+
const rows = [
|
|
273
|
+
{ id: 1, text: long('样本一:跨机同步的挡推裁决口径'), tags: '技术 同步', updated_at: NOW },
|
|
274
|
+
{ id: 2, text: long('样本二:跨机同步的挡推裁决口径'), tags: '技术 同步', updated_at: NOW - 1000 },
|
|
275
|
+
];
|
|
276
|
+
const f = makeDb('dryrun', rows);
|
|
277
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
278
|
+
const shaBefore = crypto.createHash('sha256').update(fs.readFileSync(f.dbPath)).digest('hex');
|
|
279
|
+
const fake = makeFakeFetch();
|
|
280
|
+
const r = await D.runDistill({
|
|
281
|
+
memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW,
|
|
282
|
+
apply: false, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake,
|
|
283
|
+
});
|
|
284
|
+
const shaAfter = crypto.createHash('sha256').update(fs.readFileSync(f.dbPath)).digest('hex');
|
|
285
|
+
assertEq(shaAfter, shaBefore, '库字节未变');
|
|
286
|
+
assertEq(fake.calls(), 0, 'dry-run 不调模型');
|
|
287
|
+
assertEq(fs.existsSync(f.stateFile), false, 'dry-run 不写 state');
|
|
288
|
+
assert(r.planned.some((x) => x.action === 'call'), '但要**看得见**本来会调几次');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// ── 05b 单条组默认不提炼(护住 memory:recall 段) ─────────────────────────────
|
|
292
|
+
section('05b 单条组默认不提炼(minClusterSize=2)');
|
|
293
|
+
test('默认 minClusterSize=2:单条成组的条被跳过,多条组照常提炼', async () => {
|
|
294
|
+
const rows = [
|
|
295
|
+
{ id: 1, text: long('同题甲一'), updated_at: NOW },
|
|
296
|
+
{ id: 2, text: long('同题甲二'), updated_at: NOW - 1000 },
|
|
297
|
+
{ id: 3, text: long('孤条丙丙'), updated_at: NOW - 2000 },
|
|
298
|
+
];
|
|
299
|
+
const f = makeDb('min-cluster-size', rows);
|
|
300
|
+
writeVectors(f.vectorsDb, [
|
|
301
|
+
[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text],
|
|
302
|
+
[2, [0.99, 0.02, 0, 0, 0, 0, 0, 0], rows[1].text],
|
|
303
|
+
[3, [0, 1, 0, 0, 0, 0, 0, 0], rows[2].text],
|
|
304
|
+
]);
|
|
305
|
+
const fake = makeFakeFetch();
|
|
306
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, fetchImpl: fake });
|
|
307
|
+
assertEq(fake.calls(), 1, '只对"多条组"调 1 次(单条组不调)');
|
|
308
|
+
assertEq(r.written, 1, '只写 1 条');
|
|
309
|
+
const singleton = r.planned.find((x) => x.ids.length === 1);
|
|
310
|
+
assertEq(singleton.action, 'skip-singleton', '单条组标注跳过原因');
|
|
311
|
+
assert(singleton.reason.includes('minClusterSize'), '原因写明判据');
|
|
312
|
+
});
|
|
313
|
+
test('默认值就是 2(不许悄悄改回 1 —— 它直接决定每天往主库塞几条、进而挤占 recall 段)', () => {
|
|
314
|
+
assertEq(D.CLUSTER_DEFAULTS.minClusterSize, 2, 'minClusterSize 默认 2');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// ── 06 执行:写库形态 + 幂等 + 跨机改 id ────────────────────────────────────
|
|
318
|
+
section('06 执行(写库形态 / 幂等 / 跨机改 id 不重复)');
|
|
319
|
+
test('apply:写入 N 条 distilled(标签前缀 + srcids + date + owner/visibility + pinned=0)', async () => {
|
|
320
|
+
const rows = sampleRows(NOW, 6);
|
|
321
|
+
const f = makeDb('apply', rows);
|
|
322
|
+
writeVectors(f.vectorsDb, rows.map((r, i) => [r.id, [1, i * 0.001, 0, 0, 0, 0, 0, 0], r.text]));
|
|
323
|
+
const fake = makeFakeFetch();
|
|
324
|
+
const before = countRows(f.dbPath);
|
|
325
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
326
|
+
assert(r.written >= 1, `至少写 1 条(实得 ${r.written})`);
|
|
327
|
+
assertEq(countRows(f.dbPath), before + r.written, '库条数 = 原条数 + 新写条数');
|
|
328
|
+
for (const d of allDistilled(f.dbPath)) {
|
|
329
|
+
assert(String(d.tags).startsWith('distilled '), '标签以 distilled 开头');
|
|
330
|
+
assert(/src=d[0-9a-f]{16}/.test(d.tags), '带 src 键');
|
|
331
|
+
assert(/srcids=\d/.test(d.tags), '带源 id 清单');
|
|
332
|
+
assert(/\sdate=\d{8}/.test(d.tags), '带日期');
|
|
333
|
+
assertEq(d.pinned, 0, 'pinned=0(方案 §5.1:不挤占 pinned 段)');
|
|
334
|
+
assertEq(d.owner_user, OWNER, 'owner_user = 本机 owner');
|
|
335
|
+
assertEq(d.visibility, 'private_owner', 'visibility 默认私有');
|
|
336
|
+
assert(d.text.length > 0 && d.text.length <= 400, '结论非空且不超长');
|
|
337
|
+
}
|
|
338
|
+
assert(fs.existsSync(f.stateFile), 'apply 写 state');
|
|
339
|
+
const st = JSON.parse(fs.readFileSync(f.stateFile, 'utf8'));
|
|
340
|
+
assertEq(st.schemaVersion, D.STATE_SCHEMA, 'state 带 schemaVersion');
|
|
341
|
+
assert(st.cost.calls === fake.calls(), 'state 记账 = 实际调用数');
|
|
342
|
+
assert(st.lastDailyLine.includes('提炼条数'), 'state 存日报行');
|
|
343
|
+
});
|
|
344
|
+
test('**幂等**:同日重跑 ⇒ 0 次新调用、0 条新写入(内容哈希去重生效)', async () => {
|
|
345
|
+
const rows = sampleRows(NOW, 4);
|
|
346
|
+
const f = makeDb('idempotent', rows);
|
|
347
|
+
writeVectors(f.vectorsDb, rows.map((r) => [r.id, [1, 0, 0, 0, 0, 0, 0, 0], r.text]));
|
|
348
|
+
const fake1 = makeFakeFetch();
|
|
349
|
+
const r1 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake1 });
|
|
350
|
+
const fake2 = makeFakeFetch();
|
|
351
|
+
const r2 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake2 });
|
|
352
|
+
assertEq(fake2.calls(), 0, '第二次 0 次模型调用');
|
|
353
|
+
assertEq(r2.written, 0, '第二次 0 条写入');
|
|
354
|
+
assertEq(r2.planned.every((x) => x.action !== 'call'), true, '全部走跳过分支');
|
|
355
|
+
assertEq(countRows(f.dbPath), rows.length + r1.written, '库条数不变');
|
|
356
|
+
});
|
|
357
|
+
test('**跨机改 id 后重跑仍不重复**:同正文换新 id(模拟 merge-memory 灌高位段)⇒ 0 次调用', async () => {
|
|
358
|
+
const rows = sampleRows(NOW, 3);
|
|
359
|
+
const f = makeDb('xhost-a', rows);
|
|
360
|
+
writeVectors(f.vectorsDb, rows.map((r) => [r.id, [1, 0, 0, 0, 0, 0, 0, 0], r.text]));
|
|
361
|
+
const fake1 = makeFakeFetch();
|
|
362
|
+
await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake1 });
|
|
363
|
+
const written = allDistilled(f.dbPath);
|
|
364
|
+
assert(written.length >= 1, '先有一批提炼条');
|
|
365
|
+
|
|
366
|
+
// 模拟跨机合并:**同样的正文**、**全新的高位 id**,老 id 全部不在(内容哈希主键口径)
|
|
367
|
+
const f2 = makeDb('xhost-b', rows.map((r, i) => ({ ...r, id: 9001 + i, tags: r.tags })));
|
|
368
|
+
for (const d of written) {
|
|
369
|
+
const meta = R.parseDistilledTags(d.tags);
|
|
370
|
+
const db = new DatabaseSync(f2.dbPath);
|
|
371
|
+
db.prepare(`INSERT INTO memories (text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,0,?,?,?,?)`)
|
|
372
|
+
.run(d.text, `distilled src=${meta.srcKey} srcids=${meta.srcIds.join(',')} date=${meta.date}`, NOW, NOW, OWNER, 'private_owner');
|
|
373
|
+
db.close();
|
|
374
|
+
}
|
|
375
|
+
const fake2 = makeFakeFetch();
|
|
376
|
+
const r2 = await D.runDistill({ memoryDb: f2.dbPath, vectorsDb: f2.vectorsDb, stateFile: f2.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake2 });
|
|
377
|
+
assertEq(fake2.calls(), 0, 'id 变了但**内容键不变** ⇒ 不再调模型');
|
|
378
|
+
assertEq(r2.written, 0, '不产出重复条');
|
|
379
|
+
assertEq(countRows(f2.dbPath), rows.length + written.length, '库条数不变');
|
|
380
|
+
});
|
|
381
|
+
test('state 丢失也能挡(去重的 durable 源 = 库里的 src= 标签)', async () => {
|
|
382
|
+
const rows = sampleRows(NOW, 3);
|
|
383
|
+
const f = makeDb('durable-dedup', rows);
|
|
384
|
+
writeVectors(f.vectorsDb, rows.map((r) => [r.id, [1, 0, 0, 0, 0, 0, 0, 0], r.text]));
|
|
385
|
+
const fake1 = makeFakeFetch();
|
|
386
|
+
await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake1 });
|
|
387
|
+
fs.rmSync(f.stateFile, { force: true }); // 台账丢了
|
|
388
|
+
const fake2 = makeFakeFetch();
|
|
389
|
+
const r2 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake2 });
|
|
390
|
+
assertEq(fake2.calls(), 0, '靠库里的 src= 标签挡住(state 不是唯一防线)');
|
|
391
|
+
assertEq(r2.written, 0, '不重复写');
|
|
392
|
+
});
|
|
393
|
+
test('同一组内 id 全换但正文同 ⇒ 仍只产出一次(第二次 run 的 planned 全非 call)', async () => {
|
|
394
|
+
const f = makeDb('same-content', [{ id: 11, text: long('同一条正文'), updated_at: NOW }]);
|
|
395
|
+
writeVectors(f.vectorsDb, [[11, [1, 0, 0, 0, 0, 0, 0, 0], long('同一条正文')]]);
|
|
396
|
+
const fake = makeFakeFetch();
|
|
397
|
+
const r1 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
398
|
+
assertEq(r1.written, 1, '先产出 1 条');
|
|
399
|
+
const db = new DatabaseSync(f.dbPath); // 换 id 再来一条同正文
|
|
400
|
+
db.prepare('INSERT INTO memories (id, text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,?,0,?,?,?,?)')
|
|
401
|
+
.run(4242, long('同一条正文'), '', NOW, NOW, OWNER, 'private_owner');
|
|
402
|
+
db.close();
|
|
403
|
+
const fake2 = makeFakeFetch();
|
|
404
|
+
const r2 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake2 });
|
|
405
|
+
assertEq(fake2.calls(), 0, '内容级去重挡住(不是 id 级)');
|
|
406
|
+
assertEq(r2.written, 0, '无新增');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// ── 07 不自我指涉:连续三天 ────────────────────────────────────────────────
|
|
410
|
+
section('07 不自我指涉(模拟连续三天)');
|
|
411
|
+
test('三天连跑:第 2/3 天的输入里**永远不出现 distilled 条**,且不出现"源自 distilled"的键', async () => {
|
|
412
|
+
const f = makeDb('three-days', [{ id: 1, text: long('第一天的内容:提炼面 A'), updated_at: NOW }]);
|
|
413
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], long('第一天的内容:提炼面 A')]]);
|
|
414
|
+
const days = [NOW, NOW + 86400_000, NOW + 2 * 86400_000];
|
|
415
|
+
const perDay = [];
|
|
416
|
+
for (let d = 0; d < days.length; d += 1) {
|
|
417
|
+
const t = days[d];
|
|
418
|
+
// 每天造一条当天"新写"的记忆(模拟正常使用)
|
|
419
|
+
const db = new DatabaseSync(f.dbPath);
|
|
420
|
+
db.prepare('INSERT INTO memories (text, tags, pinned, created_at, updated_at, owner_user, visibility) VALUES (?,?,0,?,?,?,?)')
|
|
421
|
+
.run(long(`第 ${d + 1} 天的内容:提炼面 ${d + 1}`), '', t, t, OWNER, 'private_owner');
|
|
422
|
+
db.close();
|
|
423
|
+
const fake = makeFakeFetch();
|
|
424
|
+
// 先算当天输入(= 当天提炼真正看到的东西),再跑
|
|
425
|
+
const sel = D.selectInputs({ dbPath: f.dbPath, now: t, windowHours: 24, localOwner: OWNER });
|
|
426
|
+
// 硬不变量:当天输入里**绝不能混进 distilled 条**(逐日现算,不靠计数自证)
|
|
427
|
+
const distilledIdsBefore = new Set(allDistilled(f.dbPath).map((x) => x.id));
|
|
428
|
+
for (const id of sel.accepted.map((x) => x.id)) assert(!distilledIdsBefore.has(id), `第 ${d + 1} 天输入里混进了 distilled 条 #${id}`);
|
|
429
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: t, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake, dirtyProbe: false });
|
|
430
|
+
perDay.push({ day: d + 1, calls: fake.calls(), written: r.written, inputIds: sel.accepted.map((x) => x.id), distilledExcluded: sel.stats.distilledExcluded });
|
|
431
|
+
// 每天都核对:所有 distilled 条的 tags 里,src 键必须来自**真实条**(不能来自 distilled 条)
|
|
432
|
+
const distilledRows = allDistilled(f.dbPath);
|
|
433
|
+
for (const drow of distilledRows) {
|
|
434
|
+
const meta = R.parseDistilledTags(drow.tags);
|
|
435
|
+
for (const sid of meta.srcIds) {
|
|
436
|
+
const src = row(f.dbPath, sid);
|
|
437
|
+
assert(src, `源 id ${sid} 必须存在`);
|
|
438
|
+
assert(!String(src.tags).startsWith('distilled'), `源条 ${sid} 不得是 distilled 条(自我指涉)`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
assertEq(perDay[0].distilledExcluded, 0, '第 1 天输入里没有 distilled 可排');
|
|
443
|
+
assert(perDay[1].distilledExcluded >= 1, '第 2 天:第 1 天的产物被显式排除');
|
|
444
|
+
assert(perDay[2].distilledExcluded >= 1, '第 3 天:前几天的产物仍被排除(不叠摘要)');
|
|
445
|
+
assert(perDay.every((x) => x.inputIds.length > 0), '每天仍有真实内容可提炼');
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ── 08 成本闸 ──────────────────────────────────────────────────────────────
|
|
449
|
+
section('08 成本闸(≤N 次/日 · ≤M token/日 · 超限即停 · 不重试)');
|
|
450
|
+
test('调用数上限:3 组但上限 1 ⇒ 只调 1 次 + 记停机原因', async () => {
|
|
451
|
+
const rows = [
|
|
452
|
+
{ id: 1, text: long('A 主题内容甲甲甲甲'), updated_at: NOW },
|
|
453
|
+
{ id: 2, text: long('B 主题内容乙乙乙乙'), updated_at: NOW },
|
|
454
|
+
{ id: 3, text: long('C 主题内容丙丙丙丙'), updated_at: NOW },
|
|
455
|
+
];
|
|
456
|
+
const f = makeDb('gate-calls', rows);
|
|
457
|
+
writeVectors(f.vectorsDb, [
|
|
458
|
+
[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text],
|
|
459
|
+
[2, [0, 1, 0, 0, 0, 0, 0, 0], rows[1].text],
|
|
460
|
+
[3, [0, 0, 1, 0, 0, 0, 0, 0], rows[2].text],
|
|
461
|
+
]);
|
|
462
|
+
const fake = makeFakeFetch();
|
|
463
|
+
const r = await D.runDistill({
|
|
464
|
+
memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true,
|
|
465
|
+
localOwner: OWNER, minClusterSize: 1, fetchImpl: fake, limits: { callsPerDay: 1, tokensPerDay: 150000 },
|
|
466
|
+
});
|
|
467
|
+
assertEq(fake.calls(), 1, '只调 1 次');
|
|
468
|
+
assert(r.stopping && r.stopping.includes('调用数已达上限'), `停机原因写明上限(实得 ${r.stopping})`);
|
|
469
|
+
const st = JSON.parse(fs.readFileSync(f.stateFile, 'utf8'));
|
|
470
|
+
assertEq(st.cost.calls, 1, 'state 记账 1 次');
|
|
471
|
+
});
|
|
472
|
+
test('token 上限:预估超限 ⇒ 一次都不调', async () => {
|
|
473
|
+
const rows = [{ id: 1, text: '内容'.repeat(300), updated_at: NOW }];
|
|
474
|
+
const f = makeDb('gate-tokens', rows);
|
|
475
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
476
|
+
const fake = makeFakeFetch();
|
|
477
|
+
const r = await D.runDistill({
|
|
478
|
+
memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true,
|
|
479
|
+
localOwner: OWNER, minClusterSize: 1, fetchImpl: fake, limits: { callsPerDay: 20, tokensPerDay: 50 },
|
|
480
|
+
});
|
|
481
|
+
assertEq(fake.calls(), 0, 'token 上限挡住');
|
|
482
|
+
assert(r.stopping && r.stopping.includes('token'), `停机原因写明 token(实得 ${r.stopping})`);
|
|
483
|
+
});
|
|
484
|
+
test('**不重试**:模型 500 ⇒ 该组只调一次、记 skipped、本轮继续(不重复调用)', async () => {
|
|
485
|
+
const rows = [
|
|
486
|
+
{ id: 1, text: long('主题甲甲甲甲甲甲甲甲'), updated_at: NOW },
|
|
487
|
+
{ id: 2, text: long('主题乙乙乙乙乙乙乙乙'), updated_at: NOW },
|
|
488
|
+
];
|
|
489
|
+
const f = makeDb('no-retry', rows);
|
|
490
|
+
writeVectors(f.vectorsDb, [
|
|
491
|
+
[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text],
|
|
492
|
+
[2, [0, 1, 0, 0, 0, 0, 0, 0], rows[1].text],
|
|
493
|
+
]);
|
|
494
|
+
const fake = makeFakeFetch({ failTimes: 1 });
|
|
495
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
496
|
+
assertEq(fake.calls(), 2, '两组共 2 次调用(失败的那组没有重试)');
|
|
497
|
+
const st = JSON.parse(fs.readFileSync(f.stateFile, 'utf8'));
|
|
498
|
+
assert(Object.values(st.skipped).some((s) => String(s.reason).startsWith('model-error')), '失败组落 skipped 台账');
|
|
499
|
+
});
|
|
500
|
+
test('当日调用已满(跨轮)⇒ 本轮 0 调用', async () => {
|
|
501
|
+
const rows = [{ id: 1, text: long('内容内容内容内容'), updated_at: NOW }];
|
|
502
|
+
const f = makeDb('gate-cross-run', rows);
|
|
503
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
504
|
+
fs.writeFileSync(f.stateFile, JSON.stringify({
|
|
505
|
+
schemaVersion: 1, cost: { date: D.localDateIso(NOW), calls: 20, promptTokens: 1000, completionTokens: 500 },
|
|
506
|
+
processed: {}, skipped: {}, history: [], stats: {},
|
|
507
|
+
}), 'utf8');
|
|
508
|
+
const fake = makeFakeFetch();
|
|
509
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
510
|
+
assertEq(fake.calls(), 0, '当日额度已满 ⇒ 不再调用');
|
|
511
|
+
assert(r.stopping.includes('上限'), '停机原因');
|
|
512
|
+
});
|
|
513
|
+
test('同日失败过的组**不再重试**(跨轮也不重试);换日才给第二次机会', async () => {
|
|
514
|
+
const rows = [{ id: 1, text: long('主题甲甲甲甲甲甲'), updated_at: NOW }];
|
|
515
|
+
const f = makeDb('no-retry-same-day', rows);
|
|
516
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
517
|
+
const failFake = makeFakeFetch({ failTimes: 1 });
|
|
518
|
+
const r1 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: failFake });
|
|
519
|
+
assertEq(failFake.calls(), 1, '第一次调用失败(不重试)');
|
|
520
|
+
assertEq(r1.calls[0].ok, false, '记录失败');
|
|
521
|
+
// 同一天再跑 ⇒ 不再试(保护额度)
|
|
522
|
+
const again = makeFakeFetch();
|
|
523
|
+
const r2 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW + 3600_000, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: again });
|
|
524
|
+
assertEq(again.calls(), 0, '同日不重试');
|
|
525
|
+
assertEq(r2.planned[0].action, 'skip-failed-today', '计划里写明同日不再试');
|
|
526
|
+
// 换一天 ⇒ 允许再试一次(否则一次 429 会永久丢掉这一组)
|
|
527
|
+
const nextDay = makeFakeFetch();
|
|
528
|
+
const r3 = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW + 86400_000, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: nextDay });
|
|
529
|
+
assertEq(nextDay.calls(), 1, '换日允许再试一次');
|
|
530
|
+
assertEq(r3.written, 1, '这次成功写入');
|
|
531
|
+
});
|
|
532
|
+
test('单轮组数上限:计划 N 组但上限 1 ⇒ 只处理 1 组,且日报行写明"本轮处理 1/N 组"', async () => {
|
|
533
|
+
const rows = [
|
|
534
|
+
{ id: 1, text: long('甲甲甲'), updated_at: NOW },
|
|
535
|
+
{ id: 2, text: long('乙乙乙'), updated_at: NOW },
|
|
536
|
+
{ id: 3, text: long('丙丙丙'), updated_at: NOW },
|
|
537
|
+
];
|
|
538
|
+
const f = makeDb('capped', rows);
|
|
539
|
+
writeVectors(f.vectorsDb, [
|
|
540
|
+
[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text],
|
|
541
|
+
[2, [0, 1, 0, 0, 0, 0, 0, 0], rows[1].text],
|
|
542
|
+
[3, [0, 0, 1, 0, 0, 0, 0, 0], rows[2].text],
|
|
543
|
+
]);
|
|
544
|
+
const fake = makeFakeFetch();
|
|
545
|
+
const r = await D.runDistill({
|
|
546
|
+
memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true,
|
|
547
|
+
localOwner: OWNER, minClusterSize: 1, fetchImpl: fake, maxClustersPerRun: 1,
|
|
548
|
+
});
|
|
549
|
+
assertEq(fake.calls(), 1, '只处理上限内的组');
|
|
550
|
+
assertEq(r.capped.plannedCalls, 3, '计划组数可见');
|
|
551
|
+
assertEq(r.capped.processed, 1, '本轮处理数可见');
|
|
552
|
+
assertEq(r.capped.capped, true, '标出被截断');
|
|
553
|
+
assert(r.dailyLine.includes('本轮处理 1/3 组'), `日报行写明截断(实得 ${r.dailyLine})`);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// ── 09 模型档与提示词 ──────────────────────────────────────────────────────
|
|
557
|
+
section('09 模型档(deepseek-flash · 本鱼链)与调用形态');
|
|
558
|
+
test('默认模型档 = deepseek-flash(**2026-09-21 主人拍板:记忆线一律走本鱼链**)', () => {
|
|
559
|
+
assertEq(D.DEFAULT_LLM.model, 'deepseek-flash', '模型档');
|
|
560
|
+
assertEq(D.DEFAULT_LLM.baseUrl, 'https://api.deepseek.com', 'DeepSeek 官方端点');
|
|
561
|
+
assertEq(D.DEFAULT_LLM.credentialKey, 'DEEPSEEK_API_KEY', '凭证键名');
|
|
562
|
+
assertEq(D.COST_LIMITS, { callsPerDay: 20, tokensPerDay: 150000 }, '成本闸现值(三项上限未变)');
|
|
563
|
+
});
|
|
564
|
+
test('请求体:关思考(deepseek-flash 默认开思考,会白烧 token)+ 带 key 回传(供匹配)', async () => {
|
|
565
|
+
const rows = [{ id: 1, text: long('内容内容内容内容'), updated_at: NOW }];
|
|
566
|
+
const f = makeDb('req-shape', rows);
|
|
567
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
568
|
+
const fake = makeFakeFetch();
|
|
569
|
+
await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
570
|
+
const seen = fake.seen[0];
|
|
571
|
+
assertEq(seen.model, 'deepseek-flash', '实际请求模型');
|
|
572
|
+
assertEq(seen.thinking, { type: 'disabled' }, '显式关思考');
|
|
573
|
+
assert(seen.user.includes('【组 '), '提示词含组编号');
|
|
574
|
+
});
|
|
575
|
+
test('结论为空 ⇒ 不写库(宁缺勿滥),并记 skipped', async () => {
|
|
576
|
+
const rows = [{ id: 1, text: long('流水账内容内容内容内容'), updated_at: NOW }];
|
|
577
|
+
const f = makeDb('empty-conclusion', rows);
|
|
578
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], rows[0].text]]);
|
|
579
|
+
const fake = makeFakeFetch({ emptyFor: () => true });
|
|
580
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
581
|
+
assertEq(r.written, 0, '空结论不写库');
|
|
582
|
+
const st = JSON.parse(fs.readFileSync(f.stateFile, 'utf8'));
|
|
583
|
+
assert(Object.values(st.skipped).some((s) => s.reason.includes('无可提炼')), '记 skipped 可追溯');
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
// ── 10 回滚 ────────────────────────────────────────────────────────────────
|
|
587
|
+
section('10 回滚(按 tag 批量删除 + 快照 + 内容摘要回原状)');
|
|
588
|
+
test('回滚演练:写 N 条 ⇒ 列表 ⇒ dry-run 不删 ⇒ apply 删除后**内容摘要回到写入前**', async () => {
|
|
589
|
+
const rows = sampleRows(NOW, 5);
|
|
590
|
+
const f = makeDb('rollback', rows);
|
|
591
|
+
writeVectors(f.vectorsDb, rows.map((r) => [r.id, [1, 0, 0, 0, 0, 0, 0, 0], r.text]));
|
|
592
|
+
const beforeWrite = R.contentDigest(f.dbPath);
|
|
593
|
+
const fake = makeFakeFetch();
|
|
594
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
595
|
+
assert(r.written >= 1, '先写出一批提炼条');
|
|
596
|
+
const afterWrite = R.contentDigest(f.dbPath);
|
|
597
|
+
assertEq(afterWrite.allCount, beforeWrite.allCount + r.written, '全表条数 = 原条数 + 新写条数');
|
|
598
|
+
assertEq(R.contentDigest(f.dbPath, { excludeTagsLike: 'distilled%' }).digest, beforeWrite.digest, '**真实(非派生)条一个字节都没被动**');
|
|
599
|
+
assertEq(countRows(f.dbPath), rows.length + r.written, '全表条数增加了');
|
|
600
|
+
|
|
601
|
+
const listed = R.listDistilled({ dbPath: f.dbPath });
|
|
602
|
+
assertEq(listed.length, r.written, '能按 tag 全部列出来');
|
|
603
|
+
|
|
604
|
+
const dry = R.rollbackDistilled({ dbPath: f.dbPath, apply: false });
|
|
605
|
+
assertEq(dry.deleted, 0, 'dry-run 不删');
|
|
606
|
+
assertEq(countRows(f.dbPath), rows.length + r.written, 'dry-run 后条数不变');
|
|
607
|
+
|
|
608
|
+
const snapDir = path.join(f.dir, 'snapshots');
|
|
609
|
+
const done = R.rollbackDistilled({ dbPath: f.dbPath, apply: true, snapshotDir: snapDir, now: NOW });
|
|
610
|
+
assertEq(done.deleted, r.written, '删掉全部提炼条');
|
|
611
|
+
assert(fs.existsSync(done.snapshot), '删除前 VACUUM INTO 快照存在');
|
|
612
|
+
assert(fs.existsSync(done.rollbackFile), '生成一键恢复脚本');
|
|
613
|
+
assertEq(countRows(f.dbPath), rows.length, '全表条数回到写入前');
|
|
614
|
+
assertEq(R.contentDigest(f.dbPath).digest, beforeWrite.digest, '**内容摘要与写入前完全一致**(回到原状)');
|
|
615
|
+
assertEq(allDistilled(f.dbPath).length, 0, '提炼条清空');
|
|
616
|
+
});
|
|
617
|
+
test('回滚只删带 distilled 前缀的条(按 key 定向回滚时不会误删真实记忆)', async () => {
|
|
618
|
+
const rows = sampleRows(NOW, 4);
|
|
619
|
+
const f = makeDb('rollback-scoped', rows);
|
|
620
|
+
writeVectors(f.vectorsDb, rows.map((r) => [r.id, [1, 0, 0, 0, 0, 0, 0, 0], r.text]));
|
|
621
|
+
const fake = makeFakeFetch();
|
|
622
|
+
await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
623
|
+
const listed = R.listDistilled({ dbPath: f.dbPath });
|
|
624
|
+
const key = listed[0].srcKey;
|
|
625
|
+
const r = R.rollbackDistilled({ dbPath: f.dbPath, apply: true, keys: [key], snapshotDir: path.join(f.dir, 'snap') });
|
|
626
|
+
assertEq(r.deleted, 1, '只删指定 src 键那一条');
|
|
627
|
+
assertEq(countRows(f.dbPath), rows.length + listed.length - 1, '其余提炼条与真实条都在');
|
|
628
|
+
// 传一个"瞎编的 id"(真实记忆的 id)也必须删不动
|
|
629
|
+
const r2 = R.rollbackDistilled({ dbPath: f.dbPath, apply: true, ids: [1], snapshotDir: path.join(f.dir, 'snap2') });
|
|
630
|
+
assertEq(r2.deleted, 0, '非 distilled 条即使被点名也**删不动**(DELETE 恒带 tag 条件)');
|
|
631
|
+
assertEq(countRows(f.dbPath), rows.length + listed.length - 1, '真实记忆条数不变');
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// ── 11 日报行 ──────────────────────────────────────────────────────────────
|
|
635
|
+
section('11 日报行(条数 + 首行样本 + 当日调用数与 token)');
|
|
636
|
+
test('renderDailyLine:字段齐全', () => {
|
|
637
|
+
const line = D.renderDailyLine({
|
|
638
|
+
mode: 'apply',
|
|
639
|
+
run: { inputs: 60, clustersPlanned: 7, written: 3, skippedDuplicate: 4, skippedOwner: 1, skippedFamily: 2, firstSample: '仓2 会话仓的恢复路径与配额口径' },
|
|
640
|
+
cost: { calls: 3, promptTokens: 3000, completionTokens: 600 },
|
|
641
|
+
limits: D.COST_LIMITS,
|
|
642
|
+
stopping: null,
|
|
643
|
+
});
|
|
644
|
+
assert(line.includes('提炼条数 3'), '条数');
|
|
645
|
+
assert(line.includes('首行样本'), '首行样本');
|
|
646
|
+
assert(line.includes('当日调用 3/20'), '当日调用数/上限');
|
|
647
|
+
assert(line.includes('当日 token 3600/150000'), '当日 token/上限');
|
|
648
|
+
});
|
|
649
|
+
test('renderDailyLine:有停机原因时显式写明', () => {
|
|
650
|
+
const line = D.renderDailyLine({ mode: 'apply', run: {}, cost: { calls: 20 }, limits: D.COST_LIMITS, stopping: '当日调用数已达上限 20 次' });
|
|
651
|
+
assert(line.includes('停机原因:当日调用数已达上限 20 次'), '超限必须在日报里留痕');
|
|
652
|
+
});
|
|
653
|
+
test('updateDailySection:段级替换幂等(同段重写不重复堆叠)', () => {
|
|
654
|
+
const file = path.join(TMP, 'report-fixture.md');
|
|
655
|
+
fs.writeFileSync(file, '# 日报\n\n## 摘要\n\n- x\n', 'utf8');
|
|
656
|
+
D.updateDailySection({ file, line: '第一版' });
|
|
657
|
+
D.updateDailySection({ file, line: '第二版' });
|
|
658
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
659
|
+
assertEq((text.match(/## 🧪 记忆提炼/g) ?? []).length, 1, '段落唯一');
|
|
660
|
+
assert(text.includes('第二版') && !text.includes('第一版'), '内容被子替换');
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
// ── 12 端到端:台账 + 写库 + 回滚 ─────────────────────────────────────────
|
|
664
|
+
section('12 端到端(台账补充输入 → 提炼 → 消费台账 → 回滚)');
|
|
665
|
+
test('A7 场景全链路:改 text 不动 updated_at ⇒ 提炼补上 ⇒ 台账被消费', async () => {
|
|
666
|
+
const f = makeDb('e2e-dirty', [{ id: 1, text: long('原始正文:跨机同步口径'), updated_at: NOW - 72 * 3600_000 }]);
|
|
667
|
+
writeVectors(f.vectorsDb, [[1, [1, 0, 0, 0, 0, 0, 0, 0], long('原始正文:跨机同步口径')]]);
|
|
668
|
+
const db = new DatabaseSync(f.dbPath);
|
|
669
|
+
db.prepare('UPDATE memories SET text = ? WHERE id = ?').run(long('被勘误后的正文:跨机同步口径与挡推处置'), 1);
|
|
670
|
+
db.close();
|
|
671
|
+
// 该条 updated_at 是 3 天前 ⇒ 增量窗口抓不到,只有台账能补
|
|
672
|
+
const win = D.selectInputs({ dbPath: f.dbPath, now: NOW, windowHours: 24, localOwner: OWNER });
|
|
673
|
+
assertEq(win.accepted.length, 0, '增量窗口确实抓不到(A7 盲区复现)');
|
|
674
|
+
const fake = makeFakeFetch();
|
|
675
|
+
const r = await D.runDistill({ memoryDb: f.dbPath, vectorsDb: f.vectorsDb, stateFile: f.stateFile, now: NOW, apply: true, localOwner: OWNER, minClusterSize: 1, fetchImpl: fake });
|
|
676
|
+
assertEq(fake.calls(), 1, '台账把这条补进输入 ⇒ 调了 1 次');
|
|
677
|
+
assertEq(r.written, 1, '产出 1 条提炼');
|
|
678
|
+
const led = D.readDirtyIds(f.vectorsDb);
|
|
679
|
+
assertEq(led.ids, [], '台账已消费(open=0)');
|
|
680
|
+
const st = JSON.parse(fs.readFileSync(f.stateFile, 'utf8'));
|
|
681
|
+
assertEq(st.cost.calls, 1, '成本台账记账');
|
|
682
|
+
// 回滚
|
|
683
|
+
const roll = R.rollbackDistilled({ dbPath: f.dbPath, apply: true, snapshotDir: path.join(f.dir, 'snap') });
|
|
684
|
+
assertEq(roll.deleted, 1, '回滚掉提炼条');
|
|
685
|
+
assertEq(countRows(f.dbPath), 1, '真实条不受影响');
|
|
686
|
+
});
|