@ghcrawl/api-core 0.7.1 → 0.8.0-beta.1

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.
@@ -12,5 +12,35 @@ export declare function buildClusters(nodes: Node[], edges: SimilarityEdge[]): A
12
12
  representativeThreadId: number;
13
13
  members: number[];
14
14
  }>;
15
+ /**
16
+ * Build clusters with size-bounded Union-Find.
17
+ *
18
+ * Process edges from highest to lowest score, merging components only when
19
+ * the combined size stays within `maxClusterSize`. Strongest connections are
20
+ * preserved; weaker edges that would create oversized clusters are skipped.
21
+ * This avoids the "threshold raising" problem where splitting mega-clusters
22
+ * creates many solos.
23
+ */
24
+ export declare function buildSizeBoundedClusters(nodes: Node[], edges: SimilarityEdge[], options: {
25
+ maxClusterSize: number;
26
+ }): Array<{
27
+ representativeThreadId: number;
28
+ members: number[];
29
+ }>;
30
+ /**
31
+ * Build clusters with iterative refinement of oversized components.
32
+ *
33
+ * 1. Run Union-Find at the base threshold (edges already filtered by minScore).
34
+ * 2. For any cluster above `maxClusterSize`, re-cluster its members using only
35
+ * edges above a progressively higher threshold (raised by `refineStep` each
36
+ * iteration) until all clusters are within limits or threshold reaches 1.0.
37
+ */
38
+ export declare function buildRefinedClusters(nodes: Node[], edges: SimilarityEdge[], options: {
39
+ maxClusterSize: number;
40
+ refineStep: number;
41
+ }): Array<{
42
+ representativeThreadId: number;
43
+ members: number[];
44
+ }>;
15
45
  export {};
16
46
  //# sourceMappingURL=build.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cluster/build.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,KAAK,IAAI,GAAG;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AA8BF,wBAAgB,aAAa,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,GAAG,KAAK,CAAC;IAAE,sBAAsB,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAiClI"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/cluster/build.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG;IAC3B,YAAY,EAAE,MAAM,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,KAAK,IAAI,GAAG;IACV,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AA4DF,wBAAgB,aAAa,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,GAAG,KAAK,CAAC;IAAE,sBAAsB,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAclI;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,IAAI,EAAE,EACb,KAAK,EAAE,cAAc,EAAE,EACvB,OAAO,EAAE;IAAE,cAAc,EAAE,MAAM,CAAA;CAAE,GAClC,KAAK,CAAC;IAAE,sBAAsB,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CAuB9D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,IAAI,EAAE,EACb,KAAK,EAAE,cAAc,EAAE,EACvB,OAAO,EAAE;IAAE,cAAc,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GACtD,KAAK,CAAC;IAAE,sBAAsB,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CA0F9D"}
@@ -1,28 +1,56 @@
1
1
  class UnionFind {
2
2
  parent = new Map();
3
+ size = new Map();
3
4
  add(value) {
4
- if (!this.parent.has(value))
5
+ if (!this.parent.has(value)) {
5
6
  this.parent.set(value, value);
7
+ this.size.set(value, 1);
8
+ }
6
9
  }
7
10
  find(value) {
8
- const parent = this.parent.get(value);
11
+ let parent = this.parent.get(value);
9
12
  if (parent === undefined) {
10
13
  this.parent.set(value, value);
14
+ this.size.set(value, 1);
11
15
  return value;
12
16
  }
13
- if (parent === value)
14
- return value;
15
- const root = this.find(parent);
16
- this.parent.set(value, root);
17
- return root;
17
+ // Iterative path-finding to avoid stack overflow on deep chains
18
+ let current = value;
19
+ while (parent !== current) {
20
+ const grandparent = this.parent.get(parent) ?? parent;
21
+ this.parent.set(current, grandparent); // path splitting
22
+ current = parent;
23
+ parent = grandparent;
24
+ }
25
+ return current;
18
26
  }
19
27
  union(left, right) {
20
28
  const leftRoot = this.find(left);
21
29
  const rightRoot = this.find(right);
22
30
  if (leftRoot !== rightRoot) {
31
+ const leftSize = this.size.get(leftRoot) ?? 1;
32
+ const rightSize = this.size.get(rightRoot) ?? 1;
23
33
  this.parent.set(rightRoot, leftRoot);
34
+ this.size.set(leftRoot, leftSize + rightSize);
24
35
  }
25
36
  }
37
+ /** Merge only if the combined component would not exceed maxSize. Returns true if merged. */
38
+ unionBounded(left, right, maxSize) {
39
+ const leftRoot = this.find(left);
40
+ const rightRoot = this.find(right);
41
+ if (leftRoot === rightRoot)
42
+ return true; // already same component
43
+ const leftSize = this.size.get(leftRoot) ?? 1;
44
+ const rightSize = this.size.get(rightRoot) ?? 1;
45
+ if (leftSize + rightSize > maxSize)
46
+ return false;
47
+ this.parent.set(rightRoot, leftRoot);
48
+ this.size.set(leftRoot, leftSize + rightSize);
49
+ return true;
50
+ }
51
+ getSize(value) {
52
+ return this.size.get(this.find(value)) ?? 1;
53
+ }
26
54
  }
27
55
  export function buildClusters(nodes, edges) {
28
56
  const uf = new UnionFind();
@@ -37,6 +65,149 @@ export function buildClusters(nodes, edges) {
37
65
  list.push(node.threadId);
38
66
  byRoot.set(root, list);
39
67
  }
68
+ return formatClusters(nodes, edges, byRoot);
69
+ }
70
+ /**
71
+ * Build clusters with size-bounded Union-Find.
72
+ *
73
+ * Process edges from highest to lowest score, merging components only when
74
+ * the combined size stays within `maxClusterSize`. Strongest connections are
75
+ * preserved; weaker edges that would create oversized clusters are skipped.
76
+ * This avoids the "threshold raising" problem where splitting mega-clusters
77
+ * creates many solos.
78
+ */
79
+ export function buildSizeBoundedClusters(nodes, edges, options) {
80
+ const uf = new UnionFind();
81
+ for (const node of nodes)
82
+ uf.add(node.threadId);
83
+ // Sort edges by score descending — strongest connections first
84
+ const sortedEdges = [...edges].sort((a, b) => b.score - a.score);
85
+ const keptEdges = [];
86
+ for (const edge of sortedEdges) {
87
+ if (uf.unionBounded(edge.leftThreadId, edge.rightThreadId, options.maxClusterSize)) {
88
+ keptEdges.push(edge);
89
+ }
90
+ }
91
+ const byRoot = new Map();
92
+ for (const node of nodes) {
93
+ const root = uf.find(node.threadId);
94
+ const list = byRoot.get(root) ?? [];
95
+ list.push(node.threadId);
96
+ byRoot.set(root, list);
97
+ }
98
+ return formatClusters(nodes, keptEdges, byRoot);
99
+ }
100
+ /**
101
+ * Build clusters with iterative refinement of oversized components.
102
+ *
103
+ * 1. Run Union-Find at the base threshold (edges already filtered by minScore).
104
+ * 2. For any cluster above `maxClusterSize`, re-cluster its members using only
105
+ * edges above a progressively higher threshold (raised by `refineStep` each
106
+ * iteration) until all clusters are within limits or threshold reaches 1.0.
107
+ */
108
+ export function buildRefinedClusters(nodes, edges, options) {
109
+ const nodesById = new Map(nodes.map((node) => [node.threadId, node]));
110
+ const result = [];
111
+ // Initial Union-Find pass
112
+ const uf = new UnionFind();
113
+ for (const node of nodes)
114
+ uf.add(node.threadId);
115
+ for (const edge of edges)
116
+ uf.union(edge.leftThreadId, edge.rightThreadId);
117
+ const byRoot = new Map();
118
+ for (const node of nodes) {
119
+ const root = uf.find(node.threadId);
120
+ const list = byRoot.get(root) ?? [];
121
+ list.push(node.threadId);
122
+ byRoot.set(root, list);
123
+ }
124
+ // Build adjacency list for O(E) iteration instead of O(n²) pair scanning
125
+ const adjacency = new Map();
126
+ for (const edge of edges) {
127
+ let list = adjacency.get(edge.leftThreadId);
128
+ if (!list) {
129
+ list = [];
130
+ adjacency.set(edge.leftThreadId, list);
131
+ }
132
+ list.push(edge);
133
+ let rList = adjacency.get(edge.rightThreadId);
134
+ if (!rList) {
135
+ rList = [];
136
+ adjacency.set(edge.rightThreadId, rList);
137
+ }
138
+ rList.push(edge);
139
+ }
140
+ const workQueue = [];
141
+ for (const members of byRoot.values()) {
142
+ if (members.length <= options.maxClusterSize) {
143
+ const clusterNodes = members.map((id) => nodesById.get(id)).filter((n) => n !== undefined);
144
+ const clusterEdges = edgesWithinSet(new Set(members), adjacency);
145
+ result.push(...formatClusters(clusterNodes, clusterEdges, new Map([[0, members]])));
146
+ }
147
+ else {
148
+ workQueue.push({ memberIds: members, currentThreshold: 0 });
149
+ }
150
+ }
151
+ // Iteratively refine oversized clusters
152
+ while (workQueue.length > 0) {
153
+ const item = workQueue.pop();
154
+ const newThreshold = item.currentThreshold + options.refineStep;
155
+ if (newThreshold >= 1.0) {
156
+ for (const memberId of item.memberIds) {
157
+ result.push({ representativeThreadId: memberId, members: [memberId] });
158
+ }
159
+ continue;
160
+ }
161
+ // Filter edges within this component to the higher threshold
162
+ const memberSet = new Set(item.memberIds);
163
+ const filteredEdges = [];
164
+ for (const memberId of item.memberIds) {
165
+ for (const edge of adjacency.get(memberId) ?? []) {
166
+ const otherId = edge.leftThreadId === memberId ? edge.rightThreadId : edge.leftThreadId;
167
+ if (otherId > memberId && memberSet.has(otherId) && edge.score >= newThreshold) {
168
+ filteredEdges.push(edge);
169
+ }
170
+ }
171
+ }
172
+ // Re-cluster with filtered edges
173
+ const subUf = new UnionFind();
174
+ for (const memberId of item.memberIds)
175
+ subUf.add(memberId);
176
+ for (const edge of filteredEdges)
177
+ subUf.union(edge.leftThreadId, edge.rightThreadId);
178
+ const subByRoot = new Map();
179
+ for (const memberId of item.memberIds) {
180
+ const root = subUf.find(memberId);
181
+ const list = subByRoot.get(root) ?? [];
182
+ list.push(memberId);
183
+ subByRoot.set(root, list);
184
+ }
185
+ for (const subMembers of subByRoot.values()) {
186
+ if (subMembers.length <= options.maxClusterSize) {
187
+ const clusterNodes = subMembers.map((id) => nodesById.get(id)).filter((n) => n !== undefined);
188
+ const clusterEdges = edgesWithinSet(new Set(subMembers), adjacency);
189
+ result.push(...formatClusters(clusterNodes, clusterEdges, new Map([[0, subMembers]])));
190
+ }
191
+ else {
192
+ workQueue.push({ memberIds: subMembers, currentThreshold: newThreshold });
193
+ }
194
+ }
195
+ }
196
+ return result.sort((left, right) => right.members.length - left.members.length);
197
+ }
198
+ function edgesWithinSet(memberSet, adjacency) {
199
+ const edges = [];
200
+ for (const memberId of memberSet) {
201
+ for (const edge of adjacency.get(memberId) ?? []) {
202
+ const otherId = edge.leftThreadId === memberId ? edge.rightThreadId : edge.leftThreadId;
203
+ if (otherId > memberId && memberSet.has(otherId)) {
204
+ edges.push(edge);
205
+ }
206
+ }
207
+ }
208
+ return edges;
209
+ }
210
+ function formatClusters(nodes, edges, byRoot) {
40
211
  const edgeCounts = new Map();
41
212
  for (const edge of edges) {
42
213
  edgeCounts.set(edge.leftThreadId, (edgeCounts.get(edge.leftThreadId) ?? 0) + 1);
@@ -1 +1 @@
1
- {"version":3,"file":"build.js","sourceRoot":"","sources":["../../src/cluster/build.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"build.js","sourceRoot":"","sources":["../../src/cluster/build.ts"],"names":[],"mappings":"AAYA,MAAM,SAAS;IACI,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAC;IACnC,IAAI,GAAG,IAAI,GAAG,EAAkB,CAAC;IAElD,GAAG,CAAC,KAAa;QACf,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC5B,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC9B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;QAC1B,CAAC;IACH,CAAC;IAED,IAAI,CAAC,KAAa;QAChB,IAAI,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACpC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACzB,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;YAC9B,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC,CAAC;YACxB,OAAO,KAAK,CAAC;QACf,CAAC;QACD,gEAAgE;QAChE,IAAI,OAAO,GAAW,KAAK,CAAC;QAC5B,OAAO,MAAM,KAAK,OAAO,EAAE,CAAC;YAC1B,MAAM,WAAW,GAAW,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;YAC9D,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,CAAC,iBAAiB;YACxD,OAAO,GAAG,MAAM,CAAC;YACjB,MAAM,GAAG,WAAW,CAAC;QACvB,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC;IAED,KAAK,CAAC,IAAY,EAAE,KAAa;QAC/B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,QAAQ,KAAK,SAAS,EAAE,CAAC;YAC3B,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;YAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;YAChD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;YACrC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAC,CAAC;QAChD,CAAC;IACH,CAAC;IAED,6FAA6F;IAC7F,YAAY,CAAC,IAAY,EAAE,KAAa,EAAE,OAAe;QACvD,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACjC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACnC,IAAI,QAAQ,KAAK,SAAS;YAAE,OAAO,IAAI,CAAC,CAAC,yBAAyB;QAClE,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAC9C,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC;QAChD,IAAI,QAAQ,GAAG,SAAS,GAAG,OAAO;YAAE,OAAO,KAAK,CAAC;QACjD,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;QACrC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,EAAE,QAAQ,GAAG,SAAS,CAAC,CAAC;QAC9C,OAAO,IAAI,CAAC;IACd,CAAC;IAED,OAAO,CAAC,KAAa;QACnB,OAAO,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC;IAC9C,CAAC;CACF;AAED,MAAM,UAAU,aAAa,CAAC,KAAa,EAAE,KAAuB;IAClE,MAAM,EAAE,GAAG,IAAI,SAAS,EAAE,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChD,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;IAE1E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC3C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACzB,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC;IAED,OAAO,cAAc,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC;AAC9C,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,wBAAwB,CACtC,KAAa,EACb,KAAuB,EACvB,OAAmC;IAEnC,MAAM,EAAE,GAAG,IAAI,SAAS,EAAE,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAEhD,+DAA+D;IAC/D,MAAM,WAAW,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC;IACjE,MAAM,SAAS,GAAqB,EAAE,CAAC;IAEvC,KAAK,MAAM,IAAI,IAAI,WAAW,EAAE,CAAC;QAC/B,IAAI,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,aAAa,EAAE,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;YACnF,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC;IACH,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC3C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACzB,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC;IAED,OAAO,cAAc,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;AAClD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAClC,KAAa,EACb,KAAuB,EACvB,OAAuD;IAEvD,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;IACtE,MAAM,MAAM,GAAiE,EAAE,CAAC;IAEhF,0BAA0B;IAC1B,MAAM,EAAE,GAAG,IAAI,SAAS,EAAE,CAAC;IAC3B,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChD,KAAK,MAAM,IAAI,IAAI,KAAK;QAAE,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;IAE1E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC3C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACpC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QACpC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QACzB,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;IACzB,CAAC;IAED,yEAAyE;IACzE,MAAM,SAAS,GAAG,IAAI,GAAG,EAA4B,CAAC;IACtD,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAI,EAAE,CAAC;YAAC,IAAI,GAAG,EAAE,CAAC;YAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,CAAC;QAAC,CAAC;QACjE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAChB,IAAI,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;QAC9C,IAAI,CAAC,KAAK,EAAE,CAAC;YAAC,KAAK,GAAG,EAAE,CAAC;YAAC,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,EAAE,KAAK,CAAC,CAAC;QAAC,CAAC;QACrE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACnB,CAAC;IAID,MAAM,SAAS,GAAe,EAAE,CAAC;IAEjC,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,MAAM,EAAE,EAAE,CAAC;QACtC,IAAI,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;YAC7C,MAAM,YAAY,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAa,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;YACtG,MAAM,YAAY,GAAG,cAAc,CAAC,IAAI,GAAG,CAAC,OAAO,CAAC,EAAE,SAAS,CAAC,CAAC;YACjE,MAAM,CAAC,IAAI,CAAC,GAAG,cAAc,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACtF,CAAC;aAAM,CAAC;YACN,SAAS,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,OAAO,EAAE,gBAAgB,EAAE,CAAC,EAAE,CAAC,CAAC;QAC9D,CAAC;IACH,CAAC;IAED,wCAAwC;IACxC,OAAO,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,EAAG,CAAC;QAC9B,MAAM,YAAY,GAAG,IAAI,CAAC,gBAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;QAChE,IAAI,YAAY,IAAI,GAAG,EAAE,CAAC;YACxB,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;gBACtC,MAAM,CAAC,IAAI,CAAC,EAAE,sBAAsB,EAAE,QAAQ,EAAE,OAAO,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;YACzE,CAAC;YACD,SAAS;QACX,CAAC;QAED,6DAA6D;QAC7D,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAC1C,MAAM,aAAa,GAAqB,EAAE,CAAC;QAC3C,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACtC,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;gBACjD,MAAM,OAAO,GAAG,IAAI,CAAC,YAAY,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC;gBACxF,IAAI,OAAO,GAAG,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,KAAK,IAAI,YAAY,EAAE,CAAC;oBAC/E,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBAC3B,CAAC;YACH,CAAC;QACH,CAAC;QAED,iCAAiC;QACjC,MAAM,KAAK,GAAG,IAAI,SAAS,EAAE,CAAC;QAC9B,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS;YAAE,KAAK,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC3D,KAAK,MAAM,IAAI,IAAI,aAAa;YAAE,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,YAAY,EAAE,IAAI,CAAC,aAAa,CAAC,CAAC;QAErF,MAAM,SAAS,GAAG,IAAI,GAAG,EAAoB,CAAC;QAC9C,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC;YACtC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAClC,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;YACvC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YACpB,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;QAC5B,CAAC;QAED,KAAK,MAAM,UAAU,IAAI,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC;YAC5C,IAAI,UAAU,CAAC,MAAM,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;gBAChD,MAAM,YAAY,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAa,EAAE,CAAC,CAAC,KAAK,SAAS,CAAC,CAAC;gBACzG,MAAM,YAAY,GAAG,cAAc,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,EAAE,SAAS,CAAC,CAAC;gBACpE,MAAM,CAAC,IAAI,CAAC,GAAG,cAAc,CAAC,YAAY,EAAE,YAAY,EAAE,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;YACzF,CAAC;iBAAM,CAAC;gBACN,SAAS,CAAC,IAAI,CAAC,EAAE,SAAS,EAAE,UAAU,EAAE,gBAAgB,EAAE,YAAY,EAAE,CAAC,CAAC;YAC5E,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;AAClF,CAAC;AAED,SAAS,cAAc,CAAC,SAAsB,EAAE,SAAwC;IACtF,MAAM,KAAK,GAAqB,EAAE,CAAC;IACnC,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,KAAK,MAAM,IAAI,IAAI,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,EAAE,EAAE,CAAC;YACjD,MAAM,OAAO,GAAG,IAAI,CAAC,YAAY,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC;YACxF,IAAI,OAAO,GAAG,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gBACjD,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACnB,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC;AAED,SAAS,cAAc,CACrB,KAAa,EACb,KAAuB,EACvB,MAA6B;IAE7B,MAAM,UAAU,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC7C,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;QAChF,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IACpF,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,CAAC,CAAC,CAAC;IACtE,OAAO,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC;SAC/B,GAAG,CAAC,CAAC,OAAO,EAAE,EAAE;QACf,MAAM,cAAc,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE;YAC3D,MAAM,IAAI,GAAG,SAAS,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACnC,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;YACrC,MAAM,SAAS,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC;YACjF,IAAI,SAAS,KAAK,CAAC;gBAAE,OAAO,SAAS,CAAC;YACtC,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK;gBAAE,OAAO,MAAM,GAAG,OAAO,CAAC;YAC7C,OAAO,IAAI,CAAC,MAAM,GAAG,KAAK,CAAC,MAAM,CAAC;QACpC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACN,OAAO,EAAE,sBAAsB,EAAE,cAAc,EAAE,OAAO,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,GAAG,KAAK,CAAC,EAAE,CAAC;IAC1G,CAAC,CAAC;SACD,IAAI,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;AACvE,CAAC"}
@@ -5,13 +5,32 @@ import path from 'node:path';
5
5
  import { performance } from 'node:perf_hooks';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { GHCrawlService } from '../service.js';
8
- const BASELINE_PATH = fileURLToPath(new URL('./perf-baseline.json', import.meta.url));
8
+ const DEFAULT_BASELINE_PATH = fileURLToPath(new URL('./perf-baseline.json', import.meta.url));
9
+ function getBaselinePath() {
10
+ const configuredPath = process.env.GHCRAWL_CLUSTER_PERF_CONFIG_PATH?.trim();
11
+ return configuredPath ? path.resolve(configuredPath) : DEFAULT_BASELINE_PATH;
12
+ }
9
13
  function loadBaseline() {
10
- return JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
14
+ return JSON.parse(fs.readFileSync(getBaselinePath(), 'utf8'));
11
15
  }
12
16
  function shouldBootstrapBaseline() {
13
17
  return process.env.GHCRAWL_CLUSTER_PERF_BOOTSTRAP === '1';
14
18
  }
19
+ function shouldIgnoreRegressionThreshold() {
20
+ return process.env.GHCRAWL_CLUSTER_PERF_IGNORE_THRESHOLD === '1';
21
+ }
22
+ function getPerfBackend() {
23
+ return process.env.GHCRAWL_CLUSTER_PERF_BACKEND === 'vectorlite' ? 'vectorlite' : 'exact';
24
+ }
25
+ function assertBenchmarkShape(result, baseline, backend) {
26
+ if (backend === 'exact' && baseline.fixture.assertExactClusterCount !== false) {
27
+ assert.equal(result.clusters, baseline.fixture.clusterCount);
28
+ }
29
+ else {
30
+ assert.ok(result.clusters > 0);
31
+ }
32
+ assert.ok(result.edges > baseline.fixture.clusterCount);
33
+ }
15
34
  function formatDurationMs(durationMs) {
16
35
  if (!Number.isFinite(durationMs))
17
36
  return 'n/a';
@@ -26,6 +45,14 @@ function formatDurationMs(durationMs) {
26
45
  const seconds = totalSeconds - minutes * 60;
27
46
  return `${minutes}m ${seconds.toFixed(1)}s`;
28
47
  }
48
+ function formatBytes(bytes) {
49
+ if (!Number.isFinite(bytes))
50
+ return 'n/a';
51
+ if (bytes < 1024 * 1024) {
52
+ return `${(bytes / 1024).toFixed(1)} KiB`;
53
+ }
54
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
55
+ }
29
56
  function formatPercent(value) {
30
57
  const sign = value > 0 ? '+' : '';
31
58
  return `${sign}${value.toFixed(1)}%`;
@@ -38,6 +65,22 @@ function median(values) {
38
65
  }
39
66
  return sorted[middle] ?? 0;
40
67
  }
68
+ function roundFixtureMedianMs(value) {
69
+ return Number(value.toFixed(1));
70
+ }
71
+ function roundProjectedOpenclawMs(value) {
72
+ return Math.round(value);
73
+ }
74
+ function buildSuggestedBaseline(result) {
75
+ const shouldSuggest = result.deltaPercent < 0 || result.baselineMedianMs === result.medianMs;
76
+ if (!shouldSuggest) {
77
+ return null;
78
+ }
79
+ return {
80
+ fixtureMedianMs: roundFixtureMedianMs(result.medianMs),
81
+ projectedOpenclawMs: roundProjectedOpenclawMs(result.projectedOpenclawMs),
82
+ };
83
+ }
41
84
  function createGitHubStub() {
42
85
  return {
43
86
  checkAuth: async () => undefined,
@@ -67,6 +110,8 @@ function createService(dbPath) {
67
110
  openaiApiKeySource: 'none',
68
111
  summaryModel: 'gpt-5-mini',
69
112
  embedModel: 'text-embedding-3-large',
113
+ embeddingBasis: 'title_original',
114
+ vectorBackend: 'vectorlite',
70
115
  embedBatchSize: 2,
71
116
  embedConcurrency: 2,
72
117
  embedMaxUnread: 4,
@@ -144,26 +189,64 @@ function seedBenchmarkDatabase(dbPath, baseline) {
144
189
  service.close();
145
190
  }
146
191
  }
147
- async function runSingleCluster(dbPath, baseline) {
192
+ async function runSingleCluster(dbPath, baseline, backend) {
148
193
  const service = createService(dbPath);
149
194
  try {
150
- const startedAt = performance.now();
151
- const result = await service.clusterRepository({
195
+ // clusterExperiment may not exist on older branches (e.g. base worktree in CI)
196
+ if (typeof service.clusterExperiment !== 'function') {
197
+ const startedAt = performance.now();
198
+ const result = await service.clusterRepository({
199
+ owner: 'openclaw',
200
+ repo: 'openclaw',
201
+ k: baseline.fixture.k,
202
+ minScore: baseline.fixture.minScore,
203
+ });
204
+ const durationMs = performance.now() - startedAt;
205
+ return {
206
+ durationMs,
207
+ totalDurationMs: durationMs,
208
+ loadMs: 0,
209
+ setupMs: 0,
210
+ edgeBuildMs: durationMs,
211
+ indexBuildMs: 0,
212
+ queryMs: 0,
213
+ clusterBuildMs: 0,
214
+ peakRssBytes: 0,
215
+ peakHeapUsedBytes: 0,
216
+ clusters: result.clusters,
217
+ edges: result.edges,
218
+ };
219
+ }
220
+ const result = service.clusterExperiment({
152
221
  owner: 'openclaw',
153
222
  repo: 'openclaw',
223
+ backend,
154
224
  k: baseline.fixture.k,
155
225
  minScore: baseline.fixture.minScore,
156
226
  });
157
- const durationMs = performance.now() - startedAt;
158
- return { durationMs, clusters: result.clusters, edges: result.edges };
227
+ return {
228
+ durationMs: result.durationMs,
229
+ totalDurationMs: result.totalDurationMs,
230
+ loadMs: result.loadMs,
231
+ setupMs: result.setupMs,
232
+ edgeBuildMs: result.edgeBuildMs,
233
+ indexBuildMs: result.indexBuildMs,
234
+ queryMs: result.queryMs,
235
+ clusterBuildMs: result.clusterBuildMs,
236
+ peakRssBytes: result.memory.peakRssBytes,
237
+ peakHeapUsedBytes: result.memory.peakHeapUsedBytes,
238
+ clusters: result.clusters,
239
+ edges: result.edges,
240
+ };
159
241
  }
160
242
  finally {
161
243
  service.close();
162
244
  }
163
245
  }
164
246
  async function measureBenchmark(baseline) {
247
+ const backend = getPerfBackend();
165
248
  if (baseline.baseline.fixtureMedianMs <= 0 && !shouldBootstrapBaseline()) {
166
- throw new Error(`Cluster perf baseline is not set in ${BASELINE_PATH}. Run the benchmark once, then record fixtureMedianMs before enforcing regressions.`);
249
+ throw new Error(`Cluster perf baseline is not set in ${getBaselinePath()}. Run the benchmark once, then record fixtureMedianMs before enforcing regressions.`);
167
250
  }
168
251
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ghcrawl-cluster-perf-'));
169
252
  const seedDbPath = path.join(tempRoot, 'seed.sqlite');
@@ -172,32 +255,76 @@ async function measureBenchmark(baseline) {
172
255
  const warmupRuns = baseline.benchmark.warmupRuns;
173
256
  const runsPerSample = baseline.benchmark.runsPerSample;
174
257
  const sampleDurationsMs = [];
258
+ const totalSampleDurationsMs = [];
259
+ const loadSampleDurationsMs = [];
260
+ const setupSampleDurationsMs = [];
261
+ const edgeBuildSampleDurationsMs = [];
262
+ const indexBuildSampleDurationsMs = [];
263
+ const querySampleDurationsMs = [];
264
+ const clusterBuildSampleDurationsMs = [];
265
+ const peakRssBytesSamples = [];
266
+ const peakHeapUsedBytesSamples = [];
175
267
  const benchmarkStartedAt = performance.now();
176
268
  let runCounter = 0;
177
269
  for (let warmupIndex = 0; warmupIndex < warmupRuns; warmupIndex += 1) {
178
270
  const warmupDbPath = path.join(tempRoot, `warmup-${warmupIndex}.sqlite`);
179
271
  fs.copyFileSync(seedDbPath, warmupDbPath);
180
- const warmupResult = await runSingleCluster(warmupDbPath, baseline);
181
- assert.equal(warmupResult.clusters, baseline.fixture.clusterCount);
182
- assert.ok(warmupResult.edges > baseline.fixture.clusterCount);
272
+ const warmupResult = await runSingleCluster(warmupDbPath, baseline, backend);
273
+ assertBenchmarkShape(warmupResult, baseline, backend);
183
274
  }
184
275
  while (sampleDurationsMs.length < baseline.benchmark.maxSamples) {
185
- const sampleStartedAt = performance.now();
276
+ let sampleDurationMs = 0;
277
+ let totalSampleDurationMs = 0;
278
+ let loadSampleDurationMs = 0;
279
+ let setupSampleDurationMs = 0;
280
+ let edgeBuildSampleDurationMs = 0;
281
+ let indexBuildSampleDurationMs = 0;
282
+ let querySampleDurationMs = 0;
283
+ let clusterBuildSampleDurationMs = 0;
284
+ let samplePeakRssBytes = 0;
285
+ let samplePeakHeapUsedBytes = 0;
186
286
  for (let runIndex = 0; runIndex < runsPerSample; runIndex += 1) {
187
287
  const runDbPath = path.join(tempRoot, `run-${runCounter}.sqlite`);
188
288
  runCounter += 1;
189
289
  fs.copyFileSync(seedDbPath, runDbPath);
190
- const result = await runSingleCluster(runDbPath, baseline);
191
- assert.equal(result.clusters, baseline.fixture.clusterCount);
192
- assert.ok(result.edges > baseline.fixture.clusterCount);
290
+ const result = await runSingleCluster(runDbPath, baseline, backend);
291
+ assertBenchmarkShape(result, baseline, backend);
292
+ sampleDurationMs += result.durationMs;
293
+ totalSampleDurationMs += result.totalDurationMs;
294
+ loadSampleDurationMs += result.loadMs;
295
+ setupSampleDurationMs += result.setupMs;
296
+ edgeBuildSampleDurationMs += result.edgeBuildMs;
297
+ indexBuildSampleDurationMs += result.indexBuildMs;
298
+ querySampleDurationMs += result.queryMs;
299
+ clusterBuildSampleDurationMs += result.clusterBuildMs;
300
+ samplePeakRssBytes = Math.max(samplePeakRssBytes, result.peakRssBytes);
301
+ samplePeakHeapUsedBytes = Math.max(samplePeakHeapUsedBytes, result.peakHeapUsedBytes);
193
302
  }
194
- sampleDurationsMs.push(performance.now() - sampleStartedAt);
303
+ sampleDurationsMs.push(sampleDurationMs);
304
+ totalSampleDurationsMs.push(totalSampleDurationMs);
305
+ loadSampleDurationsMs.push(loadSampleDurationMs);
306
+ setupSampleDurationsMs.push(setupSampleDurationMs);
307
+ edgeBuildSampleDurationsMs.push(edgeBuildSampleDurationMs);
308
+ indexBuildSampleDurationsMs.push(indexBuildSampleDurationMs);
309
+ querySampleDurationsMs.push(querySampleDurationMs);
310
+ clusterBuildSampleDurationsMs.push(clusterBuildSampleDurationMs);
311
+ peakRssBytesSamples.push(samplePeakRssBytes);
312
+ peakHeapUsedBytesSamples.push(samplePeakHeapUsedBytes);
195
313
  const elapsedMs = performance.now() - benchmarkStartedAt;
196
314
  if (sampleDurationsMs.length >= baseline.benchmark.minSamples && elapsedMs >= baseline.benchmark.maxTotalMs) {
197
315
  break;
198
316
  }
199
317
  }
200
318
  const medianMs = median(sampleDurationsMs);
319
+ const totalMedianMs = median(totalSampleDurationsMs);
320
+ const loadMedianMs = median(loadSampleDurationsMs);
321
+ const setupMedianMs = median(setupSampleDurationsMs);
322
+ const edgeBuildMedianMs = median(edgeBuildSampleDurationsMs);
323
+ const indexBuildMedianMs = median(indexBuildSampleDurationsMs);
324
+ const queryMedianMs = median(querySampleDurationsMs);
325
+ const clusterBuildMedianMs = median(clusterBuildSampleDurationsMs);
326
+ const medianPeakRssBytes = median(peakRssBytesSamples);
327
+ const medianPeakHeapUsedBytes = median(peakHeapUsedBytesSamples);
201
328
  const baselineMedianMs = baseline.baseline.fixtureMedianMs > 0 ? baseline.baseline.fixtureMedianMs : medianMs;
202
329
  const deltaMs = medianMs - baselineMedianMs;
203
330
  const deltaPercent = baselineMedianMs > 0 ? (deltaMs / baselineMedianMs) * 100 : 0;
@@ -206,8 +333,28 @@ async function measureBenchmark(baseline) {
206
333
  const projectedDeltaMs = projectedOpenclawMs - projectedBaselineOpenclawMs;
207
334
  const projectedDeltaPercent = (projectedDeltaMs / projectedBaselineOpenclawMs) * 100;
208
335
  return {
336
+ backend,
337
+ timingBasis: 'cluster-only',
209
338
  sampleDurationsMs,
339
+ totalSampleDurationsMs,
340
+ loadSampleDurationsMs,
341
+ setupSampleDurationsMs,
342
+ edgeBuildSampleDurationsMs,
343
+ indexBuildSampleDurationsMs,
344
+ querySampleDurationsMs,
345
+ clusterBuildSampleDurationsMs,
346
+ peakRssBytesSamples,
347
+ peakHeapUsedBytesSamples,
210
348
  medianMs,
349
+ totalMedianMs,
350
+ loadMedianMs,
351
+ setupMedianMs,
352
+ edgeBuildMedianMs,
353
+ indexBuildMedianMs,
354
+ queryMedianMs,
355
+ clusterBuildMedianMs,
356
+ medianPeakRssBytes,
357
+ medianPeakHeapUsedBytes,
211
358
  baselineMedianMs,
212
359
  deltaMs,
213
360
  deltaPercent,
@@ -229,14 +376,30 @@ async function measureBenchmark(baseline) {
229
376
  function buildSummary(result) {
230
377
  const status = result.deltaPercent > result.maxRegressionPercent ? 'FAIL' : 'PASS';
231
378
  const sampleList = result.sampleDurationsMs.map((value) => formatDurationMs(value)).join(', ');
379
+ const suggestedBaseline = buildSuggestedBaseline(result);
380
+ const timingLabel = 'Fixture median';
232
381
  const bootstrapLine = result.baselineMedianMs === result.medianMs
233
382
  ? '- Bootstrap mode: using the current fixture median as the provisional baseline'
234
383
  : null;
384
+ const suggestedBaselineLine = suggestedBaseline
385
+ ? `- Suggested baseline update: ${JSON.stringify(suggestedBaseline)}`
386
+ : null;
235
387
  return [
236
388
  '## Cluster Performance',
237
389
  '',
390
+ `- Backend: ${result.backend}`,
391
+ `- Timing basis: ${result.timingBasis}`,
238
392
  `- Status: ${status}`,
239
- `- Fixture median: ${formatDurationMs(result.medianMs)} (${result.samples} samples, ${result.runsPerSample} cluster rebuilds/sample)`,
393
+ `- Fixture median (cluster-only): ${formatDurationMs(result.medianMs)} (${result.samples} samples, ${result.runsPerSample} cluster rebuilds/sample)`,
394
+ `- Fixture median (total run): ${formatDurationMs(result.totalMedianMs)}`,
395
+ `- Fixture median load stage: ${formatDurationMs(result.loadMedianMs)}`,
396
+ `- Fixture median setup stage: ${formatDurationMs(result.setupMedianMs)}`,
397
+ `- Fixture median exact edge-build stage: ${formatDurationMs(result.edgeBuildMedianMs)}`,
398
+ `- Fixture median vector index-build stage: ${formatDurationMs(result.indexBuildMedianMs)}`,
399
+ `- Fixture median vector query stage: ${formatDurationMs(result.queryMedianMs)}`,
400
+ `- Fixture median cluster-assembly stage: ${formatDurationMs(result.clusterBuildMedianMs)}`,
401
+ `- Median peak RSS: ${formatBytes(result.medianPeakRssBytes)}`,
402
+ `- Median peak heap used: ${formatBytes(result.medianPeakHeapUsedBytes)}`,
240
403
  `- Fixture baseline: ${formatDurationMs(result.baselineMedianMs)}`,
241
404
  `- Fixture delta: ${formatDurationMs(result.deltaMs)} (${formatPercent(result.deltaPercent)})`,
242
405
  `- Projected openclaw/openclaw duration: ${formatDurationMs(result.projectedOpenclawMs)}`,
@@ -245,6 +408,7 @@ function buildSummary(result) {
245
408
  `- Regression threshold: ${formatPercent(result.maxRegressionPercent)}`,
246
409
  `- Fixture shape: ${result.threadCount} threads x ${result.sourceKinds.length} source kinds`,
247
410
  `- Sample durations: ${sampleList}`,
411
+ suggestedBaselineLine,
248
412
  bootstrapLine,
249
413
  '',
250
414
  ]
@@ -261,6 +425,7 @@ function writeOutput(result, summary, bootstrap) {
261
425
  status: result.deltaPercent > result.maxRegressionPercent ? 'FAIL' : 'PASS',
262
426
  bootstrap,
263
427
  summary,
428
+ suggestedBaseline: buildSuggestedBaseline(result),
264
429
  result,
265
430
  }, null, 2) + '\n');
266
431
  }
@@ -269,10 +434,11 @@ async function main() {
269
434
  const result = await measureBenchmark(baseline);
270
435
  const summary = buildSummary(result);
271
436
  const bootstrap = shouldBootstrapBaseline();
272
- const shouldFail = !bootstrap && result.deltaPercent > result.maxRegressionPercent;
437
+ const shouldFail = !bootstrap && !shouldIgnoreRegressionThreshold() && result.deltaPercent > result.maxRegressionPercent;
273
438
  process.stdout.write(`${summary}\n`);
274
- if (bootstrap) {
275
- process.stdout.write(`Suggested fixtureMedianMs: ${result.medianMs.toFixed(1)}\n`);
439
+ const suggestedBaseline = buildSuggestedBaseline(result);
440
+ if (bootstrap && suggestedBaseline) {
441
+ process.stdout.write(`Suggested baseline update: ${JSON.stringify(suggestedBaseline)}\n`);
276
442
  }
277
443
  const summaryPath = process.env.GITHUB_STEP_SUMMARY;
278
444
  if (summaryPath) {