Skip to content

Commit 683904e

Browse files
authored
Merge pull request #18 from ai-2070/perf/linear-bulk-fold
Performance
2 parents c418c2e + 414ef81 commit 683904e

11 files changed

Lines changed: 768 additions & 147 deletions

.npmignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ Thumbs.db
1717
# Dev dependencies config
1818
renovate.json
1919

20+
# Benchmarks (dev-only)
21+
bench/
22+
2023
# Lock files (users have their own)
2124
package-lock.json
2225
pnpm-lock.yaml

bench/memex.bench.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
// Performance benchmarks for the bulk fold paths.
2+
//
3+
// Run with: npm run bench
4+
//
5+
// These guard the O(N) behavior of replay / import / cascade. The "fold
6+
// strategy contrast" group makes the win explicit: folding commands with the
7+
// immutable `applyCommand` clones the whole graph per command (the old,
8+
// quadratic shape) while the in-place fold used by replay stays linear.
9+
10+
import { bench, describe } from "vitest";
11+
import { applyCommand } from "../src/reducer.js";
12+
import { createGraphState } from "../src/graph.js";
13+
import { createMemoryItem, createEdge } from "../src/helpers.js";
14+
import { replayCommands, replayFromEnvelopes } from "../src/replay.js";
15+
import { importSlice } from "../src/transplant.js";
16+
import type { MemexExport } from "../src/transplant.js";
17+
import { createIntentState } from "../src/intent.js";
18+
import { createTaskState } from "../src/task.js";
19+
import { cascadeRetract, getDependents } from "../src/integrity.js";
20+
import type {
21+
GraphState,
22+
MemoryItem,
23+
MemoryCommand,
24+
Edge,
25+
EventEnvelope,
26+
} from "../src/types.js";
27+
28+
const BASE = 1_700_000_000_000;
29+
const padId = (i: number): string => `m-${i.toString().padStart(8, "0")}`;
30+
31+
function item(i: number, parents?: string[]): MemoryItem {
32+
return createMemoryItem({
33+
id: padId(i),
34+
scope: "bench",
35+
kind: "observation",
36+
content: { text: `item ${i}` },
37+
author: "agent:bench",
38+
source_kind: "observed",
39+
authority: 0.5,
40+
created_at: BASE + i,
41+
...(parents ? { parents } : {}),
42+
});
43+
}
44+
45+
function createCommands(n: number): MemoryCommand[] {
46+
const cmds: MemoryCommand[] = [];
47+
for (let i = 0; i < n; i++)
48+
cmds.push({ type: "memory.create", item: item(i) });
49+
return cmds;
50+
}
51+
52+
function createEnvelopes(n: number): EventEnvelope<MemoryCommand>[] {
53+
const envs: EventEnvelope<MemoryCommand>[] = [];
54+
for (let i = 0; i < n; i++) {
55+
envs.push({
56+
id: `e-${i}`,
57+
namespace: "memory",
58+
type: "memory.create",
59+
// Reverse the timestamps so replayFromEnvelopes pays for the sort.
60+
ts: new Date(BASE + (n - i)).toISOString(),
61+
payload: { type: "memory.create", item: item(i) },
62+
});
63+
}
64+
return envs;
65+
}
66+
67+
function chainCommands(n: number): MemoryCommand[] {
68+
const cmds: MemoryCommand[] = [];
69+
for (let i = 0; i < n; i++) {
70+
cmds.push({
71+
type: "memory.create",
72+
item: item(i, i > 0 ? [padId(i - 1)] : undefined),
73+
});
74+
}
75+
return cmds;
76+
}
77+
78+
function sliceOf(n: number): MemexExport {
79+
const memories: MemoryItem[] = [];
80+
const edges: Edge[] = [];
81+
for (let i = 0; i < n; i++) {
82+
memories.push(item(i));
83+
if (i > 0) {
84+
edges.push(
85+
createEdge({
86+
edge_id: `edge-${i}`,
87+
from: padId(i),
88+
to: padId(i - 1),
89+
kind: "DERIVED_FROM",
90+
author: "agent:bench",
91+
source_kind: "observed",
92+
authority: 0.5,
93+
}),
94+
);
95+
}
96+
}
97+
return { memories, edges, intents: [], tasks: [] };
98+
}
99+
100+
describe("replayCommands", () => {
101+
const c5k = createCommands(5_000);
102+
const c20k = createCommands(20_000);
103+
bench("5k creates", () => {
104+
replayCommands(c5k);
105+
});
106+
bench("20k creates", () => {
107+
replayCommands(c20k);
108+
});
109+
});
110+
111+
describe("replayFromEnvelopes (sort + fold)", () => {
112+
const e5k = createEnvelopes(5_000);
113+
bench("5k reverse-sorted creates", () => {
114+
replayFromEnvelopes(e5k);
115+
});
116+
});
117+
118+
describe("importSlice (memories + edges into empty graph)", () => {
119+
const s5k = sliceOf(5_000);
120+
const s10k = sliceOf(10_000);
121+
bench("5k memories", () => {
122+
importSlice(
123+
createGraphState(),
124+
createIntentState(),
125+
createTaskState(),
126+
s5k,
127+
);
128+
});
129+
bench("10k memories", () => {
130+
importSlice(
131+
createGraphState(),
132+
createIntentState(),
133+
createTaskState(),
134+
s10k,
135+
);
136+
});
137+
});
138+
139+
describe("cascadeRetract / getDependents (5k-deep chain)", () => {
140+
const { state } = replayCommands(chainCommands(5_000));
141+
const root = padId(0);
142+
bench("cascadeRetract whole chain", () => {
143+
cascadeRetract(state, root, "agent:bench");
144+
});
145+
bench("getDependents transitive", () => {
146+
getDependents(state, root, true);
147+
});
148+
});
149+
150+
describe("fold strategy contrast (2k creates)", () => {
151+
const cmds = createCommands(2_000);
152+
bench("immutable applyCommand fold — clone per command (~O(N^2))", () => {
153+
let state: GraphState = createGraphState();
154+
for (const cmd of cmds) state = applyCommand(state, cmd).state;
155+
});
156+
bench("in-place fold via replayCommands (O(N))", () => {
157+
replayCommands(cmds);
158+
});
159+
});

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
"build": "tsc",
2020
"test": "vitest run",
2121
"test:watch": "vitest",
22-
"prettier": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
23-
"prettier:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\""
22+
"bench": "vitest bench --run",
23+
"prettier": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"bench/**/*.ts\"",
24+
"prettier:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"bench/**/*.ts\""
2425
},
2526
"license": "Apache-2.0",
2627
"dependencies": {

src/integrity.ts

Lines changed: 35 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,13 @@ import type {
66
ScoreWeights,
77
ScoredItem,
88
} from "./types.js";
9-
import { applyCommand } from "./reducer.js";
10-
import { getEdges, getChildren, getScoredItems } from "./query.js";
9+
import { applyCommand, retractItemsInPlace } from "./reducer.js";
10+
import {
11+
getEdges,
12+
getChildren,
13+
getScoredItems,
14+
buildChildrenIndex,
15+
} from "./query.js";
1116
import { uuidv7 } from "uuidv7";
1217

1318
// ---------------------------------------------------------------------------
@@ -181,19 +186,23 @@ export function getDependents(
181186
itemId: string,
182187
transitive = false,
183188
): MemoryItem[] {
184-
const direct = getChildren(state, itemId);
185-
if (!transitive) return direct;
189+
if (!transitive) return getChildren(state, itemId);
186190

191+
// Walk the dependency tree off a single children index instead of re-scanning
192+
// the whole graph at every node (which made the transitive walk O(nodes x
193+
// items)).
194+
const childrenIndex = buildChildrenIndex(state);
187195
const visited = new Set<string>();
188196
const result: MemoryItem[] = [];
189-
const queue = [...direct];
197+
const queue = [...(childrenIndex.get(itemId) ?? [])];
190198

191199
while (queue.length > 0) {
192200
const item = queue.pop()!;
193201
if (visited.has(item.id)) continue;
194202
visited.add(item.id);
195203
result.push(item);
196-
queue.push(...getChildren(state, item.id));
204+
const children = childrenIndex.get(item.id);
205+
if (children) for (const child of children) queue.push(child);
197206
}
198207

199208
return result;
@@ -215,12 +224,17 @@ export function cascadeRetract(
215224
// Pre-mark the root as visited so any cycle that points back to it is
216225
// ignored — the root is retracted separately at the end of this function,
217226
// never prematurely as part of the descendants list.
227+
// Children index built once: getChildren is O(items), and walking it per node
228+
// made the traversal O(nodes x items) on top of the per-retract clone below.
229+
const childrenIndex = buildChildrenIndex(state);
230+
const childrenOf = (id: string): MemoryItem[] => childrenIndex.get(id) ?? [];
231+
218232
const visited = new Set<string>([itemId]);
219233
const order: string[] = [];
220234

221235
type Frame = { id: string; phase: "enter" | "exit" };
222236
const stack: Frame[] = [];
223-
for (const child of getChildren(state, itemId)) {
237+
for (const child of childrenOf(itemId)) {
224238
stack.push({ id: child.id, phase: "enter" });
225239
}
226240

@@ -234,43 +248,26 @@ export function cascadeRetract(
234248
visited.add(frame.id);
235249
// Push exit first so it's processed after all children (post-order).
236250
stack.push({ id: frame.id, phase: "exit" });
237-
for (const child of getChildren(state, frame.id)) {
251+
for (const child of childrenOf(frame.id)) {
238252
if (!visited.has(child.id)) {
239253
stack.push({ id: child.id, phase: "enter" });
240254
}
241255
}
242256
}
243257

244-
let current = state;
245-
const allEvents: MemoryLifecycleEvent[] = [];
246-
const retracted: string[] = [];
247-
248-
for (const depId of order) {
249-
if (!current.items.has(depId)) continue;
250-
const r = applyCommand(current, {
251-
type: "memory.retract",
252-
item_id: depId,
253-
author,
254-
reason: reason ?? `parent ${itemId} retracted`,
255-
});
256-
current = r.state;
257-
allEvents.push(...r.events);
258-
retracted.push(depId);
259-
}
260-
261-
if (current.items.has(itemId)) {
262-
const r = applyCommand(current, {
263-
type: "memory.retract",
264-
item_id: itemId,
265-
author,
266-
reason,
267-
});
268-
current = r.state;
269-
allEvents.push(...r.events);
270-
retracted.push(itemId);
271-
}
272-
273-
return { state: current, events: allEvents, retracted };
258+
// Retract descendants (post-order) then the root, in a single clone with one
259+
// edge index, instead of cloning the whole graph once per retracted item.
260+
const items = new Map(state.items);
261+
const edges = new Map(state.edges);
262+
if (state.items.has(itemId)) order.push(itemId); // root retracted last
263+
const { events, retracted } = retractItemsInPlace(
264+
items,
265+
edges,
266+
order,
267+
"memory.retract",
268+
);
269+
270+
return { state: { items, edges }, events, retracted };
274271
}
275272

276273
// ---------------------------------------------------------------------------

src/query.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,3 +423,33 @@ export function getChildren(state: GraphState, itemId: string): MemoryItem[] {
423423
}
424424
return results;
425425
}
426+
427+
/**
428+
* Build a parent-id -> children index in a single pass over the graph.
429+
*
430+
* `getChildren` is O(items) per call; callers that need every node's children
431+
* (transitive dependents, cascade retraction) would otherwise re-scan the whole
432+
* graph once per node, making the walk O(nodes x items). Building the index once
433+
* up front turns those walks into O(items + edges-of-the-walk).
434+
*/
435+
export function buildChildrenIndex(
436+
state: GraphState,
437+
): Map<string, MemoryItem[]> {
438+
const index = new Map<string, MemoryItem[]>();
439+
for (const item of state.items.values()) {
440+
if (!item.parents) continue;
441+
// Dedup an item's own parent list so a child listed twice under the same
442+
// parent appears once, matching getChildren's `includes` semantics.
443+
const seen = item.parents.length > 1 ? new Set<string>() : null;
444+
for (const pid of item.parents) {
445+
if (seen) {
446+
if (seen.has(pid)) continue;
447+
seen.add(pid);
448+
}
449+
let list = index.get(pid);
450+
if (!list) index.set(pid, (list = []));
451+
list.push(item);
452+
}
453+
}
454+
return index;
455+
}

0 commit comments

Comments
 (0)