@ai2070/memex 0.9.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 (46) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/.github/workflows/release.yml +35 -0
  3. package/API.md +1078 -0
  4. package/LICENSE +190 -0
  5. package/README.md +574 -0
  6. package/package.json +30 -0
  7. package/src/bulk.ts +128 -0
  8. package/src/envelope.ts +52 -0
  9. package/src/errors.ts +27 -0
  10. package/src/graph.ts +15 -0
  11. package/src/helpers.ts +51 -0
  12. package/src/index.ts +142 -0
  13. package/src/integrity.ts +378 -0
  14. package/src/intent.ts +311 -0
  15. package/src/query.ts +357 -0
  16. package/src/reducer.ts +177 -0
  17. package/src/replay.ts +32 -0
  18. package/src/retrieval.ts +306 -0
  19. package/src/serialization.ts +34 -0
  20. package/src/stats.ts +62 -0
  21. package/src/task.ts +373 -0
  22. package/src/transplant.ts +488 -0
  23. package/src/types.ts +248 -0
  24. package/tests/bugfix-and-coverage.test.ts +958 -0
  25. package/tests/bugfix-holes.test.ts +856 -0
  26. package/tests/bulk.test.ts +256 -0
  27. package/tests/edge-cases-v2.test.ts +355 -0
  28. package/tests/edge-cases.test.ts +661 -0
  29. package/tests/envelope.test.ts +92 -0
  30. package/tests/graph.test.ts +41 -0
  31. package/tests/helpers.test.ts +120 -0
  32. package/tests/integrity.test.ts +371 -0
  33. package/tests/intent.test.ts +276 -0
  34. package/tests/query-advanced.test.ts +252 -0
  35. package/tests/query.test.ts +623 -0
  36. package/tests/reducer.test.ts +342 -0
  37. package/tests/replay.test.ts +145 -0
  38. package/tests/retrieval.test.ts +691 -0
  39. package/tests/serialization.test.ts +118 -0
  40. package/tests/setup.test.ts +7 -0
  41. package/tests/stats.test.ts +163 -0
  42. package/tests/task.test.ts +322 -0
  43. package/tests/transplant.test.ts +385 -0
  44. package/tests/types.test.ts +231 -0
  45. package/tsconfig.json +18 -0
  46. package/vitest.config.ts +7 -0
@@ -0,0 +1,691 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ getSupportTree,
4
+ getSupportSet,
5
+ filterContradictions,
6
+ surfaceContradictions,
7
+ applyDiversity,
8
+ smartRetrieve,
9
+ } from "../src/retrieval.js";
10
+ import { extractTimestamp, getItems, getScoredItems } from "../src/query.js";
11
+ import { markContradiction, resolveContradiction } from "../src/integrity.js";
12
+ import { createGraphState } from "../src/graph.js";
13
+ import { createMemoryItem } from "../src/helpers.js";
14
+ import { applyCommand } from "../src/reducer.js";
15
+ import type { MemoryItem, GraphState, ScoredItem } from "../src/types.js";
16
+
17
+ // -- helpers --
18
+
19
+ const makeItem = (
20
+ id: string,
21
+ overrides: Partial<MemoryItem> = {},
22
+ ): MemoryItem => ({
23
+ id,
24
+ scope: "test",
25
+ kind: "observation",
26
+ content: {},
27
+ author: "agent:a",
28
+ source_kind: "observed",
29
+ authority: 0.5,
30
+ ...overrides,
31
+ });
32
+
33
+ function stateWith(items: MemoryItem[]): GraphState {
34
+ const s = createGraphState();
35
+ for (const i of items) s.items.set(i.id, i);
36
+ return s;
37
+ }
38
+
39
+ function toScored(items: MemoryItem[], scores: number[]): ScoredItem[] {
40
+ return items.map((item, i) => ({ item, score: scores[i] }));
41
+ }
42
+
43
+ // ============================================================
44
+ // 1. Support tree & support set
45
+ // ============================================================
46
+
47
+ describe("getSupportTree", () => {
48
+ it("returns null for non-existent item", () => {
49
+ const state = stateWith([]);
50
+ expect(getSupportTree(state, "nope")).toBeNull();
51
+ });
52
+
53
+ it("returns leaf node for item with no parents", () => {
54
+ const state = stateWith([makeItem("m1")]);
55
+ const tree = getSupportTree(state, "m1")!;
56
+ expect(tree.item.id).toBe("m1");
57
+ expect(tree.parents).toHaveLength(0);
58
+ });
59
+
60
+ it("builds a simple chain", () => {
61
+ const state = stateWith([
62
+ makeItem("m1"),
63
+ makeItem("m2", { parents: ["m1"] }),
64
+ makeItem("m3", { parents: ["m2"] }),
65
+ ]);
66
+ const tree = getSupportTree(state, "m3")!;
67
+ expect(tree.item.id).toBe("m3");
68
+ expect(tree.parents).toHaveLength(1);
69
+ expect(tree.parents[0].item.id).toBe("m2");
70
+ expect(tree.parents[0].parents).toHaveLength(1);
71
+ expect(tree.parents[0].parents[0].item.id).toBe("m1");
72
+ expect(tree.parents[0].parents[0].parents).toHaveLength(0);
73
+ });
74
+
75
+ it("builds a diamond (two parents, shared grandparent)", () => {
76
+ const state = stateWith([
77
+ makeItem("m1"),
78
+ makeItem("m2", { parents: ["m1"] }),
79
+ makeItem("m3", { parents: ["m1"] }),
80
+ makeItem("m4", { parents: ["m2", "m3"] }),
81
+ ]);
82
+ const tree = getSupportTree(state, "m4")!;
83
+ expect(tree.parents).toHaveLength(2);
84
+ // m1 appears in both branches but visited set prevents infinite recursion
85
+ const allIds = new Set<string>();
86
+ function collect(node: typeof tree): void {
87
+ allIds.add(node.item.id);
88
+ node.parents.forEach(collect);
89
+ }
90
+ collect(tree);
91
+ expect(allIds).toEqual(new Set(["m1", "m2", "m3", "m4"]));
92
+ });
93
+
94
+ it("handles missing parents gracefully", () => {
95
+ const state = stateWith([
96
+ makeItem("m2", { parents: ["m1"] }), // m1 doesn't exist
97
+ ]);
98
+ const tree = getSupportTree(state, "m2")!;
99
+ expect(tree.item.id).toBe("m2");
100
+ expect(tree.parents).toHaveLength(0); // m1 missing, skipped
101
+ });
102
+ });
103
+
104
+ describe("getSupportSet", () => {
105
+ it("returns empty for non-existent item", () => {
106
+ expect(getSupportSet(stateWith([]), "nope")).toHaveLength(0);
107
+ });
108
+
109
+ it("returns the item itself for root items", () => {
110
+ const state = stateWith([makeItem("m1")]);
111
+ const set = getSupportSet(state, "m1");
112
+ expect(set).toHaveLength(1);
113
+ expect(set[0].id).toBe("m1");
114
+ });
115
+
116
+ it("returns full provenance chain (deduplicated)", () => {
117
+ const state = stateWith([
118
+ makeItem("m1"),
119
+ makeItem("m2", { parents: ["m1"] }),
120
+ makeItem("m3", { parents: ["m1"] }),
121
+ makeItem("m4", { parents: ["m2", "m3"] }),
122
+ ]);
123
+ const set = getSupportSet(state, "m4");
124
+ expect(set).toHaveLength(4);
125
+ expect(set.map((i) => i.id).sort()).toEqual(["m1", "m2", "m3", "m4"]);
126
+ });
127
+ });
128
+
129
+ // ============================================================
130
+ // 2. Contradiction-aware packing
131
+ // ============================================================
132
+
133
+ describe("filterContradictions", () => {
134
+ it("removes superseded items", () => {
135
+ const state = stateWith([
136
+ makeItem("m1", { authority: 0.9 }),
137
+ makeItem("m2", { authority: 0.3 }),
138
+ ]);
139
+ const { state: marked } = markContradiction(
140
+ state,
141
+ "m1",
142
+ "m2",
143
+ "system:detector",
144
+ );
145
+ const { state: resolved } = resolveContradiction(
146
+ marked,
147
+ "m1",
148
+ "m2",
149
+ "system:resolver",
150
+ );
151
+
152
+ const scored = toScored(
153
+ [resolved.items.get("m1")!, resolved.items.get("m2")!],
154
+ [0.9, 0.03],
155
+ );
156
+ const filtered = filterContradictions(resolved, scored);
157
+ expect(filtered).toHaveLength(1);
158
+ expect(filtered[0].item.id).toBe("m1");
159
+ });
160
+
161
+ it("keeps higher-scoring side of unresolved contradiction", () => {
162
+ const state = stateWith([
163
+ makeItem("m1", { authority: 0.9 }),
164
+ makeItem("m2", { authority: 0.7 }),
165
+ ]);
166
+ const { state: marked } = markContradiction(
167
+ state,
168
+ "m1",
169
+ "m2",
170
+ "system:detector",
171
+ );
172
+
173
+ const scored = toScored(
174
+ [marked.items.get("m1")!, marked.items.get("m2")!],
175
+ [0.9, 0.7],
176
+ );
177
+ const filtered = filterContradictions(marked, scored);
178
+ expect(filtered).toHaveLength(1);
179
+ expect(filtered[0].item.id).toBe("m1");
180
+ });
181
+
182
+ it("passes through items with no contradictions", () => {
183
+ const state = stateWith([makeItem("m1"), makeItem("m2")]);
184
+ const scored = toScored(
185
+ [state.items.get("m1")!, state.items.get("m2")!],
186
+ [0.9, 0.7],
187
+ );
188
+ const filtered = filterContradictions(state, scored);
189
+ expect(filtered).toHaveLength(2);
190
+ });
191
+ });
192
+
193
+ describe("surfaceContradictions", () => {
194
+ it("keeps both sides and flags them", () => {
195
+ const state = stateWith([
196
+ makeItem("m1", { authority: 0.9 }),
197
+ makeItem("m2", { authority: 0.7 }),
198
+ ]);
199
+ const { state: marked } = markContradiction(
200
+ state,
201
+ "m1",
202
+ "m2",
203
+ "system:detector",
204
+ );
205
+
206
+ const scored = toScored(
207
+ [marked.items.get("m1")!, marked.items.get("m2")!],
208
+ [0.9, 0.7],
209
+ );
210
+ const result = surfaceContradictions(marked, scored);
211
+ expect(result).toHaveLength(2);
212
+ expect(
213
+ result.find((s) => s.item.id === "m1")!.contradicted_by,
214
+ ).toHaveLength(1);
215
+ expect(result.find((s) => s.item.id === "m1")!.contradicted_by![0].id).toBe(
216
+ "m2",
217
+ );
218
+ expect(
219
+ result.find((s) => s.item.id === "m2")!.contradicted_by,
220
+ ).toHaveLength(1);
221
+ expect(result.find((s) => s.item.id === "m2")!.contradicted_by![0].id).toBe(
222
+ "m1",
223
+ );
224
+ });
225
+
226
+ it("still removes superseded items", () => {
227
+ const state = stateWith([
228
+ makeItem("m1", { authority: 0.9 }),
229
+ makeItem("m2", { authority: 0.3 }),
230
+ ]);
231
+ const { state: marked } = markContradiction(
232
+ state,
233
+ "m1",
234
+ "m2",
235
+ "system:detector",
236
+ );
237
+ const { state: resolved } = resolveContradiction(
238
+ marked,
239
+ "m1",
240
+ "m2",
241
+ "system:resolver",
242
+ );
243
+
244
+ const scored = toScored(
245
+ [resolved.items.get("m1")!, resolved.items.get("m2")!],
246
+ [0.9, 0.03],
247
+ );
248
+ const result = surfaceContradictions(resolved, scored);
249
+ expect(result).toHaveLength(1);
250
+ expect(result[0].item.id).toBe("m1");
251
+ expect(result[0].contradicted_by).toBeUndefined();
252
+ });
253
+
254
+ it("no contradictions means no flags", () => {
255
+ const state = stateWith([makeItem("m1"), makeItem("m2")]);
256
+ const scored = toScored(
257
+ [state.items.get("m1")!, state.items.get("m2")!],
258
+ [0.9, 0.7],
259
+ );
260
+ const result = surfaceContradictions(state, scored);
261
+ expect(result).toHaveLength(2);
262
+ expect(result[0].contradicted_by).toBeUndefined();
263
+ expect(result[1].contradicted_by).toBeUndefined();
264
+ });
265
+ });
266
+
267
+ describe("smartRetrieve with contradictions: surface", () => {
268
+ it("surfaces both sides with flags in the pipeline", () => {
269
+ const state = stateWith([
270
+ makeItem("m1", { authority: 0.9 }),
271
+ makeItem("m2", { authority: 0.7 }),
272
+ makeItem("m3", { authority: 0.5 }),
273
+ ]);
274
+ const { state: marked } = markContradiction(
275
+ state,
276
+ "m1",
277
+ "m2",
278
+ "system:detector",
279
+ );
280
+
281
+ const result = smartRetrieve(marked, {
282
+ budget: 100,
283
+ costFn: () => 1,
284
+ weights: { authority: 1 },
285
+ contradictions: "surface",
286
+ });
287
+
288
+ expect(result).toHaveLength(3);
289
+ const m1 = result.find((s) => s.item.id === "m1")!;
290
+ const m2 = result.find((s) => s.item.id === "m2")!;
291
+ const m3 = result.find((s) => s.item.id === "m3")!;
292
+ expect(m1.contradicted_by).toHaveLength(1);
293
+ expect(m2.contradicted_by).toHaveLength(1);
294
+ expect(m3.contradicted_by).toBeUndefined();
295
+ });
296
+ });
297
+
298
+ // ============================================================
299
+ // 3. Diversity scoring
300
+ // ============================================================
301
+
302
+ describe("applyDiversity", () => {
303
+ it("penalizes duplicate authors", () => {
304
+ const items = [
305
+ makeItem("m1", { author: "agent:a" }),
306
+ makeItem("m2", { author: "agent:a" }),
307
+ makeItem("m3", { author: "agent:b" }),
308
+ ];
309
+ const scored = toScored(items, [0.9, 0.8, 0.7]);
310
+ const diversified = applyDiversity(scored, { author_penalty: 0.3 });
311
+
312
+ // m1: 0.9 (first from agent:a, no penalty)
313
+ // m3: 0.7 (first from agent:b, no penalty)
314
+ // m2: 0.8 - 0.3 = 0.5 (second from agent:a)
315
+ expect(diversified[0].item.id).toBe("m1");
316
+ expect(diversified[1].item.id).toBe("m3");
317
+ expect(diversified[2].item.id).toBe("m2");
318
+ expect(diversified[2].score).toBeCloseTo(0.5);
319
+ });
320
+
321
+ it("penalizes shared parents", () => {
322
+ const items = [
323
+ makeItem("m2", { parents: ["m1"] }),
324
+ makeItem("m3", { parents: ["m1"] }),
325
+ makeItem("m4", { parents: ["m5"] }),
326
+ ];
327
+ const scored = toScored(items, [0.9, 0.8, 0.7]);
328
+ const diversified = applyDiversity(scored, { parent_penalty: 0.4 });
329
+
330
+ // m2: 0.9 (first from parent m1)
331
+ // m4: 0.7 (first from parent m5)
332
+ // m3: 0.8 - 0.4 = 0.4 (second from parent m1)
333
+ expect(diversified[0].item.id).toBe("m2");
334
+ expect(diversified[1].item.id).toBe("m4");
335
+ expect(diversified[2].item.id).toBe("m3");
336
+ });
337
+
338
+ it("penalizes duplicate source_kind", () => {
339
+ const items = [
340
+ makeItem("m1", { source_kind: "observed" }),
341
+ makeItem("m2", { source_kind: "observed" }),
342
+ makeItem("m3", { source_kind: "agent_inferred" }),
343
+ ];
344
+ const scored = toScored(items, [0.9, 0.85, 0.7]);
345
+ const diversified = applyDiversity(scored, { source_penalty: 0.2 });
346
+
347
+ expect(diversified[0].item.id).toBe("m1");
348
+ expect(diversified[1].item.id).toBe("m3"); // different source, no penalty
349
+ expect(diversified[2].item.id).toBe("m2"); // 0.85 - 0.2 = 0.65
350
+ });
351
+
352
+ it("combines multiple penalties", () => {
353
+ const items = [
354
+ makeItem("m1", { author: "agent:a", source_kind: "observed" }),
355
+ makeItem("m2", { author: "agent:a", source_kind: "observed" }),
356
+ ];
357
+ const scored = toScored(items, [0.9, 0.9]);
358
+ const diversified = applyDiversity(scored, {
359
+ author_penalty: 0.1,
360
+ source_penalty: 0.1,
361
+ });
362
+
363
+ // m1: 0.9, m2: 0.9 - 0.1 - 0.1 = 0.7
364
+ expect(diversified[0].score).toBeCloseTo(0.9);
365
+ expect(diversified[1].score).toBeCloseTo(0.7);
366
+ });
367
+
368
+ it("clamps score to 0", () => {
369
+ const items = [makeItem("m1"), makeItem("m2", { author: "agent:a" })];
370
+ // m1 first with author agent:a, m2 second with same author
371
+ items[0].author = "agent:a";
372
+ const scored = toScored(items, [0.5, 0.1]);
373
+ const diversified = applyDiversity(scored, { author_penalty: 0.5 });
374
+ expect(diversified[1].score).toBe(0); // 0.1 - 0.5, clamped to 0
375
+ });
376
+ });
377
+
378
+ // ============================================================
379
+ // 4. Temporal sort (recency)
380
+ // ============================================================
381
+
382
+ describe("extractTimestamp", () => {
383
+ it("extracts milliseconds from a uuidv7", () => {
384
+ const item = createMemoryItem({
385
+ scope: "test",
386
+ kind: "observation",
387
+ content: {},
388
+ author: "test",
389
+ source_kind: "observed",
390
+ authority: 1,
391
+ });
392
+ const ts = extractTimestamp(item.id);
393
+ const now = Date.now();
394
+ // should be within 1 second of now
395
+ expect(Math.abs(ts - now)).toBeLessThan(1000);
396
+ });
397
+
398
+ it("preserves ordering between items created sequentially", () => {
399
+ const item1 = createMemoryItem({
400
+ scope: "test",
401
+ kind: "observation",
402
+ content: {},
403
+ author: "test",
404
+ source_kind: "observed",
405
+ authority: 1,
406
+ });
407
+ const item2 = createMemoryItem({
408
+ scope: "test",
409
+ kind: "observation",
410
+ content: {},
411
+ author: "test",
412
+ source_kind: "observed",
413
+ authority: 1,
414
+ });
415
+ expect(extractTimestamp(item2.id)).toBeGreaterThanOrEqual(
416
+ extractTimestamp(item1.id),
417
+ );
418
+ });
419
+ });
420
+
421
+ describe("recency sort", () => {
422
+ it("sorts by creation time descending", () => {
423
+ const items = [
424
+ makeItem("older", { authority: 0.5 }),
425
+ makeItem("newer", { authority: 0.5 }),
426
+ ];
427
+ // manually set ids with known time ordering
428
+ // use createMemoryItem to get real uuidv7 ids
429
+ const older = createMemoryItem({
430
+ scope: "test",
431
+ kind: "observation",
432
+ content: { order: 1 },
433
+ author: "test",
434
+ source_kind: "observed",
435
+ authority: 0.5,
436
+ });
437
+ const newer = createMemoryItem({
438
+ scope: "test",
439
+ kind: "observation",
440
+ content: { order: 2 },
441
+ author: "test",
442
+ source_kind: "observed",
443
+ authority: 0.5,
444
+ });
445
+ const state = stateWith([older, newer]);
446
+
447
+ const result = getItems(
448
+ state,
449
+ {},
450
+ { sort: { field: "recency", order: "desc" } },
451
+ );
452
+ expect(extractTimestamp(result[0].id)).toBeGreaterThanOrEqual(
453
+ extractTimestamp(result[1].id),
454
+ );
455
+ });
456
+ });
457
+
458
+ // ============================================================
459
+ // 5. Decay scoring
460
+ // ============================================================
461
+
462
+ describe("decay scoring", () => {
463
+ it("exponential decay reduces score for older items", () => {
464
+ // items with synthetic ids — use real uuidv7 so they have "now" timestamps
465
+ const item = createMemoryItem({
466
+ scope: "test",
467
+ kind: "observation",
468
+ content: {},
469
+ author: "test",
470
+ source_kind: "observed",
471
+ authority: 1.0,
472
+ importance: 1.0,
473
+ });
474
+ const state = stateWith([item]);
475
+
476
+ // no decay — full score
477
+ const noDecay = getScoredItems(state, { authority: 0.5, importance: 0.5 });
478
+ expect(noDecay[0].score).toBeCloseTo(1.0);
479
+
480
+ // with decay — item created just now, so decay multiplier is ~1.0
481
+ const withDecay = getScoredItems(state, {
482
+ authority: 0.5,
483
+ importance: 0.5,
484
+ decay: { rate: 0.1, interval: "day", type: "exponential" },
485
+ });
486
+ // age is ~0ms, so multiplier is ~1.0
487
+ expect(withDecay[0].score).toBeCloseTo(1.0, 1);
488
+ });
489
+
490
+ it("exponential decay formula is correct", () => {
491
+ // manually construct an item with a known old timestamp
492
+ // uuidv7 encodes ms in first 48 bits — we can fake it
493
+ const now = Date.now();
494
+ const twoDaysAgo = now - 2 * 86400000;
495
+ const hex = twoDaysAgo.toString(16).padStart(12, "0");
496
+ const fakeId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
497
+
498
+ const item: MemoryItem = {
499
+ id: fakeId,
500
+ scope: "test",
501
+ kind: "observation",
502
+ content: {},
503
+ author: "test",
504
+ source_kind: "observed",
505
+ authority: 1.0,
506
+ };
507
+ const state = stateWith([item]);
508
+
509
+ const result = getScoredItems(state, {
510
+ authority: 1.0,
511
+ decay: { rate: 0.5, interval: "day", type: "exponential" },
512
+ });
513
+ // 2 days old, 50% decay per day exponential: (1 - 0.5)^2 = 0.25
514
+ expect(result[0].score).toBeCloseTo(0.25, 1);
515
+ });
516
+
517
+ it("linear decay reaches zero", () => {
518
+ const now = Date.now();
519
+ const fiveDaysAgo = now - 5 * 86400000;
520
+ const hex = fiveDaysAgo.toString(16).padStart(12, "0");
521
+ const fakeId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
522
+
523
+ const item: MemoryItem = {
524
+ id: fakeId,
525
+ scope: "test",
526
+ kind: "observation",
527
+ content: {},
528
+ author: "test",
529
+ source_kind: "observed",
530
+ authority: 1.0,
531
+ };
532
+ const state = stateWith([item]);
533
+
534
+ const result = getScoredItems(state, {
535
+ authority: 1.0,
536
+ decay: { rate: 0.3, interval: "day", type: "linear" },
537
+ });
538
+ // 5 days * 0.3/day = 1.5 → clamped to 0 → multiplier = 0
539
+ expect(result[0].score).toBe(0);
540
+ });
541
+
542
+ it("step decay drops at interval boundaries", () => {
543
+ const now = Date.now();
544
+ const oneAndHalfDays = now - 1.5 * 86400000;
545
+ const hex = oneAndHalfDays.toString(16).padStart(12, "0");
546
+ const fakeId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
547
+
548
+ const item: MemoryItem = {
549
+ id: fakeId,
550
+ scope: "test",
551
+ kind: "observation",
552
+ content: {},
553
+ author: "test",
554
+ source_kind: "observed",
555
+ authority: 1.0,
556
+ };
557
+ const state = stateWith([item]);
558
+
559
+ const result = getScoredItems(state, {
560
+ authority: 1.0,
561
+ decay: { rate: 0.5, interval: "day", type: "step" },
562
+ });
563
+ // floor(1.5) = 1 interval, step: (1-0.5)^1 = 0.5
564
+ expect(result[0].score).toBeCloseTo(0.5, 1);
565
+ });
566
+
567
+ it("hourly interval works", () => {
568
+ const now = Date.now();
569
+ const threeHoursAgo = now - 3 * 3600000;
570
+ const hex = threeHoursAgo.toString(16).padStart(12, "0");
571
+ const fakeId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
572
+
573
+ const item: MemoryItem = {
574
+ id: fakeId,
575
+ scope: "test",
576
+ kind: "observation",
577
+ content: {},
578
+ author: "test",
579
+ source_kind: "observed",
580
+ authority: 1.0,
581
+ };
582
+ const state = stateWith([item]);
583
+
584
+ const result = getScoredItems(state, {
585
+ authority: 1.0,
586
+ decay: { rate: 0.2, interval: "hour", type: "exponential" },
587
+ });
588
+ // 3 hours, 20%/hour: (0.8)^3 = 0.512
589
+ expect(result[0].score).toBeCloseTo(0.512, 1);
590
+ });
591
+
592
+ it("no decay config means no decay applied", () => {
593
+ const item = makeItem("m1", { authority: 0.8 });
594
+ const state = stateWith([item]);
595
+ const result = getScoredItems(state, { authority: 1.0 });
596
+ expect(result[0].score).toBeCloseTo(0.8);
597
+ });
598
+ });
599
+
600
+ // ============================================================
601
+ // 6. Smart retrieval (combined pipeline)
602
+ // ============================================================
603
+
604
+ describe("smartRetrieve", () => {
605
+ it("basic budget packing", () => {
606
+ const state = stateWith([
607
+ makeItem("m1", { authority: 0.9 }),
608
+ makeItem("m2", { authority: 0.5 }),
609
+ makeItem("m3", { authority: 0.3 }),
610
+ ]);
611
+ const result = smartRetrieve(state, {
612
+ budget: 20,
613
+ costFn: () => 10,
614
+ weights: { authority: 1 },
615
+ });
616
+ expect(result).toHaveLength(2);
617
+ expect(result[0].item.id).toBe("m1");
618
+ expect(result[1].item.id).toBe("m2");
619
+ });
620
+
621
+ it("excludes superseded items with contradictions: filter", () => {
622
+ const state = stateWith([
623
+ makeItem("m1", { authority: 0.9 }),
624
+ makeItem("m2", { authority: 0.7 }),
625
+ makeItem("m3", { authority: 0.5 }),
626
+ ]);
627
+ const { state: marked } = markContradiction(
628
+ state,
629
+ "m1",
630
+ "m2",
631
+ "system:detector",
632
+ );
633
+ const { state: resolved } = resolveContradiction(
634
+ marked,
635
+ "m1",
636
+ "m2",
637
+ "system:resolver",
638
+ );
639
+
640
+ const result = smartRetrieve(resolved, {
641
+ budget: 100,
642
+ costFn: () => 1,
643
+ weights: { authority: 1 },
644
+ contradictions: "filter",
645
+ });
646
+
647
+ const ids = result.map((r) => r.item.id);
648
+ expect(ids).toContain("m1");
649
+ expect(ids).not.toContain("m2"); // superseded
650
+ expect(ids).toContain("m3");
651
+ });
652
+
653
+ it("applies diversity to spread authors", () => {
654
+ const state = stateWith([
655
+ makeItem("m1", { author: "agent:a", authority: 0.9 }),
656
+ makeItem("m2", { author: "agent:a", authority: 0.85 }),
657
+ makeItem("m3", { author: "agent:b", authority: 0.8 }),
658
+ ]);
659
+ const result = smartRetrieve(state, {
660
+ budget: 20,
661
+ costFn: () => 10,
662
+ weights: { authority: 1 },
663
+ diversity: { author_penalty: 0.5 },
664
+ });
665
+ // m1 (0.9) and m3 (0.8) should be picked over m2 (0.85 - 0.5 = 0.35)
666
+ expect(result).toHaveLength(2);
667
+ expect(result[0].item.id).toBe("m1");
668
+ expect(result[1].item.id).toBe("m3");
669
+ });
670
+
671
+ it("full pipeline: filter + contradictions + diversity + budget", () => {
672
+ const state = stateWith([
673
+ makeItem("m1", { scope: "a", author: "agent:x", authority: 0.9 }),
674
+ makeItem("m2", { scope: "a", author: "agent:x", authority: 0.85 }),
675
+ makeItem("m3", { scope: "a", author: "agent:y", authority: 0.8 }),
676
+ makeItem("m4", { scope: "b", author: "agent:z", authority: 0.95 }),
677
+ ]);
678
+ const result = smartRetrieve(state, {
679
+ budget: 20,
680
+ costFn: () => 10,
681
+ weights: { authority: 1 },
682
+ filter: { scope: "a" },
683
+ diversity: { author_penalty: 0.3 },
684
+ });
685
+ // scope "a" only: m1 (0.9), m2 (0.85-0.3=0.55), m3 (0.8)
686
+ // budget 20, cost 10: picks m1 and m3
687
+ expect(result).toHaveLength(2);
688
+ expect(result[0].item.id).toBe("m1");
689
+ expect(result[1].item.id).toBe("m3");
690
+ });
691
+ });