graphwise 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/dist/__test__/fixtures/graphs/index.d.ts +1 -0
  2. package/dist/__test__/fixtures/graphs/index.d.ts.map +1 -1
  3. package/dist/__test__/fixtures/graphs/linear-chain.d.ts +18 -0
  4. package/dist/__test__/fixtures/graphs/linear-chain.d.ts.map +1 -0
  5. package/dist/__test__/fixtures/helpers.d.ts +20 -1
  6. package/dist/__test__/fixtures/helpers.d.ts.map +1 -1
  7. package/dist/__test__/fixtures/index.d.ts +1 -0
  8. package/dist/__test__/fixtures/index.d.ts.map +1 -1
  9. package/dist/__test__/fixtures/metrics.d.ts +86 -0
  10. package/dist/__test__/fixtures/metrics.d.ts.map +1 -0
  11. package/dist/__test__/fixtures/metrics.unit.test.d.ts +7 -0
  12. package/dist/__test__/fixtures/metrics.unit.test.d.ts.map +1 -0
  13. package/dist/expansion/dfs-priority.d.ts +23 -0
  14. package/dist/expansion/dfs-priority.d.ts.map +1 -0
  15. package/dist/expansion/dfs-priority.unit.test.d.ts +2 -0
  16. package/dist/expansion/dfs-priority.unit.test.d.ts.map +1 -0
  17. package/dist/expansion/edge.d.ts.map +1 -1
  18. package/dist/expansion/flux.d.ts.map +1 -1
  19. package/dist/expansion/fuse.d.ts.map +1 -1
  20. package/dist/expansion/index.d.ts +3 -0
  21. package/dist/expansion/index.d.ts.map +1 -1
  22. package/dist/expansion/k-hop.d.ts +26 -0
  23. package/dist/expansion/k-hop.d.ts.map +1 -0
  24. package/dist/expansion/k-hop.unit.test.d.ts +2 -0
  25. package/dist/expansion/k-hop.unit.test.d.ts.map +1 -0
  26. package/dist/expansion/lace.d.ts.map +1 -1
  27. package/dist/expansion/maze.d.ts.map +1 -1
  28. package/dist/expansion/priority-helpers.d.ts +43 -0
  29. package/dist/expansion/priority-helpers.d.ts.map +1 -0
  30. package/dist/expansion/random-walk.d.ts +35 -0
  31. package/dist/expansion/random-walk.d.ts.map +1 -0
  32. package/dist/expansion/random-walk.unit.test.d.ts +2 -0
  33. package/dist/expansion/random-walk.unit.test.d.ts.map +1 -0
  34. package/dist/expansion/sage.d.ts.map +1 -1
  35. package/dist/expansion/sift.d.ts.map +1 -1
  36. package/dist/expansion/warp.d.ts.map +1 -1
  37. package/dist/index/index.cjs +887 -297
  38. package/dist/index/index.cjs.map +1 -1
  39. package/dist/index/index.js +878 -299
  40. package/dist/index/index.js.map +1 -1
  41. package/dist/ranking/baselines/betweenness.d.ts.map +1 -1
  42. package/dist/ranking/baselines/communicability.d.ts.map +1 -1
  43. package/dist/ranking/baselines/degree-sum.d.ts.map +1 -1
  44. package/dist/ranking/baselines/hitting-time.d.ts +27 -0
  45. package/dist/ranking/baselines/hitting-time.d.ts.map +1 -0
  46. package/dist/ranking/baselines/hitting-time.unit.test.d.ts +2 -0
  47. package/dist/ranking/baselines/hitting-time.unit.test.d.ts.map +1 -0
  48. package/dist/ranking/baselines/index.d.ts +1 -0
  49. package/dist/ranking/baselines/index.d.ts.map +1 -1
  50. package/dist/ranking/baselines/jaccard-arithmetic.d.ts.map +1 -1
  51. package/dist/ranking/baselines/katz.d.ts.map +1 -1
  52. package/dist/ranking/baselines/pagerank.d.ts.map +1 -1
  53. package/dist/ranking/baselines/random-ranking.d.ts.map +1 -1
  54. package/dist/ranking/baselines/resistance-distance.d.ts.map +1 -1
  55. package/dist/ranking/baselines/shortest.d.ts.map +1 -1
  56. package/dist/ranking/baselines/utils.d.ts +20 -0
  57. package/dist/ranking/baselines/utils.d.ts.map +1 -0
  58. package/dist/ranking/baselines/widest-path.d.ts.map +1 -1
  59. package/dist/ranking/mi/adaptive.d.ts.map +1 -1
  60. package/dist/ranking/mi/cosine.d.ts +13 -0
  61. package/dist/ranking/mi/cosine.d.ts.map +1 -0
  62. package/dist/ranking/mi/cosine.unit.test.d.ts +2 -0
  63. package/dist/ranking/mi/cosine.unit.test.d.ts.map +1 -0
  64. package/dist/ranking/mi/etch.d.ts.map +1 -1
  65. package/dist/ranking/mi/hub-promoted.d.ts +13 -0
  66. package/dist/ranking/mi/hub-promoted.d.ts.map +1 -0
  67. package/dist/ranking/mi/hub-promoted.unit.test.d.ts +2 -0
  68. package/dist/ranking/mi/hub-promoted.unit.test.d.ts.map +1 -0
  69. package/dist/ranking/mi/index.d.ts +5 -0
  70. package/dist/ranking/mi/index.d.ts.map +1 -1
  71. package/dist/ranking/mi/jaccard.d.ts.map +1 -1
  72. package/dist/ranking/mi/notch.d.ts.map +1 -1
  73. package/dist/ranking/mi/overlap-coefficient.d.ts +13 -0
  74. package/dist/ranking/mi/overlap-coefficient.d.ts.map +1 -0
  75. package/dist/ranking/mi/overlap-coefficient.unit.test.d.ts +2 -0
  76. package/dist/ranking/mi/overlap-coefficient.unit.test.d.ts.map +1 -0
  77. package/dist/ranking/mi/resource-allocation.d.ts +13 -0
  78. package/dist/ranking/mi/resource-allocation.d.ts.map +1 -0
  79. package/dist/ranking/mi/resource-allocation.unit.test.d.ts +2 -0
  80. package/dist/ranking/mi/resource-allocation.unit.test.d.ts.map +1 -0
  81. package/dist/ranking/mi/scale.d.ts.map +1 -1
  82. package/dist/ranking/mi/skew.d.ts.map +1 -1
  83. package/dist/ranking/mi/sorensen.d.ts +13 -0
  84. package/dist/ranking/mi/sorensen.d.ts.map +1 -0
  85. package/dist/ranking/mi/sorensen.unit.test.d.ts +2 -0
  86. package/dist/ranking/mi/sorensen.unit.test.d.ts.map +1 -0
  87. package/dist/ranking/mi/span.d.ts.map +1 -1
  88. package/dist/ranking/mi/types.d.ts +1 -1
  89. package/dist/ranking/mi/types.d.ts.map +1 -1
  90. package/dist/schemas/graph.d.ts +1 -1
  91. package/dist/utils/index.cjs +22 -0
  92. package/dist/utils/index.cjs.map +1 -1
  93. package/dist/utils/index.js +22 -1
  94. package/dist/utils/index.js.map +1 -1
  95. package/dist/utils/neighbours.d.ts +23 -0
  96. package/dist/utils/neighbours.d.ts.map +1 -1
  97. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { AdjacencyMapGraph } from "../graph/index.js";
2
2
  import { bfs, bfsWithPath, dfs, dfsWithPath } from "../traversal/index.js";
3
3
  import { PriorityQueue } from "../structures/index.js";
4
4
  import { n as miniBatchKMeans, r as normaliseFeatures, t as _computeMean } from "../kmeans-87ExSUNZ.js";
5
- import { approximateClusteringCoefficient, batchClusteringCoefficients, countEdgesOfType, countNodesOfType, entropyFromCounts, localClusteringCoefficient, localTypeEntropy, neighbourIntersection, neighbourOverlap, neighbourSet, normalisedEntropy, shannonEntropy } from "../utils/index.js";
5
+ import { approximateClusteringCoefficient, batchClusteringCoefficients, computeJaccard, countEdgesOfType, countNodesOfType, entropyFromCounts, localClusteringCoefficient, localTypeEntropy, neighbourIntersection, neighbourOverlap, neighbourSet, normalisedEntropy, shannonEntropy } from "../utils/index.js";
6
6
  import { grasp, stratified } from "../seeds/index.js";
7
7
  import { GPUContext, GPUNotAvailableError, assertWebGPUAvailable, createGPUContext, createResultBuffer, csrToGPUBuffers, detectWebGPU, getGPUContext, graphToCSR, isWebGPUAvailable, readBufferToCPU } from "../gpu/index.js";
8
8
  //#region src/expansion/base.ts
@@ -23,7 +23,7 @@ function degreePriority(_nodeId, context) {
23
23
  function base(graph, seeds, config) {
24
24
  const startTime = performance.now();
25
25
  const { maxNodes = 0, maxIterations = 0, maxPaths = 0, priority = degreePriority, debug = false } = config ?? {};
26
- if (seeds.length === 0) return emptyResult("base", startTime);
26
+ if (seeds.length === 0) return emptyResult$2("base", startTime);
27
27
  const numFrontiers = seeds.length;
28
28
  const allVisited = /* @__PURE__ */ new Set();
29
29
  const combinedVisited = /* @__PURE__ */ new Map();
@@ -104,7 +104,7 @@ function base(graph, seeds, config) {
104
104
  const otherVisited = visitedByFrontier[otherFrontier];
105
105
  if (otherVisited === void 0) continue;
106
106
  if (otherVisited.has(nodeId)) {
107
- const path = reconstructPath(nodeId, activeFrontier, otherFrontier, predecessors, seeds);
107
+ const path = reconstructPath$1(nodeId, activeFrontier, otherFrontier, predecessors, seeds);
108
108
  if (path !== null) {
109
109
  discoveredPaths.push(path);
110
110
  if (debug) console.log(`[BASE] Path found: ${path.nodes.join(" -> ")}`);
@@ -169,7 +169,7 @@ function createPriorityContext(graph, nodeId, frontierIndex, combinedVisited, al
169
169
  /**
170
170
  * Reconstruct path from collision point.
171
171
  */
172
- function reconstructPath(collisionNode, frontierA, frontierB, predecessors, seeds) {
172
+ function reconstructPath$1(collisionNode, frontierA, frontierB, predecessors, seeds) {
173
173
  const pathA = [collisionNode];
174
174
  const predA = predecessors[frontierA];
175
175
  if (predA !== void 0) {
@@ -205,7 +205,7 @@ function reconstructPath(collisionNode, frontierA, frontierB, predecessors, seed
205
205
  /**
206
206
  * Create an empty result for early termination.
207
207
  */
208
- function emptyResult(algorithm, startTime) {
208
+ function emptyResult$2(algorithm, startTime) {
209
209
  return {
210
210
  paths: [],
211
211
  sampledNodes: /* @__PURE__ */ new Set(),
@@ -254,46 +254,12 @@ function domeHighDegree(graph, seeds, config) {
254
254
  });
255
255
  }
256
256
  //#endregion
257
- //#region src/expansion/edge.ts
258
- var EPSILON$1 = 1e-10;
259
- /**
260
- * Priority function using local type entropy.
261
- * Lower values = higher priority (expanded first).
262
- */
263
- function edgePriority(nodeId, context) {
264
- const graph = context.graph;
265
- const neighbours = graph.neighbours(nodeId);
266
- const neighbourTypes = [];
267
- for (const neighbour of neighbours) {
268
- const node = graph.getNode(neighbour);
269
- neighbourTypes.push(node?.type ?? "default");
270
- }
271
- return 1 / (localTypeEntropy(neighbourTypes) + EPSILON$1) * Math.log(context.degree + 1);
272
- }
273
- /**
274
- * Run EDGE expansion (Entropy-Driven Graph Expansion).
275
- *
276
- * Discovers paths by prioritising nodes with diverse neighbour types,
277
- * deferring nodes with homogeneous neighbourhoods.
278
- *
279
- * @param graph - Source graph
280
- * @param seeds - Seed nodes for expansion
281
- * @param config - Expansion configuration
282
- * @returns Expansion result with discovered paths
283
- */
284
- function edge(graph, seeds, config) {
285
- return base(graph, seeds, {
286
- ...config,
287
- priority: edgePriority
288
- });
289
- }
290
- //#endregion
291
257
  //#region src/expansion/hae.ts
292
258
  var EPSILON = 1e-10;
293
259
  /**
294
260
  * Default type mapper - uses node.type property.
295
261
  */
296
- function defaultTypeMapper(node) {
262
+ function defaultTypeMapper$1(node) {
297
263
  return node.type ?? "default";
298
264
  }
299
265
  /**
@@ -323,13 +289,34 @@ function createHAEPriority(typeMapper) {
323
289
  * @returns Expansion result with discovered paths
324
290
  */
325
291
  function hae(graph, seeds, config) {
326
- const typeMapper = config?.typeMapper ?? defaultTypeMapper;
292
+ const typeMapper = config?.typeMapper ?? defaultTypeMapper$1;
327
293
  return base(graph, seeds, {
328
294
  ...config,
329
295
  priority: createHAEPriority(typeMapper)
330
296
  });
331
297
  }
332
298
  //#endregion
299
+ //#region src/expansion/edge.ts
300
+ /** Default type mapper: reads `node.type`, falling back to "default". */
301
+ var defaultTypeMapper = (n) => typeof n.type === "string" ? n.type : "default";
302
+ /**
303
+ * Run EDGE expansion (Entropy-Driven Graph Expansion).
304
+ *
305
+ * Discovers paths by prioritising nodes with diverse neighbour types,
306
+ * deferring nodes with homogeneous neighbourhoods.
307
+ *
308
+ * @param graph - Source graph
309
+ * @param seeds - Seed nodes for expansion
310
+ * @param config - Expansion configuration
311
+ * @returns Expansion result with discovered paths
312
+ */
313
+ function edge(graph, seeds, config) {
314
+ return hae(graph, seeds, {
315
+ ...config,
316
+ typeMapper: defaultTypeMapper
317
+ });
318
+ }
319
+ //#endregion
333
320
  //#region src/expansion/pipe.ts
334
321
  /**
335
322
  * Priority function using path potential.
@@ -365,6 +352,69 @@ function pipe(graph, seeds, config) {
365
352
  });
366
353
  }
367
354
  //#endregion
355
+ //#region src/expansion/priority-helpers.ts
356
+ /**
357
+ * Compute the average mutual information between a node and all visited
358
+ * nodes in the same frontier.
359
+ *
360
+ * Returns a value in [0, 1] — higher means the node is more similar
361
+ * (on average) to already-visited same-frontier nodes.
362
+ *
363
+ * @param graph - Source graph
364
+ * @param nodeId - Node being prioritised
365
+ * @param context - Current priority context
366
+ * @param mi - MI function to use for pairwise scoring
367
+ * @returns Average MI score, or 0 if no same-frontier visited nodes exist
368
+ */
369
+ function avgFrontierMI(graph, nodeId, context, mi) {
370
+ const { frontierIndex, visitedByFrontier } = context;
371
+ let total = 0;
372
+ let count = 0;
373
+ for (const [visitedId, idx] of visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
374
+ total += mi(graph, visitedId, nodeId);
375
+ count++;
376
+ }
377
+ return count > 0 ? total / count : 0;
378
+ }
379
+ /**
380
+ * Count the number of a node's neighbours that have been visited by
381
+ * frontiers other than the node's own frontier.
382
+ *
383
+ * A higher count indicates this node is likely to bridge two frontiers,
384
+ * making it a strong candidate for path completion.
385
+ *
386
+ * @param graph - Source graph
387
+ * @param nodeId - Node being evaluated
388
+ * @param context - Current priority context
389
+ * @returns Number of neighbours visited by other frontiers
390
+ */
391
+ function countCrossFrontierNeighbours(graph, nodeId, context) {
392
+ const { frontierIndex, visitedByFrontier } = context;
393
+ const nodeNeighbours = new Set(graph.neighbours(nodeId));
394
+ let count = 0;
395
+ for (const [visitedId, idx] of visitedByFrontier) if (idx !== frontierIndex && nodeNeighbours.has(visitedId)) count++;
396
+ return count;
397
+ }
398
+ /**
399
+ * Incrementally update salience counts for paths discovered since the
400
+ * last update.
401
+ *
402
+ * Iterates only over paths from `fromIndex` onwards, avoiding redundant
403
+ * re-processing of already-counted paths.
404
+ *
405
+ * @param salienceCounts - Mutable map of node ID to salience count (mutated in place)
406
+ * @param paths - Full list of discovered paths
407
+ * @param fromIndex - Index to start counting from (exclusive of earlier paths)
408
+ * @returns The new `fromIndex` value (i.e. `paths.length` after update)
409
+ */
410
+ function updateSalienceCounts(salienceCounts, paths, fromIndex) {
411
+ for (let i = fromIndex; i < paths.length; i++) {
412
+ const path = paths[i];
413
+ if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
414
+ }
415
+ return paths.length;
416
+ }
417
+ //#endregion
368
418
  //#region src/expansion/sage.ts
369
419
  /**
370
420
  * Run SAGE expansion algorithm.
@@ -388,13 +438,7 @@ function sage(graph, seeds, config) {
388
438
  function sagePriority(nodeId, context) {
389
439
  const pathCount = context.discoveredPaths.length;
390
440
  if (pathCount > 0 && !inPhase2) inPhase2 = true;
391
- if (pathCount > lastPathCount) {
392
- for (let i = lastPathCount; i < pathCount; i++) {
393
- const path = context.discoveredPaths[i];
394
- if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
395
- }
396
- lastPathCount = pathCount;
397
- }
441
+ if (pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
398
442
  if (!inPhase2) return Math.log(context.degree + 1);
399
443
  return -((salienceCounts.get(nodeId) ?? 0) * 1e3 - context.degree);
400
444
  }
@@ -416,10 +460,9 @@ function sage(graph, seeds, config) {
416
460
  */
417
461
  function jaccard(graph, source, target, config) {
418
462
  const { epsilon = 1e-10 } = config ?? {};
419
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
420
- if (union === 0) return 0;
421
- const score = intersection / union;
422
- return Math.max(epsilon, score);
463
+ const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = computeJaccard(graph, source, target);
464
+ if (sourceNeighbours.size === 0 && targetNeighbours.size === 0) return 0;
465
+ return Math.max(epsilon, jaccardScore);
423
466
  }
424
467
  //#endregion
425
468
  //#region src/expansion/reach.ts
@@ -505,15 +548,9 @@ function maze(graph, seeds, config) {
505
548
  const pathCount = context.discoveredPaths.length;
506
549
  if (pathCount >= DEFAULT_PHASE2_THRESHOLD && !inPhase2) {
507
550
  inPhase2 = true;
508
- for (const path of context.discoveredPaths) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
509
- }
510
- if (inPhase2 && pathCount > lastPathCount) {
511
- for (let i = lastPathCount; i < pathCount; i++) {
512
- const path = context.discoveredPaths[i];
513
- if (path !== void 0) for (const node of path.nodes) salienceCounts.set(node, (salienceCounts.get(node) ?? 0) + 1);
514
- }
515
- lastPathCount = pathCount;
551
+ updateSalienceCounts(salienceCounts, context.discoveredPaths, 0);
516
552
  }
553
+ if (inPhase2 && pathCount > lastPathCount) lastPathCount = updateSalienceCounts(salienceCounts, context.discoveredPaths, lastPathCount);
517
554
  const nodeNeighbours = graph.neighbours(nodeId);
518
555
  let pathPotential = 0;
519
556
  for (const neighbour of nodeNeighbours) {
@@ -565,22 +602,11 @@ function tide(graph, seeds, config) {
565
602
  /**
566
603
  * LACE priority function.
567
604
  *
568
- * Priority = 1 - MI(source, neighbour)
569
- * Higher MI = lower priority value = explored first
605
+ * Priority = 1 - avgMI(node, same-frontier visited nodes)
606
+ * Higher average MI = lower priority value = explored first
570
607
  */
571
608
  function lacePriority(nodeId, context, mi) {
572
- const graph = context.graph;
573
- const frontierIndex = context.frontierIndex;
574
- let maxMi = 0;
575
- let totalMi = 0;
576
- let count = 0;
577
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
578
- const edgeMi = mi(graph, visitedId, nodeId);
579
- totalMi += edgeMi;
580
- count++;
581
- if (edgeMi > maxMi) maxMi = edgeMi;
582
- }
583
- return 1 - (count > 0 ? totalMi / count : 0);
609
+ return 1 - avgFrontierMI(context.graph, nodeId, context, mi);
584
610
  }
585
611
  /**
586
612
  * Run LACE expansion algorithm.
@@ -604,18 +630,15 @@ function lace(graph, seeds, config) {
604
630
  //#endregion
605
631
  //#region src/expansion/warp.ts
606
632
  /**
607
- * PIPE priority function.
633
+ * WARP priority function.
608
634
  *
609
635
  * Priority = 1 / (1 + bridge_score)
610
- * Bridge score = neighbourhood overlap with other frontiers
611
- * Higher bridge score = more likely to be on paths = explored first
636
+ * Bridge score = cross-frontier neighbour count plus bonus for nodes
637
+ * already on discovered paths.
638
+ * Higher bridge score = more likely to complete paths = explored first.
612
639
  */
613
640
  function warpPriority(nodeId, context) {
614
- const graph = context.graph;
615
- const currentFrontier = context.frontierIndex;
616
- const nodeNeighbours = new Set(graph.neighbours(nodeId));
617
- let bridgeScore = 0;
618
- for (const [visitedId, frontierIdx] of context.visitedByFrontier) if (frontierIdx !== currentFrontier && nodeNeighbours.has(visitedId)) bridgeScore++;
641
+ let bridgeScore = countCrossFrontierNeighbours(context.graph, nodeId, context);
619
642
  for (const path of context.discoveredPaths) if (path.nodes.includes(nodeId)) bridgeScore += 2;
620
643
  return 1 / (1 + bridgeScore);
621
644
  }
@@ -639,24 +662,15 @@ function warp(graph, seeds, config) {
639
662
  //#endregion
640
663
  //#region src/expansion/fuse.ts
641
664
  /**
642
- * SAGE priority function.
665
+ * FUSE priority function.
643
666
  *
644
- * Combines degree with salience:
645
- * Priority = (1 - w) * degree + w * (1 - avg_salience)
646
- * Lower values = higher priority
667
+ * Combines degree with average frontier MI as a salience proxy:
668
+ * Priority = (1 - w) * degree + w * (1 - avgMI)
669
+ * Lower values = higher priority; high salience lowers priority
647
670
  */
648
671
  function fusePriority(nodeId, context, mi, salienceWeight) {
649
- const graph = context.graph;
650
- const degree = context.degree;
651
- const frontierIndex = context.frontierIndex;
652
- let totalSalience = 0;
653
- let count = 0;
654
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
655
- totalSalience += mi(graph, visitedId, nodeId);
656
- count++;
657
- }
658
- const avgSalience = count > 0 ? totalSalience / count : 0;
659
- return (1 - salienceWeight) * degree + salienceWeight * (1 - avgSalience);
672
+ const avgSalience = avgFrontierMI(context.graph, nodeId, context, mi);
673
+ return (1 - salienceWeight) * context.degree + salienceWeight * (1 - avgSalience);
660
674
  }
661
675
  /**
662
676
  * Run FUSE expansion algorithm.
@@ -680,20 +694,13 @@ function fuse(graph, seeds, config) {
680
694
  //#endregion
681
695
  //#region src/expansion/sift.ts
682
696
  /**
683
- * REACH priority function (phase 2).
697
+ * REACH (SIFT) priority function.
684
698
  *
685
- * Uses learned MI threshold to prioritise high-MI edges.
699
+ * Prioritises nodes with average frontier MI above the threshold;
700
+ * falls back to degree-based ordering for those below it.
686
701
  */
687
702
  function siftPriority(nodeId, context, mi, miThreshold) {
688
- const graph = context.graph;
689
- const frontierIndex = context.frontierIndex;
690
- let totalMi = 0;
691
- let count = 0;
692
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx === frontierIndex && visitedId !== nodeId) {
693
- totalMi += mi(graph, visitedId, nodeId);
694
- count++;
695
- }
696
- const avgMi = count > 0 ? totalMi / count : 0;
703
+ const avgMi = avgFrontierMI(context.graph, nodeId, context, mi);
697
704
  if (avgMi >= miThreshold) return 1 - avgMi;
698
705
  else return context.degree + 100;
699
706
  }
@@ -735,16 +742,6 @@ function localDensity(graph, nodeId) {
735
742
  return edges / maxEdges;
736
743
  }
737
744
  /**
738
- * Compute bridge score (how many other frontiers visit neighbours).
739
- */
740
- function bridgeScore(nodeId, context) {
741
- const currentFrontier = context.frontierIndex;
742
- const nodeNeighbours = new Set(context.graph.neighbours(nodeId));
743
- let score = 0;
744
- for (const [visitedId, idx] of context.visitedByFrontier) if (idx !== currentFrontier && nodeNeighbours.has(visitedId)) score++;
745
- return score;
746
- }
747
- /**
748
745
  * MAZE adaptive priority function.
749
746
  *
750
747
  * Switches strategies based on local conditions:
@@ -756,10 +753,10 @@ function fluxPriority(nodeId, context, densityThreshold, bridgeThreshold) {
756
753
  const graph = context.graph;
757
754
  const degree = context.degree;
758
755
  const density = localDensity(graph, nodeId);
759
- const bridge = bridgeScore(nodeId, context);
756
+ const bridge = countCrossFrontierNeighbours(graph, nodeId, context);
760
757
  const numFrontiers = new Set(context.visitedByFrontier.values()).size;
761
758
  if ((numFrontiers > 0 ? bridge / numFrontiers : 0) >= bridgeThreshold) return 1 / (1 + bridge);
762
- else if (density >= densityThreshold) return -degree;
759
+ else if (density >= densityThreshold) return 1 / (degree + 1);
763
760
  else return degree;
764
761
  }
765
762
  /**
@@ -857,6 +854,346 @@ function randomPriority(graph, seeds, config) {
857
854
  });
858
855
  }
859
856
  //#endregion
857
+ //#region src/expansion/dfs-priority.ts
858
+ /**
859
+ * DFS priority function: negative iteration produces LIFO ordering.
860
+ *
861
+ * Lower priority values are expanded first, so negating the iteration
862
+ * counter ensures the most recently enqueued node is always next.
863
+ */
864
+ function dfsPriorityFn(_nodeId, context) {
865
+ return -context.iteration;
866
+ }
867
+ /**
868
+ * Run DFS-priority expansion (LIFO discovery order).
869
+ *
870
+ * Uses the BASE framework with a negative-iteration priority function,
871
+ * which causes the most recently discovered node to be expanded first —
872
+ * equivalent to depth-first search behaviour.
873
+ *
874
+ * @param graph - Source graph
875
+ * @param seeds - Seed nodes for expansion
876
+ * @param config - Expansion configuration
877
+ * @returns Expansion result with discovered paths
878
+ */
879
+ function dfsPriority(graph, seeds, config) {
880
+ return base(graph, seeds, {
881
+ ...config,
882
+ priority: dfsPriorityFn
883
+ });
884
+ }
885
+ //#endregion
886
+ //#region src/expansion/k-hop.ts
887
+ /**
888
+ * Run k-hop expansion (fixed-depth BFS).
889
+ *
890
+ * Explores all nodes reachable within exactly k hops of any seed using
891
+ * breadth-first search. Paths between seeds are detected when a node
892
+ * is reached by frontiers from two different seeds.
893
+ *
894
+ * @param graph - Source graph
895
+ * @param seeds - Seed nodes for expansion
896
+ * @param config - K-hop configuration (k defaults to 2)
897
+ * @returns Expansion result with discovered paths
898
+ */
899
+ function kHop(graph, seeds, config) {
900
+ const startTime = performance.now();
901
+ const { k = 2 } = config ?? {};
902
+ if (seeds.length === 0) return emptyResult$1(startTime);
903
+ const visitedByFrontier = seeds.map(() => /* @__PURE__ */ new Map());
904
+ const firstVisitedBy = /* @__PURE__ */ new Map();
905
+ const allVisited = /* @__PURE__ */ new Set();
906
+ const sampledEdgeMap = /* @__PURE__ */ new Map();
907
+ const discoveredPaths = [];
908
+ let iterations = 0;
909
+ let edgesTraversed = 0;
910
+ for (let i = 0; i < seeds.length; i++) {
911
+ const seed = seeds[i];
912
+ if (seed === void 0) continue;
913
+ if (!graph.hasNode(seed.id)) continue;
914
+ visitedByFrontier[i]?.set(seed.id, null);
915
+ allVisited.add(seed.id);
916
+ if (!firstVisitedBy.has(seed.id)) firstVisitedBy.set(seed.id, i);
917
+ else {
918
+ const otherIdx = firstVisitedBy.get(seed.id) ?? -1;
919
+ if (otherIdx < 0) continue;
920
+ const fromSeed = seeds[otherIdx];
921
+ const toSeed = seeds[i];
922
+ if (fromSeed !== void 0 && toSeed !== void 0) discoveredPaths.push({
923
+ fromSeed,
924
+ toSeed,
925
+ nodes: [seed.id]
926
+ });
927
+ }
928
+ }
929
+ let currentLevel = seeds.map((s, i) => {
930
+ const frontier = visitedByFrontier[i];
931
+ if (frontier === void 0) return [];
932
+ return frontier.has(s.id) ? [s.id] : [];
933
+ });
934
+ for (let hop = 0; hop < k; hop++) {
935
+ const nextLevel = seeds.map(() => []);
936
+ for (let i = 0; i < seeds.length; i++) {
937
+ const level = currentLevel[i];
938
+ if (level === void 0) continue;
939
+ const frontierVisited = visitedByFrontier[i];
940
+ if (frontierVisited === void 0) continue;
941
+ for (const nodeId of level) {
942
+ iterations++;
943
+ for (const neighbour of graph.neighbours(nodeId)) {
944
+ edgesTraversed++;
945
+ const [s, t] = nodeId < neighbour ? [nodeId, neighbour] : [neighbour, nodeId];
946
+ let targets = sampledEdgeMap.get(s);
947
+ if (targets === void 0) {
948
+ targets = /* @__PURE__ */ new Set();
949
+ sampledEdgeMap.set(s, targets);
950
+ }
951
+ targets.add(t);
952
+ if (frontierVisited.has(neighbour)) continue;
953
+ frontierVisited.set(neighbour, nodeId);
954
+ allVisited.add(neighbour);
955
+ nextLevel[i]?.push(neighbour);
956
+ const previousFrontier = firstVisitedBy.get(neighbour);
957
+ if (previousFrontier !== void 0 && previousFrontier !== i) {
958
+ const fromSeed = seeds[previousFrontier];
959
+ const toSeed = seeds[i];
960
+ if (fromSeed !== void 0 && toSeed !== void 0) {
961
+ const path = reconstructPath(neighbour, previousFrontier, i, visitedByFrontier, seeds);
962
+ if (path !== null) {
963
+ 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);
964
+ }
965
+ }
966
+ }
967
+ if (!firstVisitedBy.has(neighbour)) firstVisitedBy.set(neighbour, i);
968
+ }
969
+ }
970
+ }
971
+ currentLevel = nextLevel;
972
+ if (currentLevel.every((level) => level.length === 0)) break;
973
+ }
974
+ const endTime = performance.now();
975
+ const edgeTuples = /* @__PURE__ */ new Set();
976
+ for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
977
+ return {
978
+ paths: discoveredPaths,
979
+ sampledNodes: allVisited,
980
+ sampledEdges: edgeTuples,
981
+ visitedPerFrontier: visitedByFrontier.map((m) => new Set(m.keys())),
982
+ stats: {
983
+ iterations,
984
+ nodesVisited: allVisited.size,
985
+ edgesTraversed,
986
+ pathsFound: discoveredPaths.length,
987
+ durationMs: endTime - startTime,
988
+ algorithm: "k-hop",
989
+ termination: "exhausted"
990
+ }
991
+ };
992
+ }
993
+ /**
994
+ * Reconstruct the path between two colliding frontiers.
995
+ */
996
+ function reconstructPath(collisionNode, frontierA, frontierB, visitedByFrontier, seeds) {
997
+ const seedA = seeds[frontierA];
998
+ const seedB = seeds[frontierB];
999
+ if (seedA === void 0 || seedB === void 0) return null;
1000
+ const pathA = [collisionNode];
1001
+ const predA = visitedByFrontier[frontierA];
1002
+ if (predA !== void 0) {
1003
+ let node = collisionNode;
1004
+ let pred = predA.get(node);
1005
+ while (pred !== null && pred !== void 0) {
1006
+ pathA.unshift(pred);
1007
+ node = pred;
1008
+ pred = predA.get(node);
1009
+ }
1010
+ }
1011
+ const pathB = [];
1012
+ const predB = visitedByFrontier[frontierB];
1013
+ if (predB !== void 0) {
1014
+ let node = collisionNode;
1015
+ let pred = predB.get(node);
1016
+ while (pred !== null && pred !== void 0) {
1017
+ pathB.push(pred);
1018
+ node = pred;
1019
+ pred = predB.get(node);
1020
+ }
1021
+ }
1022
+ return {
1023
+ fromSeed: seedA,
1024
+ toSeed: seedB,
1025
+ nodes: [...pathA, ...pathB]
1026
+ };
1027
+ }
1028
+ /**
1029
+ * Create an empty result for early termination (no seeds).
1030
+ */
1031
+ function emptyResult$1(startTime) {
1032
+ return {
1033
+ paths: [],
1034
+ sampledNodes: /* @__PURE__ */ new Set(),
1035
+ sampledEdges: /* @__PURE__ */ new Set(),
1036
+ visitedPerFrontier: [],
1037
+ stats: {
1038
+ iterations: 0,
1039
+ nodesVisited: 0,
1040
+ edgesTraversed: 0,
1041
+ pathsFound: 0,
1042
+ durationMs: performance.now() - startTime,
1043
+ algorithm: "k-hop",
1044
+ termination: "exhausted"
1045
+ }
1046
+ };
1047
+ }
1048
+ //#endregion
1049
+ //#region src/expansion/random-walk.ts
1050
+ /**
1051
+ * Mulberry32 seeded PRNG — fast, compact, and high-quality for simulation.
1052
+ *
1053
+ * Returns a closure that yields the next pseudo-random value in [0, 1)
1054
+ * on each call.
1055
+ *
1056
+ * @param seed - 32-bit integer seed
1057
+ */
1058
+ function mulberry32(seed) {
1059
+ let s = seed;
1060
+ return () => {
1061
+ s += 1831565813;
1062
+ let t = s;
1063
+ t = Math.imul(t ^ t >>> 15, t | 1);
1064
+ t ^= t + Math.imul(t ^ t >>> 7, t | 61);
1065
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
1066
+ };
1067
+ }
1068
+ /**
1069
+ * Run random-walk-with-restart expansion.
1070
+ *
1071
+ * For each seed, performs `walks` independent random walks of up to
1072
+ * `walkLength` steps. At each step the walk either restarts (with
1073
+ * probability `restartProbability`) or moves to a uniformly sampled
1074
+ * neighbour. All visited nodes and traversed edges are collected.
1075
+ *
1076
+ * Inter-seed paths are detected when a walk reaches a node that was
1077
+ * previously reached by a walk originating from a different seed.
1078
+ * The recorded path contains only the two seed endpoints rather than
1079
+ * the full walk trajectory, consistent with the ExpansionPath contract.
1080
+ *
1081
+ * @param graph - Source graph
1082
+ * @param seeds - Seed nodes for expansion
1083
+ * @param config - Random walk configuration
1084
+ * @returns Expansion result with discovered paths
1085
+ */
1086
+ function randomWalk(graph, seeds, config) {
1087
+ const startTime = performance.now();
1088
+ const { restartProbability = .15, walks = 10, walkLength = 20, seed = 0 } = config ?? {};
1089
+ if (seeds.length === 0) return emptyResult(startTime);
1090
+ const rand = mulberry32(seed);
1091
+ const firstVisitedBySeed = /* @__PURE__ */ new Map();
1092
+ const allVisited = /* @__PURE__ */ new Set();
1093
+ const sampledEdgeMap = /* @__PURE__ */ new Map();
1094
+ const discoveredPaths = [];
1095
+ let iterations = 0;
1096
+ let edgesTraversed = 0;
1097
+ const visitedPerFrontier = seeds.map(() => /* @__PURE__ */ new Set());
1098
+ for (let seedIdx = 0; seedIdx < seeds.length; seedIdx++) {
1099
+ const seed_ = seeds[seedIdx];
1100
+ if (seed_ === void 0) continue;
1101
+ const seedId = seed_.id;
1102
+ if (!graph.hasNode(seedId)) continue;
1103
+ if (!firstVisitedBySeed.has(seedId)) firstVisitedBySeed.set(seedId, seedIdx);
1104
+ allVisited.add(seedId);
1105
+ visitedPerFrontier[seedIdx]?.add(seedId);
1106
+ for (let w = 0; w < walks; w++) {
1107
+ let current = seedId;
1108
+ for (let step = 0; step < walkLength; step++) {
1109
+ iterations++;
1110
+ if (rand() < restartProbability) {
1111
+ current = seedId;
1112
+ continue;
1113
+ }
1114
+ const neighbourList = [];
1115
+ for (const nb of graph.neighbours(current)) neighbourList.push(nb);
1116
+ if (neighbourList.length === 0) {
1117
+ current = seedId;
1118
+ continue;
1119
+ }
1120
+ const next = neighbourList[Math.floor(rand() * neighbourList.length)];
1121
+ if (next === void 0) {
1122
+ current = seedId;
1123
+ continue;
1124
+ }
1125
+ edgesTraversed++;
1126
+ const [s, t] = current < next ? [current, next] : [next, current];
1127
+ let targets = sampledEdgeMap.get(s);
1128
+ if (targets === void 0) {
1129
+ targets = /* @__PURE__ */ new Set();
1130
+ sampledEdgeMap.set(s, targets);
1131
+ }
1132
+ targets.add(t);
1133
+ const previousSeedIdx = firstVisitedBySeed.get(next);
1134
+ if (previousSeedIdx !== void 0 && previousSeedIdx !== seedIdx) {
1135
+ const fromSeed = seeds[previousSeedIdx];
1136
+ const toSeed = seeds[seedIdx];
1137
+ if (fromSeed !== void 0 && toSeed !== void 0) {
1138
+ const path = {
1139
+ fromSeed,
1140
+ toSeed,
1141
+ nodes: [
1142
+ fromSeed.id,
1143
+ next,
1144
+ toSeed.id
1145
+ ].filter((n, i, arr) => arr.indexOf(n) === i)
1146
+ };
1147
+ 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);
1148
+ }
1149
+ }
1150
+ if (!firstVisitedBySeed.has(next)) firstVisitedBySeed.set(next, seedIdx);
1151
+ allVisited.add(next);
1152
+ visitedPerFrontier[seedIdx]?.add(next);
1153
+ current = next;
1154
+ }
1155
+ }
1156
+ }
1157
+ const endTime = performance.now();
1158
+ const edgeTuples = /* @__PURE__ */ new Set();
1159
+ for (const [source, targets] of sampledEdgeMap) for (const target of targets) edgeTuples.add([source, target]);
1160
+ return {
1161
+ paths: discoveredPaths,
1162
+ sampledNodes: allVisited,
1163
+ sampledEdges: edgeTuples,
1164
+ visitedPerFrontier,
1165
+ stats: {
1166
+ iterations,
1167
+ nodesVisited: allVisited.size,
1168
+ edgesTraversed,
1169
+ pathsFound: discoveredPaths.length,
1170
+ durationMs: endTime - startTime,
1171
+ algorithm: "random-walk",
1172
+ termination: "exhausted"
1173
+ }
1174
+ };
1175
+ }
1176
+ /**
1177
+ * Create an empty result for early termination (no seeds).
1178
+ */
1179
+ function emptyResult(startTime) {
1180
+ return {
1181
+ paths: [],
1182
+ sampledNodes: /* @__PURE__ */ new Set(),
1183
+ sampledEdges: /* @__PURE__ */ new Set(),
1184
+ visitedPerFrontier: [],
1185
+ stats: {
1186
+ iterations: 0,
1187
+ nodesVisited: 0,
1188
+ edgesTraversed: 0,
1189
+ pathsFound: 0,
1190
+ durationMs: performance.now() - startTime,
1191
+ algorithm: "random-walk",
1192
+ termination: "exhausted"
1193
+ }
1194
+ };
1195
+ }
1196
+ //#endregion
860
1197
  //#region src/ranking/parse.ts
861
1198
  /**
862
1199
  * Rank paths using PARSE (Path-Aware Ranking via Salience Estimation).
@@ -950,20 +1287,128 @@ function adamicAdar(graph, source, target, config) {
950
1287
  return Math.max(epsilon, score);
951
1288
  }
952
1289
  //#endregion
1290
+ //#region src/ranking/mi/cosine.ts
1291
+ /**
1292
+ * Compute cosine similarity between neighbourhoods of two nodes.
1293
+ *
1294
+ * @param graph - Source graph
1295
+ * @param source - Source node ID
1296
+ * @param target - Target node ID
1297
+ * @param config - Optional configuration
1298
+ * @returns Cosine similarity in [0, 1]
1299
+ */
1300
+ function cosine(graph, source, target, config) {
1301
+ const { epsilon = 1e-10 } = config ?? {};
1302
+ const sourceNeighbours = neighbourSet(graph, source, target);
1303
+ const targetNeighbours = neighbourSet(graph, target, source);
1304
+ const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
1305
+ const denominator = Math.sqrt(sourceNeighbours.size) * Math.sqrt(targetNeighbours.size);
1306
+ if (denominator === 0) return 0;
1307
+ const score = intersection / denominator;
1308
+ return Math.max(epsilon, score);
1309
+ }
1310
+ //#endregion
1311
+ //#region src/ranking/mi/sorensen.ts
1312
+ /**
1313
+ * Compute Sorensen-Dice similarity between neighbourhoods of two nodes.
1314
+ *
1315
+ * @param graph - Source graph
1316
+ * @param source - Source node ID
1317
+ * @param target - Target node ID
1318
+ * @param config - Optional configuration
1319
+ * @returns Sorensen-Dice coefficient in [0, 1]
1320
+ */
1321
+ function sorensen(graph, source, target, config) {
1322
+ const { epsilon = 1e-10 } = config ?? {};
1323
+ const sourceNeighbours = neighbourSet(graph, source, target);
1324
+ const targetNeighbours = neighbourSet(graph, target, source);
1325
+ const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
1326
+ const denominator = sourceNeighbours.size + targetNeighbours.size;
1327
+ if (denominator === 0) return 0;
1328
+ const score = 2 * intersection / denominator;
1329
+ return Math.max(epsilon, score);
1330
+ }
1331
+ //#endregion
1332
+ //#region src/ranking/mi/resource-allocation.ts
1333
+ /**
1334
+ * Compute Resource Allocation index between neighbourhoods of two nodes.
1335
+ *
1336
+ * @param graph - Source graph
1337
+ * @param source - Source node ID
1338
+ * @param target - Target node ID
1339
+ * @param config - Optional configuration
1340
+ * @returns Resource Allocation index (normalised to [0, 1] if configured)
1341
+ */
1342
+ function resourceAllocation(graph, source, target, config) {
1343
+ const { epsilon = 1e-10, normalise = true } = config ?? {};
1344
+ const commonNeighbours = neighbourIntersection(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
1345
+ let score = 0;
1346
+ for (const neighbour of commonNeighbours) {
1347
+ const degree = graph.degree(neighbour);
1348
+ if (degree > 0) score += 1 / degree;
1349
+ }
1350
+ if (normalise && commonNeighbours.size > 0) {
1351
+ const maxScore = commonNeighbours.size;
1352
+ score = score / maxScore;
1353
+ }
1354
+ return Math.max(epsilon, score);
1355
+ }
1356
+ //#endregion
1357
+ //#region src/ranking/mi/overlap-coefficient.ts
1358
+ /**
1359
+ * Compute Overlap Coefficient between neighbourhoods of two nodes.
1360
+ *
1361
+ * @param graph - Source graph
1362
+ * @param source - Source node ID
1363
+ * @param target - Target node ID
1364
+ * @param config - Optional configuration
1365
+ * @returns Overlap Coefficient in [0, 1]
1366
+ */
1367
+ function overlapCoefficient(graph, source, target, config) {
1368
+ const { epsilon = 1e-10 } = config ?? {};
1369
+ const sourceNeighbours = neighbourSet(graph, source, target);
1370
+ const targetNeighbours = neighbourSet(graph, target, source);
1371
+ const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
1372
+ const denominator = Math.min(sourceNeighbours.size, targetNeighbours.size);
1373
+ if (denominator === 0) return 0;
1374
+ const score = intersection / denominator;
1375
+ return Math.max(epsilon, score);
1376
+ }
1377
+ //#endregion
1378
+ //#region src/ranking/mi/hub-promoted.ts
1379
+ /**
1380
+ * Compute Hub Promoted index between neighbourhoods of two nodes.
1381
+ *
1382
+ * @param graph - Source graph
1383
+ * @param source - Source node ID
1384
+ * @param target - Target node ID
1385
+ * @param config - Optional configuration
1386
+ * @returns Hub Promoted index in [0, 1]
1387
+ */
1388
+ function hubPromoted(graph, source, target, config) {
1389
+ const { epsilon = 1e-10 } = config ?? {};
1390
+ const { intersection } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
1391
+ const sourceDegree = graph.degree(source);
1392
+ const targetDegree = graph.degree(target);
1393
+ const denominator = Math.min(sourceDegree, targetDegree);
1394
+ if (denominator === 0) return 0;
1395
+ const score = intersection / denominator;
1396
+ return Math.max(epsilon, score);
1397
+ }
1398
+ //#endregion
953
1399
  //#region src/ranking/mi/scale.ts
954
1400
  /**
955
1401
  * Compute SCALE MI between two nodes.
956
1402
  */
957
1403
  function scale(graph, source, target, config) {
958
1404
  const { epsilon = 1e-10 } = config ?? {};
959
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
960
- const jaccard = union > 0 ? intersection / union : 0;
1405
+ const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
961
1406
  const n = graph.nodeCount;
962
1407
  const m = graph.edgeCount;
963
1408
  const possibleEdges = n * (n - 1);
964
1409
  const density = possibleEdges > 0 ? (graph.directed ? m : 2 * m) / possibleEdges : 0;
965
1410
  if (density === 0) return epsilon;
966
- const score = jaccard / density;
1411
+ const score = jaccardScore / density;
967
1412
  return Math.max(epsilon, score);
968
1413
  }
969
1414
  //#endregion
@@ -973,14 +1418,13 @@ function scale(graph, source, target, config) {
973
1418
  */
974
1419
  function skew(graph, source, target, config) {
975
1420
  const { epsilon = 1e-10 } = config ?? {};
976
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
977
- const jaccard = union > 0 ? intersection / union : 0;
1421
+ const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
978
1422
  const N = graph.nodeCount;
979
1423
  const sourceDegree = graph.degree(source);
980
1424
  const targetDegree = graph.degree(target);
981
1425
  const sourceIdf = Math.log(N / (sourceDegree + 1));
982
1426
  const targetIdf = Math.log(N / (targetDegree + 1));
983
- const score = jaccard * sourceIdf * targetIdf;
1427
+ const score = jaccardScore * sourceIdf * targetIdf;
984
1428
  return Math.max(epsilon, score);
985
1429
  }
986
1430
  //#endregion
@@ -990,11 +1434,10 @@ function skew(graph, source, target, config) {
990
1434
  */
991
1435
  function span(graph, source, target, config) {
992
1436
  const { epsilon = 1e-10 } = config ?? {};
993
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
994
- const jaccard = union > 0 ? intersection / union : 0;
1437
+ const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
995
1438
  const sourceCc = localClusteringCoefficient(graph, source);
996
1439
  const targetCc = localClusteringCoefficient(graph, target);
997
- const score = jaccard * (1 - Math.max(sourceCc, targetCc));
1440
+ const score = jaccardScore * (1 - Math.max(sourceCc, targetCc));
998
1441
  return Math.max(epsilon, score);
999
1442
  }
1000
1443
  //#endregion
@@ -1004,13 +1447,12 @@ function span(graph, source, target, config) {
1004
1447
  */
1005
1448
  function etch(graph, source, target, config) {
1006
1449
  const { epsilon = 1e-10 } = config ?? {};
1007
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
1008
- const jaccard = union > 0 ? intersection / union : 0;
1450
+ const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
1009
1451
  const edge = graph.getEdge(source, target);
1010
- if (edge?.type === void 0) return Math.max(epsilon, jaccard);
1452
+ if (edge?.type === void 0) return Math.max(epsilon, jaccardScore);
1011
1453
  const edgeTypeCount = countEdgesOfType(graph, edge.type);
1012
- if (edgeTypeCount === 0) return Math.max(epsilon, jaccard);
1013
- const score = jaccard * Math.log(graph.edgeCount / edgeTypeCount);
1454
+ if (edgeTypeCount === 0) return Math.max(epsilon, jaccardScore);
1455
+ const score = jaccardScore * Math.log(graph.edgeCount / edgeTypeCount);
1014
1456
  return Math.max(epsilon, score);
1015
1457
  }
1016
1458
  //#endregion
@@ -1020,17 +1462,16 @@ function etch(graph, source, target, config) {
1020
1462
  */
1021
1463
  function notch(graph, source, target, config) {
1022
1464
  const { epsilon = 1e-10 } = config ?? {};
1023
- const { intersection, union } = neighbourOverlap(neighbourSet(graph, source, target), neighbourSet(graph, target, source));
1024
- const jaccard = union > 0 ? intersection / union : 0;
1465
+ const { jaccard: jaccardScore } = computeJaccard(graph, source, target);
1025
1466
  const sourceNode = graph.getNode(source);
1026
1467
  const targetNode = graph.getNode(target);
1027
- if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccard);
1468
+ if (sourceNode?.type === void 0 || targetNode?.type === void 0) return Math.max(epsilon, jaccardScore);
1028
1469
  const sourceTypeCount = countNodesOfType(graph, sourceNode.type);
1029
1470
  const targetTypeCount = countNodesOfType(graph, targetNode.type);
1030
- if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccard);
1471
+ if (sourceTypeCount === 0 || targetTypeCount === 0) return Math.max(epsilon, jaccardScore);
1031
1472
  const sourceRarity = Math.log(graph.nodeCount / sourceTypeCount);
1032
1473
  const targetRarity = Math.log(graph.nodeCount / targetTypeCount);
1033
- const score = jaccard * sourceRarity * targetRarity;
1474
+ const score = jaccardScore * sourceRarity * targetRarity;
1034
1475
  return Math.max(epsilon, score);
1035
1476
  }
1036
1477
  //#endregion
@@ -1049,13 +1490,12 @@ function notch(graph, source, target, config) {
1049
1490
  */
1050
1491
  function adaptive(graph, source, target, config) {
1051
1492
  const { epsilon = 1e-10, structuralWeight = .4, degreeWeight = .3, overlapWeight = .3 } = config ?? {};
1052
- const structural = jaccard(graph, source, target, { epsilon });
1493
+ const { jaccard: jaccardScore, sourceNeighbours, targetNeighbours } = computeJaccard(graph, source, target);
1494
+ const structural = sourceNeighbours.size === 0 && targetNeighbours.size === 0 ? 0 : Math.max(epsilon, jaccardScore);
1053
1495
  const degreeComponent = adamicAdar(graph, source, target, {
1054
1496
  epsilon,
1055
1497
  normalise: true
1056
1498
  });
1057
- const sourceNeighbours = neighbourSet(graph, source, target);
1058
- const targetNeighbours = neighbourSet(graph, target, source);
1059
1499
  let overlap;
1060
1500
  if (sourceNeighbours.size > 0 && targetNeighbours.size > 0) {
1061
1501
  const { intersection } = neighbourOverlap(sourceNeighbours, targetNeighbours);
@@ -1067,6 +1507,42 @@ function adaptive(graph, source, target, config) {
1067
1507
  return Math.max(epsilon, Math.min(1, score));
1068
1508
  }
1069
1509
  //#endregion
1510
+ //#region src/ranking/baselines/utils.ts
1511
+ /**
1512
+ * Normalise a set of scored paths and return them sorted highest-first.
1513
+ *
1514
+ * All scores are normalised relative to the maximum observed score.
1515
+ * When `includeScores` is false, raw (un-normalised) scores are preserved.
1516
+ * Handles degenerate cases: empty input and all-zero scores.
1517
+ *
1518
+ * @param paths - Original paths in input order
1519
+ * @param scored - Paths paired with their computed scores
1520
+ * @param method - Method name to embed in the result
1521
+ * @param includeScores - When true, normalise scores to [0, 1]; when false, keep raw scores
1522
+ * @returns BaselineResult with ranked paths
1523
+ */
1524
+ function normaliseAndRank(paths, scored, method, includeScores) {
1525
+ if (scored.length === 0) return {
1526
+ paths: [],
1527
+ method
1528
+ };
1529
+ const maxScore = Math.max(...scored.map((s) => s.score));
1530
+ if (maxScore === 0) return {
1531
+ paths: paths.map((path) => ({
1532
+ ...path,
1533
+ score: 0
1534
+ })),
1535
+ method
1536
+ };
1537
+ return {
1538
+ paths: scored.map(({ path, score }) => ({
1539
+ ...path,
1540
+ score: includeScores ? score / maxScore : score
1541
+ })).sort((a, b) => b.score - a.score),
1542
+ method
1543
+ };
1544
+ }
1545
+ //#endregion
1070
1546
  //#region src/ranking/baselines/shortest.ts
1071
1547
  /**
1072
1548
  * Rank paths by length (shortest first).
@@ -1084,18 +1560,10 @@ function shortest(_graph, paths, config) {
1084
1560
  paths: [],
1085
1561
  method: "shortest"
1086
1562
  };
1087
- const scored = paths.map((path) => ({
1563
+ return normaliseAndRank(paths, paths.map((path) => ({
1088
1564
  path,
1089
1565
  score: 1 / path.nodes.length
1090
- }));
1091
- const maxScore = Math.max(...scored.map((s) => s.score));
1092
- return {
1093
- paths: scored.map(({ path, score }) => ({
1094
- ...path,
1095
- score: includeScores ? score / maxScore : score / maxScore
1096
- })).sort((a, b) => b.score - a.score),
1097
- method: "shortest"
1098
- };
1566
+ })), "shortest", includeScores);
1099
1567
  }
1100
1568
  //#endregion
1101
1569
  //#region src/ranking/baselines/degree-sum.ts
@@ -1113,29 +1581,14 @@ function degreeSum(graph, paths, config) {
1113
1581
  paths: [],
1114
1582
  method: "degree-sum"
1115
1583
  };
1116
- const scored = paths.map((path) => {
1584
+ return normaliseAndRank(paths, paths.map((path) => {
1117
1585
  let degreeSum = 0;
1118
1586
  for (const nodeId of path.nodes) degreeSum += graph.degree(nodeId);
1119
1587
  return {
1120
1588
  path,
1121
1589
  score: degreeSum
1122
1590
  };
1123
- });
1124
- const maxScore = Math.max(...scored.map((s) => s.score));
1125
- if (maxScore === 0) return {
1126
- paths: paths.map((path) => ({
1127
- ...path,
1128
- score: 0
1129
- })),
1130
- method: "degree-sum"
1131
- };
1132
- return {
1133
- paths: scored.map(({ path, score }) => ({
1134
- ...path,
1135
- score: includeScores ? score / maxScore : score
1136
- })).sort((a, b) => b.score - a.score),
1137
- method: "degree-sum"
1138
- };
1591
+ }), "degree-sum", includeScores);
1139
1592
  }
1140
1593
  //#endregion
1141
1594
  //#region src/ranking/baselines/widest-path.ts
@@ -1153,7 +1606,7 @@ function widestPath(graph, paths, config) {
1153
1606
  paths: [],
1154
1607
  method: "widest-path"
1155
1608
  };
1156
- const scored = paths.map((path) => {
1609
+ return normaliseAndRank(paths, paths.map((path) => {
1157
1610
  if (path.nodes.length < 2) return {
1158
1611
  path,
1159
1612
  score: 1
@@ -1170,22 +1623,7 @@ function widestPath(graph, paths, config) {
1170
1623
  path,
1171
1624
  score: minSimilarity === Number.POSITIVE_INFINITY ? 1 : minSimilarity
1172
1625
  };
1173
- });
1174
- const maxScore = Math.max(...scored.map((s) => s.score));
1175
- if (maxScore === 0) return {
1176
- paths: paths.map((path) => ({
1177
- ...path,
1178
- score: 0
1179
- })),
1180
- method: "widest-path"
1181
- };
1182
- return {
1183
- paths: scored.map(({ path, score }) => ({
1184
- ...path,
1185
- score: includeScores ? score / maxScore : score / maxScore
1186
- })).sort((a, b) => b.score - a.score),
1187
- method: "widest-path"
1188
- };
1626
+ }), "widest-path", includeScores);
1189
1627
  }
1190
1628
  //#endregion
1191
1629
  //#region src/ranking/baselines/jaccard-arithmetic.ts
@@ -1203,7 +1641,7 @@ function jaccardArithmetic(graph, paths, config) {
1203
1641
  paths: [],
1204
1642
  method: "jaccard-arithmetic"
1205
1643
  };
1206
- const scored = paths.map((path) => {
1644
+ return normaliseAndRank(paths, paths.map((path) => {
1207
1645
  if (path.nodes.length < 2) return {
1208
1646
  path,
1209
1647
  score: 1
@@ -1222,22 +1660,7 @@ function jaccardArithmetic(graph, paths, config) {
1222
1660
  path,
1223
1661
  score: edgeCount > 0 ? similaritySum / edgeCount : 1
1224
1662
  };
1225
- });
1226
- const maxScore = Math.max(...scored.map((s) => s.score));
1227
- if (maxScore === 0) return {
1228
- paths: paths.map((path) => ({
1229
- ...path,
1230
- score: 0
1231
- })),
1232
- method: "jaccard-arithmetic"
1233
- };
1234
- return {
1235
- paths: scored.map(({ path, score }) => ({
1236
- ...path,
1237
- score: includeScores ? score / maxScore : score
1238
- })).sort((a, b) => b.score - a.score),
1239
- method: "jaccard-arithmetic"
1240
- };
1663
+ }), "jaccard-arithmetic", includeScores);
1241
1664
  }
1242
1665
  //#endregion
1243
1666
  //#region src/ranking/baselines/pagerank.ts
@@ -1298,29 +1721,14 @@ function pagerank(graph, paths, config) {
1298
1721
  method: "pagerank"
1299
1722
  };
1300
1723
  const ranks = computePageRank(graph);
1301
- const scored = paths.map((path) => {
1724
+ return normaliseAndRank(paths, paths.map((path) => {
1302
1725
  let prSum = 0;
1303
1726
  for (const nodeId of path.nodes) prSum += ranks.get(nodeId) ?? 0;
1304
1727
  return {
1305
1728
  path,
1306
1729
  score: prSum
1307
1730
  };
1308
- });
1309
- const maxScore = Math.max(...scored.map((s) => s.score));
1310
- if (maxScore === 0) return {
1311
- paths: paths.map((path) => ({
1312
- ...path,
1313
- score: 0
1314
- })),
1315
- method: "pagerank"
1316
- };
1317
- return {
1318
- paths: scored.map(({ path, score }) => ({
1319
- ...path,
1320
- score: includeScores ? score / maxScore : score / maxScore
1321
- })).sort((a, b) => b.score - a.score),
1322
- method: "pagerank"
1323
- };
1731
+ }), "pagerank", includeScores);
1324
1732
  }
1325
1733
  //#endregion
1326
1734
  //#region src/ranking/baselines/betweenness.ts
@@ -1405,29 +1813,14 @@ function betweenness(graph, paths, config) {
1405
1813
  method: "betweenness"
1406
1814
  };
1407
1815
  const bcMap = computeBetweenness(graph);
1408
- const scored = paths.map((path) => {
1816
+ return normaliseAndRank(paths, paths.map((path) => {
1409
1817
  let bcSum = 0;
1410
1818
  for (const nodeId of path.nodes) bcSum += bcMap.get(nodeId) ?? 0;
1411
1819
  return {
1412
1820
  path,
1413
1821
  score: bcSum
1414
1822
  };
1415
- });
1416
- const maxScore = Math.max(...scored.map((s) => s.score));
1417
- if (maxScore === 0) return {
1418
- paths: paths.map((path) => ({
1419
- ...path,
1420
- score: 0
1421
- })),
1422
- method: "betweenness"
1423
- };
1424
- return {
1425
- paths: scored.map(({ path, score }) => ({
1426
- ...path,
1427
- score: includeScores ? score / maxScore : score / maxScore
1428
- })).sort((a, b) => b.score - a.score),
1429
- method: "betweenness"
1430
- };
1823
+ }), "betweenness", includeScores);
1431
1824
  }
1432
1825
  //#endregion
1433
1826
  //#region src/ranking/baselines/katz.ts
@@ -1490,7 +1883,7 @@ function katz(graph, paths, config) {
1490
1883
  paths: [],
1491
1884
  method: "katz"
1492
1885
  };
1493
- const scored = paths.map((path) => {
1886
+ return normaliseAndRank(paths, paths.map((path) => {
1494
1887
  const source = path.nodes[0];
1495
1888
  const target = path.nodes[path.nodes.length - 1];
1496
1889
  if (source === void 0 || target === void 0) return {
@@ -1501,22 +1894,7 @@ function katz(graph, paths, config) {
1501
1894
  path,
1502
1895
  score: computeKatz(graph, source, target)
1503
1896
  };
1504
- });
1505
- const maxScore = Math.max(...scored.map((s) => s.score));
1506
- if (maxScore === 0) return {
1507
- paths: paths.map((path) => ({
1508
- ...path,
1509
- score: 0
1510
- })),
1511
- method: "katz"
1512
- };
1513
- return {
1514
- paths: scored.map(({ path, score }) => ({
1515
- ...path,
1516
- score: includeScores ? score / maxScore : score / maxScore
1517
- })).sort((a, b) => b.score - a.score),
1518
- method: "katz"
1519
- };
1897
+ }), "katz", includeScores);
1520
1898
  }
1521
1899
  //#endregion
1522
1900
  //#region src/ranking/baselines/communicability.ts
@@ -1578,7 +1956,7 @@ function communicability(graph, paths, config) {
1578
1956
  paths: [],
1579
1957
  method: "communicability"
1580
1958
  };
1581
- const scored = paths.map((path) => {
1959
+ return normaliseAndRank(paths, paths.map((path) => {
1582
1960
  const source = path.nodes[0];
1583
1961
  const target = path.nodes[path.nodes.length - 1];
1584
1962
  if (source === void 0 || target === void 0) return {
@@ -1589,22 +1967,7 @@ function communicability(graph, paths, config) {
1589
1967
  path,
1590
1968
  score: computeCommunicability(graph, source, target)
1591
1969
  };
1592
- });
1593
- const maxScore = Math.max(...scored.map((s) => s.score));
1594
- if (maxScore === 0) return {
1595
- paths: paths.map((path) => ({
1596
- ...path,
1597
- score: 0
1598
- })),
1599
- method: "communicability"
1600
- };
1601
- return {
1602
- paths: scored.map(({ path, score }) => ({
1603
- ...path,
1604
- score: includeScores ? score / maxScore : score / maxScore
1605
- })).sort((a, b) => b.score - a.score),
1606
- method: "communicability"
1607
- };
1970
+ }), "communicability", includeScores);
1608
1971
  }
1609
1972
  //#endregion
1610
1973
  //#region src/ranking/baselines/resistance-distance.ts
@@ -1721,7 +2084,7 @@ function resistanceDistance(graph, paths, config) {
1721
2084
  };
1722
2085
  const nodeCount = Array.from(graph.nodeIds()).length;
1723
2086
  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.`);
1724
- const scored = paths.map((path) => {
2087
+ return normaliseAndRank(paths, paths.map((path) => {
1725
2088
  const source = path.nodes[0];
1726
2089
  const target = path.nodes[path.nodes.length - 1];
1727
2090
  if (source === void 0 || target === void 0) return {
@@ -1732,22 +2095,7 @@ function resistanceDistance(graph, paths, config) {
1732
2095
  path,
1733
2096
  score: 1 / computeResistance(graph, source, target)
1734
2097
  };
1735
- });
1736
- const maxScore = Math.max(...scored.map((s) => s.score));
1737
- if (maxScore === 0) return {
1738
- paths: paths.map((path) => ({
1739
- ...path,
1740
- score: 0
1741
- })),
1742
- method: "resistance-distance"
1743
- };
1744
- return {
1745
- paths: scored.map(({ path, score }) => ({
1746
- ...path,
1747
- score: includeScores ? score / maxScore : score / maxScore
1748
- })).sort((a, b) => b.score - a.score),
1749
- method: "resistance-distance"
1750
- };
2098
+ }), "resistance-distance", includeScores);
1751
2099
  }
1752
2100
  //#endregion
1753
2101
  //#region src/ranking/baselines/random-ranking.ts
@@ -1781,27 +2129,258 @@ function randomRanking(_graph, paths, config) {
1781
2129
  paths: [],
1782
2130
  method: "random"
1783
2131
  };
1784
- const scored = paths.map((path) => {
2132
+ return normaliseAndRank(paths, paths.map((path) => {
1785
2133
  return {
1786
2134
  path,
1787
2135
  score: seededRandom(path.nodes.join(","), seed)
1788
2136
  };
2137
+ }), "random", includeScores);
2138
+ }
2139
+ //#endregion
2140
+ //#region src/ranking/baselines/hitting-time.ts
2141
+ /**
2142
+ * Seeded deterministic random number generator (LCG).
2143
+ * Suitable for reproducible random walk simulation.
2144
+ */
2145
+ var SeededRNG = class {
2146
+ state;
2147
+ constructor(seed) {
2148
+ this.state = seed;
2149
+ }
2150
+ /**
2151
+ * Generate next pseudorandom value in [0, 1).
2152
+ */
2153
+ next() {
2154
+ this.state = this.state * 1103515245 + 12345 & 2147483647;
2155
+ return this.state / 2147483647;
2156
+ }
2157
+ };
2158
+ /**
2159
+ * Compute hitting time via Monte Carlo random walk simulation.
2160
+ *
2161
+ * @param graph - Source graph
2162
+ * @param source - Source node ID
2163
+ * @param target - Target node ID
2164
+ * @param walks - Number of walks to simulate
2165
+ * @param maxSteps - Maximum steps per walk
2166
+ * @param rng - Seeded RNG instance
2167
+ * @returns Average hitting time across walks
2168
+ */
2169
+ function computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng) {
2170
+ if (source === target) return 0;
2171
+ let totalSteps = 0;
2172
+ let successfulWalks = 0;
2173
+ for (let w = 0; w < walks; w++) {
2174
+ let current = source;
2175
+ let steps = 0;
2176
+ while (current !== target && steps < maxSteps) {
2177
+ const neighbours = Array.from(graph.neighbours(current));
2178
+ if (neighbours.length === 0) break;
2179
+ const nextNode = neighbours[Math.floor(rng.next() * neighbours.length)];
2180
+ if (nextNode === void 0) break;
2181
+ current = nextNode;
2182
+ steps++;
2183
+ }
2184
+ if (current === target) {
2185
+ totalSteps += steps;
2186
+ successfulWalks++;
2187
+ }
2188
+ }
2189
+ if (successfulWalks > 0) return totalSteps / successfulWalks;
2190
+ return maxSteps;
2191
+ }
2192
+ /**
2193
+ * Compute hitting time via exact fundamental matrix method.
2194
+ *
2195
+ * For small graphs, computes exact expected hitting times using
2196
+ * the fundamental matrix of the random walk.
2197
+ *
2198
+ * @param graph - Source graph
2199
+ * @param source - Source node ID
2200
+ * @param target - Target node ID
2201
+ * @returns Exact hitting time (or approximation if convergence fails)
2202
+ */
2203
+ function computeHittingTimeExact(graph, source, target) {
2204
+ if (source === target) return 0;
2205
+ const nodes = Array.from(graph.nodeIds());
2206
+ const nodeToIdx = /* @__PURE__ */ new Map();
2207
+ nodes.forEach((nodeId, idx) => {
2208
+ nodeToIdx.set(nodeId, idx);
2209
+ });
2210
+ const n = nodes.length;
2211
+ const sourceIdx = nodeToIdx.get(source);
2212
+ const targetIdx = nodeToIdx.get(target);
2213
+ if (sourceIdx === void 0 || targetIdx === void 0) return 0;
2214
+ const P = [];
2215
+ for (let i = 0; i < n; i++) {
2216
+ const row = [];
2217
+ for (let j = 0; j < n; j++) row[j] = 0;
2218
+ P[i] = row;
2219
+ }
2220
+ for (const nodeId of nodes) {
2221
+ const idx = nodeToIdx.get(nodeId);
2222
+ if (idx === void 0) continue;
2223
+ const pRow = P[idx];
2224
+ if (pRow === void 0) continue;
2225
+ if (idx === targetIdx) pRow[idx] = 1;
2226
+ else {
2227
+ const neighbours = Array.from(graph.neighbours(nodeId));
2228
+ const degree = neighbours.length;
2229
+ if (degree > 0) for (const neighbourId of neighbours) {
2230
+ const nIdx = nodeToIdx.get(neighbourId);
2231
+ if (nIdx !== void 0) pRow[nIdx] = 1 / degree;
2232
+ }
2233
+ }
2234
+ }
2235
+ const transientIndices = [];
2236
+ for (let i = 0; i < n; i++) if (i !== targetIdx) transientIndices.push(i);
2237
+ const m = transientIndices.length;
2238
+ const Q = [];
2239
+ for (let i = 0; i < m; i++) {
2240
+ const row = [];
2241
+ for (let j = 0; j < m; j++) row[j] = 0;
2242
+ Q[i] = row;
2243
+ }
2244
+ for (let i = 0; i < m; i++) {
2245
+ const qRow = Q[i];
2246
+ if (qRow === void 0) continue;
2247
+ const origI = transientIndices[i];
2248
+ if (origI === void 0) continue;
2249
+ const pRow = P[origI];
2250
+ if (pRow === void 0) continue;
2251
+ for (let j = 0; j < m; j++) {
2252
+ const origJ = transientIndices[j];
2253
+ if (origJ === void 0) continue;
2254
+ qRow[j] = pRow[origJ] ?? 0;
2255
+ }
2256
+ }
2257
+ const IMQ = [];
2258
+ for (let i = 0; i < m; i++) {
2259
+ const row = [];
2260
+ for (let j = 0; j < m; j++) row[j] = i === j ? 1 : 0;
2261
+ IMQ[i] = row;
2262
+ }
2263
+ for (let i = 0; i < m; i++) {
2264
+ const imqRow = IMQ[i];
2265
+ if (imqRow === void 0) continue;
2266
+ const qRow = Q[i];
2267
+ for (let j = 0; j < m; j++) {
2268
+ const qVal = qRow?.[j] ?? 0;
2269
+ imqRow[j] = (i === j ? 1 : 0) - qVal;
2270
+ }
2271
+ }
2272
+ const N = invertMatrix(IMQ);
2273
+ if (N === null) return 1;
2274
+ const sourceTransientIdx = transientIndices.indexOf(sourceIdx);
2275
+ if (sourceTransientIdx < 0) return 0;
2276
+ let hittingTime = 0;
2277
+ const row = N[sourceTransientIdx];
2278
+ if (row !== void 0) for (const val of row) hittingTime += val;
2279
+ return hittingTime;
2280
+ }
2281
+ /**
2282
+ * Invert a square matrix using Gaussian elimination with partial pivoting.
2283
+ *
2284
+ * @param matrix - Input matrix (n × n)
2285
+ * @returns Inverted matrix, or null if singular
2286
+ */
2287
+ function invertMatrix(matrix) {
2288
+ const n = matrix.length;
2289
+ const aug = [];
2290
+ for (let i = 0; i < n; i++) {
2291
+ const row = [];
2292
+ const matRow = matrix[i];
2293
+ for (let j = 0; j < n; j++) row[j] = matRow?.[j] ?? 0;
2294
+ for (let j = 0; j < n; j++) row[n + j] = i === j ? 1 : 0;
2295
+ aug[i] = row;
2296
+ }
2297
+ for (let col = 0; col < n; col++) {
2298
+ let pivotRow = col;
2299
+ const pivotCol = aug[pivotRow];
2300
+ if (pivotCol === void 0) return null;
2301
+ for (let row = col + 1; row < n; row++) {
2302
+ const currRowVal = aug[row]?.[col] ?? 0;
2303
+ const pivotRowVal = pivotCol[col] ?? 0;
2304
+ if (Math.abs(currRowVal) > Math.abs(pivotRowVal)) pivotRow = row;
2305
+ }
2306
+ const augPivot = aug[pivotRow];
2307
+ if (augPivot === void 0 || Math.abs(augPivot[col] ?? 0) < 1e-10) return null;
2308
+ [aug[col], aug[pivotRow]] = [aug[pivotRow] ?? [], aug[col] ?? []];
2309
+ const scaledPivotRow = aug[col];
2310
+ if (scaledPivotRow === void 0) return null;
2311
+ const pivot = scaledPivotRow[col] ?? 1;
2312
+ for (let j = 0; j < 2 * n; j++) scaledPivotRow[j] = (scaledPivotRow[j] ?? 0) / pivot;
2313
+ for (let row = col + 1; row < n; row++) {
2314
+ const currRow = aug[row];
2315
+ if (currRow === void 0) continue;
2316
+ const factor = currRow[col] ?? 0;
2317
+ for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (scaledPivotRow[j] ?? 0);
2318
+ }
2319
+ }
2320
+ for (let col = n - 1; col > 0; col--) {
2321
+ const colRow = aug[col];
2322
+ if (colRow === void 0) return null;
2323
+ for (let row = col - 1; row >= 0; row--) {
2324
+ const currRow = aug[row];
2325
+ if (currRow === void 0) continue;
2326
+ const factor = currRow[col] ?? 0;
2327
+ for (let j = 0; j < 2 * n; j++) currRow[j] = (currRow[j] ?? 0) - factor * (colRow[j] ?? 0);
2328
+ }
2329
+ }
2330
+ const inv = [];
2331
+ for (let i = 0; i < n; i++) {
2332
+ const row = [];
2333
+ for (let j = 0; j < n; j++) row[j] = 0;
2334
+ inv[i] = row;
2335
+ }
2336
+ for (let i = 0; i < n; i++) {
2337
+ const invRow = inv[i];
2338
+ if (invRow === void 0) continue;
2339
+ const augRow = aug[i];
2340
+ if (augRow === void 0) continue;
2341
+ for (let j = 0; j < n; j++) invRow[j] = augRow[n + j] ?? 0;
2342
+ }
2343
+ return inv;
2344
+ }
2345
+ /**
2346
+ * Rank paths by inverse hitting time between endpoints.
2347
+ *
2348
+ * @param graph - Source graph
2349
+ * @param paths - Paths to rank
2350
+ * @param config - Configuration options
2351
+ * @returns Ranked paths (highest inverse hitting time first)
2352
+ */
2353
+ function hittingTime(graph, paths, config) {
2354
+ const { includeScores = true, mode = "auto", walks = 1e3, maxSteps = 1e4, seed = 42 } = config ?? {};
2355
+ if (paths.length === 0) return {
2356
+ paths: [],
2357
+ method: "hitting-time"
2358
+ };
2359
+ const nodeCount = Array.from(graph.nodeIds()).length;
2360
+ const actualMode = mode === "auto" ? nodeCount < 100 ? "exact" : "approximate" : mode;
2361
+ const rng = new SeededRNG(seed);
2362
+ const scored = paths.map((path) => {
2363
+ const source = path.nodes[0];
2364
+ const target = path.nodes[path.nodes.length - 1];
2365
+ if (source === void 0 || target === void 0) return {
2366
+ path,
2367
+ score: 0
2368
+ };
2369
+ const ht = actualMode === "exact" ? computeHittingTimeExact(graph, source, target) : computeHittingTimeApproximate(graph, source, target, walks, maxSteps, rng);
2370
+ return {
2371
+ path,
2372
+ score: ht > 0 ? 1 / ht : 0
2373
+ };
1789
2374
  });
1790
2375
  const maxScore = Math.max(...scored.map((s) => s.score));
1791
- if (maxScore === 0) return {
2376
+ if (!Number.isFinite(maxScore)) return {
1792
2377
  paths: paths.map((path) => ({
1793
2378
  ...path,
1794
2379
  score: 0
1795
2380
  })),
1796
- method: "random"
1797
- };
1798
- return {
1799
- paths: scored.map(({ path, score }) => ({
1800
- ...path,
1801
- score: includeScores ? score / maxScore : score / maxScore
1802
- })).sort((a, b) => b.score - a.score),
1803
- method: "random"
2381
+ method: "hitting-time"
1804
2382
  };
2383
+ return normaliseAndRank(paths, scored, "hitting-time", includeScores);
1805
2384
  }
1806
2385
  //#endregion
1807
2386
  //#region src/extraction/ego-network.ts
@@ -2421,6 +3000,6 @@ function filterSubgraph(graph, options) {
2421
3000
  return result;
2422
3001
  }
2423
3002
  //#endregion
2424
- export { AdjacencyMapGraph, GPUContext, GPUNotAvailableError, PriorityQueue, _computeMean, adamicAdar, adaptive, approximateClusteringCoefficient, assertWebGPUAvailable, base, batchClusteringCoefficients, betweenness, bfs, bfsWithPath, communicability, computeTrussNumbers, countEdgesOfType, countNodesOfType, createGPUContext, createResultBuffer, csrToGPUBuffers, degreeSum, detectWebGPU, dfs, dfsWithPath, dome, domeHighDegree, edge, entropyFromCounts, enumerateMotifs, enumerateMotifsWithInstances, etch, extractEgoNetwork, extractInducedSubgraph, extractKCore, extractKTruss, filterSubgraph, flux, frontierBalanced, fuse, getGPUContext, getMotifName, graphToCSR, grasp, hae, isWebGPUAvailable, jaccard, jaccardArithmetic, katz, lace, localClusteringCoefficient, localTypeEntropy, maze, miniBatchKMeans, neighbourIntersection, neighbourOverlap, neighbourSet, normaliseFeatures, normaliseFeatures as zScoreNormalise, normalisedEntropy, notch, pagerank, parse, pipe, randomPriority, randomRanking, reach, readBufferToCPU, resistanceDistance, sage, scale, shannonEntropy, shortest, sift, skew, span, standardBfs, stratified, tide, warp, widestPath };
3003
+ export { AdjacencyMapGraph, GPUContext, GPUNotAvailableError, PriorityQueue, _computeMean, adamicAdar, adaptive, approximateClusteringCoefficient, assertWebGPUAvailable, base, batchClusteringCoefficients, betweenness, bfs, bfsWithPath, communicability, computeJaccard, computeTrussNumbers, cosine, countEdgesOfType, countNodesOfType, createGPUContext, createResultBuffer, csrToGPUBuffers, degreeSum, detectWebGPU, dfs, dfsPriority, dfsPriorityFn, dfsWithPath, dome, domeHighDegree, edge, entropyFromCounts, enumerateMotifs, enumerateMotifsWithInstances, etch, extractEgoNetwork, extractInducedSubgraph, extractKCore, extractKTruss, filterSubgraph, flux, frontierBalanced, fuse, getGPUContext, getMotifName, graphToCSR, grasp, hae, hittingTime, hubPromoted, isWebGPUAvailable, jaccard, jaccardArithmetic, kHop, katz, lace, localClusteringCoefficient, localTypeEntropy, maze, miniBatchKMeans, neighbourIntersection, neighbourOverlap, neighbourSet, normaliseFeatures, normaliseFeatures as zScoreNormalise, normalisedEntropy, notch, overlapCoefficient, pagerank, parse, pipe, randomPriority, randomRanking, randomWalk, reach, readBufferToCPU, resistanceDistance, resourceAllocation, sage, scale, shannonEntropy, shortest, sift, skew, sorensen, span, standardBfs, stratified, tide, warp, widestPath };
2425
3004
 
2426
3005
  //# sourceMappingURL=index.js.map