@modusensus/dsh-mneme 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +5 -5
  2. package/assets/icon.svg +3 -0
  3. package/lib/client.js +190 -58
  4. package/lib/config.js +47 -1
  5. package/lib/dream/decisions.js +16 -0
  6. package/lib/dream/sleep.js +3 -0
  7. package/lib/dream.js +57 -9
  8. package/lib/entities/extractor.js +4 -1
  9. package/lib/graph/anchoring.js +180 -0
  10. package/lib/index.js +5 -0
  11. package/lib/inject.js +15 -3
  12. package/lib/lang.js +10 -0
  13. package/lib/service.js +164 -23
  14. package/lib/settings.js +24 -2
  15. package/lib/store.js +155 -11
  16. package/lib/summarize.js +104 -7
  17. package/locale/en.json +6 -0
  18. package/locale/zh.json +6 -0
  19. package/package.json +12 -7
  20. package/scripts/sync-test-badge.mjs +9 -7
  21. package/scripts/test-count-sync.mjs +44 -0
  22. package/src/config.js +47 -1
  23. package/src/dream/decisions.js +16 -0
  24. package/src/dream/sleep.js +3 -0
  25. package/src/dream.js +57 -9
  26. package/src/entities/extractor.js +4 -1
  27. package/src/graph/anchoring.js +180 -0
  28. package/src/index.js +5 -0
  29. package/src/inject.js +15 -3
  30. package/src/lang.js +10 -0
  31. package/src/service.js +164 -23
  32. package/src/settings.js +24 -2
  33. package/src/store.js +155 -11
  34. package/src/summarize.js +104 -7
  35. package/test/api.test.js +7 -2
  36. package/test/baseline-persist.test.js +248 -0
  37. package/test/client.test.js +245 -45
  38. package/test/dream.test.js +75 -0
  39. package/test/graph-anchoring.test.js +185 -0
  40. package/test/graph-inject-budget.test.js +104 -0
  41. package/test/graph-passive-confirm.test.js +92 -0
  42. package/test/graph-weight-evolution.test.js +105 -0
  43. package/test/scope-inject-soft.test.js +106 -0
  44. package/test/summarize.test.js +177 -0
  45. package/test/test-count-sync.test.js +60 -0
@@ -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
+ });
@@ -0,0 +1,92 @@
1
+ // issue #24 块4 被动确认(graphPassiveConfirm)测试。
2
+ // 覆盖:默认关=零行为变化 / 开启=命中记忆的关联边 bump / heat 关但 passive
3
+ // 开仍 bump(独立闸门)/ bump 只加不减封顶 / 无关联边的记忆触达安全降级。
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
+
9
+ function setup(config = {}) {
10
+ const store = createStore(":memory:");
11
+ const service = createService({ store, mirror: null, config });
12
+ return { store, service };
13
+ }
14
+
15
+ function putMem(store, service, { title, content, importance = 3 }) {
16
+ return service.saveWithDedupe({ type: "project", title, content, importance }).memory;
17
+ }
18
+
19
+ function linkRelToMem(store, { fromName, toName, memoryId, source = "llm" }) {
20
+ const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type: "technology" });
21
+ const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type: "technology" });
22
+ return store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", memory_id: memoryId, source });
23
+ }
24
+
25
+ /** 跑一次检索(内部 touchRecalled 触发触达),返回 rows。 */
26
+ async function searchOnce(service, query) {
27
+ return service.searchMemories(query, { mode: "auto" });
28
+ }
29
+
30
+ test("graphPassiveConfirm=false (default): touched memory does NOT bump relation weight", async () => {
31
+ const { store, service } = setup();
32
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
33
+ const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
34
+ assert.equal(rel.weight, 0.4);
35
+ await searchOnce(service, "数据库迁移");
36
+ // 默认关:检索触达不 bump(weight 保持 0.4)。
37
+ assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "weight unchanged by default");
38
+ store.close();
39
+ });
40
+
41
+ test("graphPassiveConfirm=true: touched memory bumps its linked relation weight", async () => {
42
+ const { store, service } = setup({ graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
43
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
44
+ const rel = linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
45
+ assert.equal(rel.weight, 0.4);
46
+ const rows = await searchOnce(service, "数据库迁移");
47
+ assert.ok(rows.some((r) => r.id === mem.id), "memory surfaced");
48
+ assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "weight bumped after touch");
49
+ store.close();
50
+ });
51
+
52
+ test("heatEnabled=false does NOT gate passive confirm (independent gates)", async () => {
53
+ const { store, service } = setup({ heatEnabled: false, graphWeightEnabled: true, graphPassiveConfirm: true, graphWeightDelta: 0.1 });
54
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
55
+ linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
56
+ await searchOnce(service, "数据库迁移");
57
+ assert.ok(store.getRelationsByMemory(mem.id)[0].weight > 0.4, "bumped even with heat off");
58
+ // 但 touchLastAccess 不写(heat 关的既有语义保留:last_accessed_at 仍为空)。
59
+ assert.equal(store.getById(mem.id).last_accessed_at, undefined);
60
+ store.close();
61
+ });
62
+
63
+ test("repeated touches cap at 1.0 (never exceeds)", () => {
64
+ const { store } = setup();
65
+ const a = store.createEntity({ name: "A", type: "technology" });
66
+ const b = store.createEntity({ name: "B", type: "technology" });
67
+ const rel = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" }); // 0.4
68
+ for (let i = 0; i < 5; i++) {
69
+ store.bumpRelationWeight(rel.id, 0.3); // 0.4→0.7→1.0→1.0…
70
+ }
71
+ assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped at 1.0");
72
+ store.close();
73
+ });
74
+
75
+ // 总闸(块2 的 graphWeightEnabled)关着时被动确认不许生效——单开通道键就能演化
76
+ // 边权的话,块2 立的「演化默认关」总闸形同虚设。
77
+ test("graphWeightEnabled=false gates passive confirm (master switch)", async () => {
78
+ const { store, service } = setup({ graphPassiveConfirm: true, graphWeightDelta: 0.1 });
79
+ const mem = putMem(store, service, { title: "数据库迁移", content: "主库切换完成" });
80
+ linkRelToMem(store, { fromName: "A", toName: "B", memoryId: mem.id, source: "llm" });
81
+ await searchOnce(service, "数据库迁移");
82
+ assert.equal(store.getRelationsByMemory(mem.id)[0].weight, 0.4, "master switch off → weight untouched");
83
+ store.close();
84
+ });
85
+
86
+ test("memory without relations survives touch (no crash)", async () => {
87
+ const { store, service } = setup({ graphPassiveConfirm: true });
88
+ const mem = putMem(store, service, { title: "孤立条", content: "无关联边" });
89
+ await searchOnce(service, "孤立条");
90
+ assert.ok(store.getById(mem.id), "memory still present");
91
+ store.close();
92
+ });
@@ -0,0 +1,105 @@
1
+ // issue #24 块2 权重演化(entity_relations weight + source + bump)测试。
2
+ // 覆盖:来源→初值映射(manual/tag/llm)/ weight 显式优先 / toRelation 回读 /
3
+ // bumpRelationWeight 只加不减封顶 / extractor 建 llm 边 / 四来源全映射落表。
4
+ import test from "node:test";
5
+ import assert from "node:assert/strict";
6
+ import { createStore, RELATION_SOURCE_DEFAULTS } from "../src/store.js";
7
+
8
+ function makeStore() {
9
+ return createStore(":memory:");
10
+ }
11
+
12
+ test("RELATION_SOURCE_DEFAULTS matches issue #24 mapping", () => {
13
+ assert.equal(RELATION_SOURCE_DEFAULTS.manual, 1.0);
14
+ assert.equal(RELATION_SOURCE_DEFAULTS.confirmed, 1.0);
15
+ assert.equal(RELATION_SOURCE_DEFAULTS.tag, 0.3);
16
+ assert.equal(RELATION_SOURCE_DEFAULTS.llm, 0.4);
17
+ });
18
+
19
+ test("saveRelation maps source to initial weight", () => {
20
+ const store = makeStore();
21
+ const a = store.createEntity({ name: "A", type: "technology" });
22
+ const b = store.createEntity({ name: "B", type: "technology" });
23
+ const manual = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "manual" });
24
+ const tag = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "y", source: "tag" });
25
+ const llm = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "z", source: "llm" });
26
+ assert.equal(manual.weight, 1.0);
27
+ assert.equal(manual.source, "manual");
28
+ assert.equal(tag.weight, 0.3);
29
+ assert.equal(tag.source, "tag");
30
+ assert.equal(llm.weight, 0.4);
31
+ assert.equal(llm.source, "llm");
32
+ });
33
+
34
+ test("explicit weight overrides source mapping", () => {
35
+ const store = makeStore();
36
+ const a = store.createEntity({ name: "A", type: "technology" });
37
+ const b = store.createEntity({ name: "B", type: "technology" });
38
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm", weight: 0.7 });
39
+ assert.equal(r.weight, 0.7);
40
+ });
41
+
42
+ test("toRelation round-trips weight and source via getRelations", () => {
43
+ const store = makeStore();
44
+ const a = store.createEntity({ name: "A", type: "technology" });
45
+ const b = store.createEntity({ name: "B", type: "technology" });
46
+ store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "tag" });
47
+ const rels = store.getRelations(a.id);
48
+ assert.equal(rels.length, 1);
49
+ assert.equal(rels[0].weight, 0.3);
50
+ assert.equal(rels[0].source, "tag");
51
+ });
52
+
53
+ test("bumpRelationWeight raises weight, caps at 1.0, never lowers", () => {
54
+ const store = makeStore();
55
+ const a = store.createEntity({ name: "A", type: "technology" });
56
+ const b = store.createEntity({ name: "B", type: "technology" });
57
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
58
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
59
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
60
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true); // 0.4→0.6→0.8→1.0
61
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true);
62
+ assert.equal(store.getRelations(a.id)[0].weight, 1.0, "capped");
63
+ // 不存在的关系:幂等 false
64
+ assert.equal(store.bumpRelationWeight("no-such-id"), false);
65
+ });
66
+
67
+ test("legacy relation rows migrate with manual weight default (no crash)", () => {
68
+ const store = makeStore();
69
+ const a = store.createEntity({ name: "A", type: "technology" });
70
+ const b = store.createEntity({ name: "B", type: "technology" });
71
+ // 直接走底层 SQL 模拟旧库行(不带 weight/source 列 —— 但新 schema 已含列,
72
+ // 所以这条主要验证 migration 幂等路径不崩 + 默认映射成立)。
73
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x" });
74
+ assert.equal(r.weight, 1.0, "default source manual → weight 1.0");
75
+ assert.equal(r.source, "manual");
76
+ });
77
+
78
+ // ---- #24 复审回归(review findings 逐条落锁)--------------------------------
79
+
80
+ // 「只加不减」是存储接口自己的契约:负/非法增量必须在存储边界被拒(false +
81
+ // 权值不动),不能把下界托付给调用方的配置范围——旧写法 bumpRelationWeight(id,
82
+ // -0.2) 能把 0.4 敲到 0.2。
83
+ test("bumpRelationWeight rejects negative and non-finite increments (只加不减)", () => {
84
+ const store = makeStore();
85
+ const a = store.createEntity({ name: "A", type: "technology" });
86
+ const b = store.createEntity({ name: "B", type: "technology" });
87
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "llm" });
88
+ for (const bad of [-0.2, -1, Number.NaN, Number.POSITIVE_INFINITY, "0.2"]) {
89
+ assert.equal(store.bumpRelationWeight(r.id, bad), false, `increment ${String(bad)} must be refused`);
90
+ assert.equal(store.getRelations(a.id)[0].weight, 0.4, `weight must not move on increment ${String(bad)}`);
91
+ }
92
+ assert.equal(store.bumpRelationWeight(r.id, 0.2), true, "a valid increment still lands");
93
+ assert.ok(Math.abs(store.getRelations(a.id)[0].weight - 0.6) < 1e-9, "0.4 + 0.2 lands at 0.6 (float-safe)");
94
+ });
95
+
96
+ // 来源查询只认映射表的自有键:src="constructor"/"toString" 会命中原型链上的函数,
97
+ // 旧写法把它当权重绑进 SQL(函数不可绑定 → 整条写入抛错)而不是回退默认 1.0。
98
+ test("an inherited source name falls back to the default weight", () => {
99
+ const store = makeStore();
100
+ const a = store.createEntity({ name: "A", type: "technology" });
101
+ const b = store.createEntity({ name: "B", type: "technology" });
102
+ const r = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "constructor" });
103
+ assert.equal(r.weight, 1.0, "prototype-chain names must fall back to 1.0, not a function");
104
+ assert.equal(r.source, "constructor", "source is recorded verbatim");
105
+ });