@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,958 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { applyCommand, mergeItem } from "../src/reducer.js";
|
|
3
|
+
import { createGraphState } from "../src/graph.js";
|
|
4
|
+
import {
|
|
5
|
+
getItems,
|
|
6
|
+
getEdges,
|
|
7
|
+
getScoredItems,
|
|
8
|
+
extractTimestamp,
|
|
9
|
+
} from "../src/query.js";
|
|
10
|
+
import {
|
|
11
|
+
filterContradictions,
|
|
12
|
+
surfaceContradictions,
|
|
13
|
+
applyDiversity,
|
|
14
|
+
smartRetrieve,
|
|
15
|
+
} from "../src/retrieval.js";
|
|
16
|
+
import {
|
|
17
|
+
getContradictions,
|
|
18
|
+
markContradiction,
|
|
19
|
+
resolveContradiction,
|
|
20
|
+
getStaleItems,
|
|
21
|
+
getDependents,
|
|
22
|
+
cascadeRetract,
|
|
23
|
+
getAliases,
|
|
24
|
+
getAliasGroup,
|
|
25
|
+
} from "../src/integrity.js";
|
|
26
|
+
import {
|
|
27
|
+
createIntentState,
|
|
28
|
+
createIntent,
|
|
29
|
+
applyIntentCommand,
|
|
30
|
+
InvalidIntentTransitionError,
|
|
31
|
+
} from "../src/intent.js";
|
|
32
|
+
import {
|
|
33
|
+
createTaskState,
|
|
34
|
+
createTask,
|
|
35
|
+
applyTaskCommand,
|
|
36
|
+
InvalidTaskTransitionError,
|
|
37
|
+
} from "../src/task.js";
|
|
38
|
+
import { exportSlice, importSlice } from "../src/transplant.js";
|
|
39
|
+
import { toJSON, fromJSON, stringify, parse } from "../src/serialization.js";
|
|
40
|
+
import { cloneGraphState } from "../src/graph.js";
|
|
41
|
+
import {
|
|
42
|
+
DuplicateMemoryError,
|
|
43
|
+
EdgeNotFoundError,
|
|
44
|
+
} from "../src/errors.js";
|
|
45
|
+
import type {
|
|
46
|
+
MemoryItem,
|
|
47
|
+
Edge,
|
|
48
|
+
GraphState,
|
|
49
|
+
ScoredItem,
|
|
50
|
+
} from "../src/types.js";
|
|
51
|
+
import type { IntentState, Intent } from "../src/intent.js";
|
|
52
|
+
import type { TaskState, Task } from "../src/task.js";
|
|
53
|
+
|
|
54
|
+
// -- helpers --
|
|
55
|
+
|
|
56
|
+
const makeItem = (
|
|
57
|
+
id: string,
|
|
58
|
+
overrides: Partial<MemoryItem> = {},
|
|
59
|
+
): MemoryItem => ({
|
|
60
|
+
id,
|
|
61
|
+
scope: "test",
|
|
62
|
+
kind: "observation",
|
|
63
|
+
content: {},
|
|
64
|
+
author: "agent:a",
|
|
65
|
+
source_kind: "observed",
|
|
66
|
+
authority: 0.5,
|
|
67
|
+
...overrides,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const makeEdge = (
|
|
71
|
+
id: string,
|
|
72
|
+
from: string,
|
|
73
|
+
to: string,
|
|
74
|
+
kind: string = "SUPPORTS",
|
|
75
|
+
overrides: Partial<Edge> = {},
|
|
76
|
+
): Edge => ({
|
|
77
|
+
edge_id: id,
|
|
78
|
+
from,
|
|
79
|
+
to,
|
|
80
|
+
kind,
|
|
81
|
+
author: "system:rule",
|
|
82
|
+
source_kind: "derived_deterministic",
|
|
83
|
+
authority: 0.8,
|
|
84
|
+
active: true,
|
|
85
|
+
...overrides,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
function stateWith(items: MemoryItem[], edges: Edge[] = []): GraphState {
|
|
89
|
+
const s = createGraphState();
|
|
90
|
+
for (const i of items) s.items.set(i.id, i);
|
|
91
|
+
for (const e of edges) s.edges.set(e.edge_id, e);
|
|
92
|
+
return s;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toScored(items: MemoryItem[], scores: number[]): ScoredItem[] {
|
|
96
|
+
return items.map((item, i) => ({ item, score: scores[i] }));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const makeIntent = (overrides: Partial<Intent> = {}): Intent => ({
|
|
100
|
+
id: "i1",
|
|
101
|
+
label: "find_kati",
|
|
102
|
+
priority: 0.8,
|
|
103
|
+
owner: "user:laz",
|
|
104
|
+
status: "active",
|
|
105
|
+
...overrides,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
|
109
|
+
id: "t1",
|
|
110
|
+
intent_id: "i1",
|
|
111
|
+
action: "search_linkedin",
|
|
112
|
+
status: "pending",
|
|
113
|
+
priority: 0.7,
|
|
114
|
+
attempt: 0,
|
|
115
|
+
...overrides,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ============================================================
|
|
119
|
+
// BUG FIX: mergeEdge strips undefined and protects identity fields
|
|
120
|
+
// ============================================================
|
|
121
|
+
|
|
122
|
+
describe("edge.update — mergeEdge fixes", () => {
|
|
123
|
+
it("does not overwrite edge fields with undefined", () => {
|
|
124
|
+
const state = stateWith(
|
|
125
|
+
[],
|
|
126
|
+
[makeEdge("e1", "m1", "m2", "SUPPORTS", { weight: 0.8 })],
|
|
127
|
+
);
|
|
128
|
+
const { state: next } = applyCommand(state, {
|
|
129
|
+
type: "edge.update",
|
|
130
|
+
edge_id: "e1",
|
|
131
|
+
partial: { weight: undefined, kind: "ABOUT" } as Partial<Edge>,
|
|
132
|
+
author: "test",
|
|
133
|
+
});
|
|
134
|
+
const edge = next.edges.get("e1")!;
|
|
135
|
+
expect(edge.weight).toBe(0.8); // preserved, not overwritten with undefined
|
|
136
|
+
expect(edge.kind).toBe("ABOUT"); // actual update applied
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("ignores edge_id in partial (cannot change identity)", () => {
|
|
140
|
+
const state = stateWith([], [makeEdge("e1", "m1", "m2")]);
|
|
141
|
+
const { state: next } = applyCommand(state, {
|
|
142
|
+
type: "edge.update",
|
|
143
|
+
edge_id: "e1",
|
|
144
|
+
partial: { edge_id: "sneaky" } as Partial<Edge>,
|
|
145
|
+
author: "test",
|
|
146
|
+
});
|
|
147
|
+
const edge = next.edges.get("e1")!;
|
|
148
|
+
expect(edge.edge_id).toBe("e1"); // identity preserved
|
|
149
|
+
expect(next.edges.has("sneaky")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("ignores from/to in partial (cannot change endpoints)", () => {
|
|
153
|
+
const state = stateWith([], [makeEdge("e1", "m1", "m2")]);
|
|
154
|
+
const { state: next } = applyCommand(state, {
|
|
155
|
+
type: "edge.update",
|
|
156
|
+
edge_id: "e1",
|
|
157
|
+
partial: { from: "x", to: "y" } as Partial<Edge>,
|
|
158
|
+
author: "test",
|
|
159
|
+
});
|
|
160
|
+
const edge = next.edges.get("e1")!;
|
|
161
|
+
expect(edge.from).toBe("m1");
|
|
162
|
+
expect(edge.to).toBe("m2");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ============================================================
|
|
167
|
+
// BUG FIX: intent.update cannot bypass state machine
|
|
168
|
+
// ============================================================
|
|
169
|
+
|
|
170
|
+
describe("intent.update — status protection", () => {
|
|
171
|
+
it("ignores status in partial, does not bypass state machine", () => {
|
|
172
|
+
const intent = makeIntent({ status: "active" });
|
|
173
|
+
let state = createIntentState();
|
|
174
|
+
state = applyIntentCommand(state, {
|
|
175
|
+
type: "intent.create",
|
|
176
|
+
intent,
|
|
177
|
+
}).state;
|
|
178
|
+
|
|
179
|
+
// attempt to set status to "completed" via update
|
|
180
|
+
const { state: next } = applyIntentCommand(state, {
|
|
181
|
+
type: "intent.update",
|
|
182
|
+
intent_id: "i1",
|
|
183
|
+
partial: { status: "completed" } as Partial<Intent>,
|
|
184
|
+
author: "test",
|
|
185
|
+
});
|
|
186
|
+
// status should remain "active"
|
|
187
|
+
expect(next.intents.get("i1")!.status).toBe("active");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("cannot set status to cancelled via update on cancelled intent", () => {
|
|
191
|
+
const intent = makeIntent({ status: "active" });
|
|
192
|
+
let state = createIntentState();
|
|
193
|
+
state = applyIntentCommand(state, {
|
|
194
|
+
type: "intent.create",
|
|
195
|
+
intent,
|
|
196
|
+
}).state;
|
|
197
|
+
state = applyIntentCommand(state, {
|
|
198
|
+
type: "intent.cancel",
|
|
199
|
+
intent_id: "i1",
|
|
200
|
+
author: "test",
|
|
201
|
+
}).state;
|
|
202
|
+
|
|
203
|
+
// try to "revive" a cancelled intent via update
|
|
204
|
+
const { state: next } = applyIntentCommand(state, {
|
|
205
|
+
type: "intent.update",
|
|
206
|
+
intent_id: "i1",
|
|
207
|
+
partial: { status: "active" } as Partial<Intent>,
|
|
208
|
+
author: "test",
|
|
209
|
+
});
|
|
210
|
+
expect(next.intents.get("i1")!.status).toBe("cancelled");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("still allows updating other fields", () => {
|
|
214
|
+
const intent = makeIntent();
|
|
215
|
+
let state = createIntentState();
|
|
216
|
+
state = applyIntentCommand(state, {
|
|
217
|
+
type: "intent.create",
|
|
218
|
+
intent,
|
|
219
|
+
}).state;
|
|
220
|
+
const { state: next } = applyIntentCommand(state, {
|
|
221
|
+
type: "intent.update",
|
|
222
|
+
intent_id: "i1",
|
|
223
|
+
partial: { label: "new_label", priority: 0.3 },
|
|
224
|
+
author: "test",
|
|
225
|
+
});
|
|
226
|
+
expect(next.intents.get("i1")!.label).toBe("new_label");
|
|
227
|
+
expect(next.intents.get("i1")!.priority).toBe(0.3);
|
|
228
|
+
expect(next.intents.get("i1")!.status).toBe("active"); // unchanged
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
// ============================================================
|
|
233
|
+
// BUG FIX: task.update cannot bypass state machine
|
|
234
|
+
// ============================================================
|
|
235
|
+
|
|
236
|
+
describe("task.update — status protection", () => {
|
|
237
|
+
it("ignores status in partial, does not bypass state machine", () => {
|
|
238
|
+
const task = makeTask({ status: "pending" });
|
|
239
|
+
let state = createTaskState();
|
|
240
|
+
state = applyTaskCommand(state, { type: "task.create", task }).state;
|
|
241
|
+
|
|
242
|
+
const { state: next } = applyTaskCommand(state, {
|
|
243
|
+
type: "task.update",
|
|
244
|
+
task_id: "t1",
|
|
245
|
+
partial: { status: "completed" } as Partial<Task>,
|
|
246
|
+
author: "test",
|
|
247
|
+
});
|
|
248
|
+
expect(next.tasks.get("t1")!.status).toBe("pending");
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("cannot revive a failed task via update", () => {
|
|
252
|
+
const task = makeTask({ status: "pending" });
|
|
253
|
+
let state = createTaskState();
|
|
254
|
+
state = applyTaskCommand(state, { type: "task.create", task }).state;
|
|
255
|
+
state = applyTaskCommand(state, {
|
|
256
|
+
type: "task.start",
|
|
257
|
+
task_id: "t1",
|
|
258
|
+
}).state;
|
|
259
|
+
state = applyTaskCommand(state, {
|
|
260
|
+
type: "task.fail",
|
|
261
|
+
task_id: "t1",
|
|
262
|
+
error: "oops",
|
|
263
|
+
}).state;
|
|
264
|
+
|
|
265
|
+
const { state: next } = applyTaskCommand(state, {
|
|
266
|
+
type: "task.update",
|
|
267
|
+
task_id: "t1",
|
|
268
|
+
partial: { status: "running" } as Partial<Task>,
|
|
269
|
+
author: "test",
|
|
270
|
+
});
|
|
271
|
+
expect(next.tasks.get("t1")!.status).toBe("failed");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// ============================================================
|
|
276
|
+
// BUG FIX: applyDiversity preserves contradicted_by
|
|
277
|
+
// ============================================================
|
|
278
|
+
|
|
279
|
+
describe("applyDiversity — contradicted_by preservation", () => {
|
|
280
|
+
it("preserves contradicted_by annotations through diversity", () => {
|
|
281
|
+
const m1 = makeItem("m1", { author: "a" });
|
|
282
|
+
const m2 = makeItem("m2", { author: "a" });
|
|
283
|
+
const m3 = makeItem("m3", { author: "b" });
|
|
284
|
+
|
|
285
|
+
const scored: ScoredItem[] = [
|
|
286
|
+
{ item: m1, score: 0.9, contradicted_by: [m3] },
|
|
287
|
+
{ item: m2, score: 0.8 },
|
|
288
|
+
{ item: m3, score: 0.7, contradicted_by: [m1] },
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
const result = applyDiversity(scored, { author_penalty: 0.1 });
|
|
292
|
+
const r1 = result.find((s) => s.item.id === "m1")!;
|
|
293
|
+
const r3 = result.find((s) => s.item.id === "m3")!;
|
|
294
|
+
expect(r1.contradicted_by).toEqual([m3]);
|
|
295
|
+
expect(r3.contradicted_by).toEqual([m1]);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// ============================================================
|
|
300
|
+
// BUG FIX: smartRetrieve surface+diversity preserves contradicted_by
|
|
301
|
+
// ============================================================
|
|
302
|
+
|
|
303
|
+
describe("smartRetrieve — surface + diversity pipeline", () => {
|
|
304
|
+
it("returns contradicted_by when both surface and diversity are used", () => {
|
|
305
|
+
const m1 = makeItem("m1", { authority: 0.9, author: "a" });
|
|
306
|
+
const m2 = makeItem("m2", { authority: 0.8, author: "a" });
|
|
307
|
+
let state = stateWith([m1, m2]);
|
|
308
|
+
const { state: marked } = markContradiction(
|
|
309
|
+
state,
|
|
310
|
+
"m1",
|
|
311
|
+
"m2",
|
|
312
|
+
"system:detector",
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
const result = smartRetrieve(marked, {
|
|
316
|
+
budget: 1000,
|
|
317
|
+
costFn: () => 1,
|
|
318
|
+
weights: { authority: 1 },
|
|
319
|
+
contradictions: "surface",
|
|
320
|
+
diversity: { author_penalty: 0.05 },
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const item1 = result.find((s) => s.item.id === "m1");
|
|
324
|
+
const item2 = result.find((s) => s.item.id === "m2");
|
|
325
|
+
expect(item1?.contradicted_by).toBeDefined();
|
|
326
|
+
expect(item1!.contradicted_by!.length).toBeGreaterThan(0);
|
|
327
|
+
expect(item2?.contradicted_by).toBeDefined();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ============================================================
|
|
332
|
+
// BUG FIX: computeDecayMultiplier throws on unknown interval
|
|
333
|
+
// ============================================================
|
|
334
|
+
|
|
335
|
+
describe("decay interval validation", () => {
|
|
336
|
+
it("throws RangeError for unknown decay interval", () => {
|
|
337
|
+
const m1 = makeItem("m1", { authority: 0.5 });
|
|
338
|
+
const state = stateWith([m1]);
|
|
339
|
+
|
|
340
|
+
expect(() =>
|
|
341
|
+
getScoredItems(state, {
|
|
342
|
+
authority: 1,
|
|
343
|
+
decay: {
|
|
344
|
+
rate: 0.1,
|
|
345
|
+
interval: "month" as any,
|
|
346
|
+
type: "exponential",
|
|
347
|
+
},
|
|
348
|
+
}),
|
|
349
|
+
).toThrow(RangeError);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("throws with descriptive message for unknown interval", () => {
|
|
353
|
+
const m1 = makeItem("m1", { authority: 0.5 });
|
|
354
|
+
const state = stateWith([m1]);
|
|
355
|
+
|
|
356
|
+
expect(() =>
|
|
357
|
+
getScoredItems(state, {
|
|
358
|
+
authority: 1,
|
|
359
|
+
decay: {
|
|
360
|
+
rate: 0.1,
|
|
361
|
+
interval: "month" as any,
|
|
362
|
+
type: "exponential",
|
|
363
|
+
},
|
|
364
|
+
}),
|
|
365
|
+
).toThrow(/Unknown decay interval.*month/);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ============================================================
|
|
370
|
+
// COVERAGE: resolveContradiction without prior CONTRADICTS edge
|
|
371
|
+
// ============================================================
|
|
372
|
+
|
|
373
|
+
describe("resolveContradiction edge cases", () => {
|
|
374
|
+
it("throws when no CONTRADICTS edge exists between the items", () => {
|
|
375
|
+
const state = stateWith([
|
|
376
|
+
makeItem("m1", { authority: 0.9 }),
|
|
377
|
+
makeItem("m2", { authority: 0.7 }),
|
|
378
|
+
]);
|
|
379
|
+
// no markContradiction first — calling resolve directly should throw
|
|
380
|
+
expect(() =>
|
|
381
|
+
resolveContradiction(state, "m1", "m2", "system:resolver"),
|
|
382
|
+
).toThrow(/No active CONTRADICTS edge/);
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
// ============================================================
|
|
387
|
+
// COVERAGE: filterContradictions tie-breaking
|
|
388
|
+
// ============================================================
|
|
389
|
+
|
|
390
|
+
describe("filterContradictions — equal scores", () => {
|
|
391
|
+
it("excludes one item when scores are exactly equal", () => {
|
|
392
|
+
const m1 = makeItem("m1");
|
|
393
|
+
const m2 = makeItem("m2");
|
|
394
|
+
let state = stateWith([m1, m2]);
|
|
395
|
+
state = markContradiction(state, "m1", "m2", "system:detector").state;
|
|
396
|
+
|
|
397
|
+
const scored = toScored([m1, m2], [0.5, 0.5]);
|
|
398
|
+
const filtered = filterContradictions(state, scored);
|
|
399
|
+
// one should be excluded
|
|
400
|
+
expect(filtered).toHaveLength(1);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it("tiebreaks deterministically regardless of edge direction", () => {
|
|
404
|
+
const m1 = makeItem("aaa");
|
|
405
|
+
const m2 = makeItem("zzz");
|
|
406
|
+
|
|
407
|
+
// edge direction: m1 -> m2
|
|
408
|
+
let state1 = stateWith([m1, m2]);
|
|
409
|
+
state1 = markContradiction(state1, "aaa", "zzz", "sys").state;
|
|
410
|
+
const r1 = filterContradictions(state1, toScored([m1, m2], [0.5, 0.5]));
|
|
411
|
+
|
|
412
|
+
// edge direction: m2 -> m1
|
|
413
|
+
let state2 = stateWith([m1, m2]);
|
|
414
|
+
state2 = markContradiction(state2, "zzz", "aaa", "sys").state;
|
|
415
|
+
const r2 = filterContradictions(state2, toScored([m1, m2], [0.5, 0.5]));
|
|
416
|
+
|
|
417
|
+
// same item should survive regardless of edge direction
|
|
418
|
+
expect(r1).toHaveLength(1);
|
|
419
|
+
expect(r2).toHaveLength(1);
|
|
420
|
+
expect(r1[0].item.id).toBe(r2[0].item.id);
|
|
421
|
+
// lexicographically smaller id survives
|
|
422
|
+
expect(r1[0].item.id).toBe("aaa");
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// ============================================================
|
|
427
|
+
// COVERAGE: getContradictions when one side is retracted
|
|
428
|
+
// ============================================================
|
|
429
|
+
|
|
430
|
+
describe("getContradictions — retracted items", () => {
|
|
431
|
+
it("skips contradictions where one item has been retracted", () => {
|
|
432
|
+
const m1 = makeItem("m1");
|
|
433
|
+
const m2 = makeItem("m2");
|
|
434
|
+
let state = stateWith([m1, m2]);
|
|
435
|
+
state = markContradiction(state, "m1", "m2", "system:detector").state;
|
|
436
|
+
// retract m2
|
|
437
|
+
state = applyCommand(state, {
|
|
438
|
+
type: "memory.retract",
|
|
439
|
+
item_id: "m2",
|
|
440
|
+
author: "test",
|
|
441
|
+
}).state;
|
|
442
|
+
|
|
443
|
+
const contradictions = getContradictions(state);
|
|
444
|
+
expect(contradictions).toHaveLength(0);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ============================================================
|
|
449
|
+
// COVERAGE: getScoredItems with post filter
|
|
450
|
+
// ============================================================
|
|
451
|
+
|
|
452
|
+
describe("getScoredItems — post filter", () => {
|
|
453
|
+
it("applies post filter after scoring", () => {
|
|
454
|
+
const state = stateWith([
|
|
455
|
+
makeItem("m1", { authority: 0.9, scope: "a" }),
|
|
456
|
+
makeItem("m2", { authority: 0.8, scope: "b" }),
|
|
457
|
+
makeItem("m3", { authority: 0.7, scope: "a" }),
|
|
458
|
+
]);
|
|
459
|
+
|
|
460
|
+
const result = getScoredItems(state, { authority: 1 }, {
|
|
461
|
+
post: { scope: "a" },
|
|
462
|
+
});
|
|
463
|
+
expect(result).toHaveLength(2);
|
|
464
|
+
expect(result.every((r) => r.item.scope === "a")).toBe(true);
|
|
465
|
+
// should be sorted by score
|
|
466
|
+
expect(result[0].item.id).toBe("m1");
|
|
467
|
+
expect(result[1].item.id).toBe("m3");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("post filter can use score-based range after scoring", () => {
|
|
471
|
+
const state = stateWith([
|
|
472
|
+
makeItem("m1", { authority: 0.9, importance: 0.1 }),
|
|
473
|
+
makeItem("m2", { authority: 0.3, importance: 0.9 }),
|
|
474
|
+
makeItem("m3", { authority: 0.1, importance: 0.1 }),
|
|
475
|
+
]);
|
|
476
|
+
|
|
477
|
+
// score by authority, then post-filter for high-importance only
|
|
478
|
+
const result = getScoredItems(
|
|
479
|
+
state,
|
|
480
|
+
{ authority: 1 },
|
|
481
|
+
{ post: { range: { importance: { min: 0.5 } } } },
|
|
482
|
+
);
|
|
483
|
+
expect(result).toHaveLength(1);
|
|
484
|
+
expect(result[0].item.id).toBe("m2");
|
|
485
|
+
});
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
// ============================================================
|
|
489
|
+
// COVERAGE: getEdges with to filter
|
|
490
|
+
// ============================================================
|
|
491
|
+
|
|
492
|
+
describe("getEdges — to filter", () => {
|
|
493
|
+
it("filters edges by to field", () => {
|
|
494
|
+
const state = stateWith(
|
|
495
|
+
[],
|
|
496
|
+
[
|
|
497
|
+
makeEdge("e1", "m1", "m2"),
|
|
498
|
+
makeEdge("e2", "m1", "m3"),
|
|
499
|
+
makeEdge("e3", "m2", "m3"),
|
|
500
|
+
],
|
|
501
|
+
);
|
|
502
|
+
const result = getEdges(state, { to: "m3" });
|
|
503
|
+
expect(result).toHaveLength(2);
|
|
504
|
+
expect(result.every((e) => e.to === "m3")).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ============================================================
|
|
509
|
+
// COVERAGE: cascadeRetract on nonexistent item
|
|
510
|
+
// ============================================================
|
|
511
|
+
|
|
512
|
+
describe("cascadeRetract — edge cases", () => {
|
|
513
|
+
it("returns empty retracted list for nonexistent item", () => {
|
|
514
|
+
const state = stateWith([makeItem("m1")]);
|
|
515
|
+
const { state: next, retracted } = cascadeRetract(
|
|
516
|
+
state,
|
|
517
|
+
"nonexistent",
|
|
518
|
+
"test",
|
|
519
|
+
);
|
|
520
|
+
expect(retracted).toHaveLength(0);
|
|
521
|
+
expect(next.items.has("m1")).toBe(true);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it("handles circular parent-child dependencies", () => {
|
|
525
|
+
// m1 -> m2 -> m3 -> m1 (cycle via parents)
|
|
526
|
+
const state = stateWith([
|
|
527
|
+
makeItem("m1", { parents: ["m3"] }),
|
|
528
|
+
makeItem("m2", { parents: ["m1"] }),
|
|
529
|
+
makeItem("m3", { parents: ["m2"] }),
|
|
530
|
+
]);
|
|
531
|
+
const deps = getDependents(state, "m1", true);
|
|
532
|
+
// should not infinite loop; should find m2 and m3
|
|
533
|
+
expect(deps.length).toBeGreaterThanOrEqual(2);
|
|
534
|
+
const ids = deps.map((d) => d.id).sort();
|
|
535
|
+
expect(ids).toContain("m2");
|
|
536
|
+
expect(ids).toContain("m3");
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// ============================================================
|
|
541
|
+
// COVERAGE: getAliasGroup with nonexistent start
|
|
542
|
+
// ============================================================
|
|
543
|
+
|
|
544
|
+
describe("getAliasGroup — nonexistent item", () => {
|
|
545
|
+
it("returns empty array for nonexistent item id", () => {
|
|
546
|
+
const state = stateWith([makeItem("m1")]);
|
|
547
|
+
const group = getAliasGroup(state, "nonexistent");
|
|
548
|
+
expect(group).toHaveLength(0);
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ============================================================
|
|
553
|
+
// COVERAGE: cloneGraphState shallow clone behavior
|
|
554
|
+
// ============================================================
|
|
555
|
+
|
|
556
|
+
describe("cloneGraphState — shallow clone", () => {
|
|
557
|
+
it("Map-level mutations do not affect original", () => {
|
|
558
|
+
const state = stateWith([makeItem("m1")]);
|
|
559
|
+
const clone = cloneGraphState(state);
|
|
560
|
+
clone.items.delete("m1");
|
|
561
|
+
expect(state.items.has("m1")).toBe(true);
|
|
562
|
+
expect(clone.items.has("m1")).toBe(false);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("value-level references are shared (shallow)", () => {
|
|
566
|
+
const state = stateWith([makeItem("m1", { content: { x: 1 } })]);
|
|
567
|
+
const clone = cloneGraphState(state);
|
|
568
|
+
// both maps point to the same MemoryItem object
|
|
569
|
+
expect(clone.items.get("m1")).toBe(state.items.get("m1"));
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
// ============================================================
|
|
574
|
+
// COVERAGE: extractTimestamp with non-uuidv7 ids
|
|
575
|
+
// ============================================================
|
|
576
|
+
|
|
577
|
+
describe("extractTimestamp — edge cases", () => {
|
|
578
|
+
it("returns NaN for non-uuidv7 formatted id", () => {
|
|
579
|
+
const ts = extractTimestamp("not-a-uuid");
|
|
580
|
+
// "nota-uuid" after removing hyphens -> "notauuid", first 12 chars
|
|
581
|
+
// parseInt of non-hex string = NaN
|
|
582
|
+
expect(Number.isNaN(ts)).toBe(true);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("extracts valid timestamp from real uuidv7", () => {
|
|
586
|
+
// uuidv7 encodes timestamp in first 48 bits
|
|
587
|
+
const now = Date.now();
|
|
588
|
+
const ts = extractTimestamp(
|
|
589
|
+
now.toString(16).padStart(12, "0").slice(0, 8) +
|
|
590
|
+
"-" +
|
|
591
|
+
now.toString(16).padStart(12, "0").slice(8, 12) +
|
|
592
|
+
"-7000-8000-000000000000",
|
|
593
|
+
);
|
|
594
|
+
expect(ts).toBe(now);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ============================================================
|
|
599
|
+
// COVERAGE: serialization.parse with malformed input
|
|
600
|
+
// ============================================================
|
|
601
|
+
|
|
602
|
+
describe("serialization — error handling", () => {
|
|
603
|
+
it("throws on malformed JSON", () => {
|
|
604
|
+
expect(() => parse("{not valid json")).toThrow();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("creates empty maps when items field is missing", () => {
|
|
608
|
+
// parse doesn't validate shape — this documents the behavior
|
|
609
|
+
const state = parse('{"edges": []}');
|
|
610
|
+
expect(state.items.size).toBe(0);
|
|
611
|
+
expect(state.edges.size).toBe(0);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("round-trips correctly", () => {
|
|
615
|
+
const state = stateWith(
|
|
616
|
+
[makeItem("m1", { content: { text: "hello" } })],
|
|
617
|
+
[makeEdge("e1", "m1", "m2")],
|
|
618
|
+
);
|
|
619
|
+
const json = stringify(state);
|
|
620
|
+
const restored = parse(json);
|
|
621
|
+
expect(restored.items.get("m1")!.content).toEqual({ text: "hello" });
|
|
622
|
+
expect(restored.edges.get("e1")!.from).toBe("m1");
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
// ============================================================
|
|
627
|
+
// COVERAGE: importSlice with skipExistingIds: false + collision
|
|
628
|
+
// ============================================================
|
|
629
|
+
|
|
630
|
+
describe("importSlice — skipExistingIds: false", () => {
|
|
631
|
+
it("throws DuplicateMemoryError when importing existing id with skipExisting=false", () => {
|
|
632
|
+
const mem = stateWith([makeItem("m1")]);
|
|
633
|
+
const intents = createIntentState();
|
|
634
|
+
const tasks = createTaskState();
|
|
635
|
+
|
|
636
|
+
const slice = {
|
|
637
|
+
memories: [makeItem("m1", { content: { new: true } })],
|
|
638
|
+
edges: [],
|
|
639
|
+
intents: [],
|
|
640
|
+
tasks: [],
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
expect(() =>
|
|
644
|
+
importSlice(mem, intents, tasks, slice, { skipExistingIds: false }),
|
|
645
|
+
).toThrow(DuplicateMemoryError);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("creates non-colliding items with skipExisting=false", () => {
|
|
649
|
+
const mem = stateWith([makeItem("m1")]);
|
|
650
|
+
const intents = createIntentState();
|
|
651
|
+
const tasks = createTaskState();
|
|
652
|
+
|
|
653
|
+
const slice = {
|
|
654
|
+
memories: [makeItem("m2")],
|
|
655
|
+
edges: [],
|
|
656
|
+
intents: [],
|
|
657
|
+
tasks: [],
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
const result = importSlice(mem, intents, tasks, slice, {
|
|
661
|
+
skipExistingIds: false,
|
|
662
|
+
});
|
|
663
|
+
expect(result.memState.items.has("m2")).toBe(true);
|
|
664
|
+
expect(result.report.created.memories).toContain("m2");
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// ============================================================
|
|
669
|
+
// COVERAGE: exportSlice with include_aliases
|
|
670
|
+
// ============================================================
|
|
671
|
+
|
|
672
|
+
describe("exportSlice — include_aliases", () => {
|
|
673
|
+
it("walks alias edges and includes aliased items", () => {
|
|
674
|
+
const m1 = makeItem("m1");
|
|
675
|
+
const m2 = makeItem("m2");
|
|
676
|
+
const m3 = makeItem("m3");
|
|
677
|
+
let state = stateWith([m1, m2, m3]);
|
|
678
|
+
const { state: aliased } = applyCommand(state, {
|
|
679
|
+
type: "edge.create",
|
|
680
|
+
edge: makeEdge("ae1", "m1", "m2", "ALIAS"),
|
|
681
|
+
});
|
|
682
|
+
const { state: aliased2 } = applyCommand(aliased, {
|
|
683
|
+
type: "edge.create",
|
|
684
|
+
edge: makeEdge("ae2", "m2", "m3", "ALIAS"),
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
const intents = createIntentState();
|
|
688
|
+
const tasks = createTaskState();
|
|
689
|
+
|
|
690
|
+
const slice = exportSlice(aliased2, intents, tasks, {
|
|
691
|
+
memory_ids: ["m1"],
|
|
692
|
+
include_aliases: true,
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
const ids = slice.memories.map((m) => m.id).sort();
|
|
696
|
+
expect(ids).toContain("m1");
|
|
697
|
+
expect(ids).toContain("m2");
|
|
698
|
+
expect(ids).toContain("m3");
|
|
699
|
+
// alias edges should be included
|
|
700
|
+
expect(slice.edges.length).toBeGreaterThanOrEqual(2);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
// ============================================================
|
|
705
|
+
// COVERAGE: smartRetrieve with no contradiction handling
|
|
706
|
+
// ============================================================
|
|
707
|
+
|
|
708
|
+
describe("smartRetrieve — no contradiction handling", () => {
|
|
709
|
+
it("returns all items when contradictions option is undefined", () => {
|
|
710
|
+
const m1 = makeItem("m1", { authority: 0.9 });
|
|
711
|
+
const m2 = makeItem("m2", { authority: 0.8 });
|
|
712
|
+
let state = stateWith([m1, m2]);
|
|
713
|
+
state = markContradiction(state, "m1", "m2", "system:detector").state;
|
|
714
|
+
|
|
715
|
+
const result = smartRetrieve(state, {
|
|
716
|
+
budget: 1000,
|
|
717
|
+
costFn: () => 1,
|
|
718
|
+
weights: { authority: 1 },
|
|
719
|
+
// contradictions: undefined — no handling
|
|
720
|
+
});
|
|
721
|
+
// both items should be returned
|
|
722
|
+
expect(result).toHaveLength(2);
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ============================================================
|
|
727
|
+
// COVERAGE: surfaceContradictions idempotency
|
|
728
|
+
// ============================================================
|
|
729
|
+
|
|
730
|
+
describe("surfaceContradictions — repeated calls", () => {
|
|
731
|
+
it("does not accumulate duplicate contradicted_by entries on fresh clone", () => {
|
|
732
|
+
const m1 = makeItem("m1");
|
|
733
|
+
const m2 = makeItem("m2");
|
|
734
|
+
let state = stateWith([m1, m2]);
|
|
735
|
+
state = markContradiction(state, "m1", "m2", "system:detector").state;
|
|
736
|
+
|
|
737
|
+
const scored = toScored([m1, m2], [0.5, 0.5]);
|
|
738
|
+
const result1 = surfaceContradictions(state, scored);
|
|
739
|
+
// calling again with fresh scored (no stale contradicted_by)
|
|
740
|
+
const result2 = surfaceContradictions(state, toScored([m1, m2], [0.5, 0.5]));
|
|
741
|
+
|
|
742
|
+
const r1m1 = result1.find((s) => s.item.id === "m1")!;
|
|
743
|
+
const r2m1 = result2.find((s) => s.item.id === "m1")!;
|
|
744
|
+
expect(r1m1.contradicted_by).toHaveLength(1);
|
|
745
|
+
expect(r2m1.contradicted_by).toHaveLength(1);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
// ============================================================
|
|
750
|
+
// COVERAGE: applyDiversity with mixed parent/no-parent items
|
|
751
|
+
// ============================================================
|
|
752
|
+
|
|
753
|
+
describe("applyDiversity — mixed parents", () => {
|
|
754
|
+
it("handles items with and without parents", () => {
|
|
755
|
+
const m1 = makeItem("m1", { parents: ["p1"] });
|
|
756
|
+
const m2 = makeItem("m2"); // no parents
|
|
757
|
+
const m3 = makeItem("m3", { parents: ["p1"] });
|
|
758
|
+
|
|
759
|
+
const scored = toScored([m1, m2, m3], [0.9, 0.8, 0.7]);
|
|
760
|
+
const result = applyDiversity(scored, { parent_penalty: 0.1 });
|
|
761
|
+
|
|
762
|
+
// m1 has parent p1 (first seen, no penalty)
|
|
763
|
+
// m2 has no parents (no penalty)
|
|
764
|
+
// m3 has parent p1 (second occurrence, penalty applied)
|
|
765
|
+
const m3result = result.find((s) => s.item.id === "m3")!;
|
|
766
|
+
expect(m3result.score).toBeLessThan(0.7); // penalty applied
|
|
767
|
+
const m2result = result.find((s) => s.item.id === "m2")!;
|
|
768
|
+
expect(m2result.score).toBe(0.8); // no penalty
|
|
769
|
+
});
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// ============================================================
|
|
773
|
+
// COVERAGE: memory.update — mergeItem behavior
|
|
774
|
+
// ============================================================
|
|
775
|
+
|
|
776
|
+
describe("mergeItem — edge cases", () => {
|
|
777
|
+
it("stripUndefined prevents accidental key deletion in content", () => {
|
|
778
|
+
const existing = makeItem("m1", { content: { a: 1, b: 2 } });
|
|
779
|
+
const merged = mergeItem(existing, {
|
|
780
|
+
content: { a: undefined, c: 3 } as any,
|
|
781
|
+
});
|
|
782
|
+
// a should be preserved (undefined stripped), b preserved, c added
|
|
783
|
+
expect(merged.content.a).toBe(1);
|
|
784
|
+
expect(merged.content.b).toBe(2);
|
|
785
|
+
expect(merged.content.c).toBe(3);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("stripUndefined prevents accidental key deletion in meta", () => {
|
|
789
|
+
const existing = makeItem("m1", { meta: { agent_id: "bot", x: 1 } });
|
|
790
|
+
const merged = mergeItem(existing, {
|
|
791
|
+
meta: { agent_id: undefined, y: 2 } as any,
|
|
792
|
+
});
|
|
793
|
+
expect(merged.meta!.agent_id).toBe("bot");
|
|
794
|
+
expect(merged.meta!.x).toBe(1);
|
|
795
|
+
expect(merged.meta!.y).toBe(2);
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
it("does not allow changing id via partial", () => {
|
|
799
|
+
const existing = makeItem("m1");
|
|
800
|
+
const merged = mergeItem(existing, { id: "sneaky" });
|
|
801
|
+
expect(merged.id).toBe("m1");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
// ============================================================
|
|
806
|
+
// COVERAGE: importSlice re-id on intents and tasks
|
|
807
|
+
// ============================================================
|
|
808
|
+
|
|
809
|
+
describe("importSlice — re-id intents and tasks", () => {
|
|
810
|
+
it("remaps intent root_memory_ids when memories are re-id'd", () => {
|
|
811
|
+
// set up existing state with m1
|
|
812
|
+
const mem = stateWith([makeItem("m1", { content: { old: true } })]);
|
|
813
|
+
const intents = createIntentState();
|
|
814
|
+
const tasks = createTaskState();
|
|
815
|
+
|
|
816
|
+
// slice has m1 (different content) and intent referencing m1
|
|
817
|
+
const slice = {
|
|
818
|
+
memories: [makeItem("m1", { content: { new: true } })],
|
|
819
|
+
edges: [],
|
|
820
|
+
intents: [
|
|
821
|
+
makeIntent({
|
|
822
|
+
id: "i1",
|
|
823
|
+
root_memory_ids: ["m1"],
|
|
824
|
+
}),
|
|
825
|
+
],
|
|
826
|
+
tasks: [],
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
const result = importSlice(mem, intents, tasks, slice, {
|
|
830
|
+
skipExistingIds: true,
|
|
831
|
+
shallowCompareExisting: true,
|
|
832
|
+
reIdOnDifference: true,
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
// memory should have been re-id'd
|
|
836
|
+
expect(result.report.created.memories).toHaveLength(1);
|
|
837
|
+
const newMemId = result.report.created.memories[0];
|
|
838
|
+
expect(newMemId).not.toBe("m1");
|
|
839
|
+
|
|
840
|
+
// intent should reference the new memory id
|
|
841
|
+
const importedIntent = result.report.created.intents[0];
|
|
842
|
+
const intent = result.intentState.intents.get(importedIntent)!;
|
|
843
|
+
expect(intent.root_memory_ids).toContain(newMemId);
|
|
844
|
+
expect(intent.root_memory_ids).not.toContain("m1");
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("remaps task memory ids when memories are re-id'd", () => {
|
|
848
|
+
const mem = stateWith([makeItem("m1", { content: { old: true } })]);
|
|
849
|
+
let intents = createIntentState();
|
|
850
|
+
intents = applyIntentCommand(intents, {
|
|
851
|
+
type: "intent.create",
|
|
852
|
+
intent: makeIntent({ id: "i1" }),
|
|
853
|
+
}).state;
|
|
854
|
+
const tasks = createTaskState();
|
|
855
|
+
|
|
856
|
+
const slice = {
|
|
857
|
+
memories: [makeItem("m1", { content: { new: true } })],
|
|
858
|
+
edges: [],
|
|
859
|
+
intents: [],
|
|
860
|
+
tasks: [
|
|
861
|
+
makeTask({
|
|
862
|
+
id: "t1",
|
|
863
|
+
intent_id: "i1",
|
|
864
|
+
input_memory_ids: ["m1"],
|
|
865
|
+
output_memory_ids: ["m1"],
|
|
866
|
+
}),
|
|
867
|
+
],
|
|
868
|
+
};
|
|
869
|
+
|
|
870
|
+
const result = importSlice(mem, intents, tasks, slice, {
|
|
871
|
+
skipExistingIds: true,
|
|
872
|
+
shallowCompareExisting: true,
|
|
873
|
+
reIdOnDifference: true,
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
const newMemId = result.report.created.memories[0];
|
|
877
|
+
const importedTaskId = result.report.created.tasks[0];
|
|
878
|
+
const task = result.taskState.tasks.get(importedTaskId)!;
|
|
879
|
+
expect(task.input_memory_ids).toContain(newMemId);
|
|
880
|
+
expect(task.output_memory_ids).toContain(newMemId);
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
// ============================================================
|
|
885
|
+
// COVERAGE: edge collision in importSlice
|
|
886
|
+
// ============================================================
|
|
887
|
+
|
|
888
|
+
describe("importSlice — edge collision", () => {
|
|
889
|
+
it("skips edge when id already exists and skipExisting is true", () => {
|
|
890
|
+
const edge = makeEdge("e1", "m1", "m2");
|
|
891
|
+
const mem = stateWith([makeItem("m1"), makeItem("m2")], [edge]);
|
|
892
|
+
const intents = createIntentState();
|
|
893
|
+
const tasks = createTaskState();
|
|
894
|
+
|
|
895
|
+
const slice = {
|
|
896
|
+
memories: [],
|
|
897
|
+
edges: [makeEdge("e1", "m1", "m2", "ABOUT")],
|
|
898
|
+
intents: [],
|
|
899
|
+
tasks: [],
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
const result = importSlice(mem, intents, tasks, slice);
|
|
903
|
+
expect(result.report.skipped.edges).toContain("e1");
|
|
904
|
+
// original edge should be unchanged
|
|
905
|
+
expect(result.memState.edges.get("e1")!.kind).toBe("SUPPORTS");
|
|
906
|
+
});
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// ============================================================
|
|
910
|
+
// COVERAGE: created filter boundary (exclusive after)
|
|
911
|
+
// ============================================================
|
|
912
|
+
|
|
913
|
+
describe("created filter — boundary semantics", () => {
|
|
914
|
+
it("before is exclusive (item at exact boundary is excluded)", () => {
|
|
915
|
+
// Create an item with a known id that encodes a specific timestamp
|
|
916
|
+
const ts = 1700000000000;
|
|
917
|
+
const hex = ts.toString(16).padStart(12, "0");
|
|
918
|
+
const id =
|
|
919
|
+
hex.slice(0, 8) +
|
|
920
|
+
"-" +
|
|
921
|
+
hex.slice(8, 12) +
|
|
922
|
+
"-7000-8000-000000000000";
|
|
923
|
+
|
|
924
|
+
const state = stateWith([makeItem(id)]);
|
|
925
|
+
const items = getItems(state, { created: { before: ts } });
|
|
926
|
+
expect(items).toHaveLength(0); // exclusive: at exactly ts, excluded
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
it("after is inclusive (item at exact boundary is included)", () => {
|
|
930
|
+
const ts = 1700000000000;
|
|
931
|
+
const hex = ts.toString(16).padStart(12, "0");
|
|
932
|
+
const id =
|
|
933
|
+
hex.slice(0, 8) +
|
|
934
|
+
"-" +
|
|
935
|
+
hex.slice(8, 12) +
|
|
936
|
+
"-7000-8000-000000000000";
|
|
937
|
+
|
|
938
|
+
const state = stateWith([makeItem(id)]);
|
|
939
|
+
const items = getItems(state, { created: { after: ts } });
|
|
940
|
+
expect(items).toHaveLength(1); // inclusive: at exactly ts, included
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
it("item between before and after is included", () => {
|
|
944
|
+
const ts = 1700000000000;
|
|
945
|
+
const hex = ts.toString(16).padStart(12, "0");
|
|
946
|
+
const id =
|
|
947
|
+
hex.slice(0, 8) +
|
|
948
|
+
"-" +
|
|
949
|
+
hex.slice(8, 12) +
|
|
950
|
+
"-7000-8000-000000000000";
|
|
951
|
+
|
|
952
|
+
const state = stateWith([makeItem(id)]);
|
|
953
|
+
const items = getItems(state, {
|
|
954
|
+
created: { after: ts - 1, before: ts + 1 },
|
|
955
|
+
});
|
|
956
|
+
expect(items).toHaveLength(1);
|
|
957
|
+
});
|
|
958
|
+
});
|