@modusensus/dsh-mneme 0.8.1 → 0.8.2
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/lib/api-standalone.js +12 -0
- package/lib/api.js +12 -2
- package/lib/config.js +8 -0
- package/lib/dream/decisions.js +55 -8
- package/lib/dream/sleep.js +36 -5
- package/lib/dream.js +18 -7
- package/lib/entities/extractor.js +2 -2
- package/lib/index.js +6 -0
- package/lib/lang.js +2 -2
- package/lib/local-embedder.js +9 -1
- package/lib/reranker.js +38 -8
- package/lib/service.js +16 -0
- package/lib/store.js +85 -25
- package/lib/summarize.js +2 -2
- package/lib/tools.js +6 -19
- package/lib/vector-index.js +2 -8
- package/package.json +2 -1
- package/scripts/sync-test-badge.mjs +61 -0
- package/src/api-standalone.js +12 -0
- package/src/api.js +12 -2
- package/src/config.js +8 -0
- package/src/dream/decisions.js +55 -8
- package/src/dream/sleep.js +36 -5
- package/src/dream.js +18 -7
- package/src/entities/extractor.js +2 -2
- package/src/index.js +6 -0
- package/src/lang.js +2 -2
- package/src/local-embedder.js +9 -1
- package/src/reranker.js +38 -8
- package/src/service.js +16 -0
- package/src/store.js +85 -25
- package/src/summarize.js +2 -2
- package/src/tools.js +6 -19
- package/src/vector-index.js +2 -8
- package/test/api.test.js +5 -0
- package/test/dream-archive-guardrail.test.js +116 -0
- package/test/dream-coverage-degrade.test.js +86 -0
- package/test/dream.test.js +15 -11
- package/test/inject-first-round-bm25.test.js +89 -0
- package/test/llm-message-source.test.js +177 -0
- package/test/reranker-single-logit.test.js +73 -0
- package/test/skip-invalid-restore.test.js +7 -5
- package/test/sleep-scheduler-rearm.test.js +89 -0
- package/test/sleep.test.js +2 -2
- package/test/standalone-api.test.js +34 -1
- package/test/store-vector-cache.test.js +98 -0
- package/test/tools-memory-get-schema.test.js +65 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createDreamScheduler, validateDecisions } from "../src/dream.js";
|
|
6
|
+
import { mockCtx } from "./helpers/dream-mock.js";
|
|
7
|
+
|
|
8
|
+
// 回归(issue #104 方向 2):archive 决策语义越界 + 批量大扫除。
|
|
9
|
+
// 实测事故:一轮 12 条互不相关主题被批量 archive,LLM 自述理由是「价值判断」
|
|
10
|
+
// (对核心知识库长期参考价值较低、保持整洁和高信噪比),而提示词对 archive 的
|
|
11
|
+
// 定义是「重复或过时」;12 条中 8 条疑似误伤,含 rejected_solution ×2、
|
|
12
|
+
// constraint ×3、pitfall ×1 等长保留类型——这些类型的存在意义即长期保存,
|
|
13
|
+
// 误归档损失最大。修复:
|
|
14
|
+
// 1. 类型护栏——长保留类型(preference / pattern / rejected_solution /
|
|
15
|
+
// constraint / pitfall)的 archive 必须命中「重复/过时」类理由,否则单条
|
|
16
|
+
// 跳过记 skipped(run 记 degraded),绝不整单拒绝;
|
|
17
|
+
// 2. 批量上限——单轮 archive 条数封顶(dreamMaxArchivePerRun,默认 8),
|
|
18
|
+
// 与 update 上限同类的全局闸门:skipInvalid 也不豁免,超限整单拒绝;
|
|
19
|
+
// 3. 提示词同步强化(辅助防线)。
|
|
20
|
+
// conflict 裁决的败者归档不经过 archive action,不受护栏影响。
|
|
21
|
+
|
|
22
|
+
function snap(entries) {
|
|
23
|
+
return new Map(entries.map(([id, type]) => [id, { id, type, title: `t-${id}`, content: `c-${id}`, importance: 3, archived: false, forgotten: false }]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------- 类型护栏(纯函数)
|
|
27
|
+
|
|
28
|
+
test("#104-2: long-retention archive with a duplicate/outdated rationale passes", () => {
|
|
29
|
+
const s = snap([["p1", "preference"], ["rs1", "rejected_solution"]]);
|
|
30
|
+
const decisions = [
|
|
31
|
+
{ action: "archive", ids: ["p1"], reason: "与后续偏好重复,已被新条目取代" },
|
|
32
|
+
{ action: "archive", ids: ["rs1"], reason: "duplicate of an earlier rejected approach (stale)" }
|
|
33
|
+
];
|
|
34
|
+
const { ok, skipped } = validateDecisions(decisions, s, { skipInvalid: true });
|
|
35
|
+
assert.equal(ok, true);
|
|
36
|
+
assert.deepEqual(skipped, [], "duplicate/outdated rationales are accepted");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("#104-2: long-retention archive with a value-judgment rationale is skipped (run degrades)", () => {
|
|
40
|
+
const s = snap([["c1", "constraint"], ["k1", "project"]]);
|
|
41
|
+
const decisions = [
|
|
42
|
+
{ action: "archive", ids: ["c1"], reason: "多为一次性调试细节,对核心知识库长期参考价值较低,保持整洁和高信噪比" },
|
|
43
|
+
{ action: "archive", ids: ["k1"], reason: "价值较低" }
|
|
44
|
+
];
|
|
45
|
+
const { ok, skipped } = validateDecisions(decisions, s, { skipInvalid: true });
|
|
46
|
+
assert.equal(ok, true, "guard skips the single decision instead of rejecting the run");
|
|
47
|
+
assert.equal(skipped.length, 1, "only the guarded long-retention archive is skipped");
|
|
48
|
+
assert.match(skipped[0].error, /long-retention type\(s\) constraint/);
|
|
49
|
+
assert.match(skipped[0].error, /duplicate\/outdated rationale/);
|
|
50
|
+
// 幸存列表:普通类型照常归档,c1 被跳过后由隐式 keep 补位
|
|
51
|
+
assert.deepEqual(decisions.map((d) => d.action), ["archive", "keep"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("#104-2: plain-type archive needs no rationale keywords (guard boundary)", () => {
|
|
55
|
+
const s = snap([["d1", "decision"], ["f1", "factor"]]);
|
|
56
|
+
const decisions = [
|
|
57
|
+
{ action: "archive", ids: ["d1"], reason: "阶段性结论,已沉淀进项目记忆" },
|
|
58
|
+
{ action: "archive", ids: ["f1"], reason: "清理" }
|
|
59
|
+
];
|
|
60
|
+
const { ok, skipped } = validateDecisions(decisions, s, { skipInvalid: true });
|
|
61
|
+
assert.equal(ok, true);
|
|
62
|
+
assert.deepEqual(skipped, [], "guard applies to long-retention types only");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("#104-2: conflict-loser archive is exempt (adjudication, not archive action)", () => {
|
|
66
|
+
const s = snap([["w", "preference"], ["l", "preference"]]);
|
|
67
|
+
const decisions = [{ action: "conflict", winner: "w", loser: "l", reason: "内容矛盾,保留更新的信息" }];
|
|
68
|
+
const { ok, skipped } = validateDecisions(decisions, s, { skipInvalid: true });
|
|
69
|
+
assert.equal(ok, true);
|
|
70
|
+
assert.deepEqual(skipped, [], "conflict adjudication does not go through the archive guard");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------- 批量上限(纯函数)
|
|
74
|
+
|
|
75
|
+
test("#104-2: archive count above the cap rejects the whole batch (global gate)", () => {
|
|
76
|
+
const entries = Array.from({ length: 10 }, (_, i) => [`d${i}`, "decision"]);
|
|
77
|
+
const s = snap(entries);
|
|
78
|
+
const decisions = entries.map(([id]) => ({ action: "archive", ids: [id], reason: "stale" }));
|
|
79
|
+
const { ok, errors } = validateDecisions(decisions, s, { skipInvalid: true });
|
|
80
|
+
assert.equal(ok, false, "9 archives > default cap 8 → reject even under skipInvalid");
|
|
81
|
+
assert.ok(errors.some((e) => e.includes("too many archive decisions")), `cap error present, got: ${errors.join("; ")}`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("#104-2: archive cap is configurable", () => {
|
|
85
|
+
const entries = Array.from({ length: 10 }, (_, i) => [`d${i}`, "decision"]);
|
|
86
|
+
const s = snap(entries);
|
|
87
|
+
const decisions = entries.map(([id]) => ({ action: "archive", ids: [id], reason: "stale" }));
|
|
88
|
+
const { ok, errors } = validateDecisions(decisions, s, { skipInvalid: true, maxArchivePerRun: 20 });
|
|
89
|
+
assert.equal(ok, true, `raised cap lets the sweep through, got: ${errors.join("; ")}`);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------- 端到端(runDream)
|
|
93
|
+
|
|
94
|
+
test("#104-2: runDream skips a value-judgment archive of a long-retention type and lands the rest", async () => {
|
|
95
|
+
const store = createStore(":memory:");
|
|
96
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
97
|
+
service.saveWithDedupe({ type: "project", title: "旧项目", content: "已完结并被取代", importance: 3 });
|
|
98
|
+
service.saveWithDedupe({ type: "constraint", title: "接口约束", content: "v1 接口只能串行调用", importance: 3 });
|
|
99
|
+
const ctx = mockCtx({
|
|
100
|
+
onConsolidation: (listText) => {
|
|
101
|
+
const entries = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)/g)].map((m) => ({ id: m[1], type: m[2] }));
|
|
102
|
+
const projectId = entries.find((e) => e.type === "project").id;
|
|
103
|
+
const constraintId = entries.find((e) => e.type === "constraint").id;
|
|
104
|
+
return JSON.stringify([
|
|
105
|
+
{ action: "archive", ids: [projectId], reason: "重复或过时" },
|
|
106
|
+
{ action: "archive", ids: [constraintId], reason: "保持知识库整洁,参考价值较低" }
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
111
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "deepseek", dreamModel: "deepseek-chat" });
|
|
112
|
+
assert.notEqual(result.status, "failed", "guard skips instead of failing the run");
|
|
113
|
+
const constraint = service.all().find((m) => m.type === "constraint");
|
|
114
|
+
assert.equal(constraint.archived, false, "value-judgment archive of a long-retention type must not land");
|
|
115
|
+
store.close();
|
|
116
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createDreamScheduler, validateDecisions } from "../src/dream.js";
|
|
6
|
+
import { mockCtx } from "./helpers/dream-mock.js";
|
|
7
|
+
|
|
8
|
+
// 回归(issue #104 方向 1):dreamMinExplicitCoverage 覆盖率护栏误伤「挑重点」
|
|
9
|
+
// 型输出。提示词明文要求「无问题的条目无需输出」(lang.js 硬性规则),模型
|
|
10
|
+
// 只显式决策少数条目是提示词要求的行为;但护栏把显式覆盖率 < 下限判成
|
|
11
|
+
// 「截断 → 隐式 keep 洗白」整单拒绝——heptaspirit 实测官方路由 + 强模型也
|
|
12
|
+
// 天天命中(81 轮 failed 全是 coverage,applied=0,LLM 调用本身已成功)。
|
|
13
|
+
// 修复:coverage 不足降级为 degraded(合法子集照常应用 + 未提及条目隐式
|
|
14
|
+
// keep),降级理由落库 outcome.degradations;护栏以状态暴露而非一票否决。
|
|
15
|
+
// 明确异常信号(create 上限等)保持整单拒绝不变;dreamImplicitKeep=false 的
|
|
16
|
+
// 严格模式不受影响。
|
|
17
|
+
|
|
18
|
+
function snapshot(ids, type = "project") {
|
|
19
|
+
return new Map(ids.map((id, i) => [id, { id, type, title: `t${i}`, content: `c${i}`, importance: 3, archived: false, forgotten: false }]));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------- validateDecisions
|
|
23
|
+
|
|
24
|
+
test("#104-1: coverage shortfall degrades — ok:true + reason surfaced, implicit keeps fill", () => {
|
|
25
|
+
const snap = snapshot(["a", "b", "c", "d"]);
|
|
26
|
+
const decisions = [{ action: "update", ids: ["a"], title: "t-new", content: "c-new" }];
|
|
27
|
+
const { ok, errors, coverageShortfall } = validateDecisions(decisions, snap, {
|
|
28
|
+
dreamImplicitKeep: true,
|
|
29
|
+
dreamMinExplicitCoverage: 0.5,
|
|
30
|
+
skipInvalid: true
|
|
31
|
+
});
|
|
32
|
+
assert.equal(ok, true, "valid subset is applied instead of whole-order rejection");
|
|
33
|
+
assert.deepEqual(errors, [], "coverage shortfall is not a per-decision error");
|
|
34
|
+
assert.match(String(coverageShortfall), /coverage/, "degradation reason surfaced for audit");
|
|
35
|
+
assert.equal(decisions.length, 4, "unclaimed ids filled with implicit keeps");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("#104-1: sufficient coverage carries no shortfall marker", () => {
|
|
39
|
+
const snap = snapshot(["a", "b"]);
|
|
40
|
+
const decisions = [{ action: "archive", ids: ["a"], reason: "stale" }, { action: "keep", ids: ["b"] }];
|
|
41
|
+
const { ok, coverageShortfall } = validateDecisions(decisions, snap, {
|
|
42
|
+
dreamImplicitKeep: true,
|
|
43
|
+
dreamMinExplicitCoverage: 0.5
|
|
44
|
+
});
|
|
45
|
+
assert.equal(ok, true);
|
|
46
|
+
assert.equal(coverageShortfall ?? null, null);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("#104-1: strict mode (implicitKeep=false) unchanged — missing ids still reject", () => {
|
|
50
|
+
const snap = snapshot(["a", "b"]);
|
|
51
|
+
const decisions = [{ action: "keep", ids: ["a"] }];
|
|
52
|
+
const { ok } = validateDecisions(decisions, snap, { dreamImplicitKeep: false });
|
|
53
|
+
assert.equal(ok, false, "strict mode keeps whole-order rejection");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------- runDream 端到端
|
|
57
|
+
|
|
58
|
+
test("#104-1: runDream records degraded (not failed) with the coverage reason in the run row", async () => {
|
|
59
|
+
const store = createStore(":memory:");
|
|
60
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
61
|
+
for (let i = 0; i < 20; i++) {
|
|
62
|
+
service.saveWithDedupe({ type: "project", title: `主题${i}`, content: `内容${i}`, importance: 3 });
|
|
63
|
+
}
|
|
64
|
+
// 只显式决策 1/20 = 5%(< 默认下限 50%)——提示词要求的「挑重点」形态
|
|
65
|
+
const ctx = mockCtx({
|
|
66
|
+
onConsolidation: (listText) => {
|
|
67
|
+
const ids = [...listText.matchAll(/id=([^\s|]+)\s*\|\s*type=(\w+)/g)].map((m) => m[1]);
|
|
68
|
+
return JSON.stringify([{ action: "archive", ids: [ids[0]], reason: "stale variant" }]);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
72
|
+
const result = await dream.runDream(ctx, service, {
|
|
73
|
+
dreamProvider: "deepseek",
|
|
74
|
+
dreamModel: "deepseek-chat",
|
|
75
|
+
dreamMinExplicitCoverage: 0.5
|
|
76
|
+
});
|
|
77
|
+
assert.equal(result.status, "degraded", "coverage shortfall degrades the run, not rejects it");
|
|
78
|
+
assert.equal(result.ok, true, "valid subset absorbed — scheduler baseline advances");
|
|
79
|
+
assert.ok(result.applied >= 1, "the explicit decision was applied");
|
|
80
|
+
|
|
81
|
+
const run = store.listDreamRuns()[0];
|
|
82
|
+
assert.equal(run.status, "degraded");
|
|
83
|
+
const degradations = run.outcome?.degradations ?? [];
|
|
84
|
+
assert.ok(degradations.some((s) => String(s).includes("coverage")), "coverage reason persisted in outcome.degradations");
|
|
85
|
+
store.close();
|
|
86
|
+
});
|
package/test/dream.test.js
CHANGED
|
@@ -789,8 +789,10 @@ test("autoDream with 650 memories: sliding window truncates snapshot + implicit
|
|
|
789
789
|
dreamProvider: "deepseek", dreamModel: "deepseek-chat", dreamMaxSnapshotSize: 200,
|
|
790
790
|
// mock only claims 20/200 = 10%; the implicit-keep coverage floor must be
|
|
791
791
|
// lowered so this deliberate "tiny explicit claim" scenario still passes
|
|
792
|
-
// (it exercises the window + keep-fill, not the coverage guard)
|
|
793
|
-
|
|
792
|
+
// (it exercises the window + keep-fill, not the coverage guard). Same for
|
|
793
|
+
// the archive cap: 20 mock archives exceed the default 8 — #104 方向 2.
|
|
794
|
+
dreamMinExplicitCoverage: 0.1,
|
|
795
|
+
dreamMaxArchivePerRun: 20
|
|
794
796
|
});
|
|
795
797
|
assert.equal(result.ok, true, "run succeeds instead of 677-error rejection");
|
|
796
798
|
assert.ok(result.applied > 0, "archive decisions applied");
|
|
@@ -807,16 +809,18 @@ test("autoDream with 650 memories: sliding window truncates snapshot + implicit
|
|
|
807
809
|
|
|
808
810
|
// --- v0.4.4 fix: 残缺输出防洗白(显式覆盖率下限) + 严格模式透传 --------------
|
|
809
811
|
|
|
810
|
-
test("validateDecisions
|
|
812
|
+
test("validateDecisions degrades a low-coverage valid output (Issue #104 方向 1)", () => {
|
|
811
813
|
const snap = snapshot(["a", "b", "c", "d"]);
|
|
812
814
|
const decisions = [{ action: "keep", ids: ["a"] }]; // claims 1/4 = 25%
|
|
813
|
-
const { ok, errors } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
814
|
-
assert.equal(ok,
|
|
815
|
-
assert.
|
|
816
|
-
|
|
817
|
-
|
|
815
|
+
const { ok, errors, coverageShortfall } = validateDecisions(decisions, snap, { dreamMinExplicitCoverage: 0.5 });
|
|
816
|
+
assert.equal(ok, true, "valid subset applies — coverage shortfall degrades instead of rejecting");
|
|
817
|
+
assert.deepEqual(errors, [], "shortfall is not a per-decision error");
|
|
818
|
+
assert.match(
|
|
819
|
+
String(coverageShortfall),
|
|
820
|
+
/explicit decision coverage 25% < minimum 50%/,
|
|
821
|
+
`coverage reason surfaced, got: ${coverageShortfall}`
|
|
818
822
|
);
|
|
819
|
-
assert.equal(decisions.length,
|
|
823
|
+
assert.equal(decisions.length, 4, "implicit keeps fill the unclaimed ids");
|
|
820
824
|
});
|
|
821
825
|
|
|
822
826
|
test("validateDecisions covers the whole snapshot when explicit coverage meets the floor", () => {
|
|
@@ -1014,7 +1018,7 @@ test("validateDecisions skipInvalid: a single invalid decision is skipped, valid
|
|
|
1014
1018
|
assert.ok(decisions.some((d) => d.action === "keep" && d.ids.includes("j")), "j auto-kept");
|
|
1015
1019
|
});
|
|
1016
1020
|
|
|
1017
|
-
test("validateDecisions skipInvalid: an all-invalid batch still rejects (
|
|
1021
|
+
test("validateDecisions skipInvalid: an all-invalid batch still rejects (zero-survivor guard, #104 方向 1 重审后保留)", () => {
|
|
1018
1022
|
const snap = new Map([
|
|
1019
1023
|
["p", { id: "p", type: "preference", title: "语言", content: "中文", importance: 3, archived: false, forgotten: false }],
|
|
1020
1024
|
["j", { id: "j", type: "project", title: "插件", content: "内容", importance: 3, archived: false, forgotten: false }],
|
|
@@ -1029,7 +1033,7 @@ test("validateDecisions skipInvalid: an all-invalid batch still rejects (coverag
|
|
|
1029
1033
|
const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
1030
1034
|
assert.equal(ok, false, "no valid decisions left → whole batch rejected");
|
|
1031
1035
|
assert.equal(skipped.length, 2);
|
|
1032
|
-
assert.ok(errors.some((e) => e.includes("
|
|
1036
|
+
assert.ok(errors.some((e) => e.includes("nothing valid survived")), "zero-survivor guard error present");
|
|
1033
1037
|
assert.equal(decisions.length, 2, "rejected batch left untouched (splice only on the success path)");
|
|
1034
1038
|
});
|
|
1035
1039
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
6
|
+
|
|
7
|
+
// 回归(issue #198):注入渲染是同步的(宿主 systemPrompt 不支持异步 text),
|
|
8
|
+
// 查询向量只能异步 prefetch 给下一轮用——首轮 queryVectorCache 必 miss。
|
|
9
|
+
// 此时 injectCandidates 落到静态 priority 排序(summary > preference >
|
|
10
|
+
// importance×quality):老的高重要性 preference 会占满全部槽位,当前话题的
|
|
11
|
+
// decision/project 进不来(报告者库:427 活跃、29 条 imp=5 preference)。
|
|
12
|
+
// 修复:首轮同步兜底用 BM25 词法召回领位(纯进程内、无需向量);语义向量
|
|
13
|
+
// 命中时行为不变(向量 > 缓存召回 > BM25 > 静态排序)。
|
|
14
|
+
|
|
15
|
+
function setup(config = {}) {
|
|
16
|
+
const store = createStore(":memory:");
|
|
17
|
+
const service = createService({ store, mirror: null, config });
|
|
18
|
+
return { store, service };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 与查询词法完全不相交的旧高价值 preference(双字组不重叠)
|
|
22
|
+
function seedStalePreferences(service, n = 8) {
|
|
23
|
+
const ids = [];
|
|
24
|
+
for (let i = 0; i < n; i++) {
|
|
25
|
+
ids.push(service.saveWithDedupe({
|
|
26
|
+
type: "preference",
|
|
27
|
+
title: `旧偏好条目${i}`,
|
|
28
|
+
content: `用户喜欢深色界面与紧凑排版,这是第${i}条历史偏好存档`
|
|
29
|
+
, importance: 5 }).memory.id);
|
|
30
|
+
}
|
|
31
|
+
return ids;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("#198: first round (no vector) leads with BM25 topical hits, not stale preferences", () => {
|
|
35
|
+
const { service, store } = setup();
|
|
36
|
+
seedStalePreferences(service, 8);
|
|
37
|
+
const onTopic = service.saveWithDedupe({
|
|
38
|
+
type: "decision",
|
|
39
|
+
title: "夜航限流选型",
|
|
40
|
+
content: "决定采用令牌桶做夜航App的限流方案,阈值按用户等级分层",
|
|
41
|
+
importance: 3
|
|
42
|
+
}).memory;
|
|
43
|
+
|
|
44
|
+
const picked = service.injectCandidates({ query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3 });
|
|
45
|
+
assert.ok(picked.some((m) => m.id === onTopic.id), "on-topic decision must enter the first-round block");
|
|
46
|
+
assert.equal(picked[0].id, onTopic.id, "BM25 topical hit leads the block");
|
|
47
|
+
store.close();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("#198: legacy static order is kept when bm25 is disabled", () => {
|
|
51
|
+
const { service, store } = setup({ bm25SearchEnabled: false });
|
|
52
|
+
seedStalePreferences(service, 8);
|
|
53
|
+
const onTopic = service.saveWithDedupe({
|
|
54
|
+
type: "decision",
|
|
55
|
+
title: "夜航限流选型",
|
|
56
|
+
content: "决定采用令牌桶做夜航App的限流方案,阈值按用户等级分层",
|
|
57
|
+
importance: 3
|
|
58
|
+
}).memory;
|
|
59
|
+
|
|
60
|
+
const picked = service.injectCandidates({ query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3 });
|
|
61
|
+
assert.ok(!picked.some((m) => m.id === onTopic.id), "bm25 off -> legacy static order (preferences fill the slots)");
|
|
62
|
+
store.close();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("#198: vector hits still outrank BM25 when a queryVector is available", async () => {
|
|
66
|
+
const { service, store } = setup();
|
|
67
|
+
seedStalePreferences(service, 3);
|
|
68
|
+
// 词法与查询不相交、但向量命中的记忆:语义路径必须压过 BM25 词法路径。
|
|
69
|
+
const vectorHit = service.saveWithDedupe({
|
|
70
|
+
type: "project",
|
|
71
|
+
title: "星图渲染重构",
|
|
72
|
+
content: "星图渲染重构记录:绘制管线迁移与图层合并",
|
|
73
|
+
importance: 3
|
|
74
|
+
}).memory;
|
|
75
|
+
const bm25Hit = service.saveWithDedupe({
|
|
76
|
+
type: "decision",
|
|
77
|
+
title: "夜航限流选型",
|
|
78
|
+
content: "决定采用令牌桶做夜航App的限流方案,阈值按用户等级分层",
|
|
79
|
+
importance: 3
|
|
80
|
+
}).memory;
|
|
81
|
+
const vectorIndex = createVectorIndex({ store });
|
|
82
|
+
service.setVectorIndex(vectorIndex);
|
|
83
|
+
vectorIndex.saveEmbedding(vectorHit.id, [1, 0, 0]);
|
|
84
|
+
|
|
85
|
+
const picked = service.injectCandidates({ query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0] });
|
|
86
|
+
assert.equal(picked[0].id, vectorHit.id, "vector hit leads; BM25 only fills the gap when no vector");
|
|
87
|
+
assert.ok(picked.some((m) => m.id === bm25Hit.id), "BM25 hit still present among candidates");
|
|
88
|
+
store.close();
|
|
89
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
6
|
+
import { createSummarizer } from "../src/summarize.js";
|
|
7
|
+
import { runSleep } from "../src/dream/sleep.js";
|
|
8
|
+
import { extractEntities } from "../src/entities/extractor.js";
|
|
9
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
10
|
+
|
|
11
|
+
// 回归:dsh-llm 的消息契约要求每条消息带 source。provider 适配器会直接读
|
|
12
|
+
// message.source.kind 来区分用户内容与工具结果(例如
|
|
13
|
+
// @mars-sea/dsh-commandcode-provider 的 messagesToCC),缺字段时请求在序列化
|
|
14
|
+
// 阶段就抛 "Cannot read properties of undefined (reading 'kind')",四条 LLM 管线
|
|
15
|
+
// (dream / sleep / summarize / entity extraction)全军覆没、耗时 0ms。
|
|
16
|
+
// 这里用同等严格的桩驱动真实管线,任何新加的消息漏了 source 都会失败。
|
|
17
|
+
function assertMessageSources(options) {
|
|
18
|
+
for (const message of options.messages ?? []) {
|
|
19
|
+
assert.equal(
|
|
20
|
+
message.source?.kind,
|
|
21
|
+
"plugin",
|
|
22
|
+
`LLM 消息缺 source(provider 会抛 TypeError): ${message.role} / ${String(message.content?.[0]?.text ?? "").slice(0, 40)}`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const logger = { warn: () => {}, info: () => {} };
|
|
28
|
+
|
|
29
|
+
function makeService() {
|
|
30
|
+
const store = createStore(":memory:");
|
|
31
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
32
|
+
return { store, service };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// dsh-llm chunk 形状与真实 provider 对齐:文本增量 + 终止 chunk。
|
|
36
|
+
function reply(text, finish = { reason: { kind: "stop" } }) {
|
|
37
|
+
return (async function* () {
|
|
38
|
+
yield { type: "text-delta", index: 0, text };
|
|
39
|
+
yield { type: "finish", ...finish };
|
|
40
|
+
})();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------- dream
|
|
44
|
+
|
|
45
|
+
test("dream: consolidation and summary messages carry source", async () => {
|
|
46
|
+
const { store, service } = makeService();
|
|
47
|
+
service.saveWithDedupe({ type: "project", title: "主题01", content: "规范内容", importance: 5 });
|
|
48
|
+
service.saveWithDedupe({ type: "project", title: "主题01·变体", content: "旧变体", importance: 3 });
|
|
49
|
+
|
|
50
|
+
const calls = [];
|
|
51
|
+
const ctx = {
|
|
52
|
+
logger,
|
|
53
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "source-model" }) },
|
|
54
|
+
llm: {
|
|
55
|
+
async *stream(options) {
|
|
56
|
+
calls.push(options);
|
|
57
|
+
assertMessageSources(options);
|
|
58
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
59
|
+
yield* reply(userText.startsWith("id=") ? "[]" : "记忆库总览:已巩固。");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
65
|
+
const run = await dream.runDream(ctx, service, {});
|
|
66
|
+
assert.equal(run.ok, true, "dream run succeeds against the strict provider stub");
|
|
67
|
+
assert.ok(calls.some((c) => c.purpose === "compaction"), "consolidation called the LLM");
|
|
68
|
+
assert.ok(calls.length >= 2, "consolidation + summary both called");
|
|
69
|
+
store.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------- summarize
|
|
73
|
+
|
|
74
|
+
test("summarize: distilled transcript and prompt messages carry source", async () => {
|
|
75
|
+
const { store, service } = makeService();
|
|
76
|
+
const events = [];
|
|
77
|
+
const calls = [];
|
|
78
|
+
const ctx = {
|
|
79
|
+
on(name, fn) {
|
|
80
|
+
events.push({ name, fn });
|
|
81
|
+
return () => {};
|
|
82
|
+
},
|
|
83
|
+
logger,
|
|
84
|
+
llm: {
|
|
85
|
+
stream(options) {
|
|
86
|
+
calls.push(options);
|
|
87
|
+
assertMessageSources(options);
|
|
88
|
+
return reply("[]", { kind: "ok" });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
createSummarizer(ctx, service, { autoSummarize: true });
|
|
93
|
+
|
|
94
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
95
|
+
const session = {
|
|
96
|
+
id: "s-source",
|
|
97
|
+
requestHeader: () => ({ config: { provider: "mock", model: "source-model" } }),
|
|
98
|
+
events: [
|
|
99
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "帮我选型" }] } },
|
|
100
|
+
{ seq: 2, type: "turn/end" }
|
|
101
|
+
]
|
|
102
|
+
};
|
|
103
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
104
|
+
|
|
105
|
+
assert.equal(calls.length, 1, "summarization called the LLM");
|
|
106
|
+
store.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------- sleep
|
|
110
|
+
|
|
111
|
+
test("sleep: conflict and pattern messages carry source", async () => {
|
|
112
|
+
const { store, service } = makeService();
|
|
113
|
+
const vectorIndex = createVectorIndex({ store });
|
|
114
|
+
const embedder = {
|
|
115
|
+
embedSingle: async () => [1, 0, 0],
|
|
116
|
+
embed: async () => [1, 0, 0],
|
|
117
|
+
schedule: () => {},
|
|
118
|
+
modelHash: "mock#1",
|
|
119
|
+
dimension: 3
|
|
120
|
+
};
|
|
121
|
+
service.setEmbedder(embedder);
|
|
122
|
+
service.setVectorIndex(vectorIndex);
|
|
123
|
+
// 冲突候选要在向量索引里真有向量(同 sleep.test.js 的 seedConflictPair);
|
|
124
|
+
// 同时给 pattern 阶段留下 2 条素材。
|
|
125
|
+
const a = service.saveWithDedupe({ type: "project", title: "主题X", content: "内容A 关于主题X", importance: 3 }).memory;
|
|
126
|
+
const b = service.saveWithDedupe({ type: "project", title: "主题X副本", content: "内容B 关于主题X", importance: 3 }).memory;
|
|
127
|
+
vectorIndex.saveEmbedding(a.id, [1, 0, 0]);
|
|
128
|
+
vectorIndex.saveEmbedding(b.id, [1, 0, 0]);
|
|
129
|
+
|
|
130
|
+
const calls = [];
|
|
131
|
+
const ctx = {
|
|
132
|
+
logger,
|
|
133
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
|
|
134
|
+
llm: {
|
|
135
|
+
async *stream(options) {
|
|
136
|
+
calls.push(options);
|
|
137
|
+
assertMessageSources(options);
|
|
138
|
+
yield* reply("[]");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const config = {
|
|
143
|
+
sleepModeEnabled: true,
|
|
144
|
+
sleepIdleMinutes: 5,
|
|
145
|
+
sleepMinIntervalHours: 8,
|
|
146
|
+
sleepConflictStrictness: "normal",
|
|
147
|
+
sleepArchiveDays: 30,
|
|
148
|
+
sleepCompressDays: 90,
|
|
149
|
+
sleepPatternMinMemories: 2,
|
|
150
|
+
sleepMaxPatternPerRun: 3
|
|
151
|
+
};
|
|
152
|
+
const result = await runSleep(ctx, service, config, logger, { embedder, vectorIndex }, null);
|
|
153
|
+
|
|
154
|
+
assert.ok(calls.some((c) => c.purpose === "sleep-conflict"), "conflict phase called the LLM");
|
|
155
|
+
assert.ok(calls.some((c) => c.purpose === "sleep-pattern"), "pattern phase called the LLM");
|
|
156
|
+
assert.ok(result.runId, "sleep run produced a receipt");
|
|
157
|
+
store.close();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------- entity extraction
|
|
161
|
+
|
|
162
|
+
test("entity extraction: messages handed to the llm adapter carry source", async () => {
|
|
163
|
+
const { store, service } = makeService();
|
|
164
|
+
const memory = service.saveWithDedupe({ type: "project", title: "协作", content: "Alpha 与 Beta 一起干活", importance: 3 }).memory;
|
|
165
|
+
|
|
166
|
+
const calls = [];
|
|
167
|
+
const callLLM = async (messages) => {
|
|
168
|
+
calls.push(messages);
|
|
169
|
+
assertMessageSources({ messages });
|
|
170
|
+
return "[]";
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
await extractEntities(memory, { store, config: {}, callLLM, logger });
|
|
174
|
+
|
|
175
|
+
assert.equal(calls.length, 1, "extraction called the LLM adapter");
|
|
176
|
+
store.close();
|
|
177
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { LocalReranker } from "../src/reranker.js";
|
|
4
|
+
|
|
5
|
+
// 回归(issue #188):Xenova/bge-reranker-base 是单 logit 交叉编码器——每个
|
|
6
|
+
// (query, passage) 对只输出 1 个 logit。tc 策略此前在 cols===1 时把 l1 退化成
|
|
7
|
+
// l0,sigmoid(l0 - l1) = sigmoid(0) = 0.5:所有候选分数恒为常量,重排静默失效
|
|
8
|
+
// (不抛错、日志照常 ready、语义状态照常 reranker="ready",只有分数是死的)。
|
|
9
|
+
// 单 logit 头的正类概率是 sigmoid(logit);sigmoid(l1 - l0) 只对双 logit 头
|
|
10
|
+
// (num_labels=2 的导出,如 bge-reranker-v2-m3)成立。修复后按 cols 分支,
|
|
11
|
+
// 两路各有回归;另加「分数全等 → warn 一次」的静默失效绊线。
|
|
12
|
+
|
|
13
|
+
function sigmoid(x) {
|
|
14
|
+
return 1 / (1 + Math.exp(-x));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function candidates(ids) {
|
|
18
|
+
return ids.map((id) => ({ id, title: `T-${id}`, content: `C-${id}` }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 只支持 text-classification 的假引擎:cascade 先试 "rerank"(transformers
|
|
22
|
+
// 4.2.0 无此任务,真机上也必然先失败),随后落到 tc。
|
|
23
|
+
function makeTcFactory(dims, data) {
|
|
24
|
+
return async (task) => {
|
|
25
|
+
if (task === "rerank") throw new Error("unsupported task: rerank");
|
|
26
|
+
assert.equal(task, "text-classification");
|
|
27
|
+
return {
|
|
28
|
+
tokenizer: (texts, _opts) => ({ texts }),
|
|
29
|
+
model: async (inputs) => ({ logits: { dims, data: Float64Array.from(data.slice(0, inputs.texts.length * dims[1])) } })
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("#188: single-logit head scores sigmoid(logit) instead of constant 0.5", async () => {
|
|
35
|
+
// 报告的实测 logits:相关 -0.556、无关 -10.193,区分度近 10——模型判断得
|
|
36
|
+
// 很好,只是旧公式把它们全抹成 0.5000。
|
|
37
|
+
const r = new LocalReranker({
|
|
38
|
+
engineFactory: makeTcFactory([3, 1], [-0.5561, -10.193, 8.7])
|
|
39
|
+
});
|
|
40
|
+
await r.init();
|
|
41
|
+
const out = await r.rerank("查询", candidates(["rel", "noise", "hit"]));
|
|
42
|
+
// sigmoid(-0.5561)≈0.3645 保留;sigmoid(-10.193)≈3.7e-5 被 0.1 阈值滤掉;
|
|
43
|
+
// sigmoid(8.7)≈0.9998 居首。修复前三者全 0.5、顺序不变。
|
|
44
|
+
assert.deepEqual(out.map((x) => x.id), ["hit", "rel"]);
|
|
45
|
+
assert.ok(Math.abs(out[0].score - sigmoid(8.7)) < 1e-6, "hit = sigmoid(logit)");
|
|
46
|
+
assert.ok(Math.abs(out[1].score - sigmoid(-0.5561)) < 1e-6, "rel = sigmoid(logit)");
|
|
47
|
+
r.dispose();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("#188: dual-logit head keeps softmax-positive-class semantics", async () => {
|
|
51
|
+
// l0=-2,l1=2 → sigmoid(4);l0=1,l1=1.5 → sigmoid(0.5)——与原实现逐字节等价。
|
|
52
|
+
const r = new LocalReranker({
|
|
53
|
+
engineFactory: makeTcFactory([2, 2], [-2, 2, 1, 1.5])
|
|
54
|
+
});
|
|
55
|
+
await r.init();
|
|
56
|
+
const out = await r.rerank("查询", candidates(["a", "b"]));
|
|
57
|
+
assert.deepEqual(out.map((x) => x.id), ["a", "b"]);
|
|
58
|
+
assert.ok(Math.abs(out[0].score - sigmoid(4)) < 1e-6);
|
|
59
|
+
assert.ok(Math.abs(out[1].score - sigmoid(0.5)) < 1e-6);
|
|
60
|
+
r.dispose();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("#188: constant-score batch warns once (silent-failure tripwire)", async () => {
|
|
64
|
+
const warns = [];
|
|
65
|
+
const r = new LocalReranker({
|
|
66
|
+
scorePair: async () => 0.5,
|
|
67
|
+
logger: { warn: (m) => warns.push(m), info: () => {} }
|
|
68
|
+
});
|
|
69
|
+
await r.rerank("查询", candidates(["a", "b", "c"]));
|
|
70
|
+
await r.rerank("查询", candidates(["a", "b", "c"]));
|
|
71
|
+
assert.equal(warns.length, 1, "constant-score batch warns exactly once across calls");
|
|
72
|
+
r.dispose();
|
|
73
|
+
});
|
|
@@ -151,17 +151,19 @@ test("validateDecisions skipInvalid: conflict without a distinct winner/loser is
|
|
|
151
151
|
|
|
152
152
|
// ------------------------------------------------------- skipInvalid 与全局闸门
|
|
153
153
|
|
|
154
|
-
test("validateDecisions skipInvalid: valid subset below the coverage floor
|
|
154
|
+
test("validateDecisions skipInvalid: valid subset below the coverage floor degrades (Issue #104 方向 1)", () => {
|
|
155
155
|
const snap = makeSnap(["p", "j", "x"]);
|
|
156
156
|
const decisions = [
|
|
157
157
|
{ action: "archive", ids: ["p"], reason: "stale" },
|
|
158
158
|
{ action: "merge", ids: ["p", "j"], keepSource: "p", title: "跨类型", content: "m", importance: 4 }
|
|
159
159
|
];
|
|
160
|
-
const { ok, errors, skipped } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
161
|
-
assert.equal(ok,
|
|
160
|
+
const { ok, errors, skipped, coverageShortfall } = validateDecisions(decisions, snap, { skipInvalid: true });
|
|
161
|
+
assert.equal(ok, true, "valid subset applies — shortfall degrades instead of rejecting");
|
|
162
|
+
assert.deepEqual(errors, []);
|
|
162
163
|
assert.equal(skipped.length, 1);
|
|
163
|
-
assert.
|
|
164
|
-
|
|
164
|
+
assert.match(String(coverageShortfall), /coverage/, "shortfall reason surfaced for audit");
|
|
165
|
+
// claimed {p} = 1/3 = 33% < 50%;j/x 隐式 keep 补齐
|
|
166
|
+
assert.deepEqual(decisions.map((d) => d.action), ["archive", "keep", "keep"]);
|
|
165
167
|
});
|
|
166
168
|
|
|
167
169
|
test("validateDecisions skipInvalid: updates skipped for other reasons do not count toward the update cap", () => {
|