recallnest 2.6.0 → 3.0.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +131 -14
  3. package/README_CN.md +121 -17
  4. package/config.json.example +8 -1
  5. package/docs/api-reference.md +1 -1
  6. package/integrations/README.md +1 -1
  7. package/integrations/agy/README.md +73 -0
  8. package/integrations/agy/setup.sh +75 -0
  9. package/integrations/setup-all.sh +1 -1
  10. package/package.json +7 -5
  11. package/src/__tests__/access-tracker-novelty.test.ts +22 -2
  12. package/src/__tests__/activity-counter.test.ts +38 -38
  13. package/src/__tests__/age-label.test.ts +33 -0
  14. package/src/__tests__/cluster-consolidation.test.ts +313 -22
  15. package/src/__tests__/consolidation-engine.test.ts +90 -0
  16. package/src/__tests__/contracts.test.ts +2 -2
  17. package/src/__tests__/data-checkup.test.ts +31 -3
  18. package/src/__tests__/dream-pipeline.test.ts +41 -1
  19. package/src/__tests__/dream-scope-isolation.test.ts +386 -0
  20. package/src/__tests__/dream-write-batching.test.ts +211 -0
  21. package/src/__tests__/env-config-invariant.test.ts +80 -0
  22. package/src/__tests__/env-config.test.ts +60 -0
  23. package/src/__tests__/ingest-scope-prefix.test.ts +69 -0
  24. package/src/__tests__/maintenance-locks.test.ts +8 -0
  25. package/src/__tests__/memory-evolution-b2-b3-e1.test.ts +17 -0
  26. package/src/__tests__/memory-output.test.ts +9 -0
  27. package/src/__tests__/memory-utility.test.ts +256 -0
  28. package/src/__tests__/openai-contract.test.ts +513 -0
  29. package/src/__tests__/promotion-distinct-sources.test.ts +162 -0
  30. package/src/__tests__/retrieve-audit-revision.test.ts +255 -0
  31. package/src/__tests__/source-heartbeat.test.ts +6 -2
  32. package/src/__tests__/store-patch-metadata.test.ts +30 -0
  33. package/src/__tests__/synthesis-contract.test.ts +252 -0
  34. package/src/__tests__/synthesis-promotion.test.ts +369 -0
  35. package/src/__tests__/trusted-memory.test.ts +28 -0
  36. package/src/__tests__/utility-weight.test.ts +57 -0
  37. package/src/__tests__/verbatim-self-recall.test.ts +279 -0
  38. package/src/__tests__/version-manager.test.ts +35 -0
  39. package/src/access-tracker.ts +10 -1
  40. package/src/activity-counter.ts +69 -11
  41. package/src/age-label.ts +29 -0
  42. package/src/audit-log.ts +35 -0
  43. package/src/auto-gc.ts +97 -30
  44. package/src/cli.ts +224 -10
  45. package/src/consolidation-engine.ts +317 -123
  46. package/src/data-checkup.ts +17 -3
  47. package/src/dream-pipeline.ts +90 -12
  48. package/src/embedder.ts +20 -5
  49. package/src/env-config.ts +53 -0
  50. package/src/forget-engine.ts +8 -2
  51. package/src/gateway-server.ts +272 -0
  52. package/src/ingest.ts +23 -5
  53. package/src/llm-client.ts +86 -25
  54. package/src/mcp-server.ts +2 -1
  55. package/src/mcp-tools-advanced.ts +7 -2
  56. package/src/mcp-tools-core.ts +11 -1
  57. package/src/mcp-tools-governance.ts +45 -3
  58. package/src/memory-evolution.ts +16 -0
  59. package/src/memory-output.ts +12 -8
  60. package/src/memory-promotion.ts +280 -0
  61. package/src/memory-store-port.ts +32 -3
  62. package/src/memory-utility.ts +314 -0
  63. package/src/recall-ledger.ts +76 -0
  64. package/src/retriever.ts +82 -9
  65. package/src/scope-policy.ts +22 -1
  66. package/src/session-output.ts +4 -3
  67. package/src/skill-promotion.ts +152 -1
  68. package/src/store.ts +231 -71
  69. package/src/synthesis-contract.ts +286 -0
  70. package/src/version-manager.ts +83 -0
  71. package/src/workflow-observation-schema.ts +26 -0
  72. package/integrations/gemini-cli/README.md +0 -57
  73. package/integrations/gemini-cli/setup.sh +0 -64
  74. /package/integrations/{gemini-cli/gemini-md-snippet.md → agy/agy-md-snippet.md} +0 -0
@@ -0,0 +1,369 @@
1
+ /**
2
+ * Evidence → durable promotion for dream-synthesized conclusions.
3
+ *
4
+ * The layering RecallNest works under is "evidence retained → current projection →
5
+ * eligible for recall → ranked result", and until now a synthesized conclusion could only
6
+ * ever occupy the first of those. `buildDerivedBoundary` stamps every cluster insight and
7
+ * cross-memory pattern `layer: "evidence"` on purpose — a model re-reading its own
8
+ * memories is a lead to its sources, not authority over them — but
9
+ * `shouldUseStableMemoryResult` rejects the evidence layer outright, so there was no path
10
+ * from a well-supported conclusion to anything downstream could lean on.
11
+ *
12
+ * These tests cover the road that closes that gap, and the guardrails on it: the synthesis
13
+ * row is never touched, re-running revises instead of duplicating, and anything whose
14
+ * support cannot be checked is abstained on rather than waved through.
15
+ */
16
+
17
+ import { describe, expect, it } from "bun:test";
18
+
19
+ import {
20
+ DEFAULT_SYNTHESIS_PROMOTE_CONFIG,
21
+ MIN_PROMOTABLE_CONTRACT_VERSION,
22
+ formatSynthesisPromoteResult,
23
+ scanSynthesisPromotions,
24
+ type PromoteRequest,
25
+ type SynthesisPromoteScanDeps,
26
+ } from "../memory-promotion.js";
27
+ import { shouldUseStableMemoryResult } from "../memory-boundaries.js";
28
+ import type { MemoryEntry } from "../store.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Fixture
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const SCOPE = "memory";
35
+
36
+ interface RowSpec {
37
+ id: string;
38
+ text: string;
39
+ category?: string;
40
+ importance?: number;
41
+ /** Omit for a plain (non-synthesis) row. */
42
+ synthesis?: {
43
+ contract?: number | null;
44
+ evidenceMemories?: unknown;
45
+ originalCategory?: string;
46
+ };
47
+ status?: string;
48
+ promotedTo?: string;
49
+ }
50
+
51
+ function row(spec: RowSpec): MemoryEntry {
52
+ const metadata: Record<string, unknown> = {
53
+ evolution: { status: spec.status ?? "active", version: 1, accessCount: 0 },
54
+ };
55
+
56
+ if (spec.synthesis) {
57
+ metadata.boundary = {
58
+ layer: "evidence",
59
+ authority: "distillation",
60
+ conflictPolicy: "append-only",
61
+ originalCategory: spec.synthesis.originalCategory ?? "cases",
62
+ note: "Dream-synthesized derivative: a lead to its sources, never authority over them.",
63
+ };
64
+ if (spec.synthesis.contract !== null) {
65
+ metadata.synthesis_contract = spec.synthesis.contract ?? MIN_PROMOTABLE_CONTRACT_VERSION;
66
+ }
67
+ if (spec.synthesis.evidenceMemories !== undefined) {
68
+ metadata.evidenceMemories = spec.synthesis.evidenceMemories;
69
+ }
70
+ } else {
71
+ metadata.boundary = {
72
+ layer: "durable",
73
+ authority: "structured-memory",
74
+ conflictPolicy: "latest-wins",
75
+ };
76
+ }
77
+
78
+ if (spec.promotedTo) metadata.promotedTo = spec.promotedTo;
79
+
80
+ return {
81
+ id: spec.id,
82
+ text: spec.text,
83
+ vector: [],
84
+ category: (spec.category ?? "cases") as MemoryEntry["category"],
85
+ scope: SCOPE,
86
+ importance: spec.importance ?? 0.8,
87
+ timestamp: Date.parse("2026-08-20T00:00:00.000Z"),
88
+ metadata: JSON.stringify(metadata),
89
+ };
90
+ }
91
+
92
+ /** Two live evidence rows every well-formed synthesis in these tests points at. */
93
+ const EVIDENCE = [
94
+ row({ id: "ev-alpha", text: "第一次踩坑:换机没 push,第二天分叉" }),
95
+ row({ id: "ev-beta", text: "第二次:hooks symlink 化之后工作区常态脏" }),
96
+ ];
97
+
98
+ function buildDeps(rows: MemoryEntry[]): {
99
+ deps: SynthesisPromoteScanDeps;
100
+ promoted: PromoteRequest[];
101
+ } {
102
+ const all = [...EVIDENCE, ...rows];
103
+ const byId = new Map(all.map((e) => [e.id, e]));
104
+ const promoted: PromoteRequest[] = [];
105
+
106
+ return {
107
+ promoted,
108
+ deps: {
109
+ store: {
110
+ async list() {
111
+ return all;
112
+ },
113
+ async getById(id: string) {
114
+ return byId.get(id) ?? null;
115
+ },
116
+ } as any,
117
+ async promote(req) {
118
+ promoted.push(req);
119
+ return {
120
+ id: `durable-${req.memoryId}`,
121
+ text: byId.get(req.memoryId)?.text ?? "",
122
+ category: req.category,
123
+ importance: req.importance,
124
+ scope: req.scope,
125
+ resolvedScope: req.scope,
126
+ canonicalKey: `key:${req.memoryId}`,
127
+ storedAt: new Date().toISOString(),
128
+ disposition: "stored",
129
+ sourceMemoryId: req.memoryId,
130
+ sourceCategory: "cases",
131
+ } as any;
132
+ },
133
+ },
134
+ };
135
+ }
136
+
137
+ function goodSynthesis(over: Partial<RowSpec> = {}): MemoryEntry {
138
+ return row({
139
+ id: "syn-good",
140
+ text: "换机交接的失败都同一个形状:脏工作区被当成临时状态,真相源迁走后它变成常态",
141
+ importance: 0.85,
142
+ ...over,
143
+ synthesis: {
144
+ contract: MIN_PROMOTABLE_CONTRACT_VERSION,
145
+ evidenceMemories: ["ev-alpha", "ev-beta"],
146
+ originalCategory: "patterns",
147
+ ...(over.synthesis ?? {}),
148
+ },
149
+ });
150
+ }
151
+
152
+ // ---------------------------------------------------------------------------
153
+
154
+ describe("synthesis promotion: what qualifies", () => {
155
+ it("promotes a contract-compliant conclusion with enough live evidence", async () => {
156
+ const { deps, promoted } = buildDeps([goodSynthesis()]);
157
+
158
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
159
+
160
+ expect(result.candidates).toHaveLength(1);
161
+ expect(result.promoted).toBe(1);
162
+ expect(result.candidates[0]).toMatchObject({
163
+ seedId: "syn-good",
164
+ category: "patterns",
165
+ contractVersion: MIN_PROMOTABLE_CONTRACT_VERSION,
166
+ evidenceIds: ["ev-alpha", "ev-beta"],
167
+ });
168
+ // Promotion goes through the shared promoteMemory path, not a private writer.
169
+ expect(promoted).toEqual([
170
+ { memoryId: "syn-good", category: "patterns", scope: SCOPE, importance: 0.85 },
171
+ ]);
172
+ });
173
+
174
+ it("defaults to dry-run and writes nothing", async () => {
175
+ const { deps, promoted } = buildDeps([goodSynthesis()]);
176
+
177
+ const result = await scanSynthesisPromotions(deps, SCOPE);
178
+
179
+ expect(DEFAULT_SYNTHESIS_PROMOTE_CONFIG.dryRun).toBe(true);
180
+ expect(result.dryRun).toBe(true);
181
+ expect(result.candidates).toHaveLength(1);
182
+ expect(result.promoted).toBe(0);
183
+ expect(promoted).toEqual([]);
184
+ expect(result.candidates[0]?.promoted).toBeNull();
185
+ });
186
+
187
+ it("ignores rows that are not synthesized derivatives", async () => {
188
+ // A hand-written durable memory is already on the durable layer; promoting it would be
189
+ // meaningless, and promoteMemory itself rejects non-evidence sources.
190
+ const { deps } = buildDeps([
191
+ row({ id: "handwritten", text: "Alice 用 Bun,不用 npm" }),
192
+ goodSynthesis(),
193
+ ]);
194
+
195
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
196
+
197
+ expect(result.scannedSyntheses).toBe(1);
198
+ expect(result.candidates.map((c) => c.seedId)).toEqual(["syn-good"]);
199
+ });
200
+
201
+ it("takes the durable category from the synthesis boundary", async () => {
202
+ const { deps, promoted } = buildDeps([
203
+ goodSynthesis({ synthesis: { originalCategory: "preferences" } as any }),
204
+ ]);
205
+
206
+ await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
207
+
208
+ expect(promoted[0]?.category).toBe("preferences");
209
+ });
210
+ });
211
+
212
+ describe("synthesis promotion: what it refuses, and says so", () => {
213
+ it("skips derivatives written before the synthesis contract", async () => {
214
+ // The 4,749 rows already in the database predate validation entirely — they were never
215
+ // checked for abstention, evidence, or prompt echo. Promoting them would launder
216
+ // unvalidated output into durable memory.
217
+ const { deps, promoted } = buildDeps([
218
+ goodSynthesis({ id: "syn-legacy", synthesis: { contract: null } as any }),
219
+ goodSynthesis({ id: "syn-v1", synthesis: { contract: 1 } as any }),
220
+ ]);
221
+
222
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
223
+
224
+ expect(result.skipped.pre_contract).toBe(2);
225
+ expect(result.candidates).toHaveLength(0);
226
+ expect(promoted).toEqual([]);
227
+ });
228
+
229
+ it("abstains when the declared evidence cannot be resolved at all", async () => {
230
+ // Not judging is the right answer when the provenance needed for the judgement is
231
+ // missing — the same abstention rule the cross-source promotion check uses.
232
+ const { deps, promoted } = buildDeps([
233
+ goodSynthesis({ id: "syn-dangling", synthesis: { evidenceMemories: ["gone-1", "gone-2"] } as any }),
234
+ ]);
235
+
236
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
237
+
238
+ expect(result.skipped.evidence_unresolvable).toBe(1);
239
+ expect(promoted).toEqual([]);
240
+ });
241
+
242
+ it("abstains when the evidence field is missing or malformed", async () => {
243
+ const { deps } = buildDeps([
244
+ goodSynthesis({ id: "syn-noevidence", synthesis: { evidenceMemories: undefined } as any }),
245
+ goodSynthesis({ id: "syn-badevidence", synthesis: { evidenceMemories: "not-an-array" } as any }),
246
+ ]);
247
+
248
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
249
+
250
+ expect(result.skipped.evidence_unresolvable).toBe(2);
251
+ expect(result.candidates).toHaveLength(0);
252
+ });
253
+
254
+ it("counts duplicate evidence ids once", async () => {
255
+ // "Three similar cases" and "three distinct episodes" differ in dimension, not degree;
256
+ // the same source listed twice is one source.
257
+ const { deps } = buildDeps([
258
+ goodSynthesis({ id: "syn-dup", synthesis: { evidenceMemories: ["ev-alpha", "ev-alpha"] } as any }),
259
+ ]);
260
+
261
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
262
+
263
+ expect(result.skipped.too_few_distinct_evidence).toBe(1);
264
+ expect(result.candidates).toHaveLength(0);
265
+ });
266
+
267
+ it("does not count evidence that is no longer active", async () => {
268
+ const superseded = row({ id: "ev-gone", text: "已被推翻的旧结论", status: "superseded" });
269
+ const { deps } = buildDeps([
270
+ superseded,
271
+ goodSynthesis({ id: "syn-stale", synthesis: { evidenceMemories: ["ev-alpha", "ev-gone"] } as any }),
272
+ ]);
273
+
274
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
275
+
276
+ // One live source left, below the floor of two.
277
+ expect(result.skipped.too_few_distinct_evidence).toBe(1);
278
+ expect(result.candidates).toHaveLength(0);
279
+ });
280
+
281
+ it("skips a conclusion below the importance floor", async () => {
282
+ const { deps } = buildDeps([goodSynthesis({ id: "syn-weak", importance: 0.4 })]);
283
+
284
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
285
+
286
+ expect(result.skipped.below_importance).toBe(1);
287
+ expect(result.candidates).toHaveLength(0);
288
+ });
289
+
290
+ it("skips a conclusion already promoted", async () => {
291
+ const { deps } = buildDeps([goodSynthesis({ id: "syn-done", promotedTo: "durable-syn-done" })]);
292
+
293
+ const result = await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
294
+
295
+ expect(result.skipped.already_promoted).toBe(1);
296
+ expect(result.candidates).toHaveLength(0);
297
+ });
298
+
299
+ it("reports the rejection breakdown even when nothing is promoted", async () => {
300
+ // Otherwise "found nothing" and "filtered everything out" read identically.
301
+ const { deps } = buildDeps([
302
+ goodSynthesis({ id: "syn-legacy", synthesis: { contract: 1 } as any }),
303
+ goodSynthesis({ id: "syn-weak", importance: 0.2 }),
304
+ ]);
305
+
306
+ const text = formatSynthesisPromoteResult(await scanSynthesisPromotions(deps, SCOPE));
307
+
308
+ expect(text).toContain("2 synthesized conclusion(s) examined");
309
+ expect(text).toContain("pre_contract=1");
310
+ expect(text).toContain("below_importance=1");
311
+ expect(text).toContain("No promotion candidates found");
312
+ });
313
+ });
314
+
315
+ describe("synthesis promotion: the guarantees around it", () => {
316
+ it("never modifies the synthesized row — evidence stays readable as evidence", async () => {
317
+ const synthesis = goodSynthesis();
318
+ const before = synthesis.metadata;
319
+ const { deps } = buildDeps([synthesis]);
320
+
321
+ await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
322
+
323
+ // Archive-first: the conclusion keeps its evidence boundary and its support set, so a
324
+ // later reader can still tell what the durable copy was derived from.
325
+ expect(synthesis.metadata).toBe(before);
326
+ expect(JSON.parse(synthesis.metadata!).boundary.layer).toBe("evidence");
327
+ });
328
+
329
+ it("re-scanning promotes the same seed through the same idempotent path", async () => {
330
+ // Duplicate suppression lives in promoteMemory -> writeDurableEntry's canonicalKey
331
+ // dedup, so a second scan revises rather than piling up a parallel memory. What this
332
+ // asserts is that the scan keeps routing through it instead of growing its own writer.
333
+ const { deps, promoted } = buildDeps([goodSynthesis()]);
334
+
335
+ await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
336
+ await scanSynthesisPromotions(deps, SCOPE, { dryRun: false });
337
+
338
+ expect(promoted).toHaveLength(2);
339
+ expect(new Set(promoted.map((p) => p.memoryId)).size).toBe(1);
340
+ });
341
+
342
+ it("makes the promoted copy eligible for stable memory while the source stays out", async () => {
343
+ // This is the whole point. The synthesized row is refused by
344
+ // shouldUseStableMemoryResult; the durable copy promoteMemory writes is not.
345
+ const synthesis = goodSynthesis({ synthesis: { originalCategory: "preferences" } as any });
346
+
347
+ expect(
348
+ shouldUseStableMemoryResult({
349
+ category: "preferences",
350
+ scope: SCOPE,
351
+ metadata: synthesis.metadata,
352
+ }),
353
+ ).toBe(false);
354
+
355
+ const promotedMetadata = JSON.stringify({
356
+ evolution: { status: "active", version: 1 },
357
+ boundary: { layer: "durable", authority: "structured-memory", conflictPolicy: "latest-wins" },
358
+ promotedFrom: { memoryId: synthesis.id, scope: SCOPE, category: "cases" },
359
+ });
360
+
361
+ expect(
362
+ shouldUseStableMemoryResult({
363
+ category: "preferences",
364
+ scope: SCOPE,
365
+ metadata: promotedMetadata,
366
+ }),
367
+ ).toBe(true);
368
+ });
369
+ });
@@ -506,6 +506,7 @@ describe("F-2 integration: retention policy + auto-gc", () => {
506
506
  return {
507
507
  store: {
508
508
  stats: async () => ({ totalCount: data.size, scopeCounts: {}, categoryCounts: {} }),
509
+ repairSingletonVersionGroups: async () => 0,
509
510
  list: async () => Array.from(data.values()),
510
511
  listPage: async (opts: { limit?: number; offset?: number } = {}) => {
511
512
  const { limit = 1000, offset = 0 } = opts;
@@ -518,6 +519,22 @@ describe("F-2 integration: retention policy + auto-gc", () => {
518
519
  entry.metadata = patch.metadata;
519
520
  return entry;
520
521
  },
522
+ // 2026-08-14 auto-gc 批量归档通道:以 data 里的最新行起底应用 patchFn(生产语义)
523
+ patchMetadataBatch: async (
524
+ patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
525
+ ) => {
526
+ let written = 0;
527
+ for (const { id, patchFn } of patches) {
528
+ const entry = data.get(id);
529
+ if (!entry) continue;
530
+ let meta: Record<string, unknown>;
531
+ try { meta = JSON.parse(entry.metadata || "{}") as Record<string, unknown>; } catch { meta = {}; }
532
+ entry.metadata = JSON.stringify(patchFn(meta, entry));
533
+ updates.push({ id, metadata: entry.metadata });
534
+ written++;
535
+ }
536
+ return written;
537
+ },
521
538
  },
522
539
  updates,
523
540
  };
@@ -815,12 +832,23 @@ describe("F-1 integration: audit logger", () => {
815
832
  const rows = [{ id: "gc-1", text: "old", importance: 0.1, timestamp: oldTs, metadata: activeMeta, category: "events", scope: "project:test" }];
816
833
  const store = {
817
834
  async stats() { return { totalCount: 100, scopeCounts: {}, categoryCounts: {} }; },
835
+ async repairSingletonVersionGroups() { return 0; },
818
836
  async list() { return rows; },
819
837
  async listPage(opts: { limit?: number; offset?: number } = {}) {
820
838
  const { limit = 1000, offset = 0 } = opts;
821
839
  return rows.slice(offset, offset + limit);
822
840
  },
823
841
  async update(id: string, upd: Record<string, unknown>) { return { id, ...upd }; },
842
+ async patchMetadataBatch(
843
+ patches: Array<{ id: string; patchFn: (meta: Record<string, unknown>, entry: unknown) => Record<string, unknown> }>,
844
+ ) {
845
+ for (const { id, patchFn } of patches) {
846
+ const row = rows.find(r => r.id === id);
847
+ if (!row) continue;
848
+ row.metadata = JSON.stringify(patchFn(JSON.parse(row.metadata || "{}") as Record<string, unknown>, row));
849
+ }
850
+ return patches.length;
851
+ },
824
852
  };
825
853
 
826
854
  const result = await maybeRunGc(
@@ -0,0 +1,57 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { createRetriever } from "../retriever.js";
3
+ import { UTILITY_FIELD } from "../memory-utility.js";
4
+
5
+ function run(utilityWeight: number, utilities: Array<number | null>): number[] {
6
+ const retriever = createRetriever({} as any, {} as any, { utilityWeight });
7
+ const results = utilities.map((u, i) => ({
8
+ entry: {
9
+ id: `u-${i}`,
10
+ text: "utility test",
11
+ vector: [],
12
+ category: "cases",
13
+ scope: "test:utility",
14
+ importance: 0.7,
15
+ timestamp: Date.now(),
16
+ metadata: JSON.stringify(u === null ? { tier: "peripheral" } : { [UTILITY_FIELD]: u }),
17
+ },
18
+ score: 0.5,
19
+ sources: {},
20
+ }));
21
+ const out = (retriever as any).applyUtilityWeight(results) as Array<{ entry: { id: string }; score: number }>;
22
+ // 按原顺序取回分数(该 stage 会重排)
23
+ return utilities.map((_, i) => out.find(r => r.entry.id === `u-${i}`)!.score);
24
+ }
25
+
26
+ describe("applyUtilityWeight", () => {
27
+ it("is a no-op at the default weight of 0", () => {
28
+ expect(run(0, [0.8, -0.8, null])).toEqual([0.5, 0.5, 0.5]);
29
+ });
30
+
31
+ it("lifts positive utility and sinks negative utility", () => {
32
+ const [positive, negative, absent] = run(0.2, [0.8, -0.8, null]);
33
+ expect(positive).toBeGreaterThan(0.5);
34
+ expect(negative).toBeLessThan(0.5);
35
+ // 没有 utility 的条目原样放行 —— 「没数据」不该被当成差评(bonus-only 冷启动)。
36
+ expect(absent).toBe(0.5);
37
+ });
38
+
39
+ it("keeps scores inside [0,1] even at weight 1 with the most negative utility", () => {
40
+ const [score] = run(1, [-0.8]);
41
+ expect(score).toBeGreaterThanOrEqual(0);
42
+ expect(score).toBeLessThanOrEqual(1);
43
+ });
44
+
45
+ it("ignores broken metadata instead of throwing", () => {
46
+ const retriever = createRetriever({} as any, {} as any, { utilityWeight: 0.3 });
47
+ const results = [{
48
+ entry: {
49
+ id: "broken", text: "x", vector: [], category: "cases", scope: "s",
50
+ importance: 0.5, timestamp: Date.now(), metadata: "{not json",
51
+ },
52
+ score: 0.42,
53
+ sources: {},
54
+ }];
55
+ expect((retriever as any).applyUtilityWeight(results)[0].score).toBe(0.42);
56
+ });
57
+ });