@modusensus/dsh-mneme 0.8.11 → 0.8.13
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/README.md +5 -5
- package/assets/icon.svg +3 -3
- package/lib/api.js +2 -0
- package/lib/client.js +12 -1
- package/lib/config.js +66 -1
- package/lib/dream/decisions.js +16 -0
- package/lib/dream/sleep.js +3 -0
- package/lib/dream.js +57 -9
- package/lib/entities/extractor.js +4 -1
- package/lib/graph/anchoring.js +180 -0
- package/lib/guide.js +13 -1
- package/lib/index.js +18 -5
- package/lib/inject.js +15 -3
- package/lib/lang.js +10 -0
- package/lib/sensitive-scan.js +209 -0
- package/lib/service.js +203 -35
- package/lib/settings.js +29 -3
- package/lib/store.js +155 -11
- package/lib/summarize.js +104 -7
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +114 -113
- package/scripts/sync-test-badge.mjs +9 -7
- package/scripts/test-count-sync.mjs +44 -0
- package/src/api.js +2 -0
- package/src/config.js +66 -1
- package/src/dream/decisions.js +16 -0
- package/src/dream/sleep.js +3 -0
- package/src/dream.js +57 -9
- package/src/entities/extractor.js +4 -1
- package/src/graph/anchoring.js +180 -0
- package/src/guide.js +13 -1
- package/src/index.js +18 -5
- package/src/inject.js +15 -3
- package/src/lang.js +10 -0
- package/src/sensitive-scan.js +209 -0
- package/src/service.js +203 -35
- package/src/settings.js +29 -3
- package/src/store.js +155 -11
- package/src/summarize.js +104 -7
- package/test/api.test.js +15 -2
- package/test/baseline-persist.test.js +248 -0
- package/test/dream.test.js +75 -0
- package/test/graph-anchoring.test.js +185 -0
- package/test/graph-inject-budget.test.js +104 -0
- package/test/graph-passive-confirm.test.js +92 -0
- package/test/graph-weight-evolution.test.js +105 -0
- package/test/inject-pools.test.js +19 -0
- package/test/scope-inject-soft.test.js +106 -0
- package/test/scope-strict.test.js +112 -0
- package/test/sensitive-scan.test.js +207 -0
- package/test/summarize.test.js +177 -0
- package/test/test-count-sync.test.js +60 -0
- package/test/write-admission.test.js +115 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
import { createStore } from "../src/store.js";
|
|
9
|
+
import { createService } from "../src/service.js";
|
|
10
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
11
|
+
|
|
12
|
+
// 回归(issue #89 基线半边):阈值基线 baseline{count,chars} 只活在调度器闭包里,
|
|
13
|
+
// 进程重启归零。库里记忆远多于阈值时,重启后第一条写入(更新类写入同样计入
|
|
14
|
+
// notifyWrite)就让 overBase 成立,绕过阈值开跑一整轮巩固——0.8.11 实测四次重启
|
|
15
|
+
// 分别只新增 6/7/0/8 条记忆(阈值 10 条 / 5000 字符),每轮约 16 万 tokens。
|
|
16
|
+
// #291 只持久化了 lastRunAt(时间闸门那半边),本文件锁另一半:
|
|
17
|
+
// 1. lastDreamBaseline 只认「成功轮落下的完整基线」,按 run_type 隔离;
|
|
18
|
+
// 2. 播种后重启不再绕过阈值——同库同写入的无种子对照组仍触发,锁的正是这个差异;
|
|
19
|
+
// 3. 播种不妨碍阈值正常累积(计数长到 +10 仍然触发);
|
|
20
|
+
// 4. runDream 成功轮把基线落库并回给调度器(进程内刷新与跨重启播种同一份数字);
|
|
21
|
+
// 5. 老库两列由幂等迁移补上,存量行认不出基线 → 退回零基线(升级前行为)。
|
|
22
|
+
|
|
23
|
+
/** 与阈值判定同口径的活跃库规模(测试侧独立实现,避免与被测代码共用一份过滤条件)。 */
|
|
24
|
+
function activeSize(store) {
|
|
25
|
+
const rows = store.all().filter((m) => !m.archived && m.type !== "summary" && m.type !== "document");
|
|
26
|
+
return {
|
|
27
|
+
count: rows.length,
|
|
28
|
+
chars: rows.reduce((sum, m) => sum + m.title.length + m.content.length, 0)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function seedMemories(service, n, tag = "a") {
|
|
33
|
+
// 标题必须全局唯一:saveWithDedupe 的去重键是 type+title+scope,同名会被合并成
|
|
34
|
+
// 同一条(条数不增长),「累计到阈值」的断言就会假失败。
|
|
35
|
+
for (let i = 0; i < n; i++) {
|
|
36
|
+
service.saveWithDedupe({ type: "history", title: `${tag}-${i}`, content: "x".repeat(300), importance: 3 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
test("issue#89: store.lastDreamBaseline reads the last baseline-advancing run only", () => {
|
|
41
|
+
const store = createStore(":memory:");
|
|
42
|
+
assert.equal(store.lastDreamBaseline(), null, "empty trail → null (never ran)");
|
|
43
|
+
// 非 ok 轮不推进基线(既有语义)→ 不落这两列,播种端必须无视它
|
|
44
|
+
store.saveDreamRun({ status: "failed", snapshot_hash: "h0", input_count: 1, receipt: "r0", created_at: "2026-09-30T10:00:00.000Z", run_type: "auto" });
|
|
45
|
+
assert.equal(store.lastDreamBaseline("auto"), null, "failed run carries no baseline");
|
|
46
|
+
store.saveDreamRun({ status: "ok", snapshot_hash: "h1", input_count: 1, receipt: "r1", created_at: "2026-09-30T10:01:00.000Z", run_type: "auto", store_count: 4, store_chars: 400 });
|
|
47
|
+
assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 });
|
|
48
|
+
// sleep/organize 的轮次不参与 auto 的阈值判定
|
|
49
|
+
store.saveDreamRun({ status: "ok", snapshot_hash: "h2", input_count: 1, receipt: "r2", created_at: "2026-09-30T10:02:00.000Z", run_type: "sleep", store_count: 9, store_chars: 900 });
|
|
50
|
+
assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 }, "sleep rows are filtered out of the auto baseline");
|
|
51
|
+
assert.deepEqual(store.lastDreamBaseline("sleep"), { count: 9, chars: 900 });
|
|
52
|
+
// 半个基线(只有条数没有字符数)写入侧就该拒绝:否则阈值会按 0 字符起算
|
|
53
|
+
store.saveDreamRun({ status: "ok", snapshot_hash: "h3", input_count: 1, receipt: "r3", created_at: "2026-09-30T10:03:00.000Z", run_type: "auto", store_count: 7 });
|
|
54
|
+
assert.deepEqual(store.lastDreamBaseline("auto"), { count: 4, chars: 400 }, "half a baseline is not accepted");
|
|
55
|
+
store.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("issue#89: a seeded baseline survives a restart — the first write no longer bypasses the threshold", async () => {
|
|
59
|
+
const store = createStore(":memory:");
|
|
60
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
61
|
+
seedMemories(service, 12); // 条数与字符数都远超阈值(12 ≥ 10 条)
|
|
62
|
+
// 上一进程的成功轮把「当时的库规模」落进审计表,然后进程重启:
|
|
63
|
+
const size = activeSize(store);
|
|
64
|
+
store.saveDreamRun({
|
|
65
|
+
status: "ok", snapshot_hash: "h", input_count: 1, receipt: "r",
|
|
66
|
+
created_at: new Date(Date.now() - 10).toISOString(), run_type: "auto",
|
|
67
|
+
store_count: size.count, store_chars: size.chars
|
|
68
|
+
});
|
|
69
|
+
const opts = { onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} } };
|
|
70
|
+
const seeded = createDreamScheduler({ ...opts, baselineSeed: store.lastDreamBaseline("auto") });
|
|
71
|
+
assert.equal(seeded.maybeSchedule(service), false, "seeded baseline blocks the first write after restart");
|
|
72
|
+
// 对照组:同一个库、同一次写入,没有种子就是被修掉的那个缺陷。若哪天播种被忽略,
|
|
73
|
+
// 上面那条断言会与这条同真同假——所以两条必须成对存在。
|
|
74
|
+
const unseeded = createDreamScheduler(opts);
|
|
75
|
+
assert.equal(unseeded.maybeSchedule(service), true, "without the seed the threshold is bypassed (the defect this test locks)");
|
|
76
|
+
await unseeded.dispose(); // 清掉 0ms 待跑计时器,别让对照组真开跑
|
|
77
|
+
store.close();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("issue#89: a seeded baseline still lets the threshold accumulate", async () => {
|
|
81
|
+
const store = createStore(":memory:");
|
|
82
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
83
|
+
seedMemories(service, 12);
|
|
84
|
+
const dream = createDreamScheduler({
|
|
85
|
+
onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0,
|
|
86
|
+
baselineSeed: activeSize(store), logger: { warn: () => {} }
|
|
87
|
+
});
|
|
88
|
+
seedMemories(service, 5, "b");
|
|
89
|
+
assert.equal(dream.maybeSchedule(service), false, "half the threshold is not enough");
|
|
90
|
+
seedMemories(service, 5, "c"); // 累计 +10 条
|
|
91
|
+
assert.equal(dream.maybeSchedule(service), true, "the threshold still fires once the store grew by it");
|
|
92
|
+
await dream.dispose();
|
|
93
|
+
store.close();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("issue#89: the run-reported baseline is what the scheduler keeps (single source of truth)", async () => {
|
|
97
|
+
const store = createStore(":memory:");
|
|
98
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
99
|
+
seedMemories(service, 12);
|
|
100
|
+
const reported = { count: 50, chars: 50000 };
|
|
101
|
+
const dream = createDreamScheduler({
|
|
102
|
+
onRun: async () => ({ ok: true, baseline: reported }),
|
|
103
|
+
thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} }
|
|
104
|
+
});
|
|
105
|
+
assert.equal(dream.maybeSchedule(service), true, "no seed yet → the first run is allowed");
|
|
106
|
+
await new Promise((r) => setTimeout(r, 10)); // 让 run 收尾并刷新基线
|
|
107
|
+
// 运行后再长 15 条:若调度器改用「现场计算」的基线(12),这里会触发;用 run
|
|
108
|
+
// 自报的 50 则不触发。这条差异就是「审计行与内存基线同一份数字」的锁。
|
|
109
|
+
seedMemories(service, 15, "grow");
|
|
110
|
+
assert.equal(dream.maybeSchedule(service), false, "the persisted-reported baseline governs, not the live store size");
|
|
111
|
+
await dream.dispose();
|
|
112
|
+
store.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("issue#89: an ok run persists its post-run store size; a failed run persists none", async () => {
|
|
116
|
+
const store = createStore(":memory:");
|
|
117
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
118
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
119
|
+
const b = service.saveWithDedupe({ type: "project", title: "旧2", content: "第二段内容" });
|
|
120
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0, logger: { warn: () => {} } });
|
|
121
|
+
|
|
122
|
+
// 失败轮(无路由):不能留下任何基线——否则重启播种会拿到一个「没推进过」的假基线
|
|
123
|
+
const failed = await dream.runDream({ llm: { stream: async function* () {} }, logger: { warn: () => {} } }, service, {});
|
|
124
|
+
assert.equal(failed.ok, false, "no route → failed run");
|
|
125
|
+
assert.equal(failed.baseline, undefined, "failed run reports no baseline");
|
|
126
|
+
assert.equal(store.lastDreamBaseline("auto"), null, "failed run persists no baseline");
|
|
127
|
+
|
|
128
|
+
// 成功轮:落库的数字 = 本轮结束后的活跃库规模(合并已归档一方,summary 不计入)
|
|
129
|
+
let calls = 0;
|
|
130
|
+
const ctx = {
|
|
131
|
+
llm: {
|
|
132
|
+
stream: async function* () {
|
|
133
|
+
calls++;
|
|
134
|
+
const text = calls === 1
|
|
135
|
+
? JSON.stringify([{ action: "merge", ids: [a.memory.id, b.memory.id], title: "合并标题", content: "合并后的内容", importance: 4, keepSource: a.memory.id }])
|
|
136
|
+
: "记忆库总览摘要文本";
|
|
137
|
+
yield { type: "text-delta", text };
|
|
138
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
logger: { warn: () => {} }
|
|
142
|
+
};
|
|
143
|
+
const ok = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
144
|
+
assert.equal(ok.ok, true, "run with decisions + summary is ok");
|
|
145
|
+
const expected = activeSize(store);
|
|
146
|
+
assert.deepEqual(ok.baseline, expected, "the run reports the post-run store size");
|
|
147
|
+
assert.deepEqual(store.lastDreamBaseline("auto"), expected, "the audit row carries the same numbers");
|
|
148
|
+
store.close();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("issue#89: legacy dream_runs gains the baseline columns on open (idempotent ALTER)", () => {
|
|
152
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-baseline-migrate-"));
|
|
153
|
+
const dbPath = join(dir, "legacy.db");
|
|
154
|
+
try {
|
|
155
|
+
const legacy = new DatabaseSync(dbPath);
|
|
156
|
+
legacy.exec(`CREATE TABLE dream_runs (
|
|
157
|
+
id TEXT PRIMARY KEY, created_at TEXT NOT NULL, status TEXT NOT NULL, error TEXT,
|
|
158
|
+
provider TEXT, model TEXT, snapshot_hash TEXT NOT NULL, input_count INTEGER NOT NULL,
|
|
159
|
+
input TEXT, decisions TEXT, outcome TEXT, applied INTEGER NOT NULL DEFAULT 0,
|
|
160
|
+
summary_stored INTEGER NOT NULL DEFAULT 0, receipt TEXT NOT NULL,
|
|
161
|
+
policy_epoch INTEGER NOT NULL DEFAULT 0, run_type TEXT NOT NULL DEFAULT 'auto', skipped TEXT
|
|
162
|
+
);`);
|
|
163
|
+
legacy.prepare(
|
|
164
|
+
`INSERT INTO dream_runs (id, created_at, status, snapshot_hash, input_count, applied, summary_stored, receipt)
|
|
165
|
+
VALUES ('legacy', '2026-09-30T10:00:00.000Z', 'ok', 'h', 1, 0, 0, 'r')`
|
|
166
|
+
).run();
|
|
167
|
+
legacy.close();
|
|
168
|
+
|
|
169
|
+
const store = createStore(dbPath);
|
|
170
|
+
const cols = store.db.prepare("PRAGMA table_info(dream_runs)").all().map((c) => c.name);
|
|
171
|
+
assert.ok(cols.includes("store_count") && cols.includes("store_chars"), "baseline columns added to legacy db");
|
|
172
|
+
// 存量行两列皆 NULL:反推不出「那轮结束时库有多大」,认不出就不认——退回零基线
|
|
173
|
+
assert.equal(store.lastDreamBaseline("auto"), null, "legacy rows carry no baseline");
|
|
174
|
+
store.close();
|
|
175
|
+
|
|
176
|
+
const again = createStore(dbPath); // 二次打开:ALTER 幂等,不抛 duplicate column
|
|
177
|
+
assert.equal(again.lastDreamBaseline("auto"), null);
|
|
178
|
+
again.close();
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(dir, { recursive: true, force: true });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("issue#89: an unusable seed is treated as no baseline at all", () => {
|
|
185
|
+
const store = createStore(":memory:");
|
|
186
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
187
|
+
seedMemories(service, 12);
|
|
188
|
+
const opts = { onRun: async () => ({ ok: false }), thresholdCount: 10, thresholdChars: 5000, delayMs: 0, logger: { warn: () => {} } };
|
|
189
|
+
// 库里的持久化值可能是老库的 NULL、或手工改坏的库:半个基线(只有条数)若被当成
|
|
190
|
+
// 「12 条 0 字符」,阈值会按 0 字符起算——比不认还糟。非法种子一律退回零基线。
|
|
191
|
+
for (const bad of [{ count: 12 }, { chars: 2000 }, { count: -1, chars: 2000 }, { count: 1.5, chars: 10 }, "nope"]) {
|
|
192
|
+
const sched = createDreamScheduler({ ...opts, baselineSeed: bad });
|
|
193
|
+
assert.equal(sched.maybeSchedule(service), true, `unusable seed ${JSON.stringify(bad)} falls back to the zero baseline`);
|
|
194
|
+
sched.dispose();
|
|
195
|
+
}
|
|
196
|
+
store.close();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("issue#89: a failing baseline read never breaks the run nor fakes a baseline", async () => {
|
|
200
|
+
const store = createStore(":memory:");
|
|
201
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
202
|
+
const a = service.saveWithDedupe({ type: "project", title: "旧1", content: "第一段内容" });
|
|
203
|
+
// finish 阶段读库失败(关库 / IO 故障):必须不反噬巩固本身(审计行照写),也不
|
|
204
|
+
// 写一个猜出来的基线(落 NULL → 下次重启退回零基线,而不是拿假基线抬高阈值)。
|
|
205
|
+
// 故障点在「总览出文本之后」才武装:runDream 前面还要读两次库(候选集与总览输入),
|
|
206
|
+
// 过早抛错会把整轮打成 failed,验的就不是收尾取证这条路了。
|
|
207
|
+
const realAll = service.all.bind(service);
|
|
208
|
+
let armed = false;
|
|
209
|
+
let calls = 0;
|
|
210
|
+
service.all = () => {
|
|
211
|
+
if (armed) throw new Error("store closed");
|
|
212
|
+
return realAll();
|
|
213
|
+
};
|
|
214
|
+
const ctx = {
|
|
215
|
+
llm: {
|
|
216
|
+
stream: async function* () {
|
|
217
|
+
if (calls++ === 0) {
|
|
218
|
+
yield { type: "text-delta", text: JSON.stringify([{ action: "keep", ids: [a.memory.id] }]) };
|
|
219
|
+
} else {
|
|
220
|
+
armed = true;
|
|
221
|
+
yield { type: "text-delta", text: "记忆库总览摘要文本" };
|
|
222
|
+
}
|
|
223
|
+
yield { type: "finish", reason: { kind: "ok" } };
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
logger: { warn: () => {} }
|
|
227
|
+
};
|
|
228
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0, logger: { warn: () => {} } });
|
|
229
|
+
const ok = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
230
|
+
assert.equal(ok.ok, true, "the run still completes when the baseline read fails");
|
|
231
|
+
assert.equal(ok.baseline, undefined, "no made-up baseline is reported");
|
|
232
|
+
assert.ok(store.listDreamRuns({ limit: 1 })[0], "the audit row is still written");
|
|
233
|
+
assert.equal(store.listDreamRuns({ limit: 1 })[0].store_count, undefined, "the audit row carries no baseline");
|
|
234
|
+
store.close();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// --- CodeRabbit 在 #291 上的同类要求:index.js 的 seed 是单行无分支接线,仓库没有
|
|
238
|
+
// 插件级挂载 harness(apply 需要完整宿主 ctx),改为源码级锁——删掉该注入行即红。
|
|
239
|
+
// import 用 URL 相对本文件解析,不依赖测试进程 CWD。
|
|
240
|
+
const indexSrc = readFileSync(fileURLToPath(new URL("../src/index.js", import.meta.url)), "utf8");
|
|
241
|
+
|
|
242
|
+
test("issue#89: index.js wires the dream baseline seed from the audit trail", () => {
|
|
243
|
+
assert.match(
|
|
244
|
+
indexSrc,
|
|
245
|
+
/baselineSeed:\s*store\.lastDreamBaseline\("auto"\)/,
|
|
246
|
+
"dream scheduler must seed its threshold baseline from dream_runs (run_type='auto') — remove this line and the restart-bypass returns"
|
|
247
|
+
);
|
|
248
|
+
});
|
package/test/dream.test.js
CHANGED
|
@@ -1306,3 +1306,78 @@ test("issue#258: dreamSummaryMaxInputs caps summary inputs to the newest N (0 =
|
|
|
1306
1306
|
assert.match(overview().content, /整理后 5 条/, "uncapped run covers the whole library");
|
|
1307
1307
|
store.close();
|
|
1308
1308
|
});
|
|
1309
|
+
|
|
1310
|
+
// --- issue #339 / E8 考卷:merge 护栏(dreamMergeGuard,opt-in)---------------
|
|
1311
|
+
// E8 实测巩固损耗里 10/26 条被丢约束已归位 guarded 类型仍被 merge 吃掉——
|
|
1312
|
+
// archive 护栏只挡 archive 不挡 merge。开启后被合并对象命中 ARCHIVE_GUARDED_TYPES
|
|
1313
|
+
// 的 merge 决策走与 archive 护栏同款通道:skipInvalid 时 skipped、严格时整单拒绝。
|
|
1314
|
+
|
|
1315
|
+
test("validateDecisions mergeGuard: guarded-type merge is skipped under skipInvalid", () => {
|
|
1316
|
+
const snap = new Map([
|
|
1317
|
+
["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
|
|
1318
|
+
["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }],
|
|
1319
|
+
["h1", { id: "h1", type: "history", title: "旧事", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
1320
|
+
["h2", { id: "h2", type: "history", title: "旧事(近似)", content: "内容2", importance: 3, archived: false, forgotten: false }]
|
|
1321
|
+
]);
|
|
1322
|
+
const decisions = [
|
|
1323
|
+
{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "$12,400 上下", importance: 4 },
|
|
1324
|
+
{ action: "merge", ids: ["h1", "h2"], keepSource: "h1", title: "旧事合并", content: "合并内容", importance: 3 }
|
|
1325
|
+
];
|
|
1326
|
+
const { ok, skipped } = validateDecisions(decisions, snap, { skipInvalid: true, mergeGuard: true });
|
|
1327
|
+
assert.equal(ok, true, "history merge survives, guarded merge skipped");
|
|
1328
|
+
assert.equal(skipped.length, 1);
|
|
1329
|
+
assert.match(skipped[0].error, /dreamMergeGuard/, "skip reason names the guard");
|
|
1330
|
+
assert.match(skipped[0].error, /constraint/, "skip reason names the guarded type");
|
|
1331
|
+
// guarded merge 被 splice 掉,history merge 与两条隐式 keep 存活。
|
|
1332
|
+
assert.deepEqual(decisions.map((d) => d.action), ["merge", "keep", "keep"]);
|
|
1333
|
+
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("c1")), "c1 auto-kept");
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
test("validateDecisions mergeGuard: strict mode rejects the whole batch", () => {
|
|
1337
|
+
const guardedSnap = new Map([
|
|
1338
|
+
["g1", { id: "g1", type: "pitfall", title: "坑", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
1339
|
+
["g2", { id: "g2", type: "pitfall", title: "坑2", content: "内容2", importance: 3, archived: false, forgotten: false }]
|
|
1340
|
+
]);
|
|
1341
|
+
const strict = validateDecisions(
|
|
1342
|
+
[{ action: "merge", ids: ["g1", "g2"], keepSource: "g1", title: "t", content: "c" }],
|
|
1343
|
+
guardedSnap,
|
|
1344
|
+
{ skipInvalid: false, mergeGuard: true }
|
|
1345
|
+
);
|
|
1346
|
+
assert.equal(strict.ok, false, "strict mode rejects guarded merge");
|
|
1347
|
+
assert.ok(strict.errors.some((e) => /dreamMergeGuard/.test(e)));
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
test("validateDecisions mergeGuard: non-guarded types unaffected, guard off = legacy behavior", () => {
|
|
1351
|
+
const snap = new Map([
|
|
1352
|
+
["c1", { id: "c1", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
|
|
1353
|
+
["c2", { id: "c2", type: "constraint", title: "预算(疑似重复)", content: "$12,450", importance: 4, archived: false, forgotten: false }]
|
|
1354
|
+
]);
|
|
1355
|
+
const decisions = [{ action: "merge", ids: ["c1", "c2"], keepSource: "c1", title: "预算合并", content: "合并", importance: 4 }];
|
|
1356
|
+
// guard 关 = 现行为(merge 照常通过)。
|
|
1357
|
+
assert.equal(validateDecisions(decisions, snap, { skipInvalid: true }).ok, true);
|
|
1358
|
+
// guard 开但类型非 guarded(history 用 snapshot 默认 type=project 亦非 guarded)。
|
|
1359
|
+
const plainSnap = snapshot(["x", "y"]);
|
|
1360
|
+
assert.equal(
|
|
1361
|
+
validateDecisions(
|
|
1362
|
+
[{ action: "merge", ids: ["x", "y"], keepSource: "x", title: "t", content: "c" }],
|
|
1363
|
+
plainSnap,
|
|
1364
|
+
{ skipInvalid: true, mergeGuard: true }
|
|
1365
|
+
).ok,
|
|
1366
|
+
true,
|
|
1367
|
+
"non-guarded merge passes under guard"
|
|
1368
|
+
);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test("validateDecisions archive guard: long-retention type without duplicate/outdated rationale is skipped (prior gap)", () => {
|
|
1372
|
+
const snap = new Map([
|
|
1373
|
+
["c", { id: "c", type: "constraint", title: "预算", content: "$12,400", importance: 4, archived: false, forgotten: false }],
|
|
1374
|
+
["p", { id: "p", type: "project", title: "项目", content: "内容", importance: 3, archived: false, forgotten: false }]
|
|
1375
|
+
]);
|
|
1376
|
+
const { ok, skipped } = validateDecisions([
|
|
1377
|
+
{ action: "archive", ids: ["c"], reason: "keeps the store tidy" },
|
|
1378
|
+
{ action: "archive", ids: ["p"], reason: "outdated" }
|
|
1379
|
+
], snap, { skipInvalid: true });
|
|
1380
|
+
assert.equal(ok, true, "project archive with stale rationale survives");
|
|
1381
|
+
assert.equal(skipped.length, 1, "constraint archive without rationale skipped");
|
|
1382
|
+
assert.match(skipped[0].error, /long-retention type/);
|
|
1383
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// issue #24 块1 图谱锚定层(graphAnchoringEnabled)测试。
|
|
2
|
+
// 覆盖:默认关=单跳轴行为逐字节不变 / 开=级联扩散邻居挂联记忆进融合池 /
|
|
3
|
+
// 邻居分 = hop 配额 × tier(attr 1.0 > relation 0.9)/ 种子直达分不被邻居覆盖 /
|
|
4
|
+
// 级联深度上界 / graphSeedCap 收敛邻居总量 / 空关系表降级 / lightMode 关闭配置。
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { createStore } from "../src/store.js";
|
|
8
|
+
import { createService } from "../src/service.js";
|
|
9
|
+
import { HOP_QUOTA } from "../src/graph/anchoring.js";
|
|
10
|
+
|
|
11
|
+
function makeService(config = {}) {
|
|
12
|
+
const store = createStore(":memory:");
|
|
13
|
+
const service = createService({ store, mirror: null, config });
|
|
14
|
+
return { store, service };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function putMemory(store, service, { type = "project", title, content, importance = 3 }) {
|
|
18
|
+
return service.saveWithDedupe({ type, title, content, importance }).memory;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function linkAttr(store, service, { entityName, memory, key = "k", value = "v", type = "technology" }) {
|
|
22
|
+
let entity = store.findEntityByName(entityName);
|
|
23
|
+
if (!entity) entity = store.createEntity({ name: entityName, type });
|
|
24
|
+
store.saveAttr({ entity_id: entity.id, attr_key: key, attr_value: value, memory_id: memory.id });
|
|
25
|
+
return entity;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function linkRelation(store, { from, to, relType = "related", memoryId, type = "technology" }) {
|
|
29
|
+
const a = store.findEntityByName(from) ?? store.createEntity({ name: from, type });
|
|
30
|
+
const b = store.findEntityByName(to) ?? store.createEntity({ name: to, type });
|
|
31
|
+
store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: relType, memory_id: memoryId });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ============================================================ 默认关(回归红线)
|
|
35
|
+
|
|
36
|
+
test("graphAnchoringEnabled=false (default): neighbor-linked memory NOT recalled, seed-only preserved", async () => {
|
|
37
|
+
const { store, service } = makeService({ entityRecallEnabled: true });
|
|
38
|
+
// 种子实体 PostgreSQL 挂一条记忆;关系边连到 PgBouncer(不挂记忆)。
|
|
39
|
+
const seedMem = putMemory(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
40
|
+
linkAttr(store, service, { entityName: "PostgreSQL", memory: seedMem, key: "version", value: "16" });
|
|
41
|
+
// 邻居实体 + 它自己挂的记忆:必须不被默认关猜中。
|
|
42
|
+
const neighborMem = putMemory(store, service, { title: "连接池笔记", content: "PgBouncer 池调优记录" });
|
|
43
|
+
const neigh = linkAttr(store, service, { entityName: "PgBouncer", memory: neighborMem, key: "mode", value: "pool" });
|
|
44
|
+
// 关系:PostgreSQL —uses→ PgBouncer(种子 → 邻居),不直接挂 seedMem。
|
|
45
|
+
const a = store.findEntityByName("PostgreSQL");
|
|
46
|
+
linkRelation(store, { from: a.name, to: neigh.name, relType: "uses", memoryId: seedMem.id });
|
|
47
|
+
|
|
48
|
+
const rows = await service.searchMemories("PostgreSQL 现状", { mode: "auto" });
|
|
49
|
+
const ids = rows.map((r) => r.id);
|
|
50
|
+
assert.ok(ids.includes(seedMem.id), "seed-linked memory present");
|
|
51
|
+
assert.ok(!ids.includes(neighborMem.id), "neighbor memory absent when anchoring off");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ============================================================ 开:级联扩散
|
|
55
|
+
|
|
56
|
+
test("graphAnchoringEnabled=true: neighbor-linked memory joins via cascade, seed keeps full tier score", async () => {
|
|
57
|
+
const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
|
|
58
|
+
const seedMem = putMemory(store, service, { title: "数据库迁移", content: "主库切换完成" });
|
|
59
|
+
linkAttr(store, service, { entityName: "PostgreSQL", memory: seedMem, key: "version", value: "16" });
|
|
60
|
+
const neighborMem = putMemory(store, service, { title: "连接池笔记", content: "PgBouncer 池调优记录" });
|
|
61
|
+
const neigh = linkAttr(store, service, { entityName: "PgBouncer", memory: neighborMem, key: "mode", value: "pool" });
|
|
62
|
+
const a = store.findEntityByName("PostgreSQL");
|
|
63
|
+
linkRelation(store, { from: a.name, to: neigh.name, relType: "uses", memoryId: seedMem.id });
|
|
64
|
+
|
|
65
|
+
// hybrid blend 走加权合算,便于精确断言 entity 轴分(blend: + we·score)。
|
|
66
|
+
const rows = await service.searchMemories("PostgreSQL 现状", { mode: "hybrid" });
|
|
67
|
+
const ids = rows.map((r) => r.id);
|
|
68
|
+
assert.ok(ids.includes(seedMem.id), "seed memory present");
|
|
69
|
+
assert.ok(ids.includes(neighborMem.id), "neighbor memory present via cascade");
|
|
70
|
+
// 种子 attr 1.0 × we(0.3) = 0.3;邻居 attr 1.0 × HOP_QUOTA[1](0.5) × we = 0.15。
|
|
71
|
+
const seedRow = rows.find((r) => r.id === seedMem.id);
|
|
72
|
+
const nbRow = rows.find((r) => r.id === neighborMem.id);
|
|
73
|
+
assert.ok(Math.abs(seedRow.score - 0.3) < 1e-9, "seed keeps full tier score");
|
|
74
|
+
assert.ok(Math.abs(nbRow.score - 0.3 * HOP_QUOTA[1]) < 1e-9, "neighbor hop-weighted");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("cascade respects hop-quota ordering: 1-hop neighbors score above 2-hop", async () => {
|
|
78
|
+
const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
|
|
79
|
+
const seedMem = putMemory(store, service, { title: "根服务", content: "服务 A 上线" });
|
|
80
|
+
linkAttr(store, service, { entityName: "ServiceA", memory: seedMem, key: "env", value: "prod" });
|
|
81
|
+
const hop1Mem = putMemory(store, service, { title: "链路一", content: "ServiceB 依赖记录" });
|
|
82
|
+
const hop2Mem = putMemory(store, service, { title: "链路二", content: "ServiceC 间接关联" });
|
|
83
|
+
const b = linkAttr(store, service, { entityName: "ServiceB", memory: hop1Mem, key: "tier", value: "edge" });
|
|
84
|
+
const c = linkAttr(store, service, { entityName: "ServiceC", memory: hop2Mem, key: "tier", value: "leaf" });
|
|
85
|
+
const a = store.findEntityByName("ServiceA");
|
|
86
|
+
// A—B 关系(hop1);B—C 关系(hop2)。
|
|
87
|
+
linkRelation(store, { from: a.name, to: b.name, relType: "depends", memoryId: hop1Mem.id });
|
|
88
|
+
linkRelation(store, { from: b.name, to: c.name, relType: "depends", memoryId: hop2Mem.id });
|
|
89
|
+
|
|
90
|
+
const rows = await service.searchMemories("ServiceA", { mode: "auto" });
|
|
91
|
+
const ids = rows.map((r) => r.id);
|
|
92
|
+
assert.ok(ids.includes(hop1Mem.id) && ids.includes(hop2Mem.id), "both hops recalled");
|
|
93
|
+
const h1 = rows.find((r) => r.id === hop1Mem.id);
|
|
94
|
+
const h2 = rows.find((r) => r.id === hop2Mem.id);
|
|
95
|
+
assert.ok(h1.score > h2.score, `1-hop (${h1.score}) scores above 2-hop (${h2.score})`);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("empty relation table degrades cleanly (still returns seed memories)", async () => {
|
|
99
|
+
const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
|
|
100
|
+
const seedMem = putMemory(store, service, { title: "独立条", content: "单独存在" });
|
|
101
|
+
linkAttr(store, service, { entityName: "Standalone", memory: seedMem, key: "k", value: "v" });
|
|
102
|
+
const rows = await service.searchMemories("Standalone", { mode: "auto" });
|
|
103
|
+
assert.ok(rows.some((r) => r.id === seedMem.id), "seed memory survives with no relations");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ============================================================ 配置与配额
|
|
107
|
+
|
|
108
|
+
test("graphSeedCap bounds neighbor expansion (small cap cuts deeper hops)", async () => {
|
|
109
|
+
const { store, service } = makeService({
|
|
110
|
+
entityRecallEnabled: true, graphAnchoringEnabled: true, graphSeedCap: 1, graphCascadeDepth: 3
|
|
111
|
+
});
|
|
112
|
+
const seedMem = putMemory(store, service, { title: "根", content: "入口" });
|
|
113
|
+
const hop1Mem = putMemory(store, service, { title: "一层", content: "近邻" });
|
|
114
|
+
const hop2Mem = putMemory(store, service, { title: "二层", content: "远邻" });
|
|
115
|
+
const b = linkAttr(store, service, { entityName: "NodeB", memory: hop1Mem, key: "k", value: "v" });
|
|
116
|
+
const c = linkAttr(store, service, { entityName: "NodeC", memory: hop2Mem, key: "k", value: "v" });
|
|
117
|
+
const a = store.findEntityByName("入口占位"); // 无;用 NodeA
|
|
118
|
+
const nodeA = store.createEntity({ name: "NodeA", type: "technology" });
|
|
119
|
+
store.saveRelation({ from_entity: nodeA.id, to_entity: b.id, relation_type: "x", memory_id: seedMem.id });
|
|
120
|
+
store.saveRelation({ from_entity: b.id, to_entity: c.id, relation_type: "x", memory_id: hop1Mem.id });
|
|
121
|
+
|
|
122
|
+
const rows = await service.searchMemories("NodeA", { mode: "auto" });
|
|
123
|
+
const ids = rows.map((r) => r.id).sort();
|
|
124
|
+
// cap=1 → 配额 floor(1*0.5)=0 → max(1,…) 兜底只进 1 个 1-hop;2-hop 进不来。
|
|
125
|
+
assert.ok(ids.includes(hop1Mem.id), "1-hop fits within cap");
|
|
126
|
+
assert.ok(!ids.includes(hop2Mem.id), "2-hop cut by small cap");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("the anchoring pure module is self-checked", () => {
|
|
130
|
+
// 模块自带 _selfCheck 已在模块加载时断言;这里再锚定导出契约,防重构断链。
|
|
131
|
+
assert.ok(typeof HOP_QUOTA[1] === "number" && HOP_QUOTA[1] > HOP_QUOTA[2]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ---- #24 复审回归(review findings 逐条落锁)--------------------------------
|
|
135
|
+
|
|
136
|
+
// 关档逐字节复用 #219 单跳轴:graphSeedCap 只在锚定开启后生效。种子裁剪若漏进
|
|
137
|
+
// 关档路径,cap=1 时第二个命中实体整支挂联记忆都会消失——这条专门钉死它。
|
|
138
|
+
test("anchoring off: graphSeedCap does not cap the legacy single-hop axis", async () => {
|
|
139
|
+
const { store, service } = makeService({ entityRecallEnabled: true, graphSeedCap: 1 });
|
|
140
|
+
const memA = putMemory(store, service, { title: "迁移记录", content: "主库切换完成" });
|
|
141
|
+
const memB = putMemory(store, service, { title: "池化记录", content: "连接池调优" });
|
|
142
|
+
linkAttr(store, service, { entityName: "Alpha", memory: memA, key: "k", value: "v" });
|
|
143
|
+
linkAttr(store, service, { entityName: "Beta", memory: memB, key: "k", value: "v" });
|
|
144
|
+
|
|
145
|
+
const rows = await service.searchMemories("Alpha Beta", { mode: "auto" });
|
|
146
|
+
const ids = rows.map((r) => r.id);
|
|
147
|
+
assert.ok(ids.includes(memA.id), "first seed-linked memory present");
|
|
148
|
+
assert.ok(ids.includes(memB.id), "second seed-linked memory must survive when anchoring is off");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// 级联要真走出 >1 跳:邻接若只带种子那一层,2-hop 实体挂联的记忆根本不进池。
|
|
152
|
+
// 本地图上 2-hop 记忆只经 AnchorC 可达(两条关系行的 memory_id 都留空,堵死
|
|
153
|
+
// 「1-hop 关系行旁路」),所以这条能红着抓回归。
|
|
154
|
+
test("cascade reaches 2 hops (lazy per-node adjacency, deeper hop weighted lower)", async () => {
|
|
155
|
+
const { store, service } = makeService({
|
|
156
|
+
entityRecallEnabled: true, graphAnchoringEnabled: true, graphCascadeDepth: 2
|
|
157
|
+
});
|
|
158
|
+
const seedMem = putMemory(store, service, { title: "根服务", content: "入口记录" });
|
|
159
|
+
const hop2Mem = putMemory(store, service, { title: "远环", content: "间接可达" });
|
|
160
|
+
const a = linkAttr(store, service, { entityName: "AnchorA", memory: seedMem, key: "k", value: "v" });
|
|
161
|
+
const c = linkAttr(store, service, { entityName: "AnchorC", memory: hop2Mem, key: "k", value: "v" });
|
|
162
|
+
const b = store.createEntity({ name: "AnchorB", type: "technology" });
|
|
163
|
+
store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x" });
|
|
164
|
+
store.saveRelation({ from_entity: b.id, to_entity: c.id, relation_type: "x" });
|
|
165
|
+
|
|
166
|
+
const rows = await service.searchMemories("AnchorA", { mode: "hybrid" });
|
|
167
|
+
const ids = rows.map((r) => r.id);
|
|
168
|
+
assert.ok(ids.includes(seedMem.id), "seed memory present");
|
|
169
|
+
assert.ok(ids.includes(hop2Mem.id), "2-hop linked memory reached via cascade");
|
|
170
|
+
const h2 = rows.find((r) => r.id === hop2Mem.id);
|
|
171
|
+
assert.ok(Math.abs(h2.score - 0.3 * HOP_QUOTA[2]) < 1e-9, `2-hop keeps hop-quota weighting, got ${h2.score}`);
|
|
172
|
+
store.close();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 邻接查询两个 IN 组都要绑定:关系方向写成 X→种子 时,种子同样应看到 X 是它的
|
|
176
|
+
// 邻居(只绑第一组时这类行整批消失,级联方向性丢失)。
|
|
177
|
+
test("getEntityNeighbors sees both directions of a relation", () => {
|
|
178
|
+
const store = createStore(":memory:");
|
|
179
|
+
const a = store.createEntity({ name: "Seed", type: "technology" });
|
|
180
|
+
const b = store.createEntity({ name: "Other", type: "technology" });
|
|
181
|
+
store.saveRelation({ from_entity: b.id, to_entity: a.id, relation_type: "x" });
|
|
182
|
+
const nb = store.getEntityNeighbors([a.id]);
|
|
183
|
+
assert.deepEqual(nb.get(a.id), [b.id], "seed must see the reverse-direction neighbor");
|
|
184
|
+
store.close();
|
|
185
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// issue #24 块3 关联提示防幻觉(graphInjectHint / graphInjectBudget)测试。
|
|
2
|
+
// 覆盖:图候选打标 graphHint / 保守档(默认关)线索行不进注入块 / 开档受独立
|
|
3
|
+
// 预算 graphInjectBudget 约束且不挤占 maxItems / [检索线索] 引导语与线索行文本。
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
import { createService } from "../src/service.js";
|
|
8
|
+
import { STR, langOf } from "../src/lang.js";
|
|
9
|
+
|
|
10
|
+
function setup(config = {}) {
|
|
11
|
+
const store = createStore(":memory:");
|
|
12
|
+
const service = createService({ store, mirror: null, config });
|
|
13
|
+
return { store, service };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function linkEntityMem(store, service, { name, title, content, key = "k", value = "v", type = "technology" }) {
|
|
17
|
+
const mem = service.saveWithDedupe({ type: "project", title, content, importance: 3 }).memory;
|
|
18
|
+
let ent = store.findEntityByName(name);
|
|
19
|
+
if (!ent) ent = store.createEntity({ name, type });
|
|
20
|
+
store.saveAttr({ entity_id: ent.id, attr_key: key, attr_value: value, memory_id: mem.id });
|
|
21
|
+
return mem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("graphHint candidates are tagged when entityRecallEnabled, and do NOT enter the block by default (conservative)", () => {
|
|
25
|
+
const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5 });
|
|
26
|
+
// 一条与查询无关的普通记忆(避免规则路占用),一条实体挂联记忆。
|
|
27
|
+
linkEntityMem(store, service, {
|
|
28
|
+
name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成,复制延迟归零"
|
|
29
|
+
});
|
|
30
|
+
service.saveWithDedupe({ type: "decision", title: "无关决策", content: "今天天气不错", importance: 4 });
|
|
31
|
+
const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" });
|
|
32
|
+
// 保守档默认 graphInjectHint=false:图线索行被 general 选取跳过——
|
|
33
|
+
// 候选池里能查到 graphHint 标记,但注入终集会排除它。
|
|
34
|
+
assert.ok(selected.every((m) => m.graphHint !== true), "conservative: no graph hint rows in the injected set");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("graphInjectHint=true admits graph hint rows under independent budget", () => {
|
|
38
|
+
const { store, service } = setup({
|
|
39
|
+
entityRecallEnabled: true, maxInjectedItems: 5, graphInjectHint: true, graphInjectBudget: 2
|
|
40
|
+
});
|
|
41
|
+
// 两条实体挂联记忆(都命中查询实体),一条普通规则记忆。
|
|
42
|
+
linkEntityMem(store, service, { name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成", key: "k1" });
|
|
43
|
+
linkEntityMem(store, service, { name: "PostgreSQL", title: "连接池调优", content: "PgBouncer 配置", key: "k2" });
|
|
44
|
+
service.saveWithDedupe({ type: "decision", title: "无关决策", content: "其他内容", importance: 5 });
|
|
45
|
+
const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" });
|
|
46
|
+
const hints = selected.filter((m) => m.graphHint === true);
|
|
47
|
+
assert.equal(hints.length, 2, "both graph hint rows fit within budget 2");
|
|
48
|
+
// 普通规则记忆仍在(graph 行不挤占 maxItems 槽)。
|
|
49
|
+
assert.ok(selected.some((m) => !m.graphHint), "non-graph rows still present");
|
|
50
|
+
store.close();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("graphInjectBudget caps graph hint rows even when more match the query", () => {
|
|
54
|
+
const { store, service } = setup({
|
|
55
|
+
entityRecallEnabled: true, maxInjectedItems: 10, graphInjectHint: true, graphInjectBudget: 1
|
|
56
|
+
});
|
|
57
|
+
// 三条候选实体挂联记忆,预算只有 1。
|
|
58
|
+
for (const i of [1, 2, 3]) {
|
|
59
|
+
linkEntityMem(store, service, { name: "PostgreSQL", title: `PG 记录 ${i}`, content: `内容 ${i}`, key: `k${i}` });
|
|
60
|
+
}
|
|
61
|
+
const selected = service.injectCandidates({ maxItems: 10 });
|
|
62
|
+
const hints = selected.filter((m) => m.graphHint === true);
|
|
63
|
+
assert.ok(hints.length <= 1, `budget 1 caps at 1, got ${hints.length}`);
|
|
64
|
+
store.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("graphHint=false (default) + entityRecallEnabled returns zero candidate set change (conservative = sorting only)", () => {
|
|
68
|
+
const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5 });
|
|
69
|
+
// 仅实体挂联记忆,保守档下不该有任何候选进块。
|
|
70
|
+
linkEntityMem(store, service, { name: "PostgreSQL", title: "数据库迁移", content: "主库切换完成" });
|
|
71
|
+
const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL 现状" });
|
|
72
|
+
assert.equal(selected.filter((m) => m.graphHint).length, 0);
|
|
73
|
+
store.close();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("[检索线索] 引导语与线索行文本渲染正确(zh/en 双语)", () => {
|
|
77
|
+
assert.ok(STR.graphHintHeader.zh.includes("检索线索"));
|
|
78
|
+
assert.ok(STR.graphHintHeader.zh.includes("非事实断言"));
|
|
79
|
+
assert.ok(STR.graphHintHeader.en.includes("Retrieval hints"));
|
|
80
|
+
assert.ok(STR.graphHintHeader.en.toLowerCase().includes("not factual"));
|
|
81
|
+
const lineZh = STR.graphHintLine.zh("entity", "某某", "内容");
|
|
82
|
+
assert.ok(lineZh.startsWith("- [线索/entity]"));
|
|
83
|
+
const lineEn = STR.graphHintLine.en("technology", "Foo", "bar");
|
|
84
|
+
assert.ok(lineEn.startsWith("- [hint/technology]"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ---- 复审回归 ---------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
// 保守档(graphInjectHint 关)下线索行不许从 pin 池侧漏进注入块:pin 池取自打标
|
|
90
|
+
// 之后、又不占 maxItems 名额,pin 若不排除 graphHint 行,「图召回 + 约束型记忆」
|
|
91
|
+
// 就会在默认关档下照常进常驻注入文本,把线索开关整个绕过去。
|
|
92
|
+
test("conservative mode: a graph hint row cannot leak in via the pin pool", () => {
|
|
93
|
+
const { store, service } = setup({ entityRecallEnabled: true, maxInjectedItems: 5, pinnedInjectBudget: 1 });
|
|
94
|
+
// 约束型记忆 + 命中查询实体:同时是 pin 候选与图线索候选(最坏交叉)。
|
|
95
|
+
const mem = service.saveWithDedupe({
|
|
96
|
+
type: "constraint", title: "部署约束", content: "生产环境不许直接改库", importance: 4
|
|
97
|
+
}).memory;
|
|
98
|
+
const ent = store.createEntity({ name: "PostgreSQL", type: "technology" });
|
|
99
|
+
store.saveAttr({ entity_id: ent.id, attr_key: "k", attr_value: "v", memory_id: mem.id });
|
|
100
|
+
|
|
101
|
+
const selected = service.injectCandidates({ maxItems: 5, query: "PostgreSQL" });
|
|
102
|
+
assert.equal(selected.filter((m) => m.graphHint === true).length, 0, "hint rows stay out while graphInjectHint is off");
|
|
103
|
+
store.close();
|
|
104
|
+
});
|