@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,256 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { applyMany, bulkAdjustScores } from "../src/bulk.js";
3
+ import type { MemoryItem, GraphState } from "../src/types.js";
4
+
5
+ function buildState(items: MemoryItem[]): GraphState {
6
+ const state: GraphState = { items: new Map(), edges: new Map() };
7
+ for (const i of items) state.items.set(i.id, i);
8
+ return state;
9
+ }
10
+
11
+ const baseItem = (
12
+ id: string,
13
+ overrides: Partial<MemoryItem> = {},
14
+ ): MemoryItem => ({
15
+ id,
16
+ scope: "test",
17
+ kind: "observation",
18
+ content: {},
19
+ author: "user:laz",
20
+ source_kind: "observed",
21
+ authority: 0.5,
22
+ ...overrides,
23
+ });
24
+
25
+ // -- applyMany --
26
+
27
+ describe("applyMany", () => {
28
+ it("updates matching items with a transform", () => {
29
+ const state = buildState([
30
+ baseItem("m1", { authority: 0.5 }),
31
+ baseItem("m2", { authority: 0.6 }),
32
+ ]);
33
+ const { state: next, events } = applyMany(
34
+ state,
35
+ {},
36
+ () => ({ authority: 0.9 }),
37
+ "system:eval",
38
+ );
39
+ expect(next.items.get("m1")!.authority).toBe(0.9);
40
+ expect(next.items.get("m2")!.authority).toBe(0.9);
41
+ expect(events).toHaveLength(2);
42
+ expect(events.every((e) => e.type === "memory.updated")).toBe(true);
43
+ });
44
+
45
+ it("retracts items when transform returns null", () => {
46
+ const state = buildState([
47
+ baseItem("m1", { authority: 0.1 }),
48
+ baseItem("m2", { authority: 0.8 }),
49
+ ]);
50
+ const { state: next, events } = applyMany(
51
+ state,
52
+ {},
53
+ (item) => (item.authority < 0.5 ? null : {}),
54
+ "system:cleanup",
55
+ );
56
+ expect(next.items.has("m1")).toBe(false);
57
+ expect(next.items.has("m2")).toBe(true);
58
+ expect(events).toHaveLength(1);
59
+ expect(events[0].type).toBe("memory.retracted");
60
+ });
61
+
62
+ it("skips items when transform returns empty object", () => {
63
+ const state = buildState([baseItem("m1"), baseItem("m2")]);
64
+ const { state: next, events } = applyMany(
65
+ state,
66
+ {},
67
+ () => ({}),
68
+ "system:noop",
69
+ );
70
+ expect(next).toBe(state); // no changes, same reference
71
+ expect(events).toHaveLength(0);
72
+ });
73
+
74
+ it("uses item-dependent transform (decay)", () => {
75
+ const state = buildState([
76
+ baseItem("m1", { authority: 0.8 }),
77
+ baseItem("m2", { authority: 0.4 }),
78
+ ]);
79
+ const { state: next } = applyMany(
80
+ state,
81
+ {},
82
+ (item) => ({ authority: item.authority * 0.5 }),
83
+ "system:decay",
84
+ );
85
+ expect(next.items.get("m1")!.authority).toBeCloseTo(0.4);
86
+ expect(next.items.get("m2")!.authority).toBeCloseTo(0.2);
87
+ });
88
+
89
+ it("conditional: retract low, boost high", () => {
90
+ const state = buildState([
91
+ baseItem("m1", { authority: 0.1 }),
92
+ baseItem("m2", { authority: 0.7 }),
93
+ baseItem("m3", { authority: 0.3 }),
94
+ ]);
95
+ const { state: next, events } = applyMany(
96
+ state,
97
+ {},
98
+ (item) => (item.authority < 0.3 ? null : { authority: 1.0 }),
99
+ "system:evaluator",
100
+ );
101
+ expect(next.items.has("m1")).toBe(false); // retracted
102
+ expect(next.items.get("m2")!.authority).toBe(1.0); // boosted
103
+ expect(next.items.get("m3")!.authority).toBe(1.0); // boosted
104
+ expect(events).toHaveLength(3);
105
+ });
106
+
107
+ it("skips items already retracted by a prior transform in the same batch", () => {
108
+ // m1 and m2 both match. Transform retracts m1, then when processing m2
109
+ // it tries to also reference m1 — but m1 is gone. Should not crash.
110
+ const state = buildState([
111
+ baseItem("m1", { authority: 0.1 }),
112
+ baseItem("m2", { authority: 0.1 }),
113
+ ]);
114
+ // First pass retracts both; second one should be skipped, not crash
115
+ const { state: next, events } = applyMany(
116
+ state,
117
+ {},
118
+ (item) => null, // retract all
119
+ "system:cleanup",
120
+ );
121
+ expect(next.items.size).toBe(0);
122
+ expect(events).toHaveLength(2);
123
+ });
124
+
125
+ it("shallow-merges meta without losing existing fields", () => {
126
+ const state = buildState([
127
+ baseItem("m1", { meta: { agent_id: "agent:x", session_id: "s1" } }),
128
+ ]);
129
+ const { state: next } = applyMany(
130
+ state,
131
+ {},
132
+ () => ({ meta: { hot: true } }),
133
+ "system:tagger",
134
+ );
135
+ const meta = next.items.get("m1")!.meta!;
136
+ expect(meta.hot).toBe(true);
137
+ expect(meta.agent_id).toBe("agent:x");
138
+ expect(meta.session_id).toBe("s1");
139
+ });
140
+
141
+ it("applies filter before transform", () => {
142
+ const state = buildState([
143
+ baseItem("m1", { scope: "a" }),
144
+ baseItem("m2", { scope: "b" }),
145
+ ]);
146
+ const { state: next } = applyMany(
147
+ state,
148
+ { scope: "a" },
149
+ () => ({ authority: 1.0 }),
150
+ "system:eval",
151
+ );
152
+ expect(next.items.get("m1")!.authority).toBe(1.0);
153
+ expect(next.items.get("m2")!.authority).toBe(0.5); // untouched
154
+ });
155
+
156
+ it("respects QueryOptions sort + limit", () => {
157
+ const state = buildState([
158
+ baseItem("m1", { authority: 0.3 }),
159
+ baseItem("m2", { authority: 0.9 }),
160
+ baseItem("m3", { authority: 0.6 }),
161
+ ]);
162
+ const { state: next, events } = applyMany(
163
+ state,
164
+ {},
165
+ () => ({ meta: { top: true } }),
166
+ "system:tagger",
167
+ undefined,
168
+ { sort: { field: "authority", order: "desc" }, limit: 2 },
169
+ );
170
+ // only top 2 by authority (m2, m3) should be tagged
171
+ expect(next.items.get("m2")!.meta?.top).toBe(true);
172
+ expect(next.items.get("m3")!.meta?.top).toBe(true);
173
+ expect(next.items.get("m1")!.meta?.top).toBeUndefined();
174
+ expect(events).toHaveLength(2);
175
+ });
176
+
177
+ it("does not mutate original state", () => {
178
+ const state = buildState([baseItem("m1", { authority: 0.5 })]);
179
+ applyMany(state, {}, () => ({ authority: 1.0 }), "test");
180
+ expect(state.items.get("m1")!.authority).toBe(0.5);
181
+ });
182
+ });
183
+
184
+ // -- bulkAdjustScores (now wraps applyMany) --
185
+
186
+ describe("bulkAdjustScores", () => {
187
+ it("adjusts authority on matching items", () => {
188
+ const state = buildState([
189
+ baseItem("m1", { authority: 0.5 }),
190
+ baseItem("m2", { authority: 0.6 }),
191
+ baseItem("m3", { authority: 0.7 }),
192
+ ]);
193
+ const { state: next, events } = bulkAdjustScores(
194
+ state,
195
+ { range: { authority: { min: 0.5 } } },
196
+ { authority: -0.2 },
197
+ "system:tuner",
198
+ "decay",
199
+ );
200
+ expect(next.items.get("m1")!.authority).toBeCloseTo(0.3);
201
+ expect(next.items.get("m2")!.authority).toBeCloseTo(0.4);
202
+ expect(next.items.get("m3")!.authority).toBeCloseTo(0.5);
203
+ expect(events).toHaveLength(3);
204
+ });
205
+
206
+ it("clamps to 0 (no negative)", () => {
207
+ const state = buildState([baseItem("m1", { authority: 0.1 })]);
208
+ const { state: next } = bulkAdjustScores(
209
+ state,
210
+ {},
211
+ { authority: -0.5 },
212
+ "system:tuner",
213
+ );
214
+ expect(next.items.get("m1")!.authority).toBe(0);
215
+ });
216
+
217
+ it("clamps to 1 (no overflow)", () => {
218
+ const state = buildState([baseItem("m1", { authority: 0.9 })]);
219
+ const { state: next } = bulkAdjustScores(
220
+ state,
221
+ {},
222
+ { authority: 0.5 },
223
+ "system:tuner",
224
+ );
225
+ expect(next.items.get("m1")!.authority).toBe(1);
226
+ });
227
+
228
+ it("treats undefined importance as 0 when adding delta", () => {
229
+ const state = buildState([baseItem("m1")]);
230
+ const { state: next } = bulkAdjustScores(
231
+ state,
232
+ {},
233
+ { importance: 0.7 },
234
+ "system:tuner",
235
+ );
236
+ expect(next.items.get("m1")!.importance).toBe(0.7);
237
+ });
238
+
239
+ it("returns unchanged state and empty events when no matches", () => {
240
+ const state = buildState([baseItem("m1", { scope: "other" })]);
241
+ const { state: next, events } = bulkAdjustScores(
242
+ state,
243
+ { scope: "nonexistent" },
244
+ { authority: 0.1 },
245
+ "system:tuner",
246
+ );
247
+ expect(next).toBe(state);
248
+ expect(events).toHaveLength(0);
249
+ });
250
+
251
+ it("does not mutate original state", () => {
252
+ const state = buildState([baseItem("m1", { authority: 0.5 })]);
253
+ bulkAdjustScores(state, {}, { authority: 0.3 }, "system:tuner");
254
+ expect(state.items.get("m1")!.authority).toBe(0.5);
255
+ });
256
+ });
@@ -0,0 +1,355 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { applyCommand } from "../src/reducer.js";
3
+ import { createGraphState } from "../src/graph.js";
4
+ import {
5
+ getItems,
6
+ getScoredItems,
7
+ extractTimestamp,
8
+ } from "../src/query.js";
9
+ import { exportSlice, importSlice } from "../src/transplant.js";
10
+ import {
11
+ createIntentState,
12
+ applyIntentCommand,
13
+ createIntent,
14
+ InvalidIntentTransitionError,
15
+ } from "../src/intent.js";
16
+ import {
17
+ createTaskState,
18
+ applyTaskCommand,
19
+ createTask,
20
+ InvalidTaskTransitionError,
21
+ } from "../src/task.js";
22
+ import {
23
+ surfaceContradictions,
24
+ } from "../src/retrieval.js";
25
+ import { markContradiction } from "../src/integrity.js";
26
+ import type { MemoryItem, Edge, GraphState, ScoredItem } from "../src/types.js";
27
+
28
+ const makeItem = (
29
+ id: string,
30
+ overrides: Partial<MemoryItem> = {},
31
+ ): MemoryItem => ({
32
+ id,
33
+ scope: "test",
34
+ kind: "observation",
35
+ content: {},
36
+ author: "agent:a",
37
+ source_kind: "observed",
38
+ authority: 0.5,
39
+ ...overrides,
40
+ });
41
+
42
+ function stateWith(items: MemoryItem[], edges: Edge[] = []): GraphState {
43
+ const s = createGraphState();
44
+ for (const i of items) s.items.set(i.id, i);
45
+ for (const e of edges) s.edges.set(e.edge_id, e);
46
+ return s;
47
+ }
48
+
49
+ function fakeIdAtMs(ms: number): string {
50
+ const hex = ms.toString(16).padStart(12, "0");
51
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-7000-8000-000000000000`;
52
+ }
53
+
54
+ // ============================================================
55
+ // 1. Export with circular parents
56
+ // ============================================================
57
+
58
+ describe("exportSlice with circular parents", () => {
59
+ it("does not infinite loop on circular parent chain", () => {
60
+ const state = stateWith([
61
+ makeItem("m1", { parents: ["m2"] }),
62
+ makeItem("m2", { parents: ["m1"] }),
63
+ ]);
64
+ const slice = exportSlice(
65
+ state,
66
+ createIntentState(),
67
+ createTaskState(),
68
+ { memory_ids: ["m1"], include_parents: true },
69
+ );
70
+ expect(slice.memories.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
71
+ });
72
+
73
+ it("does not infinite loop on circular children", () => {
74
+ const state = stateWith([
75
+ makeItem("m1", { parents: ["m2"] }),
76
+ makeItem("m2", { parents: ["m1"] }),
77
+ ]);
78
+ const slice = exportSlice(
79
+ state,
80
+ createIntentState(),
81
+ createTaskState(),
82
+ { memory_ids: ["m1"], include_children: true },
83
+ );
84
+ expect(slice.memories.map((m) => m.id).sort()).toEqual(["m1", "m2"]);
85
+ });
86
+ });
87
+
88
+ // ============================================================
89
+ // 2. Transplant reIdFor produces valid ids
90
+ // ============================================================
91
+
92
+ describe("transplant reIdFor validity", () => {
93
+ it("re-id'd item has a valid extractable timestamp = original + 1ms", () => {
94
+ const originalMs = Date.now() - 5000; // 5 seconds ago
95
+ const originalId = fakeIdAtMs(originalMs);
96
+ const originalItem = makeItem(originalId, { authority: 0.9 });
97
+
98
+ let targetMem = createGraphState();
99
+ targetMem = applyCommand(targetMem, {
100
+ type: "memory.create",
101
+ item: makeItem(originalId, { authority: 0.1 }), // same id, different content
102
+ }).state;
103
+
104
+ const slice = {
105
+ memories: [originalItem],
106
+ edges: [],
107
+ intents: [],
108
+ tasks: [],
109
+ };
110
+
111
+ const result = importSlice(
112
+ targetMem,
113
+ createIntentState(),
114
+ createTaskState(),
115
+ slice,
116
+ { shallowCompareExisting: true, reIdOnDifference: true },
117
+ );
118
+
119
+ const newId = result.report.created.memories[0];
120
+ const newTs = extractTimestamp(newId);
121
+ expect(newTs).toBe(originalMs + 1);
122
+ });
123
+ });
124
+
125
+ // ============================================================
126
+ // 3. Intent: update on completed/cancelled
127
+ // ============================================================
128
+
129
+ describe("intent update on terminal states", () => {
130
+ it("allows updating a completed intent (no status guard on update)", () => {
131
+ let state = createIntentState();
132
+ state = applyIntentCommand(state, {
133
+ type: "intent.create",
134
+ intent: createIntent({ id: "i1", label: "test", priority: 0.5, owner: "user:laz" }),
135
+ }).state;
136
+ state = applyIntentCommand(state, {
137
+ type: "intent.complete",
138
+ intent_id: "i1",
139
+ author: "test",
140
+ }).state;
141
+ // update should work — it's a field update, not a status transition
142
+ const { state: next } = applyIntentCommand(state, {
143
+ type: "intent.update",
144
+ intent_id: "i1",
145
+ partial: { description: "added after completion" },
146
+ author: "test",
147
+ });
148
+ expect(next.intents.get("i1")!.description).toBe("added after completion");
149
+ expect(next.intents.get("i1")!.status).toBe("completed");
150
+ });
151
+
152
+ it("allows updating a cancelled intent", () => {
153
+ let state = createIntentState();
154
+ state = applyIntentCommand(state, {
155
+ type: "intent.create",
156
+ intent: createIntent({ id: "i1", label: "test", priority: 0.5, owner: "user:laz" }),
157
+ }).state;
158
+ state = applyIntentCommand(state, {
159
+ type: "intent.cancel",
160
+ intent_id: "i1",
161
+ author: "test",
162
+ }).state;
163
+ const { state: next } = applyIntentCommand(state, {
164
+ type: "intent.update",
165
+ intent_id: "i1",
166
+ partial: { meta: { reason: "post-mortem note" } },
167
+ author: "test",
168
+ });
169
+ expect(next.intents.get("i1")!.meta?.reason).toBe("post-mortem note");
170
+ });
171
+ });
172
+
173
+ // ============================================================
174
+ // 4. Task: fail/cancel on invalid states
175
+ // ============================================================
176
+
177
+ describe("task state machine edge cases", () => {
178
+ it("task.fail on pending throws InvalidTaskTransitionError", () => {
179
+ let state = createTaskState();
180
+ state = applyTaskCommand(state, {
181
+ type: "task.create",
182
+ task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
183
+ }).state;
184
+ expect(() =>
185
+ applyTaskCommand(state, { type: "task.fail", task_id: "t1", error: "oops" }),
186
+ ).toThrow(InvalidTaskTransitionError);
187
+ });
188
+
189
+ it("task.fail on cancelled throws InvalidTaskTransitionError", () => {
190
+ let state = createTaskState();
191
+ state = applyTaskCommand(state, {
192
+ type: "task.create",
193
+ task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
194
+ }).state;
195
+ state = applyTaskCommand(state, {
196
+ type: "task.cancel",
197
+ task_id: "t1",
198
+ }).state;
199
+ expect(() =>
200
+ applyTaskCommand(state, { type: "task.fail", task_id: "t1", error: "oops" }),
201
+ ).toThrow(InvalidTaskTransitionError);
202
+ });
203
+
204
+ it("task.update on cancelled task works (field update, not transition)", () => {
205
+ let state = createTaskState();
206
+ state = applyTaskCommand(state, {
207
+ type: "task.create",
208
+ task: createTask({ id: "t1", intent_id: "i1", action: "test", priority: 0.5 }),
209
+ }).state;
210
+ state = applyTaskCommand(state, {
211
+ type: "task.cancel",
212
+ task_id: "t1",
213
+ }).state;
214
+ const { state: next } = applyTaskCommand(state, {
215
+ type: "task.update",
216
+ task_id: "t1",
217
+ partial: { meta: { cancelled_reason: "no longer needed" } },
218
+ author: "test",
219
+ });
220
+ expect(next.tasks.get("t1")!.meta?.cancelled_reason).toBe("no longer needed");
221
+ });
222
+ });
223
+
224
+ // ============================================================
225
+ // 5. Reducer: nested undefined in content/meta
226
+ // ============================================================
227
+
228
+ describe("reducer nested undefined handling", () => {
229
+ it("strips top-level undefined in content but preserves nested undefined", () => {
230
+ const state = stateWith([
231
+ makeItem("m1", { content: { a: 1, b: 2 } }),
232
+ ]);
233
+ const { state: next } = applyCommand(state, {
234
+ type: "memory.update",
235
+ item_id: "m1",
236
+ partial: { content: { a: undefined, c: 3 } },
237
+ author: "test",
238
+ });
239
+ const content = next.items.get("m1")!.content;
240
+ // top-level undefined stripped — 'a' is NOT overwritten
241
+ expect(content.a).toBe(1);
242
+ expect(content.b).toBe(2);
243
+ expect(content.c).toBe(3);
244
+ });
245
+
246
+ it("strips top-level undefined in meta", () => {
247
+ const state = stateWith([
248
+ makeItem("m1", { meta: { agent_id: "agent:x", session_id: "s1" } }),
249
+ ]);
250
+ const { state: next } = applyCommand(state, {
251
+ type: "memory.update",
252
+ item_id: "m1",
253
+ partial: { meta: { agent_id: undefined, tag: "new" } },
254
+ author: "test",
255
+ });
256
+ const meta = next.items.get("m1")!.meta!;
257
+ expect(meta.agent_id).toBe("agent:x"); // not overwritten
258
+ expect(meta.session_id).toBe("s1");
259
+ expect(meta.tag).toBe("new");
260
+ });
261
+ });
262
+
263
+ // ============================================================
264
+ // 6. Decay with future items (clock skew)
265
+ // ============================================================
266
+
267
+ describe("decay with future items", () => {
268
+ it("future item gets multiplier of 1 (no decay boost)", () => {
269
+ const futureMs = Date.now() + 60000; // 1 minute in the future
270
+ const futureId = fakeIdAtMs(futureMs);
271
+ const state = stateWith([makeItem(futureId, { authority: 1.0 })]);
272
+
273
+ const result = getScoredItems(state, {
274
+ authority: 1.0,
275
+ decay: { rate: 0.5, interval: "day", type: "exponential" },
276
+ });
277
+
278
+ // should be exactly 1.0, not boosted above 1.0
279
+ expect(result[0].score).toBe(1.0);
280
+ });
281
+
282
+ it("future item passes decay filter (not excluded)", () => {
283
+ const futureMs = Date.now() + 60000;
284
+ const futureId = fakeIdAtMs(futureMs);
285
+ const state = stateWith([makeItem(futureId)]);
286
+
287
+ const result = getItems(state, {
288
+ decay: {
289
+ config: { rate: 0.5, interval: "day", type: "exponential" },
290
+ min: 0.5,
291
+ },
292
+ });
293
+ expect(result).toHaveLength(1);
294
+ });
295
+ });
296
+
297
+ // ============================================================
298
+ // 7. surfaceContradictions does not mutate input
299
+ // ============================================================
300
+
301
+ describe("surfaceContradictions immutability", () => {
302
+ it("does not mutate the input scored array", () => {
303
+ const state = stateWith([
304
+ makeItem("m1", { authority: 0.9 }),
305
+ makeItem("m2", { authority: 0.7 }),
306
+ ]);
307
+ const { state: marked } = markContradiction(state, "m1", "m2", "system:detector");
308
+
309
+ const original: ScoredItem[] = [
310
+ { item: marked.items.get("m1")!, score: 0.9 },
311
+ { item: marked.items.get("m2")!, score: 0.7 },
312
+ ];
313
+
314
+ // save original state
315
+ const m1Before = { ...original[0] };
316
+
317
+ surfaceContradictions(marked, original);
318
+
319
+ // original entries should NOT have contradicted_by
320
+ expect(original[0].contradicted_by).toBeUndefined();
321
+ expect(original[1].contradicted_by).toBeUndefined();
322
+ expect(original[0].score).toBe(m1Before.score);
323
+ });
324
+ });
325
+
326
+ // ============================================================
327
+ // 8. Export/import with circular parents round-trip
328
+ // ============================================================
329
+
330
+ describe("transplant with circular parents", () => {
331
+ it("exports and imports circular parent chain", () => {
332
+ const state = stateWith([
333
+ makeItem("m1", { parents: ["m2"] }),
334
+ makeItem("m2", { parents: ["m1"] }),
335
+ ]);
336
+
337
+ const slice = exportSlice(
338
+ state,
339
+ createIntentState(),
340
+ createTaskState(),
341
+ { memory_ids: ["m1"], include_parents: true },
342
+ );
343
+
344
+ const result = importSlice(
345
+ createGraphState(),
346
+ createIntentState(),
347
+ createTaskState(),
348
+ slice,
349
+ );
350
+
351
+ expect(result.memState.items.size).toBe(2);
352
+ expect(result.memState.items.get("m1")!.parents).toEqual(["m2"]);
353
+ expect(result.memState.items.get("m2")!.parents).toEqual(["m1"]);
354
+ });
355
+ });