@graphty/cytoscape-extensions 0.0.0-placeholder.0 → 0.0.2

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.
@@ -0,0 +1,1911 @@
1
+ /**
2
+ * Every @graphty/algorithms algorithm as a Cytoscape.js 3.x collection and core method, named "graphty<Name>".
3
+ *
4
+ * The conventions, which follow Cytoscape's built-in algorithms:
5
+ * - scope is the calling collection (its nodes, and its edges whose two ends are in it); `cy.graphtyX(o)` is
6
+ * `cy.elements().graphtyX(o)`;
7
+ * - synchronous, one options object;
8
+ * - `directed` (default false) and `weight` (an edge data field, or a function of the edge) describe the graph;
9
+ * - nodes are named by a selector or a collection (`root`, `goal`, `source`, `sink`, ...);
10
+ * - results are keyed by element: accessor functions (`score(node)`, `distanceTo(node)`), collections, and arrays
11
+ * of collections, never index arrays;
12
+ * - `field` writes the per-element value (what `score` or `cluster` returns) into `data(field)`, so a stylesheet
13
+ * can map it;
14
+ * - every other option the method's type lists goes to the @graphty/algorithms function unchanged, and an option it
15
+ * does not list throws (src/algorithm-options.ts, generated from those types);
16
+ * - errors throw, from the adapter (a selection that matches nothing, a weight given to an algorithm that ignores
17
+ * weights, an unknown option) and from the algorithm (an undirected graph given to one that needs a directed one),
18
+ * each message starting with the method name.
19
+ */
20
+
21
+ import {
22
+ accelerated,
23
+ type AcceleratedAlgorithms,
24
+ adamicAdarForPairs,
25
+ adamicAdarPrediction,
26
+ adamicAdarScore,
27
+ allPairsShortestPath,
28
+ type ApspOptions,
29
+ astar,
30
+ bellmanFord,
31
+ betweennessCentrality,
32
+ type BetweennessOptions,
33
+ bidirectionalDijkstra,
34
+ type BipartiteOptions,
35
+ breadthFirstSearch,
36
+ type CandidateOptions,
37
+ closenessCentrality,
38
+ type ClosenessOptions,
39
+ commonNeighborsForPairs,
40
+ commonNeighborsPrediction,
41
+ commonNeighborsScore,
42
+ condensation,
43
+ connectedComponents,
44
+ degreeCentrality,
45
+ type DegreeCentralityOptions,
46
+ degrees,
47
+ DeltaPageRank,
48
+ type DeltaPageRankComputeOptions,
49
+ depthFirstSearch,
50
+ dijkstra,
51
+ directionOptimizedBfs,
52
+ type DirectionOptimizedBfsOptions,
53
+ edgeBetweennessCentrality,
54
+ eigenvectorCentrality,
55
+ type EigenvectorOptions,
56
+ evaluateAdamicAdar,
57
+ evaluateCommonNeighbors,
58
+ findAllIsomorphisms,
59
+ getTopAdamicAdarCandidatesForNode,
60
+ getTopCandidatesForNode,
61
+ girvanNewman,
62
+ type GirvanNewmanOptions,
63
+ greedyBipartiteMatching,
64
+ grsbm,
65
+ type GrsbmOptions,
66
+ hasCycle,
67
+ hierarchicalClustering,
68
+ type HierarchicalOptions,
69
+ hits,
70
+ type HitsOptions,
71
+ isBipartite,
72
+ isGraphIsomorphic,
73
+ kargerMinCut,
74
+ type KargerOptions,
75
+ katzCentrality,
76
+ type KatzOptions,
77
+ kCoreDecomposition,
78
+ kruskalMST,
79
+ labelPropagation,
80
+ type LabelPropagationOptions,
81
+ labelPropagationSemiSupervised,
82
+ labelPropagationSynchronous,
83
+ leiden,
84
+ type LeidenOptions,
85
+ type LinkPredictionMetrics,
86
+ type LinkPredictionOptions,
87
+ type LinkPredictionResult,
88
+ louvain,
89
+ type LouvainOptions,
90
+ markovClustering,
91
+ type MarkovOptions,
92
+ maxFlow,
93
+ type MaxFlowOptions,
94
+ maximumBipartiteMatching,
95
+ type MinCutResult,
96
+ minSTCut,
97
+ modularity,
98
+ type ModularityOptions,
99
+ nodeClosenessCentrality,
100
+ pageRank,
101
+ type PageRankOptions,
102
+ personalizedPageRank,
103
+ primMST,
104
+ PriorityDeltaPageRank,
105
+ spectralClustering,
106
+ type SpectralOptions,
107
+ stoerWagner,
108
+ stronglyConnectedComponents,
109
+ syncClustering,
110
+ type SyncClusteringOptions,
111
+ teraHAC,
112
+ type TeraHacOptions,
113
+ topologicalSort,
114
+ triangleCount,
115
+ weaklyConnectedComponents,
116
+ } from "@graphty/algorithms";
117
+ import {
118
+ type DerivedGraph,
119
+ type F64,
120
+ type GraphSnapshot,
121
+ INVALID_INDEX,
122
+ makeMask,
123
+ maskSet,
124
+ maskTest,
125
+ type U32,
126
+ } from "@graphty/graph-format";
127
+ import type {
128
+ Collection,
129
+ CollectionArgument,
130
+ CollectionReturnValue,
131
+ Core,
132
+ EdgeCollection,
133
+ EdgeSingular,
134
+ NodeCollection,
135
+ NodeSingular,
136
+ } from "cytoscape";
137
+
138
+ import { OPTION_NAMES } from "./algorithm-options.js";
139
+ import { type Backend, backendOf, checkGpuMode, gpuFor, type GpuMode, recording, warnIfFixable } from "./gpu.js";
140
+ import {
141
+ coreOf,
142
+ type CytoscapeSnapshot,
143
+ hold,
144
+ indexOf,
145
+ indicesOf,
146
+ type NodeSelection,
147
+ type SnapshotOptions,
148
+ toSnapshot,
149
+ writeData,
150
+ } from "./snapshot.js";
151
+
152
+ /** A node or edge as a result accessor takes it: the element, or a selector whose first match is used. */
153
+ export type ElementRef = string | Collection | NodeCollection | EdgeCollection;
154
+
155
+ /** Options every "graphty*" algorithm takes. */
156
+ export interface AlgorithmOptions {
157
+ /** Read the edges as directed (source to target). Default false, except where an algorithm says otherwise. */
158
+ readonly directed?: boolean;
159
+ /** Edge data field holding the weight, or a function of the edge. Default: every edge weighs 1. */
160
+ readonly weight?: SnapshotOptions["weight"];
161
+ /**
162
+ * Write each element's value (what `score` or `cluster` returns) into `data(field)`. A method whose result has no
163
+ * per-element value (a path, a tree, a number, a list of predictions) throws when given it.
164
+ */
165
+ readonly field?: string;
166
+ /**
167
+ * The `...Async` methods only: "auto" (default) runs on the GPU when the runtime has a usable WebGPU device,
168
+ * "off" runs on the CPU, "require" throws instead of running on the CPU when no device is available. The synchronous methods always run on the CPU and reject "require". Any other value throws a TypeError.
169
+ */
170
+ readonly gpu?: GpuMode;
171
+ }
172
+
173
+ /** Per-element values, keyed by element. */
174
+ export interface ScoreResult {
175
+ /** The value of an element of the collection; undefined for one outside it. */
176
+ score(ele: ElementRef): number | undefined;
177
+ }
178
+
179
+ /** A partition: an array of node collections, one per cluster, plus each node's cluster number. */
180
+ export type Partition<T = object> = NodeCollection[] &
181
+ T & {
182
+ /** The cluster number (its position in the array) of a node; undefined for one outside the collection. */
183
+ cluster(node: ElementRef): number | undefined;
184
+ };
185
+
186
+ /** Paths from one root, in the shape of Cytoscape's `dijkstra`. */
187
+ export interface PathsResult {
188
+ /** Distance from the root; Infinity when unreachable. */
189
+ distanceTo(node: ElementRef): number;
190
+ /** The path from the root, alternating node, edge, node; empty when unreachable. */
191
+ pathTo(node: ElementRef): CollectionReturnValue;
192
+ }
193
+
194
+ /** A walk from one root, in the shape of Cytoscape's `bfs` / `dfs`. */
195
+ export interface SearchResult {
196
+ /** The visited nodes in visit order, each after the tree edge that reached it. */
197
+ readonly path: CollectionReturnValue;
198
+ /** The `goal` node when it was reached, else empty. */
199
+ readonly found: CollectionReturnValue;
200
+ /**
201
+ * Edges from the root along the walk's tree (the parent links): the shortest hop count for a breadth-first
202
+ * search, the tree depth for a depth-first search, which can be larger. Undefined when not visited.
203
+ */
204
+ depth(node: ElementRef): number | undefined;
205
+ /** The node this one was reached from; undefined for the root and unvisited nodes. */
206
+ parent(node: ElementRef): NodeSingular | undefined;
207
+ }
208
+
209
+ /** One path between two nodes, in the shape of Cytoscape's `aStar`. */
210
+ export interface PointPathResult {
211
+ /** Whether `goal` can be reached from `root`. */
212
+ readonly found: boolean;
213
+ /** The length of the path; Infinity when not found. */
214
+ readonly distance: number;
215
+ /** Alternating node, edge, node; empty when not found. */
216
+ readonly path: CollectionReturnValue;
217
+ }
218
+
219
+ /**
220
+ * A cut, like the result of Cytoscape's `kargerStein` with `partition1` and `partition2` named `partitionFirst` and
221
+ * `partitionSecond`, plus `value` and without `components`.
222
+ */
223
+ export interface CutResult {
224
+ /** The total weight (capacity) of the cut edges. */
225
+ readonly value: number;
226
+ /** The edges that cross the cut. */
227
+ readonly cut: EdgeCollection;
228
+ /** The nodes on one side: the source side for a flow or s-t cut. */
229
+ readonly partitionFirst: NodeCollection;
230
+ /** The nodes on the other side. */
231
+ readonly partitionSecond: NodeCollection;
232
+ }
233
+
234
+ /** A predicted link. */
235
+ export interface PredictedLink {
236
+ /** One end of the proposed edge. */
237
+ readonly source: NodeSingular;
238
+ /** The other end. */
239
+ readonly target: NodeSingular;
240
+ /** The pair's score; higher is a more likely link. */
241
+ readonly score: number;
242
+ }
243
+
244
+ /** A pair of nodes, each a selector or a collection. */
245
+ export type NodePair = readonly [NodeSelection, NodeSelection];
246
+
247
+ /** Held-out edges and non-edges for the link-prediction evaluations. */
248
+ interface EvaluateOptions extends AlgorithmOptions {
249
+ /** Node pairs that are edges (held out of the graph), which a good score ranks high. */
250
+ readonly edges: readonly NodePair[];
251
+ /** Node pairs that are not edges, which a good score ranks low. */
252
+ readonly nonEdges: readonly NodePair[];
253
+ }
254
+
255
+ /** What every algorithm implementation gets. */
256
+ interface Ctx {
257
+ readonly name: string;
258
+ readonly cy: Core;
259
+ readonly cs: CytoscapeSnapshot;
260
+ readonly s: GraphSnapshot;
261
+ readonly field: string | undefined;
262
+ /** `weighted` for the algorithms that read the snapshot's weights only when asked. */
263
+ readonly weighted: boolean;
264
+ /** The caller's options without the adapter's own, over this package's pinned defaults (see DEFAULTS). */
265
+ readonly rest: Record<string, unknown>;
266
+ /**
267
+ * Runs one of the algorithms the GPU can answer: synchronously on the CPU for the plain methods, through the
268
+ * dispatcher of `@graphty/algorithms` (GPU or CPU, a promise) for the `...Async` methods.
269
+ */
270
+ call<K extends GpuKey>(key: K, ...args: Parameters<Dispatch[K]>): Out<K> | Promise<Out<K>>;
271
+ }
272
+
273
+ /** The @graphty/algorithms dispatcher's methods: the ones an accelerator can answer, by the dispatcher's names. */
274
+ type Dispatch = Omit<AcceleratedAlgorithms, "accelerator">;
275
+ type Out<K extends keyof Dispatch> = Awaited<ReturnType<Dispatch[K]>>;
276
+
277
+ /**
278
+ * The CPU functions behind the dispatcher methods the WebGPU accelerator implements (its `createAccelerator`
279
+ * list), under the dispatcher's names. The plain methods call these directly, so they stay synchronous.
280
+ */
281
+ const CPU = {
282
+ breadthFirstSearch,
283
+ sssp: dijkstra,
284
+ bellmanFord,
285
+ allPairsShortestPath,
286
+ pageRank,
287
+ personalizedPageRank,
288
+ eigenvectorCentrality,
289
+ katzCentrality,
290
+ hits,
291
+ closenessCentrality,
292
+ betweennessCentrality,
293
+ edgeBetweennessCentrality,
294
+ connectedComponents,
295
+ weaklyConnectedComponents,
296
+ triangleCount,
297
+ labelPropagation,
298
+ labelPropagationSynchronous,
299
+ } satisfies Partial<{ [K in keyof Dispatch]: (...args: Parameters<Dispatch[K]>) => Out<K> }>;
300
+
301
+ type GpuKey = keyof typeof CPU;
302
+
303
+ /**
304
+ * Applies a function to a value that is either plain (a CPU run) or a promise (a dispatcher run).
305
+ * @param x - the value or its promise
306
+ * @param f - the function
307
+ * @returns f(x), or its promise
308
+ */
309
+ function then<T, U>(x: T | Promise<T>, f: (t: T) => U): U | Promise<U> {
310
+ return x instanceof Promise ? x.then(f) : f(x);
311
+ }
312
+
313
+ // Options the adapter consumes and converts; everything else passes through to the algorithm.
314
+ const ADAPTER_KEYS = new Set([
315
+ "gpu",
316
+ "directed",
317
+ "weight",
318
+ "field",
319
+ "root",
320
+ "goal",
321
+ "target",
322
+ "source",
323
+ "sink",
324
+ "heuristic",
325
+ "personalization",
326
+ "seeds",
327
+ "left",
328
+ "right",
329
+ "clusters",
330
+ "pairs",
331
+ "edges",
332
+ "nonEdges",
333
+ "nodeMatch",
334
+ "edgeMatch",
335
+ "priority",
336
+ "candidates",
337
+ "sources",
338
+ "alive",
339
+ "startVector",
340
+ "initialRanks",
341
+ ]);
342
+
343
+ // Algorithms that read no weights: a `weight` option would be silently ignored, so it throws.
344
+ export const UNWEIGHTED: ReadonlySet<string> = new Set([
345
+ "breadthFirstSearch",
346
+ "directionOptimizedBfs",
347
+ "depthFirstSearch",
348
+ "hasCycle",
349
+ "topologicalSort",
350
+ "degreeCentrality",
351
+ "degrees",
352
+ "eigenvectorCentrality",
353
+ "betweennessCentrality",
354
+ "edgeBetweennessCentrality",
355
+ "connectedComponents",
356
+ "weaklyConnectedComponents",
357
+ "stronglyConnectedComponents",
358
+ "condensation",
359
+ "kCoreDecomposition",
360
+ "triangleCount",
361
+ "isBipartite",
362
+ "isGraphIsomorphic",
363
+ "findAllIsomorphisms",
364
+ "hierarchicalClustering",
365
+ "teraHAC",
366
+ "syncClustering",
367
+ "maximumBipartiteMatching",
368
+ "greedyBipartiteMatching",
369
+ "commonNeighborsScore",
370
+ "adamicAdarScore",
371
+ "commonNeighborsPrediction",
372
+ "adamicAdarPrediction",
373
+ "commonNeighborsForPairs",
374
+ "adamicAdarForPairs",
375
+ "topCandidatesForNode",
376
+ "topAdamicAdarCandidatesForNode",
377
+ "evaluateCommonNeighbors",
378
+ "evaluateAdamicAdar",
379
+ "compareAdamicAdarWithCommonNeighbors",
380
+ ]);
381
+
382
+ // Algorithms whose result has no per-element value to write: a `field` option would be silently ignored, so it throws.
383
+ export const NO_FIELD: ReadonlySet<string> = new Set([
384
+ "hasCycle",
385
+ "topologicalSort",
386
+ "bidirectionalDijkstra",
387
+ "aStar",
388
+ "allPairsShortestPath",
389
+ "degrees",
390
+ "nodeClosenessCentrality",
391
+ "isGraphIsomorphic",
392
+ "findAllIsomorphisms",
393
+ "modularity",
394
+ "hierarchicalClustering",
395
+ "kruskalMST",
396
+ "primMST",
397
+ "maximumBipartiteMatching",
398
+ "greedyBipartiteMatching",
399
+ "commonNeighborsScore",
400
+ "adamicAdarScore",
401
+ "commonNeighborsPrediction",
402
+ "adamicAdarPrediction",
403
+ "commonNeighborsForPairs",
404
+ "adamicAdarForPairs",
405
+ "topCandidatesForNode",
406
+ "topAdamicAdarCandidatesForNode",
407
+ "evaluateCommonNeighbors",
408
+ "evaluateAdamicAdar",
409
+ "compareAdamicAdarWithCommonNeighbors",
410
+ ]);
411
+
412
+ // This package's own defaults, passed explicitly to @graphty/algorithms (and through it to the GPU), so a change of
413
+ // a library default does not change what a Cytoscape user gets. docs/reference/algorithms.md shows them; a caller's
414
+ // option overrides them. `weighted` is pinned separately: it is true exactly when the caller passed `weight`.
415
+ const POWER = { maxIterations: 100, tolerance: 1e-6 } as const;
416
+ const PAGERANK = { ...POWER, dampingFactor: 0.85 } as const;
417
+ export const DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
418
+ pageRank: PAGERANK,
419
+ personalizedPageRank: PAGERANK,
420
+ deltaPageRank: PAGERANK,
421
+ eigenvectorCentrality: POWER,
422
+ hits: POWER,
423
+ katzCentrality: { ...POWER, alpha: 0.1, beta: 1 },
424
+ labelPropagation: { maxIterations: 100 },
425
+ labelPropagationSynchronous: { maxIterations: 100 },
426
+ labelPropagationSemiSupervised: { maxIterations: 100 },
427
+ allPairsShortestPath: { paths: true },
428
+ maxFlow: { algorithm: "edmonds-karp" },
429
+ minSTCut: { algorithm: "ford-fulkerson" },
430
+ };
431
+
432
+ // Algorithms defined only for directed graphs: `directed` defaults to true.
433
+ export const DIRECTED: ReadonlySet<string> = new Set([
434
+ "topologicalSort",
435
+ "stronglyConnectedComponents",
436
+ "condensation",
437
+ "deltaPageRank",
438
+ ]);
439
+
440
+ // ---------------------------------------------------------------------------------------------------------------
441
+ // Conversions between elements and indices
442
+
443
+ /**
444
+ * A collection of elements, in the given order. Cytoscape accepts an element array at run time; its typings declare
445
+ * only element definitions, hence the cast.
446
+ * @param c - the context
447
+ * @param eles - the elements
448
+ * @returns the collection
449
+ */
450
+ function collect(c: Ctx, eles: readonly (NodeSingular | EdgeSingular)[]): CollectionReturnValue {
451
+ return c.cy.collection(eles as unknown as CollectionArgument);
452
+ }
453
+
454
+ /**
455
+ * Throws on a negative edge weight, which Dijkstra and A* cannot take (on an undirected graph Dijkstra would never
456
+ * return). The code matches graphtyBidirectionalDijkstra's error.
457
+ * @param c - the context
458
+ */
459
+ function nonNegative(c: Ctx): void {
460
+ if (c.s.weights?.some((w) => w < 0) === true) {
461
+ throw Object.assign(new Error(`${c.name}: a weight is negative; use graphtyBellmanFord`), {
462
+ code: "E_BAD_WEIGHT",
463
+ });
464
+ }
465
+ }
466
+
467
+ /**
468
+ * The index of a required node option.
469
+ * @param c - the context
470
+ * @param sel - the option value
471
+ * @param what - the option name
472
+ * @returns the node index
473
+ */
474
+ function req(c: Ctx, sel: NodeSelection | undefined, what: string): number {
475
+ const i = indexOf(c.cs, sel, `${c.name}: ${what}`);
476
+ if (i === undefined) {
477
+ throw new Error(`${c.name}: the ${what} option is required`);
478
+ }
479
+ return i;
480
+ }
481
+
482
+ /**
483
+ * The index of a node or edge reference, or undefined when it is not in the snapshot.
484
+ * @param c - the context
485
+ * @param ref - an element or a selector
486
+ * @param edges - look among the edges instead of the nodes
487
+ * @returns the index
488
+ */
489
+ function lookup(c: Ctx, ref: ElementRef, edges = false): number | undefined {
490
+ const list = (edges ? c.cs.edges : c.cs.nodes) as unknown as Collection;
491
+ // a selector goes through Cytoscape's own matching (a collection has no find() that takes one)
492
+ const matches = typeof ref === "string" ? list.filter(ref) : (ref as Collection);
493
+ const ele = matches[0];
494
+ if (ele === undefined) {
495
+ return undefined;
496
+ }
497
+ if (!edges) {
498
+ const i = c.s.ids.indexOf(ele.id());
499
+ return i === INVALID_INDEX ? undefined : i;
500
+ }
501
+ // ponytail: linear scan per edge lookup; an id -> index map if edge accessors show up in a profile.
502
+ const i = c.cs.edges.toArray().indexOf(ele as EdgeSingular);
503
+ return i < 0 ? undefined : i;
504
+ }
505
+
506
+ /**
507
+ * An accessor from element to value.
508
+ * @param c - the context
509
+ * @param values - one value per node (or edge) index
510
+ * @param edges - the values are per edge
511
+ * @returns the accessor
512
+ */
513
+ function accessor(c: Ctx, values: ArrayLike<number>, edges = false): (ref: ElementRef) => number | undefined {
514
+ return (ref) => {
515
+ const i = lookup(c, ref, edges);
516
+ return i === undefined ? undefined : values[i];
517
+ };
518
+ }
519
+
520
+ /**
521
+ * The nodes at some indices, as a collection in that order.
522
+ * @param c - the context
523
+ * @param idx - node indices
524
+ * @returns the collection
525
+ */
526
+ function nodesAt(c: Ctx, idx: ArrayLike<number>): NodeCollection {
527
+ return collect(
528
+ c,
529
+ Array.from(idx, (i) => c.cs.nodes[i]),
530
+ );
531
+ }
532
+
533
+ /**
534
+ * The edges at some indices, as a collection in that order.
535
+ * @param c - the context
536
+ * @param idx - edge indices
537
+ * @returns the collection
538
+ */
539
+ function edgesAt(c: Ctx, idx: ArrayLike<number>): EdgeCollection {
540
+ return collect(
541
+ c,
542
+ Array.from(idx, (i) => c.cs.edges[i]),
543
+ );
544
+ }
545
+
546
+ /**
547
+ * A path as Cytoscape's path algorithms return it: node, edge, node, ...
548
+ * @param c - the context
549
+ * @param nodes - node indices along the path
550
+ * @param edges - edge indices along the path, one fewer
551
+ * @returns the collection
552
+ */
553
+ function pathOf(c: Ctx, nodes: ArrayLike<number>, edges: ArrayLike<number>): CollectionReturnValue {
554
+ const out: (NodeSingular | EdgeSingular)[] = [];
555
+ for (let k = 0; k < nodes.length; k++) {
556
+ if (k > 0) {
557
+ out.push(c.cs.edges[edges[k - 1]]);
558
+ }
559
+ out.push(c.cs.nodes[nodes[k]]);
560
+ }
561
+ return collect(c, out);
562
+ }
563
+
564
+ /**
565
+ * The node indices of each selection in a list.
566
+ * @param c - the context
567
+ * @param pairs - node pairs
568
+ * @param what - the option name
569
+ * @returns the sources and targets
570
+ */
571
+ function pairsOf(
572
+ c: Ctx,
573
+ pairs: readonly NodePair[] | undefined,
574
+ what: string,
575
+ ): { sources: number[]; targets: number[] } {
576
+ if (pairs === undefined) {
577
+ throw new Error(`${c.name}: the ${what} option is required`);
578
+ }
579
+ return { sources: pairs.map((p) => req(c, p[0], what)), targets: pairs.map((p) => req(c, p[1], what)) };
580
+ }
581
+
582
+ /**
583
+ * A node mask of a selection.
584
+ * @param c - the context
585
+ * @param sel - a selection, or undefined
586
+ * @returns the mask, or undefined
587
+ */
588
+ function maskOf(c: Ctx, sel: NodeSelection | undefined): U32 | undefined {
589
+ if (sel === undefined) {
590
+ return undefined;
591
+ }
592
+ const m = makeMask(c.s.nodeCount);
593
+ for (const i of indicesOf(c.cs, sel)) {
594
+ maskSet(m, i, true);
595
+ }
596
+ return m;
597
+ }
598
+
599
+ // ---------------------------------------------------------------------------------------------------------------
600
+ // Result builders
601
+
602
+ /**
603
+ * A score result: `score(ele)` plus extras; writes `field`.
604
+ * @param c - the context
605
+ * @param values - one value per node (or edge) index
606
+ * @param extra - other fields of the result
607
+ * @param edges - the values are per edge
608
+ * @returns the result
609
+ */
610
+ function scored<T extends object>(c: Ctx, values: ArrayLike<number>, extra: T, edges = false): ScoreResult & T {
611
+ if (c.field !== undefined) {
612
+ writeData(edges ? c.cs.edges : c.cs.nodes, values, c.field);
613
+ }
614
+ return { score: accessor(c, values, edges), ...extra };
615
+ }
616
+
617
+ /**
618
+ * A partition: one node collection per label, with `cluster(node)` and extras; writes `field`.
619
+ * @param c - the context
620
+ * @param labels - the label of every node index, 0..count-1 (INVALID_INDEX: in no cluster)
621
+ * @param extra - other fields of the result
622
+ * @returns the partition
623
+ */
624
+ function partition<T extends object>(c: Ctx, labels: ArrayLike<number>, extra: T): Partition<T> {
625
+ const groups: number[][] = [];
626
+ for (let i = 0; i < labels.length; i++) {
627
+ const l = labels[i];
628
+ if (l !== INVALID_INDEX) {
629
+ const group = groups[l] ?? [];
630
+ group.push(i);
631
+ groups[l] = group;
632
+ }
633
+ }
634
+ if (c.field !== undefined) {
635
+ writeData(c.cs.nodes, labels, c.field);
636
+ }
637
+ const out = Array.from(groups, (g) => nodesAt(c, g ?? []));
638
+ return Object.assign(out, extra, {
639
+ cluster: (ref: ElementRef) => {
640
+ const i = lookup(c, ref);
641
+ return i === undefined || labels[i] === INVALID_INDEX ? undefined : labels[i];
642
+ },
643
+ });
644
+ }
645
+
646
+ /**
647
+ * A walk result (BFS, DFS).
648
+ * @param c - the context
649
+ * @param r - the walk
650
+ * @param r.order - visited node indices in visit order
651
+ * @param r.parent - parent node index per node
652
+ * @param r.depth - depth per node
653
+ * @param r.visitedCount - number of visited nodes
654
+ * @param target - the target index, if any
655
+ * @returns the search result
656
+ */
657
+ function search(
658
+ c: Ctx,
659
+ r: { order: Uint32Array; parent: Uint32Array; depth: Uint32Array; visitedCount: number },
660
+ target: number | undefined,
661
+ ): SearchResult {
662
+ const out: (NodeSingular | EdgeSingular)[] = [];
663
+ for (let k = 0; k < r.visitedCount; k++) {
664
+ const v = r.order[k];
665
+ const p = r.parent[v];
666
+ if (p !== INVALID_INDEX) {
667
+ // The walk records the parent node, not the arc, so the tree edge is looked up: the first edge of the
668
+ // collection joining the two (CPU BFS/DFS results have no `predArc`, unlike Dijkstra's).
669
+ const from = c.cs.nodes[p];
670
+ const to = c.cs.nodes[v];
671
+ const joining = c.s.directed ? from.edgesTo(to) : from.edgesWith(to);
672
+ const e = joining.intersection(c.cs.edges)[0];
673
+ if (e !== undefined) {
674
+ out.push(e);
675
+ }
676
+ }
677
+ out.push(c.cs.nodes[v]);
678
+ }
679
+ const reached = target !== undefined && r.depth[target] !== INVALID_INDEX;
680
+ if (c.field !== undefined) {
681
+ writeData(
682
+ c.cs.nodes,
683
+ Array.from(r.depth, (d) => (d === INVALID_INDEX ? Number.NaN : d)),
684
+ c.field,
685
+ );
686
+ }
687
+ return {
688
+ path: collect(c, out),
689
+ found: collect(c, reached ? [c.cs.nodes[target]] : []),
690
+ depth: (ref) => {
691
+ const i = lookup(c, ref);
692
+ return i === undefined || r.depth[i] === INVALID_INDEX ? undefined : r.depth[i];
693
+ },
694
+ parent: (ref) => {
695
+ const i = lookup(c, ref);
696
+ return i === undefined || r.parent[i] === INVALID_INDEX ? undefined : c.cs.nodes[r.parent[i]];
697
+ },
698
+ };
699
+ }
700
+
701
+ /**
702
+ * A single-source shortest-path result.
703
+ * @param c - the context
704
+ * @param r - the result
705
+ * @param r.dist - distance per node
706
+ * @param r.pathTo - node path to a target
707
+ * @param r.pathEdges - edge path to a target
708
+ * @returns the accessors
709
+ */
710
+ function paths(
711
+ c: Ctx,
712
+ r: { dist: ArrayLike<number>; pathTo(t: number): Uint32Array; pathEdges(t: number): Uint32Array },
713
+ ): PathsResult {
714
+ if (c.field !== undefined) {
715
+ writeData(c.cs.nodes, r.dist, c.field);
716
+ }
717
+ return {
718
+ distanceTo: (ref) => {
719
+ const i = lookup(c, ref);
720
+ return i === undefined ? Infinity : r.dist[i];
721
+ },
722
+ pathTo: (ref) => {
723
+ const i = lookup(c, ref);
724
+ return i === undefined ? c.cy.collection() : pathOf(c, r.pathTo(i), r.pathEdges(i));
725
+ },
726
+ };
727
+ }
728
+
729
+ /**
730
+ * A cut result.
731
+ * @param c - the context
732
+ * @param side - the first side as a node mask
733
+ * @param cutEdges - the cut edge indices
734
+ * @param value - the cut's weight
735
+ * @returns the cut
736
+ */
737
+ function cutOf(c: Ctx, side: U32, cutEdges: ArrayLike<number>, value: number): CutResult {
738
+ const first: number[] = [];
739
+ const second: number[] = [];
740
+ for (let i = 0; i < c.s.nodeCount; i++) {
741
+ (maskTest(side, i) ? first : second).push(i);
742
+ }
743
+ if (c.field !== undefined) {
744
+ writeData(
745
+ c.cs.nodes,
746
+ Array.from({ length: c.s.nodeCount }, (_, i) => (maskTest(side, i) ? 0 : 1)),
747
+ c.field,
748
+ );
749
+ }
750
+ return { value, cut: edgesAt(c, cutEdges), partitionFirst: nodesAt(c, first), partitionSecond: nodesAt(c, second) };
751
+ }
752
+
753
+ /**
754
+ * A minimum cut result.
755
+ * @param c - the context
756
+ * @param r - the cut
757
+ * @returns the cut
758
+ */
759
+ function minCut(c: Ctx, r: MinCutResult): CutResult {
760
+ return cutOf(c, r.side, r.cutEdges, r.cutValue);
761
+ }
762
+
763
+ /**
764
+ * Predicted links with their nodes.
765
+ * @param c - the context
766
+ * @param r - the prediction
767
+ * @returns one entry per predicted pair, best first
768
+ */
769
+ function links(c: Ctx, r: LinkPredictionResult): PredictedLink[] {
770
+ return Array.from(r.scores, (score, k) => ({
771
+ source: c.cs.nodes[r.sources[k]],
772
+ target: c.cs.nodes[r.targets[k]],
773
+ score,
774
+ }));
775
+ }
776
+
777
+ /**
778
+ * A node-to-node mapping accessor.
779
+ * @param c - the context
780
+ * @param other - the image collection's snapshot
781
+ * @param mapping - image index per node index
782
+ * @returns the accessor
783
+ */
784
+ function mapper(
785
+ c: Ctx,
786
+ other: CytoscapeSnapshot,
787
+ mapping: Uint32Array,
788
+ ): (node: ElementRef) => NodeSingular | undefined {
789
+ return (ref) => {
790
+ const i = lookup(c, ref);
791
+ return i === undefined ? undefined : other.nodes[mapping[i]];
792
+ };
793
+ }
794
+
795
+ /**
796
+ * The two-snapshot isomorphism options, with element callbacks turned into index callbacks.
797
+ * @param c - the context
798
+ * @param other - the second graph
799
+ * @param o - the options
800
+ * @returns the options for @graphty/algorithms
801
+ */
802
+ function isoOptions(c: Ctx, other: CytoscapeSnapshot, o: IsomorphismOptions): Record<string, unknown> {
803
+ const { nodeMatch, edgeMatch } = o;
804
+ return {
805
+ ...c.rest,
806
+ nodeMatch: nodeMatch && ((a: number, b: number) => nodeMatch(c.cs.nodes[a], other.nodes[b])),
807
+ edgeMatch: edgeMatch && ((a: number, b: number) => edgeMatch(c.cs.edges[a], other.edges[b])),
808
+ };
809
+ }
810
+
811
+ /**
812
+ * The snapshot of the second graph of an isomorphism test, read like the first.
813
+ * @param c - the context
814
+ * @param o - the options naming it
815
+ * @returns its snapshot
816
+ */
817
+ function otherOf(c: Ctx, o: IsomorphismOptions): CytoscapeSnapshot {
818
+ const other = o.other as unknown;
819
+ // required by the type, checked for callers without types
820
+ if (other === undefined) {
821
+ throw new Error(`${c.name}: the other option (the collection to compare with) is required`);
822
+ }
823
+ // a selector (over the whole core) or an array of elements names the other graph too
824
+ let eles = other;
825
+ if (typeof other === "string") {
826
+ eles = c.cy.$(other);
827
+ } else if (Array.isArray(other)) {
828
+ // Cytoscape takes an array of elements here; its typings list only definitions
829
+ eles = c.cy.collection(other);
830
+ }
831
+ if (typeof (eles as Partial<Collection> | null)?.nodes !== "function") {
832
+ throw new TypeError(
833
+ `${c.name}: the other option must be a Cytoscape collection, a selector or an array of elements`,
834
+ );
835
+ }
836
+ return toSnapshot(eles as Collection, { directed: c.s.directed });
837
+ }
838
+
839
+ /**
840
+ * A per-node vector from a function of the node or a selection (1 for selected nodes, 0 for the rest).
841
+ * @param c - the context
842
+ * @param v - the function or selection
843
+ * @param what - the option name
844
+ * @returns the vector
845
+ */
846
+ function vectorOf(c: Ctx, v: NodeSelection | ((node: NodeSingular) => number) | undefined, what: string): F64 {
847
+ if (v === undefined) {
848
+ throw new Error(`${c.name}: the ${what} option is required`);
849
+ }
850
+ if (typeof v === "function") {
851
+ return Float64Array.from(c.cs.nodes.toArray(), (n) => v(n));
852
+ }
853
+ const out = new Float64Array(c.s.nodeCount);
854
+ for (const i of indicesOf(c.cs, v)) {
855
+ out[i] = 1;
856
+ }
857
+ return out;
858
+ }
859
+
860
+ /**
861
+ * The teleport vector of a personalized PageRank. One that selects no node would run plain PageRank without a word,
862
+ * so it throws, as a required node option that matches nothing does.
863
+ * @param c - the context
864
+ * @param v - the selection or the weight function
865
+ * @returns the vector
866
+ */
867
+ function teleportOf(c: Ctx, v: NodeSelection | ((node: NodeSingular) => number) | undefined): F64 {
868
+ const p = vectorOf(c, v, "personalization");
869
+ if (!p.some((x) => x > 0)) {
870
+ throw new Error(
871
+ `${c.name}: personalization ${typeof v === "function" ? "gives no node a positive weight" : "matches no node"}`,
872
+ );
873
+ }
874
+ return p;
875
+ }
876
+
877
+ /**
878
+ * Labels per node from a list of clusters or a node data field.
879
+ * @param c - the context
880
+ * @param clusters - the clusters, or the data field holding each node's cluster
881
+ * @param what - the option name, for the error
882
+ * @returns label per node index (INVALID_INDEX: none)
883
+ */
884
+ function labelsOf(c: Ctx, clusters: string | readonly NodeSelection[] | undefined, what: string): U32 {
885
+ if (clusters === undefined) {
886
+ throw new Error(`${c.name}: the ${what} option is required`);
887
+ }
888
+ const labels = new Uint32Array(c.s.nodeCount).fill(INVALID_INDEX);
889
+ if (typeof clusters === "string") {
890
+ const ids = new Map<unknown, number>();
891
+ c.cs.nodes.forEach((n, i) => {
892
+ const v: unknown = n.data(clusters);
893
+ if (v !== undefined) {
894
+ labels[i] = ids.get(v) ?? ids.set(v, ids.size).size - 1;
895
+ }
896
+ });
897
+ } else {
898
+ clusters.forEach((sel, k) => {
899
+ for (const i of indicesOf(c.cs, sel)) {
900
+ labels[i] = k;
901
+ }
902
+ });
903
+ }
904
+ // a misspelled field or selector would otherwise give a plausible result (modularity 0, an unseeded partition)
905
+ if (labels.every((l) => l === INVALID_INDEX)) {
906
+ throw new Error(
907
+ typeof clusters === "string"
908
+ ? `${c.name}: ${what} names the data field "${clusters}", which no node has`
909
+ : `${c.name}: ${what} matches no node`,
910
+ );
911
+ }
912
+ return labels;
913
+ }
914
+
915
+ /** The all-pairs result. */
916
+ interface ApspResult {
917
+ distance(from: ElementRef, to: ElementRef): number;
918
+ /** The path; throws when the run was made with `paths: false`. */
919
+ path(from: ElementRef, to: ElementRef): CollectionReturnValue;
920
+ readonly hasNegativeWeightCycle: boolean;
921
+ }
922
+
923
+ /**
924
+ * The all-pairs accessors.
925
+ * @param c - the context
926
+ * @param r - the distance matrix, with the path walkers when it was run with `paths: true`
927
+ * @returns the accessors
928
+ */
929
+ function apsp(
930
+ c: Ctx,
931
+ r: Out<"allPairsShortestPath"> & {
932
+ pathTo?: (i: number, j: number) => Uint32Array;
933
+ pathEdges?: (i: number, j: number) => Uint32Array;
934
+ },
935
+ ): ApspResult {
936
+ const pair = (a: ElementRef, b: ElementRef): [number, number] | undefined => {
937
+ const i = lookup(c, a);
938
+ const j = lookup(c, b);
939
+ return i === undefined || j === undefined ? undefined : [i, j];
940
+ };
941
+ return {
942
+ hasNegativeWeightCycle: r.hasNegativeCycle,
943
+ distance: (a, b) => {
944
+ const p = pair(a, b);
945
+ return p === undefined ? Infinity : r.dist[p[0] * r.n + p[1]];
946
+ },
947
+ path: (a, b) => {
948
+ const { pathTo, pathEdges } = r;
949
+ if (pathTo === undefined || pathEdges === undefined) {
950
+ // the same text the CPU result throws, so the message does not depend on the backend
951
+ throw new Error("allPairsShortestPath: pass paths: true to walk shortest paths");
952
+ }
953
+ const p = pair(a, b);
954
+ return p === undefined ? c.cy.collection() : pathOf(c, pathTo(p[0], p[1]), pathEdges(p[0], p[1]));
955
+ },
956
+ };
957
+ }
958
+
959
+ /** A PageRank result. */
960
+ type RankResult = ScoreResult & { rank(node: ElementRef): number | undefined; iterations: number; converged: boolean };
961
+
962
+ /**
963
+ * A PageRank result from scores.
964
+ * @param c - the context
965
+ * @param r - the run
966
+ * @returns the result
967
+ */
968
+ function ranked(c: Ctx, r: Out<"pageRank">): RankResult {
969
+ return scored(c, r.scores, { rank: accessor(c, r.scores), iterations: r.iterations, converged: r.converged });
970
+ }
971
+
972
+ /**
973
+ * A label-propagation partition. The GPU result has no `iterations` / `converged` (the WebGPU label propagation
974
+ * does not report them), so both are undefined when the GPU ran.
975
+ * @param c - the context
976
+ * @param r - the run
977
+ * @returns the partition
978
+ */
979
+ function propagated(
980
+ c: Ctx,
981
+ r: Out<"labelPropagation"> & { iterations?: number; converged?: boolean },
982
+ ): Partition<LpaReport> {
983
+ return partition(c, r.labels, { iterations: r.iterations, converged: r.converged });
984
+ }
985
+
986
+ /** What label propagation reports beside the partition; undefined when the GPU ran. */
987
+ interface LpaReport {
988
+ iterations: number | undefined;
989
+ converged: boolean | undefined;
990
+ }
991
+
992
+ /** A result that is plain from a plain method and a promise from an `...Async` method. */
993
+ type MaybeAsync<T> = T | Promise<T>;
994
+
995
+ // ---------------------------------------------------------------------------------------------------------------
996
+ // Option types
997
+
998
+ interface Rooted extends AlgorithmOptions {
999
+ /** The start node: a selector or a collection (its first node). */
1000
+ readonly root: NodeSelection;
1001
+ }
1002
+
1003
+ interface PointToPoint extends Rooted {
1004
+ /** The node to reach. */
1005
+ readonly goal: NodeSelection;
1006
+ }
1007
+
1008
+ interface WalkOptions extends Rooted {
1009
+ /** Stop when this node is reached; it is then `found`. */
1010
+ readonly goal?: NodeSelection;
1011
+ /** Stop expanding at this many hops from the root; unbounded when absent. */
1012
+ readonly maxDepth?: number;
1013
+ }
1014
+
1015
+ interface FlowOptions extends AlgorithmOptions, Omit<MaxFlowOptions, "weights"> {
1016
+ /** The node the flow leaves. */
1017
+ readonly source: NodeSelection;
1018
+ /** The node the flow arrives at. */
1019
+ readonly sink: NodeSelection;
1020
+ }
1021
+
1022
+ interface IsomorphismOptions extends AlgorithmOptions {
1023
+ /**
1024
+ * The graph to compare with, read with the same `directed`: a collection, an array of elements, or a selector
1025
+ * over the whole core (for example ".second", matching that graph's nodes and edges).
1026
+ */
1027
+ readonly other: Collection | readonly (NodeSingular | EdgeSingular)[] | string;
1028
+ /** Two nodes may be paired only when this returns true; by default any two may. */
1029
+ readonly nodeMatch?: (a: NodeSingular, b: NodeSingular) => boolean;
1030
+ /** Two edges may be paired only when this returns true; by default any two may. */
1031
+ readonly edgeMatch?: (a: EdgeSingular, b: EdgeSingular) => boolean;
1032
+ }
1033
+
1034
+ interface MatchingOptions extends AlgorithmOptions {
1035
+ /** One side of the bipartite graph; inferred when absent. */
1036
+ readonly left?: NodeSelection;
1037
+ /** The other side; inferred when absent. */
1038
+ readonly right?: NodeSelection;
1039
+ /** With `directed`: "both" (default) ignores direction; "out" joins a left node only to its out-neighbors. */
1040
+ readonly arcs?: "both" | "out";
1041
+ }
1042
+
1043
+ /** A cap on path length. */
1044
+ interface Cutoff {
1045
+ /** Stop relaxing beyond this distance; a node farther away reads as unreachable. Unbounded when absent. */
1046
+ readonly cutoff?: number;
1047
+ }
1048
+
1049
+ /** Sampled centrality. */
1050
+ interface Sampled {
1051
+ /** The nodes to run from, a selector or a collection; default every node. */
1052
+ readonly sources?: NodeSelection;
1053
+ }
1054
+
1055
+ /** One node pair. */
1056
+ interface PairOptions {
1057
+ /** One node of the pair. */
1058
+ readonly source: NodeSelection;
1059
+ /** The other node of the pair. */
1060
+ readonly target: NodeSelection;
1061
+ }
1062
+
1063
+ /** Several node pairs. */
1064
+ interface PairsOptions {
1065
+ /** The node pairs to score, each `[a, b]` of selectors or collections. */
1066
+ readonly pairs: readonly NodePair[];
1067
+ }
1068
+
1069
+ /** The nodes link prediction may propose. */
1070
+ interface Candidates {
1071
+ /** The nodes to consider linking to `root`; default every node. */
1072
+ readonly candidates?: NodeSelection;
1073
+ }
1074
+
1075
+ /**
1076
+ * The `topK` of a candidates method with the meaning it has on the prediction methods: a positive count keeps that
1077
+ * many, 0 or less keeps every candidate (@graphty/algorithms slices with it, so 0 would keep none and -1 drop one).
1078
+ * @param o - the options
1079
+ * @param o.topK - the caller's value
1080
+ * @returns the value for @graphty/algorithms
1081
+ */
1082
+ function topK(o: { readonly topK?: number }): number | undefined {
1083
+ return o.topK !== undefined && o.topK <= 0 ? Infinity : o.topK;
1084
+ }
1085
+
1086
+ interface MatchingResult {
1087
+ readonly size: number;
1088
+ /** The node matched with this one, either side. */
1089
+ mate(node: ElementRef): NodeSingular | undefined;
1090
+ }
1091
+
1092
+ type WeightedFlag<T> = AlgorithmOptions & Omit<T, "weighted" | "weights">;
1093
+
1094
+ /**
1095
+ * A matching result.
1096
+ * @param c - the context
1097
+ * @param r - the matching
1098
+ * @param r.matching - partner per node index
1099
+ * @param r.size - matched pairs
1100
+ * @returns mate accessor and size
1101
+ */
1102
+ function matched(c: Ctx, r: { matching: Uint32Array; size: number }): MatchingResult {
1103
+ const mate = new Uint32Array(c.s.nodeCount).fill(INVALID_INDEX);
1104
+ r.matching.forEach((m, i) => {
1105
+ if (m !== INVALID_INDEX) {
1106
+ mate[i] = m;
1107
+ mate[m] = i;
1108
+ }
1109
+ });
1110
+ return {
1111
+ size: r.size,
1112
+ mate: (ref) => {
1113
+ const i = lookup(c, ref);
1114
+ return i === undefined || mate[i] === INVALID_INDEX ? undefined : c.cs.nodes[mate[i]];
1115
+ },
1116
+ };
1117
+ }
1118
+
1119
+ /**
1120
+ * The best level of a Girvan-Newman run.
1121
+ * @param modularities - modularity per level
1122
+ * @returns the level index with the highest modularity
1123
+ */
1124
+ function bestLevel(modularities: ArrayLike<number>): number {
1125
+ let best = 0;
1126
+ for (let k = 1; k < modularities.length; k++) {
1127
+ if (modularities[k] > modularities[best]) {
1128
+ best = k;
1129
+ }
1130
+ }
1131
+ return best;
1132
+ }
1133
+
1134
+ // ---------------------------------------------------------------------------------------------------------------
1135
+ // The algorithms. Key = the @graphty/algorithms name; registered as "graphty" + the key with a capital.
1136
+
1137
+ const IMPLS = {
1138
+ // Traversal and paths
1139
+ breadthFirstSearch: (c: Ctx, o: WalkOptions): MaybeAsync<SearchResult> => {
1140
+ const target = indexOf(c.cs, o.goal, `${c.name}: goal`);
1141
+ return then(c.call("breadthFirstSearch", c.s, req(c, o.root, "root"), { ...c.rest, target }), (r) =>
1142
+ search(c, r, target),
1143
+ );
1144
+ },
1145
+ directionOptimizedBfs: (c: Ctx, o: Rooted & DirectionOptimizedBfsOptions): SearchResult =>
1146
+ search(c, directionOptimizedBfs(c.s, req(c, o.root, "root"), c.rest), undefined),
1147
+ depthFirstSearch: (
1148
+ c: Ctx,
1149
+ o: Omit<WalkOptions, "maxDepth"> & {
1150
+ /** "pre" (default) lists a node when it is first reached, "post" when everything below it is finished. */
1151
+ readonly order?: "pre" | "post";
1152
+ },
1153
+ ): SearchResult => {
1154
+ const target = indexOf(c.cs, o.goal, `${c.name}: goal`);
1155
+ // a post-order walk cannot stop at the goal, so it would silently visit every node
1156
+ if (target !== undefined && o.order === "post") {
1157
+ throw new Error(`${c.name}: goal stops only a pre-order walk; leave it out with order "post"`);
1158
+ }
1159
+ return search(c, depthFirstSearch(c.s, req(c, o.root, "root"), { ...c.rest, target }), target);
1160
+ },
1161
+ hasCycle: (c: Ctx, _o: AlgorithmOptions = {}): boolean => hasCycle(c.s),
1162
+ topologicalSort: (c: Ctx, _o: AlgorithmOptions = {}): NodeCollection | null => {
1163
+ const order = topologicalSort(c.s);
1164
+ return order === null ? null : nodesAt(c, order);
1165
+ },
1166
+ dijkstra: (c: Ctx, o: Rooted & Cutoff): PathsResult | Promise<PathsResult> => {
1167
+ nonNegative(c);
1168
+ return then(c.call("sssp", c.s, req(c, o.root, "root"), c.rest), (r) => paths(c, r));
1169
+ },
1170
+ bellmanFord: (c: Ctx, o: Rooted & Cutoff): MaybeAsync<PathsResult & { readonly hasNegativeWeightCycle: boolean }> =>
1171
+ then(c.call("bellmanFord", c.s, req(c, o.root, "root"), c.rest), (r) => ({
1172
+ ...paths(c, r),
1173
+ hasNegativeWeightCycle: r.hasNegativeCycle,
1174
+ })),
1175
+ bidirectionalDijkstra: (c: Ctx, o: PointToPoint): PointPathResult => {
1176
+ nonNegative(c);
1177
+ const r = bidirectionalDijkstra(c.s, req(c, o.root, "root"), req(c, o.goal, "goal"), c.rest);
1178
+ return { found: r.path.length > 0, distance: r.distance, path: pathOf(c, r.path, r.edges) };
1179
+ },
1180
+ aStar: (
1181
+ c: Ctx,
1182
+ o: PointToPoint & {
1183
+ /** An estimate of the distance left from a node to `goal`, never more than the real one. Default 0. */
1184
+ readonly heuristic?: (node: NodeSingular) => number;
1185
+ },
1186
+ ): PointPathResult => {
1187
+ const h = o.heuristic;
1188
+ nonNegative(c);
1189
+ const r = astar(
1190
+ c.s,
1191
+ req(c, o.root, "root"),
1192
+ req(c, o.goal, "goal"),
1193
+ h === undefined ? () => 0 : (u) => h(c.cs.nodes[u]),
1194
+ c.rest,
1195
+ );
1196
+ return { found: r.path.length > 0, distance: r.distance, path: pathOf(c, r.path, r.edges) };
1197
+ },
1198
+ allPairsShortestPath: (
1199
+ c: Ctx,
1200
+ _o: AlgorithmOptions & Omit<ApspOptions, "weights" | "weighted"> = {},
1201
+ ): MaybeAsync<ApspResult> => then(c.call("allPairsShortestPath", c.s, c.rest), (r) => apsp(c, r)),
1202
+
1203
+ // Centrality
1204
+ degreeCentrality: (
1205
+ c: Ctx,
1206
+ _o: AlgorithmOptions & DegreeCentralityOptions = {},
1207
+ ): ScoreResult & { degree(node: ElementRef): number | undefined } => {
1208
+ const v = degreeCentrality(c.s, c.rest);
1209
+ return scored(c, v, { degree: accessor(c, v) });
1210
+ },
1211
+ degrees: (
1212
+ c: Ctx,
1213
+ _o: AlgorithmOptions = {},
1214
+ ): { indegree(node: ElementRef): number | undefined; outdegree(node: ElementRef): number | undefined } => {
1215
+ const r = degrees(c.s);
1216
+ if (!c.s.directed) {
1217
+ // read both ways, an edge enters and leaves each end: both accessors give the degree (@graphty/algorithms'
1218
+ // degrees counts by source and target whatever the direction)
1219
+ const degree = Float64Array.from(r.inDegree, (d, i) => d + r.outDegree[i]);
1220
+ return { indegree: accessor(c, degree), outdegree: accessor(c, degree) };
1221
+ }
1222
+ return { indegree: accessor(c, r.inDegree), outdegree: accessor(c, r.outDegree) };
1223
+ },
1224
+ pageRank: (
1225
+ c: Ctx,
1226
+ o: WeightedFlag<Omit<PageRankOptions, "initialRanks">> & {
1227
+ /** Starting rank of each node. */
1228
+ readonly initialRanks?: (node: NodeSingular) => number;
1229
+ } = {},
1230
+ ): MaybeAsync<RankResult> => {
1231
+ const initialRanks = o.initialRanks && vectorOf(c, o.initialRanks, "initialRanks");
1232
+ return then(c.call("pageRank", c.s, { ...c.rest, initialRanks, weighted: c.weighted }), (r) => ranked(c, r));
1233
+ },
1234
+ personalizedPageRank: (
1235
+ c: Ctx,
1236
+ o: WeightedFlag<Omit<PageRankOptions, "initialRanks">> & {
1237
+ /** The teleport set: a selection (uniform over it) or a weight per node. */
1238
+ readonly personalization: NodeSelection | ((node: NodeSingular) => number);
1239
+ /** Starting rank of each node. */
1240
+ readonly initialRanks?: (node: NodeSingular) => number;
1241
+ },
1242
+ ): MaybeAsync<RankResult> => {
1243
+ const p = teleportOf(c, o.personalization);
1244
+ const initialRanks = o.initialRanks && vectorOf(c, o.initialRanks, "initialRanks");
1245
+ return then(c.call("personalizedPageRank", c.s, p, { ...c.rest, initialRanks, weighted: c.weighted }), (r) =>
1246
+ ranked(c, r),
1247
+ );
1248
+ },
1249
+ deltaPageRank: (
1250
+ c: Ctx,
1251
+ o: WeightedFlag<Omit<DeltaPageRankComputeOptions, "personalization">> & {
1252
+ /**
1253
+ * Process the largest pending delta first (`PriorityDeltaPageRank`, which ignores `personalization`); its
1254
+ * ranks match `graphtyPageRank({ directed: true })`. Without it the ranks can differ from PageRank's (by up
1255
+ * to 0.07 on the karate club), the run usually goes on until `maxIterations`, and a `maxIterations` in the
1256
+ * thousands overflows and throws.
1257
+ */
1258
+ readonly priority?: boolean;
1259
+ /** The teleport set: a selection (uniform over it) or a weight per node. */
1260
+ readonly personalization?: NodeSelection | ((node: NodeSingular) => number);
1261
+ } = {},
1262
+ ): ScoreResult & { rank(node: ElementRef): number | undefined } => {
1263
+ // The engines keep state for `update()` over the same frozen snapshot; a changed Cytoscape graph is a new
1264
+ // snapshot, so only the one-shot computation is offered.
1265
+ const personalization = o.personalization === undefined ? undefined : teleportOf(c, o.personalization);
1266
+ // PriorityDeltaPageRank's maxIterations counts processed nodes, so the pinned 100 rounds would stop it long
1267
+ // before it converges on any but a tiny graph: without the caller's own budget it runs until no delta is left
1268
+ const budget = o.priority === true && o.maxIterations === undefined ? { maxIterations: Infinity } : {};
1269
+ const opts = { ...c.rest, ...budget, personalization, weighted: c.weighted };
1270
+ const v =
1271
+ o.priority === true
1272
+ ? new PriorityDeltaPageRank(c.s).computeWithPriority(opts)
1273
+ : new DeltaPageRank(c.s).compute(opts);
1274
+ // ponytail: @graphty/algorithms' DeltaPageRank overflows to NaN at a large maxIterations; refuse it here
1275
+ // until that engine converges (then this check never fires)
1276
+ if (v.some((x) => !Number.isFinite(x))) {
1277
+ throw new RangeError(
1278
+ `${c.name}: the ranks overflowed (maxIterations ${String(c.rest.maxIterations)} is too high for this graph); ` +
1279
+ "lower maxIterations, or pass priority: true",
1280
+ );
1281
+ }
1282
+ return scored(c, v, { rank: accessor(c, v) });
1283
+ },
1284
+ eigenvectorCentrality: (
1285
+ c: Ctx,
1286
+ o: AlgorithmOptions &
1287
+ Omit<EigenvectorOptions, "startVector"> & {
1288
+ /** Starting value of each node; default uniform. */
1289
+ readonly startVector?: (node: NodeSingular) => number;
1290
+ } = {},
1291
+ ): MaybeAsync<ScoreResult & { iterations: number; converged: boolean }> => {
1292
+ const startVector = o.startVector && vectorOf(c, o.startVector, "startVector");
1293
+ return then(c.call("eigenvectorCentrality", c.s, { ...c.rest, startVector }), (r) =>
1294
+ scored(c, r.scores, { iterations: r.iterations, converged: r.converged }),
1295
+ );
1296
+ },
1297
+ katzCentrality: (
1298
+ c: Ctx,
1299
+ _o: WeightedFlag<KatzOptions> = {},
1300
+ ): MaybeAsync<ScoreResult & { iterations: number; converged: boolean }> =>
1301
+ then(c.call("katzCentrality", c.s, { ...c.rest, weighted: c.weighted }), (r) =>
1302
+ scored(c, r.scores, { iterations: r.iterations, converged: r.converged }),
1303
+ ),
1304
+ hits: (
1305
+ c: Ctx,
1306
+ _o: WeightedFlag<HitsOptions> = {},
1307
+ ): MaybeAsync<
1308
+ ScoreResult & {
1309
+ hub(node: ElementRef): number | undefined;
1310
+ authority(node: ElementRef): number | undefined;
1311
+ iterations: number;
1312
+ converged: boolean;
1313
+ }
1314
+ > =>
1315
+ then(c.call("hits", c.s, { ...c.rest, weighted: c.weighted }), (r) =>
1316
+ scored(c, r.authorities, {
1317
+ hub: accessor(c, r.hubs),
1318
+ authority: accessor(c, r.authorities),
1319
+ iterations: r.iterations,
1320
+ converged: r.converged,
1321
+ }),
1322
+ ),
1323
+ closenessCentrality: (
1324
+ c: Ctx,
1325
+ o: WeightedFlag<Omit<ClosenessOptions, "sources">> & Sampled = {},
1326
+ ): MaybeAsync<ScoreResult & { closeness(node: ElementRef): number | undefined }> => {
1327
+ const sources = o.sources === undefined ? undefined : indicesOf(c.cs, o.sources);
1328
+ return then(c.call("closenessCentrality", c.s, { ...c.rest, sources, weighted: c.weighted }), (r) =>
1329
+ scored(c, r.scores, { closeness: accessor(c, r.scores) }),
1330
+ );
1331
+ },
1332
+ nodeClosenessCentrality: (
1333
+ c: Ctx,
1334
+ o: Rooted & Omit<ClosenessOptions, "sources" | "k" | "weights" | "weighted">,
1335
+ ): number => nodeClosenessCentrality(c.s, req(c, o.root, "root"), { ...c.rest, weighted: c.weighted }),
1336
+ betweennessCentrality: (
1337
+ c: Ctx,
1338
+ o: AlgorithmOptions & Omit<BetweennessOptions, "sources"> & Sampled = {},
1339
+ ): MaybeAsync<
1340
+ ScoreResult & {
1341
+ betweenness(node: ElementRef): number | undefined;
1342
+ betweennessNormalized(node: ElementRef): number | undefined;
1343
+ }
1344
+ > => {
1345
+ const sources = o.sources === undefined ? undefined : indicesOf(c.cs, o.sources);
1346
+ return then(c.call("betweennessCentrality", c.s, { ...c.rest, sources }), (r) => {
1347
+ // Cytoscape's `betweenness` counts ordered pairs, so on an undirected graph it is twice the NetworkX
1348
+ // convention `score` follows. The result does not say which scale it is in, so the factor is rebuilt here
1349
+ // from the rules in @graphty/algorithms' BetweennessOptions.normalized.
1350
+ const n = c.s.nodeCount;
1351
+ const pairs = o.endpoints === true ? n * (n - 1) : (n - 1) * (n - 2);
1352
+ const unordered = c.s.directed ? 1 : 2;
1353
+ const toOrderedPairs = o.normalized === true && pairs > 0 ? pairs : unordered;
1354
+ const raw = Float64Array.from(r.scores, (x) => x * toOrderedPairs);
1355
+ const max = raw.reduce((m, x) => Math.max(m, x), 0);
1356
+ return scored(c, r.scores, {
1357
+ betweenness: accessor(c, raw),
1358
+ betweennessNormalized: accessor(
1359
+ c,
1360
+ raw.map((x) => (max === 0 ? 0 : x / max)),
1361
+ ),
1362
+ });
1363
+ });
1364
+ },
1365
+ edgeBetweennessCentrality: (
1366
+ c: Ctx,
1367
+ o: AlgorithmOptions &
1368
+ Sampled & {
1369
+ /** Divide by `(n - 1)(n - 2)` on a directed graph, half that on an undirected one. Default false. */
1370
+ readonly normalized?: boolean;
1371
+ /**
1372
+ * Draw this many source nodes instead of using every node; the same count draws the same nodes. Not
1373
+ * rescaled (see `sources`).
1374
+ */
1375
+ readonly k?: number;
1376
+ } = {},
1377
+ ): MaybeAsync<ScoreResult> => {
1378
+ const sources = o.sources === undefined ? undefined : indicesOf(c.cs, o.sources);
1379
+ return then(c.call("edgeBetweennessCentrality", c.s, { ...c.rest, sources }), (r) =>
1380
+ scored(c, r.scores, {}, true),
1381
+ );
1382
+ },
1383
+
1384
+ // Components and structure
1385
+ connectedComponents: (c: Ctx, _o: AlgorithmOptions = {}): MaybeAsync<Partition> =>
1386
+ then(c.call("connectedComponents", c.s), (r) => partition(c, r.labels, {})),
1387
+ weaklyConnectedComponents: (c: Ctx, _o: AlgorithmOptions = {}): MaybeAsync<Partition> =>
1388
+ then(c.call("weaklyConnectedComponents", c.s), (r) => partition(c, r.labels, {})),
1389
+ stronglyConnectedComponents: (c: Ctx, _o: AlgorithmOptions = {}): Partition =>
1390
+ partition(c, stronglyConnectedComponents(c.s).labels, {}),
1391
+ condensation: (c: Ctx, _o: AlgorithmOptions = {}): Partition<{ condensed: DerivedGraph }> => {
1392
+ const r = condensation(c.s);
1393
+ return partition(c, r.components.labels, { condensed: r.condensed });
1394
+ },
1395
+ kCoreDecomposition: (
1396
+ c: Ctx,
1397
+ _o: AlgorithmOptions = {},
1398
+ ): ScoreResult & { maxCore: number; core(k: number): NodeCollection } => {
1399
+ const r = kCoreDecomposition(c.s);
1400
+ const core = (k: number): NodeCollection =>
1401
+ nodesAt(
1402
+ c,
1403
+ Array.from(r.coreness.keys()).filter((i) => r.coreness[i] >= k),
1404
+ );
1405
+ return scored(c, r.coreness, { maxCore: r.maxCore, core });
1406
+ },
1407
+ triangleCount: (
1408
+ c: Ctx,
1409
+ _o: AlgorithmOptions = {},
1410
+ ): MaybeAsync<
1411
+ ScoreResult & { coefficient(node: ElementRef): number | undefined; total: number; transitivity: number }
1412
+ > =>
1413
+ then(c.call("triangleCount", c.s), (r) =>
1414
+ scored(c, r.perNode, {
1415
+ coefficient: accessor(c, r.coefficient),
1416
+ total: r.total,
1417
+ transitivity: r.transitivity,
1418
+ }),
1419
+ ),
1420
+ isBipartite: (
1421
+ c: Ctx,
1422
+ _o: AlgorithmOptions & BipartiteOptions = {},
1423
+ ): { bipartite: boolean; partitionFirst: NodeCollection; partitionSecond: NodeCollection } => {
1424
+ const r = isBipartite(c.s, c.rest);
1425
+ if (r.sides === null) {
1426
+ const empty = c.cy.collection() as unknown as NodeCollection;
1427
+ return { bipartite: false, partitionFirst: empty, partitionSecond: empty };
1428
+ }
1429
+ const { partitionFirst, partitionSecond } = cutOf(c, r.sides, [], 0);
1430
+ return { bipartite: true, partitionFirst, partitionSecond };
1431
+ },
1432
+ isGraphIsomorphic: (
1433
+ c: Ctx,
1434
+ o: IsomorphismOptions,
1435
+ ): { isomorphic: boolean; mapping(node: ElementRef): NodeSingular | undefined } => {
1436
+ const other = otherOf(c, o);
1437
+ const r = isGraphIsomorphic(c.s, other.snapshot, isoOptions(c, other, o));
1438
+ const m = r.mapping;
1439
+ return { isomorphic: r.isomorphic, mapping: m === null ? () => undefined : mapper(c, other, m) };
1440
+ },
1441
+ findAllIsomorphisms: (c: Ctx, o: IsomorphismOptions): ((node: ElementRef) => NodeSingular | undefined)[] => {
1442
+ const other = otherOf(c, o);
1443
+ return findAllIsomorphisms(c.s, other.snapshot, isoOptions(c, other, o)).map((m) => mapper(c, other, m));
1444
+ },
1445
+ modularity: (
1446
+ c: Ctx,
1447
+ o: AlgorithmOptions &
1448
+ Omit<ModularityOptions, "weights"> & {
1449
+ /** The clusters (a partition result, or selections), or the node data field holding each node's cluster. */
1450
+ readonly clusters: string | readonly NodeSelection[];
1451
+ },
1452
+ ): number => modularity(c.s, labelsOf(c, o.clusters, "clusters"), c.rest),
1453
+
1454
+ // Community detection and clustering
1455
+ louvain: (
1456
+ c: Ctx,
1457
+ _o: AlgorithmOptions & LouvainOptions = {},
1458
+ ): Partition<{ modularity: number; iterations: number }> => {
1459
+ const r = louvain(c.s, c.rest);
1460
+ return partition(c, r.labels, { modularity: r.modularity, iterations: r.iterations });
1461
+ },
1462
+ leiden: (
1463
+ c: Ctx,
1464
+ _o: AlgorithmOptions & LeidenOptions = {},
1465
+ ): Partition<{ modularity: number; iterations: number }> => {
1466
+ const r = leiden(c.s, c.rest);
1467
+ return partition(c, r.labels, { modularity: r.modularity, iterations: r.iterations });
1468
+ },
1469
+ labelPropagation: (c: Ctx, _o: WeightedFlag<LabelPropagationOptions> = {}): MaybeAsync<Partition<LpaReport>> =>
1470
+ then(c.call("labelPropagation", c.s, { ...c.rest, weighted: c.weighted }), (r) => propagated(c, r)),
1471
+ labelPropagationSynchronous: (
1472
+ c: Ctx,
1473
+ _o: AlgorithmOptions & {
1474
+ /** Iteration cap. */
1475
+ readonly maxIterations?: number;
1476
+ } = {},
1477
+ ): MaybeAsync<Partition<LpaReport>> =>
1478
+ then(c.call("labelPropagationSynchronous", c.s, { ...c.rest, weighted: c.weighted }), (r) => propagated(c, r)),
1479
+ labelPropagationSemiSupervised: (
1480
+ c: Ctx,
1481
+ o: WeightedFlag<LabelPropagationOptions> & {
1482
+ /**
1483
+ * Seed labels: the node data field holding them (nodes without it are free), or one selection per label.
1484
+ * Every free node starts with a label of its own, so a seed label spreads only where its seeds outvote
1485
+ * their neighbors: a single seed in a dense group can end up alone in its own cluster.
1486
+ */
1487
+ readonly seeds: string | readonly NodeSelection[];
1488
+ },
1489
+ ): Partition<{ iterations: number; converged: boolean }> => {
1490
+ const r = labelPropagationSemiSupervised(c.s, labelsOf(c, o.seeds, "seeds"), {
1491
+ ...c.rest,
1492
+ weighted: c.weighted,
1493
+ });
1494
+ return partition(c, r.labels, { iterations: r.iterations, converged: r.converged });
1495
+ },
1496
+ girvanNewman: (
1497
+ c: Ctx,
1498
+ _o: AlgorithmOptions & GirvanNewmanOptions = {},
1499
+ ): Partition<{
1500
+ modularity: number;
1501
+ levels: number;
1502
+ level(k: number): Partition;
1503
+ modularities: number[];
1504
+ }> => {
1505
+ const r = girvanNewman(c.s, c.rest);
1506
+ const quiet: Ctx = { ...c, field: undefined };
1507
+ const level = (k: number): Partition => partition(quiet, r.levels[k] ?? [], {});
1508
+ const best = bestLevel(r.modularity);
1509
+ return partition(c, r.levels[best] ?? [], {
1510
+ modularity: r.modularity[best] ?? Number.NaN,
1511
+ levels: r.levels.length,
1512
+ level,
1513
+ modularities: Array.from(r.modularity),
1514
+ });
1515
+ },
1516
+ markovClustering: (
1517
+ c: Ctx,
1518
+ _o: AlgorithmOptions & Omit<MarkovOptions, "weights"> = {},
1519
+ ): Partition<{ iterations: number; converged: boolean }> => {
1520
+ const r = markovClustering(c.s, c.rest);
1521
+ return partition(c, r.labels, { iterations: r.iterations, converged: r.converged });
1522
+ },
1523
+ spectralClustering: (
1524
+ c: Ctx,
1525
+ o: AlgorithmOptions & Omit<SpectralOptions, "weights">,
1526
+ ): Partition<{ converged: boolean; eigenvalues: Float64Array }> => {
1527
+ // required by the type, checked for callers without types
1528
+ if ((o.k as number | undefined) === undefined) {
1529
+ throw new Error(`${c.name}: the k option is required`);
1530
+ }
1531
+ const r = spectralClustering(c.s, c.rest as unknown as SpectralOptions);
1532
+ return partition(c, r.labels, { converged: r.converged, eigenvalues: r.eigenvalues });
1533
+ },
1534
+ hierarchicalClustering: (
1535
+ c: Ctx,
1536
+ _o: AlgorithmOptions & HierarchicalOptions = {},
1537
+ ): { cut(height: number): Partition; readonly merges: number } => {
1538
+ const r = hierarchicalClustering(c.s, c.rest);
1539
+ const quiet: Ctx = { ...c, field: undefined };
1540
+ const cut = (h: number): Partition => {
1541
+ const labels = new Uint32Array(c.s.nodeCount).fill(INVALID_INDEX);
1542
+ r.cut(h).forEach((group, k) => {
1543
+ for (const i of group) {
1544
+ labels[i] = k;
1545
+ }
1546
+ });
1547
+ return partition(quiet, labels, {});
1548
+ };
1549
+ return { merges: r.left.length, cut };
1550
+ },
1551
+ teraHAC: (c: Ctx, _o: AlgorithmOptions & TeraHacOptions = {}): Partition<{ merges: number }> => {
1552
+ const r = teraHAC(c.s, c.rest);
1553
+ return partition(c, r.labels, { merges: r.merges });
1554
+ },
1555
+ grsbm: (c: Ctx, _o: WeightedFlag<GrsbmOptions> = {}): Partition =>
1556
+ partition(c, grsbm(c.s, { ...c.rest, weighted: c.weighted }).labels, {}),
1557
+ syncClustering: (
1558
+ c: Ctx,
1559
+ o: AlgorithmOptions & SyncClusteringOptions,
1560
+ ): Partition<{ loss: number; iterations: number; converged: boolean }> => {
1561
+ // required by the type, checked for callers without types
1562
+ if ((o.numClusters as number | undefined) === undefined) {
1563
+ throw new Error(`${c.name}: the numClusters option is required`);
1564
+ }
1565
+ const r = syncClustering(c.s, c.rest as unknown as SyncClusteringOptions);
1566
+ return partition(c, r.labels, { loss: r.loss, iterations: r.iterations, converged: r.converged });
1567
+ },
1568
+
1569
+ // Spanning trees, flow, cuts, matching
1570
+ kruskalMST: (c: Ctx, _o: AlgorithmOptions = {}): CollectionReturnValue & { totalWeight: number } => {
1571
+ const r = kruskalMST(c.s);
1572
+ return Object.assign(c.cs.nodes.union(edgesAt(c, r.edges)), { totalWeight: r.totalWeight });
1573
+ },
1574
+ primMST: (
1575
+ c: Ctx,
1576
+ o: AlgorithmOptions & {
1577
+ /** The node the tree grows from; default the first node. */
1578
+ readonly root?: NodeSelection;
1579
+ /** Grow a tree in every component instead of throwing on a disconnected graph. Default false. */
1580
+ readonly forest?: boolean;
1581
+ } = {},
1582
+ ): CollectionReturnValue & { totalWeight: number } => {
1583
+ const start = indexOf(c.cs, o.root, `${c.name}: root`);
1584
+ const r = primMST(c.s, { ...c.rest, start });
1585
+ // a run that does not throw spans every node: the graph is connected, or `forest` grew a tree in every
1586
+ // component (an isolated node is a tree of its own)
1587
+ return Object.assign(c.cs.nodes.union(edgesAt(c, r.edges)), { totalWeight: r.totalWeight });
1588
+ },
1589
+ maxFlow: (c: Ctx, o: FlowOptions): CutResult & { flow(edge: ElementRef): number | undefined } => {
1590
+ const r = maxFlow(c.s, req(c, o.source, "source"), req(c, o.sink, "sink"), c.rest);
1591
+ return { ...cutOf(c, r.sourceSide, r.cutEdges, r.maxFlow), flow: accessor(c, r.flow, true) };
1592
+ },
1593
+ minSTCut: (c: Ctx, o: FlowOptions): CutResult =>
1594
+ minCut(c, minSTCut(c.s, req(c, o.source, "source"), req(c, o.sink, "sink"), c.rest)),
1595
+ stoerWagner: (c: Ctx, _o: AlgorithmOptions = {}): CutResult => minCut(c, stoerWagner(c.s)),
1596
+ kargerMinCut: (c: Ctx, _o: AlgorithmOptions & Omit<KargerOptions, "weights"> = {}): CutResult =>
1597
+ minCut(c, kargerMinCut(c.s, c.rest)),
1598
+ maximumBipartiteMatching: (c: Ctx, o: MatchingOptions = {}): MatchingResult =>
1599
+ matched(c, maximumBipartiteMatching(c.s, { ...c.rest, left: maskOf(c, o.left), right: maskOf(c, o.right) })),
1600
+ greedyBipartiteMatching: (c: Ctx, o: MatchingOptions = {}): MatchingResult =>
1601
+ matched(c, greedyBipartiteMatching(c.s, { ...c.rest, left: maskOf(c, o.left), right: maskOf(c, o.right) })),
1602
+
1603
+ // Link prediction. `directed` also selects the directed score (out-neighbours of u against in-neighbours of v).
1604
+ commonNeighborsScore: (c: Ctx, o: AlgorithmOptions & PairOptions): number =>
1605
+ commonNeighborsScore(c.s, req(c, o.source, "source"), req(c, o.target, "target"), { directed: c.s.directed }),
1606
+ adamicAdarScore: (c: Ctx, o: AlgorithmOptions & PairOptions): number =>
1607
+ adamicAdarScore(c.s, req(c, o.source, "source"), req(c, o.target, "target"), { directed: c.s.directed }),
1608
+ commonNeighborsPrediction: (
1609
+ c: Ctx,
1610
+ _o: AlgorithmOptions & Omit<LinkPredictionOptions, "directed"> = {},
1611
+ ): PredictedLink[] => links(c, commonNeighborsPrediction(c.s, { ...c.rest, directed: c.s.directed })),
1612
+ adamicAdarPrediction: (
1613
+ c: Ctx,
1614
+ _o: AlgorithmOptions & Omit<LinkPredictionOptions, "directed"> = {},
1615
+ ): PredictedLink[] => links(c, adamicAdarPrediction(c.s, { ...c.rest, directed: c.s.directed })),
1616
+ commonNeighborsForPairs: (c: Ctx, o: AlgorithmOptions & PairsOptions): number[] =>
1617
+ Array.from(commonNeighborsForPairs(c.s, pairsOf(c, o.pairs, "pairs"), { directed: c.s.directed })),
1618
+ adamicAdarForPairs: (c: Ctx, o: AlgorithmOptions & PairsOptions): number[] =>
1619
+ Array.from(adamicAdarForPairs(c.s, pairsOf(c, o.pairs, "pairs"), { directed: c.s.directed })),
1620
+ topCandidatesForNode: (
1621
+ c: Ctx,
1622
+ o: Rooted & Omit<CandidateOptions, "directed" | "candidates"> & Candidates,
1623
+ ): PredictedLink[] => {
1624
+ const candidates = o.candidates === undefined ? undefined : indicesOf(c.cs, o.candidates);
1625
+ return links(
1626
+ c,
1627
+ getTopCandidatesForNode(c.s, req(c, o.root, "root"), {
1628
+ ...c.rest,
1629
+ topK: topK(o),
1630
+ candidates,
1631
+ directed: c.s.directed,
1632
+ }),
1633
+ );
1634
+ },
1635
+ topAdamicAdarCandidatesForNode: (
1636
+ c: Ctx,
1637
+ o: Rooted & Omit<CandidateOptions, "directed" | "candidates"> & Candidates,
1638
+ ): PredictedLink[] => {
1639
+ const candidates = o.candidates === undefined ? undefined : indicesOf(c.cs, o.candidates);
1640
+ return links(
1641
+ c,
1642
+ getTopAdamicAdarCandidatesForNode(c.s, req(c, o.root, "root"), {
1643
+ ...c.rest,
1644
+ topK: topK(o),
1645
+ candidates,
1646
+ directed: c.s.directed,
1647
+ }),
1648
+ );
1649
+ },
1650
+ evaluateCommonNeighbors: (c: Ctx, o: EvaluateOptions): LinkPredictionMetrics =>
1651
+ evaluateCommonNeighbors(c.s, pairsOf(c, o.edges, "edges"), pairsOf(c, o.nonEdges, "nonEdges"), {
1652
+ directed: c.s.directed,
1653
+ }),
1654
+ evaluateAdamicAdar: (c: Ctx, o: EvaluateOptions): LinkPredictionMetrics =>
1655
+ evaluateAdamicAdar(c.s, pairsOf(c, o.edges, "edges"), pairsOf(c, o.nonEdges, "nonEdges"), {
1656
+ directed: c.s.directed,
1657
+ }),
1658
+ compareAdamicAdarWithCommonNeighbors: (
1659
+ c: Ctx,
1660
+ o: EvaluateOptions,
1661
+ ): { adamicAdar: LinkPredictionMetrics; commonNeighbors: LinkPredictionMetrics } => {
1662
+ // both halves with `directed` (@graphty/algorithms' compareAdamicAdarWithCommonNeighbors drops it for the
1663
+ // common-neighbors half), so each matches the method of its own name
1664
+ const edges = pairsOf(c, o.edges, "edges");
1665
+ const nonEdges = pairsOf(c, o.nonEdges, "nonEdges");
1666
+ const opts = { directed: c.s.directed };
1667
+ return {
1668
+ adamicAdar: evaluateAdamicAdar(c.s, edges, nonEdges, opts),
1669
+ commonNeighbors: evaluateCommonNeighbors(c.s, edges, nonEdges, opts),
1670
+ };
1671
+ },
1672
+ };
1673
+
1674
+ // ---------------------------------------------------------------------------------------------------------------
1675
+ // Registration
1676
+
1677
+ type Impls = typeof IMPLS;
1678
+ type Tail<T extends readonly unknown[]> = T extends readonly [unknown, ...infer R] ? R : never;
1679
+
1680
+ /** The methods `cytoscape.use(graphtyCytoscape)` adds to every collection and to the core. */
1681
+ export type GraphtyAlgorithms = {
1682
+ [K in keyof Impls as `graphty${Capitalize<K>}`]: (
1683
+ ...options: Tail<Parameters<Impls[K]>>
1684
+ ) => Awaited<ReturnType<Impls[K]>>;
1685
+ } & {
1686
+ [K in AsyncKey as `graphty${Capitalize<K>}Async`]: (
1687
+ ...options: Tail<Parameters<Impls[K]>>
1688
+ ) => Promise<Awaited<ReturnType<Impls[K]>> & { readonly backend: Backend }>;
1689
+ };
1690
+
1691
+ // Module augmentation is how a Cytoscape extension types its methods. The interfaces are empty because the members
1692
+ // come from the merged-in supertype, which is what the lint rule cannot see.
1693
+ declare module "cytoscape" {
1694
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging, see above
1695
+ interface CollectionAlgorithms extends GraphtyAlgorithms {}
1696
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -- declaration merging, see above
1697
+ interface Core extends GraphtyAlgorithms {}
1698
+ }
1699
+
1700
+ /**
1701
+ * The method name of an algorithm.
1702
+ * @param key - the @graphty/algorithms name
1703
+ * @returns "graphty" + the name with a capital
1704
+ */
1705
+ function methodName(key: string): string {
1706
+ return `graphty${key.charAt(0).toUpperCase()}${key.slice(1)}`;
1707
+ }
1708
+
1709
+ /** Every synchronous method name `cytoscape.use(graphtyCytoscape)` registers on collections and the core. */
1710
+ export const ALGORITHM_NAMES: readonly string[] = Object.keys(IMPLS).map(methodName);
1711
+
1712
+ // The algorithms whose implementation goes through `c.call`, i.e. those the dispatcher can send to the GPU.
1713
+ const ASYNC_KEYS = [
1714
+ "breadthFirstSearch",
1715
+ "dijkstra",
1716
+ "bellmanFord",
1717
+ "allPairsShortestPath",
1718
+ "pageRank",
1719
+ "personalizedPageRank",
1720
+ "eigenvectorCentrality",
1721
+ "katzCentrality",
1722
+ "hits",
1723
+ "closenessCentrality",
1724
+ "betweennessCentrality",
1725
+ "edgeBetweennessCentrality",
1726
+ "connectedComponents",
1727
+ "weaklyConnectedComponents",
1728
+ "triangleCount",
1729
+ "labelPropagation",
1730
+ "labelPropagationSynchronous",
1731
+ ] as const satisfies readonly (keyof Impls)[];
1732
+
1733
+ /** The algorithms with an `...Async` method: those the WebGPU accelerator can run. */
1734
+ type AsyncKey = (typeof ASYNC_KEYS)[number];
1735
+
1736
+ /** Every `...Async` method name: the algorithms the WebGPU accelerator can run. */
1737
+ export const ASYNC_ALGORITHM_NAMES: readonly string[] = ASYNC_KEYS.map((k) => `${methodName(k)}Async`);
1738
+
1739
+ /** The algorithm table a run calls through: the CPU functions, or the dispatcher. */
1740
+ type Algos = Record<GpuKey, (...args: never[]) => unknown>;
1741
+
1742
+ /**
1743
+ * Runs one algorithm over a collection.
1744
+ * @param eles - the collection
1745
+ * @param key - the algorithm
1746
+ * @param options - the caller's options
1747
+ * @param algos - the CPU functions (a synchronous run) or the dispatcher (an asynchronous one)
1748
+ * @returns the algorithm's result (a promise through the dispatcher) and the snapshot it ran on
1749
+ */
1750
+ function run(
1751
+ eles: Collection,
1752
+ key: keyof Impls,
1753
+ options: AlgorithmOptions,
1754
+ algos: Algos,
1755
+ ): { result: unknown; snapshot: GraphSnapshot } {
1756
+ const name = methodName(key);
1757
+ // a Cytoscape built-in takes some arguments positionally (kruskal(weightFn)); one ignored here would run unweighted
1758
+ if (typeof options !== "object" || options === null || Array.isArray(options)) {
1759
+ throw new TypeError(`${name}: takes one options object; pass a weight function as { weight: fn }`);
1760
+ }
1761
+ if (options.weight !== undefined && UNWEIGHTED.has(key)) {
1762
+ throw new Error(`${name}: this algorithm reads no edge weights; remove the weight option`);
1763
+ }
1764
+ if (options.field !== undefined && NO_FIELD.has(key)) {
1765
+ throw new Error(`${name}: this algorithm has no per-element value to write; remove the field option`);
1766
+ }
1767
+ const known = OPTION_NAMES[name] ?? [];
1768
+ for (const [k, v] of Object.entries(options)) {
1769
+ if (v !== undefined && !known.includes(k)) {
1770
+ throw new Error(`${name}: unknown option ${k}; the options are ${known.join(", ")}`);
1771
+ }
1772
+ }
1773
+ const directed = options.directed ?? DIRECTED.has(key);
1774
+ // degree and eigenvector centrality read mode only on a directed graph; ignored, it would give total degree
1775
+ const { mode } = options as { mode?: unknown };
1776
+ if ((mode === "in" || mode === "out") && !directed) {
1777
+ throw new Error(`${name}: mode: "${mode}" needs directed: true`);
1778
+ }
1779
+ const cs = toSnapshot(eles, { directed, weight: options.weight });
1780
+ const rest = {
1781
+ ...DEFAULTS[key],
1782
+ ...Object.fromEntries(Object.entries(options).filter(([k, v]) => !ADAPTER_KEYS.has(k) && v !== undefined)),
1783
+ };
1784
+ const c: Ctx = {
1785
+ name,
1786
+ cy: coreOf(eles),
1787
+ cs,
1788
+ s: cs.snapshot,
1789
+ field: options.field,
1790
+ weighted: options.weight !== undefined,
1791
+ rest,
1792
+ call: (k, ...args) => (algos[k] as (...a: unknown[]) => never)(...args),
1793
+ };
1794
+ const fail = (error: unknown): never => {
1795
+ throw named(error, key);
1796
+ };
1797
+ let result: unknown;
1798
+ try {
1799
+ result = (IMPLS[key] as (c: Ctx, o: AlgorithmOptions) => unknown)(c, options);
1800
+ } catch (error) {
1801
+ fail(error);
1802
+ }
1803
+ return { result: result instanceof Promise ? result.catch(fail) : result, snapshot: cs.snapshot };
1804
+ }
1805
+
1806
+ /**
1807
+ * An algorithm's error in a Cytoscape user's terms: it starts with the method name, and a graph of the wrong
1808
+ * direction is told which option to change (the @graphty/algorithms message names its own functions and snapshot
1809
+ * methods). The error object, its class and its `code` are kept.
1810
+ * @param error - what the algorithm threw
1811
+ * @param key - the algorithm
1812
+ * @returns the error to throw
1813
+ */
1814
+ function named(error: unknown, key: keyof Impls): unknown {
1815
+ if (!(error instanceof Error)) {
1816
+ return error;
1817
+ }
1818
+ const name = methodName(key);
1819
+ const { code } = error as { code?: unknown };
1820
+ if (code === "E_NEEDS_UNDIRECTED") {
1821
+ const instead = key === "connectedComponents" ? ", or call graphtyWeaklyConnectedComponents" : "";
1822
+ error.message = `${name}: needs an undirected graph; leave out directed: true${instead}`;
1823
+ } else if (code === "E_NEEDS_DIRECTED") {
1824
+ error.message = `${name}: needs a directed graph; ${DIRECTED.has(key) ? "leave out directed: false" : "pass directed: true"}`;
1825
+ } else if (!error.message.startsWith(`${name}:`)) {
1826
+ error.message = `${name}: ${error.message}`;
1827
+ }
1828
+ return error;
1829
+ }
1830
+
1831
+ /**
1832
+ * Runs an algorithm synchronously on the CPU.
1833
+ * @param eles - the collection
1834
+ * @param key - the algorithm
1835
+ * @param options - the caller's options
1836
+ * @returns the result
1837
+ */
1838
+ function runSync(eles: Collection, key: keyof Impls, options: AlgorithmOptions = {}): unknown {
1839
+ checkGpuMode(methodName(key), options?.gpu);
1840
+ if (options.gpu === "require") {
1841
+ const name = methodName(key);
1842
+ throw new Error(
1843
+ (ASYNC_KEYS as readonly string[]).includes(key)
1844
+ ? `${name}: runs on the CPU; call ${name}Async for the GPU`
1845
+ : `${name}: has no GPU implementation and runs only on the CPU; leave out gpu: "require"`,
1846
+ );
1847
+ }
1848
+ return run(eles, key, options, CPU).result;
1849
+ }
1850
+
1851
+ /**
1852
+ * Runs an algorithm through the @graphty/algorithms dispatcher, on the core's GPU when it has one, and reports
1853
+ * which implementation answered. A GPU failure after the run started is thrown, never finished on the CPU.
1854
+ * @param eles - the collection
1855
+ * @param key - the algorithm
1856
+ * @param options - the caller's options
1857
+ * @returns the result with `backend`
1858
+ */
1859
+ async function runAsync(eles: Collection, key: keyof Impls, options: AlgorithmOptions = {}): Promise<unknown> {
1860
+ checkGpuMode(`${methodName(key)}Async`, options?.gpu);
1861
+ const d = await gpuFor(coreOf(eles), options.gpu);
1862
+ const rec = d.gpu === null ? null : recording(d.gpu.accelerator);
1863
+ warnIfFixable(d, eles.nodes().length);
1864
+ const { result, snapshot } = run(eles, key, options, accelerated(rec?.accelerator ?? null));
1865
+ // a data change during the run (another run writing its field, say) must not release what this run reads
1866
+ const unhold = hold(snapshot);
1867
+ let value: object;
1868
+ try {
1869
+ value = (await result) as object;
1870
+ } finally {
1871
+ unhold();
1872
+ if (d.gpu !== null && typeof options.weight === "function") {
1873
+ // a weight function bypasses the snapshot cache, so nothing else would release this upload
1874
+ d.gpu.accelerator.release(snapshot);
1875
+ }
1876
+ }
1877
+ const backend = backendOf(
1878
+ d,
1879
+ rec?.used() ?? false,
1880
+ "the options or the graph need the CPU implementation (the @graphty/algorithms dispatcher does not say which)",
1881
+ );
1882
+ return Object.assign(value, { backend });
1883
+ }
1884
+
1885
+ /** The registration function Cytoscape's `use()` calls. */
1886
+ type Register = (type: string, name: string, registrant: unknown) => void;
1887
+
1888
+ /**
1889
+ * Registers every algorithm as a collection method and a core method named "graphty<Name>".
1890
+ * @param cytoscape - the cytoscape function
1891
+ */
1892
+ export function registerAlgorithms(cytoscape: Register): void {
1893
+ for (const key of Object.keys(IMPLS) as (keyof Impls)[]) {
1894
+ const name = methodName(key);
1895
+ cytoscape("collection", name, function (this: Collection, options?: AlgorithmOptions) {
1896
+ return runSync(this, key, options);
1897
+ });
1898
+ cytoscape("core", name, function (this: Core, options?: AlgorithmOptions) {
1899
+ return runSync(this.elements(), key, options);
1900
+ });
1901
+ }
1902
+ for (const key of ASYNC_KEYS) {
1903
+ const name = `${methodName(key)}Async`;
1904
+ cytoscape("collection", name, function (this: Collection, options?: AlgorithmOptions) {
1905
+ return runAsync(this, key, options);
1906
+ });
1907
+ cytoscape("core", name, function (this: Core, options?: AlgorithmOptions) {
1908
+ return runAsync(this.elements(), key, options);
1909
+ });
1910
+ }
1911
+ }