@modusensus/dsh-mneme 0.10.0 → 0.10.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/README.md +4 -3
- package/lib/api.js +51 -1
- package/lib/client.js +125 -4
- package/lib/graph/anchoring.js +7 -2
- package/lib/service.js +69 -8
- package/lib/store.js +155 -0
- package/package.json +1 -1
- package/src/api.js +51 -1
- package/src/graph/anchoring.js +7 -2
- package/src/service.js +69 -8
- package/src/store.js +155 -0
- package/test/client.test.js +165 -0
- package/test/graph-boosted-relations.test.js +189 -0
- package/test/graph-multipath.test.js +269 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// issue #24 块4(被动确认)的复核出口:
|
|
2
|
+
// GET /api/dsh-mneme/semantic/graph/boosted-relations
|
|
3
|
+
// 复核的对象是「权重被抬过的边」——抬升是唯一会改变检索形状的动作,所以它自己
|
|
4
|
+
// 就是证据,不必再开一张记账表。这里同时钉住 ego 边的 weight/source 形状:
|
|
5
|
+
// 少了那两个字段,面板就分不清「这条边被用过」和「这条边从没被用过」。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { EventEmitter } from "node:events";
|
|
9
|
+
import { createStore } from "../src/store.js";
|
|
10
|
+
import { createService } from "../src/service.js";
|
|
11
|
+
import { createApi } from "../src/api.js";
|
|
12
|
+
import { createSettings } from "../src/settings.js";
|
|
13
|
+
|
|
14
|
+
class FakeRes extends EventEmitter {
|
|
15
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
16
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
17
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function req(path) {
|
|
21
|
+
const r = new EventEmitter();
|
|
22
|
+
r.url = path;
|
|
23
|
+
r.method = "GET";
|
|
24
|
+
r.headers = {};
|
|
25
|
+
return r;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function setup({ config = {}, apiToken = "" } = {}) {
|
|
29
|
+
const store = createStore(":memory:");
|
|
30
|
+
const service = createService({ store, mirror: null, config });
|
|
31
|
+
const settings = createSettings(store.db);
|
|
32
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
33
|
+
const routes = [];
|
|
34
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
35
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken, config);
|
|
36
|
+
return { store, service, routes };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const BOOSTED = "/api/dsh-mneme/semantic/graph/boosted-relations";
|
|
40
|
+
|
|
41
|
+
test("boosted-relations route is registered and open without apiToken", async () => {
|
|
42
|
+
const { routes } = setup({ apiToken: "secret-token" });
|
|
43
|
+
const route = routes.find((r) => r.path === BOOSTED);
|
|
44
|
+
assert.ok(route, "route must be registered");
|
|
45
|
+
const res = new FakeRes();
|
|
46
|
+
await route.handler(req(BOOSTED), res);
|
|
47
|
+
assert.equal(res.statusCode, 200, "read-only route must not demand a token");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("boosted-relations lists only edges whose weight rose above its source baseline", async () => {
|
|
51
|
+
const { store, service, routes } = setup();
|
|
52
|
+
const a = service.createEntity({ name: "A", type: "concept" });
|
|
53
|
+
const b = service.createEntity({ name: "B", type: "concept" });
|
|
54
|
+
const c = service.createEntity({ name: "C", type: "concept" });
|
|
55
|
+
// tag 边初值 0.3,抬两次 → 0.5,gain 0.2,必进表。
|
|
56
|
+
const boosted = service.saveRelation({
|
|
57
|
+
from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "tag"
|
|
58
|
+
});
|
|
59
|
+
store.bumpRelationWeight(boosted.id, 0.1);
|
|
60
|
+
store.bumpRelationWeight(boosted.id, 0.1);
|
|
61
|
+
// manual 边留在初值 1.0:gain 为 0,不得进表。
|
|
62
|
+
service.saveRelation({ from_entity: b.id, to_entity: c.id, relation_type: "uses", source: "manual" });
|
|
63
|
+
|
|
64
|
+
const res = new FakeRes();
|
|
65
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
|
|
66
|
+
assert.equal(res.statusCode, 200);
|
|
67
|
+
const data = JSON.parse(res.body);
|
|
68
|
+
assert.equal(data.count, 1, "only the raised edge qualifies");
|
|
69
|
+
const item = data.items[0];
|
|
70
|
+
assert.equal(item.id, boosted.id);
|
|
71
|
+
assert.equal(item.from, a.id);
|
|
72
|
+
assert.equal(item.to, b.id);
|
|
73
|
+
assert.equal(item.from_name, "A", "both endpoint names ride along so the panel need not re-query");
|
|
74
|
+
assert.equal(item.to_name, "B");
|
|
75
|
+
assert.equal(item.source, "tag");
|
|
76
|
+
assert.ok(Math.abs(item.base_weight - 0.3) < 1e-9, "baseline comes from RELATION_SOURCE_DEFAULTS");
|
|
77
|
+
assert.ok(Math.abs(item.weight - 0.5) < 1e-9, `weight rose to 0.5, got ${item.weight}`);
|
|
78
|
+
assert.ok(Math.abs(item.gain - 0.2) < 1e-9, `gain = weight − base, got ${item.gain}`);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("boosted-relations reports an empty table honestly, with the gate that explains why", async () => {
|
|
82
|
+
const { service, routes } = setup();
|
|
83
|
+
const a = service.createEntity({ name: "A", type: "concept" });
|
|
84
|
+
const b = service.createEntity({ name: "B", type: "concept" });
|
|
85
|
+
service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
|
|
86
|
+
|
|
87
|
+
const res = new FakeRes();
|
|
88
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
|
|
89
|
+
const data = JSON.parse(res.body);
|
|
90
|
+
assert.equal(data.count, 0, "an unraised edge is not a finding");
|
|
91
|
+
assert.equal(data.passiveConfirmEnabled, false, "the gate is off by default — the panel can say so");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("boosted-relations surfaces passiveConfirmEnabled=true when the gate is on", async () => {
|
|
95
|
+
const { routes } = setup({ config: { graphPassiveConfirm: true, graphWeightEnabled: true } });
|
|
96
|
+
const res = new FakeRes();
|
|
97
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
|
|
98
|
+
assert.equal(JSON.parse(res.body).passiveConfirmEnabled, true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// 只有 graphPassiveConfirm 开着不算「会继续产生新提升」:touchRecalled 里
|
|
102
|
+
// passive = graphPassiveConfirm && graphWeightEnabled,只看前者面板会说
|
|
103
|
+
// 「会自动提升」而实际一条都不会抬(评审指出)。
|
|
104
|
+
test("boosted-relations reports the gate as off when only graphPassiveConfirm is set", async () => {
|
|
105
|
+
const { routes } = setup({ config: { graphPassiveConfirm: true, graphWeightEnabled: false } });
|
|
106
|
+
const res = new FakeRes();
|
|
107
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
|
|
108
|
+
assert.equal(JSON.parse(res.body).passiveConfirmEnabled, false,
|
|
109
|
+
"passive confirmation only bites when weight evolution is also on");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 排序口径:按 gain 降序,不是按 weight 降序。不同 source 的 base 不同,
|
|
113
|
+
// 所以 weight 大的边 gain 可能更小。首版把排序交给 SQL 的 weight DESC 并在
|
|
114
|
+
// 循环里按 limit 提前 break,截断处丢掉的正是 gain 更大的边(评审指出)。
|
|
115
|
+
test("boosted-relations ranks by gain, not by raw weight", async () => {
|
|
116
|
+
const { store, routes } = setup();
|
|
117
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
118
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
119
|
+
const c = store.createEntity({ name: "C", type: "technology" });
|
|
120
|
+
// llm 边:base 0.4,weight 0.95 → gain 0.55
|
|
121
|
+
const llmEdge = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
|
|
122
|
+
store.bumpRelationWeight(llmEdge.id, 0.55);
|
|
123
|
+
// tag 边:base 0.3,weight 0.90 → gain 0.60(weight 更小、gain 更大)
|
|
124
|
+
const tagEdge = store.saveRelation({ from_entity: a.id, to_entity: c.id, relation_type: "uses", source: "tag" });
|
|
125
|
+
store.bumpRelationWeight(tagEdge.id, 0.60);
|
|
126
|
+
|
|
127
|
+
const res = new FakeRes();
|
|
128
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED), res);
|
|
129
|
+
const items = JSON.parse(res.body).items;
|
|
130
|
+
assert.ok(items.length >= 2);
|
|
131
|
+
assert.ok(items[0].gain >= items[1].gain, "the list must be ordered by gain");
|
|
132
|
+
assert.equal(items[0].to_name, "C",
|
|
133
|
+
"the tag edge (weight 0.90, gain 0.60) must outrank the llm edge (weight 0.95, gain 0.55)");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// 截断也必须发生在按 gain 排完之后:limit=1 时留下的得是 gain 最大那条。
|
|
137
|
+
test("boosted-relations truncates after ranking by gain", async () => {
|
|
138
|
+
const { store, routes } = setup();
|
|
139
|
+
const a = store.createEntity({ name: "A", type: "technology" });
|
|
140
|
+
const b = store.createEntity({ name: "B", type: "technology" });
|
|
141
|
+
const c = store.createEntity({ name: "C", type: "technology" });
|
|
142
|
+
const llmEdge = store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
|
|
143
|
+
store.bumpRelationWeight(llmEdge.id, 0.55);
|
|
144
|
+
const tagEdge = store.saveRelation({ from_entity: a.id, to_entity: c.id, relation_type: "uses", source: "tag" });
|
|
145
|
+
store.bumpRelationWeight(tagEdge.id, 0.60);
|
|
146
|
+
|
|
147
|
+
const res = new FakeRes();
|
|
148
|
+
await routes.find((r) => r.path === BOOSTED).handler(req(BOOSTED + "?limit=1"), res);
|
|
149
|
+
const items = JSON.parse(res.body).items;
|
|
150
|
+
assert.equal(items.length, 1);
|
|
151
|
+
assert.equal(items[0].to_name, "C",
|
|
152
|
+
"truncation must keep the largest gain, not the largest weight");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("boosted-relations honours limit and never exceeds the hard cap", async () => {
|
|
156
|
+
const { store, service, routes } = setup();
|
|
157
|
+
for (let i = 0; i < 6; i++) {
|
|
158
|
+
const a = service.createEntity({ name: `L${i}a`, type: "concept" });
|
|
159
|
+
const b = service.createEntity({ name: `L${i}b`, type: "concept" });
|
|
160
|
+
const r = service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "x", source: "tag" });
|
|
161
|
+
store.bumpRelationWeight(r.id, 0.1);
|
|
162
|
+
}
|
|
163
|
+
const route = routes.find((r) => r.path === BOOSTED);
|
|
164
|
+
let res = new FakeRes();
|
|
165
|
+
await route.handler(req(`${BOOSTED}?limit=2`), res);
|
|
166
|
+
assert.equal(JSON.parse(res.body).count, 2, "limit caps the returned rows");
|
|
167
|
+
res = new FakeRes();
|
|
168
|
+
await route.handler(req(`${BOOSTED}?limit=9999`), res);
|
|
169
|
+
assert.equal(JSON.parse(res.body).count, 6, "an absurd limit clamps to the cap, it does not error");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ego 的边要带 weight/source:面板靠它区分「这条边被用过」与「从没被用过」。
|
|
173
|
+
test("ego edges carry weight and source", async () => {
|
|
174
|
+
const { service, routes } = setup();
|
|
175
|
+
const a = service.createEntity({ name: "A", type: "concept" });
|
|
176
|
+
const b = service.createEntity({ name: "B", type: "concept" });
|
|
177
|
+
const rel = service.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: "uses", source: "llm" });
|
|
178
|
+
|
|
179
|
+
const res = new FakeRes();
|
|
180
|
+
await routes
|
|
181
|
+
.find((r) => r.path === "/api/dsh-mneme/semantic/graph/ego")
|
|
182
|
+
.handler(req("/api/dsh-mneme/semantic/graph/ego?entity=A&depth=1"), res);
|
|
183
|
+
assert.equal(res.statusCode, 200);
|
|
184
|
+
const data = JSON.parse(res.body);
|
|
185
|
+
const edge = data.edges.find((e) => e.id === rel.id);
|
|
186
|
+
assert.ok(edge, "the relation must appear among ego edges");
|
|
187
|
+
assert.ok(Math.abs(edge.weight - 0.4) < 1e-9, `llm edge starts at 0.4, got ${edge.weight}`);
|
|
188
|
+
assert.equal(edge.source, "llm");
|
|
189
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// issue #24 块1 三路起点的回归(tag 桥接 / 向量 / 零分种子)。
|
|
2
|
+
// 这三条都在钉「调用点真的接上了」:能力早就写在 graph/anchoring.js 里,
|
|
3
|
+
// 之前的病是 service.js 只喂了一路 exact,另两路永远等不到自己被调用。
|
|
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 { createVectorIndex } from "../src/vector-index.js";
|
|
9
|
+
import { HOP_QUOTA } from "../src/graph/anchoring.js";
|
|
10
|
+
|
|
11
|
+
function mockEmbedder(vec = [1, 0, 0, 0]) {
|
|
12
|
+
return {
|
|
13
|
+
embedSingle: async () => vec,
|
|
14
|
+
embed: async (texts) => texts.map(() => vec),
|
|
15
|
+
schedule: () => {},
|
|
16
|
+
modelHash: "mock#abc",
|
|
17
|
+
dimension: 4
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function makeService(config = {}, { withEmbedder = false } = {}) {
|
|
22
|
+
const store = createStore(":memory:");
|
|
23
|
+
const service = createService({ store, mirror: null, config });
|
|
24
|
+
if (withEmbedder) {
|
|
25
|
+
const vectorIndex = createVectorIndex({ store });
|
|
26
|
+
service.setEmbedder(mockEmbedder());
|
|
27
|
+
service.setVectorIndex(vectorIndex);
|
|
28
|
+
return { store, service, vectorIndex };
|
|
29
|
+
}
|
|
30
|
+
return { store, service, vectorIndex: null };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function putMemory(store, service, { type = "project", title, content, tags = [], importance = 3 }) {
|
|
34
|
+
return service.saveWithDedupe({ type, title, content, tags, importance }).memory;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function linkAttr(store, entityName, memory, { key = "k", value = "v", type = "technology" } = {}) {
|
|
38
|
+
let entity = store.findEntityByName(entityName);
|
|
39
|
+
if (!entity) entity = store.createEntity({ name: entityName, type });
|
|
40
|
+
store.saveAttr({ entity_id: entity.id, attr_key: key, attr_value: value, memory_id: memory.id });
|
|
41
|
+
return entity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function linkEntities(store, fromName, toName, { relType = "uses", type = "technology" } = {}) {
|
|
45
|
+
const a = store.findEntityByName(fromName) ?? store.createEntity({ name: fromName, type });
|
|
46
|
+
const b = store.findEntityByName(toName) ?? store.createEntity({ name: toName, type });
|
|
47
|
+
store.saveRelation({ from_entity: a.id, to_entity: b.id, relation_type: relType });
|
|
48
|
+
return { a, b };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---- Tag 桥接 -------------------------------------------------------------
|
|
52
|
+
// 查询「数据库迁移」既不是任何实体名,也不含实体名的子串。tag 命中一条记忆后,
|
|
53
|
+
// 该记忆挂联的实体就是「这次在谈的东西」,沿它级联出去的邻居记忆才有机会进池。
|
|
54
|
+
// 判别点:farMem 只能经 PgTuner → PgCache 这条边到达;tag 路不接线时它必然缺席。
|
|
55
|
+
test("tag path bridges a tag hit into an entity seed (query names no entity)", async () => {
|
|
56
|
+
const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
|
|
57
|
+
// nearMem 带 tag,标题正文都不含查询词——但 store.search 的 tags LIKE 也会命中它,
|
|
58
|
+
// 所以它的存在不是本条用例的判别点,判别点是 farMem。
|
|
59
|
+
const nearMem = putMemory(store, service, {
|
|
60
|
+
title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
|
|
61
|
+
});
|
|
62
|
+
const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
|
|
63
|
+
linkAttr(store, "PgTuner", nearMem);
|
|
64
|
+
linkAttr(store, "PgCache", farMem);
|
|
65
|
+
linkEntities(store, "PgTuner", "PgCache");
|
|
66
|
+
|
|
67
|
+
const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
|
|
68
|
+
const ids = rows.map((r) => r.id);
|
|
69
|
+
assert.ok(ids.includes(farMem.id), "1-hop memory reached only via the tag-bridged seed");
|
|
70
|
+
const far = rows.find((r) => r.id === farMem.id);
|
|
71
|
+
assert.ok(
|
|
72
|
+
Math.abs(far.score - 1.0 * HOP_QUOTA[1] * 0.3) < 1e-9,
|
|
73
|
+
`hop-quota weighting preserved through the tag path, got ${far.score}`
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// 关锚定时 tag 路一次都不许查:否则「默认关=旧单跳轴逐字节不变」这条承诺就破了。
|
|
78
|
+
test("tag path is not consulted when anchoring is off", async () => {
|
|
79
|
+
const { store, service } = makeService({ entityRecallEnabled: true });
|
|
80
|
+
const nearMem = putMemory(store, service, {
|
|
81
|
+
title: "alpha", content: "beta gamma", tags: ["数据库迁移"]
|
|
82
|
+
});
|
|
83
|
+
const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
|
|
84
|
+
linkAttr(store, "PgTuner", nearMem);
|
|
85
|
+
linkAttr(store, "PgCache", farMem);
|
|
86
|
+
linkEntities(store, "PgTuner", "PgCache");
|
|
87
|
+
|
|
88
|
+
const rows = await service.searchMemories("数据库迁移", { mode: "hybrid" });
|
|
89
|
+
assert.ok(!rows.some((r) => r.id === farMem.id), "no cascade when anchoring is off");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---- 向量起点 -------------------------------------------------------------
|
|
93
|
+
// 查询里没有实体名、也没有 tag 命中,唯一能给出种子的是向量。早退条件若仍以
|
|
94
|
+
// 「名字没命中」收场,第三路接上了也永远不被调用——这条用例正是钉那个早退。
|
|
95
|
+
test("vector path seeds from near memories when the query names no entity", async () => {
|
|
96
|
+
const { store, service, vectorIndex } = makeService(
|
|
97
|
+
{ entityRecallEnabled: true, graphAnchoringEnabled: true },
|
|
98
|
+
{ withEmbedder: true }
|
|
99
|
+
);
|
|
100
|
+
const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
|
|
101
|
+
const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
|
|
102
|
+
vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]); // farMem 刻意不嵌:它只能经图轴到达
|
|
103
|
+
linkAttr(store, "VecSeed", nearMem);
|
|
104
|
+
linkAttr(store, "VecNeighbor", farMem);
|
|
105
|
+
linkEntities(store, "VecSeed", "VecNeighbor");
|
|
106
|
+
|
|
107
|
+
const rows = await service.searchMemories("omega", { mode: "hybrid" });
|
|
108
|
+
const ids = rows.map((r) => r.id);
|
|
109
|
+
assert.ok(ids.includes(farMem.id), "1-hop memory reached through the vector seed");
|
|
110
|
+
const far = rows.find((r) => r.id === farMem.id);
|
|
111
|
+
assert.ok(
|
|
112
|
+
Math.abs(far.score - HOP_QUOTA[1] * 0.3) < 1e-9,
|
|
113
|
+
`vector seed keeps hop-quota weighting, got ${far.score}`
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ---- 向量起点也要看关系端点 -----------------------------------------------
|
|
118
|
+
// 与 Tag 路同一条病:一条近邻记忆若只通过关系挂到实体上(phaseRelations 不建
|
|
119
|
+
// attr 行),只读 entity_attrs 的反查会漏掉它,向量路就凭空少了种子。
|
|
120
|
+
test("vector path seeds from relation-only memories, not just attr rows", async () => {
|
|
121
|
+
const { store, service, vectorIndex } = makeService(
|
|
122
|
+
{ entityRecallEnabled: true, graphAnchoringEnabled: true },
|
|
123
|
+
{ withEmbedder: true }
|
|
124
|
+
);
|
|
125
|
+
const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
|
|
126
|
+
const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
|
|
127
|
+
vectorIndex.saveEmbedding(nearMem.id, [1, 0, 0, 0]);
|
|
128
|
+
// nearMem 不写 attr,只通过一条关系挂到 RelSeed / RelNeighbor 上。
|
|
129
|
+
const seed = store.createEntity({ name: "RelSeed", type: "module" });
|
|
130
|
+
const neighbor = store.createEntity({ name: "RelNeighbor", type: "module" });
|
|
131
|
+
store.saveRelation({
|
|
132
|
+
from_entity: seed.id, to_entity: neighbor.id, relation_type: "uses",
|
|
133
|
+
memory_id: nearMem.id, metadata: { source: "sleep_relation_completion" }
|
|
134
|
+
});
|
|
135
|
+
linkAttr(store, "RelNeighbor", farMem, { key: "k2" });
|
|
136
|
+
|
|
137
|
+
const rows = await service.searchMemories("omega", { mode: "hybrid" });
|
|
138
|
+
assert.ok(rows.some((r) => r.id === farMem.id),
|
|
139
|
+
"a relation-only near memory must still produce vector seeds");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// 对照:没有 embedder 就没有向量路,三路全空时必须老实返回空,而不是凭空造种子。
|
|
143
|
+
test("vector path absent leaves the axis empty (no phantom seeds)", async () => { const { store, service } = makeService({ entityRecallEnabled: true, graphAnchoringEnabled: true });
|
|
144
|
+
const nearMem = putMemory(store, service, { title: "alpha", content: "beta gamma" });
|
|
145
|
+
const farMem = putMemory(store, service, { title: "delta", content: "epsilon zeta" });
|
|
146
|
+
linkAttr(store, "VecSeed", nearMem);
|
|
147
|
+
linkAttr(store, "VecNeighbor", farMem);
|
|
148
|
+
linkEntities(store, "VecSeed", "VecNeighbor");
|
|
149
|
+
|
|
150
|
+
const rows = await service.searchMemories("omega", { mode: "hybrid" });
|
|
151
|
+
assert.ok(!rows.some((r) => r.id === farMem.id), "no seed sources → no cascade");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---- 零分种子 -------------------------------------------------------------
|
|
155
|
+
// min-max 会把连续通路里**最弱的那条**压成 0。0 分种子仍会被 anchorSeeds 收下,
|
|
156
|
+
// 而下游给种子挂联记忆的是**固定满权 tier**(不看种子自己的分数)——于是「这一路
|
|
157
|
+
// 里最不像的那条」会以最强的姿态把记忆拉进池。
|
|
158
|
+
//
|
|
159
|
+
// 两条近邻记忆都必须拿到**正分**:若让第二条与查询正交(cos=0),它在进向量
|
|
160
|
+
// 路时就被 score > 0 挡掉了,min-max 根本轮不到把它压成 0——那样这条用例会
|
|
161
|
+
// 以「从没发生过」的方式通过,看着绿、其实什么都没验。
|
|
162
|
+
//
|
|
163
|
+
// zeroMem 必须**挂在 0 分种子自己身上**(VoZero 同时挂 nearB 与 zeroMem)。
|
|
164
|
+
// 首版把它挂在另一个孤立实体上,于是无论滤不滤 0 分种子它都到不了——变异自证
|
|
165
|
+
// 当场抓到那条用例是空转的。
|
|
166
|
+
test("a zero-score seed does not inject its linked memory at full tier", async () => {
|
|
167
|
+
const { store, service, vectorIndex } = makeService(
|
|
168
|
+
{ entityRecallEnabled: true, graphAnchoringEnabled: true },
|
|
169
|
+
{ withEmbedder: true }
|
|
170
|
+
);
|
|
171
|
+
const nearA = putMemory(store, service, { title: "alpha", content: "beta" });
|
|
172
|
+
const nearB = putMemory(store, service, { title: "gamma", content: "delta" });
|
|
173
|
+
const zeroMem = putMemory(store, service, { title: "iota", content: "kappa" });
|
|
174
|
+
vectorIndex.saveEmbedding(nearA.id, [1, 0, 0, 0]); // cos = 1.0
|
|
175
|
+
vectorIndex.saveEmbedding(nearB.id, [0.7, 0.7, 0, 0]); // cos ≈ 0.707,正分但更低 → min-max 压成 0
|
|
176
|
+
linkAttr(store, "VoSeed", nearA);
|
|
177
|
+
linkAttr(store, "VoZero", nearB);
|
|
178
|
+
linkAttr(store, "VoZero", zeroMem, { key: "k2" }); // 同一实体挂第二条记忆,只能经这颗 0 分种子到达
|
|
179
|
+
// VoSeed 与 VoZero 之间不建边:zeroMem 拿不到任何别的到达路径。
|
|
180
|
+
|
|
181
|
+
const rows = await service.searchMemories("omega", { mode: "hybrid" });
|
|
182
|
+
assert.ok(rows.some((r) => r.id === nearB.id), "the weak memory itself still comes back via vector");
|
|
183
|
+
assert.ok(
|
|
184
|
+
!rows.some((r) => r.id === zeroMem.id),
|
|
185
|
+
"a 0-score seed must not pull its linked memory in at full tier"
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// ---- Tag 桥接的排序 -------------------------------------------------------
|
|
190
|
+
// 「一个实体命中了几条记忆」是这条路的强弱信号,必须在 SQL 里数。
|
|
191
|
+
// 首版拿 entityIdsForMemories 的 DISTINCT 结果反推计数,每个实体恒为 1,
|
|
192
|
+
// 排序静默失效、只剩名字序(评审指出)。判别点:limit=1 时返回的是宽实体。
|
|
193
|
+
// 命名上必须让「宽实体」在字母序里排后面——首版把宽实体叫 AWide,于是字母序
|
|
194
|
+
// 也选它,用例空转通过(变异自证抓到的)。
|
|
195
|
+
test("findEntitiesByTags ranks entities by how many tagged memories they cover", async () => {
|
|
196
|
+
const { store, service } = makeService();
|
|
197
|
+
const m1 = putMemory(store, service, { title: "m1", content: "c1", tags: ["数据库迁移"] });
|
|
198
|
+
const m2 = putMemory(store, service, { title: "m2", content: "c2", tags: ["数据库迁移"] });
|
|
199
|
+
// ZWide 挂两条命中记忆;ANarrow 只挂一条。名字序让 ANarrow 排前面,
|
|
200
|
+
// 所以只要计数生效,返回的必然是 ZWide。
|
|
201
|
+
linkAttr(store, "ZWide", m1);
|
|
202
|
+
linkAttr(store, "ZWide", m2, { key: "k2" });
|
|
203
|
+
linkAttr(store, "ANarrow", m1);
|
|
204
|
+
|
|
205
|
+
const hits = store.findEntitiesByTags("数据库迁移", { limit: 1 });
|
|
206
|
+
assert.equal(hits.length, 1);
|
|
207
|
+
assert.equal(hits[0].name, "ZWide",
|
|
208
|
+
"the entity covering more tagged memories must win over the alphabetical order");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ---- Tag 锚点的两个来源 ---------------------------------------------------
|
|
212
|
+
// phaseRelations 只写 entity_relations.memory_id,不建 attr 行。所以「只通过关系
|
|
213
|
+
// 挂到实体上」的记忆,若锚点只读 entity_attrs,关系两端一个种子都生不出来。
|
|
214
|
+
// 判别点:这两个实体都没有任何 attr 行,只能从关系侧被看到。
|
|
215
|
+
test("findEntitiesByTags sees relation endpoints, not only attr rows", () => {
|
|
216
|
+
const { store, service } = makeService();
|
|
217
|
+
const mem = putMemory(store, service, { title: "pg 调优", content: "一次调优", tags: ["postgres"] });
|
|
218
|
+
const a = store.createEntity({ name: "PgTuner", type: "module" });
|
|
219
|
+
const b = store.createEntity({ name: "PgCache", type: "module" });
|
|
220
|
+
store.saveRelation({
|
|
221
|
+
from_entity: a.id, to_entity: b.id, relation_type: "uses",
|
|
222
|
+
memory_id: mem.id, metadata: { source: "sleep_relation_completion" }
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const names = store.findEntitiesByTags("postgres", { limit: 5 }).map((h) => h.name).sort();
|
|
226
|
+
assert.deepEqual(names, ["PgCache", "PgTuner"],
|
|
227
|
+
"a memory linked only through a relation must seed both endpoints");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// 同一 (实体, 记忆) 被 attr 与关系同时覆盖时只算一条:UNION 去的是对,不是行。
|
|
231
|
+
test("findEntitiesByTags counts a memory once even when attr and relation both cover it", () => {
|
|
232
|
+
const { store, service } = makeService();
|
|
233
|
+
const m1 = putMemory(store, service, { title: "a", content: "b", tags: ["缓存"] });
|
|
234
|
+
const m2 = putMemory(store, service, { title: "c", content: "d", tags: ["缓存"] });
|
|
235
|
+
const wide = linkAttr(store, "ZBoth", m1);
|
|
236
|
+
store.saveRelation({ from_entity: wide.id, to_entity: wide.id, relation_type: "related_to", memory_id: m1.id });
|
|
237
|
+
linkAttr(store, "ABoth", m2);
|
|
238
|
+
|
|
239
|
+
const hits = store.findEntitiesByTags("缓存", { limit: 2 });
|
|
240
|
+
assert.deepEqual(hits.map((h) => h.name).sort(), ["ABoth", "ZBoth"]);
|
|
241
|
+
// 两边都是 1 条记忆时按名字序;若 m1 被数成 2 条,ZBoth 会排到前面。
|
|
242
|
+
assert.equal(hits[0].name, "ABoth",
|
|
243
|
+
"duplicate coverage of the same memory must not inflate the count");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ---- 分块后计数仍然正确 ---------------------------------------------------
|
|
247
|
+
// TAG_ID_CHUNK = 500,这条用例造 600 条命中记忆强制走两块:ZBulk 每块各 300 条
|
|
248
|
+
// (合计 600),ABulk 只有 400 条(全在第一块)。判别点:正确累加时 ZBulk 胜出;
|
|
249
|
+
// 若第二块覆盖而不是累加,ZBulk 只剩 300 < 400,字母序靠前的 ABulk 会赢。
|
|
250
|
+
test("findEntitiesByTags accumulates counts across chunk boundaries", () => {
|
|
251
|
+
const { store } = makeService();
|
|
252
|
+
const zBulk = store.createEntity({ name: "ZBulk", type: "module" });
|
|
253
|
+
const aBulk = store.createEntity({ name: "ABulk", type: "module" });
|
|
254
|
+
const makeTagged = (i) => store.save({
|
|
255
|
+
type: "project", title: `t${i}`, content: `c${i}`, tags: ["分块测试"], importance: 3
|
|
256
|
+
});
|
|
257
|
+
// 600 条:前 300 条挂 ZBulk,后 300 条挂 ZBulk 的另一批 attr key,
|
|
258
|
+
// 其中前 400 条同时挂 ABulk(跨两块)。
|
|
259
|
+
for (let i = 0; i < 600; i++) {
|
|
260
|
+
const m = makeTagged(i);
|
|
261
|
+
store.saveAttr({ entity_id: zBulk.id, attr_key: `k${i % 2}`, attr_value: "v", memory_id: m.id });
|
|
262
|
+
if (i < 400) store.saveAttr({ entity_id: aBulk.id, attr_key: "k", attr_value: "v", memory_id: m.id });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const hits = store.findEntitiesByTags("分块测试", { limit: 1 });
|
|
266
|
+
assert.equal(hits.length, 1);
|
|
267
|
+
assert.equal(hits[0].name, "ZBulk",
|
|
268
|
+
"counts must be summed across chunks, not overwritten by the last batch");
|
|
269
|
+
});
|