@modusensus/dsh-mneme 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -3
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +8 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +298 -12
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1153 -106
- package/lib/config-keys.js +144 -0
- package/lib/config.js +28 -0
- package/lib/graph/anchoring.js +7 -2
- package/lib/index.js +50 -4
- package/lib/inject.js +15 -3
- package/lib/lang.js +54 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +72 -8
- package/lib/settings.js +8 -1
- package/lib/store.js +657 -0
- package/lib/tools.js +127 -1
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +220 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/src/api-standalone.js +17 -21
- package/src/api.js +298 -12
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +28 -0
- package/src/graph/anchoring.js +7 -2
- package/src/index.js +50 -4
- package/src/inject.js +15 -3
- package/src/lang.js +54 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +72 -8
- package/src/settings.js +8 -1
- package/src/store.js +657 -0
- package/src/tools.js +127 -1
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +220 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +7 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +820 -19
- package/test/config-keys-routes.test.js +233 -0
- package/test/contrast.test.js +14 -7
- package/test/graph-boosted-relations.test.js +189 -0
- package/test/graph-multipath.test.js +269 -0
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/wiki-api.test.js +153 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +187 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +121 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// issue #386 第 6 条(面板 bootstrap 入口)与第 7 条(配置键覆盖面)的服务端契约。
|
|
2
|
+
//
|
|
3
|
+
// 这一层要钉住的不是「路由存在」,而是三件容易在改动里悄悄走样的事:
|
|
4
|
+
// 1. /bootstrap 的 policy 是 token(写库),且先判 body 形状、再判能力接线——
|
|
5
|
+
// 顺序反了会把「你没给目录」报成「能力没接线」,把人引去查错的排查方向。
|
|
6
|
+
// 2. /config-keys 只回键名与计数,一个值都不回。apiToken 的名字必须出现(那正是
|
|
7
|
+
// 用户想知道它存在的原因),值必须不出现(那会是一处新泄漏面)。
|
|
8
|
+
// 3. 三份清单是真算出来的:panelEditable = 有默认值的白名单键,
|
|
9
|
+
// fileOnly = schema 声明 − 白名单,undefaulted = 白名单 − 有默认值。
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { EventEmitter } from "node:events";
|
|
13
|
+
import { createStore } from "../src/store.js";
|
|
14
|
+
import { createService } from "../src/service.js";
|
|
15
|
+
import { createApi } from "../src/api.js";
|
|
16
|
+
import { createSettings } from "../src/settings.js";
|
|
17
|
+
import { Config } from "../src/config.js";
|
|
18
|
+
import { describeConfigKeys, schemaLeafKeys, defaultedLeafKeys, flagKeysFromSpec } from "../src/config-keys.js";
|
|
19
|
+
import { bootstrapReply } from "../src/bootstrap-http.js";
|
|
20
|
+
|
|
21
|
+
class FakeRes extends EventEmitter {
|
|
22
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
23
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
24
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function req(path, { method = "GET", body = null } = {}) {
|
|
28
|
+
const r = new EventEmitter();
|
|
29
|
+
r.url = path;
|
|
30
|
+
r.method = method;
|
|
31
|
+
r.headers = {};
|
|
32
|
+
// readBody 是事件式的(data/end),不是 async iterable:必须真的把事件发出去,
|
|
33
|
+
// 否则 handler 会永远挂在那个 Promise 上(第一版测试就是这么挂死的)。
|
|
34
|
+
setImmediate(() => {
|
|
35
|
+
if (body !== null) r.emit("data", Buffer.from(body, "utf8"));
|
|
36
|
+
r.emit("end");
|
|
37
|
+
});
|
|
38
|
+
return r;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function setup({ config = {}, apiToken = "" } = {}) {
|
|
42
|
+
const store = createStore(":memory:");
|
|
43
|
+
const service = createService({ store, mirror: null, config });
|
|
44
|
+
const settings = createSettings(store.db);
|
|
45
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
46
|
+
const routes = [];
|
|
47
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
48
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken, config);
|
|
49
|
+
return { store, service, settings, routes };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const BOOTSTRAP = "/api/dsh-mneme/bootstrap";
|
|
53
|
+
const CONFIG_KEYS = "/api/dsh-mneme/config-keys";
|
|
54
|
+
|
|
55
|
+
async function call(routes, path, opts) {
|
|
56
|
+
const route = routes.find((r) => r.path === path);
|
|
57
|
+
assert.ok(route, `route ${path} must be registered`);
|
|
58
|
+
const res = new FakeRes();
|
|
59
|
+
await route.handler(req(path, opts), res);
|
|
60
|
+
return { route, res, json: res.body ? JSON.parse(res.body) : null };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// --- /bootstrap -------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
test("bootstrap route is registered with the token policy", () => {
|
|
66
|
+
const { routes } = setup({ apiToken: "secret-token" });
|
|
67
|
+
const route = routes.find((r) => r.path === BOOTSTRAP);
|
|
68
|
+
assert.ok(route, "route must be registered");
|
|
69
|
+
// 它写库(虽然幂等),所以不能是 open;也不该借 token-write 的读写双义——这条
|
|
70
|
+
// 路由没有 GET 分支,声明 token-write 只会让 SECURITY.md 的开放读清单多一条假的。
|
|
71
|
+
assert.equal(route.policy, "token");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("bootstrap demands a token when one is configured", async () => {
|
|
75
|
+
const { routes } = setup({ apiToken: "secret-token" });
|
|
76
|
+
const { res } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({ dir: "/tmp" }) });
|
|
77
|
+
assert.equal(res.statusCode, 401);
|
|
78
|
+
assert.equal(JSON.parse(res.body).error, "unauthorized");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("bootstrap rejects GET with 404 (no read branch exists)", async () => {
|
|
82
|
+
const { routes } = setup();
|
|
83
|
+
const { res } = await call(routes, BOOTSTRAP, { method: "GET" });
|
|
84
|
+
assert.equal(res.statusCode, 404);
|
|
85
|
+
assert.equal(JSON.parse(res.body).error, "not-found");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("bootstrap separates malformed JSON from a non-object body", async () => {
|
|
89
|
+
const { routes } = setup();
|
|
90
|
+
const bad = await call(routes, BOOTSTRAP, { method: "POST", body: "{not json" });
|
|
91
|
+
assert.equal(bad.res.statusCode, 400);
|
|
92
|
+
assert.equal(bad.json.error, "invalid-json");
|
|
93
|
+
// 数组是合法 JSON 但不是「对象」:两个码分开,调用方才知道该改哪里。
|
|
94
|
+
const arr = await call(routes, BOOTSTRAP, { method: "POST", body: "[]" });
|
|
95
|
+
assert.equal(arr.res.statusCode, 400);
|
|
96
|
+
assert.equal(arr.json.error, "invalid-body");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("bootstrap validates dir before it decides whether the capability is wired", async () => {
|
|
100
|
+
// 这条是顺序锁:给一个没有 saveWithDedupe 的装配 + 缺 dir 的 body。若先判能力,
|
|
101
|
+
// 用户会看到「能力没接线」——而真正的问题是他没填目录。
|
|
102
|
+
const store = createStore(":memory:");
|
|
103
|
+
const settings = createSettings(store.db);
|
|
104
|
+
const routes = [];
|
|
105
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
106
|
+
createApi(ctx, {}, settings, { add: () => {}, remove: () => {}, list: () => [] }, null, undefined, "", {});
|
|
107
|
+
const { res, json } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({}) });
|
|
108
|
+
assert.equal(res.statusCode, 400);
|
|
109
|
+
assert.equal(json.error, "missing-dir", "缺 dir 必须报 missing-dir,不能报 bootstrap-unavailable");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("bootstrap reports the coded error when the directory does not exist", async () => {
|
|
113
|
+
const { routes, service } = setup();
|
|
114
|
+
const { res, json } = await call(routes, BOOTSTRAP, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
body: JSON.stringify({ dir: "/definitely/not/here/386-bootstrap" })
|
|
117
|
+
});
|
|
118
|
+
assert.equal(res.statusCode, 400);
|
|
119
|
+
assert.equal(json.error, "dir-not-found");
|
|
120
|
+
// 失败路径不能留下半截记忆:目录都没读到,一条都不该落库。
|
|
121
|
+
assert.equal(service.count({}), 0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("bootstrapReply survives a logger that throws (the layer never throws)", async () => {
|
|
125
|
+
// 这一层的契约是「从不抛」:warn 裸调时,宿主 logger 自己坏掉会从 catch 块里再抛出去,
|
|
126
|
+
// 而两侧 handler 的外层 try 在 .then 回调里早已退栈——现场就是「一个字节都没回」
|
|
127
|
+
// (#410,share-http.js 的 warnSafely 注释记着同一件事)。守卫必须住在层里。
|
|
128
|
+
const reply = await bootstrapReply({
|
|
129
|
+
bootstrapper: () => Promise.reject(new Error("disk on fire")),
|
|
130
|
+
dir: "/tmp",
|
|
131
|
+
warn: () => { throw new Error("logger is broken too"); }
|
|
132
|
+
});
|
|
133
|
+
assert.equal(reply.status, 500);
|
|
134
|
+
assert.deepEqual(reply.body, { error: "internal" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("bootstrap imports deterministic artifacts from a real directory", async () => {
|
|
138
|
+
const { routes, service } = setup();
|
|
139
|
+
const os = await import("node:os");
|
|
140
|
+
const fs = await import("node:fs");
|
|
141
|
+
const path = await import("node:path");
|
|
142
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mneme-386-"));
|
|
143
|
+
try {
|
|
144
|
+
fs.writeFileSync(path.join(dir, "README.md"), "# sample project\n\nA tiny fixture.\n", "utf8");
|
|
145
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "sample", scripts: { build: "tsc -p ." } }), "utf8");
|
|
146
|
+
const { res, json } = await call(routes, BOOTSTRAP, { method: "POST", body: JSON.stringify({ dir }) });
|
|
147
|
+
assert.equal(res.statusCode, 200);
|
|
148
|
+
assert.ok(json.created >= 2, `expected at least 2 imported rows, got ${JSON.stringify(json)}`);
|
|
149
|
+
assert.equal(json.dir, fs.realpathSync(dir));
|
|
150
|
+
// 「返回成功不等于生效」:去看库里到底有没有那几行。
|
|
151
|
+
const rows = service.list({ limit: 20, includeArchived: false });
|
|
152
|
+
assert.ok(rows.length >= 2, "the response must correspond to rows that actually exist");
|
|
153
|
+
assert.ok(rows.every((m) => (m.tags || []).includes("bootstrap")), "every imported row carries the bootstrap tag");
|
|
154
|
+
} finally {
|
|
155
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// --- /config-keys -----------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
test("config-keys route is open and read-only", async () => {
|
|
162
|
+
const { routes } = setup({ apiToken: "secret-token" });
|
|
163
|
+
const { route, res } = await call(routes, CONFIG_KEYS, { method: "GET" });
|
|
164
|
+
assert.equal(route.policy, "open");
|
|
165
|
+
assert.equal(res.statusCode, 200, "read-only route must not demand a token");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("config-keys rejects non-GET", async () => {
|
|
169
|
+
const { routes } = setup();
|
|
170
|
+
const { res } = await call(routes, CONFIG_KEYS, { method: "POST", body: "{}" });
|
|
171
|
+
assert.equal(res.statusCode, 404);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("config-keys returns key names but never the apiToken value", async () => {
|
|
175
|
+
const { routes } = setup({ config: { apiToken: "super-secret-value" }, apiToken: "super-secret-value" });
|
|
176
|
+
const { res } = await call(routes, CONFIG_KEYS, { method: "GET" });
|
|
177
|
+
assert.equal(res.statusCode, 200);
|
|
178
|
+
// 名字必须在:用户正是要靠这份清单知道「apiToken 只能改配置文件」。
|
|
179
|
+
assert.ok(res.body.includes("apiToken"), "the key name must be listed");
|
|
180
|
+
// 值必须不在:这条断言是这整条路由存在的安全边界。
|
|
181
|
+
assert.ok(!res.body.includes("super-secret-value"), "the value must never leave the server");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("config-keys only reads the schema — it never echoes a configured value", async () => {
|
|
185
|
+
// 上一条锁的是「没漏出去」,这条锁的是「不是靠脱敏才没漏」:即便装了一个
|
|
186
|
+
// 非敏感键的值,响应里也不该出现它——这份清单只谈键名与计数。
|
|
187
|
+
const { routes } = setup({ config: { memoryDir: "/unique/marker/path/386" } });
|
|
188
|
+
const { res } = await call(routes, CONFIG_KEYS, { method: "GET" });
|
|
189
|
+
assert.ok(!res.body.includes("/unique/marker/path/386"), "no configured value may appear in this response");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("config-keys partitions the two source lists without losing anything", async () => {
|
|
193
|
+
const d = describeConfigKeys();
|
|
194
|
+
const declared = schemaLeafKeys();
|
|
195
|
+
const defaulted = defaultedLeafKeys();
|
|
196
|
+
const flags = flagKeysFromSpec();
|
|
197
|
+
assert.equal(d.counts.schemaLeaf, declared.length);
|
|
198
|
+
assert.equal(d.counts.flag, flags.length);
|
|
199
|
+
// 并集必须正好铺满「schema 声明 ∪ 白名单」,一个键都不许漏在三个桶之外。
|
|
200
|
+
const union = new Set([...d.panelEditable, ...d.fileOnly, ...d.undefaulted]);
|
|
201
|
+
const expected = new Set([...declared, ...flags]);
|
|
202
|
+
assert.deepEqual([...union].sort(), [...expected].sort());
|
|
203
|
+
// 两个等式同时成立,才说明三个桶互不相交且覆盖完整。注意第一个等式要把
|
|
204
|
+
// undefaulted 也加进去:dreamProvider / dreamModel 既是 schema 声明的键、又挂在
|
|
205
|
+
// 白名单上,它既不属于 panelEditable(没默认值)也不属于 fileOnly(白名单有它),
|
|
206
|
+
// 只落在 undefaulted 这一个桶里。
|
|
207
|
+
assert.equal(d.panelEditable.length + d.fileOnly.length + d.undefaulted.length, declared.length);
|
|
208
|
+
assert.equal(d.panelEditable.length + d.undefaulted.length, flags.length);
|
|
209
|
+
// 白名单里每个键都必须在 schema 里声明过——否则那是登记了一个不存在的键。
|
|
210
|
+
for (const key of flags) assert.ok(expected.has(key), `flag ${key} is not declared in the schema`);
|
|
211
|
+
// 有默认值的键必须都被 schema 声明覆盖(反向包含,防 schema 漏声明)。
|
|
212
|
+
for (const key of defaulted) assert.ok(declared.includes(key), `defaulted key ${key} is missing from the schema declaration`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("config-keys flags keys that the whitelist registers without a schema default", async () => {
|
|
216
|
+
const d = describeConfigKeys();
|
|
217
|
+
// dreamProvider / dreamModel 在 settings.js 白名单里,但 config.js 只有
|
|
218
|
+
// z.string().description(...) 没有 .default()。面板因此有个永远不进 effective 的
|
|
219
|
+
// 输入框——这正是 #386 第 7 条说的「不对称」,必须被告知而不是被悄悄抹平。
|
|
220
|
+
assert.deepEqual(d.undefaulted.sort(), ["dreamModel", "dreamProvider"]);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("config-keys lists file-only keys that users actually hit (apiToken, memoryDir, maxInjectedItems)", async () => {
|
|
224
|
+
const d = describeConfigKeys();
|
|
225
|
+
// apiToken 是最要紧的一个:它没有默认值,所以「拿解析结果当 schema」的写法会把它
|
|
226
|
+
// 整个漏掉——第一版正是这么错的,这条用例是那个错的回归锁。
|
|
227
|
+
assert.ok(d.fileOnly.includes("apiToken"), "apiToken must be visible as file-only");
|
|
228
|
+
// 另外两个是 issue 原文点名的键:用户能理解、却在面板上找不到。
|
|
229
|
+
assert.ok(d.fileOnly.includes("memoryDir"));
|
|
230
|
+
assert.ok(d.fileOnly.includes("maxInjectedItems"));
|
|
231
|
+
// 反向:一个面板确实能改的键,绝不能被列进 fileOnly。
|
|
232
|
+
assert.ok(!d.fileOnly.includes("autoInject"));
|
|
233
|
+
});
|
package/test/contrast.test.js
CHANGED
|
@@ -233,13 +233,16 @@ test("面板引用的宿主 token 必须真实存在(幻影 token 回归锁)
|
|
|
233
233
|
assert.deepEqual(unknown, [], "面板引用了宿主快照里没有的 token(幻影名或快照过期):" + unknown.join(", "));
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
//
|
|
237
|
-
// body(.mneme-overlay)、侧边栏入口在宿主插槽(.mneme-triggerwrap
|
|
238
|
-
// portal 到宿主侧边栏(.mneme-topentry
|
|
239
|
-
//
|
|
240
|
-
|
|
236
|
+
// 变量层必须挂满五个渲染根:面板不只长在 .mneme-x 下——老宿主上记忆库 sheet
|
|
237
|
+
// portal 到 body(.mneme-overlay)、侧边栏入口在宿主插槽(.mneme-triggerwrap)、
|
|
238
|
+
// 顶部入口 portal 到宿主侧边栏(.mneme-topentry);新宿主的侧栏图标长在宿主
|
|
239
|
+
// panelGlyph 里、不在 .mneme-x 子树内(.mneme-paneliglyph),它挂着冲突红点,
|
|
240
|
+
// 必须吃到 --mneme-danger。
|
|
241
|
+
// 少挂一个,那个根下的文字/图形就退回宿主的低对比度 token,而且上面的主锁
|
|
242
|
+
// **照样全绿**(主锁只算颜色,不知道变量有没有继承到)。
|
|
243
|
+
test("面板语义色层覆盖五个渲染根", () => {
|
|
241
244
|
const sel = layer.sel;
|
|
242
|
-
assert.equal(sel, ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry");
|
|
245
|
+
assert.equal(sel, ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry,.mneme-paneliglyph");
|
|
243
246
|
});
|
|
244
247
|
|
|
245
248
|
// 图形色的对比度(WCAG 1.4.11,非文字 3:1)。上面那条主锁只看 `color:`,
|
|
@@ -275,7 +278,11 @@ const GRAPHICS = [
|
|
|
275
278
|
{ sel: ".mneme-heatcell.l1", prop: "background" },
|
|
276
279
|
{ sel: ".mneme-heatcell.l2", prop: "background" },
|
|
277
280
|
{ sel: ".mneme-heatcell.l3", prop: "background" },
|
|
278
|
-
{ sel: ".mneme-heatcell.l4", prop: "background" }
|
|
281
|
+
{ sel: ".mneme-heatcell.l4", prop: "background" },
|
|
282
|
+
// 8 周沉淀的两根柱:高度差就是「进来的 vs 稳住的」这层信息本身(新增高、
|
|
283
|
+
// 巩固矮=库在膨胀),柱子掉回浅灰等于把结论擦掉,所以按图形色 3:1 锁。
|
|
284
|
+
{ sel: ".mneme-sinkbar--added", prop: "background" },
|
|
285
|
+
{ sel: ".mneme-sinkbar--merged", prop: "background" }
|
|
279
286
|
];
|
|
280
287
|
test("图形色(热度/打卡/连线)在浅深两主题下都达到 3:1", () => {
|
|
281
288
|
const bySel = new Map(rules.map((r) => [r.sel, r]));
|
|
@@ -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
|
+
});
|