graphwise 1.8.0 → 1.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 (125) hide show
  1. package/dist/adjacency-map-BtKzcuJq.js +229 -0
  2. package/dist/adjacency-map-BtKzcuJq.js.map +1 -0
  3. package/dist/adjacency-map-JqBnMNkF.cjs +234 -0
  4. package/dist/adjacency-map-JqBnMNkF.cjs.map +1 -0
  5. package/dist/async/index.cjs +15 -242
  6. package/dist/async/index.js +2 -229
  7. package/dist/expansion/index.cjs +43 -0
  8. package/dist/expansion/index.js +2 -0
  9. package/dist/expansion-ClDhlMK8.js +1704 -0
  10. package/dist/expansion-ClDhlMK8.js.map +1 -0
  11. package/dist/expansion-DaTroIyv.cjs +1949 -0
  12. package/dist/expansion-DaTroIyv.cjs.map +1 -0
  13. package/dist/extraction/index.cjs +630 -0
  14. package/dist/extraction/index.cjs.map +1 -0
  15. package/dist/extraction/index.js +621 -0
  16. package/dist/extraction/index.js.map +1 -0
  17. package/dist/gpu/csr.d.ts +29 -30
  18. package/dist/gpu/csr.d.ts.map +1 -1
  19. package/dist/gpu/dispatch.d.ts +31 -0
  20. package/dist/gpu/dispatch.d.ts.map +1 -0
  21. package/dist/gpu/dispatch.unit.test.d.ts +5 -0
  22. package/dist/gpu/dispatch.unit.test.d.ts.map +1 -0
  23. package/dist/gpu/index.cjs +15 -410
  24. package/dist/gpu/index.d.ts +3 -1
  25. package/dist/gpu/index.d.ts.map +1 -1
  26. package/dist/gpu/index.js +2 -400
  27. package/dist/gpu/kernels/bfs/kernel.d.ts +59 -0
  28. package/dist/gpu/kernels/bfs/kernel.d.ts.map +1 -0
  29. package/dist/gpu/kernels/bfs/logic.d.ts +47 -0
  30. package/dist/gpu/kernels/bfs/logic.d.ts.map +1 -0
  31. package/dist/gpu/kernels/bfs/logic.unit.test.d.ts +2 -0
  32. package/dist/gpu/kernels/bfs/logic.unit.test.d.ts.map +1 -0
  33. package/dist/gpu/kernels/degree-histogram/kernel.d.ts +32 -0
  34. package/dist/gpu/kernels/degree-histogram/kernel.d.ts.map +1 -0
  35. package/dist/gpu/kernels/degree-histogram/logic.d.ts +45 -0
  36. package/dist/gpu/kernels/degree-histogram/logic.d.ts.map +1 -0
  37. package/dist/gpu/kernels/degree-histogram/logic.unit.test.d.ts +2 -0
  38. package/dist/gpu/kernels/degree-histogram/logic.unit.test.d.ts.map +1 -0
  39. package/dist/gpu/kernels/jaccard/kernel.d.ts +40 -0
  40. package/dist/gpu/kernels/jaccard/kernel.d.ts.map +1 -0
  41. package/dist/gpu/kernels/jaccard/logic.d.ts +43 -0
  42. package/dist/gpu/kernels/jaccard/logic.d.ts.map +1 -0
  43. package/dist/gpu/kernels/jaccard/logic.unit.test.d.ts +2 -0
  44. package/dist/gpu/kernels/jaccard/logic.unit.test.d.ts.map +1 -0
  45. package/dist/gpu/kernels/pagerank/kernel.d.ts +44 -0
  46. package/dist/gpu/kernels/pagerank/kernel.d.ts.map +1 -0
  47. package/dist/gpu/kernels/pagerank/logic.d.ts +50 -0
  48. package/dist/gpu/kernels/pagerank/logic.d.ts.map +1 -0
  49. package/dist/gpu/kernels/pagerank/logic.unit.test.d.ts +2 -0
  50. package/dist/gpu/kernels/pagerank/logic.unit.test.d.ts.map +1 -0
  51. package/dist/gpu/kernels/spmv/kernel.d.ts +43 -0
  52. package/dist/gpu/kernels/spmv/kernel.d.ts.map +1 -0
  53. package/dist/gpu/kernels/spmv/logic.d.ts +31 -0
  54. package/dist/gpu/kernels/spmv/logic.d.ts.map +1 -0
  55. package/dist/gpu/kernels/spmv/logic.unit.test.d.ts +2 -0
  56. package/dist/gpu/kernels/spmv/logic.unit.test.d.ts.map +1 -0
  57. package/dist/gpu/operations.d.ts +76 -0
  58. package/dist/gpu/operations.d.ts.map +1 -0
  59. package/dist/gpu/operations.unit.test.d.ts +5 -0
  60. package/dist/gpu/operations.unit.test.d.ts.map +1 -0
  61. package/dist/gpu/root.d.ts +53 -0
  62. package/dist/gpu/root.d.ts.map +1 -0
  63. package/dist/gpu/root.unit.test.d.ts +2 -0
  64. package/dist/gpu/root.unit.test.d.ts.map +1 -0
  65. package/dist/gpu/types.d.ts +3 -8
  66. package/dist/gpu/types.d.ts.map +1 -1
  67. package/dist/gpu-CHiCN0wa.js +16945 -0
  68. package/dist/gpu-CHiCN0wa.js.map +1 -0
  69. package/dist/gpu-Y6owRVMi.cjs +17028 -0
  70. package/dist/gpu-Y6owRVMi.cjs.map +1 -0
  71. package/dist/graph/index.cjs +2 -229
  72. package/dist/graph/index.js +1 -228
  73. package/dist/index/index.cjs +141 -4040
  74. package/dist/index/index.js +15 -3917
  75. package/dist/jaccard-3rCdilwm.js +39 -0
  76. package/dist/jaccard-3rCdilwm.js.map +1 -0
  77. package/dist/jaccard-Bys9_dGW.cjs +50 -0
  78. package/dist/jaccard-Bys9_dGW.cjs.map +1 -0
  79. package/dist/{kmeans-BIgSyGKu.cjs → kmeans-B8x9D1kt.cjs} +1 -1
  80. package/dist/{kmeans-BIgSyGKu.cjs.map → kmeans-B8x9D1kt.cjs.map} +1 -1
  81. package/dist/{kmeans-87ExSUNZ.js → kmeans-DKkL9rAN.js} +1 -1
  82. package/dist/{kmeans-87ExSUNZ.js.map → kmeans-DKkL9rAN.js.map} +1 -1
  83. package/dist/ops-djAsQQSh.cjs +277 -0
  84. package/dist/ops-djAsQQSh.cjs.map +1 -0
  85. package/dist/ops-upIi6JIi.js +212 -0
  86. package/dist/ops-upIi6JIi.js.map +1 -0
  87. package/dist/priority-queue-BIiD1L0k.cjs +148 -0
  88. package/dist/priority-queue-BIiD1L0k.cjs.map +1 -0
  89. package/dist/priority-queue-CFDd5cBg.js +143 -0
  90. package/dist/priority-queue-CFDd5cBg.js.map +1 -0
  91. package/dist/ranking/index.cjs +43 -0
  92. package/dist/ranking/index.js +4 -0
  93. package/dist/ranking/mi/index.cjs +581 -0
  94. package/dist/ranking/mi/index.cjs.map +1 -0
  95. package/dist/ranking/mi/index.js +555 -0
  96. package/dist/ranking/mi/index.js.map +1 -0
  97. package/dist/ranking-3ez5m67U.js +1016 -0
  98. package/dist/ranking-3ez5m67U.js.map +1 -0
  99. package/dist/ranking-DVvajgUZ.cjs +1093 -0
  100. package/dist/ranking-DVvajgUZ.cjs.map +1 -0
  101. package/dist/seeds/index.cjs +1 -1
  102. package/dist/seeds/index.js +1 -1
  103. package/dist/structures/index.cjs +2 -143
  104. package/dist/structures/index.js +1 -142
  105. package/dist/utils/index.cjs +1 -1
  106. package/dist/utils/index.js +1 -1
  107. package/dist/utils-BodeE2Mo.js +22 -0
  108. package/dist/utils-BodeE2Mo.js.map +1 -0
  109. package/dist/utils-CDtCcsyF.cjs +33 -0
  110. package/dist/utils-CDtCcsyF.cjs.map +1 -0
  111. package/package.json +3 -1
  112. package/dist/async/index.cjs.map +0 -1
  113. package/dist/async/index.js.map +0 -1
  114. package/dist/gpu/context.d.ts +0 -118
  115. package/dist/gpu/context.d.ts.map +0 -1
  116. package/dist/gpu/context.unit.test.d.ts +0 -2
  117. package/dist/gpu/context.unit.test.d.ts.map +0 -1
  118. package/dist/gpu/index.cjs.map +0 -1
  119. package/dist/gpu/index.js.map +0 -1
  120. package/dist/graph/index.cjs.map +0 -1
  121. package/dist/graph/index.js.map +0 -1
  122. package/dist/index/index.cjs.map +0 -1
  123. package/dist/index/index.js.map +0 -1
  124. package/dist/structures/index.cjs.map +0 -1
  125. package/dist/structures/index.js.map +0 -1
@@ -1,3920 +1,18 @@
1
- import { AdjacencyMapGraph } from "../graph/index.js";
1
+ import { a as gpuSpmv, c as initGPUFromDevice, d as isWebGPUAvailable, f as csrToTypedBuffers, i as gpuPageRank, l as assertWebGPUAvailable, m as GPUNotAvailableError, n as gpuDegreeHistogram, o as withBackend, p as graphToCSR, r as gpuJaccardBatch, s as initGPU, t as gpuBfsLevels, u as detectWebGPU } from "../gpu-CHiCN0wa.js";
2
+ import { t as AdjacencyMapGraph } from "../adjacency-map-BtKzcuJq.js";
3
+ import "../graph/index.js";
2
4
  import { bfs, bfsWithPath, dfs, dfsWithPath } from "../traversal/index.js";
3
- import { PriorityQueue } from "../structures/index.js";
4
- import { n as miniBatchKMeans, r as normaliseFeatures, t as _computeMean } from "../kmeans-87ExSUNZ.js";
5
+ import { t as PriorityQueue } from "../priority-queue-CFDd5cBg.js";
6
+ import "../structures/index.js";
7
+ import { n as defaultYieldStrategy, t as collectAsyncIterable } from "../utils-BodeE2Mo.js";
8
+ import { a as opNeighbours, c as resolveAsyncOp, d as runSync, i as opHasNode, l as resolveSyncOp, n as opGetEdge, o as opProgress, r as opGetNode, s as opYield, t as opDegree, u as runAsync } from "../ops-upIi6JIi.js";
9
+ import { A as pipe, B as base, C as tideAsync, D as reachAsync, E as reach, F as haeAsync, I as dome, L as domeAsync, M as edge, N as edgeAsync, O as sage, P as hae, R as domeHighDegree, S as tide, T as mazeAsync, V as baseAsync, _ as fuseAsync, a as dfsPriorityFn, b as lace, c as frontierBalanced, d as standardBfsAsync, f as flux, g as fuse, h as siftAsync, i as dfsPriorityAsync, j as pipeAsync, k as sageAsync, l as frontierBalancedAsync, m as sift, n as kHop, o as randomPriority, p as fluxAsync, r as dfsPriority, s as randomPriorityAsync, t as randomWalk, u as standardBfs, v as warp, w as maze, x as laceAsync, y as warpAsync, z as domeHighDegreeAsync } from "../expansion-ClDhlMK8.js";
10
+ import { n as miniBatchKMeans, r as normaliseFeatures, t as _computeMean } from "../kmeans-DKkL9rAN.js";
5
11
  import { approximateClusteringCoefficient, batchClusteringCoefficients, computeJaccard, countEdgesOfType, countNodesOfType, entropyFromCounts, localClusteringCoefficient, localTypeEntropy, neighbourIntersection, neighbourOverlap, neighbourSet, normalisedEntropy, shannonEntropy } from "../utils/index.js";
12
+ import { n as jaccardAsync, t as jaccard } from "../jaccard-3rCdilwm.js";
13
+ import { a as katz, c as jaccardArithmetic, d as shortest, f as parse, i as communicability, l as widestPath, n as randomRanking, o as betweenness, p as parseAsync, r as resistanceDistance, s as pagerank, t as hittingTime, u as degreeSum } from "../ranking-3ez5m67U.js";
14
+ import { adamicAdar, adamicAdarAsync, adaptive, adaptiveAsync, cosine, cosineAsync, etch, etchAsync, hubPromoted, hubPromotedAsync, notch, notchAsync, overlapCoefficient, overlapCoefficientAsync, resourceAllocation, resourceAllocationAsync, scale, scaleAsync, skew, skewAsync, sorensen, sorensenAsync, span, spanAsync } from "../ranking/mi/index.js";
6
15
  import { grasp, stratified } from "../seeds/index.js";
7
- import { GPUContext, GPUNotAvailableError, assertWebGPUAvailable, createGPUContext, createResultBuffer, csrToGPUBuffers, detectWebGPU, getGPUContext, graphToCSR, isWebGPUAvailable, readBufferToCPU } from "../gpu/index.js";
8
- import { collectAsyncIterable, defaultYieldStrategy, opDegree, opGetEdge, opGetNode, opHasNode, opNeighbours, opProgress, opYield, resolveAsyncOp, resolveSyncOp, runAsync, runSync } from "../async/index.js";
9
- //#region src/expansion/base-helpers.ts
10
- /**
11
- * Check whether expansion should continue given current progress.
12
- *
13
- * Returns shouldContinue=false as soon as any configured limit is reached,
14
- * along with the appropriate termination reason.
15
- *
16
- * @param iterations - Number of iterations completed so far
17
- * @param nodesVisited - Number of distinct nodes visited so far
18
- * @param pathsFound - Number of paths discovered so far
19
- * @param limits - Configured expansion limits (0 = unlimited)
20
- * @returns Whether to continue and the termination reason if stopping
21
- */
22
- function continueExpansion(iterations, nodesVisited, pathsFound, limits) {
23
- if (limits.maxIterations > 0 && iterations >= limits.maxIterations) return {
24
- shouldContinue: false,
25
- termination: "limit"
26
- };
27
- if (limits.maxNodes > 0 && nodesVisited >= limits.maxNodes) return {
28
- shouldContinue: false,
29
- termination: "limit"
30
- };
31
- if (limits.maxPaths > 0 && pathsFound >= limits.maxPaths) return {
32
- shouldContinue: false,
33
- termination: "limit"
34
- };
35
- return {
36
- shouldContinue: true,
37
- termination: "exhausted"
38
- };
39
- }
40
- /**
41
- * Reconstruct path from collision point.
42
- *
43
- * Traces backwards through the predecessor maps of both frontiers from the
44
- * collision node, then concatenates the two halves to form the full path.
45
- *
46
- * @param collisionNode - The node where the two frontiers met
47
- * @param frontierA - Index of the first frontier
48
- * @param frontierB - Index of the second frontier
49
- * @param predecessors - Predecessor maps, one per frontier
50
- * @param seeds - Seed nodes, one per frontier
51
- * @returns The reconstructed path, or null if seeds are missing
52
- */
53
- function reconstructPath$1(collisionNode, frontierA, frontierB, predecessors, seeds) {
54
- const pathA = [collisionNode];
55
- const predA = predecessors[frontierA];
56
- if (predA !== void 0) {
57
- let node = collisionNode;
58
- let next = predA.get(node);
59
- while (next !== null && next !== void 0) {
60
- node = next;
61
- pathA.unshift(node);
62
- next = predA.get(node);
63
- }
64
- }
65
- const pathB = [];
66
- const predB = predecessors[frontierB];
67
- if (predB !== void 0) {
68
- let node = collisionNode;
69
- let next = predB.get(node);
70
- while (next !== null && next !== void 0) {
71
- node = next;
72
- pathB.push(node);
73
- next = predB.get(node);
74
- }
75
- }
76
- const fullPath = [...pathA, ...pathB];
77
- const seedA = seeds[frontierA];
78
- const seedB = seeds[frontierB];
79
- if (seedA === void 0 || seedB === void 0) return null;
80
- return {
81
- fromSeed: seedA,
82
- toSeed: seedB,
83
- nodes: fullPath
84
- };
85
- }
86
- /**
87
- * Create an empty expansion result for early termination (e.g. no seeds given).
88
- *
89
- * @param algorithm - Name of the algorithm producing this result
90
- * @param startTime - performance.now() timestamp taken before the algorithm began
91
- * @returns An ExpansionResult with zero paths and zero stats
92
- */
93
- function emptyResult$2(algorithm, startTime) {
94
- return {
95
- paths: [],
96
- sampledNodes: /* @__PURE__ */ new Set(),
97
- sampledEdges: /* @__PURE__ */ new Set(),
98
- visitedPerFrontier: [],
99
- stats: {
100
- iterations: 0,
101
- nodesVisited: 0,
102
- edgesTraversed: 0,
103
- pathsFound: 0,
104
- durationMs: performance.now() - startTime,
105
- algorithm,
106
- termination: "exhausted"
107
- }
108
- };
109
- }
110
- //#endregion
111
- //#region src/expansion/base-core.ts
112
- /**
113
- * Default priority function — degree-ordered (DOME).
114
- *
115
- * Lower degree = higher priority, so sparse nodes are explored before hubs.
116
- */
117
- function degreePriority(_nodeId, context) {
118
- return context.degree;
119
- }
120
- /**
121
- * Generator core of the BASE expansion algorithm.
122
- *
123
- * Yields GraphOp objects to request graph data, allowing the caller to
124
- * provide a sync or async runner. The optional `graphRef` parameter is
125
- * required when the priority function accesses `context.graph` — it is
126
- * populated in sync mode by `base()`. In async mode (Phase 4+), a proxy
127
- * graph may be supplied instead.
128
- *
129
- * @param graphMeta - Immutable graph metadata (directed, nodeCount, edgeCount)
130
- * @param seeds - Seed nodes for expansion
131
- * @param config - Expansion configuration (priority, limits, debug)
132
- * @param graphRef - Optional real graph reference for context.graph in priority functions
133
- * @returns An ExpansionResult with all discovered paths and statistics
134
- */
135
- function* baseCore(graphMeta, seeds, config, graphRef) {
136
- const startTime = performance.now();
137
- const { maxNodes = 0, maxIterations = 0, maxPaths = 0, priority = degreePriority, debug = false } = config ?? {};
138
- if (seeds.length === 0) return emptyResult$2("base", startTime);
139
- const numFrontiers = seeds.length;
140
- const allVisited = /* @__PURE__ */ new Set();
141
- const combinedVisited = /* @__PURE__ */ new Map();
142
- const visitedByFrontier = [];
143
- const predecessors = [];
144
- const queues = [];
145
- for (let i = 0; i < numFrontiers; i++) {
146
- visitedByFrontier.push(/* @__PURE__ */ new Map());
147
- predecessors.push(/* @__PURE__ */ new Map());
148
- queues.push(new PriorityQueue());
149
- const seed = seeds[i];
150
- if (seed === void 0) continue;
151
- const seedNode = seed.id;
152
- predecessors[i]?.set(seedNode, null);
153
- combinedVisited.set(seedNode, i);
154
- allVisited.add(seedNode);
155
- const seedDegree = yield* opDegree(seedNode);
156
- const seedPriority = priority(seedNode, buildPriorityContext(seedNode, i, combinedVisited, allVisited, [], 0, seedDegree, graphRef));
157
- queues[i]?.push({
158
- nodeId: seedNode,
159
- frontierIndex: i,
160
- predecessor: null
161
- }, seedPriority);
162
- }
163
- const sampledEdgeMap = /* @__PURE__ */ new Map();
164
- const discoveredPaths = [];
165
- let iterations = 0;
166
- let edgesTraversed = 0;
167
- let termination = "exhausted";
168
- const limits = {
169
- maxIterations,
170
- maxNodes,
171
- maxPaths
172
- };
173
- for (;;) {
174
- const check = continueExpansion(iterations, allVisited.size, discoveredPaths.length, limits);
175
- if (!check.shouldContinue) {
176
- termination = check.termination;
177
- break;
178
- }
179
- let lowestPriority = Number.POSITIVE_INFINITY;
180
- let activeFrontier = -1;
181
- for (let i = 0; i < numFrontiers; i++) {
182
- const queue = queues[i];
183
- if (queue !== void 0 && !queue.isEmpty()) {
184
- const peek = queue.peek();
185
- if (peek !== void 0 && peek.priority < lowestPriority) {
186
- lowestPriority = peek.priority;
187
- activeFrontier = i;
188
- }
189
- }
190
- }
191
- if (activeFrontier < 0) {
192
- termination = "exhausted";
193
- break;
194
- }
195
- const queue = queues[activeFrontier];
196
- if (queue === void 0) break;
197
- const entry = queue.pop();
198
- if (entry === void 0) break;
199
- const { nodeId, predecessor } = entry.item;
200
- const frontierVisited = visitedByFrontier[activeFrontier];
201
- if (frontierVisited === void 0 || frontierVisited.has(nodeId)) continue;
202
- frontierVisited.set(nodeId, activeFrontier);
203
- combinedVisited.set(nodeId, activeFrontier);
204
- if (predecessor !== null) {
205
- const predMap = predecessors[activeFrontier];
206
- if (predMap !== void 0) predMap.set(nodeId, predecessor);
207
- }
208
- allVisited.add(nodeId);
209
- if (debug) console.log(`[BASE] Iteration ${String(iterations)}: Frontier ${String(activeFrontier)} visiting ${nodeId}`);
210
- for (let otherFrontier = 0; otherFrontier < numFrontiers; otherFrontier++) {
211
- if (otherFrontier === activeFrontier) continue;
212
- const otherVisited = visitedByFrontier[otherFrontier];
213
- if (otherVisited === void 0) continue;
214
- if (otherVisited.has(nodeId)) {
215
- const path = reconstructPath$1(nodeId, activeFrontier, otherFrontier, predecessors, seeds);
216
- if (path !== null) {
217
- discoveredPaths.push(path);
218
- if (debug) console.log(`[BASE] Path found: ${path.nodes.join(" -> ")}`);
219
- }
220
- }
221
- }
222
- const neighbours = yield* opNeighbours(nodeId);
223
- for (const neighbour of neighbours) {
224
- edgesTraversed++;
225
- const [s, t] = nodeId < neighbour ? [nodeId, neighbour] : [neighbour, nodeId];
226
- let targets = sampledEdgeMap.get(s);
227
- if (targets === void 0) {
228
- targets = /* @__PURE__ */ new Set();
229
- sampledEdgeMap.set(s, targets);
230
- }
231
- targets.add(t);
232
- const fv = visitedByFrontier[activeFrontier];
233
- if (fv === void 0 || fv.has(neighbour)) continue;
234
- const neighbourDegree = yield* opDegree(neighbour);
235
- const neighbourPriority = priority(neighbour, buildPriorityContext(neighbour, activeFrontier, combinedVisited, allVisited, discoveredPaths, iterations + 1, neighbourDegree, graphRef));
236
- queue.push({
237
- nodeId: neighbour,
238
- frontierIndex: activeFrontier,
239
- predecessor: nodeId
240
- }, neighbourPriority);
241
- }
242
- iterations++;
243
- }
244
- const endTime = performance.now();
245
- const visitedPerFrontier = visitedByFrontier.map((m) => new Set(m.keys()));
246
- const edgeTuples = /* @__PURE__ */ new Set();
247
- for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
248
- return {
249
- paths: discoveredPaths,
250
- sampledNodes: allVisited,
251
- sampledEdges: edgeTuples,
252
- visitedPerFrontier,
253
- stats: {
254
- iterations,
255
- nodesVisited: allVisited.size,
256
- edgesTraversed,
257
- pathsFound: discoveredPaths.length,
258
- durationMs: endTime - startTime,
259
- algorithm: "base",
260
- termination
261
- }
262
- };
263
- }
264
- /**
265
- * Create a sentinel ReadableGraph that throws if any member is accessed.
266
- *
267
- * Used in async mode when no graphRef is provided. Gives a clear error
268
- * message rather than silently returning incorrect results if a priority
269
- * function attempts to access `context.graph` before Phase 4b introduces
270
- * a real async proxy.
271
- */
272
- function makeNoGraphSentinel() {
273
- const msg = "Priority function accessed context.graph in async mode without a graph proxy. Pass a graphRef or use a priority function that does not access context.graph.";
274
- const fail = () => {
275
- throw new Error(msg);
276
- };
277
- return {
278
- get directed() {
279
- return fail();
280
- },
281
- get nodeCount() {
282
- return fail();
283
- },
284
- get edgeCount() {
285
- return fail();
286
- },
287
- hasNode: fail,
288
- getNode: fail,
289
- nodeIds: fail,
290
- neighbours: fail,
291
- degree: fail,
292
- getEdge: fail,
293
- edges: fail
294
- };
295
- }
296
- /**
297
- * Build a PriorityContext for a node using a pre-fetched degree.
298
- *
299
- * When `graphRef` is provided (sync mode), it is used as `context.graph` so
300
- * priority functions can access the graph directly. When it is absent (async
301
- * mode), a Proxy is used in its place that throws a clear error if any
302
- * property is accessed — this prevents silent failures until Phase 4b
303
- * introduces a real async proxy graph.
304
- */
305
- function buildPriorityContext(_nodeId, frontierIndex, combinedVisited, allVisited, discoveredPaths, iteration, degree, graphRef) {
306
- return {
307
- graph: graphRef ?? makeNoGraphSentinel(),
308
- degree,
309
- frontierIndex,
310
- visitedByFrontier: combinedVisited,
311
- allVisited,
312
- discoveredPaths,
313
- iteration
314
- };
315
- }
316
- //#endregion
317
- //#region src/expansion/base.ts
318
- /**
319
- * Run BASE expansion synchronously.
320
- *
321
- * Delegates to baseCore + runSync. Behaviour is identical to the previous
322
- * direct implementation — all existing callers are unaffected.
323
- *
324
- * @param graph - Source graph
325
- * @param seeds - Seed nodes for expansion
326
- * @param config - Expansion configuration
327
- * @returns Expansion result with discovered paths
328
- */
329
- function base(graph, seeds, config) {
330
- return runSync(baseCore({
331
- directed: graph.directed,
332
- nodeCount: graph.nodeCount,
333
- edgeCount: graph.edgeCount
334
- }, seeds, config, graph), graph);
335
- }
336
- /**
337
- * Run BASE expansion asynchronously.
338
- *
339
- * Delegates to baseCore + runAsync. Supports:
340
- * - Cancellation via AbortSignal (config.signal)
341
- * - Progress callbacks (config.onProgress)
342
- * - Custom cooperative yield strategies (config.yieldStrategy)
343
- *
344
- * Note: priority functions that access `context.graph` are not supported in
345
- * async mode without a graph proxy (Phase 4b). The default degree-based
346
- * priority (DOME) does not access context.graph and works correctly.
347
- *
348
- * @param graph - Async source graph
349
- * @param seeds - Seed nodes for expansion
350
- * @param config - Expansion and async runner configuration
351
- * @returns Promise resolving to the expansion result
352
- */
353
- async function baseAsync(graph, seeds, config) {
354
- const [nodeCount, edgeCount] = await Promise.all([graph.nodeCount, graph.edgeCount]);
355
- const gen = baseCore({
356
- directed: graph.directed,
357
- nodeCount,
358
- edgeCount
359
- }, seeds, config);
360
- const runnerOptions = {};
361
- if (config?.signal !== void 0) runnerOptions.signal = config.signal;
362
- if (config?.onProgress !== void 0) runnerOptions.onProgress = config.onProgress;
363
- if (config?.yieldStrategy !== void 0) runnerOptions.yieldStrategy = config.yieldStrategy;
364
- return runAsync(gen, graph, runnerOptions);
365
- }
366
- //#endregion
367
- //#region src/expansion/dome.ts
368
- /**
369
- * DOME priority: lower degree is expanded first.
370
- */
371
- function domePriority(_nodeId, context) {
372
- return context.degree;
373
- }
374
- /**
375
- * DOME high-degree priority: negate degree to prioritise high-degree nodes.
376
- */
377
- function domeHighDegreePriority(_nodeId, context) {
378
- return -context.degree;
379
- }
380
- /**
381
- * Run DOME expansion (degree-ordered).
382
- *
383
- * @param graph - Source graph
384
- * @param seeds - Seed nodes for expansion
385
- * @param config - Expansion configuration
386
- * @returns Expansion result with discovered paths
387
- */
388
- function dome(graph, seeds, config) {
389
- return base(graph, seeds, {
390
- ...config,
391
- priority: domePriority
392
- });
393
- }
394
- /**
395
- * Run DOME expansion asynchronously (degree-ordered).
396
- *
397
- * @param graph - Async source graph
398
- * @param seeds - Seed nodes for expansion
399
- * @param config - Expansion and async runner configuration
400
- * @returns Promise resolving to the expansion result
401
- */
402
- async function domeAsync(graph, seeds, config) {
403
- return baseAsync(graph, seeds, {
404
- ...config,
405
- priority: domePriority
406
- });
407
- }
408
- /**
409
- * DOME with reverse priority (high degree first).
410
- */
411
- function domeHighDegree(graph, seeds, config) {
412
- return base(graph, seeds, {
413
- ...config,
414
- priority: domeHighDegreePriority
415
- });
416
- }
417
- /**
418
- * Run DOME high-degree expansion asynchronously (high degree first).
419
- *
420
- * @param graph - Async source graph
421
- * @param seeds - Seed nodes for expansion
422
- * @param config - Expansion and async runner configuration
423
- * @returns Promise resolving to the expansion result
424
- */
425
- async function domeHighDegreeAsync(graph, seeds, config) {
426
- return baseAsync(graph, seeds, {
427
- ...config,
428
- priority: domeHighDegreePriority
429
- });
430
- }
431
- //#endregion
432
- //#region src/expansion/hae.ts
433
- var EPSILON = 1e-10;
434
- /**
435
- * Default type mapper - uses node.type property.
436
- */
437
- function defaultTypeMapper$1(node) {
438
- return node.type ?? "default";
439
- }
440
- /**
441
- * Create a priority function using the given type mapper.
442
- */
443
- function createHAEPriority(typeMapper) {
444
- return function haePriority(nodeId, context) {
445
- const graph = context.graph;
446
- const neighbours = graph.neighbours(nodeId);
447
- const neighbourTypes = [];
448
- for (const neighbour of neighbours) {
449
- const node = graph.getNode(neighbour);
450
- if (node !== void 0) neighbourTypes.push(typeMapper(node));
451
- }
452
- return 1 / (localTypeEntropy(neighbourTypes) + EPSILON) * Math.log(context.degree + 1);
453
- };
454
- }
455
- /**
456
- * Run HAE expansion (Heterogeneity-Aware Expansion).
457
- *
458
- * Discovers paths by prioritising nodes with diverse neighbour types,
459
- * using a custom type mapper for flexible type extraction.
460
- *
461
- * @param graph - Source graph
462
- * @param seeds - Seed nodes for expansion
463
- * @param config - HAE configuration with optional typeMapper
464
- * @returns Expansion result with discovered paths
465
- */
466
- function hae(graph, seeds, config) {
467
- const typeMapper = config?.typeMapper ?? defaultTypeMapper$1;
468
- return base(graph, seeds, {
469
- ...config,
470
- priority: createHAEPriority(typeMapper)
471
- });
472
- }
473
- /**
474
- * Run HAE expansion asynchronously.
475
- *
476
- * Note: the HAE priority function accesses `context.graph` to retrieve
477
- * neighbour types. Full async equivalence requires PriorityContext
478
- * refactoring (Phase 4b deferred). This export establishes the async API
479
- * surface; use with a `wrapAsync`-wrapped sync graph for testing.
480
- *
481
- * @param graph - Async source graph
482
- * @param seeds - Seed nodes for expansion
483
- * @param config - HAE configuration combined with async runner options
484
- * @returns Promise resolving to the expansion result
485
- */
486
- async function haeAsync(graph, seeds, config) {
487
- const typeMapper = config?.typeMapper ?? defaultTypeMapper$1;
488
- return baseAsync(graph, seeds, {
489
- ...config,
490
- priority: createHAEPriority(typeMapper)
491
- });
492
- }
493
- //#endregion
494
- //#region src/expansion/edge.ts
495
- /** Default type mapper: reads `node.type`, falling back to "default". */
496
- var defaultTypeMapper = (n) => typeof n.type === "string" ? n.type : "default";
497
- /**
498
- * Run EDGE expansion (Entropy-Driven Graph Expansion).
499
- *
500
- * Discovers paths by prioritising nodes with diverse neighbour types,
501
- * deferring nodes with homogeneous neighbourhoods.
502
- *
503
- * @param graph - Source graph
504
- * @param seeds - Seed nodes for expansion
505
- * @param config - Expansion configuration
506
- * @returns Expansion result with discovered paths
507
- */
508
- function edge(graph, seeds, config) {
509
- return hae(graph, seeds, {
510
- ...config,
511
- typeMapper: defaultTypeMapper
512
- });
513
- }
514
- /**
515
- * Run EDGE expansion asynchronously.
516
- *
517
- * Delegates to `haeAsync` with the default `node.type` mapper.
518
- *
519
- * Note: the HAE priority function accesses `context.graph` to retrieve
520
- * neighbour types. Full async equivalence requires PriorityContext
521
- * refactoring (Phase 4b deferred). This export establishes the async API
522
- * surface; use with a `wrapAsync`-wrapped sync graph for testing.
523
- *
524
- * @param graph - Async source graph
525
- * @param seeds - Seed nodes for expansion
526
- * @param config - Expansion and async runner configuration
527
- * @returns Promise resolving to the expansion result
528
- */
529
- async function edgeAsync(graph, seeds, config) {
530
- return haeAsync(graph, seeds, {
531
- ...config,
532
- typeMapper: defaultTypeMapper
533
- });
534
- }
535
- //#endregion
536
- //#region src/expansion/pipe.ts
537
- /**
538
- * Priority function using path potential.
539
- * Lower values = higher priority (expanded first).
540
- *
541
- * Path potential measures how many of a node's neighbours have been
542
- * visited by OTHER frontiers (not the current frontier).
543
- */
544
- function pipePriority(nodeId, context) {
545
- const neighbours = context.graph.neighbours(nodeId);
546
- let pathPotential = 0;
547
- for (const neighbour of neighbours) {
548
- const visitedBy = context.visitedByFrontier.get(neighbour);
549
- if (visitedBy !== void 0 && visitedBy !== context.frontierIndex) pathPotential++;
550
- }
551
- return context.degree / (1 + pathPotential);
552
- }
553
- /**
554
- * Run PIPE expansion (Path-Potential Informed Priority Expansion).
555
- *
556
- * Discovers paths by prioritising nodes that bridge multiple frontiers,
557
- * identifying connecting points between seed regions.
558
- *
559
- * @param graph - Source graph
560
- * @param seeds - Seed nodes for expansion
561
- * @param config - Expansion configuration
562
- * @returns Expansion result with discovered paths
563
- */
564
- function pipe(graph, seeds, config) {
565
- return base(graph, seeds, {
566
- ...config,
567
- priority: pipePriority
568
- });
569
- }
570
- /**
571
- * Run PIPE expansion asynchronously.
572
- *
573
- * Note: the PIPE priority function accesses `context.graph` to retrieve
574
- * neighbour lists. Full async equivalence requires PriorityContext
575
- * refactoring (Phase 4b deferred). This export establishes the async API
576
- * surface; use with a `wrapAsync`-wrapped sync graph for testing.
577
- *
578
- * @param graph - Async source graph
579
- * @param seeds - Seed nodes for expansion
580
- * @param config - Expansion and async runner configuration
581
- * @returns Promise resolving to the expansion result
582
- */
583
- async function pipeAsync(graph, seeds, config) {
584
- return baseAsync(graph, seeds, {
585
- ...config,
586
- priority: pipePriority
587
- });
588
- }
589
- //#endregion
590
- //#region src/expansion/priority-helpers.ts
591
- /**
592
- * Compute the average mutual information between a node and all visited
593
- * nodes in the same frontier.
594
- *
595
- * Returns a value in [0, 1] — higher means the node is more similar
596
- * (on average) to already-visited same-frontier nodes.
597
- *
598
- * @param graph - Source graph
599
- * @param nodeId - Node being prioritised
600
- * @param context - Current priority context
601
- * @param mi - MI function to use for pairwise scoring
602
- * @returns Average MI score, or 0 if no same-frontier visited nodes exist
603
- */
604
- function avgFrontierMI(graph, nodeId, context, mi) {
605
- const { frontierIndex, visitedByFrontier } = context;
606
- let total = 0;
607
- let count = 0;
608
- for (const [visitedId, idx] of visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
609
- total += mi(graph, visitedId, nodeId);
610
- count++;
611
- }
612
- return count > 0 ? total / count : 0;
613
- }
614
- /**
615
- * Count the number of a node's neighbours that have been visited by
616
- * frontiers other than the node's own frontier.
617
- *
618
- * A higher count indicates this node is likely to bridge two frontiers,
619
- * making it a strong candidate for path completion.
620
- *
621
- * @param graph - Source graph
622
- * @param nodeId - Node being evaluated
623
- * @param context - Current priority context
624
- * @returns Number of neighbours visited by other frontiers
625
- */
626
- function countCrossFrontierNeighbours(graph, nodeId, context) {
627
- const { frontierIndex, visitedByFrontier } = context;
628
- const nodeNeighbours = new Set(graph.neighbours(nodeId));
629
- let count = 0;
630
- for (const [visitedId, idx] of visitedByFrontier) if (idx !== frontierIndex && nodeNeighbours.has(visitedId)) count++;
631
- return count;
632
- }
633
- /**
634
- * Incrementally update salience counts for paths discovered since the
635
- * last update.
636
- *
637
- * Iterates only over paths from `fromIndex` onwards, avoiding redundant
638
- * re-processing of already-counted paths.
639
- *
640
- * @param salienceCounts - Mutable map of node ID to salience count (mutated in place)
641
- * @param paths - Full list of discovered paths
642
- * @param fromIndex - Index to start counting from (exclusive of earlier paths)
643
- * @returns The new `fromIndex` value (i.e. `paths.length` after update)
644
- */
645
- function updateSalienceCounts(salienceCounts, paths, fromIndex) {
646
- for (let i = fromIndex; i < paths.length; i++) {
647
- const path = paths[i];
648
- if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
649
- }
650
- return paths.length;
651
- }
652
- //#endregion
653
- //#region src/expansion/sage.ts
654
- /**
655
- * Run SAGE expansion algorithm.
656
- *
657
- * Salience-aware multi-frontier expansion with two phases:
658
- * - Phase 1: Degree-based priority (early exploration)
659
- * - Phase 2: Salience feedback (path-aware frontier steering)
660
- *
661
- * @param graph - Source graph
662
- * @param seeds - Seed nodes for expansion
663
- * @param config - Expansion configuration
664
- * @returns Expansion result with discovered paths
665
- */
666
- function sage(graph, seeds, config) {
667
- const salienceCounts = /* @__PURE__ */ new Map();
668
- let inPhase2 = false;
669
- let lastPathCount = 0;
670
- /**
671
- * SAGE priority function with phase transition logic.
672
- */
673
- function sagePriority(nodeId, context) {
674
- const pathCount = context.discoveredPaths.length;
675
- if (pathCount > 0 && !inPhase2) inPhase2 = true;
676
- if (pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
677
- if (!inPhase2) return Math.log(context.degree + 1);
678
- return -((salienceCounts.get(nodeId) ?? 0) * 1e3 - context.degree);
679
- }
680
- return base(graph, seeds, {
681
- ...config,
682
- priority: sagePriority
683
- });
684
- }
685
- /**
686
- * Run SAGE expansion asynchronously.
687
- *
688
- * Creates fresh closure state (salienceCounts, phase tracking) for this
689
- * invocation. The SAGE priority function does not access `context.graph`
690
- * directly, so it is safe to use in async mode via `baseAsync`.
691
- *
692
- * @param graph - Async source graph
693
- * @param seeds - Seed nodes for expansion
694
- * @param config - Expansion and async runner configuration
695
- * @returns Promise resolving to the expansion result
696
- */
697
- async function sageAsync(graph, seeds, config) {
698
- const salienceCounts = /* @__PURE__ */ new Map();
699
- let inPhase2 = false;
700
- let lastPathCount = 0;
701
- function sagePriority(nodeId, context) {
702
- const pathCount = context.discoveredPaths.length;
703
- if (pathCount > 0 && !inPhase2) inPhase2 = true;
704
- if (pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
705
- if (!inPhase2) return Math.log(context.degree + 1);
706
- return -((salienceCounts.get(nodeId) ?? 0) * 1e3 - context.degree);
707
- }
708
- return baseAsync(graph, seeds, {
709
- ...config,
710
- priority: sagePriority
711
- });
712
- }
713
- //#endregion
714
- //#region src/ranking/mi/jaccard.ts
715
- /**
716
- * Compute Jaccard similarity between neighbourhoods of two nodes.
717
- *
718
- * @param graph - Source graph
719
- * @param source - Source node ID
720
- * @param target - Target node ID
721
- * @param config - Optional configuration
722
- * @returns Jaccard coefficient in [0, 1]
723
- */
724
- function jaccard(graph, source, target, config) {
725
- const { epsilon = 1e-10 } = config ?? {};
726
- const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = computeJaccard(graph, source, target);
727
- if (sourceNeighbours.size === 0 && targetNeighbours.size === 0) return 0;
728
- return Math.max(epsilon, jaccardScore);
729
- }
730
- /**
731
- * Async variant of Jaccard similarity for use with async graph data sources.
732
- *
733
- * Fetches both neighbourhoods concurrently, then applies the same formula.
734
- */
735
- async function jaccardAsync(graph, source, target, config) {
736
- const { epsilon = 1e-10 } = config ?? {};
737
- const [sourceNeighboursArr, targetNeighboursArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
738
- const srcSet = new Set(sourceNeighboursArr.filter((n) => n !== target));
739
- const tgtSet = new Set(targetNeighboursArr.filter((n) => n !== source));
740
- if (srcSet.size === 0 && tgtSet.size === 0) return 0;
741
- let intersection = 0;
742
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
743
- const union = srcSet.size + tgtSet.size - intersection;
744
- const jaccardScore = union > 0 ? intersection / union : 0;
745
- return Math.max(epsilon, jaccardScore);
746
- }
747
- //#endregion
748
- //#region src/expansion/reach.ts
749
- /**
750
- * Run REACH expansion algorithm.
751
- *
752
- * Mutual information-aware multi-frontier expansion with two phases:
753
- * - Phase 1: Degree-based priority (early exploration)
754
- * - Phase 2: Structural similarity feedback (MI-guided frontier steering)
755
- *
756
- * @param graph - Source graph
757
- * @param seeds - Seed nodes for expansion
758
- * @param config - Expansion configuration
759
- * @returns Expansion result with discovered paths
760
- */
761
- function reach(graph, seeds, config) {
762
- let inPhase2 = false;
763
- const jaccardCache = /* @__PURE__ */ new Map();
764
- /**
765
- * Compute Jaccard similarity with caching.
766
- *
767
- * Exploits symmetry of Jaccard (J(A,B) = J(B,A)) to reduce
768
- * duplicate computations when the same pair appears in multiple
769
- * discovered paths. Key format ensures consistent ordering.
770
- */
771
- function cachedJaccard(source, target) {
772
- const key = source < target ? `${source}::${target}` : `${target}::${source}`;
773
- let score = jaccardCache.get(key);
774
- if (score === void 0) {
775
- score = jaccard(graph, source, target);
776
- jaccardCache.set(key, score);
777
- }
778
- return score;
779
- }
780
- /**
781
- * REACH priority function with MI estimation.
782
- */
783
- function reachPriority(nodeId, context) {
784
- if (context.discoveredPaths.length > 0 && !inPhase2) inPhase2 = true;
785
- if (!inPhase2) return Math.log(context.degree + 1);
786
- let totalMI = 0;
787
- let endpointCount = 0;
788
- for (const path of context.discoveredPaths) {
789
- const fromNodeId = path.fromSeed.id;
790
- const toNodeId = path.toSeed.id;
791
- totalMI += cachedJaccard(nodeId, fromNodeId);
792
- totalMI += cachedJaccard(nodeId, toNodeId);
793
- endpointCount += 2;
794
- }
795
- const miHat = endpointCount > 0 ? totalMI / endpointCount : 0;
796
- return Math.log(context.degree + 1) * (1 - miHat);
797
- }
798
- return base(graph, seeds, {
799
- ...config,
800
- priority: reachPriority
801
- });
802
- }
803
- /**
804
- * Run REACH expansion asynchronously.
805
- *
806
- * Creates fresh closure state (phase tracking, Jaccard cache) for this
807
- * invocation. The REACH priority function uses `jaccard(context.graph, ...)`
808
- * in Phase 2; in async mode `context.graph` is the sentinel and will throw.
809
- * Full async equivalence requires PriorityContext refactoring (Phase 4b
810
- * deferred). This export establishes the async API surface.
811
- *
812
- * @param graph - Async source graph
813
- * @param seeds - Seed nodes for expansion
814
- * @param config - Expansion and async runner configuration
815
- * @returns Promise resolving to the expansion result
816
- */
817
- async function reachAsync(graph, seeds, config) {
818
- let inPhase2 = false;
819
- function reachPriority(nodeId, context) {
820
- if (context.discoveredPaths.length > 0 && !inPhase2) inPhase2 = true;
821
- if (!inPhase2) return Math.log(context.degree + 1);
822
- let totalMI = 0;
823
- let endpointCount = 0;
824
- for (const path of context.discoveredPaths) {
825
- totalMI += jaccard(context.graph, nodeId, path.fromSeed.id);
826
- totalMI += jaccard(context.graph, nodeId, path.toSeed.id);
827
- endpointCount += 2;
828
- }
829
- const miHat = endpointCount > 0 ? totalMI / endpointCount : 0;
830
- return Math.log(context.degree + 1) * (1 - miHat);
831
- }
832
- return baseAsync(graph, seeds, {
833
- ...config,
834
- priority: reachPriority
835
- });
836
- }
837
- //#endregion
838
- //#region src/expansion/maze.ts
839
- /** Default threshold for switching to phase 2 (after M paths) */
840
- var DEFAULT_PHASE2_THRESHOLD = 1;
841
- /** Salience weighting factor */
842
- var SALIENCE_WEIGHT = 1e3;
843
- /**
844
- * Run MAZE expansion algorithm.
845
- *
846
- * Multi-phase expansion combining path potential and salience with
847
- * adaptive frontier steering.
848
- *
849
- * @param graph - Source graph
850
- * @param seeds - Seed nodes for expansion
851
- * @param config - Expansion configuration
852
- * @returns Expansion result with discovered paths
853
- */
854
- function maze(graph, seeds, config) {
855
- const salienceCounts = /* @__PURE__ */ new Map();
856
- let inPhase2 = false;
857
- let lastPathCount = 0;
858
- /**
859
- * MAZE priority function with path potential and salience feedback.
860
- */
861
- function mazePriority(nodeId, context) {
862
- const pathCount = context.discoveredPaths.length;
863
- if (pathCount >= DEFAULT_PHASE2_THRESHOLD && !inPhase2) {
864
- inPhase2 = true;
865
- updateSalienceCounts(salienceCounts, context.discoveredPaths, 0);
866
- }
867
- if (inPhase2 && pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
868
- const nodeNeighbours = graph.neighbours(nodeId);
869
- let pathPotential = 0;
870
- for (const neighbour of nodeNeighbours) {
871
- const visitedBy = context.visitedByFrontier.get(neighbour);
872
- if (visitedBy !== void 0 && visitedBy !== context.frontierIndex) pathPotential++;
873
- }
874
- if (!inPhase2) return context.degree / (1 + pathPotential);
875
- const salience = salienceCounts.get(nodeId) ?? 0;
876
- return context.degree / (1 + pathPotential) * (1 / (1 + SALIENCE_WEIGHT * salience));
877
- }
878
- return base(graph, seeds, {
879
- ...config,
880
- priority: mazePriority
881
- });
882
- }
883
- /**
884
- * Run MAZE expansion asynchronously.
885
- *
886
- * Creates fresh closure state (salienceCounts, phase tracking) for this
887
- * invocation. The MAZE priority function accesses `context.graph` to
888
- * retrieve neighbour lists for path potential computation. Full async
889
- * equivalence requires PriorityContext refactoring (Phase 4b deferred).
890
- * This export establishes the async API surface.
891
- *
892
- * @param graph - Async source graph
893
- * @param seeds - Seed nodes for expansion
894
- * @param config - Expansion and async runner configuration
895
- * @returns Promise resolving to the expansion result
896
- */
897
- async function mazeAsync(graph, seeds, config) {
898
- const salienceCounts = /* @__PURE__ */ new Map();
899
- let inPhase2 = false;
900
- let lastPathCount = 0;
901
- function mazePriority(nodeId, context) {
902
- const pathCount = context.discoveredPaths.length;
903
- if (pathCount >= DEFAULT_PHASE2_THRESHOLD && !inPhase2) {
904
- inPhase2 = true;
905
- updateSalienceCounts(salienceCounts, context.discoveredPaths, 0);
906
- }
907
- if (inPhase2 && pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
908
- const nodeNeighbours = context.graph.neighbours(nodeId);
909
- let pathPotential = 0;
910
- for (const neighbour of nodeNeighbours) {
911
- const visitedBy = context.visitedByFrontier.get(neighbour);
912
- if (visitedBy !== void 0 && visitedBy !== context.frontierIndex) pathPotential++;
913
- }
914
- if (!inPhase2) return context.degree / (1 + pathPotential);
915
- const salience = salienceCounts.get(nodeId) ?? 0;
916
- return context.degree / (1 + pathPotential) * (1 / (1 + SALIENCE_WEIGHT * salience));
917
- }
918
- return baseAsync(graph, seeds, {
919
- ...config,
920
- priority: mazePriority
921
- });
922
- }
923
- //#endregion
924
- //#region src/expansion/tide.ts
925
- /**
926
- * TIDE priority function.
927
- *
928
- * Priority = degree(source) + degree(target)
929
- * Lower values = higher priority (explored first)
930
- */
931
- function tidePriority(nodeId, context) {
932
- const graph = context.graph;
933
- let totalDegree = context.degree;
934
- for (const neighbour of graph.neighbours(nodeId)) totalDegree += graph.degree(neighbour);
935
- return totalDegree;
936
- }
937
- /**
938
- * Run TIDE expansion algorithm.
939
- *
940
- * Expands from seeds prioritising low-degree edges first.
941
- * Useful for avoiding hubs and exploring sparse regions.
942
- *
943
- * @param graph - Source graph
944
- * @param seeds - Seed nodes for expansion
945
- * @param config - Expansion configuration
946
- * @returns Expansion result with discovered paths
947
- */
948
- function tide(graph, seeds, config) {
949
- return base(graph, seeds, {
950
- ...config,
951
- priority: tidePriority
952
- });
953
- }
954
- /**
955
- * Run TIDE expansion asynchronously.
956
- *
957
- * Note: the TIDE priority function accesses `context.graph` to retrieve
958
- * neighbour lists and per-neighbour degrees. Full async equivalence
959
- * requires PriorityContext refactoring (Phase 4b deferred). This export
960
- * establishes the async API surface.
961
- *
962
- * @param graph - Async source graph
963
- * @param seeds - Seed nodes for expansion
964
- * @param config - Expansion and async runner configuration
965
- * @returns Promise resolving to the expansion result
966
- */
967
- async function tideAsync(graph, seeds, config) {
968
- return baseAsync(graph, seeds, {
969
- ...config,
970
- priority: tidePriority
971
- });
972
- }
973
- //#endregion
974
- //#region src/expansion/lace.ts
975
- /**
976
- * LACE priority function.
977
- *
978
- * Priority = 1 - avgMI(node, same-frontier visited nodes)
979
- * Higher average MI = lower priority value = explored first
980
- */
981
- function lacePriority(nodeId, context, mi) {
982
- return 1 - avgFrontierMI(context.graph, nodeId, context, mi);
983
- }
984
- /**
985
- * Run LACE expansion algorithm.
986
- *
987
- * Expands from seeds prioritising high-MI edges.
988
- * Useful for finding paths with strong semantic associations.
989
- *
990
- * @param graph - Source graph
991
- * @param seeds - Seed nodes for expansion
992
- * @param config - Expansion configuration with MI function
993
- * @returns Expansion result with discovered paths
994
- */
995
- function lace(graph, seeds, config) {
996
- const { mi = jaccard, ...restConfig } = config ?? {};
997
- const priority = (nodeId, context) => lacePriority(nodeId, context, mi);
998
- return base(graph, seeds, {
999
- ...restConfig,
1000
- priority
1001
- });
1002
- }
1003
- /**
1004
- * Run LACE expansion asynchronously.
1005
- *
1006
- * Note: the LACE priority function accesses `context.graph` via
1007
- * `avgFrontierMI`. Full async equivalence requires PriorityContext
1008
- * refactoring (Phase 4b deferred). This export establishes the async
1009
- * API surface.
1010
- *
1011
- * @param graph - Async source graph
1012
- * @param seeds - Seed nodes for expansion
1013
- * @param config - LACE configuration combined with async runner options
1014
- * @returns Promise resolving to the expansion result
1015
- */
1016
- async function laceAsync(graph, seeds, config) {
1017
- const { mi = jaccard, ...restConfig } = config ?? {};
1018
- const priority = (nodeId, context) => lacePriority(nodeId, context, mi);
1019
- return baseAsync(graph, seeds, {
1020
- ...restConfig,
1021
- priority
1022
- });
1023
- }
1024
- //#endregion
1025
- //#region src/expansion/warp.ts
1026
- /**
1027
- * WARP priority function.
1028
- *
1029
- * Priority = 1 / (1 + bridge_score)
1030
- * Bridge score = cross-frontier neighbour count plus bonus for nodes
1031
- * already on discovered paths.
1032
- * Higher bridge score = more likely to complete paths = explored first.
1033
- */
1034
- function warpPriority(nodeId, context) {
1035
- let bridgeScore = countCrossFrontierNeighbours(context.graph, nodeId, context);
1036
- for (const path of context.discoveredPaths) if (path.nodes.includes(nodeId)) bridgeScore += 2;
1037
- return 1 / (1 + bridgeScore);
1038
- }
1039
- /**
1040
- * Run WARP expansion algorithm.
1041
- *
1042
- * Expands from seeds prioritising bridge nodes.
1043
- * Useful for finding paths through structurally important nodes.
1044
- *
1045
- * @param graph - Source graph
1046
- * @param seeds - Seed nodes for expansion
1047
- * @param config - Expansion configuration
1048
- * @returns Expansion result with discovered paths
1049
- */
1050
- function warp(graph, seeds, config) {
1051
- return base(graph, seeds, {
1052
- ...config,
1053
- priority: warpPriority
1054
- });
1055
- }
1056
- /**
1057
- * Run WARP expansion asynchronously.
1058
- *
1059
- * Note: the WARP priority function accesses `context.graph` via
1060
- * `countCrossFrontierNeighbours`. Full async equivalence requires
1061
- * PriorityContext refactoring (Phase 4b deferred). This export
1062
- * establishes the async API surface.
1063
- *
1064
- * @param graph - Async source graph
1065
- * @param seeds - Seed nodes for expansion
1066
- * @param config - Expansion and async runner configuration
1067
- * @returns Promise resolving to the expansion result
1068
- */
1069
- async function warpAsync(graph, seeds, config) {
1070
- return baseAsync(graph, seeds, {
1071
- ...config,
1072
- priority: warpPriority
1073
- });
1074
- }
1075
- //#endregion
1076
- //#region src/expansion/fuse.ts
1077
- /**
1078
- * FUSE priority function.
1079
- *
1080
- * Combines degree with average frontier MI as a salience proxy:
1081
- * Priority = (1 - w) * degree + w * (1 - avgMI)
1082
- * Lower values = higher priority; high salience lowers priority
1083
- */
1084
- function fusePriority(nodeId, context, mi, salienceWeight) {
1085
- const avgSalience = avgFrontierMI(context.graph, nodeId, context, mi);
1086
- return (1 - salienceWeight) * context.degree + salienceWeight * (1 - avgSalience);
1087
- }
1088
- /**
1089
- * Run FUSE expansion algorithm.
1090
- *
1091
- * Combines structural exploration with semantic salience.
1092
- * Useful for finding paths that are both short and semantically meaningful.
1093
- *
1094
- * @param graph - Source graph
1095
- * @param seeds - Seed nodes for expansion
1096
- * @param config - Expansion configuration with MI function
1097
- * @returns Expansion result with discovered paths
1098
- */
1099
- function fuse(graph, seeds, config) {
1100
- const { mi = jaccard, salienceWeight = .5, ...restConfig } = config ?? {};
1101
- const priority = (nodeId, context) => fusePriority(nodeId, context, mi, salienceWeight);
1102
- return base(graph, seeds, {
1103
- ...restConfig,
1104
- priority
1105
- });
1106
- }
1107
- /**
1108
- * Run FUSE expansion asynchronously.
1109
- *
1110
- * Note: the FUSE priority function accesses `context.graph` via
1111
- * `avgFrontierMI`. Full async equivalence requires PriorityContext
1112
- * refactoring (Phase 4b deferred). This export establishes the async
1113
- * API surface.
1114
- *
1115
- * @param graph - Async source graph
1116
- * @param seeds - Seed nodes for expansion
1117
- * @param config - FUSE configuration combined with async runner options
1118
- * @returns Promise resolving to the expansion result
1119
- */
1120
- async function fuseAsync(graph, seeds, config) {
1121
- const { mi = jaccard, salienceWeight = .5, ...restConfig } = config ?? {};
1122
- const priority = (nodeId, context) => fusePriority(nodeId, context, mi, salienceWeight);
1123
- return baseAsync(graph, seeds, {
1124
- ...restConfig,
1125
- priority
1126
- });
1127
- }
1128
- //#endregion
1129
- //#region src/expansion/sift.ts
1130
- /**
1131
- * REACH (SIFT) priority function.
1132
- *
1133
- * Prioritises nodes with average frontier MI above the threshold;
1134
- * falls back to degree-based ordering for those below it.
1135
- */
1136
- function siftPriority(nodeId, context, mi, miThreshold) {
1137
- const avgMi = avgFrontierMI(context.graph, nodeId, context, mi);
1138
- if (avgMi >= miThreshold) return 1 - avgMi;
1139
- else return context.degree + 100;
1140
- }
1141
- /**
1142
- * Run SIFT expansion algorithm.
1143
- *
1144
- * Two-phase adaptive expansion that learns MI thresholds
1145
- * from initial sampling, then uses them for guided expansion.
1146
- *
1147
- * @param graph - Source graph
1148
- * @param seeds - Seed nodes for expansion
1149
- * @param config - Expansion configuration
1150
- * @returns Expansion result with discovered paths
1151
- */
1152
- function sift(graph, seeds, config) {
1153
- const { mi = jaccard, miThreshold = .25, ...restConfig } = config ?? {};
1154
- const priority = (nodeId, context) => siftPriority(nodeId, context, mi, miThreshold);
1155
- return base(graph, seeds, {
1156
- ...restConfig,
1157
- priority
1158
- });
1159
- }
1160
- /**
1161
- * Run SIFT expansion asynchronously.
1162
- *
1163
- * Note: the SIFT priority function accesses `context.graph` via
1164
- * `avgFrontierMI`. Full async equivalence requires PriorityContext
1165
- * refactoring (Phase 4b deferred). This export establishes the async
1166
- * API surface.
1167
- *
1168
- * @param graph - Async source graph
1169
- * @param seeds - Seed nodes for expansion
1170
- * @param config - SIFT (REACHConfig) configuration combined with async runner options
1171
- * @returns Promise resolving to the expansion result
1172
- */
1173
- async function siftAsync(graph, seeds, config) {
1174
- const { mi = jaccard, miThreshold = .25, ...restConfig } = config ?? {};
1175
- const priority = (nodeId, context) => siftPriority(nodeId, context, mi, miThreshold);
1176
- return baseAsync(graph, seeds, {
1177
- ...restConfig,
1178
- priority
1179
- });
1180
- }
1181
- //#endregion
1182
- //#region src/expansion/flux.ts
1183
- /**
1184
- * Compute local density around a node.
1185
- */
1186
- function localDensity(graph, nodeId) {
1187
- const neighbours = Array.from(graph.neighbours(nodeId));
1188
- const degree = neighbours.length;
1189
- if (degree < 2) return 0;
1190
- let edges = 0;
1191
- for (let i = 0; i < neighbours.length; i++) for (let j = i + 1; j < neighbours.length; j++) {
1192
- const ni = neighbours[i];
1193
- const nj = neighbours[j];
1194
- if (ni !== void 0 && nj !== void 0 && graph.getEdge(ni, nj) !== void 0) edges++;
1195
- }
1196
- const maxEdges = degree * (degree - 1) / 2;
1197
- return edges / maxEdges;
1198
- }
1199
- /**
1200
- * MAZE adaptive priority function.
1201
- *
1202
- * Switches strategies based on local conditions:
1203
- * - High density + low bridge: EDGE mode
1204
- * - Low density + low bridge: DOME mode
1205
- * - High bridge score: PIPE mode
1206
- */
1207
- function fluxPriority(nodeId, context, densityThreshold, bridgeThreshold) {
1208
- const graph = context.graph;
1209
- const degree = context.degree;
1210
- const density = localDensity(graph, nodeId);
1211
- const bridge = countCrossFrontierNeighbours(graph, nodeId, context);
1212
- const numFrontiers = new Set(context.visitedByFrontier.values()).size;
1213
- if ((numFrontiers > 0 ? bridge / numFrontiers : 0) >= bridgeThreshold) return 1 / (1 + bridge);
1214
- else if (density >= densityThreshold) return 1 / (degree + 1);
1215
- else return degree;
1216
- }
1217
- /**
1218
- * Run FLUX expansion algorithm.
1219
- *
1220
- * Adaptively switches between expansion strategies based on
1221
- * local graph structure. Useful for heterogeneous graphs
1222
- * with varying density.
1223
- *
1224
- * @param graph - Source graph
1225
- * @param seeds - Seed nodes for expansion
1226
- * @param config - Expansion configuration
1227
- * @returns Expansion result with discovered paths
1228
- */
1229
- function flux(graph, seeds, config) {
1230
- const { densityThreshold = .5, bridgeThreshold = .3, ...restConfig } = config ?? {};
1231
- const priority = (nodeId, context) => fluxPriority(nodeId, context, densityThreshold, bridgeThreshold);
1232
- return base(graph, seeds, {
1233
- ...restConfig,
1234
- priority
1235
- });
1236
- }
1237
- /**
1238
- * Run FLUX expansion asynchronously.
1239
- *
1240
- * Note: the FLUX priority function accesses `context.graph` to compute
1241
- * local density and cross-frontier bridge scores. Full async equivalence
1242
- * requires PriorityContext refactoring (Phase 4b deferred). This export
1243
- * establishes the async API surface.
1244
- *
1245
- * @param graph - Async source graph
1246
- * @param seeds - Seed nodes for expansion
1247
- * @param config - FLUX (MAZEConfig) configuration combined with async runner options
1248
- * @returns Promise resolving to the expansion result
1249
- */
1250
- async function fluxAsync(graph, seeds, config) {
1251
- const { densityThreshold = .5, bridgeThreshold = .3, ...restConfig } = config ?? {};
1252
- const priority = (nodeId, context) => fluxPriority(nodeId, context, densityThreshold, bridgeThreshold);
1253
- return baseAsync(graph, seeds, {
1254
- ...restConfig,
1255
- priority
1256
- });
1257
- }
1258
- //#endregion
1259
- //#region src/expansion/standard-bfs.ts
1260
- /**
1261
- * BFS priority: discovery iteration order (FIFO).
1262
- */
1263
- function bfsPriority(_nodeId, context) {
1264
- return context.iteration;
1265
- }
1266
- /**
1267
- * Run standard BFS expansion (FIFO discovery order).
1268
- *
1269
- * @param graph - Source graph
1270
- * @param seeds - Seed nodes for expansion
1271
- * @param config - Expansion configuration
1272
- * @returns Expansion result with discovered paths
1273
- */
1274
- function standardBfs(graph, seeds, config) {
1275
- return base(graph, seeds, {
1276
- ...config,
1277
- priority: bfsPriority
1278
- });
1279
- }
1280
- /**
1281
- * Run standard BFS expansion asynchronously (FIFO discovery order).
1282
- *
1283
- * @param graph - Async source graph
1284
- * @param seeds - Seed nodes for expansion
1285
- * @param config - Expansion and async runner configuration
1286
- * @returns Promise resolving to the expansion result
1287
- */
1288
- async function standardBfsAsync(graph, seeds, config) {
1289
- return baseAsync(graph, seeds, {
1290
- ...config,
1291
- priority: bfsPriority
1292
- });
1293
- }
1294
- //#endregion
1295
- //#region src/expansion/frontier-balanced.ts
1296
- /**
1297
- * Frontier-balanced priority: frontier index dominates, then discovery iteration.
1298
- * Scales frontier index by 1e9 to ensure round-robin ordering across frontiers.
1299
- */
1300
- function balancedPriority(_nodeId, context) {
1301
- return context.frontierIndex * 1e9 + context.iteration;
1302
- }
1303
- /**
1304
- * Run frontier-balanced expansion (round-robin across frontiers).
1305
- *
1306
- * @param graph - Source graph
1307
- * @param seeds - Seed nodes for expansion
1308
- * @param config - Expansion configuration
1309
- * @returns Expansion result with discovered paths
1310
- */
1311
- function frontierBalanced(graph, seeds, config) {
1312
- return base(graph, seeds, {
1313
- ...config,
1314
- priority: balancedPriority
1315
- });
1316
- }
1317
- /**
1318
- * Run frontier-balanced expansion asynchronously (round-robin across frontiers).
1319
- *
1320
- * @param graph - Async source graph
1321
- * @param seeds - Seed nodes for expansion
1322
- * @param config - Expansion and async runner configuration
1323
- * @returns Promise resolving to the expansion result
1324
- */
1325
- async function frontierBalancedAsync(graph, seeds, config) {
1326
- return baseAsync(graph, seeds, {
1327
- ...config,
1328
- priority: balancedPriority
1329
- });
1330
- }
1331
- //#endregion
1332
- //#region src/expansion/random-priority.ts
1333
- /**
1334
- * Deterministic seeded random number generator.
1335
- * Uses FNV-1a-like hash for input → [0, 1] output.
1336
- *
1337
- * @param input - String to hash
1338
- * @param seed - Random seed for reproducibility
1339
- * @returns Deterministic random value in [0, 1]
1340
- */
1341
- function seededRandom$1(input, seed = 0) {
1342
- let h = seed;
1343
- for (let i = 0; i < input.length; i++) {
1344
- h = Math.imul(h ^ input.charCodeAt(i), 2654435769);
1345
- h ^= h >>> 16;
1346
- }
1347
- return (h >>> 0) / 4294967295;
1348
- }
1349
- /**
1350
- * Build a seeded random priority function for a given seed value.
1351
- */
1352
- function makeRandomPriorityFn(seed) {
1353
- return (nodeId) => seededRandom$1(nodeId, seed);
1354
- }
1355
- /**
1356
- * Run random-priority expansion (null hypothesis baseline).
1357
- *
1358
- * @param graph - Source graph
1359
- * @param seeds - Seed nodes for expansion
1360
- * @param config - Expansion configuration
1361
- * @returns Expansion result with discovered paths
1362
- */
1363
- function randomPriority(graph, seeds, config) {
1364
- const { seed = 0 } = config ?? {};
1365
- return base(graph, seeds, {
1366
- ...config,
1367
- priority: makeRandomPriorityFn(seed)
1368
- });
1369
- }
1370
- /**
1371
- * Run random-priority expansion asynchronously (null hypothesis baseline).
1372
- *
1373
- * @param graph - Async source graph
1374
- * @param seeds - Seed nodes for expansion
1375
- * @param config - Expansion and async runner configuration
1376
- * @returns Promise resolving to the expansion result
1377
- */
1378
- async function randomPriorityAsync(graph, seeds, config) {
1379
- const { seed = 0 } = config ?? {};
1380
- return baseAsync(graph, seeds, {
1381
- ...config,
1382
- priority: makeRandomPriorityFn(seed)
1383
- });
1384
- }
1385
- //#endregion
1386
- //#region src/expansion/dfs-priority.ts
1387
- /**
1388
- * DFS priority function: negative iteration produces LIFO ordering.
1389
- *
1390
- * Lower priority values are expanded first, so negating the iteration
1391
- * counter ensures the most recently enqueued node is always next.
1392
- */
1393
- function dfsPriorityFn(_nodeId, context) {
1394
- return -context.iteration;
1395
- }
1396
- /**
1397
- * Run DFS-priority expansion (LIFO discovery order).
1398
- *
1399
- * Uses the BASE framework with a negative-iteration priority function,
1400
- * which causes the most recently discovered node to be expanded first —
1401
- * equivalent to depth-first search behaviour.
1402
- *
1403
- * @param graph - Source graph
1404
- * @param seeds - Seed nodes for expansion
1405
- * @param config - Expansion configuration
1406
- * @returns Expansion result with discovered paths
1407
- */
1408
- function dfsPriority(graph, seeds, config) {
1409
- return base(graph, seeds, {
1410
- ...config,
1411
- priority: dfsPriorityFn
1412
- });
1413
- }
1414
- /**
1415
- * Run DFS-priority expansion asynchronously (LIFO discovery order).
1416
- *
1417
- * @param graph - Async source graph
1418
- * @param seeds - Seed nodes for expansion
1419
- * @param config - Expansion and async runner configuration
1420
- * @returns Promise resolving to the expansion result
1421
- */
1422
- async function dfsPriorityAsync(graph, seeds, config) {
1423
- return baseAsync(graph, seeds, {
1424
- ...config,
1425
- priority: dfsPriorityFn
1426
- });
1427
- }
1428
- //#endregion
1429
- //#region src/expansion/k-hop.ts
1430
- /**
1431
- * Run k-hop expansion (fixed-depth BFS).
1432
- *
1433
- * Explores all nodes reachable within exactly k hops of any seed using
1434
- * breadth-first search. Paths between seeds are detected when a node
1435
- * is reached by frontiers from two different seeds.
1436
- *
1437
- * @param graph - Source graph
1438
- * @param seeds - Seed nodes for expansion
1439
- * @param config - K-hop configuration (k defaults to 2)
1440
- * @returns Expansion result with discovered paths
1441
- */
1442
- function kHop(graph, seeds, config) {
1443
- const startTime = performance.now();
1444
- const { k = 2 } = config ?? {};
1445
- if (seeds.length === 0) return emptyResult$1(startTime);
1446
- const visitedByFrontier = seeds.map(() => /* @__PURE__ */ new Map());
1447
- const firstVisitedBy = /* @__PURE__ */ new Map();
1448
- const allVisited = /* @__PURE__ */ new Set();
1449
- const sampledEdgeMap = /* @__PURE__ */ new Map();
1450
- const discoveredPaths = [];
1451
- let iterations = 0;
1452
- let edgesTraversed = 0;
1453
- for (let i = 0; i < seeds.length; i++) {
1454
- const seed = seeds[i];
1455
- if (seed === void 0) continue;
1456
- if (!graph.hasNode(seed.id)) continue;
1457
- visitedByFrontier[i]?.set(seed.id, null);
1458
- allVisited.add(seed.id);
1459
- if (!firstVisitedBy.has(seed.id)) firstVisitedBy.set(seed.id, i);
1460
- else {
1461
- const otherIdx = firstVisitedBy.get(seed.id) ?? -1;
1462
- if (otherIdx < 0) continue;
1463
- const fromSeed = seeds[otherIdx];
1464
- const toSeed = seeds[i];
1465
- if (fromSeed !== void 0 && toSeed !== void 0) discoveredPaths.push({
1466
- fromSeed,
1467
- toSeed,
1468
- nodes: [seed.id]
1469
- });
1470
- }
1471
- }
1472
- let currentLevel = seeds.map((s, i) => {
1473
- const frontier = visitedByFrontier[i];
1474
- if (frontier === void 0) return [];
1475
- return frontier.has(s.id) ? [s.id] : [];
1476
- });
1477
- for (let hop = 0; hop < k; hop++) {
1478
- const nextLevel = seeds.map(() => []);
1479
- for (let i = 0; i < seeds.length; i++) {
1480
- const level = currentLevel[i];
1481
- if (level === void 0) continue;
1482
- const frontierVisited = visitedByFrontier[i];
1483
- if (frontierVisited === void 0) continue;
1484
- for (const nodeId of level) {
1485
- iterations++;
1486
- for (const neighbour of graph.neighbours(nodeId)) {
1487
- edgesTraversed++;
1488
- const [s, t] = nodeId < neighbour ? [nodeId, neighbour] : [neighbour, nodeId];
1489
- let targets = sampledEdgeMap.get(s);
1490
- if (targets === void 0) {
1491
- targets = /* @__PURE__ */ new Set();
1492
- sampledEdgeMap.set(s, targets);
1493
- }
1494
- targets.add(t);
1495
- if (frontierVisited.has(neighbour)) continue;
1496
- frontierVisited.set(neighbour, nodeId);
1497
- allVisited.add(neighbour);
1498
- nextLevel[i]?.push(neighbour);
1499
- const previousFrontier = firstVisitedBy.get(neighbour);
1500
- if (previousFrontier !== void 0 && previousFrontier !== i) {
1501
- const fromSeed = seeds[previousFrontier];
1502
- const toSeed = seeds[i];
1503
- if (fromSeed !== void 0 && toSeed !== void 0) {
1504
- const path = reconstructPath(neighbour, previousFrontier, i, visitedByFrontier, seeds);
1505
- if (path !== null) {
1506
- if (!discoveredPaths.some((p) => p.fromSeed.id === fromSeed.id && p.toSeed.id === toSeed.id || p.fromSeed.id === toSeed.id && p.toSeed.id === fromSeed.id)) discoveredPaths.push(path);
1507
- }
1508
- }
1509
- }
1510
- if (!firstVisitedBy.has(neighbour)) firstVisitedBy.set(neighbour, i);
1511
- }
1512
- }
1513
- }
1514
- currentLevel = nextLevel;
1515
- if (currentLevel.every((level) => level.length === 0)) break;
1516
- }
1517
- const endTime = performance.now();
1518
- const edgeTuples = /* @__PURE__ */ new Set();
1519
- for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
1520
- return {
1521
- paths: discoveredPaths,
1522
- sampledNodes: allVisited,
1523
- sampledEdges: edgeTuples,
1524
- visitedPerFrontier: visitedByFrontier.map((m) => new Set(m.keys())),
1525
- stats: {
1526
- iterations,
1527
- nodesVisited: allVisited.size,
1528
- edgesTraversed,
1529
- pathsFound: discoveredPaths.length,
1530
- durationMs: endTime - startTime,
1531
- algorithm: "k-hop",
1532
- termination: "exhausted"
1533
- }
1534
- };
1535
- }
1536
- /**
1537
- * Reconstruct the path between two colliding frontiers.
1538
- */
1539
- function reconstructPath(collisionNode, frontierA, frontierB, visitedByFrontier, seeds) {
1540
- const seedA = seeds[frontierA];
1541
- const seedB = seeds[frontierB];
1542
- if (seedA === void 0 || seedB === void 0) return null;
1543
- const pathA = [collisionNode];
1544
- const predA = visitedByFrontier[frontierA];
1545
- if (predA !== void 0) {
1546
- let node = collisionNode;
1547
- let pred = predA.get(node);
1548
- while (pred !== null && pred !== void 0) {
1549
- pathA.unshift(pred);
1550
- node = pred;
1551
- pred = predA.get(node);
1552
- }
1553
- }
1554
- const pathB = [];
1555
- const predB = visitedByFrontier[frontierB];
1556
- if (predB !== void 0) {
1557
- let node = collisionNode;
1558
- let pred = predB.get(node);
1559
- while (pred !== null && pred !== void 0) {
1560
- pathB.push(pred);
1561
- node = pred;
1562
- pred = predB.get(node);
1563
- }
1564
- }
1565
- return {
1566
- fromSeed: seedA,
1567
- toSeed: seedB,
1568
- nodes: [...pathA, ...pathB]
1569
- };
1570
- }
1571
- /**
1572
- * Create an empty result for early termination (no seeds).
1573
- */
1574
- function emptyResult$1(startTime) {
1575
- return {
1576
- paths: [],
1577
- sampledNodes: /* @__PURE__ */ new Set(),
1578
- sampledEdges: /* @__PURE__ */ new Set(),
1579
- visitedPerFrontier: [],
1580
- stats: {
1581
- iterations: 0,
1582
- nodesVisited: 0,
1583
- edgesTraversed: 0,
1584
- pathsFound: 0,
1585
- durationMs: performance.now() - startTime,
1586
- algorithm: "k-hop",
1587
- termination: "exhausted"
1588
- }
1589
- };
1590
- }
1591
- //#endregion
1592
- //#region src/expansion/random-walk.ts
1593
- /**
1594
- * Mulberry32 seeded PRNG — fast, compact, and high-quality for simulation.
1595
- *
1596
- * Returns a closure that yields the next pseudo-random value in [0, 1)
1597
- * on each call.
1598
- *
1599
- * @param seed - 32-bit integer seed
1600
- */
1601
- function mulberry32(seed) {
1602
- let s = seed;
1603
- return () => {
1604
- s += 1831565813;
1605
- let t = s;
1606
- t = Math.imul(t ^ t >>> 15, t | 1);
1607
- t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1608
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
1609
- };
1610
- }
1611
- /**
1612
- * Run random-walk-with-restart expansion.
1613
- *
1614
- * For each seed, performs `walks` independent random walks of up to
1615
- * `walkLength` steps. At each step the walk either restarts (with
1616
- * probability `restartProbability`) or moves to a uniformly sampled
1617
- * neighbour. All visited nodes and traversed edges are collected.
1618
- *
1619
- * Inter-seed paths are detected when a walk reaches a node that was
1620
- * previously reached by a walk originating from a different seed.
1621
- * The recorded path contains only the two seed endpoints rather than
1622
- * the full walk trajectory, consistent with the ExpansionPath contract.
1623
- *
1624
- * @param graph - Source graph
1625
- * @param seeds - Seed nodes for expansion
1626
- * @param config - Random walk configuration
1627
- * @returns Expansion result with discovered paths
1628
- */
1629
- function randomWalk(graph, seeds, config) {
1630
- const startTime = performance.now();
1631
- const { restartProbability = .15, walks = 10, walkLength = 20, seed = 0 } = config ?? {};
1632
- if (seeds.length === 0) return emptyResult(startTime);
1633
- const rand = mulberry32(seed);
1634
- const firstVisitedBySeed = /* @__PURE__ */ new Map();
1635
- const allVisited = /* @__PURE__ */ new Set();
1636
- const sampledEdgeMap = /* @__PURE__ */ new Map();
1637
- const discoveredPaths = [];
1638
- let iterations = 0;
1639
- let edgesTraversed = 0;
1640
- const visitedPerFrontier = seeds.map(() => /* @__PURE__ */ new Set());
1641
- for (let seedIdx = 0; seedIdx < seeds.length; seedIdx++) {
1642
- const seed_ = seeds[seedIdx];
1643
- if (seed_ === void 0) continue;
1644
- const seedId = seed_.id;
1645
- if (!graph.hasNode(seedId)) continue;
1646
- if (!firstVisitedBySeed.has(seedId)) firstVisitedBySeed.set(seedId, seedIdx);
1647
- allVisited.add(seedId);
1648
- visitedPerFrontier[seedIdx]?.add(seedId);
1649
- for (let w = 0; w < walks; w++) {
1650
- let current = seedId;
1651
- for (let step = 0; step < walkLength; step++) {
1652
- iterations++;
1653
- if (rand() < restartProbability) {
1654
- current = seedId;
1655
- continue;
1656
- }
1657
- const neighbourList = [];
1658
- for (const nb of graph.neighbours(current)) neighbourList.push(nb);
1659
- if (neighbourList.length === 0) {
1660
- current = seedId;
1661
- continue;
1662
- }
1663
- const next = neighbourList[Math.floor(rand() * neighbourList.length)];
1664
- if (next === void 0) {
1665
- current = seedId;
1666
- continue;
1667
- }
1668
- edgesTraversed++;
1669
- const [s, t] = current < next ? [current, next] : [next, current];
1670
- let targets = sampledEdgeMap.get(s);
1671
- if (targets === void 0) {
1672
- targets = /* @__PURE__ */ new Set();
1673
- sampledEdgeMap.set(s, targets);
1674
- }
1675
- targets.add(t);
1676
- const previousSeedIdx = firstVisitedBySeed.get(next);
1677
- if (previousSeedIdx !== void 0 && previousSeedIdx !== seedIdx) {
1678
- const fromSeed = seeds[previousSeedIdx];
1679
- const toSeed = seeds[seedIdx];
1680
- if (fromSeed !== void 0 && toSeed !== void 0) {
1681
- const path = {
1682
- fromSeed,
1683
- toSeed,
1684
- nodes: [
1685
- fromSeed.id,
1686
- next,
1687
- toSeed.id
1688
- ].filter((n, i, arr) => arr.indexOf(n) === i)
1689
- };
1690
- if (!discoveredPaths.some((p) => p.fromSeed.id === fromSeed.id && p.toSeed.id === toSeed.id || p.fromSeed.id === toSeed.id && p.toSeed.id === fromSeed.id)) discoveredPaths.push(path);
1691
- }
1692
- }
1693
- if (!firstVisitedBySeed.has(next)) firstVisitedBySeed.set(next, seedIdx);
1694
- allVisited.add(next);
1695
- visitedPerFrontier[seedIdx]?.add(next);
1696
- current = next;
1697
- }
1698
- }
1699
- }
1700
- const endTime = performance.now();
1701
- const edgeTuples = /* @__PURE__ */ new Set();
1702
- for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
1703
- return {
1704
- paths: discoveredPaths,
1705
- sampledNodes: allVisited,
1706
- sampledEdges: edgeTuples,
1707
- visitedPerFrontier,
1708
- stats: {
1709
- iterations,
1710
- nodesVisited: allVisited.size,
1711
- edgesTraversed,
1712
- pathsFound: discoveredPaths.length,
1713
- durationMs: endTime - startTime,
1714
- algorithm: "random-walk",
1715
- termination: "exhausted"
1716
- }
1717
- };
1718
- }
1719
- /**
1720
- * Create an empty result for early termination (no seeds).
1721
- */
1722
- function emptyResult(startTime) {
1723
- return {
1724
- paths: [],
1725
- sampledNodes: /* @__PURE__ */ new Set(),
1726
- sampledEdges: /* @__PURE__ */ new Set(),
1727
- visitedPerFrontier: [],
1728
- stats: {
1729
- iterations: 0,
1730
- nodesVisited: 0,
1731
- edgesTraversed: 0,
1732
- pathsFound: 0,
1733
- durationMs: performance.now() - startTime,
1734
- algorithm: "random-walk",
1735
- termination: "exhausted"
1736
- }
1737
- };
1738
- }
1739
- //#endregion
1740
- //#region src/ranking/parse.ts
1741
- /**
1742
- * Rank paths using PARSE (Path-Aware Ranking via Salience Estimation).
1743
- *
1744
- * Computes geometric mean of edge MI scores for each path,
1745
- * then sorts by salience (highest first).
1746
- *
1747
- * @param graph - Source graph
1748
- * @param paths - Paths to rank
1749
- * @param config - Configuration options
1750
- * @returns Ranked paths with statistics
1751
- */
1752
- function parse(graph, paths, config) {
1753
- const startTime = performance.now();
1754
- const { mi = jaccard, epsilon = 1e-10 } = config ?? {};
1755
- const rankedPaths = [];
1756
- for (const path of paths) {
1757
- const salience = computePathSalience(graph, path, mi, epsilon);
1758
- rankedPaths.push({
1759
- ...path,
1760
- salience
1761
- });
1762
- }
1763
- rankedPaths.sort((a, b) => b.salience - a.salience);
1764
- const endTime = performance.now();
1765
- const saliences = rankedPaths.map((p) => p.salience);
1766
- const meanSalience = saliences.length > 0 ? saliences.reduce((a, b) => a + b, 0) / saliences.length : 0;
1767
- const sortedSaliences = [...saliences].sort((a, b) => a - b);
1768
- const mid = Math.floor(sortedSaliences.length / 2);
1769
- const medianSalience = sortedSaliences.length > 0 ? sortedSaliences.length % 2 !== 0 ? sortedSaliences[mid] ?? 0 : ((sortedSaliences[mid - 1] ?? 0) + (sortedSaliences[mid] ?? 0)) / 2 : 0;
1770
- const maxSalience = sortedSaliences.length > 0 ? sortedSaliences[sortedSaliences.length - 1] ?? 0 : 0;
1771
- const minSalience = sortedSaliences.length > 0 ? sortedSaliences[0] ?? 0 : 0;
1772
- return {
1773
- paths: rankedPaths,
1774
- stats: {
1775
- pathsRanked: rankedPaths.length,
1776
- meanSalience,
1777
- medianSalience,
1778
- maxSalience,
1779
- minSalience,
1780
- durationMs: endTime - startTime
1781
- }
1782
- };
1783
- }
1784
- /**
1785
- * Rank paths using async PARSE (Path-Aware Ranking via Salience Estimation).
1786
- *
1787
- * Async variant suitable for use with remote or lazy graph data sources.
1788
- * Computes geometric mean of edge MI scores for each path using Promise.all
1789
- * for parallelism, then sorts by salience (highest first).
1790
- *
1791
- * @param graph - Async source graph
1792
- * @param paths - Paths to rank
1793
- * @param config - Configuration options
1794
- * @returns Ranked paths with statistics
1795
- */
1796
- async function parseAsync(graph, paths, config) {
1797
- const startTime = performance.now();
1798
- const { mi = jaccardAsync, epsilon = 1e-10 } = config ?? {};
1799
- const rankedPaths = [];
1800
- for (const path of paths) {
1801
- const salience = await computePathSalienceAsync(graph, path, mi, epsilon);
1802
- rankedPaths.push({
1803
- ...path,
1804
- salience
1805
- });
1806
- }
1807
- rankedPaths.sort((a, b) => b.salience - a.salience);
1808
- const endTime = performance.now();
1809
- const saliences = rankedPaths.map((p) => p.salience);
1810
- const meanSalience = saliences.length > 0 ? saliences.reduce((a, b) => a + b, 0) / saliences.length : 0;
1811
- const sortedSaliences = [...saliences].sort((a, b) => a - b);
1812
- const mid = Math.floor(sortedSaliences.length / 2);
1813
- const medianSalience = sortedSaliences.length > 0 ? sortedSaliences.length % 2 !== 0 ? sortedSaliences[mid] ?? 0 : ((sortedSaliences[mid - 1] ?? 0) + (sortedSaliences[mid] ?? 0)) / 2 : 0;
1814
- const maxSalience = sortedSaliences.length > 0 ? sortedSaliences[sortedSaliences.length - 1] ?? 0 : 0;
1815
- const minSalience = sortedSaliences.length > 0 ? sortedSaliences[0] ?? 0 : 0;
1816
- return {
1817
- paths: rankedPaths,
1818
- stats: {
1819
- pathsRanked: rankedPaths.length,
1820
- meanSalience,
1821
- medianSalience,
1822
- maxSalience,
1823
- minSalience,
1824
- durationMs: endTime - startTime
1825
- }
1826
- };
1827
- }
1828
- /**
1829
- * Compute salience for a single path asynchronously.
1830
- *
1831
- * Uses geometric mean of edge MI scores for length-unbiased ranking.
1832
- * Edge MI values are computed in parallel via Promise.all.
1833
- */
1834
- async function computePathSalienceAsync(graph, path, mi, epsilon) {
1835
- const nodes = path.nodes;
1836
- if (nodes.length < 2) return epsilon;
1837
- const edgeMIs = await Promise.all(nodes.slice(0, -1).map((source, i) => {
1838
- const target = nodes[i + 1];
1839
- if (target !== void 0) return mi(graph, source, target);
1840
- return Promise.resolve(epsilon);
1841
- }));
1842
- let productMi = 1;
1843
- let edgeCount = 0;
1844
- for (const edgeMi of edgeMIs) {
1845
- productMi *= Math.max(epsilon, edgeMi);
1846
- edgeCount++;
1847
- }
1848
- if (edgeCount === 0) return epsilon;
1849
- const salience = Math.pow(productMi, 1 / edgeCount);
1850
- return Math.max(epsilon, Math.min(1, salience));
1851
- }
1852
- /**
1853
- * Compute salience for a single path.
1854
- *
1855
- * Uses geometric mean of edge MI scores for length-unbiased ranking.
1856
- */
1857
- function computePathSalience(graph, path, mi, epsilon) {
1858
- const nodes = path.nodes;
1859
- if (nodes.length < 2) return epsilon;
1860
- let productMi = 1;
1861
- let edgeCount = 0;
1862
- for (let i = 0; i < nodes.length - 1; i++) {
1863
- const source = nodes[i];
1864
- const target = nodes[i + 1];
1865
- if (source !== void 0 && target !== void 0) {
1866
- const edgeMi = mi(graph, source, target);
1867
- productMi *= Math.max(epsilon, edgeMi);
1868
- edgeCount++;
1869
- }
1870
- }
1871
- if (edgeCount === 0) return epsilon;
1872
- const salience = Math.pow(productMi, 1 / edgeCount);
1873
- return Math.max(epsilon, Math.min(1, salience));
1874
- }
1875
- //#endregion
1876
- //#region src/ranking/mi/adamic-adar.ts
1877
- /**
1878
- * Compute Adamic-Adar index between neighbourhoods of two nodes.
1879
- *
1880
- * @param graph - Source graph
1881
- * @param source - Source node ID
1882
- * @param target - Target node ID
1883
- * @param config - Optional configuration
1884
- * @returns Adamic-Adar index (normalised to [0, 1] if configured)
1885
- */
1886
- function adamicAdar(graph, source, target, config) {
1887
- const { epsilon = 1e-10, normalise = true } = config ?? {};
1888
- const commonNeighbours = neighbourIntersection(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
1889
- let score = 0;
1890
- for (const neighbour of commonNeighbours) {
1891
- const degree = graph.degree(neighbour);
1892
- score += 1 / Math.log(degree + 1);
1893
- }
1894
- if (normalise && commonNeighbours.size > 0) {
1895
- const maxScore = commonNeighbours.size / Math.log(2);
1896
- score = score / maxScore;
1897
- }
1898
- return Math.max(epsilon, score);
1899
- }
1900
- /**
1901
- * Async variant of Adamic-Adar index for use with async graph data sources.
1902
- *
1903
- * Fetches both neighbourhoods concurrently, then fetches degree for each common
1904
- * neighbour to compute the inverse-log-degree weighted sum.
1905
- */
1906
- async function adamicAdarAsync(graph, source, target, config) {
1907
- const { epsilon = 1e-10, normalise = true } = config ?? {};
1908
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
1909
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
1910
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
1911
- const commonNeighbours = [];
1912
- for (const n of srcSet) if (tgtSet.has(n)) commonNeighbours.push(n);
1913
- if (commonNeighbours.length === 0) return epsilon;
1914
- const degrees = await Promise.all(commonNeighbours.map((n) => graph.degree(n)));
1915
- let score = 0;
1916
- for (const degree of degrees) score += 1 / Math.log(degree + 1);
1917
- if (normalise) {
1918
- const maxScore = commonNeighbours.length / Math.log(2);
1919
- score = score / maxScore;
1920
- }
1921
- return Math.max(epsilon, score);
1922
- }
1923
- //#endregion
1924
- //#region src/ranking/mi/cosine.ts
1925
- /**
1926
- * Compute cosine similarity between neighbourhoods of two nodes.
1927
- *
1928
- * @param graph - Source graph
1929
- * @param source - Source node ID
1930
- * @param target - Target node ID
1931
- * @param config - Optional configuration
1932
- * @returns Cosine similarity in [0, 1]
1933
- */
1934
- function cosine(graph, source, target, config) {
1935
- const { epsilon = 1e-10 } = config ?? {};
1936
- const sourceNeighbours = neighbourSet(graph, source, target);
1937
- const targetNeighbours = neighbourSet(graph, target, source);
1938
- const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
1939
- const denominator = Math.sqrt(sourceNeighbours.size) * Math.sqrt(targetNeighbours.size);
1940
- if (denominator === 0) return 0;
1941
- const score = intersection / denominator;
1942
- return Math.max(epsilon, score);
1943
- }
1944
- /**
1945
- * Async variant of cosine similarity for use with async graph data sources.
1946
- *
1947
- * Fetches both neighbourhoods concurrently, then applies the same formula.
1948
- */
1949
- async function cosineAsync(graph, source, target, config) {
1950
- const { epsilon = 1e-10 } = config ?? {};
1951
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
1952
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
1953
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
1954
- let intersection = 0;
1955
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
1956
- const denominator = Math.sqrt(srcSet.size) * Math.sqrt(tgtSet.size);
1957
- if (denominator === 0) return 0;
1958
- const score = intersection / denominator;
1959
- return Math.max(epsilon, score);
1960
- }
1961
- //#endregion
1962
- //#region src/ranking/mi/sorensen.ts
1963
- /**
1964
- * Compute Sorensen-Dice similarity between neighbourhoods of two nodes.
1965
- *
1966
- * @param graph - Source graph
1967
- * @param source - Source node ID
1968
- * @param target - Target node ID
1969
- * @param config - Optional configuration
1970
- * @returns Sorensen-Dice coefficient in [0, 1]
1971
- */
1972
- function sorensen(graph, source, target, config) {
1973
- const { epsilon = 1e-10 } = config ?? {};
1974
- const sourceNeighbours = neighbourSet(graph, source, target);
1975
- const targetNeighbours = neighbourSet(graph, target, source);
1976
- const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
1977
- const denominator = sourceNeighbours.size + targetNeighbours.size;
1978
- if (denominator === 0) return 0;
1979
- const score = 2 * intersection / denominator;
1980
- return Math.max(epsilon, score);
1981
- }
1982
- /**
1983
- * Async variant of Sorensen-Dice coefficient for use with async graph data sources.
1984
- *
1985
- * Fetches both neighbourhoods concurrently, then applies the same formula.
1986
- */
1987
- async function sorensenAsync(graph, source, target, config) {
1988
- const { epsilon = 1e-10 } = config ?? {};
1989
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
1990
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
1991
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
1992
- let intersection = 0;
1993
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
1994
- const denominator = srcSet.size + tgtSet.size;
1995
- if (denominator === 0) return 0;
1996
- const score = 2 * intersection / denominator;
1997
- return Math.max(epsilon, score);
1998
- }
1999
- //#endregion
2000
- //#region src/ranking/mi/resource-allocation.ts
2001
- /**
2002
- * Compute Resource Allocation index between neighbourhoods of two nodes.
2003
- *
2004
- * @param graph - Source graph
2005
- * @param source - Source node ID
2006
- * @param target - Target node ID
2007
- * @param config - Optional configuration
2008
- * @returns Resource Allocation index (normalised to [0, 1] if configured)
2009
- */
2010
- function resourceAllocation(graph, source, target, config) {
2011
- const { epsilon = 1e-10, normalise = true } = config ?? {};
2012
- const commonNeighbours = neighbourIntersection(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
2013
- let score = 0;
2014
- for (const neighbour of commonNeighbours) {
2015
- const degree = graph.degree(neighbour);
2016
- if (degree > 0) score += 1 / degree;
2017
- }
2018
- if (normalise && commonNeighbours.size > 0) {
2019
- const maxScore = commonNeighbours.size;
2020
- score = score / maxScore;
2021
- }
2022
- return Math.max(epsilon, score);
2023
- }
2024
- /**
2025
- * Async variant of Resource Allocation index for use with async graph data sources.
2026
- *
2027
- * Fetches both neighbourhoods concurrently, then fetches degree for each common
2028
- * neighbour to compute the inverse-degree weighted sum.
2029
- */
2030
- async function resourceAllocationAsync(graph, source, target, config) {
2031
- const { epsilon = 1e-10, normalise = true } = config ?? {};
2032
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
2033
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2034
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2035
- const commonNeighbours = [];
2036
- for (const n of srcSet) if (tgtSet.has(n)) commonNeighbours.push(n);
2037
- if (commonNeighbours.length === 0) return epsilon;
2038
- const degrees = await Promise.all(commonNeighbours.map((n) => graph.degree(n)));
2039
- let score = 0;
2040
- for (const degree of degrees) if (degree > 0) score += 1 / degree;
2041
- if (normalise) {
2042
- const maxScore = commonNeighbours.length;
2043
- score = score / maxScore;
2044
- }
2045
- return Math.max(epsilon, score);
2046
- }
2047
- //#endregion
2048
- //#region src/ranking/mi/overlap-coefficient.ts
2049
- /**
2050
- * Compute Overlap Coefficient between neighbourhoods of two nodes.
2051
- *
2052
- * @param graph - Source graph
2053
- * @param source - Source node ID
2054
- * @param target - Target node ID
2055
- * @param config - Optional configuration
2056
- * @returns Overlap Coefficient in [0, 1]
2057
- */
2058
- function overlapCoefficient(graph, source, target, config) {
2059
- const { epsilon = 1e-10 } = config ?? {};
2060
- const sourceNeighbours = neighbourSet(graph, source, target);
2061
- const targetNeighbours = neighbourSet(graph, target, source);
2062
- const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
2063
- const denominator = Math.min(sourceNeighbours.size, targetNeighbours.size);
2064
- if (denominator === 0) return 0;
2065
- const score = intersection / denominator;
2066
- return Math.max(epsilon, score);
2067
- }
2068
- /**
2069
- * Async variant of Overlap Coefficient for use with async graph data sources.
2070
- *
2071
- * Fetches both neighbourhoods concurrently, then applies the same formula.
2072
- */
2073
- async function overlapCoefficientAsync(graph, source, target, config) {
2074
- const { epsilon = 1e-10 } = config ?? {};
2075
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
2076
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2077
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2078
- let intersection = 0;
2079
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2080
- const denominator = Math.min(srcSet.size, tgtSet.size);
2081
- if (denominator === 0) return 0;
2082
- const score = intersection / denominator;
2083
- return Math.max(epsilon, score);
2084
- }
2085
- //#endregion
2086
- //#region src/ranking/mi/hub-promoted.ts
2087
- /**
2088
- * Compute Hub Promoted index between neighbourhoods of two nodes.
2089
- *
2090
- * @param graph - Source graph
2091
- * @param source - Source node ID
2092
- * @param target - Target node ID
2093
- * @param config - Optional configuration
2094
- * @returns Hub Promoted index in [0, 1]
2095
- */
2096
- function hubPromoted(graph, source, target, config) {
2097
- const { epsilon = 1e-10 } = config ?? {};
2098
- const { intersection } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
2099
- const sourceDegree = graph.degree(source);
2100
- const targetDegree = graph.degree(target);
2101
- const denominator = Math.min(sourceDegree, targetDegree);
2102
- if (denominator === 0) return 0;
2103
- const score = intersection / denominator;
2104
- return Math.max(epsilon, score);
2105
- }
2106
- /**
2107
- * Async variant of Hub Promoted index for use with async graph data sources.
2108
- *
2109
- * Fetches both neighbourhoods and degrees concurrently, then applies the same formula.
2110
- */
2111
- async function hubPromotedAsync(graph, source, target, config) {
2112
- const { epsilon = 1e-10 } = config ?? {};
2113
- const [sourceArr, targetArr, sourceDegree, targetDegree] = await Promise.all([
2114
- collectAsyncIterable(graph.neighbours(source)),
2115
- collectAsyncIterable(graph.neighbours(target)),
2116
- graph.degree(source),
2117
- graph.degree(target)
2118
- ]);
2119
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2120
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2121
- let intersection = 0;
2122
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2123
- const denominator = Math.min(sourceDegree, targetDegree);
2124
- if (denominator === 0) return 0;
2125
- const score = intersection / denominator;
2126
- return Math.max(epsilon, score);
2127
- }
2128
- //#endregion
2129
- //#region src/ranking/mi/scale.ts
2130
- /**
2131
- * Compute SCALE MI between two nodes.
2132
- */
2133
- function scale(graph, source, target, config) {
2134
- const { epsilon = 1e-10 } = config ?? {};
2135
- const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
2136
- const n = graph.nodeCount;
2137
- const m = graph.edgeCount;
2138
- const possibleEdges = n * (n - 1);
2139
- const density = possibleEdges > 0 ? (graph.directed ? m : 2 * m) / possibleEdges : 0;
2140
- if (density === 0) return epsilon;
2141
- const score = jaccardScore / density;
2142
- return Math.max(epsilon, score);
2143
- }
2144
- /**
2145
- * Async variant of SCALE MI for use with async graph data sources.
2146
- *
2147
- * Fetches both neighbourhoods, node count, and edge count concurrently.
2148
- */
2149
- async function scaleAsync(graph, source, target, config) {
2150
- const { epsilon = 1e-10 } = config ?? {};
2151
- const [sourceArr, targetArr, n, m] = await Promise.all([
2152
- collectAsyncIterable(graph.neighbours(source)),
2153
- collectAsyncIterable(graph.neighbours(target)),
2154
- graph.nodeCount,
2155
- graph.edgeCount
2156
- ]);
2157
- const srcSet = new Set(sourceArr.filter((node) => node !== target));
2158
- const tgtSet = new Set(targetArr.filter((node) => node !== source));
2159
- let intersection = 0;
2160
- for (const node of srcSet) if (tgtSet.has(node)) intersection++;
2161
- const union = srcSet.size + tgtSet.size - intersection;
2162
- const jaccardScore = union > 0 ? intersection / union : 0;
2163
- const possibleEdges = n * (n - 1);
2164
- const density = possibleEdges > 0 ? (graph.directed ? m : 2 * m) / possibleEdges : 0;
2165
- if (density === 0) return epsilon;
2166
- const score = jaccardScore / density;
2167
- return Math.max(epsilon, score);
2168
- }
2169
- //#endregion
2170
- //#region src/ranking/mi/skew.ts
2171
- /**
2172
- * Compute SKEW MI between two nodes.
2173
- */
2174
- function skew(graph, source, target, config) {
2175
- const { epsilon = 1e-10 } = config ?? {};
2176
- const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
2177
- const N = graph.nodeCount;
2178
- const sourceDegree = graph.degree(source);
2179
- const targetDegree = graph.degree(target);
2180
- const sourceIdf = Math.log(N / (sourceDegree + 1));
2181
- const targetIdf = Math.log(N / (targetDegree + 1));
2182
- const score = jaccardScore * sourceIdf * targetIdf;
2183
- return Math.max(epsilon, score);
2184
- }
2185
- /**
2186
- * Async variant of SKEW MI for use with async graph data sources.
2187
- *
2188
- * Fetches both neighbourhoods, degrees, and node count concurrently.
2189
- */
2190
- async function skewAsync(graph, source, target, config) {
2191
- const { epsilon = 1e-10 } = config ?? {};
2192
- const [sourceArr, targetArr, N, sourceDegree, targetDegree] = await Promise.all([
2193
- collectAsyncIterable(graph.neighbours(source)),
2194
- collectAsyncIterable(graph.neighbours(target)),
2195
- graph.nodeCount,
2196
- graph.degree(source),
2197
- graph.degree(target)
2198
- ]);
2199
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2200
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2201
- let intersection = 0;
2202
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2203
- const union = srcSet.size + tgtSet.size - intersection;
2204
- const jaccardScore = union > 0 ? intersection / union : 0;
2205
- const sourceIdf = Math.log(N / (sourceDegree + 1));
2206
- const targetIdf = Math.log(N / (targetDegree + 1));
2207
- const score = jaccardScore * sourceIdf * targetIdf;
2208
- return Math.max(epsilon, score);
2209
- }
2210
- //#endregion
2211
- //#region src/ranking/mi/span.ts
2212
- /**
2213
- * Compute SPAN MI between two nodes.
2214
- */
2215
- function span(graph, source, target, config) {
2216
- const { epsilon = 1e-10 } = config ?? {};
2217
- const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
2218
- const sourceCc = localClusteringCoefficient(graph, source);
2219
- const targetCc = localClusteringCoefficient(graph, target);
2220
- const score = jaccardScore * (1 - Math.max(sourceCc, targetCc));
2221
- return Math.max(epsilon, score);
2222
- }
2223
- /**
2224
- * Async variant of SPAN MI for use with async graph data sources.
2225
- *
2226
- * Fetches both neighbourhoods concurrently, then computes the clustering
2227
- * coefficient for each endpoint from the collected neighbour arrays.
2228
- */
2229
- async function spanAsync(graph, source, target, config) {
2230
- const { epsilon = 1e-10 } = config ?? {};
2231
- const [sourceArr, targetArr] = await Promise.all([collectAsyncIterable(graph.neighbours(source)), collectAsyncIterable(graph.neighbours(target))]);
2232
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2233
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2234
- let intersection = 0;
2235
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2236
- const union = srcSet.size + tgtSet.size - intersection;
2237
- const jaccardScore = union > 0 ? intersection / union : 0;
2238
- const computeClusteringCoefficient = async (nodeId, neighbourArr) => {
2239
- const degree = neighbourArr.length;
2240
- if (degree < 2) return 0;
2241
- const pairs = [];
2242
- for (let i = 0; i < neighbourArr.length; i++) for (let j = i + 1; j < neighbourArr.length; j++) {
2243
- const u = neighbourArr[i];
2244
- const v = neighbourArr[j];
2245
- if (u !== void 0 && v !== void 0) pairs.push([u, v]);
2246
- }
2247
- const edgeResults = await Promise.all(pairs.flatMap(([u, v]) => [graph.getEdge(u, v), graph.getEdge(v, u)]));
2248
- let triangleCount = 0;
2249
- for (let i = 0; i < pairs.length; i++) if (edgeResults[2 * i] !== void 0 || edgeResults[2 * i + 1] !== void 0) triangleCount++;
2250
- const possibleTriangles = degree * (degree - 1) / 2;
2251
- return triangleCount / possibleTriangles;
2252
- };
2253
- const [sourceCc, targetCc] = await Promise.all([computeClusteringCoefficient(source, sourceArr), computeClusteringCoefficient(target, targetArr)]);
2254
- const score = jaccardScore * (1 - Math.max(sourceCc, targetCc));
2255
- return Math.max(epsilon, score);
2256
- }
2257
- //#endregion
2258
- //#region src/ranking/mi/etch.ts
2259
- /**
2260
- * Compute ETCH MI between two nodes.
2261
- */
2262
- function etch(graph, source, target, config) {
2263
- const { epsilon = 1e-10 } = config ?? {};
2264
- const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
2265
- const edge = graph.getEdge(source, target);
2266
- if (edge?.type === void 0) return Math.max(epsilon, jaccardScore);
2267
- const edgeTypeCount = countEdgesOfType(graph, edge.type);
2268
- if (edgeTypeCount === 0) return Math.max(epsilon, jaccardScore);
2269
- const score = jaccardScore * Math.log(graph.edgeCount / edgeTypeCount);
2270
- return Math.max(epsilon, score);
2271
- }
2272
- /**
2273
- * Async variant of ETCH MI for use with async graph data sources.
2274
- *
2275
- * Fetches both neighbourhoods and edge data concurrently, then counts
2276
- * edges of the same type by iterating the async edge stream.
2277
- */
2278
- async function etchAsync(graph, source, target, config) {
2279
- const { epsilon = 1e-10 } = config ?? {};
2280
- const [sourceArr, targetArr, edge] = await Promise.all([
2281
- collectAsyncIterable(graph.neighbours(source)),
2282
- collectAsyncIterable(graph.neighbours(target)),
2283
- graph.getEdge(source, target)
2284
- ]);
2285
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2286
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2287
- let intersection = 0;
2288
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2289
- const union = srcSet.size + tgtSet.size - intersection;
2290
- const jaccardScore = union > 0 ? intersection / union : 0;
2291
- if (edge?.type === void 0) return Math.max(epsilon, jaccardScore);
2292
- const edgeType = edge.type;
2293
- let edgeTypeCount = 0;
2294
- let totalEdges = 0;
2295
- for await (const e of graph.edges()) {
2296
- totalEdges++;
2297
- if (e.type === edgeType) edgeTypeCount++;
2298
- }
2299
- if (edgeTypeCount === 0) return Math.max(epsilon, jaccardScore);
2300
- const score = jaccardScore * Math.log(totalEdges / edgeTypeCount);
2301
- return Math.max(epsilon, score);
2302
- }
2303
- //#endregion
2304
- //#region src/ranking/mi/notch.ts
2305
- /**
2306
- * Compute NOTCH MI between two nodes.
2307
- */
2308
- function notch(graph, source, target, config) {
2309
- const { epsilon = 1e-10 } = config ?? {};
2310
- const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
2311
- const sourceNode = graph.getNode(source);
2312
- const targetNode = graph.getNode(target);
2313
- if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccardScore);
2314
- const sourceTypeCount = countNodesOfType(graph, sourceNode.type);
2315
- const targetTypeCount = countNodesOfType(graph, targetNode.type);
2316
- if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccardScore);
2317
- const sourceRarity = Math.log(graph.nodeCount / sourceTypeCount);
2318
- const targetRarity = Math.log(graph.nodeCount / targetTypeCount);
2319
- const score = jaccardScore * sourceRarity * targetRarity;
2320
- return Math.max(epsilon, score);
2321
- }
2322
- /**
2323
- * Async variant of NOTCH MI for use with async graph data sources.
2324
- *
2325
- * Fetches both neighbourhoods and node data concurrently, then counts
2326
- * nodes of each type by iterating the async node stream.
2327
- */
2328
- async function notchAsync(graph, source, target, config) {
2329
- const { epsilon = 1e-10 } = config ?? {};
2330
- const [sourceArr, targetArr, sourceNode, targetNode] = await Promise.all([
2331
- collectAsyncIterable(graph.neighbours(source)),
2332
- collectAsyncIterable(graph.neighbours(target)),
2333
- graph.getNode(source),
2334
- graph.getNode(target)
2335
- ]);
2336
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2337
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2338
- let intersection = 0;
2339
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2340
- const union = srcSet.size + tgtSet.size - intersection;
2341
- const jaccardScore = union > 0 ? intersection / union : 0;
2342
- if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccardScore);
2343
- const sourceType = sourceNode.type;
2344
- const targetType = targetNode.type;
2345
- let totalNodes = 0;
2346
- let sourceTypeCount = 0;
2347
- let targetTypeCount = 0;
2348
- for await (const nodeId of graph.nodeIds()) {
2349
- totalNodes++;
2350
- const node = await graph.getNode(nodeId);
2351
- if (node?.type === sourceType) sourceTypeCount++;
2352
- if (node?.type === targetType) targetTypeCount++;
2353
- }
2354
- if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccardScore);
2355
- const sourceRarity = Math.log(totalNodes / sourceTypeCount);
2356
- const targetRarity = Math.log(totalNodes / targetTypeCount);
2357
- const score = jaccardScore * sourceRarity * targetRarity;
2358
- return Math.max(epsilon, score);
2359
- }
2360
- //#endregion
2361
- //#region src/ranking/mi/adaptive.ts
2362
- /**
2363
- * Compute unified adaptive MI between two connected nodes.
2364
- *
2365
- * Combines structural, degree, and overlap signals with
2366
- * configurable weighting.
2367
- *
2368
- * @param graph - Source graph
2369
- * @param source - Source node ID
2370
- * @param target - Target node ID
2371
- * @param config - Optional configuration with component weights
2372
- * @returns Adaptive MI score in [0, 1]
2373
- */
2374
- function adaptive(graph, source, target, config) {
2375
- const { epsilon = 1e-10, structuralWeight = .4, degreeWeight = .3, overlapWeight = .3 } = config ?? {};
2376
- const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = computeJaccard(graph, source, target);
2377
- const structural = sourceNeighbours.size === 0 && targetNeighbours.size === 0 ? 0 : Math.max(epsilon, jaccardScore);
2378
- const degreeComponent = adamicAdar(graph, source, target, {
2379
- epsilon,
2380
- normalise: true
2381
- });
2382
- let overlap;
2383
- if (sourceNeighbours.size > 0 && targetNeighbours.size > 0) {
2384
- const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
2385
- const minDegree = Math.min(sourceNeighbours.size, targetNeighbours.size);
2386
- overlap = minDegree > 0 ? intersection / minDegree : epsilon;
2387
- } else overlap = epsilon;
2388
- const totalWeight = structuralWeight + degreeWeight + overlapWeight;
2389
- const score = (structuralWeight * structural + degreeWeight * degreeComponent + overlapWeight * overlap) / totalWeight;
2390
- return Math.max(epsilon, Math.min(1, score));
2391
- }
2392
- /**
2393
- * Async variant of Adaptive MI for use with async graph data sources.
2394
- *
2395
- * Fetches both neighbourhoods concurrently, then delegates degree-weighted
2396
- * component to the async Adamic-Adar variant.
2397
- */
2398
- async function adaptiveAsync(graph, source, target, config) {
2399
- const { epsilon = 1e-10, structuralWeight = .4, degreeWeight = .3, overlapWeight = .3 } = config ?? {};
2400
- const [sourceArr, targetArr, degreeComponent] = await Promise.all([
2401
- collectAsyncIterable(graph.neighbours(source)),
2402
- collectAsyncIterable(graph.neighbours(target)),
2403
- adamicAdarAsync(graph, source, target, {
2404
- epsilon,
2405
- normalise: true
2406
- })
2407
- ]);
2408
- const srcSet = new Set(sourceArr.filter((n) => n !== target));
2409
- const tgtSet = new Set(targetArr.filter((n) => n !== source));
2410
- let intersection = 0;
2411
- for (const n of srcSet) if (tgtSet.has(n)) intersection++;
2412
- const union = srcSet.size + tgtSet.size - intersection;
2413
- const jaccardScore = union > 0 ? intersection / union : 0;
2414
- const structural = srcSet.size === 0 && tgtSet.size === 0 ? 0 : Math.max(epsilon, jaccardScore);
2415
- let overlap;
2416
- if (srcSet.size > 0 && tgtSet.size > 0) {
2417
- const minDegree = Math.min(srcSet.size, tgtSet.size);
2418
- overlap = minDegree > 0 ? intersection / minDegree : epsilon;
2419
- } else overlap = epsilon;
2420
- const totalWeight = structuralWeight + degreeWeight + overlapWeight;
2421
- const score = (structuralWeight * structural + degreeWeight * degreeComponent + overlapWeight * overlap) / totalWeight;
2422
- return Math.max(epsilon, Math.min(1, score));
2423
- }
2424
- //#endregion
2425
- //#region src/ranking/baselines/utils.ts
2426
- /**
2427
- * Normalise a set of scored paths and return them sorted highest-first.
2428
- *
2429
- * All scores are normalised relative to the maximum observed score.
2430
- * When `includeScores` is false, raw (un-normalised) scores are preserved.
2431
- * Handles degenerate cases: empty input and all-zero scores.
2432
- *
2433
- * @param paths - Original paths in input order
2434
- * @param scored - Paths paired with their computed scores
2435
- * @param method - Method name to embed in the result
2436
- * @param includeScores - When true, normalise scores to [0, 1]; when false, keep raw scores
2437
- * @returns BaselineResult with ranked paths
2438
- */
2439
- function normaliseAndRank(paths, scored, method, includeScores) {
2440
- if (scored.length === 0) return {
2441
- paths: [],
2442
- method
2443
- };
2444
- const maxScore = Math.max(...scored.map((s) => s.score));
2445
- if (maxScore === 0) return {
2446
- paths: paths.map((path) => ({
2447
- ...path,
2448
- score: 0
2449
- })),
2450
- method
2451
- };
2452
- return {
2453
- paths: scored.map(({ path, score }) => ({
2454
- ...path,
2455
- score: includeScores ? score / maxScore : score
2456
- })).sort((a, b) => b.score - a.score),
2457
- method
2458
- };
2459
- }
2460
- //#endregion
2461
- //#region src/ranking/baselines/shortest.ts
2462
- /**
2463
- * Rank paths by length (shortest first).
2464
- *
2465
- * Score = 1 / path_length, normalised to [0, 1].
2466
- *
2467
- * @param _graph - Source graph (unused for length ranking)
2468
- * @param paths - Paths to rank
2469
- * @param config - Configuration options
2470
- * @returns Ranked paths (shortest first)
2471
- */
2472
- function shortest(_graph, paths, config) {
2473
- const { includeScores = true } = config ?? {};
2474
- if (paths.length === 0) return {
2475
- paths: [],
2476
- method: "shortest"
2477
- };
2478
- return normaliseAndRank(paths, paths.map((path) => ({
2479
- path,
2480
- score: 1 / path.nodes.length
2481
- })), "shortest", includeScores);
2482
- }
2483
- //#endregion
2484
- //#region src/ranking/baselines/degree-sum.ts
2485
- /**
2486
- * Rank paths by sum of node degrees.
2487
- *
2488
- * @param graph - Source graph
2489
- * @param paths - Paths to rank
2490
- * @param config - Configuration options
2491
- * @returns Ranked paths (highest degree-sum first)
2492
- */
2493
- function degreeSum(graph, paths, config) {
2494
- const { includeScores = true } = config ?? {};
2495
- if (paths.length === 0) return {
2496
- paths: [],
2497
- method: "degree-sum"
2498
- };
2499
- return normaliseAndRank(paths, paths.map((path) => {
2500
- let degreeSum = 0;
2501
- for (const nodeId of path.nodes) degreeSum += graph.degree(nodeId);
2502
- return {
2503
- path,
2504
- score: degreeSum
2505
- };
2506
- }), "degree-sum", includeScores);
2507
- }
2508
- //#endregion
2509
- //#region src/ranking/baselines/widest-path.ts
2510
- /**
2511
- * Rank paths by widest bottleneck (minimum edge similarity).
2512
- *
2513
- * @param graph - Source graph
2514
- * @param paths - Paths to rank
2515
- * @param config - Configuration options
2516
- * @returns Ranked paths (highest bottleneck first)
2517
- */
2518
- function widestPath(graph, paths, config) {
2519
- const { includeScores = true } = config ?? {};
2520
- if (paths.length === 0) return {
2521
- paths: [],
2522
- method: "widest-path"
2523
- };
2524
- return normaliseAndRank(paths, paths.map((path) => {
2525
- if (path.nodes.length < 2) return {
2526
- path,
2527
- score: 1
2528
- };
2529
- let minSimilarity = Number.POSITIVE_INFINITY;
2530
- for (let i = 0; i < path.nodes.length - 1; i++) {
2531
- const source = path.nodes[i];
2532
- const target = path.nodes[i + 1];
2533
- if (source === void 0 || target === void 0) continue;
2534
- const edgeSimilarity = jaccard(graph, source, target);
2535
- minSimilarity = Math.min(minSimilarity, edgeSimilarity);
2536
- }
2537
- return {
2538
- path,
2539
- score: minSimilarity === Number.POSITIVE_INFINITY ? 1 : minSimilarity
2540
- };
2541
- }), "widest-path", includeScores);
2542
- }
2543
- //#endregion
2544
- //#region src/ranking/baselines/jaccard-arithmetic.ts
2545
- /**
2546
- * Rank paths by arithmetic mean of edge Jaccard similarities.
2547
- *
2548
- * @param graph - Source graph
2549
- * @param paths - Paths to rank
2550
- * @param config - Configuration options
2551
- * @returns Ranked paths (highest arithmetic mean first)
2552
- */
2553
- function jaccardArithmetic(graph, paths, config) {
2554
- const { includeScores = true } = config ?? {};
2555
- if (paths.length === 0) return {
2556
- paths: [],
2557
- method: "jaccard-arithmetic"
2558
- };
2559
- return normaliseAndRank(paths, paths.map((path) => {
2560
- if (path.nodes.length < 2) return {
2561
- path,
2562
- score: 1
2563
- };
2564
- let similaritySum = 0;
2565
- let edgeCount = 0;
2566
- for (let i = 0; i < path.nodes.length - 1; i++) {
2567
- const source = path.nodes[i];
2568
- const target = path.nodes[i + 1];
2569
- if (source === void 0 || target === void 0) continue;
2570
- const edgeSimilarity = jaccard(graph, source, target);
2571
- similaritySum += edgeSimilarity;
2572
- edgeCount++;
2573
- }
2574
- return {
2575
- path,
2576
- score: edgeCount > 0 ? similaritySum / edgeCount : 1
2577
- };
2578
- }), "jaccard-arithmetic", includeScores);
2579
- }
2580
- //#endregion
2581
- //#region src/ranking/baselines/pagerank.ts
2582
- /**
2583
- * Compute PageRank centrality for all nodes using power iteration.
2584
- *
2585
- * @param graph - Source graph
2586
- * @param damping - Damping factor (default 0.85)
2587
- * @param tolerance - Convergence tolerance (default 1e-6)
2588
- * @param maxIterations - Maximum iterations (default 100)
2589
- * @returns Map of node ID to PageRank value
2590
- */
2591
- function computePageRank(graph, damping = .85, tolerance = 1e-6, maxIterations = 100) {
2592
- const nodes = Array.from(graph.nodeIds());
2593
- const n = nodes.length;
2594
- if (n === 0) return /* @__PURE__ */ new Map();
2595
- const ranks = /* @__PURE__ */ new Map();
2596
- const newRanks = /* @__PURE__ */ new Map();
2597
- for (const nodeId of nodes) {
2598
- ranks.set(nodeId, 1 / n);
2599
- newRanks.set(nodeId, 0);
2600
- }
2601
- let isCurrentRanks = true;
2602
- for (let iteration = 0; iteration < maxIterations; iteration++) {
2603
- let maxChange = 0;
2604
- const currMap = isCurrentRanks ? ranks : newRanks;
2605
- const nextMap = isCurrentRanks ? newRanks : ranks;
2606
- for (const nodeId of nodes) {
2607
- let incomingSum = 0;
2608
- for (const incomingId of graph.neighbours(nodeId, "in")) {
2609
- const incomingRank = currMap.get(incomingId) ?? 0;
2610
- const outDegree = graph.degree(incomingId);
2611
- if (outDegree > 0) incomingSum += incomingRank / outDegree;
2612
- }
2613
- const newRank = (1 - damping) / n + damping * incomingSum;
2614
- nextMap.set(nodeId, newRank);
2615
- const oldRank = currMap.get(nodeId) ?? 0;
2616
- maxChange = Math.max(maxChange, Math.abs(newRank - oldRank));
2617
- }
2618
- if (maxChange < tolerance) break;
2619
- isCurrentRanks = !isCurrentRanks;
2620
- currMap.clear();
2621
- }
2622
- return isCurrentRanks ? ranks : newRanks;
2623
- }
2624
- /**
2625
- * Rank paths by sum of PageRank scores.
2626
- *
2627
- * @param graph - Source graph
2628
- * @param paths - Paths to rank
2629
- * @param config - Configuration options
2630
- * @returns Ranked paths (highest PageRank sum first)
2631
- */
2632
- function pagerank(graph, paths, config) {
2633
- const { includeScores = true } = config ?? {};
2634
- if (paths.length === 0) return {
2635
- paths: [],
2636
- method: "pagerank"
2637
- };
2638
- const ranks = computePageRank(graph);
2639
- return normaliseAndRank(paths, paths.map((path) => {
2640
- let prSum = 0;
2641
- for (const nodeId of path.nodes) prSum += ranks.get(nodeId) ?? 0;
2642
- return {
2643
- path,
2644
- score: prSum
2645
- };
2646
- }), "pagerank", includeScores);
2647
- }
2648
- //#endregion
2649
- //#region src/ranking/baselines/betweenness.ts
2650
- /**
2651
- * Compute betweenness centrality for all nodes using Brandes algorithm.
2652
- *
2653
- * @param graph - Source graph
2654
- * @returns Map of node ID to betweenness value
2655
- */
2656
- function computeBetweenness(graph) {
2657
- const nodes = Array.from(graph.nodeIds());
2658
- const betweenness = /* @__PURE__ */ new Map();
2659
- for (const nodeId of nodes) betweenness.set(nodeId, 0);
2660
- for (const source of nodes) {
2661
- const predecessors = /* @__PURE__ */ new Map();
2662
- const distance = /* @__PURE__ */ new Map();
2663
- const sigma = /* @__PURE__ */ new Map();
2664
- const queue = [];
2665
- for (const nodeId of nodes) {
2666
- predecessors.set(nodeId, []);
2667
- distance.set(nodeId, -1);
2668
- sigma.set(nodeId, 0);
2669
- }
2670
- distance.set(source, 0);
2671
- sigma.set(source, 1);
2672
- queue.push(source);
2673
- for (const v of queue) {
2674
- const vDist = distance.get(v) ?? -1;
2675
- const neighbours = graph.neighbours(v);
2676
- for (const w of neighbours) {
2677
- const wDist = distance.get(w) ?? -1;
2678
- if (wDist < 0) {
2679
- distance.set(w, vDist + 1);
2680
- queue.push(w);
2681
- }
2682
- if (wDist === vDist + 1) {
2683
- const wSigma = sigma.get(w) ?? 0;
2684
- const vSigma = sigma.get(v) ?? 0;
2685
- sigma.set(w, wSigma + vSigma);
2686
- const wPred = predecessors.get(w) ?? [];
2687
- wPred.push(v);
2688
- predecessors.set(w, wPred);
2689
- }
2690
- }
2691
- }
2692
- const delta = /* @__PURE__ */ new Map();
2693
- for (const nodeId of nodes) delta.set(nodeId, 0);
2694
- const sorted = [...nodes].sort((a, b) => {
2695
- const aD = distance.get(a) ?? -1;
2696
- return (distance.get(b) ?? -1) - aD;
2697
- });
2698
- for (const w of sorted) {
2699
- if (w === source) continue;
2700
- const wDelta = delta.get(w) ?? 0;
2701
- const wSigma = sigma.get(w) ?? 0;
2702
- const wPred = predecessors.get(w) ?? [];
2703
- for (const v of wPred) {
2704
- const vSigma = sigma.get(v) ?? 0;
2705
- const vDelta = delta.get(v) ?? 0;
2706
- if (wSigma > 0) delta.set(v, vDelta + vSigma / wSigma * (1 + wDelta));
2707
- }
2708
- if (w !== source) {
2709
- const current = betweenness.get(w) ?? 0;
2710
- betweenness.set(w, current + wDelta);
2711
- }
2712
- }
2713
- }
2714
- return betweenness;
2715
- }
2716
- /**
2717
- * Rank paths by sum of betweenness scores.
2718
- *
2719
- * @param graph - Source graph
2720
- * @param paths - Paths to rank
2721
- * @param config - Configuration options
2722
- * @returns Ranked paths (highest betweenness sum first)
2723
- */
2724
- function betweenness(graph, paths, config) {
2725
- const { includeScores = true } = config ?? {};
2726
- if (paths.length === 0) return {
2727
- paths: [],
2728
- method: "betweenness"
2729
- };
2730
- const bcMap = computeBetweenness(graph);
2731
- return normaliseAndRank(paths, paths.map((path) => {
2732
- let bcSum = 0;
2733
- for (const nodeId of path.nodes) bcSum += bcMap.get(nodeId) ?? 0;
2734
- return {
2735
- path,
2736
- score: bcSum
2737
- };
2738
- }), "betweenness", includeScores);
2739
- }
2740
- //#endregion
2741
- //#region src/ranking/baselines/katz.ts
2742
- /**
2743
- * Compute truncated Katz centrality between two nodes.
2744
- *
2745
- * Uses iterative matrix-vector products to avoid full matrix powers.
2746
- * score(s,t) = sum_{k=1}^{K} beta^k * walks_k(s,t)
2747
- *
2748
- * @param graph - Source graph
2749
- * @param source - Source node ID
2750
- * @param target - Target node ID
2751
- * @param k - Truncation depth (default 5)
2752
- * @param beta - Attenuation factor (default 0.005)
2753
- * @returns Katz score
2754
- */
2755
- function computeKatz(graph, source, target, k = 5, beta = .005) {
2756
- const nodes = Array.from(graph.nodeIds());
2757
- const nodeToIdx = /* @__PURE__ */ new Map();
2758
- nodes.forEach((nodeId, idx) => {
2759
- nodeToIdx.set(nodeId, idx);
2760
- });
2761
- const n = nodes.length;
2762
- if (n === 0) return 0;
2763
- const sourceIdx = nodeToIdx.get(source);
2764
- const targetIdx = nodeToIdx.get(target);
2765
- if (sourceIdx === void 0 || targetIdx === void 0) return 0;
2766
- let walks = new Float64Array(n);
2767
- walks[targetIdx] = 1;
2768
- let katzScore = 0;
2769
- for (let depth = 1; depth <= k; depth++) {
2770
- const walksNext = new Float64Array(n);
2771
- for (const sourceNode of nodes) {
2772
- const srcIdx = nodeToIdx.get(sourceNode);
2773
- if (srcIdx === void 0) continue;
2774
- const neighbours = graph.neighbours(sourceNode);
2775
- for (const neighbourId of neighbours) {
2776
- const nIdx = nodeToIdx.get(neighbourId);
2777
- if (nIdx === void 0) continue;
2778
- walksNext[srcIdx] = (walksNext[srcIdx] ?? 0) + (walks[nIdx] ?? 0);
2779
- }
2780
- }
2781
- const walkCount = walksNext[sourceIdx] ?? 0;
2782
- katzScore += Math.pow(beta, depth) * walkCount;
2783
- walks = walksNext;
2784
- }
2785
- return katzScore;
2786
- }
2787
- /**
2788
- * Rank paths by Katz centrality between endpoints.
2789
- *
2790
- * @param graph - Source graph
2791
- * @param paths - Paths to rank
2792
- * @param config - Configuration options
2793
- * @returns Ranked paths (highest Katz score first)
2794
- */
2795
- function katz(graph, paths, config) {
2796
- const { includeScores = true } = config ?? {};
2797
- if (paths.length === 0) return {
2798
- paths: [],
2799
- method: "katz"
2800
- };
2801
- return normaliseAndRank(paths, paths.map((path) => {
2802
- const source = path.nodes[0];
2803
- const target = path.nodes[path.nodes.length - 1];
2804
- if (source === void 0 || target === void 0) return {
2805
- path,
2806
- score: 0
2807
- };
2808
- return {
2809
- path,
2810
- score: computeKatz(graph, source, target)
2811
- };
2812
- }), "katz", includeScores);
2813
- }
2814
- //#endregion
2815
- //#region src/ranking/baselines/communicability.ts
2816
- /**
2817
- * Compute truncated communicability between two nodes.
2818
- *
2819
- * Uses Taylor series expansion: (e^A)_{s,t} ≈ sum_{k=0}^{K} A^k_{s,t} / k!
2820
- *
2821
- * @param graph - Source graph
2822
- * @param source - Source node ID
2823
- * @param target - Target node ID
2824
- * @param k - Truncation depth (default 15)
2825
- * @returns Communicability score
2826
- */
2827
- function computeCommunicability(graph, source, target, k = 15) {
2828
- const nodes = Array.from(graph.nodeIds());
2829
- const nodeToIdx = /* @__PURE__ */ new Map();
2830
- nodes.forEach((nodeId, idx) => {
2831
- nodeToIdx.set(nodeId, idx);
2832
- });
2833
- const n = nodes.length;
2834
- if (n === 0) return 0;
2835
- const sourceIdx = nodeToIdx.get(source);
2836
- const targetIdx = nodeToIdx.get(target);
2837
- if (sourceIdx === void 0 || targetIdx === void 0) return 0;
2838
- let walks = new Float64Array(n);
2839
- walks[targetIdx] = 1;
2840
- let commScore = walks[sourceIdx] ?? 0;
2841
- let factorial = 1;
2842
- for (let depth = 1; depth <= k; depth++) {
2843
- const walksNext = new Float64Array(n);
2844
- for (const fromNode of nodes) {
2845
- const fromIdx = nodeToIdx.get(fromNode);
2846
- if (fromIdx === void 0) continue;
2847
- const neighbours = graph.neighbours(fromNode);
2848
- for (const toNodeId of neighbours) {
2849
- const toIdx = nodeToIdx.get(toNodeId);
2850
- if (toIdx === void 0) continue;
2851
- walksNext[fromIdx] = (walksNext[fromIdx] ?? 0) + (walks[toIdx] ?? 0);
2852
- }
2853
- }
2854
- factorial *= depth;
2855
- commScore += (walksNext[sourceIdx] ?? 0) / factorial;
2856
- walks = walksNext;
2857
- }
2858
- return commScore;
2859
- }
2860
- /**
2861
- * Rank paths by communicability between endpoints.
2862
- *
2863
- * @param graph - Source graph
2864
- * @param paths - Paths to rank
2865
- * @param config - Configuration options
2866
- * @returns Ranked paths (highest communicability first)
2867
- */
2868
- function communicability(graph, paths, config) {
2869
- const { includeScores = true } = config ?? {};
2870
- if (paths.length === 0) return {
2871
- paths: [],
2872
- method: "communicability"
2873
- };
2874
- return normaliseAndRank(paths, paths.map((path) => {
2875
- const source = path.nodes[0];
2876
- const target = path.nodes[path.nodes.length - 1];
2877
- if (source === void 0 || target === void 0) return {
2878
- path,
2879
- score: 0
2880
- };
2881
- return {
2882
- path,
2883
- score: computeCommunicability(graph, source, target)
2884
- };
2885
- }), "communicability", includeScores);
2886
- }
2887
- //#endregion
2888
- //#region src/ranking/baselines/resistance-distance.ts
2889
- /**
2890
- * Compute effective resistance between two nodes via Laplacian pseudoinverse.
2891
- *
2892
- * Resistance = L^+_{s,s} + L^+_{t,t} - 2*L^+_{s,t}
2893
- * where L^+ is the pseudoinverse of the Laplacian matrix.
2894
- *
2895
- * @param graph - Source graph
2896
- * @param source - Source node ID
2897
- * @param target - Target node ID
2898
- * @returns Effective resistance
2899
- */
2900
- function computeResistance(graph, source, target) {
2901
- const nodes = Array.from(graph.nodeIds());
2902
- const nodeToIdx = /* @__PURE__ */ new Map();
2903
- nodes.forEach((nodeId, idx) => {
2904
- nodeToIdx.set(nodeId, idx);
2905
- });
2906
- const n = nodes.length;
2907
- if (n === 0 || n > 5e3) throw new Error(`Cannot compute resistance distance: graph too large (${String(n)} nodes). Maximum 5000.`);
2908
- const sourceIdx = nodeToIdx.get(source);
2909
- const targetIdx = nodeToIdx.get(target);
2910
- if (sourceIdx === void 0 || targetIdx === void 0) return 0;
2911
- const L = Array.from({ length: n }, () => Array.from({ length: n }, () => 0));
2912
- for (let i = 0; i < n; i++) {
2913
- const nodeId = nodes[i];
2914
- if (nodeId === void 0) continue;
2915
- const degree = graph.degree(nodeId);
2916
- const row = L[i];
2917
- if (row !== void 0) row[i] = degree;
2918
- const neighbours = graph.neighbours(nodeId);
2919
- for (const neighbourId of neighbours) {
2920
- const j = nodeToIdx.get(neighbourId);
2921
- if (j !== void 0 && row !== void 0) row[j] = -1;
2922
- }
2923
- }
2924
- const Lpinv = pinv(L);
2925
- const resistance = (Lpinv[sourceIdx]?.[sourceIdx] ?? 0) + (Lpinv[targetIdx]?.[targetIdx] ?? 0) - 2 * (Lpinv[sourceIdx]?.[targetIdx] ?? 0);
2926
- return Math.max(resistance, 1e-10);
2927
- }
2928
- /**
2929
- * Compute Moore-Penrose pseudoinverse of a matrix.
2930
- * Simplified implementation for small dense matrices.
2931
- *
2932
- * @param A - Square matrix
2933
- * @returns Pseudoinverse A^+
2934
- */
2935
- function pinv(A) {
2936
- const n = A.length;
2937
- if (n === 0) return [];
2938
- const M = A.map((row) => [...row]);
2939
- const epsilon = 1e-10;
2940
- for (let i = 0; i < n; i++) {
2941
- const row = M[i];
2942
- if (row !== void 0) row[i] = (row[i] ?? 0) + epsilon;
2943
- }
2944
- return gaussianInverse(M);
2945
- }
2946
- /**
2947
- * Compute matrix inverse using Gaussian elimination with partial pivoting.
2948
- *
2949
- * @param A - Matrix to invert
2950
- * @returns Inverted matrix
2951
- */
2952
- function gaussianInverse(A) {
2953
- const n = A.length;
2954
- const aug = A.map((row, i) => {
2955
- const identity = Array.from({ length: n }, (_, j) => i === j ? 1 : 0);
2956
- return [...row, ...identity];
2957
- });
2958
- for (let col = 0; col < n; col++) {
2959
- let maxRow = col;
2960
- for (let row = col + 1; row < n; row++) {
2961
- const currentRow = aug[row];
2962
- const maxRowRef = aug[maxRow];
2963
- if (currentRow !== void 0 && maxRowRef !== void 0 && Math.abs(currentRow[col] ?? 0) > Math.abs(maxRowRef[col] ?? 0)) maxRow = row;
2964
- }
2965
- const currentCol = aug[col];
2966
- const maxRowAug = aug[maxRow];
2967
- if (currentCol !== void 0 && maxRowAug !== void 0) {
2968
- aug[col] = maxRowAug;
2969
- aug[maxRow] = currentCol;
2970
- }
2971
- const pivotRow = aug[col];
2972
- const pivot = pivotRow?.[col];
2973
- if (pivot === void 0 || Math.abs(pivot) < 1e-12) continue;
2974
- if (pivotRow !== void 0) for (let j = col; j < 2 * n; j++) pivotRow[j] = (pivotRow[j] ?? 0) / pivot;
2975
- for (let row = 0; row < n; row++) {
2976
- if (row === col) continue;
2977
- const eliminationRow = aug[row];
2978
- const factor = eliminationRow?.[col] ?? 0;
2979
- if (eliminationRow !== void 0 && pivotRow !== void 0) for (let j = col; j < 2 * n; j++) eliminationRow[j] = (eliminationRow[j] ?? 0) - factor * (pivotRow[j] ?? 0);
2980
- }
2981
- }
2982
- const Ainv = [];
2983
- for (let i = 0; i < n; i++) Ainv[i] = (aug[i]?.slice(n) ?? []).map((v) => v);
2984
- return Ainv;
2985
- }
2986
- /**
2987
- * Rank paths by reciprocal of resistance distance between endpoints.
2988
- *
2989
- * @param graph - Source graph
2990
- * @param paths - Paths to rank
2991
- * @param config - Configuration options
2992
- * @returns Ranked paths (highest conductance first)
2993
- */
2994
- function resistanceDistance(graph, paths, config) {
2995
- const { includeScores = true } = config ?? {};
2996
- if (paths.length === 0) return {
2997
- paths: [],
2998
- method: "resistance-distance"
2999
- };
3000
- const nodeCount = Array.from(graph.nodeIds()).length;
3001
- if (nodeCount > 5e3) throw new Error(`Cannot rank paths: graph too large (${String(nodeCount)} nodes). Resistance distance requires O(n^3) computation; maximum 5000 nodes.`);
3002
- return normaliseAndRank(paths, paths.map((path) => {
3003
- const source = path.nodes[0];
3004
- const target = path.nodes[path.nodes.length - 1];
3005
- if (source === void 0 || target === void 0) return {
3006
- path,
3007
- score: 0
3008
- };
3009
- return {
3010
- path,
3011
- score: 1 / computeResistance(graph, source, target)
3012
- };
3013
- }), "resistance-distance", includeScores);
3014
- }
3015
- //#endregion
3016
- //#region src/ranking/baselines/random-ranking.ts
3017
- /**
3018
- * Deterministic seeded random number generator.
3019
- * Uses FNV-1a-like hash for input → [0, 1] output.
3020
- *
3021
- * @param input - String to hash
3022
- * @param seed - Random seed for reproducibility
3023
- * @returns Deterministic random value in [0, 1]
3024
- */
3025
- function seededRandom(input, seed = 0) {
3026
- let h = seed;
3027
- for (let i = 0; i < input.length; i++) {
3028
- h = Math.imul(h ^ input.charCodeAt(i), 2654435769);
3029
- h ^= h >>> 16;
3030
- }
3031
- return (h >>> 0) / 4294967295;
3032
- }
3033
- /**
3034
- * Rank paths randomly (null hypothesis baseline).
3035
- *
3036
- * @param _graph - Source graph (unused)
3037
- * @param paths - Paths to rank
3038
- * @param config - Configuration options
3039
- * @returns Ranked paths (randomly ordered)
3040
- */
3041
- function randomRanking(_graph, paths, config) {
3042
- const { includeScores = true, seed = 0 } = config ?? {};
3043
- if (paths.length === 0) return {
3044
- paths: [],
3045
- method: "random"
3046
- };
3047
- return normaliseAndRank(paths, paths.map((path) => {
3048
- return {
3049
- path,
3050
- score: seededRandom(path.nodes.join(","), seed)
3051
- };
3052
- }), "random", includeScores);
3053
- }
3054
- //#endregion
3055
- //#region src/ranking/baselines/hitting-time.ts
3056
- /**
3057
- * Seeded deterministic random number generator (LCG).
3058
- * Suitable for reproducible random walk simulation.
3059
- */
3060
- var SeededRNG = class {
3061
- state;
3062
- constructor(seed) {
3063
- this.state = seed;
3064
- }
3065
- /**
3066
- * Generate next pseudorandom value in [0, 1).
3067
- */
3068
- next() {
3069
- this.state = this.state * 1103515245 + 12345 & 2147483647;
3070
- return this.state / 2147483647;
3071
- }
3072
- };
3073
- /**
3074
- * Compute hitting time via Monte Carlo random walk simulation.
3075
- *
3076
- * @param graph - Source graph
3077
- * @param source - Source node ID
3078
- * @param target - Target node ID
3079
- * @param walks - Number of walks to simulate
3080
- * @param maxSteps - Maximum steps per walk
3081
- * @param rng - Seeded RNG instance
3082
- * @returns Average hitting time across walks
3083
- */
3084
- function computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng) {
3085
- if (source === target) return 0;
3086
- let totalSteps = 0;
3087
- let successfulWalks = 0;
3088
- for (let w = 0; w < walks; w++) {
3089
- let current = source;
3090
- let steps = 0;
3091
- while (current !== target && steps < maxSteps) {
3092
- const neighbours = Array.from(graph.neighbours(current));
3093
- if (neighbours.length === 0) break;
3094
- const nextNode = neighbours[Math.floor(rng.next() * neighbours.length)];
3095
- if (nextNode === void 0) break;
3096
- current = nextNode;
3097
- steps++;
3098
- }
3099
- if (current === target) {
3100
- totalSteps += steps;
3101
- successfulWalks++;
3102
- }
3103
- }
3104
- if (successfulWalks > 0) return totalSteps / successfulWalks;
3105
- return maxSteps;
3106
- }
3107
- /**
3108
- * Compute hitting time via exact fundamental matrix method.
3109
- *
3110
- * For small graphs, computes exact expected hitting times using
3111
- * the fundamental matrix of the random walk.
3112
- *
3113
- * @param graph - Source graph
3114
- * @param source - Source node ID
3115
- * @param target - Target node ID
3116
- * @returns Exact hitting time (or approximation if convergence fails)
3117
- */
3118
- function computeHittingTimeExact(graph, source, target) {
3119
- if (source === target) return 0;
3120
- const nodes = Array.from(graph.nodeIds());
3121
- const nodeToIdx = /* @__PURE__ */ new Map();
3122
- nodes.forEach((nodeId, idx) => {
3123
- nodeToIdx.set(nodeId, idx);
3124
- });
3125
- const n = nodes.length;
3126
- const sourceIdx = nodeToIdx.get(source);
3127
- const targetIdx = nodeToIdx.get(target);
3128
- if (sourceIdx === void 0 || targetIdx === void 0) return 0;
3129
- const P = [];
3130
- for (let i = 0; i < n; i++) {
3131
- const row = [];
3132
- for (let j = 0; j < n; j++) row[j] = 0;
3133
- P[i] = row;
3134
- }
3135
- for (const nodeId of nodes) {
3136
- const idx = nodeToIdx.get(nodeId);
3137
- if (idx === void 0) continue;
3138
- const pRow = P[idx];
3139
- if (pRow === void 0) continue;
3140
- if (idx === targetIdx) pRow[idx] = 1;
3141
- else {
3142
- const neighbours = Array.from(graph.neighbours(nodeId));
3143
- const degree = neighbours.length;
3144
- if (degree > 0) for (const neighbourId of neighbours) {
3145
- const nIdx = nodeToIdx.get(neighbourId);
3146
- if (nIdx !== void 0) pRow[nIdx] = 1 / degree;
3147
- }
3148
- }
3149
- }
3150
- const transientIndices = [];
3151
- for (let i = 0; i < n; i++) if (i !== targetIdx) transientIndices.push(i);
3152
- const m = transientIndices.length;
3153
- const Q = [];
3154
- for (let i = 0; i < m; i++) {
3155
- const row = [];
3156
- for (let j = 0; j < m; j++) row[j] = 0;
3157
- Q[i] = row;
3158
- }
3159
- for (let i = 0; i < m; i++) {
3160
- const qRow = Q[i];
3161
- if (qRow === void 0) continue;
3162
- const origI = transientIndices[i];
3163
- if (origI === void 0) continue;
3164
- const pRow = P[origI];
3165
- if (pRow === void 0) continue;
3166
- for (let j = 0; j < m; j++) {
3167
- const origJ = transientIndices[j];
3168
- if (origJ === void 0) continue;
3169
- qRow[j] = pRow[origJ] ?? 0;
3170
- }
3171
- }
3172
- const IMQ = [];
3173
- for (let i = 0; i < m; i++) {
3174
- const row = [];
3175
- for (let j = 0; j < m; j++) row[j] = i === j ? 1 : 0;
3176
- IMQ[i] = row;
3177
- }
3178
- for (let i = 0; i < m; i++) {
3179
- const imqRow = IMQ[i];
3180
- if (imqRow === void 0) continue;
3181
- const qRow = Q[i];
3182
- for (let j = 0; j < m; j++) {
3183
- const qVal = qRow?.[j] ?? 0;
3184
- imqRow[j] = (i === j ? 1 : 0) - qVal;
3185
- }
3186
- }
3187
- const N = invertMatrix(IMQ);
3188
- if (N === null) return 1;
3189
- const sourceTransientIdx = transientIndices.indexOf(sourceIdx);
3190
- if (sourceTransientIdx < 0) return 0;
3191
- let hittingTime = 0;
3192
- const row = N[sourceTransientIdx];
3193
- if (row !== void 0) for (const val of row) hittingTime += val;
3194
- return hittingTime;
3195
- }
3196
- /**
3197
- * Invert a square matrix using Gaussian elimination with partial pivoting.
3198
- *
3199
- * @param matrix - Input matrix (n × n)
3200
- * @returns Inverted matrix, or null if singular
3201
- */
3202
- function invertMatrix(matrix) {
3203
- const n = matrix.length;
3204
- const aug = [];
3205
- for (let i = 0; i < n; i++) {
3206
- const row = [];
3207
- const matRow = matrix[i];
3208
- for (let j = 0; j < n; j++) row[j] = matRow?.[j] ?? 0;
3209
- for (let j = 0; j < n; j++) row[n + j] = i === j ? 1 : 0;
3210
- aug[i] = row;
3211
- }
3212
- for (let col = 0; col < n; col++) {
3213
- let pivotRow = col;
3214
- const pivotCol = aug[pivotRow];
3215
- if (pivotCol === void 0) return null;
3216
- for (let row = col + 1; row < n; row++) {
3217
- const currRowVal = aug[row]?.[col] ?? 0;
3218
- const pivotRowVal = pivotCol[col] ?? 0;
3219
- if (Math.abs(currRowVal) > Math.abs(pivotRowVal)) pivotRow = row;
3220
- }
3221
- const augPivot = aug[pivotRow];
3222
- if (augPivot === void 0 || Math.abs(augPivot[col] ?? 0) < 1e-10) return null;
3223
- [aug[col], aug[pivotRow]] = [aug[pivotRow] ?? [], aug[col] ?? []];
3224
- const scaledPivotRow = aug[col];
3225
- if (scaledPivotRow === void 0) return null;
3226
- const pivot = scaledPivotRow[col] ?? 1;
3227
- for (let j = 0; j < 2 * n; j++) scaledPivotRow[j] = (scaledPivotRow[j] ?? 0) / pivot;
3228
- for (let row = col + 1; row < n; row++) {
3229
- const currRow = aug[row];
3230
- if (currRow === void 0) continue;
3231
- const factor = currRow[col] ?? 0;
3232
- for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (scaledPivotRow[j] ?? 0);
3233
- }
3234
- }
3235
- for (let col = n - 1; col > 0; col--) {
3236
- const colRow = aug[col];
3237
- if (colRow === void 0) return null;
3238
- for (let row = col - 1; row >= 0; row--) {
3239
- const currRow = aug[row];
3240
- if (currRow === void 0) continue;
3241
- const factor = currRow[col] ?? 0;
3242
- for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (colRow[j] ?? 0);
3243
- }
3244
- }
3245
- const inv = [];
3246
- for (let i = 0; i < n; i++) {
3247
- const row = [];
3248
- for (let j = 0; j < n; j++) row[j] = 0;
3249
- inv[i] = row;
3250
- }
3251
- for (let i = 0; i < n; i++) {
3252
- const invRow = inv[i];
3253
- if (invRow === void 0) continue;
3254
- const augRow = aug[i];
3255
- if (augRow === void 0) continue;
3256
- for (let j = 0; j < n; j++) invRow[j] = augRow[n + j] ?? 0;
3257
- }
3258
- return inv;
3259
- }
3260
- /**
3261
- * Rank paths by inverse hitting time between endpoints.
3262
- *
3263
- * @param graph - Source graph
3264
- * @param paths - Paths to rank
3265
- * @param config - Configuration options
3266
- * @returns Ranked paths (highest inverse hitting time first)
3267
- */
3268
- function hittingTime(graph, paths, config) {
3269
- const { includeScores = true, mode = "auto", walks = 1e3, maxSteps = 1e4, seed = 42 } = config ?? {};
3270
- if (paths.length === 0) return {
3271
- paths: [],
3272
- method: "hitting-time"
3273
- };
3274
- const nodeCount = Array.from(graph.nodeIds()).length;
3275
- const actualMode = mode === "auto" ? nodeCount < 100 ? "exact" : "approximate" : mode;
3276
- const rng = new SeededRNG(seed);
3277
- const scored = paths.map((path) => {
3278
- const source = path.nodes[0];
3279
- const target = path.nodes[path.nodes.length - 1];
3280
- if (source === void 0 || target === void 0) return {
3281
- path,
3282
- score: 0
3283
- };
3284
- const ht = actualMode === "exact" ? computeHittingTimeExact(graph, source, target) : computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng);
3285
- return {
3286
- path,
3287
- score: ht > 0 ? 1 / ht : 0
3288
- };
3289
- });
3290
- const maxScore = Math.max(...scored.map((s) => s.score));
3291
- if (!Number.isFinite(maxScore)) return {
3292
- paths: paths.map((path) => ({
3293
- ...path,
3294
- score: 0
3295
- })),
3296
- method: "hitting-time"
3297
- };
3298
- return normaliseAndRank(paths, scored, "hitting-time", includeScores);
3299
- }
3300
- //#endregion
3301
- //#region src/extraction/ego-network.ts
3302
- /**
3303
- * Extract the ego-network (k-hop neighbourhood) of a centre node.
3304
- *
3305
- * The ego-network includes all nodes reachable within k hops from the
3306
- * centre node, plus all edges between those nodes (induced subgraph).
3307
- *
3308
- * For directed graphs, the search follows outgoing edges by default.
3309
- * To include incoming edges, use direction 'both' in the underlying traversal.
3310
- *
3311
- * @param graph - The source graph
3312
- * @param centre - The centre node ID
3313
- * @param options - Extraction options
3314
- * @returns An induced subgraph of the k-hop neighbourhood
3315
- * @throws Error if the centre node does not exist in the graph
3316
- *
3317
- * @example
3318
- * ```typescript
3319
- * // 2-hop neighbourhood
3320
- * const ego = extractEgoNetwork(graph, 'A', { hops: 2 });
3321
- * ```
3322
- */
3323
- function extractEgoNetwork(graph, centre, options) {
3324
- const hops = options?.hops ?? 1;
3325
- if (!graph.hasNode(centre)) throw new Error(`Centre node '${centre}' does not exist in the graph`);
3326
- if (hops < 0) throw new Error(`Hops must be non-negative, got ${String(hops)}`);
3327
- const nodesInEgoNetwork = new Set([centre]);
3328
- if (hops > 0) {
3329
- const visited = new Set([centre]);
3330
- const queue = [[centre, 0]];
3331
- while (queue.length > 0) {
3332
- const entry = queue.shift();
3333
- if (entry === void 0) break;
3334
- const [current, distance] = entry;
3335
- if (distance < hops) {
3336
- for (const neighbour of graph.neighbours(current)) if (!visited.has(neighbour)) {
3337
- visited.add(neighbour);
3338
- nodesInEgoNetwork.add(neighbour);
3339
- queue.push([neighbour, distance + 1]);
3340
- }
3341
- }
3342
- }
3343
- }
3344
- const result = graph.directed ? AdjacencyMapGraph.directed() : AdjacencyMapGraph.undirected();
3345
- for (const nodeId of nodesInEgoNetwork) {
3346
- const nodeData = graph.getNode(nodeId);
3347
- if (nodeData !== void 0) result.addNode(nodeData);
3348
- }
3349
- for (const edge of graph.edges()) if (nodesInEgoNetwork.has(edge.source) && nodesInEgoNetwork.has(edge.target)) result.addEdge(edge);
3350
- return result;
3351
- }
3352
- //#endregion
3353
- //#region src/extraction/k-core.ts
3354
- /**
3355
- * Extract the k-core of a graph.
3356
- *
3357
- * The k-core is the maximal connected subgraph where every node has
3358
- * degree at least k. This is computed using a peeling algorithm that
3359
- * iteratively removes nodes with degree less than k.
3360
- *
3361
- * For undirected graphs, degree counts all adjacent nodes.
3362
- * For directed graphs, degree counts both in- and out-neighbours.
3363
- *
3364
- * @param graph - The source graph
3365
- * @param k - The minimum degree threshold
3366
- * @returns A new graph containing the k-core (may be empty)
3367
- *
3368
- * @example
3369
- * ```typescript
3370
- * // Extract the 3-core (nodes with at least 3 neighbours)
3371
- * const core3 = extractKCore(graph, 3);
3372
- * ```
3373
- */
3374
- function extractKCore(graph, k) {
3375
- if (k < 0) throw new Error(`k must be non-negative, got ${String(k)}`);
3376
- const remaining = /* @__PURE__ */ new Set();
3377
- const degrees = /* @__PURE__ */ new Map();
3378
- for (const nodeId of graph.nodeIds()) {
3379
- remaining.add(nodeId);
3380
- const deg = graph.directed ? graph.degree(nodeId, "both") : graph.degree(nodeId);
3381
- degrees.set(nodeId, deg);
3382
- }
3383
- const toRemove = [];
3384
- for (const [nodeId, deg] of degrees) if (deg < k) toRemove.push(nodeId);
3385
- while (toRemove.length > 0) {
3386
- const nodeId = toRemove.shift();
3387
- if (nodeId === void 0) break;
3388
- if (!remaining.has(nodeId)) continue;
3389
- remaining.delete(nodeId);
3390
- const neighbours = graph.directed ? graph.neighbours(nodeId, "both") : graph.neighbours(nodeId);
3391
- for (const neighbour of neighbours) if (remaining.has(neighbour)) {
3392
- const newDeg = (degrees.get(neighbour) ?? 0) - 1;
3393
- degrees.set(neighbour, newDeg);
3394
- if (newDeg < k && newDeg === k - 1) toRemove.push(neighbour);
3395
- }
3396
- }
3397
- const result = graph.directed ? AdjacencyMapGraph.directed() : AdjacencyMapGraph.undirected();
3398
- for (const nodeId of remaining) {
3399
- const nodeData = graph.getNode(nodeId);
3400
- if (nodeData !== void 0) result.addNode(nodeData);
3401
- }
3402
- for (const edge of graph.edges()) if (remaining.has(edge.source) && remaining.has(edge.target)) result.addEdge(edge);
3403
- return result;
3404
- }
3405
- //#endregion
3406
- //#region src/extraction/truss.ts
3407
- /**
3408
- * Count triangles involving a given edge.
3409
- *
3410
- * For an edge (u, v), count common neighbours of u and v.
3411
- * Each common neighbour w forms a triangle u-v-w.
3412
- *
3413
- * @param graph - The graph
3414
- * @param u - First endpoint
3415
- * @param v - Second endpoint
3416
- * @returns Number of triangles containing the edge (u, v)
3417
- */
3418
- function countEdgeTriangles(graph, u, v) {
3419
- const uNeighbours = new Set(graph.neighbours(u));
3420
- let count = 0;
3421
- for (const w of graph.neighbours(v)) if (w !== u && uNeighbours.has(w)) count++;
3422
- return count;
3423
- }
3424
- /**
3425
- * Extract the k-truss of a graph.
3426
- *
3427
- * The k-truss is the maximal subgraph where every edge participates in
3428
- * at least k-2 triangles. This is computed by iteratively removing edges
3429
- * with fewer than k-2 triangles, then removing isolated nodes.
3430
- *
3431
- * Note: K-truss is typically defined for undirected graphs. For directed
3432
- * graphs, this treats the graph as undirected for triangle counting.
3433
- *
3434
- * @param graph - The source graph
3435
- * @param k - The minimum triangle count threshold (edge must be in >= k-2 triangles)
3436
- * @returns A new graph containing the k-truss (may be empty)
3437
- *
3438
- * @example
3439
- * ```typescript
3440
- * // Extract the 3-truss (edges in at least 1 triangle)
3441
- * const truss3 = extractKTruss(graph, 3);
3442
- * ```
3443
- */
3444
- function extractKTruss(graph, k) {
3445
- if (k < 2) throw new Error(`k must be at least 2, got ${String(k)}`);
3446
- const minTriangles = k - 2;
3447
- const adjacency = /* @__PURE__ */ new Map();
3448
- const edgeData = /* @__PURE__ */ new Map();
3449
- const remainingEdges = /* @__PURE__ */ new Set();
3450
- for (const nodeId of graph.nodeIds()) adjacency.set(nodeId, /* @__PURE__ */ new Set());
3451
- for (const edge of graph.edges()) {
3452
- const { source, target } = edge;
3453
- adjacency.get(source)?.add(target);
3454
- adjacency.get(target)?.add(source);
3455
- const key = source < target ? `${source}::${target}` : `${target}::${source}`;
3456
- edgeData.set(key, edge);
3457
- remainingEdges.add(key);
3458
- }
3459
- const triangleCounts = /* @__PURE__ */ new Map();
3460
- const edgesToRemove = [];
3461
- for (const key of remainingEdges) {
3462
- const edge = edgeData.get(key);
3463
- if (edge !== void 0) {
3464
- const count = countEdgeTriangles(graph, edge.source, edge.target);
3465
- triangleCounts.set(key, count);
3466
- if (count < minTriangles) edgesToRemove.push(key);
3467
- }
3468
- }
3469
- while (edgesToRemove.length > 0) {
3470
- const edgeKey = edgesToRemove.shift();
3471
- if (edgeKey === void 0) break;
3472
- if (!remainingEdges.has(edgeKey)) continue;
3473
- remainingEdges.delete(edgeKey);
3474
- const edge = edgeData.get(edgeKey);
3475
- if (edge === void 0) continue;
3476
- const { source, target } = edge;
3477
- adjacency.get(source)?.delete(target);
3478
- adjacency.get(target)?.delete(source);
3479
- const sourceNeighbours = adjacency.get(source);
3480
- if (sourceNeighbours !== void 0) {
3481
- for (const w of adjacency.get(target) ?? []) if (sourceNeighbours.has(w)) {
3482
- const keySw = source < w ? `${source}::${w}` : `${w}::${source}`;
3483
- const keyTw = target < w ? `${target}::${w}` : `${w}::${target}`;
3484
- for (const keyToUpdate of [keySw, keyTw]) if (remainingEdges.has(keyToUpdate)) {
3485
- const newCount = (triangleCounts.get(keyToUpdate) ?? 0) - 1;
3486
- triangleCounts.set(keyToUpdate, newCount);
3487
- if (newCount < minTriangles && newCount === minTriangles - 1) edgesToRemove.push(keyToUpdate);
3488
- }
3489
- }
3490
- }
3491
- }
3492
- const nodesWithEdges = /* @__PURE__ */ new Set();
3493
- for (const key of remainingEdges) {
3494
- const edge = edgeData.get(key);
3495
- if (edge !== void 0) {
3496
- nodesWithEdges.add(edge.source);
3497
- nodesWithEdges.add(edge.target);
3498
- }
3499
- }
3500
- const result = graph.directed ? AdjacencyMapGraph.directed() : AdjacencyMapGraph.undirected();
3501
- for (const nodeId of nodesWithEdges) {
3502
- const nodeData = graph.getNode(nodeId);
3503
- if (nodeData !== void 0) result.addNode(nodeData);
3504
- }
3505
- for (const key of remainingEdges) {
3506
- const edge = edgeData.get(key);
3507
- if (edge !== void 0 && result.hasNode(edge.source) && result.hasNode(edge.target)) result.addEdge(edge);
3508
- }
3509
- return result;
3510
- }
3511
- /**
3512
- * Compute the truss number for each edge.
3513
- *
3514
- * The truss number of an edge is the largest k such that the edge
3515
- * belongs to the k-truss.
3516
- *
3517
- * @param graph - The source graph
3518
- * @returns Map from edge key (canonical "u::v") to truss number
3519
- *
3520
- * @example
3521
- * ```typescript
3522
- * const trussNumbers = computeTrussNumbers(graph);
3523
- * const edgeKey = 'A::B'; // where A < B lexicographically
3524
- * console.log(`Edge A-B is in the ${trussNumbers.get(edgeKey)}-truss`);
3525
- * ```
3526
- */
3527
- function computeTrussNumbers(graph) {
3528
- const adjacency = /* @__PURE__ */ new Map();
3529
- const edgeData = /* @__PURE__ */ new Map();
3530
- const remainingEdges = /* @__PURE__ */ new Set();
3531
- for (const nodeId of graph.nodeIds()) adjacency.set(nodeId, /* @__PURE__ */ new Set());
3532
- for (const edge of graph.edges()) {
3533
- const { source, target } = edge;
3534
- adjacency.get(source)?.add(target);
3535
- adjacency.get(target)?.add(source);
3536
- const key = source < target ? `${source}::${target}` : `${target}::${source}`;
3537
- edgeData.set(key, edge);
3538
- remainingEdges.add(key);
3539
- }
3540
- const triangleCounts = /* @__PURE__ */ new Map();
3541
- for (const key of remainingEdges) {
3542
- const edge = edgeData.get(key);
3543
- if (edge !== void 0) triangleCounts.set(key, countEdgeTriangles(graph, edge.source, edge.target));
3544
- }
3545
- const trussNumbers = /* @__PURE__ */ new Map();
3546
- const edgesByTriangleCount = /* @__PURE__ */ new Map();
3547
- for (const [key, count] of triangleCounts) {
3548
- if (!edgesByTriangleCount.has(count)) edgesByTriangleCount.set(count, /* @__PURE__ */ new Set());
3549
- edgesByTriangleCount.get(count)?.add(key);
3550
- }
3551
- const sortedCounts = [...edgesByTriangleCount.keys()].sort((a, b) => a - b);
3552
- for (const currentCount of sortedCounts) {
3553
- const bucket = edgesByTriangleCount.get(currentCount);
3554
- if (bucket === void 0) continue;
3555
- while (bucket.size > 0) {
3556
- const edgeKey = bucket.values().next().value;
3557
- if (edgeKey === void 0) break;
3558
- bucket.delete(edgeKey);
3559
- if (!remainingEdges.has(edgeKey)) continue;
3560
- const trussNumber = currentCount + 2;
3561
- trussNumbers.set(edgeKey, trussNumber);
3562
- remainingEdges.delete(edgeKey);
3563
- const edge = edgeData.get(edgeKey);
3564
- if (edge === void 0) continue;
3565
- const { source, target } = edge;
3566
- adjacency.get(source)?.delete(target);
3567
- adjacency.get(target)?.delete(source);
3568
- const sourceNeighbours = adjacency.get(source);
3569
- if (sourceNeighbours !== void 0) {
3570
- for (const w of adjacency.get(target) ?? []) if (sourceNeighbours.has(w)) {
3571
- const keySw = source < w ? `${source}::${w}` : `${w}::${source}`;
3572
- const keyTw = target < w ? `${target}::${w}` : `${w}::${target}`;
3573
- for (const keyToUpdate of [keySw, keyTw]) if (remainingEdges.has(keyToUpdate)) {
3574
- const oldCount = triangleCounts.get(keyToUpdate) ?? 0;
3575
- const newCount = oldCount - 1;
3576
- triangleCounts.set(keyToUpdate, newCount);
3577
- edgesByTriangleCount.get(oldCount)?.delete(keyToUpdate);
3578
- if (!edgesByTriangleCount.has(newCount)) edgesByTriangleCount.set(newCount, /* @__PURE__ */ new Set());
3579
- edgesByTriangleCount.get(newCount)?.add(keyToUpdate);
3580
- }
3581
- }
3582
- }
3583
- }
3584
- }
3585
- return trussNumbers;
3586
- }
3587
- //#endregion
3588
- //#region src/extraction/motif.ts
3589
- /**
3590
- * Canonicalise an edge pattern for hashing.
3591
- *
3592
- * Returns a canonical string representation of a small graph pattern.
3593
- */
3594
- function canonicalisePattern(nodeCount, edges) {
3595
- const permutations = getPermutations(nodeCount);
3596
- let minPattern = null;
3597
- for (const perm of permutations) {
3598
- const transformedEdges = edges.map(([u, v]) => {
3599
- const pu = perm[u] ?? -1;
3600
- const pv = perm[v] ?? -1;
3601
- if (pu < 0 || pv < 0) return;
3602
- return pu < pv ? `${String(pu)}-${String(pv)}` : `${String(pv)}-${String(pu)}`;
3603
- }).filter((edge) => edge !== void 0).sort().join(",");
3604
- if (minPattern === null || transformedEdges < minPattern) minPattern = transformedEdges;
3605
- }
3606
- return minPattern ?? "";
3607
- }
3608
- /**
3609
- * Generate all permutations of [0, n-1].
3610
- */
3611
- function getPermutations(n) {
3612
- if (n === 0) return [[]];
3613
- if (n === 1) return [[0]];
3614
- const result = [];
3615
- const arr = Array.from({ length: n }, (_, i) => i);
3616
- function permute(start) {
3617
- if (start === n - 1) {
3618
- result.push([...arr]);
3619
- return;
3620
- }
3621
- for (let i = start; i < n; i++) {
3622
- const startVal = arr[start];
3623
- const iVal = arr[i];
3624
- if (startVal === void 0 || iVal === void 0) continue;
3625
- arr[start] = iVal;
3626
- arr[i] = startVal;
3627
- permute(start + 1);
3628
- arr[start] = startVal;
3629
- arr[i] = iVal;
3630
- }
3631
- }
3632
- permute(0);
3633
- return result;
3634
- }
3635
- /**
3636
- * Enumerate all 3-node motifs in the graph.
3637
- *
3638
- * A 3-node motif (triad) can be one of 4 isomorphism classes for undirected graphs:
3639
- * - Empty: no edges
3640
- * - 1-edge: single edge
3641
- * - 2-star: two edges sharing a node (path of length 2)
3642
- * - Triangle: three edges (complete graph K3)
3643
- *
3644
- * For directed graphs, there are 16 isomorphism classes.
3645
- *
3646
- * @param graph - The source graph
3647
- * @param includeInstances - Whether to include node instances in the result
3648
- * @returns Motif census with counts and optionally instances
3649
- */
3650
- function enumerate3NodeMotifs(graph, includeInstances) {
3651
- const counts = /* @__PURE__ */ new Map();
3652
- const instances = includeInstances ? /* @__PURE__ */ new Map() : void 0;
3653
- const nodeList = [...graph.nodeIds()];
3654
- const n = nodeList.length;
3655
- for (let i = 0; i < n; i++) {
3656
- const ni = nodeList[i];
3657
- if (ni === void 0) continue;
3658
- for (let j = i + 1; j < n; j++) {
3659
- const nj = nodeList[j];
3660
- if (nj === void 0) continue;
3661
- for (let k = j + 1; k < n; k++) {
3662
- const nk = nodeList[k];
3663
- if (nk === void 0) continue;
3664
- const nodes = [
3665
- ni,
3666
- nj,
3667
- nk
3668
- ];
3669
- const edges = [];
3670
- for (const [u, v] of [
3671
- [0, 1],
3672
- [0, 2],
3673
- [1, 2]
3674
- ]) {
3675
- const nu = nodes[u];
3676
- const nv = nodes[v];
3677
- if (nu === void 0 || nv === void 0) continue;
3678
- if (graph.getEdge(nu, nv) !== void 0) edges.push([u, v]);
3679
- else if (!graph.directed && graph.getEdge(nv, nu) !== void 0) edges.push([u, v]);
3680
- else if (graph.directed && graph.getEdge(nv, nu) !== void 0) edges.push([v, u]);
3681
- }
3682
- const pattern = canonicalisePattern(3, edges);
3683
- const count = counts.get(pattern) ?? 0;
3684
- counts.set(pattern, count + 1);
3685
- if (includeInstances && instances !== void 0) {
3686
- if (!instances.has(pattern)) instances.set(pattern, []);
3687
- const patternInstances = instances.get(pattern);
3688
- if (patternInstances !== void 0) patternInstances.push([
3689
- ni,
3690
- nj,
3691
- nk
3692
- ]);
3693
- }
3694
- }
3695
- }
3696
- }
3697
- if (instances !== void 0) return {
3698
- counts,
3699
- instances
3700
- };
3701
- return { counts };
3702
- }
3703
- /**
3704
- * Enumerate all 4-node motifs in the graph.
3705
- *
3706
- * A 4-node motif can be one of 11 isomorphism classes for undirected graphs
3707
- * (ranging from empty to complete K4), or many more for directed graphs.
3708
- *
3709
- * @param graph - The source graph
3710
- * @param includeInstances - Whether to include node instances in the result
3711
- * @returns Motif census with counts and optionally instances
3712
- */
3713
- function enumerate4NodeMotifs(graph, includeInstances) {
3714
- const counts = /* @__PURE__ */ new Map();
3715
- const instances = includeInstances ? /* @__PURE__ */ new Map() : void 0;
3716
- const nodeList = [...graph.nodeIds()];
3717
- const n = nodeList.length;
3718
- for (let i = 0; i < n; i++) {
3719
- const ni = nodeList[i];
3720
- if (ni === void 0) continue;
3721
- for (let j = i + 1; j < n; j++) {
3722
- const nj = nodeList[j];
3723
- if (nj === void 0) continue;
3724
- for (let k = j + 1; k < n; k++) {
3725
- const nk = nodeList[k];
3726
- if (nk === void 0) continue;
3727
- for (let l = k + 1; l < n; l++) {
3728
- const nl = nodeList[l];
3729
- if (nl === void 0) continue;
3730
- const nodes = [
3731
- ni,
3732
- nj,
3733
- nk,
3734
- nl
3735
- ];
3736
- const edges = [];
3737
- for (const [u, v] of [
3738
- [0, 1],
3739
- [0, 2],
3740
- [0, 3],
3741
- [1, 2],
3742
- [1, 3],
3743
- [2, 3]
3744
- ]) {
3745
- const nu = nodes[u];
3746
- const nv = nodes[v];
3747
- if (nu === void 0 || nv === void 0) continue;
3748
- if (graph.getEdge(nu, nv) !== void 0) edges.push([u, v]);
3749
- else if (!graph.directed && graph.getEdge(nv, nu) !== void 0) edges.push([u, v]);
3750
- else if (graph.directed && graph.getEdge(nv, nu) !== void 0) edges.push([v, u]);
3751
- }
3752
- const pattern = canonicalisePattern(4, edges);
3753
- const count = counts.get(pattern) ?? 0;
3754
- counts.set(pattern, count + 1);
3755
- if (includeInstances && instances !== void 0) {
3756
- if (!instances.has(pattern)) instances.set(pattern, []);
3757
- const patternInstances = instances.get(pattern);
3758
- if (patternInstances !== void 0) patternInstances.push([
3759
- ni,
3760
- nj,
3761
- nk,
3762
- nl
3763
- ]);
3764
- }
3765
- }
3766
- }
3767
- }
3768
- }
3769
- if (instances !== void 0) return {
3770
- counts,
3771
- instances
3772
- };
3773
- return { counts };
3774
- }
3775
- /**
3776
- * Human-readable names for common 3-node motifs.
3777
- */
3778
- var MOTIF_3_NAMES = new Map([
3779
- ["", "empty"],
3780
- ["0-1", "1-edge"],
3781
- ["0-1,0-2", "2-star"],
3782
- ["0-1,1-2", "path-3"],
3783
- ["0-1,0-2,1-2", "triangle"]
3784
- ]);
3785
- /**
3786
- * Human-readable names for common 4-node motifs.
3787
- */
3788
- var MOTIF_4_NAMES = new Map([
3789
- ["", "empty"],
3790
- ["0-1", "1-edge"],
3791
- ["0-1,0-2", "2-star"],
3792
- ["0-1,0-2,0-3", "3-star"],
3793
- ["0-1,0-2,1-2", "triangle"],
3794
- ["0-1,0-2,1-2,2-3", "paw"],
3795
- ["0-1,0-2,2-3", "path-4"],
3796
- ["0-1,0-2,1-3,2-3", "4-cycle"],
3797
- ["0-1,0-2,1-2,0-3,1-3", "diamond"],
3798
- ["0-1,0-2,0-3,1-2,1-3,2-3", "K4"]
3799
- ]);
3800
- /**
3801
- * Enumerate motifs of a given size in the graph.
3802
- *
3803
- * This function counts all occurrences of each distinct motif type
3804
- * (isomorphism class) in the graph. For graphs with many nodes,
3805
- * 4-motif enumeration can be expensive (O(n^4) worst case).
3806
- *
3807
- * @param graph - The source graph
3808
- * @param size - Motif size (3 or 4 nodes)
3809
- * @returns Motif census with counts per motif type
3810
- *
3811
- * @example
3812
- * ```typescript
3813
- * // Count all triangles and other 3-node patterns
3814
- * const census3 = enumerateMotifs(graph, 3);
3815
- * console.log(`Triangles: ${census3.counts.get('0-1,0-2,1-2')}`);
3816
- *
3817
- * // Count 4-node patterns
3818
- * const census4 = enumerateMotifs(graph, 4);
3819
- * ```
3820
- */
3821
- function enumerateMotifs(graph, size) {
3822
- return size === 3 ? enumerate3NodeMotifs(graph, false) : enumerate4NodeMotifs(graph, false);
3823
- }
3824
- /**
3825
- * Enumerate motifs with optional instance tracking.
3826
- *
3827
- * @param graph - The source graph
3828
- * @param size - Motif size (3 or 4 nodes)
3829
- * @param includeInstances - Whether to include node instances
3830
- * @returns Motif census with counts and optionally instances
3831
- */
3832
- function enumerateMotifsWithInstances(graph, size, includeInstances) {
3833
- return size === 3 ? enumerate3NodeMotifs(graph, includeInstances) : enumerate4NodeMotifs(graph, includeInstances);
3834
- }
3835
- /**
3836
- * Get a human-readable name for a motif pattern.
3837
- *
3838
- * @param pattern - The canonical pattern string
3839
- * @param size - Motif size (3 or 4 nodes)
3840
- * @returns A human-readable name, or the pattern itself if unknown
3841
- */
3842
- function getMotifName(pattern, size) {
3843
- return (size === 3 ? MOTIF_3_NAMES : MOTIF_4_NAMES).get(pattern) ?? pattern;
3844
- }
3845
- //#endregion
3846
- //#region src/extraction/induced-subgraph.ts
3847
- /**
3848
- * Extract the induced subgraph containing exactly the specified nodes.
3849
- *
3850
- * The induced subgraph includes all nodes from the input set that exist
3851
- * in the original graph, plus all edges where both endpoints are in the set.
3852
- *
3853
- * @param graph - The source graph
3854
- * @param nodes - Set of node IDs to include in the subgraph
3855
- * @returns A new graph containing the induced subgraph
3856
- *
3857
- * @example
3858
- * ```typescript
3859
- * const subgraph = extractInducedSubgraph(graph, new Set(['A', 'B', 'C']));
3860
- * ```
3861
- */
3862
- function extractInducedSubgraph(graph, nodes) {
3863
- const result = graph.directed ? AdjacencyMapGraph.directed() : AdjacencyMapGraph.undirected();
3864
- for (const nodeId of nodes) {
3865
- const nodeData = graph.getNode(nodeId);
3866
- if (nodeData !== void 0) result.addNode(nodeData);
3867
- }
3868
- for (const edge of graph.edges()) if (result.hasNode(edge.source) && result.hasNode(edge.target)) result.addEdge(edge);
3869
- return result;
3870
- }
3871
- //#endregion
3872
- //#region src/extraction/node-filter.ts
3873
- /**
3874
- * Extract a filtered subgraph based on node and edge predicates.
3875
- *
3876
- * Nodes are first filtered by the node predicate (if provided).
3877
- * Edges are then filtered by the edge predicate (if provided), and only
3878
- * retained if both endpoints pass the node predicate.
3879
- *
3880
- * @param graph - The source graph
3881
- * @param options - Filter options specifying node/edge predicates
3882
- * @returns A new graph containing only nodes and edges that pass the predicates
3883
- *
3884
- * @example
3885
- * ```typescript
3886
- * // Extract subgraph of high-weight nodes
3887
- * const filtered = filterSubgraph(graph, {
3888
- * nodePredicate: (node) => (node.weight ?? 0) > 0.5,
3889
- * removeIsolated: true
3890
- * });
3891
- * ```
3892
- */
3893
- function filterSubgraph(graph, options) {
3894
- const { nodePredicate, edgePredicate, removeIsolated = false } = options ?? {};
3895
- const result = graph.directed ? AdjacencyMapGraph.directed() : AdjacencyMapGraph.undirected();
3896
- const includedNodes = /* @__PURE__ */ new Set();
3897
- for (const nodeId of graph.nodeIds()) {
3898
- const nodeData = graph.getNode(nodeId);
3899
- if (nodeData !== void 0) {
3900
- if (nodePredicate === void 0 || nodePredicate(nodeData)) {
3901
- result.addNode(nodeData);
3902
- includedNodes.add(nodeId);
3903
- }
3904
- }
3905
- }
3906
- for (const edge of graph.edges()) {
3907
- if (!includedNodes.has(edge.source) || !includedNodes.has(edge.target)) continue;
3908
- if (edgePredicate === void 0 || edgePredicate(edge)) result.addEdge(edge);
3909
- }
3910
- if (removeIsolated) {
3911
- const isolatedNodes = [];
3912
- for (const nodeId of result.nodeIds()) if (result.degree(nodeId) === 0) isolatedNodes.push(nodeId);
3913
- for (const nodeId of isolatedNodes) result.removeNode(nodeId);
3914
- }
3915
- return result;
3916
- }
3917
- //#endregion
3918
- export { AdjacencyMapGraph, GPUContext, GPUNotAvailableError, PriorityQueue, _computeMean, adamicAdar, adamicAdarAsync, adaptive, adaptiveAsync, approximateClusteringCoefficient, assertWebGPUAvailable, base, baseAsync, batchClusteringCoefficients, betweenness, bfs, bfsWithPath, collectAsyncIterable, communicability, computeJaccard, computeTrussNumbers, cosine, cosineAsync, countEdgesOfType, countNodesOfType, createGPUContext, createResultBuffer, csrToGPUBuffers, defaultYieldStrategy, degreeSum, detectWebGPU, dfs, dfsPriority, dfsPriorityAsync, dfsPriorityFn, dfsWithPath, dome, domeAsync, domeHighDegree, domeHighDegreeAsync, edge, edgeAsync, entropyFromCounts, enumerateMotifs, enumerateMotifsWithInstances, etch, etchAsync, extractEgoNetwork, extractInducedSubgraph, extractKCore, extractKTruss, filterSubgraph, flux, fluxAsync, frontierBalanced, frontierBalancedAsync, fuse, fuseAsync, getGPUContext, getMotifName, graphToCSR, grasp, hae, haeAsync, hittingTime, hubPromoted, hubPromotedAsync, isWebGPUAvailable, jaccard, jaccardArithmetic, jaccardAsync, kHop, katz, lace, laceAsync, localClusteringCoefficient, localTypeEntropy, maze, mazeAsync, miniBatchKMeans, neighbourIntersection, neighbourOverlap, neighbourSet, normaliseFeatures, normaliseFeatures as zScoreNormalise, normalisedEntropy, notch, notchAsync, opDegree, opGetEdge, opGetNode, opHasNode, opNeighbours, opProgress, opYield, overlapCoefficient, overlapCoefficientAsync, pagerank, parse, parseAsync, pipe, pipeAsync, randomPriority, randomPriorityAsync, randomRanking, randomWalk, reach, reachAsync, readBufferToCPU, resistanceDistance, resolveAsyncOp, resolveSyncOp, resourceAllocation, resourceAllocationAsync, runAsync, runSync, sage, sageAsync, scale, scaleAsync, shannonEntropy, shortest, sift, siftAsync, skew, skewAsync, sorensen, sorensenAsync, span, spanAsync, standardBfs, standardBfsAsync, stratified, tide, tideAsync, warp, warpAsync, widestPath };
3919
-
3920
- //# sourceMappingURL=index.js.map
16
+ import { computeTrussNumbers, enumerateMotifs, enumerateMotifsWithInstances, extractEgoNetwork, extractInducedSubgraph, extractKCore, extractKTruss, filterSubgraph, getMotifName } from "../extraction/index.js";
17
+ import "../async/index.js";
18
+ export { AdjacencyMapGraph, GPUNotAvailableError, PriorityQueue, _computeMean, adamicAdar, adamicAdarAsync, adaptive, adaptiveAsync, approximateClusteringCoefficient, assertWebGPUAvailable, base, baseAsync, batchClusteringCoefficients, betweenness, bfs, bfsWithPath, collectAsyncIterable, communicability, computeJaccard, computeTrussNumbers, cosine, cosineAsync, countEdgesOfType, countNodesOfType, csrToTypedBuffers, defaultYieldStrategy, degreeSum, detectWebGPU, dfs, dfsPriority, dfsPriorityAsync, dfsPriorityFn, dfsWithPath, dome, domeAsync, domeHighDegree, domeHighDegreeAsync, edge, edgeAsync, entropyFromCounts, enumerateMotifs, enumerateMotifsWithInstances, etch, etchAsync, extractEgoNetwork, extractInducedSubgraph, extractKCore, extractKTruss, filterSubgraph, flux, fluxAsync, frontierBalanced, frontierBalancedAsync, fuse, fuseAsync, getMotifName, gpuBfsLevels, gpuDegreeHistogram, gpuJaccardBatch, gpuPageRank, gpuSpmv, graphToCSR, grasp, hae, haeAsync, hittingTime, hubPromoted, hubPromotedAsync, initGPU, initGPUFromDevice, isWebGPUAvailable, jaccard, jaccardArithmetic, jaccardAsync, kHop, katz, lace, laceAsync, localClusteringCoefficient, localTypeEntropy, maze, mazeAsync, miniBatchKMeans, neighbourIntersection, neighbourOverlap, neighbourSet, normaliseFeatures, normaliseFeatures as zScoreNormalise, normalisedEntropy, notch, notchAsync, opDegree, opGetEdge, opGetNode, opHasNode, opNeighbours, opProgress, opYield, overlapCoefficient, overlapCoefficientAsync, pagerank, parse, parseAsync, pipe, pipeAsync, randomPriority, randomPriorityAsync, randomRanking, randomWalk, reach, reachAsync, resistanceDistance, resolveAsyncOp, resolveSyncOp, resourceAllocation, resourceAllocationAsync, runAsync, runSync, sage, sageAsync, scale, scaleAsync, shannonEntropy, shortest, sift, siftAsync, skew, skewAsync, sorensen, sorensenAsync, span, spanAsync, standardBfs, standardBfsAsync, stratified, tide, tideAsync, warp, warpAsync, widestPath, withBackend };