@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.
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +35 -0
- package/API.md +1078 -0
- package/LICENSE +190 -0
- package/README.md +574 -0
- package/package.json +30 -0
- package/src/bulk.ts +128 -0
- package/src/envelope.ts +52 -0
- package/src/errors.ts +27 -0
- package/src/graph.ts +15 -0
- package/src/helpers.ts +51 -0
- package/src/index.ts +142 -0
- package/src/integrity.ts +378 -0
- package/src/intent.ts +311 -0
- package/src/query.ts +357 -0
- package/src/reducer.ts +177 -0
- package/src/replay.ts +32 -0
- package/src/retrieval.ts +306 -0
- package/src/serialization.ts +34 -0
- package/src/stats.ts +62 -0
- package/src/task.ts +373 -0
- package/src/transplant.ts +488 -0
- package/src/types.ts +248 -0
- package/tests/bugfix-and-coverage.test.ts +958 -0
- package/tests/bugfix-holes.test.ts +856 -0
- package/tests/bulk.test.ts +256 -0
- package/tests/edge-cases-v2.test.ts +355 -0
- package/tests/edge-cases.test.ts +661 -0
- package/tests/envelope.test.ts +92 -0
- package/tests/graph.test.ts +41 -0
- package/tests/helpers.test.ts +120 -0
- package/tests/integrity.test.ts +371 -0
- package/tests/intent.test.ts +276 -0
- package/tests/query-advanced.test.ts +252 -0
- package/tests/query.test.ts +623 -0
- package/tests/reducer.test.ts +342 -0
- package/tests/replay.test.ts +145 -0
- package/tests/retrieval.test.ts +691 -0
- package/tests/serialization.test.ts +118 -0
- package/tests/setup.test.ts +7 -0
- package/tests/stats.test.ts +163 -0
- package/tests/task.test.ts +322 -0
- package/tests/transplant.test.ts +385 -0
- package/tests/types.test.ts +231 -0
- package/tsconfig.json +18 -0
- 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
|
+
});
|