@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,661 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { applyCommand } from "../src/reducer.js";
3
+ import { createGraphState } from "../src/graph.js";
4
+ import { createMemoryItem, createEdge } from "../src/helpers.js";
5
+ import {
6
+ getItems,
7
+ getRelatedItems,
8
+ getEdges,
9
+ getScoredItems,
10
+ extractTimestamp,
11
+ } from "../src/query.js";
12
+ import {
13
+ getStaleItems,
14
+ getAliasGroup,
15
+ markAlias,
16
+ markContradiction,
17
+ } from "../src/integrity.js";
18
+ import {
19
+ getSupportTree,
20
+ getSupportSet,
21
+ filterContradictions,
22
+ applyDiversity,
23
+ } from "../src/retrieval.js";
24
+ import { applyMany, bulkAdjustScores, decayImportance } from "../src/bulk.js";
25
+ import { replayFromEnvelopes } from "../src/replay.js";
26
+ import type { MemoryItem, Edge, GraphState, ScoredItem } from "../src/types.js";
27
+
28
+ // -- helpers --
29
+
30
+ const makeItem = (
31
+ id: string,
32
+ overrides: Partial<MemoryItem> = {},
33
+ ): MemoryItem => ({
34
+ id,
35
+ scope: "test",
36
+ kind: "observation",
37
+ content: {},
38
+ author: "agent:a",
39
+ source_kind: "observed",
40
+ authority: 0.5,
41
+ ...overrides,
42
+ });
43
+
44
+ function stateWith(items: MemoryItem[], edges: Edge[] = []): GraphState {
45
+ const s = createGraphState();
46
+ for (const i of items) s.items.set(i.id, i);
47
+ for (const e of edges) s.edges.set(e.edge_id, e);
48
+ return s;
49
+ }
50
+
51
+ // ============================================================
52
+ // Reducer edge cases
53
+ // ============================================================
54
+
55
+ describe("reducer edge cases", () => {
56
+ it("update with null authority sets it to null (not ignored)", () => {
57
+ const state = stateWith([makeItem("m1", { authority: 0.9 })]);
58
+ const { state: next } = applyCommand(state, {
59
+ type: "memory.update",
60
+ item_id: "m1",
61
+ partial: { authority: null as any },
62
+ author: "test",
63
+ });
64
+ // null overwrites the field
65
+ expect(next.items.get("m1")!.authority).toBeNull();
66
+ });
67
+
68
+ it("update with undefined in partial does not overwrite existing value", () => {
69
+ const state = stateWith([
70
+ makeItem("m1", { authority: 0.9, importance: 0.7 }),
71
+ ]);
72
+ const { state: next } = applyCommand(state, {
73
+ type: "memory.update",
74
+ item_id: "m1",
75
+ partial: { importance: undefined },
76
+ author: "test",
77
+ });
78
+ // undefined values are stripped — "I'm not changing this field"
79
+ expect(next.items.get("m1")!.importance).toBe(0.7);
80
+ });
81
+
82
+ it("update with empty partial is a no-op (item unchanged)", () => {
83
+ const state = stateWith([makeItem("m1", { authority: 0.9 })]);
84
+ const { state: next, events } = applyCommand(state, {
85
+ type: "memory.update",
86
+ item_id: "m1",
87
+ partial: {},
88
+ author: "test",
89
+ });
90
+ expect(next.items.get("m1")!.authority).toBe(0.9);
91
+ expect(events).toHaveLength(1); // still emits event
92
+ });
93
+
94
+ it("update all three scores simultaneously", () => {
95
+ const state = stateWith([makeItem("m1", { authority: 0.5 })]);
96
+ const { state: next } = applyCommand(state, {
97
+ type: "memory.update",
98
+ item_id: "m1",
99
+ partial: { authority: 0.1, conviction: 0.2, importance: 0.3 },
100
+ author: "test",
101
+ });
102
+ expect(next.items.get("m1")!.authority).toBe(0.1);
103
+ expect(next.items.get("m1")!.conviction).toBe(0.2);
104
+ expect(next.items.get("m1")!.importance).toBe(0.3);
105
+ });
106
+ });
107
+
108
+ // ============================================================
109
+ // Helpers boundary values
110
+ // ============================================================
111
+
112
+ describe("score validation boundaries", () => {
113
+ it("accepts exactly 0", () => {
114
+ expect(() =>
115
+ createMemoryItem({
116
+ scope: "t",
117
+ kind: "observation",
118
+ content: {},
119
+ author: "t",
120
+ source_kind: "observed",
121
+ authority: 0,
122
+ }),
123
+ ).not.toThrow();
124
+ });
125
+
126
+ it("accepts exactly 1", () => {
127
+ expect(() =>
128
+ createMemoryItem({
129
+ scope: "t",
130
+ kind: "observation",
131
+ content: {},
132
+ author: "t",
133
+ source_kind: "observed",
134
+ authority: 1,
135
+ }),
136
+ ).not.toThrow();
137
+ });
138
+
139
+ it("rejects just below 0", () => {
140
+ expect(() =>
141
+ createMemoryItem({
142
+ scope: "t",
143
+ kind: "observation",
144
+ content: {},
145
+ author: "t",
146
+ source_kind: "observed",
147
+ authority: -0.00001,
148
+ }),
149
+ ).toThrow(RangeError);
150
+ });
151
+
152
+ it("rejects just above 1", () => {
153
+ expect(() =>
154
+ createMemoryItem({
155
+ scope: "t",
156
+ kind: "observation",
157
+ content: {},
158
+ author: "t",
159
+ source_kind: "observed",
160
+ authority: 1.00001,
161
+ }),
162
+ ).toThrow(RangeError);
163
+ });
164
+ });
165
+
166
+ // ============================================================
167
+ // Query edge cases
168
+ // ============================================================
169
+
170
+ describe("query edge cases", () => {
171
+ it("resolvePath handles deeply nested paths (4+ levels)", () => {
172
+ const state = stateWith([
173
+ makeItem("m1", { meta: { a: { b: { c: { d: "deep" } } } } as any }),
174
+ ]);
175
+ const result = getItems(state, { meta: { "a.b.c.d": "deep" } });
176
+ expect(result).toHaveLength(1);
177
+ expect(result[0].id).toBe("m1");
178
+ });
179
+
180
+ it("getRelatedItems does not return self for self-edges", () => {
181
+ const state = stateWith(
182
+ [makeItem("m1")],
183
+ [
184
+ {
185
+ edge_id: "e1",
186
+ from: "m1",
187
+ to: "m1",
188
+ kind: "ABOUT",
189
+ author: "test",
190
+ source_kind: "observed",
191
+ authority: 1,
192
+ active: true,
193
+ },
194
+ ],
195
+ );
196
+ const related = getRelatedItems(state, "m1");
197
+ expect(related).toHaveLength(0);
198
+ });
199
+
200
+ it("range filter min === max works as exact match", () => {
201
+ const state = stateWith([
202
+ makeItem("m1", { authority: 0.5 }),
203
+ makeItem("m2", { authority: 0.6 }),
204
+ ]);
205
+ const result = getItems(state, {
206
+ range: { authority: { min: 0.5, max: 0.5 } },
207
+ });
208
+ expect(result).toHaveLength(1);
209
+ expect(result[0].id).toBe("m1");
210
+ });
211
+
212
+ it("getScoredItems min_score at exact threshold includes item", () => {
213
+ const state = stateWith([makeItem("m1", { authority: 0.5 })]);
214
+ const result = getScoredItems(state, { authority: 1 }, { min_score: 0.5 });
215
+ expect(result).toHaveLength(1);
216
+ expect(result[0].score).toBe(0.5);
217
+ });
218
+ });
219
+
220
+ // ============================================================
221
+ // Integrity edge cases
222
+ // ============================================================
223
+
224
+ describe("integrity edge cases", () => {
225
+ it("getStaleItems with partial staleness (one parent missing, one present)", () => {
226
+ const state = stateWith([
227
+ makeItem("m2"),
228
+ makeItem("m3", { parents: ["m1", "m2"] }), // m1 missing, m2 present
229
+ ]);
230
+ const stale = getStaleItems(state);
231
+ expect(stale).toHaveLength(1);
232
+ expect(stale[0].missing_parents).toEqual(["m1"]);
233
+ });
234
+
235
+ it("getStaleItems with multiple missing parents", () => {
236
+ const state = stateWith([
237
+ makeItem("m3", { parents: ["m1", "m2"] }), // both missing
238
+ ]);
239
+ const stale = getStaleItems(state);
240
+ expect(stale[0].missing_parents.sort()).toEqual(["m1", "m2"]);
241
+ });
242
+
243
+ it("getAliasGroup handles cycles (A→B→C→A)", () => {
244
+ const state = stateWith([makeItem("m1"), makeItem("m2"), makeItem("m3")]);
245
+ let next = markAlias(state, "m1", "m2", "test").state;
246
+ next = markAlias(next, "m2", "m3", "test").state;
247
+ next = markAlias(next, "m3", "m1", "test").state;
248
+ const group = getAliasGroup(next, "m1");
249
+ expect(group).toHaveLength(3);
250
+ expect(group.map((i) => i.id).sort()).toEqual(["m1", "m2", "m3"]);
251
+ });
252
+ });
253
+
254
+ // ============================================================
255
+ // Retrieval edge cases
256
+ // ============================================================
257
+
258
+ describe("retrieval edge cases", () => {
259
+ it("getSupportTree handles cycles in parents", () => {
260
+ // m1 parents [m2], m2 parents [m1] — cycle
261
+ const state = stateWith([
262
+ makeItem("m1", { parents: ["m2"] }),
263
+ makeItem("m2", { parents: ["m1"] }),
264
+ ]);
265
+ const tree = getSupportTree(state, "m1")!;
266
+ expect(tree.item.id).toBe("m1");
267
+ expect(tree.parents).toHaveLength(1);
268
+ expect(tree.parents[0].item.id).toBe("m2");
269
+ // m2's parent is m1, already visited — should have empty parents
270
+ expect(tree.parents[0].parents).toHaveLength(1);
271
+ expect(tree.parents[0].parents[0].parents).toHaveLength(0); // cycle broken
272
+ });
273
+
274
+ it("getSupportSet handles cycles without duplicates", () => {
275
+ const state = stateWith([
276
+ makeItem("m1", { parents: ["m2"] }),
277
+ makeItem("m2", { parents: ["m1"] }),
278
+ ]);
279
+ const set = getSupportSet(state, "m1");
280
+ expect(set).toHaveLength(2);
281
+ expect(set.map((i) => i.id).sort()).toEqual(["m1", "m2"]);
282
+ });
283
+
284
+ it("getSupportSet with partial chain (middle node missing)", () => {
285
+ const state = stateWith([
286
+ makeItem("m1"),
287
+ // m2 missing
288
+ makeItem("m3", { parents: ["m2"] }),
289
+ makeItem("m4", { parents: ["m3", "m1"] }),
290
+ ]);
291
+ const set = getSupportSet(state, "m4");
292
+ // m2 is missing, so chain through m3 stops at m3
293
+ expect(set.map((i) => i.id).sort()).toEqual(["m1", "m3", "m4"]);
294
+ });
295
+
296
+ it("filterContradictions when neither contradicting item is in scored list", () => {
297
+ const state = stateWith([makeItem("m1"), makeItem("m2"), makeItem("m3")]);
298
+ const { state: marked } = markContradiction(state, "m1", "m2", "test");
299
+ // scored list only has m3
300
+ const scored: ScoredItem[] = [
301
+ { item: marked.items.get("m3")!, score: 0.8 },
302
+ ];
303
+ const filtered = filterContradictions(marked, scored);
304
+ expect(filtered).toHaveLength(1);
305
+ expect(filtered[0].item.id).toBe("m3");
306
+ });
307
+
308
+ it("applyDiversity with empty scored array", () => {
309
+ const result = applyDiversity([], { author_penalty: 0.5 });
310
+ expect(result).toHaveLength(0);
311
+ });
312
+ });
313
+
314
+ // ============================================================
315
+ // Bulk edge cases
316
+ // ============================================================
317
+
318
+ describe("bulk edge cases", () => {
319
+ it("applyMany with empty partial returns no events", () => {
320
+ const state = stateWith([makeItem("m1"), makeItem("m2")]);
321
+ const { state: next, events } = applyMany(state, {}, () => ({}), "test");
322
+ expect(events).toHaveLength(0);
323
+ // state reference should be the same (no changes)
324
+ expect(next).toBe(state);
325
+ });
326
+
327
+ it("bulkAdjustScores with only authority delta leaves conviction/importance unchanged", () => {
328
+ const state = stateWith([
329
+ makeItem("m1", { authority: 0.5, conviction: 0.8, importance: 0.6 }),
330
+ ]);
331
+ const { state: next } = bulkAdjustScores(
332
+ state,
333
+ {},
334
+ { authority: 0.1 },
335
+ "test",
336
+ );
337
+ expect(next.items.get("m1")!.authority).toBeCloseTo(0.6);
338
+ expect(next.items.get("m1")!.conviction).toBe(0.8);
339
+ expect(next.items.get("m1")!.importance).toBe(0.6);
340
+ });
341
+ });
342
+
343
+ // ============================================================
344
+ // Replay edge cases
345
+ // ============================================================
346
+
347
+ // ============================================================
348
+ // Created filter & importance decay
349
+ // ============================================================
350
+
351
+ describe("created filter", () => {
352
+ it("filters items created before a timestamp", () => {
353
+ const old = createMemoryItem({
354
+ scope: "test",
355
+ kind: "observation",
356
+ content: { v: 1 },
357
+ author: "test",
358
+ source_kind: "observed",
359
+ authority: 0.5,
360
+ });
361
+ // items created just now are "after" any past cutoff
362
+ const state = stateWith([old]);
363
+ const cutoff = Date.now() + 1000; // 1s in the future
364
+ const result = getItems(state, { created: { before: cutoff } });
365
+ expect(result).toHaveLength(1);
366
+ });
367
+
368
+ it("filters items created after a timestamp", () => {
369
+ const item = createMemoryItem({
370
+ scope: "test",
371
+ kind: "observation",
372
+ content: {},
373
+ author: "test",
374
+ source_kind: "observed",
375
+ authority: 0.5,
376
+ });
377
+ const state = stateWith([item]);
378
+ const past = Date.now() - 10000; // 10s ago
379
+ const result = getItems(state, { created: { after: past } });
380
+ expect(result).toHaveLength(1);
381
+ });
382
+
383
+ it("excludes items outside the created range", () => {
384
+ const item = createMemoryItem({
385
+ scope: "test",
386
+ kind: "observation",
387
+ content: {},
388
+ author: "test",
389
+ source_kind: "observed",
390
+ authority: 0.5,
391
+ });
392
+ const state = stateWith([item]);
393
+ const future = Date.now() + 60000;
394
+ const result = getItems(state, { created: { after: future } });
395
+ expect(result).toHaveLength(0);
396
+ });
397
+
398
+ it("combines created with other filters", () => {
399
+ const item = createMemoryItem({
400
+ scope: "a",
401
+ kind: "observation",
402
+ content: {},
403
+ author: "test",
404
+ source_kind: "observed",
405
+ authority: 0.5,
406
+ });
407
+ const state = stateWith([item]);
408
+ const past = Date.now() - 10000;
409
+ const result = getItems(state, { scope: "a", created: { after: past } });
410
+ expect(result).toHaveLength(1);
411
+
412
+ const result2 = getItems(state, { scope: "b", created: { after: past } });
413
+ expect(result2).toHaveLength(0);
414
+ });
415
+ });
416
+
417
+ describe("decayImportance", () => {
418
+ it("decays importance on old items", () => {
419
+ // create items with real uuidv7 ids (created now)
420
+ const item = createMemoryItem({
421
+ scope: "test",
422
+ kind: "observation",
423
+ content: {},
424
+ author: "test",
425
+ source_kind: "observed",
426
+ authority: 0.5,
427
+ importance: 0.8,
428
+ });
429
+ const state = stateWith([item]);
430
+
431
+ // olderThanMs = -1000 means cutoff = now + 1s → everything created before that matches
432
+ const { state: next, events } = decayImportance(
433
+ state,
434
+ -1000,
435
+ 0.5,
436
+ "system:decay",
437
+ );
438
+ expect(next.items.get(item.id)!.importance).toBeCloseTo(0.4);
439
+ expect(events).toHaveLength(1);
440
+ });
441
+
442
+ it("skips items with zero importance", () => {
443
+ const item = createMemoryItem({
444
+ scope: "test",
445
+ kind: "observation",
446
+ content: {},
447
+ author: "test",
448
+ source_kind: "observed",
449
+ authority: 0.5,
450
+ importance: 0,
451
+ });
452
+ const state = stateWith([item]);
453
+ const { events } = decayImportance(state, 0, 0.5, "system:decay");
454
+ expect(events).toHaveLength(0);
455
+ });
456
+
457
+ it("skips items with undefined importance", () => {
458
+ const item = createMemoryItem({
459
+ scope: "test",
460
+ kind: "observation",
461
+ content: {},
462
+ author: "test",
463
+ source_kind: "observed",
464
+ authority: 0.5,
465
+ });
466
+ const state = stateWith([item]);
467
+ const { events } = decayImportance(state, 0, 0.5, "system:decay");
468
+ expect(events).toHaveLength(0);
469
+ });
470
+
471
+ it("does not decay recent items", () => {
472
+ const item = createMemoryItem({
473
+ scope: "test",
474
+ kind: "observation",
475
+ content: {},
476
+ author: "test",
477
+ source_kind: "observed",
478
+ authority: 0.5,
479
+ importance: 0.9,
480
+ });
481
+ const state = stateWith([item]);
482
+ // olderThanMs = very large → cutoff is far in the past → nothing matches
483
+ const { state: next, events } = decayImportance(
484
+ state,
485
+ 999999999,
486
+ 0.5,
487
+ "system:decay",
488
+ );
489
+ expect(events).toHaveLength(0);
490
+ expect(next.items.get(item.id)!.importance).toBe(0.9);
491
+ });
492
+ });
493
+
494
+ // ============================================================
495
+ // Decay filter on getItems
496
+ // ============================================================
497
+
498
+ function fakeIdAtAge(daysAgo: number): string {
499
+ const ms = Date.now() - daysAgo * 86400000;
500
+ const hex = ms.toString(16).padStart(12, "0");
501
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
502
+ }
503
+
504
+ describe("decay filter", () => {
505
+ it("excludes items that have decayed below min (exponential)", () => {
506
+ const state = stateWith([
507
+ makeItem(fakeIdAtAge(0), { authority: 0.9 }), // just created — multiplier ~1.0
508
+ makeItem(fakeIdAtAge(1), { authority: 0.9 }), // 1 day old — multiplier 0.5
509
+ makeItem(fakeIdAtAge(3), { authority: 0.9 }), // 3 days old — multiplier 0.125
510
+ ]);
511
+ const result = getItems(state, {
512
+ decay: {
513
+ config: { rate: 0.5, interval: "day", type: "exponential" },
514
+ min: 0.4, // keep items with multiplier >= 0.4
515
+ },
516
+ });
517
+ // 0-day: ~1.0 (pass), 1-day: 0.5 (pass), 3-day: 0.125 (fail)
518
+ expect(result).toHaveLength(2);
519
+ });
520
+
521
+ it("excludes all old items with aggressive decay", () => {
522
+ const state = stateWith([
523
+ makeItem(fakeIdAtAge(2), { authority: 0.9 }),
524
+ makeItem(fakeIdAtAge(5), { authority: 0.9 }),
525
+ ]);
526
+ const result = getItems(state, {
527
+ decay: {
528
+ config: { rate: 0.9, interval: "day", type: "exponential" },
529
+ min: 0.5,
530
+ },
531
+ });
532
+ // 2 days at 90%/day: (0.1)^2 = 0.01, 5 days: (0.1)^5 = 0.00001
533
+ expect(result).toHaveLength(0);
534
+ });
535
+
536
+ it("keeps all recent items with gentle decay", () => {
537
+ const state = stateWith([
538
+ makeItem(fakeIdAtAge(0), { authority: 0.5 }),
539
+ makeItem(fakeIdAtAge(0.5), { authority: 0.5 }),
540
+ ]);
541
+ const result = getItems(state, {
542
+ decay: {
543
+ config: { rate: 0.1, interval: "day", type: "exponential" },
544
+ min: 0.5,
545
+ },
546
+ });
547
+ // < 1 day at 10%/day → multiplier > 0.9 for both
548
+ expect(result).toHaveLength(2);
549
+ });
550
+
551
+ it("linear decay excludes items past zero point", () => {
552
+ const state = stateWith([
553
+ makeItem(fakeIdAtAge(0), { authority: 0.9 }),
554
+ makeItem(fakeIdAtAge(2), { authority: 0.9 }),
555
+ makeItem(fakeIdAtAge(5), { authority: 0.9 }),
556
+ ]);
557
+ const result = getItems(state, {
558
+ decay: {
559
+ config: { rate: 0.3, interval: "day", type: "linear" },
560
+ min: 0.1,
561
+ },
562
+ });
563
+ // 0-day: 1.0, 2-day: 0.4, 5-day: max(0, 1-1.5) = 0
564
+ expect(result).toHaveLength(2);
565
+ });
566
+
567
+ it("step decay drops at interval boundaries", () => {
568
+ const state = stateWith([
569
+ makeItem(fakeIdAtAge(0.5), { authority: 0.9 }), // floor(0.5) = 0 intervals
570
+ makeItem(fakeIdAtAge(1.5), { authority: 0.9 }), // floor(1.5) = 1 interval
571
+ makeItem(fakeIdAtAge(2.5), { authority: 0.9 }), // floor(2.5) = 2 intervals
572
+ ]);
573
+ const result = getItems(state, {
574
+ decay: {
575
+ config: { rate: 0.5, interval: "day", type: "step" },
576
+ min: 0.3,
577
+ },
578
+ });
579
+ // 0 intervals: 1.0, 1 interval: 0.5, 2 intervals: 0.25
580
+ // min 0.3 → keeps first two, excludes third
581
+ expect(result).toHaveLength(2);
582
+ });
583
+
584
+ it("combines decay filter with other filters", () => {
585
+ const recent1 = createMemoryItem({
586
+ scope: "a",
587
+ kind: "observation",
588
+ content: {},
589
+ author: "test",
590
+ source_kind: "observed",
591
+ authority: 0.9,
592
+ });
593
+ const old = makeItem(fakeIdAtAge(5), { authority: 0.9, scope: "a" });
594
+ const recent2 = createMemoryItem({
595
+ scope: "b",
596
+ kind: "observation",
597
+ content: {},
598
+ author: "test",
599
+ source_kind: "observed",
600
+ authority: 0.9,
601
+ });
602
+ const state = stateWith([recent1, old, recent2]);
603
+ const result = getItems(state, {
604
+ scope: "a",
605
+ decay: {
606
+ config: { rate: 0.5, interval: "day", type: "exponential" },
607
+ min: 0.1,
608
+ },
609
+ });
610
+ // scope "a": recent1 (pass) + old (0.5^5 = 0.03, fail)
611
+ expect(result).toHaveLength(1);
612
+ expect(result[0].id).toBe(recent1.id);
613
+ });
614
+
615
+ it("decay filter works with hourly interval", () => {
616
+ const hourId = (hoursAgo: number): string => {
617
+ const ms = Date.now() - hoursAgo * 3600000;
618
+ const hex = ms.toString(16).padStart(12, "0");
619
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
620
+ };
621
+ const state = stateWith([
622
+ makeItem(hourId(1), { authority: 0.9 }),
623
+ makeItem(hourId(10), { authority: 0.9 }),
624
+ ]);
625
+ const result = getItems(state, {
626
+ decay: {
627
+ config: { rate: 0.2, interval: "hour", type: "exponential" },
628
+ min: 0.5,
629
+ },
630
+ });
631
+ // 1hr: (0.8)^1 = 0.8 (pass), 10hr: (0.8)^10 = 0.107 (fail)
632
+ expect(result).toHaveLength(1);
633
+ });
634
+ });
635
+
636
+ describe("replay edge cases", () => {
637
+ it("replayFromEnvelopes with duplicate timestamps maintains stable order", () => {
638
+ const item1 = makeItem("m1");
639
+ const item2 = makeItem("m2");
640
+ const envelopes = [
641
+ {
642
+ id: "ev1",
643
+ namespace: "memory",
644
+ type: "memory.create",
645
+ ts: "2026-01-01T00:00:00.000Z",
646
+ payload: { type: "memory.create", item: item1 },
647
+ },
648
+ {
649
+ id: "ev2",
650
+ namespace: "memory",
651
+ type: "memory.create",
652
+ ts: "2026-01-01T00:00:00.000Z",
653
+ payload: { type: "memory.create", item: item2 },
654
+ },
655
+ ];
656
+ const { state } = replayFromEnvelopes(envelopes);
657
+ expect(state.items.size).toBe(2);
658
+ expect(state.items.has("m1")).toBe(true);
659
+ expect(state.items.has("m2")).toBe(true);
660
+ });
661
+ });