@modusensus/dsh-mneme 0.5.2 → 0.6.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 +434 -419
- package/lib/client.js +1302 -1302
- package/lib/config.js +15 -6
- package/lib/dream/sleep.js +4 -4
- package/lib/dream.js +96 -4
- package/lib/hot-memory.js +53 -53
- package/lib/index.js +20 -0
- package/lib/reranker.js +218 -218
- package/lib/service.js +1517 -1489
- package/lib/store.js +65 -6
- package/lib/tools.js +16 -5
- package/package.json +1 -1
- package/src/config.js +15 -6
- package/src/dream/sleep.js +4 -4
- package/src/dream.js +96 -4
- package/src/hot-memory.js +53 -53
- package/src/index.js +20 -0
- package/src/reranker.js +218 -218
- package/src/service.js +1517 -1489
- package/src/store.js +65 -6
- package/src/tools.js +16 -5
- package/test/hot-memory.test.js +174 -174
- package/test/normalize-decisions.test.js +120 -0
- package/test/reasoning-effort.test.js +27 -0
- package/test/reranker.test.js +240 -240
- package/test/service-search.test.js +199 -199
- package/test/service.test.js +106 -0
- package/test/store.test.js +76 -0
- package/test/tools.test.js +20 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Field-name normalization guard (v0.5.3): thinking-type models
|
|
2
|
+
// (deepseek-v4-flash etc.) sometimes ignore the prompt's exact decision schema
|
|
3
|
+
// and emit alias keys —实测方案 A 输出 "consolidation"/"target_ids"、方案 B
|
|
4
|
+
// 输出 "action"/"targetIds",都不是插件要求的 "action"/"ids"。normalizeDecisions
|
|
5
|
+
// 在 validateDecisions 之前把这些变体重写回规范字段名,让"语义正确但 schema
|
|
6
|
+
// 不听话"的输出仍被应用,而不是整单被拒。
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { normalizeDecisions, createDreamScheduler } from "../src/dream.js";
|
|
10
|
+
import { createStore } from "../src/store.js";
|
|
11
|
+
import { createService } from "../src/service.js";
|
|
12
|
+
|
|
13
|
+
test("normalizeDecisions: canonical decisions pass through untouched", () => {
|
|
14
|
+
const raw = [
|
|
15
|
+
{ action: "merge", ids: ["m1", "m2"], keepSource: "m1", title: "t", content: "c", importance: 4, reason: "主题相近" },
|
|
16
|
+
{ action: "conflict", winner: "m3", loser: "m4", reason: "矛盾" },
|
|
17
|
+
{ action: "update", ids: ["m5"], content: "修正" },
|
|
18
|
+
{ action: "archive", ids: ["m6"], reason: "过时" },
|
|
19
|
+
{ action: "create", type: "pattern", title: "新模式", content: "发现", importance: 3 }
|
|
20
|
+
];
|
|
21
|
+
assert.deepEqual(normalizeDecisions(raw), raw);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("normalizeDecisions: alias keys rewritten to canonical names", () => {
|
|
25
|
+
const raw = [
|
|
26
|
+
{ consolidation: "merge", target_ids: ["m1", "m2"], keep_source: "m1", new_title: "t", new_content: "c", priority: 4, rationale: "主题相近" },
|
|
27
|
+
{ action: "conflict", winner_id: "m3", loser_id: "m4", why: "矛盾" },
|
|
28
|
+
{ action: "update", targetIds: ["m5"], merged_content: "修正" },
|
|
29
|
+
{ action: "archive", memory_ids: ["m6"] }
|
|
30
|
+
];
|
|
31
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
32
|
+
{ action: "merge", ids: ["m1", "m2"], keepSource: "m1", title: "t", content: "c", importance: 4, reason: "主题相近" },
|
|
33
|
+
{ action: "conflict", winner: "m3", loser: "m4", reason: "矛盾" },
|
|
34
|
+
{ action: "update", ids: ["m5"], content: "修正" },
|
|
35
|
+
{ action: "archive", ids: ["m6"] }
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("normalizeDecisions: action value synonyms normalized", () => {
|
|
40
|
+
const raw = [
|
|
41
|
+
{ action: "archived", ids: ["m1"], reason: "重复" },
|
|
42
|
+
{ action: "consolidation", target_ids: ["m2", "m3"], keep_source: "m2" },
|
|
43
|
+
{ action: "combine", targetIds: ["m4", "m5"], keeper: "m5" },
|
|
44
|
+
{ action: "Remove", memory_ids: ["m6"] }
|
|
45
|
+
];
|
|
46
|
+
const normalized = normalizeDecisions(raw);
|
|
47
|
+
assert.equal(normalized[0].action, "archive");
|
|
48
|
+
assert.equal(normalized[1].action, "merge");
|
|
49
|
+
assert.equal(normalized[1].ids[0], "m2");
|
|
50
|
+
assert.equal(normalized[2].action, "merge");
|
|
51
|
+
assert.equal(normalized[2].keepSource, "m5");
|
|
52
|
+
assert.equal(normalized[3].action, "archive");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("normalizeDecisions: wrapper object around the array is unwrapped", () => {
|
|
56
|
+
const raw = { consolidation: [{ action: "merge", ids: ["m1"], keepSource: "m1", title: "t", content: "c" }] };
|
|
57
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
58
|
+
{ action: "merge", ids: ["m1"], keepSource: "m1", title: "t", content: "c" }
|
|
59
|
+
]);
|
|
60
|
+
const raw2 = { decisions: [{ action: "archive", target_ids: ["m9"] }] };
|
|
61
|
+
assert.deepEqual(normalizeDecisions(raw2), [{ action: "archive", ids: ["m9"] }]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("normalizeDecisions: single decision object (non-array) normalized too", () => {
|
|
65
|
+
const raw = { action: "update", targetIds: ["m1"], new_content: "修正" };
|
|
66
|
+
assert.deepEqual(normalizeDecisions(raw), [{ action: "update", ids: ["m1"], content: "修正" }]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("normalizeDecisions: single-string ids become a one-element array", () => {
|
|
70
|
+
const raw = [{ action: "archive", target_id: "m7" }];
|
|
71
|
+
// target_id (singular) is not an alias key — the raw key survives untouched
|
|
72
|
+
// and validateDecisions rejects it (safe side). But targetIds (plural, string)
|
|
73
|
+
// IS mapped and wrapped.
|
|
74
|
+
const raw2 = [{ action: "archive", targetIds: "m7" }];
|
|
75
|
+
assert.deepEqual(normalizeDecisions(raw2), [{ action: "archive", ids: ["m7"] }]);
|
|
76
|
+
assert.deepEqual(normalizeDecisions(raw), [{ action: "archive", target_id: "m7" }]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("normalizeDecisions: create keeps its type field (never mistaken for action)", () => {
|
|
80
|
+
const raw = { action: "create", type: "preference", title: "语言", content: "中文", importance: 3 };
|
|
81
|
+
assert.deepEqual(normalizeDecisions(raw), [
|
|
82
|
+
{ action: "create", type: "preference", title: "语言", content: "中文", importance: 3 }
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("normalizeDecisions: null / non-parseable returns as-is (caller's no-json branch)", () => {
|
|
87
|
+
assert.equal(normalizeDecisions(null), null);
|
|
88
|
+
assert.equal(normalizeDecisions(undefined), undefined);
|
|
89
|
+
assert.equal(normalizeDecisions("not json"), "not json");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("dream runDream applies alias-key consolidation output end-to-end", async () => {
|
|
93
|
+
const store = createStore(":memory:");
|
|
94
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
95
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
96
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
97
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
98
|
+
const ctx = {
|
|
99
|
+
logger: { warn: () => {} },
|
|
100
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
101
|
+
llm: {
|
|
102
|
+
async *stream(options) {
|
|
103
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
104
|
+
if (userText.startsWith("id=")) {
|
|
105
|
+
// deepseek-v4-flash 实测风格:consolidation 当 action、target_ids 当 ids
|
|
106
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
107
|
+
{ consolidation: "merge", target_ids: [a.id, b.id], keep_source: b.id, new_title: "插件总览", new_content: "合并内容", priority: 4 }
|
|
108
|
+
]) };
|
|
109
|
+
} else {
|
|
110
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览:默认摘要。" };
|
|
111
|
+
}
|
|
112
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
const result = await dream.runDream(ctx, service, {});
|
|
117
|
+
assert.equal(result.ok, true, "alias-key output must not be rejected wholesale");
|
|
118
|
+
assert.ok(result.applied > 0, "alias-key decisions still land changes");
|
|
119
|
+
store.close();
|
|
120
|
+
});
|
|
@@ -36,6 +36,11 @@ test("issue#9: reasoningEffort config defaults to none and rejects unknown value
|
|
|
36
36
|
assert.equal(cfg.sleepReasoningEffort, "none");
|
|
37
37
|
assert.equal(Config({ dreamReasoningEffort: "high" }).dreamReasoningEffort, "high");
|
|
38
38
|
assert.equal(Config({ sleepReasoningEffort: "medium" }).sleepReasoningEffort, "medium");
|
|
39
|
+
// v0.5.3: 'off' explicitly disables thinking — required for thinking-type
|
|
40
|
+
// models (deepseek-v4-flash) whose reasoning otherwise drains the whole
|
|
41
|
+
// token budget and returns an empty consolidation body.
|
|
42
|
+
assert.equal(Config({ dreamReasoningEffort: "off" }).dreamReasoningEffort, "off");
|
|
43
|
+
assert.equal(Config({ sleepReasoningEffort: "off" }).sleepReasoningEffort, "off");
|
|
39
44
|
assert.throws(() => Config({ dreamReasoningEffort: "bogus" }), "invalid effort rejected");
|
|
40
45
|
assert.throws(() => Config({ sleepReasoningEffort: "ultra" }), "invalid effort rejected");
|
|
41
46
|
});
|
|
@@ -107,6 +112,28 @@ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async ()
|
|
|
107
112
|
store.close();
|
|
108
113
|
});
|
|
109
114
|
|
|
115
|
+
test("issue#9: dream forwards dreamReasoningEffort:off (thinking disabled) on both LLM calls", async () => {
|
|
116
|
+
const store = createStore(":memory:");
|
|
117
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
118
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
119
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
120
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
121
|
+
const captured = [];
|
|
122
|
+
const ctx = dreamCtx({
|
|
123
|
+
captured,
|
|
124
|
+
onConsolidation: () => JSON.stringify([
|
|
125
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
|
|
126
|
+
])
|
|
127
|
+
});
|
|
128
|
+
const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "off" });
|
|
129
|
+
assert.equal(result.ok, true);
|
|
130
|
+
assert.equal(captured.length, 2);
|
|
131
|
+
for (const options of captured) {
|
|
132
|
+
assert.equal(options.reasoningEffort, "off", `reasoningEffort:"off" forwarded on ${options.purpose}`);
|
|
133
|
+
}
|
|
134
|
+
store.close();
|
|
135
|
+
});
|
|
136
|
+
|
|
110
137
|
// ---------------------------------------------------------------- sleep passthrough
|
|
111
138
|
|
|
112
139
|
function sleepSetup() {
|
package/test/reranker.test.js
CHANGED
|
@@ -1,240 +1,240 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { spawnSync } from "node:child_process";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { LocalReranker } from "../src/reranker.js";
|
|
6
|
-
|
|
7
|
-
/** Injected scorer: records (query, passage) calls, returns a fixed score. */
|
|
8
|
-
function makeScorer(scoreOf) {
|
|
9
|
-
const calls = [];
|
|
10
|
-
const fn = async (query, passage) => {
|
|
11
|
-
calls.push({ query, passage });
|
|
12
|
-
return typeof scoreOf === "function" ? scoreOf(passage) : scoreOf;
|
|
13
|
-
};
|
|
14
|
-
return { fn, calls };
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Fake feature-extraction engine: each input embeds to [1, k*0.25, 0, 0]. */
|
|
18
|
-
function makeFakeExtractor(dim = 4, calls = []) {
|
|
19
|
-
let k = 0;
|
|
20
|
-
const fn = async (texts) => {
|
|
21
|
-
calls.push({ count: texts.length });
|
|
22
|
-
const rows = texts.map(() => {
|
|
23
|
-
const n = k++;
|
|
24
|
-
return Array.from({ length: dim }, (__, j) => (j === 0 ? 1 : j === 1 ? n * 0.25 : 0));
|
|
25
|
-
});
|
|
26
|
-
const flat = Float32Array.from(rows.flat());
|
|
27
|
-
return { data: flat, dims: [texts.length, dim] };
|
|
28
|
-
};
|
|
29
|
-
fn.dispose = () => {
|
|
30
|
-
fn.disposed = true;
|
|
31
|
-
};
|
|
32
|
-
return fn;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Fake text-classification engine exposing tokenizer + model for the tc path. */
|
|
36
|
-
function makeFakeTc(logitsPerRow) {
|
|
37
|
-
const tokenizer = (texts, opts) => ({ texts, text_pair: opts.text_pair });
|
|
38
|
-
const model = async (inputs) => {
|
|
39
|
-
const n = inputs.texts.length;
|
|
40
|
-
const flat = new Float32Array(logitsPerRow.slice(0, n * 2));
|
|
41
|
-
return { logits: { data: flat, dims: [n, 2] } };
|
|
42
|
-
};
|
|
43
|
-
return { tokenizer, model, dispose: () => {} };
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function candidates(ids) {
|
|
47
|
-
return ids.map((id) => ({ id, title: `T-${id}`, content: `C-${id}` }));
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
test("init with injected scorePair skips model loading", async () => {
|
|
51
|
-
let factoryCalled = false;
|
|
52
|
-
const { fn, calls } = makeScorer(0.7);
|
|
53
|
-
const r = new LocalReranker({
|
|
54
|
-
scorePair: fn,
|
|
55
|
-
engineFactory: async () => {
|
|
56
|
-
factoryCalled = true;
|
|
57
|
-
throw new Error("should never load");
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
await r.init();
|
|
61
|
-
assert.equal(factoryCalled, false);
|
|
62
|
-
const out = await r.rerank("q", candidates(["a"]));
|
|
63
|
-
assert.deepEqual(out, [{ id: "a", score: 0.7 }]);
|
|
64
|
-
assert.equal(calls.length, 1);
|
|
65
|
-
assert.equal(calls[0].query, "q");
|
|
66
|
-
assert.equal(calls[0].passage, "T-a\nC-a");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("rerank returns results sorted by descending score", async () => {
|
|
70
|
-
const { fn } = makeScorer((p) => (p.includes("A") ? 0.9 : p.includes("B") ? 0.5 : 0.2));
|
|
71
|
-
const r = new LocalReranker({ scorePair: fn });
|
|
72
|
-
await r.init();
|
|
73
|
-
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
74
|
-
assert.deepEqual(out.map((x) => x.id), ["A", "B", "C"]);
|
|
75
|
-
assert.deepEqual(out.map((x) => x.score), [0.9, 0.5, 0.2]);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("rerank returns [] for empty candidates", async () => {
|
|
79
|
-
const { fn } = makeScorer(0.9);
|
|
80
|
-
const r = new LocalReranker({ scorePair: fn });
|
|
81
|
-
await r.init();
|
|
82
|
-
assert.deepEqual(await r.rerank("q", []), []);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("rerank drops candidates below scoreThreshold", async () => {
|
|
86
|
-
const { fn } = makeScorer((p) => (p.includes("A") ? 0.8 : p.includes("B") ? 0.4 : 0.05));
|
|
87
|
-
const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0.3 });
|
|
88
|
-
await r.init();
|
|
89
|
-
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
90
|
-
assert.deepEqual(out.map((x) => x.id), ["A", "B"]);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test("rerank clamps scores into 0..1", async () => {
|
|
94
|
-
const { fn } = makeScorer((p) => (p.includes("A") ? 1.5 : p.includes("B") ? -0.2 : NaN));
|
|
95
|
-
const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0 });
|
|
96
|
-
await r.init();
|
|
97
|
-
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
98
|
-
assert.deepEqual(out.map((x) => x.score), [1, 0, 0]);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("rerank truncates to maxCandidates", async () => {
|
|
102
|
-
const { fn, calls } = makeScorer(0.6);
|
|
103
|
-
const r = new LocalReranker({ scorePair: fn, maxCandidates: 2 });
|
|
104
|
-
await r.init();
|
|
105
|
-
const out = await r.rerank("q", candidates(["a", "b", "c", "d"]));
|
|
106
|
-
assert.equal(calls.length, 2);
|
|
107
|
-
assert.equal(out.length, 2);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("passage falls back to title when content is missing", async () => {
|
|
111
|
-
const { fn, calls } = makeScorer(0.8);
|
|
112
|
-
const r = new LocalReranker({ scorePair: fn });
|
|
113
|
-
await r.init();
|
|
114
|
-
await r.rerank("q", [{ id: "x", title: "only-title", content: "" }]);
|
|
115
|
-
assert.equal(calls[0].passage, "only-title");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("rerank propagates injected scorer failures", async () => {
|
|
119
|
-
const bad = async () => {
|
|
120
|
-
throw new Error("scorer boom");
|
|
121
|
-
};
|
|
122
|
-
const r = new LocalReranker({ scorePair: bad });
|
|
123
|
-
await r.init();
|
|
124
|
-
await assert.rejects(() => r.rerank("q", candidates(["a"])), /scorer boom/);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("rerank throws on non-array candidates", async () => {
|
|
128
|
-
const r = new LocalReranker({ scorePair: async () => 0.5 });
|
|
129
|
-
await r.init();
|
|
130
|
-
await assert.rejects(() => r.rerank("q", "not-an-array"), /array/);
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
test("init throws when no strategy can load", async () => {
|
|
134
|
-
const r = new LocalReranker({
|
|
135
|
-
engineFactory: async () => {
|
|
136
|
-
throw new Error("Unsupported pipeline");
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
await assert.rejects(() => r.init(), /LocalReranker failed to load/);
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("init cascades rerank -> tc -> feature-extraction and batches", async () => {
|
|
143
|
-
const calls = [];
|
|
144
|
-
const loader = async (task) => {
|
|
145
|
-
if (task === "feature-extraction") return makeFakeExtractor(4, calls);
|
|
146
|
-
throw new Error(`Unsupported pipeline: ${task}`);
|
|
147
|
-
};
|
|
148
|
-
const r = new LocalReranker({ engineFactory: loader, batchSize: 2 });
|
|
149
|
-
await r.init();
|
|
150
|
-
const out = await r.rerank("q", candidates(["a", "b", "c"]));
|
|
151
|
-
// Query k=0 [1,0,0,0]; a,b,c embed at k=1..3 -> cosine 0.970, 0.894, 0.8,
|
|
152
|
-
// all above the default threshold.
|
|
153
|
-
assert.equal(out.length, 3);
|
|
154
|
-
assert.deepEqual(out.map((x) => x.id), ["a", "b", "c"]);
|
|
155
|
-
assert.ok(out[0].score >= out[1].score && out[1].score >= out[2].score);
|
|
156
|
-
// Query embed (cached) + two batches of size 2 and 1.
|
|
157
|
-
assert.deepEqual(calls.map((c) => c.count), [1, 2, 1]);
|
|
158
|
-
// Query vector is cached: a second rerank only makes batch calls.
|
|
159
|
-
await r.rerank("q", candidates(["a", "b", "c"]));
|
|
160
|
-
assert.deepEqual(calls.map((c) => c.count), [1, 2, 1, 2, 1]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
test("text-classification strategy scores via logit delta", async () => {
|
|
164
|
-
const loader = async (task) => {
|
|
165
|
-
if (task === "text-classification") return makeFakeTc([0, 1, 0, 0, -2, 2]);
|
|
166
|
-
throw new Error(`Unsupported pipeline: ${task}`);
|
|
167
|
-
};
|
|
168
|
-
const r = new LocalReranker({ engineFactory: loader });
|
|
169
|
-
await r.init();
|
|
170
|
-
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
171
|
-
// sigmoid(1-0)=0.731, sigmoid(0)=0.5, sigmoid(2-(-2))=0.982.
|
|
172
|
-
assert.deepEqual(out.map((x) => x.id), ["C", "A", "B"]);
|
|
173
|
-
assert.ok(Math.abs(out[0].score - 0.982) < 1e-3);
|
|
174
|
-
assert.ok(Math.abs(out[1].score - 0.731) < 1e-3);
|
|
175
|
-
assert.ok(Math.abs(out[2].score - 0.5) < 1e-3);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
test("modelHash follows the embedder convention", () => {
|
|
179
|
-
const a = new LocalReranker({ model: "Xenova/bge-reranker-base" });
|
|
180
|
-
const b = new LocalReranker({ model: "Xenova/bge-reranker-base" });
|
|
181
|
-
assert.equal(a.modelHash, b.modelHash);
|
|
182
|
-
assert.match(a.modelHash, /^Xenova\/bge-reranker-base#[0-9a-f]+$/);
|
|
183
|
-
assert.notEqual(a.modelHash, new LocalReranker({ model: "other/model" }).modelHash);
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
test("dispose releases the loaded pipeline", async () => {
|
|
187
|
-
const extractor = makeFakeExtractor(4);
|
|
188
|
-
const r = new LocalReranker({
|
|
189
|
-
engineFactory: async (task) => {
|
|
190
|
-
if (task === "feature-extraction") return extractor;
|
|
191
|
-
throw new Error("Unsupported pipeline");
|
|
192
|
-
}
|
|
193
|
-
});
|
|
194
|
-
await r.init();
|
|
195
|
-
assert.ok(r.pipeline);
|
|
196
|
-
r.dispose();
|
|
197
|
-
assert.equal(r.pipeline, null);
|
|
198
|
-
assert.equal(extractor.disposed, true);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
test("default pipeline loader mirrors cache_dir onto env.cacheDir (issue #13)", () => {
|
|
202
|
-
// The fix lives in the module's *default* loader — the dynamic-import of
|
|
203
|
-
// @huggingface/transformers that the in-process tests bypass by injecting
|
|
204
|
-
// engineFactory. So it is exercised in a child node process under the
|
|
205
|
-
// --experimental-test-module-mocks flag: the transformers module is mocked
|
|
206
|
-
// with an empty env, LocalReranker uses the real default loader, and we
|
|
207
|
-
// assert env.cacheDir picks up the constructor's cache_dir. A regression
|
|
208
|
-
// (loader no longer mirroring) fails the child and surfaces here as a
|
|
209
|
-
// non-zero exit.
|
|
210
|
-
const srcUrl = new URL("../src/reranker.js", import.meta.url).href;
|
|
211
|
-
const cacheDir = "/tmp/dsh-mneme-cache-mirror-test";
|
|
212
|
-
const script = `
|
|
213
|
-
import { test } from "node:test";
|
|
214
|
-
const cacheDir = ${JSON.stringify(cacheDir)};
|
|
215
|
-
test("cache_dir is mirrored onto env.cacheDir", async (t) => {
|
|
216
|
-
t.mock.module("@huggingface/transformers", {
|
|
217
|
-
namedExports: {
|
|
218
|
-
env: {},
|
|
219
|
-
pipeline: async () => ({ dispose: () => {} })
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
const { LocalReranker } = await import(${JSON.stringify(srcUrl)});
|
|
223
|
-
const r = new LocalReranker({ cacheDir, device: "cpu" });
|
|
224
|
-
await r.init();
|
|
225
|
-
const { env } = await import("@huggingface/transformers");
|
|
226
|
-
if (env.cacheDir !== cacheDir) {
|
|
227
|
-
throw new Error("env.cacheDir not mirrored from cache_dir: " + env.cacheDir);
|
|
228
|
-
}
|
|
229
|
-
console.log("CACHE_MIRROR_OK");
|
|
230
|
-
});
|
|
231
|
-
`;
|
|
232
|
-
const res = spawnSync(process.execPath, [
|
|
233
|
-
"--experimental-test-module-mocks",
|
|
234
|
-
"--input-type=module",
|
|
235
|
-
"-e",
|
|
236
|
-
script
|
|
237
|
-
], { encoding: "utf8", cwd: fileURLToPath(new URL("..", import.meta.url)) });
|
|
238
|
-
assert.equal(res.status, 0, `cache-dir mirror child failed:\n${res.stdout}\n${res.stderr}`);
|
|
239
|
-
assert.match(res.stdout, /CACHE_MIRROR_OK/);
|
|
240
|
-
});
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { LocalReranker } from "../src/reranker.js";
|
|
6
|
+
|
|
7
|
+
/** Injected scorer: records (query, passage) calls, returns a fixed score. */
|
|
8
|
+
function makeScorer(scoreOf) {
|
|
9
|
+
const calls = [];
|
|
10
|
+
const fn = async (query, passage) => {
|
|
11
|
+
calls.push({ query, passage });
|
|
12
|
+
return typeof scoreOf === "function" ? scoreOf(passage) : scoreOf;
|
|
13
|
+
};
|
|
14
|
+
return { fn, calls };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Fake feature-extraction engine: each input embeds to [1, k*0.25, 0, 0]. */
|
|
18
|
+
function makeFakeExtractor(dim = 4, calls = []) {
|
|
19
|
+
let k = 0;
|
|
20
|
+
const fn = async (texts) => {
|
|
21
|
+
calls.push({ count: texts.length });
|
|
22
|
+
const rows = texts.map(() => {
|
|
23
|
+
const n = k++;
|
|
24
|
+
return Array.from({ length: dim }, (__, j) => (j === 0 ? 1 : j === 1 ? n * 0.25 : 0));
|
|
25
|
+
});
|
|
26
|
+
const flat = Float32Array.from(rows.flat());
|
|
27
|
+
return { data: flat, dims: [texts.length, dim] };
|
|
28
|
+
};
|
|
29
|
+
fn.dispose = () => {
|
|
30
|
+
fn.disposed = true;
|
|
31
|
+
};
|
|
32
|
+
return fn;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Fake text-classification engine exposing tokenizer + model for the tc path. */
|
|
36
|
+
function makeFakeTc(logitsPerRow) {
|
|
37
|
+
const tokenizer = (texts, opts) => ({ texts, text_pair: opts.text_pair });
|
|
38
|
+
const model = async (inputs) => {
|
|
39
|
+
const n = inputs.texts.length;
|
|
40
|
+
const flat = new Float32Array(logitsPerRow.slice(0, n * 2));
|
|
41
|
+
return { logits: { data: flat, dims: [n, 2] } };
|
|
42
|
+
};
|
|
43
|
+
return { tokenizer, model, dispose: () => {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function candidates(ids) {
|
|
47
|
+
return ids.map((id) => ({ id, title: `T-${id}`, content: `C-${id}` }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test("init with injected scorePair skips model loading", async () => {
|
|
51
|
+
let factoryCalled = false;
|
|
52
|
+
const { fn, calls } = makeScorer(0.7);
|
|
53
|
+
const r = new LocalReranker({
|
|
54
|
+
scorePair: fn,
|
|
55
|
+
engineFactory: async () => {
|
|
56
|
+
factoryCalled = true;
|
|
57
|
+
throw new Error("should never load");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
await r.init();
|
|
61
|
+
assert.equal(factoryCalled, false);
|
|
62
|
+
const out = await r.rerank("q", candidates(["a"]));
|
|
63
|
+
assert.deepEqual(out, [{ id: "a", score: 0.7 }]);
|
|
64
|
+
assert.equal(calls.length, 1);
|
|
65
|
+
assert.equal(calls[0].query, "q");
|
|
66
|
+
assert.equal(calls[0].passage, "T-a\nC-a");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("rerank returns results sorted by descending score", async () => {
|
|
70
|
+
const { fn } = makeScorer((p) => (p.includes("A") ? 0.9 : p.includes("B") ? 0.5 : 0.2));
|
|
71
|
+
const r = new LocalReranker({ scorePair: fn });
|
|
72
|
+
await r.init();
|
|
73
|
+
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
74
|
+
assert.deepEqual(out.map((x) => x.id), ["A", "B", "C"]);
|
|
75
|
+
assert.deepEqual(out.map((x) => x.score), [0.9, 0.5, 0.2]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("rerank returns [] for empty candidates", async () => {
|
|
79
|
+
const { fn } = makeScorer(0.9);
|
|
80
|
+
const r = new LocalReranker({ scorePair: fn });
|
|
81
|
+
await r.init();
|
|
82
|
+
assert.deepEqual(await r.rerank("q", []), []);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("rerank drops candidates below scoreThreshold", async () => {
|
|
86
|
+
const { fn } = makeScorer((p) => (p.includes("A") ? 0.8 : p.includes("B") ? 0.4 : 0.05));
|
|
87
|
+
const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0.3 });
|
|
88
|
+
await r.init();
|
|
89
|
+
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
90
|
+
assert.deepEqual(out.map((x) => x.id), ["A", "B"]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rerank clamps scores into 0..1", async () => {
|
|
94
|
+
const { fn } = makeScorer((p) => (p.includes("A") ? 1.5 : p.includes("B") ? -0.2 : NaN));
|
|
95
|
+
const r = new LocalReranker({ scorePair: fn, scoreThreshold: 0 });
|
|
96
|
+
await r.init();
|
|
97
|
+
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
98
|
+
assert.deepEqual(out.map((x) => x.score), [1, 0, 0]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("rerank truncates to maxCandidates", async () => {
|
|
102
|
+
const { fn, calls } = makeScorer(0.6);
|
|
103
|
+
const r = new LocalReranker({ scorePair: fn, maxCandidates: 2 });
|
|
104
|
+
await r.init();
|
|
105
|
+
const out = await r.rerank("q", candidates(["a", "b", "c", "d"]));
|
|
106
|
+
assert.equal(calls.length, 2);
|
|
107
|
+
assert.equal(out.length, 2);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("passage falls back to title when content is missing", async () => {
|
|
111
|
+
const { fn, calls } = makeScorer(0.8);
|
|
112
|
+
const r = new LocalReranker({ scorePair: fn });
|
|
113
|
+
await r.init();
|
|
114
|
+
await r.rerank("q", [{ id: "x", title: "only-title", content: "" }]);
|
|
115
|
+
assert.equal(calls[0].passage, "only-title");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rerank propagates injected scorer failures", async () => {
|
|
119
|
+
const bad = async () => {
|
|
120
|
+
throw new Error("scorer boom");
|
|
121
|
+
};
|
|
122
|
+
const r = new LocalReranker({ scorePair: bad });
|
|
123
|
+
await r.init();
|
|
124
|
+
await assert.rejects(() => r.rerank("q", candidates(["a"])), /scorer boom/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("rerank throws on non-array candidates", async () => {
|
|
128
|
+
const r = new LocalReranker({ scorePair: async () => 0.5 });
|
|
129
|
+
await r.init();
|
|
130
|
+
await assert.rejects(() => r.rerank("q", "not-an-array"), /array/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("init throws when no strategy can load", async () => {
|
|
134
|
+
const r = new LocalReranker({
|
|
135
|
+
engineFactory: async () => {
|
|
136
|
+
throw new Error("Unsupported pipeline");
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
await assert.rejects(() => r.init(), /LocalReranker failed to load/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("init cascades rerank -> tc -> feature-extraction and batches", async () => {
|
|
143
|
+
const calls = [];
|
|
144
|
+
const loader = async (task) => {
|
|
145
|
+
if (task === "feature-extraction") return makeFakeExtractor(4, calls);
|
|
146
|
+
throw new Error(`Unsupported pipeline: ${task}`);
|
|
147
|
+
};
|
|
148
|
+
const r = new LocalReranker({ engineFactory: loader, batchSize: 2 });
|
|
149
|
+
await r.init();
|
|
150
|
+
const out = await r.rerank("q", candidates(["a", "b", "c"]));
|
|
151
|
+
// Query k=0 [1,0,0,0]; a,b,c embed at k=1..3 -> cosine 0.970, 0.894, 0.8,
|
|
152
|
+
// all above the default threshold.
|
|
153
|
+
assert.equal(out.length, 3);
|
|
154
|
+
assert.deepEqual(out.map((x) => x.id), ["a", "b", "c"]);
|
|
155
|
+
assert.ok(out[0].score >= out[1].score && out[1].score >= out[2].score);
|
|
156
|
+
// Query embed (cached) + two batches of size 2 and 1.
|
|
157
|
+
assert.deepEqual(calls.map((c) => c.count), [1, 2, 1]);
|
|
158
|
+
// Query vector is cached: a second rerank only makes batch calls.
|
|
159
|
+
await r.rerank("q", candidates(["a", "b", "c"]));
|
|
160
|
+
assert.deepEqual(calls.map((c) => c.count), [1, 2, 1, 2, 1]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("text-classification strategy scores via logit delta", async () => {
|
|
164
|
+
const loader = async (task) => {
|
|
165
|
+
if (task === "text-classification") return makeFakeTc([0, 1, 0, 0, -2, 2]);
|
|
166
|
+
throw new Error(`Unsupported pipeline: ${task}`);
|
|
167
|
+
};
|
|
168
|
+
const r = new LocalReranker({ engineFactory: loader });
|
|
169
|
+
await r.init();
|
|
170
|
+
const out = await r.rerank("q", candidates(["A", "B", "C"]));
|
|
171
|
+
// sigmoid(1-0)=0.731, sigmoid(0)=0.5, sigmoid(2-(-2))=0.982.
|
|
172
|
+
assert.deepEqual(out.map((x) => x.id), ["C", "A", "B"]);
|
|
173
|
+
assert.ok(Math.abs(out[0].score - 0.982) < 1e-3);
|
|
174
|
+
assert.ok(Math.abs(out[1].score - 0.731) < 1e-3);
|
|
175
|
+
assert.ok(Math.abs(out[2].score - 0.5) < 1e-3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("modelHash follows the embedder convention", () => {
|
|
179
|
+
const a = new LocalReranker({ model: "Xenova/bge-reranker-base" });
|
|
180
|
+
const b = new LocalReranker({ model: "Xenova/bge-reranker-base" });
|
|
181
|
+
assert.equal(a.modelHash, b.modelHash);
|
|
182
|
+
assert.match(a.modelHash, /^Xenova\/bge-reranker-base#[0-9a-f]+$/);
|
|
183
|
+
assert.notEqual(a.modelHash, new LocalReranker({ model: "other/model" }).modelHash);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("dispose releases the loaded pipeline", async () => {
|
|
187
|
+
const extractor = makeFakeExtractor(4);
|
|
188
|
+
const r = new LocalReranker({
|
|
189
|
+
engineFactory: async (task) => {
|
|
190
|
+
if (task === "feature-extraction") return extractor;
|
|
191
|
+
throw new Error("Unsupported pipeline");
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
await r.init();
|
|
195
|
+
assert.ok(r.pipeline);
|
|
196
|
+
r.dispose();
|
|
197
|
+
assert.equal(r.pipeline, null);
|
|
198
|
+
assert.equal(extractor.disposed, true);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("default pipeline loader mirrors cache_dir onto env.cacheDir (issue #13)", () => {
|
|
202
|
+
// The fix lives in the module's *default* loader — the dynamic-import of
|
|
203
|
+
// @huggingface/transformers that the in-process tests bypass by injecting
|
|
204
|
+
// engineFactory. So it is exercised in a child node process under the
|
|
205
|
+
// --experimental-test-module-mocks flag: the transformers module is mocked
|
|
206
|
+
// with an empty env, LocalReranker uses the real default loader, and we
|
|
207
|
+
// assert env.cacheDir picks up the constructor's cache_dir. A regression
|
|
208
|
+
// (loader no longer mirroring) fails the child and surfaces here as a
|
|
209
|
+
// non-zero exit.
|
|
210
|
+
const srcUrl = new URL("../src/reranker.js", import.meta.url).href;
|
|
211
|
+
const cacheDir = "/tmp/dsh-mneme-cache-mirror-test";
|
|
212
|
+
const script = `
|
|
213
|
+
import { test } from "node:test";
|
|
214
|
+
const cacheDir = ${JSON.stringify(cacheDir)};
|
|
215
|
+
test("cache_dir is mirrored onto env.cacheDir", async (t) => {
|
|
216
|
+
t.mock.module("@huggingface/transformers", {
|
|
217
|
+
namedExports: {
|
|
218
|
+
env: {},
|
|
219
|
+
pipeline: async () => ({ dispose: () => {} })
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
const { LocalReranker } = await import(${JSON.stringify(srcUrl)});
|
|
223
|
+
const r = new LocalReranker({ cacheDir, device: "cpu" });
|
|
224
|
+
await r.init();
|
|
225
|
+
const { env } = await import("@huggingface/transformers");
|
|
226
|
+
if (env.cacheDir !== cacheDir) {
|
|
227
|
+
throw new Error("env.cacheDir not mirrored from cache_dir: " + env.cacheDir);
|
|
228
|
+
}
|
|
229
|
+
console.log("CACHE_MIRROR_OK");
|
|
230
|
+
});
|
|
231
|
+
`;
|
|
232
|
+
const res = spawnSync(process.execPath, [
|
|
233
|
+
"--experimental-test-module-mocks",
|
|
234
|
+
"--input-type=module",
|
|
235
|
+
"-e",
|
|
236
|
+
script
|
|
237
|
+
], { encoding: "utf8", cwd: fileURLToPath(new URL("..", import.meta.url)) });
|
|
238
|
+
assert.equal(res.status, 0, `cache-dir mirror child failed:\n${res.stdout}\n${res.stderr}`);
|
|
239
|
+
assert.match(res.stdout, /CACHE_MIRROR_OK/);
|
|
240
|
+
});
|