@graphty/algorithms 1.8.1 → 2.0.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.
Files changed (43) hide show
  1. package/README.md +5 -4
  2. package/dist/algorithms.js +518 -525
  3. package/dist/algorithms.js.map +1 -1
  4. package/dist/algorithms.standalone.js +519 -526
  5. package/dist/algorithms.standalone.js.map +1 -1
  6. package/dist/src/algorithms/centrality/eigenvector.d.ts +13 -2
  7. package/dist/src/algorithms/centrality/eigenvector.d.ts.map +1 -1
  8. package/dist/src/algorithms/centrality/eigenvector.js +96 -73
  9. package/dist/src/algorithms/centrality/eigenvector.js.map +1 -1
  10. package/dist/src/algorithms/community/girvan-newman.js +35 -15
  11. package/dist/src/algorithms/community/girvan-newman.js.map +1 -1
  12. package/dist/src/algorithms/community/leiden.d.ts.map +1 -1
  13. package/dist/src/algorithms/community/leiden.js +214 -222
  14. package/dist/src/algorithms/community/leiden.js.map +1 -1
  15. package/dist/src/algorithms/community/louvain-optimized.d.ts +28 -72
  16. package/dist/src/algorithms/community/louvain-optimized.d.ts.map +1 -1
  17. package/dist/src/algorithms/community/louvain-optimized.js +249 -262
  18. package/dist/src/algorithms/community/louvain-optimized.js.map +1 -1
  19. package/dist/src/algorithms/community/modularity-utils.d.ts +15 -7
  20. package/dist/src/algorithms/community/modularity-utils.d.ts.map +1 -1
  21. package/dist/src/algorithms/community/modularity-utils.js +38 -38
  22. package/dist/src/algorithms/community/modularity-utils.js.map +1 -1
  23. package/dist/src/algorithms/shortest-path/bellman-ford.d.ts.map +1 -1
  24. package/dist/src/algorithms/shortest-path/bellman-ford.js +35 -8
  25. package/dist/src/algorithms/shortest-path/bellman-ford.js.map +1 -1
  26. package/dist/src/errors.d.ts +21 -0
  27. package/dist/src/errors.d.ts.map +1 -0
  28. package/dist/src/errors.js +23 -0
  29. package/dist/src/errors.js.map +1 -0
  30. package/dist/src/index.d.ts +1 -0
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/index.js +2 -0
  33. package/dist/src/index.js.map +1 -1
  34. package/package.json +11 -8
  35. package/src/algorithms/centrality/eigenvector.ts +103 -84
  36. package/src/algorithms/community/girvan-newman.ts +38 -17
  37. package/src/algorithms/community/leiden.ts +270 -275
  38. package/src/algorithms/community/louvain-optimized.ts +303 -313
  39. package/src/algorithms/community/modularity-utils.ts +42 -45
  40. package/src/algorithms/shortest-path/bellman-ford.ts +46 -8
  41. package/src/errors.ts +26 -0
  42. package/src/index.ts +3 -0
  43. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -4,13 +4,18 @@ import type { CommunityResult, NodeId } from "../../types/index.js";
4
4
  /**
5
5
  * Optimized Louvain community detection algorithm with early pruning and threshold cycling
6
6
  *
7
+ * Runs the full multilevel method of Blondel et al. (local moving, then aggregating each community
8
+ * into one node, repeated until nothing moves) over typed arrays indexed 0..n-1.
9
+ *
7
10
  * Key optimizations:
8
- * - Leaf node pruning: Skip nodes with degree 1
11
+ * - Typed-array adjacency (CSR) instead of per-edge map lookups
12
+ * - Leaf node pruning: skip a degree-1 node already in its neighbour's community
9
13
  * - Importance ordering: Process high-impact nodes first
10
- * - Threshold cycling: Adaptive convergence thresholds
14
+ * - Threshold cycling: Adaptive move thresholds, relative to each node's largest possible gain
11
15
  * - Early termination: Stop when changes become insignificant
12
16
  *
13
- * Expected speedup: 2-5x on large graphs with many leaf nodes
17
+ * The graph is treated as undirected: a directed edge counts as an undirected edge of its weight.
18
+ * A self-loop of weight w adds 2w to its node's degree, the standard convention (NetworkX too).
14
19
  */
15
20
 
16
21
  interface OptimizedLouvainOptions {
@@ -19,11 +24,11 @@ interface OptimizedLouvainOptions {
19
24
  */
20
25
  resolution?: number;
21
26
  /**
22
- * Maximum iterations per level (default: 100)
27
+ * Maximum local-moving sweeps per level (default: 100)
23
28
  */
24
29
  maxIterations?: number;
25
30
  /**
26
- * Convergence tolerance (default: 1e-6)
31
+ * Convergence tolerance on a sweep's modularity gain (default: 1e-6)
27
32
  */
28
33
  tolerance?: number;
29
34
  /**
@@ -35,7 +40,7 @@ interface OptimizedLouvainOptions {
35
40
  */
36
41
  importanceOrdering?: boolean;
37
42
  /**
38
- * Base pruning threshold (default: 0.01)
43
+ * Base move threshold, as a fraction of a node's largest possible modularity gain (default: 0.01)
39
44
  */
40
45
  pruningThreshold?: number;
41
46
  /**
@@ -50,16 +55,34 @@ interface PruningStats {
50
55
  stableNodesPruned: number;
51
56
  }
52
57
 
58
+ /** One level of the multilevel method: an undirected weighted graph in CSR form. */
59
+ interface Level {
60
+ n: number;
61
+ /** Row offsets into `targets` / `weights`, length n + 1. Each edge appears in both rows. */
62
+ offsets: Int32Array;
63
+ targets: Int32Array;
64
+ weights: Float64Array;
65
+ /** Weight of the edges inside each node (self-loops), each edge counted once. */
66
+ self: Float64Array;
67
+ /** Weighted degree of each node (a self-loop counts twice). */
68
+ degree: Float64Array;
69
+ }
70
+
71
+ interface LocalMovingOptions {
72
+ resolution: number;
73
+ maxIterations: number;
74
+ tolerance: number;
75
+ pruneLeaves: boolean;
76
+ importanceOrdering: boolean;
77
+ pruningThreshold: number;
78
+ thresholdCycling: boolean;
79
+ }
80
+
53
81
  /**
54
82
  * Optimized Louvain implementation with early pruning and threshold cycling
55
83
  */
56
84
  export class OptimizedLouvain {
57
85
  private graph: Graph;
58
- private communities: Map<NodeId, number>;
59
- private communityWeights: Map<number, number>;
60
- private nodeWeights: Map<NodeId, number>;
61
- private nodeDegrees: Map<NodeId, number>;
62
- private totalWeight: number;
63
86
  private pruningStats: PruningStats;
64
87
 
65
88
  /**
@@ -68,11 +91,6 @@ export class OptimizedLouvain {
68
91
  */
69
92
  constructor(graph: Graph) {
70
93
  this.graph = graph;
71
- this.communities = new Map();
72
- this.communityWeights = new Map();
73
- this.nodeWeights = new Map();
74
- this.nodeDegrees = new Map();
75
- this.totalWeight = 0;
76
94
  this.pruningStats = {
77
95
  leafNodesPruned: 0,
78
96
  lowDegreeNodesPruned: 0,
@@ -83,383 +101,355 @@ export class OptimizedLouvain {
83
101
  /**
84
102
  * Run optimized Louvain algorithm
85
103
  * @param options - Algorithm configuration options
86
- * @returns Community detection result with communities, modularity, and iterations
104
+ * @returns Community detection result with communities, modularity, and iterations (levels)
87
105
  */
88
106
  public detectCommunities(options: OptimizedLouvainOptions = {}): CommunityResult {
89
- const {
90
- resolution = 1.0,
91
- maxIterations = 100,
92
- tolerance = 1e-6,
93
- pruneLeaves = true,
94
- importanceOrdering = true,
95
- pruningThreshold = 0.01,
96
- thresholdCycling = true,
97
- } = options;
98
-
99
- // Initialize
100
- this.initialize();
101
- let modularity = this.calculateModularity(resolution);
102
- let iteration = 0;
103
- let improved = true;
104
-
105
- while (iteration < maxIterations && improved) {
106
- // Get nodes in optimal processing order
107
- const orderedNodes = importanceOrdering
108
- ? this.getNodesInImportanceOrder()
109
- : Array.from(this.graph.nodes()).map((n) => n.id);
110
-
111
- // Apply adaptive threshold
112
- const threshold = thresholdCycling ? this.getAdaptiveThreshold(iteration, pruningThreshold) : 0;
113
-
114
- // Perform local optimization
115
- improved = this.performLocalMoving(orderedNodes, {
116
- pruneLeaves,
117
- threshold,
118
- resolution,
119
- });
120
-
121
- if (improved) {
122
- const newModularity = this.calculateModularity(resolution);
123
-
124
- // Check convergence
125
- if (Math.abs(newModularity - modularity) < tolerance) {
126
- break;
127
- }
107
+ const settings: LocalMovingOptions = {
108
+ resolution: options.resolution ?? 1.0,
109
+ maxIterations: options.maxIterations ?? 100,
110
+ tolerance: options.tolerance ?? 1e-6,
111
+ pruneLeaves: options.pruneLeaves ?? true,
112
+ importanceOrdering: options.importanceOrdering ?? true,
113
+ pruningThreshold: options.pruningThreshold ?? 0.01,
114
+ thresholdCycling: options.thresholdCycling ?? true,
115
+ };
128
116
 
129
- modularity = newModularity;
130
- iteration++;
131
- }
117
+ const { level: base, ids } = this.buildLevel();
118
+ // membership[i] = the current level's node that original node i has been folded into
119
+ const membership = new Int32Array(base.n);
120
+ for (let i = 0; i < base.n; i++) {
121
+ membership[i] = i;
132
122
  }
133
123
 
134
- // Convert community assignments to result format
135
- const communityGroups = new Map<number, NodeId[]>();
136
-
137
- for (const [nodeId, community] of this.communities) {
138
- if (!communityGroups.has(community)) {
139
- communityGroups.set(community, []);
124
+ let level = base;
125
+ let iterations = 0;
126
+ while (level.n > 1) {
127
+ const { community, count, moved } = this.localMoving(level, settings);
128
+ if (!moved) {
129
+ break;
140
130
  }
141
131
 
142
- const group = communityGroups.get(community);
143
- if (group) {
144
- group.push(nodeId);
132
+ iterations++;
133
+ for (let i = 0; i < membership.length; i++) {
134
+ membership[i] = community[membership[i]];
145
135
  }
136
+
137
+ level = aggregate(level, community, count);
138
+ }
139
+
140
+ const groups: NodeId[][] = Array.from({ length: level.n }, () => []);
141
+ for (let i = 0; i < membership.length; i++) {
142
+ groups[membership[i]].push(ids[i]);
146
143
  }
147
144
 
148
145
  return {
149
- communities: Array.from(communityGroups.values()),
150
- modularity,
151
- iterations: iteration,
146
+ communities: groups.filter((group) => group.length > 0),
147
+ modularity: levelModularity(level, settings.resolution),
148
+ iterations,
152
149
  };
153
150
  }
154
151
 
155
152
  /**
156
- * Initialize data structures
153
+ * Get pruning statistics
154
+ * @returns Statistics about nodes pruned during optimization
157
155
  */
158
- private initialize(): void {
159
- let communityId = 0;
156
+ public getPruningStats(): PruningStats {
157
+ return { ...this.pruningStats };
158
+ }
160
159
 
161
- // Initialize each node in its own community
160
+ /**
161
+ * Index the graph's nodes 0..n-1 and build the level-0 CSR adjacency
162
+ * @returns The level-0 graph and the node id at each index
163
+ */
164
+ private buildLevel(): { level: Level; ids: NodeId[] } {
165
+ const ids: NodeId[] = [];
166
+ const index = new Map<NodeId, number>();
162
167
  for (const node of this.graph.nodes()) {
163
- this.communities.set(node.id, communityId);
164
-
165
- // Calculate node weight and degree
166
- let nodeWeight = 0;
167
- let degree = 0;
168
+ index.set(node.id, ids.length);
169
+ ids.push(node.id);
170
+ }
168
171
 
169
- for (const neighbor of Array.from(this.graph.neighbors(node.id))) {
170
- const edge = this.graph.getEdge(node.id, neighbor);
171
- const weight = edge?.weight ?? 1;
172
- nodeWeight += weight;
173
- degree++;
172
+ const n = ids.length;
173
+ const edges: [number, number, number][] = [];
174
+ const counts = new Int32Array(n + 1);
175
+ const self = new Float64Array(n);
176
+ const degree = new Float64Array(n);
177
+
178
+ for (const edge of this.graph.edges()) {
179
+ const s = index.get(edge.source);
180
+ const t = index.get(edge.target);
181
+ if (s === undefined || t === undefined) {
182
+ continue;
174
183
  }
175
184
 
176
- // For undirected graphs, also check incoming edges
177
- if (!this.graph.isDirected) {
178
- for (const neighbor of Array.from(this.graph.inNeighbors(node.id))) {
179
- if (!this.graph.hasEdge(node.id, neighbor)) {
180
- const edge = this.graph.getEdge(neighbor, node.id);
181
- const weight = edge?.weight ?? 1;
182
- nodeWeight += weight;
183
- degree++;
184
- }
185
- }
185
+ const w = edge.weight ?? 1;
186
+ degree[s] += w;
187
+ degree[t] += w;
188
+ if (s === t) {
189
+ self[s] += w;
190
+ continue;
186
191
  }
187
192
 
188
- this.nodeWeights.set(node.id, nodeWeight);
189
- this.nodeDegrees.set(node.id, degree);
190
- this.communityWeights.set(communityId, nodeWeight);
191
- this.totalWeight += nodeWeight;
192
-
193
- communityId++;
193
+ edges.push([s, t, w]);
194
+ counts[s + 1]++;
195
+ counts[t + 1]++;
194
196
  }
195
197
 
196
- // Total weight is sum of all edge weights
197
- // For undirected graphs, each edge is counted twice from both endpoints
198
- this.totalWeight = this.totalWeight / 2;
198
+ return { level: buildCsr(n, counts, edges, self, degree), ids };
199
199
  }
200
200
 
201
201
  /**
202
- * Get nodes ordered by importance (degree * log(weight))
203
- * @returns Array of node IDs sorted by descending importance
202
+ * Move each node to the neighbouring community with the best modularity gain, until no move
203
+ * improves modularity
204
+ * @param level - The graph at this level
205
+ * @param options - Local moving options
206
+ * @returns Each node's community (numbered 0..count-1), the count, and whether any node moved
204
207
  */
205
- private getNodesInImportanceOrder(): NodeId[] {
206
- const nodeImportance = new Map<NodeId, number>();
207
-
208
- for (const [nodeId, degree] of this.nodeDegrees) {
209
- const weight = this.nodeWeights.get(nodeId) ?? 0;
210
- // Importance score: combination of degree and weight
211
- // High-degree nodes and nodes with heavy edges are processed first
212
- const importance = degree * Math.log(1 + weight);
213
- nodeImportance.set(nodeId, importance);
208
+ private localMoving(
209
+ level: Level,
210
+ options: LocalMovingOptions,
211
+ ): { community: Int32Array; count: number; moved: boolean } {
212
+ const { n, offsets, targets, weights, degree } = level;
213
+ const { resolution, maxIterations, tolerance, pruneLeaves } = options;
214
+
215
+ const twoM = degree.reduce((sum, d) => sum + d, 0);
216
+ const community = new Int32Array(n);
217
+ const total = new Float64Array(n);
218
+ for (let i = 0; i < n; i++) {
219
+ community[i] = i;
220
+ total[i] = degree[i];
214
221
  }
215
222
 
216
- // Sort by importance (descending)
217
- return Array.from(nodeImportance.entries())
218
- .sort((a, b) => b[1] - a[1])
219
- .map(([nodeId]) => nodeId);
220
- }
223
+ if (twoM === 0) {
224
+ return { community, count: n, moved: false };
225
+ }
221
226
 
222
- /**
223
- * Perform local moving phase with optimizations
224
- * @param nodes - Array of node IDs to process
225
- * @param options - Local moving options
226
- * @param options.pruneLeaves - Whether to skip leaf nodes
227
- * @param options.threshold - Minimum gain threshold for moves
228
- * @param options.resolution - Resolution parameter for modularity
229
- * @returns True if any improvement was made, false otherwise
230
- */
231
- private performLocalMoving(
232
- nodes: NodeId[],
233
- options: {
234
- pruneLeaves: boolean;
235
- threshold: number;
236
- resolution: number;
237
- },
238
- ): boolean {
239
- const { pruneLeaves, threshold, resolution } = options;
240
- let improvement = false;
241
- let hasChanged = true;
242
-
243
- while (hasChanged) {
244
- hasChanged = false;
245
-
246
- for (const nodeId of nodes) {
247
- // Early pruning: skip leaf nodes
248
- if (pruneLeaves && this.isLeafNode(nodeId)) {
227
+ const order = this.nodeOrder(level, options.importanceOrdering);
228
+ // Scratch: weight from the current node to each community, and the communities touched
229
+ const toCommunity = new Float64Array(n);
230
+ const touched = new Int32Array(n);
231
+
232
+ let moved = false;
233
+ let useThreshold = options.thresholdCycling;
234
+ for (let sweep = 0; sweep < maxIterations; sweep++) {
235
+ // The threshold is a fraction of the most node i could gain from any move (all of its
236
+ // weight landing inside the target, k_i), so it scales with the graph
237
+ const threshold = useThreshold ? options.pruningThreshold * Math.pow(0.5, sweep / 10) : 0;
238
+ let sweepMoved = false;
239
+ let sweepGain = 0;
240
+
241
+ for (const i of order) {
242
+ const start = offsets[i];
243
+ const end = offsets[i + 1];
244
+ if (pruneLeaves && end - start === 1 && community[targets[start]] === community[i]) {
245
+ // A leaf's only move is to its one neighbour's community, and it is there
249
246
  this.pruningStats.leafNodesPruned++;
250
247
  continue;
251
248
  }
252
249
 
253
- const currentCommunity = this.communities.get(nodeId) ?? 0;
254
- const neighborCommunities = this.getNeighborCommunities(nodeId);
255
-
256
- // Skip isolated nodes
257
- if (neighborCommunities.size === 0) {
250
+ if (end === start) {
258
251
  continue;
259
252
  }
260
253
 
261
- // Find best community to move to
262
- let bestCommunity = currentCommunity;
263
- let bestGain = 0;
264
-
265
- // Remove node from its current community to calculate gains
266
- this.removeNodeFromCommunity(nodeId, currentCommunity);
254
+ const current = community[i];
255
+ let touchedCount = 0;
256
+ for (let e = start; e < end; e++) {
257
+ const c = community[targets[e]];
258
+ if (toCommunity[c] === 0) {
259
+ touched[touchedCount++] = c;
260
+ }
267
261
 
268
- for (const community of neighborCommunities) {
269
- const gain = this.calculateModularityGain(nodeId, community, resolution);
262
+ toCommunity[c] += weights[e];
263
+ }
270
264
 
271
- // Apply threshold - only move if gain exceeds threshold
272
- if (gain > bestGain + threshold) {
273
- bestGain = gain;
274
- bestCommunity = community;
265
+ // Gains are in units of m * dQ: w(i, c) - resolution * tot(c) * k_i / 2m
266
+ const k = degree[i];
267
+ total[current] -= k;
268
+ const stayGain = toCommunity[current] - (resolution * total[current] * k) / twoM;
269
+ let best = current;
270
+ let bestGain = stayGain;
271
+ const minImprovement = threshold * k;
272
+
273
+ for (let t = 0; t < touchedCount; t++) {
274
+ const c = touched[t];
275
+ if (c !== current) {
276
+ const gain = toCommunity[c] - (resolution * total[c] * k) / twoM;
277
+ if (gain > bestGain + minImprovement) {
278
+ bestGain = gain;
279
+ best = c;
280
+ }
275
281
  }
276
- }
277
282
 
278
- // Try staying in current community
279
- const currentGain = this.calculateModularityGain(nodeId, currentCommunity, resolution);
280
- if (currentGain > bestGain + threshold) {
281
- bestGain = currentGain;
282
- bestCommunity = currentCommunity;
283
+ toCommunity[c] = 0;
283
284
  }
284
285
 
285
- // Add node to best community
286
- this.addNodeToCommunity(nodeId, bestCommunity);
286
+ total[best] += k;
287
+ if (best !== current) {
288
+ community[i] = best;
289
+ sweepMoved = true;
290
+ sweepGain += (bestGain - stayGain) / (twoM / 2);
291
+ }
292
+ }
287
293
 
288
- // Track if node moved
289
- if (bestCommunity !== currentCommunity) {
290
- hasChanged = true;
291
- improvement = true;
294
+ moved ||= sweepMoved;
295
+ if (!sweepMoved || sweepGain < tolerance) {
296
+ // A threshold can stall moves that are still worth making: finish without one
297
+ if (useThreshold) {
298
+ useThreshold = false;
299
+ continue;
292
300
  }
301
+
302
+ break;
293
303
  }
294
304
  }
295
305
 
296
- return improvement;
297
- }
306
+ // Renumber the communities 0..count-1
307
+ const renumber = new Int32Array(n).fill(-1);
308
+ let count = 0;
309
+ for (let i = 0; i < n; i++) {
310
+ const c = community[i];
311
+ if (renumber[c] === -1) {
312
+ renumber[c] = count++;
313
+ }
298
314
 
299
- /**
300
- * Check if node is a leaf (degree 1)
301
- * @param nodeId - The node ID to check
302
- * @returns True if the node has degree 1, false otherwise
303
- */
304
- private isLeafNode(nodeId: NodeId): boolean {
305
- const degree = this.nodeDegrees.get(nodeId) ?? 0;
306
- return degree === 1;
307
- }
315
+ community[i] = renumber[c];
316
+ }
308
317
 
309
- /**
310
- * Get adaptive threshold that decreases with iterations
311
- * @param iteration - Current iteration number
312
- * @param baseThreshold - Base threshold value to scale
313
- * @returns Adaptive threshold value that decays over iterations
314
- */
315
- private getAdaptiveThreshold(iteration: number, baseThreshold: number): number {
316
- // Exponentially decay threshold with iterations
317
- // This allows coarse movements early and fine-tuning later
318
- return baseThreshold * Math.pow(0.5, iteration / 10);
318
+ return { community, count, moved };
319
319
  }
320
320
 
321
321
  /**
322
- * Calculate modularity gain from moving a node to a community
323
- * @param nodeId - The node ID to move
324
- * @param targetCommunity - The target community ID
325
- * @param resolution - Resolution parameter for modularity calculation
326
- * @returns The modularity gain from moving the node
322
+ * Order nodes for local moving, by importance (degree * log(1 + weighted degree)) if asked
323
+ * @param level - The graph at this level
324
+ * @param importanceOrdering - Whether to sort by importance
325
+ * @returns Node indices in processing order
327
326
  */
328
- private calculateModularityGain(nodeId: NodeId, targetCommunity: number, resolution: number): number {
329
- const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
330
-
331
- // Sum of weights from node to target community
332
- let weightToTarget = 0;
333
-
334
- for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
335
- if (this.communities.get(neighbor) === targetCommunity) {
336
- const edge = this.graph.getEdge(nodeId, neighbor);
337
- weightToTarget += edge?.weight ?? 1;
338
- }
327
+ private nodeOrder(level: Level, importanceOrdering: boolean): Int32Array {
328
+ const order = new Int32Array(level.n);
329
+ for (let i = 0; i < level.n; i++) {
330
+ order[i] = i;
339
331
  }
340
332
 
341
- // For undirected graphs, also check incoming edges
342
- if (!this.graph.isDirected) {
343
- for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
344
- if (this.communities.get(neighbor) === targetCommunity && !this.graph.hasEdge(nodeId, neighbor)) {
345
- const edge = this.graph.getEdge(neighbor, nodeId);
346
- weightToTarget += edge?.weight ?? 1;
347
- }
348
- }
333
+ if (!importanceOrdering) {
334
+ return order;
349
335
  }
350
336
 
351
- // Weight of target community
352
- const targetWeight = this.communityWeights.get(targetCommunity) ?? 0;
353
-
354
- // Modularity gain formula
355
- const gain =
356
- (weightToTarget - (resolution * nodeWeight * targetWeight) / (2 * this.totalWeight)) / this.totalWeight;
337
+ const importance = new Float64Array(level.n);
338
+ for (let i = 0; i < level.n; i++) {
339
+ importance[i] = (level.offsets[i + 1] - level.offsets[i]) * Math.log(1 + level.degree[i]);
340
+ }
357
341
 
358
- return gain;
342
+ return order.sort((a, b) => importance[b] - importance[a]);
359
343
  }
344
+ }
360
345
 
361
- /**
362
- * Remove node from community (for gain calculation)
363
- * @param nodeId - The node ID to remove
364
- * @param community - The community ID to remove from
365
- */
366
- private removeNodeFromCommunity(nodeId: NodeId, community: number): void {
367
- const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
368
- this.communityWeights.set(community, (this.communityWeights.get(community) ?? 0) - nodeWeight);
369
- this.communities.delete(nodeId);
346
+ /**
347
+ * Build a CSR level from an undirected edge list (each edge listed once, no self-loops)
348
+ * @param n - Node count
349
+ * @param counts - counts[i + 1] = number of edges incident to node i
350
+ * @param edges - The edges as [source, target, weight]
351
+ * @param self - Self-loop weight of each node
352
+ * @param degree - Weighted degree of each node
353
+ * @returns The level
354
+ */
355
+ function buildCsr(
356
+ n: number,
357
+ counts: Int32Array,
358
+ edges: [number, number, number][],
359
+ self: Float64Array,
360
+ degree: Float64Array,
361
+ ): Level {
362
+ const offsets = counts;
363
+ for (let i = 0; i < n; i++) {
364
+ offsets[i + 1] += offsets[i];
370
365
  }
371
366
 
372
- /**
373
- * Add node to community
374
- * @param nodeId - The node ID to add
375
- * @param community - The community ID to add to
376
- */
377
- private addNodeToCommunity(nodeId: NodeId, community: number): void {
378
- const nodeWeight = this.nodeWeights.get(nodeId) ?? 0;
379
- this.communityWeights.set(community, (this.communityWeights.get(community) ?? 0) + nodeWeight);
380
- this.communities.set(nodeId, community);
367
+ const targets = new Int32Array(offsets[n]);
368
+ const weights = new Float64Array(offsets[n]);
369
+ const fill = offsets.slice(0, n);
370
+ for (const [s, t, w] of edges) {
371
+ targets[fill[s]] = t;
372
+ weights[fill[s]++] = w;
373
+ targets[fill[t]] = s;
374
+ weights[fill[t]++] = w;
381
375
  }
382
376
 
383
- /**
384
- * Get neighboring communities of a node
385
- * @param nodeId - The node ID to find neighbor communities for
386
- * @returns Set of community IDs that neighbors belong to
387
- */
388
- private getNeighborCommunities(nodeId: NodeId): Set<number> {
389
- const communities = new Set<number>();
390
-
391
- for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
392
- const community = this.communities.get(neighbor);
393
- if (community !== undefined) {
394
- communities.add(community);
395
- }
396
- }
397
-
398
- // For undirected graphs, also check incoming edges
399
- if (!this.graph.isDirected) {
400
- for (const neighbor of Array.from(this.graph.inNeighbors(nodeId))) {
401
- const community = this.communities.get(neighbor);
402
- if (community !== undefined) {
403
- communities.add(community);
404
- }
405
- }
406
- }
377
+ return { n, offsets, targets, weights, self, degree };
378
+ }
407
379
 
408
- return communities;
380
+ /**
381
+ * Fold each community into one node: edges between communities are summed, edges inside a
382
+ * community become its self-loop
383
+ * @param level - The graph at this level
384
+ * @param community - Each node's community, numbered 0..count-1
385
+ * @param count - Number of communities
386
+ * @returns The next level
387
+ */
388
+ function aggregate(level: Level, community: Int32Array, count: number): Level {
389
+ const self = new Float64Array(count);
390
+ const degree = new Float64Array(count);
391
+ const members: number[][] = Array.from({ length: count }, () => []);
392
+ for (let i = 0; i < level.n; i++) {
393
+ const c = community[i];
394
+ members[c].push(i);
395
+ self[c] += level.self[i];
396
+ degree[c] += level.degree[i];
409
397
  }
410
398
 
411
- /**
412
- * Calculate total modularity
413
- * @param resolution - Resolution parameter for modularity calculation
414
- * @returns The modularity score of the current partition
415
- */
416
- private calculateModularity(resolution: number): number {
417
- if (this.totalWeight === 0) {
418
- return 0;
419
- }
420
-
421
- let modularity = 0;
422
-
423
- // Sum over all communities
424
- const communityInternalWeights = new Map<number, number>();
399
+ const edges: [number, number, number][] = [];
400
+ const counts = new Int32Array(count + 1);
401
+ const toCommunity = new Float64Array(count);
402
+ const touched: number[] = [];
403
+ for (let c = 0; c < count; c++) {
404
+ for (const i of members[c]) {
405
+ for (let e = level.offsets[i]; e < level.offsets[i + 1]; e++) {
406
+ const d = community[level.targets[e]];
407
+ if (d === c) {
408
+ // Seen once from each endpoint
409
+ self[c] += level.weights[e] / 2;
410
+ } else if (d > c) {
411
+ // Keep each inter-community edge once, from its lower-numbered side
412
+ if (toCommunity[d] === 0) {
413
+ touched.push(d);
414
+ }
425
415
 
426
- // Calculate internal weights for each community
427
- for (const node of this.graph.nodes()) {
428
- const nodeId = node.id;
429
- const community = this.communities.get(nodeId) ?? 0;
430
-
431
- for (const neighbor of Array.from(this.graph.neighbors(nodeId))) {
432
- if (this.communities.get(neighbor) === community) {
433
- const edge = this.graph.getEdge(nodeId, neighbor);
434
- const weight = edge?.weight ?? 1;
435
- communityInternalWeights.set(community, (communityInternalWeights.get(community) ?? 0) + weight);
416
+ toCommunity[d] += level.weights[e];
436
417
  }
437
418
  }
438
419
  }
439
420
 
440
- // Calculate modularity
441
- for (const [community, internalWeight] of communityInternalWeights) {
442
- const communityWeight = this.communityWeights.get(community) ?? 0;
443
- // For undirected graphs, internal weights are counted twice
444
- const aIn = this.graph.isDirected ? internalWeight : internalWeight / 2;
445
- const aTotal = communityWeight;
446
-
447
- // Modularity formula: sum of (fraction of edges within community - expected fraction)
448
- const actualFraction = aIn / this.totalWeight;
449
- const expectedFraction = resolution * Math.pow(aTotal / (2 * this.totalWeight), 2);
450
- modularity += actualFraction - expectedFraction;
421
+ for (const d of touched) {
422
+ edges.push([c, d, toCommunity[d]]);
423
+ counts[c + 1]++;
424
+ counts[d + 1]++;
425
+ toCommunity[d] = 0;
451
426
  }
452
427
 
453
- return modularity;
428
+ touched.length = 0;
454
429
  }
455
430
 
456
- /**
457
- * Get pruning statistics
458
- * @returns Statistics about nodes pruned during optimization
459
- */
460
- public getPruningStats(): PruningStats {
461
- return { ...this.pruningStats };
431
+ return buildCsr(count, counts, edges, self, degree);
432
+ }
433
+
434
+ /**
435
+ * Modularity of the partition that puts each node of `level` in its own community
436
+ * @param level - The graph at this level
437
+ * @param resolution - Resolution parameter
438
+ * @returns Q = sum over nodes c of [ self(c) / m - resolution * (k_c / 2m)^2 ]
439
+ */
440
+ function levelModularity(level: Level, resolution: number): number {
441
+ const twoM = level.degree.reduce((sum, d) => sum + d, 0);
442
+ if (twoM === 0) {
443
+ return 0;
462
444
  }
445
+
446
+ let modularity = 0;
447
+ for (let c = 0; c < level.n; c++) {
448
+ const share = level.degree[c] / twoM;
449
+ modularity += (2 * level.self[c]) / twoM - resolution * share * share;
450
+ }
451
+
452
+ return modularity;
463
453
  }
464
454
 
465
455
  /**