• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-monorepo / 37107633490

03 Oct 2026 07:34AM UTC coverage: 92.371% (-0.01%) from 92.385%
37107633490

push

github

web-flow
Merge pull request #725 from graphty-org/feat/sonarqube-prepush

build(workspace): run SonarQube as a local pre-push gate that blocks only new problems

45310 of 50781 branches covered (89.23%)

Branch coverage included in aggregate %.

72422 of 76674 relevant lines covered (94.45%)

332813.84 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.71
/graphty-element/src/algorithms/Algorithm.ts
1
import { accelerated, type AcceleratedAlgorithms } from "@graphty/algorithms";
2
import { type GraphSnapshot, INVALID_INDEX, type U32 } from "@graphty/graph-format";
3

4
import { forwardsAlgorithm, narrowAlgorithms } from "../acceleration/narrow";
5
import { type AccelerationPrecision, CPU_PRECISION } from "../acceleration/types";
6
import { type RegisterOptions, SharedImplementationMap } from "../catalog/pluginRegistry";
7
import { publishAlgorithmDescriptor } from "../catalog/registry";
8
import type { AlgorithmDescriptor, FieldDescriptor, NodeId, SuggestedName } from "../catalog/types";
9
import { type OptionsSchema as ZodOptionsSchema } from "../config";
10
import { GraphtyError } from "../errors";
11
import { Graph } from "../Graph";
12
import type { RunResult } from "../session/results";
13
import { type InputColumns, sessionColumns } from "./input/columns";
14
import type { InputOrientation, SimplifyPolicy } from "./input/derivedInputs";
15
import {
16
    type AlgorithmGraphMode,
17
    createScopedInput,
18
    type ElementScopedInput,
19
    orientationOf,
20
    runInputOf,
21
    type ScopedInputOptions,
22
    type ScopeInputDeclaration,
23
} from "./input/ScopedInput";
24
import type { RunControls } from "./results/types";
25
import { type OptionsFromSchema, type OptionsSchema, resolveOptions } from "./types/OptionSchema";
26
import { type Graph as LegacyGraph, legacyGraphOf } from "./utils/legacyGraph";
27

28
/**
29
 * Type for algorithm class constructor
30
 * Uses any for options to allow flexibility with different algorithm option types
31
 */
32
// eslint-disable-next-line @typescript-eslint/no-explicit-any
33
type AlgorithmClass = new (g: Graph, options?: any) => Algorithm;
34

35
/**
36
 * Interface for Algorithm class static members
37
 * Exported for use in type annotations when referencing algorithm classes
38
 */
39
export interface AlgorithmStatics {
40
    type: string;
41
    namespace: string;
42
    /**
43
     * What the catalogue publishes about this algorithm, for a plugin that wants to be one.
44
     *
45
     * DECLARING IT IS WHAT MAKES A PLUGIN A FIRST-CLASS ALGORITHM. Without it a class can be
46
     * registered and called, and that is all: the run machinery resolves a key through the
47
     * catalogue, so a class the catalogue does not carry cannot be started as a run, and
48
     * everything hanging off a run is out of reach -- progress, cancellation, a cost estimate
49
     * before the click, a ranking, a histogram, a summary, a plain-language reading, and the
50
     * styling the element derives from a result's shape.
51
     *
52
     * The `shape` is the load-bearing field: field NAMES are fixed by the shape rather than by
53
     * the algorithm, which is what lets any consumer read `results.<runId>.value` without
54
     * opening the catalogue first. `checkShapeContract` will tell you whether your fields match
55
     * the shape you declared.
56
     *
57
     * Absent, the class stays exactly as capable as it was: registered, callable through the
58
     * 1.10 address, and invisible to the catalogue.
59
     */
60
    descriptor?: AlgorithmDescriptor;
61
    /**
62
     * A cost model in seconds over a graph of n nodes and m edges.
63
     *
64
     * HERE RATHER THAN ON THE DESCRIPTOR, because a function is not plain JSON and the composed
65
     * catalogue has to survive `JSON.stringify` and a `postMessage` to a worker. The registry
66
     * keeps the model beside the class reference, and the estimator reads it from there, so a
67
     * plugin supplies real arithmetic for "what would this cost before I click" without a
68
     * descriptor ever carrying something unserialisable.
69
     *
70
     * Absent, the element estimates from the `costClass` the descriptor declares, which is what
71
     * every algorithm this package ships does.
72
     *
73
     * Prefer {@link costUnits}: seconds written on one machine are wrong on every other, and no
74
     * calibration can scale them, so an estimate from this hook always reports "modelled".
75
     */
76
    cost?: (n: number, m: number) => number;
77
    /**
78
     * A cost model in WORK UNITS over a graph of n nodes and m edges, for the whole run.
79
     *
80
     * The units are those of the descriptor's `costClass`: elements for `instant` and
81
     * `iterative` (count every iteration), source-edge pairs for `heavy`, operations for
82
     * `cubic`. The element divides them by the rate it measured for that class on this device,
83
     * so the estimate follows the machine and reports "calibrated" once the device is probed,
84
     * exactly as a built-in's does. Wins over {@link cost} when both are declared.
85
     *
86
     * `options` are the values the run would use -- the caller's, with the declared defaults
87
     * filled in -- so an option that multiplies the work (a number of passes) is priced.
88
     */
89
    costUnits?: (n: number, m: number, options: Readonly<Record<string, unknown>>) => number;
90
    /**
91
     * The plugin's own version, recorded on every run this algorithm produces.
92
     *
93
     * A saved run records the versions of the code that produced its numbers. Without this a run
94
     * of a third party's algorithm recorded the element's version and the two sibling packages'
95
     * and nothing at all identifying the code that actually did the work.
96
     */
97
    version?: string;
98
    /**
99
     * The name a run's result is given, from the run's option values (the declared defaults
100
     * filled in): `{ id, label }`, where `id` becomes the result path `results.<id>.value` and
101
     * `label` is what the layer list and the legend show. Name only the settings worth telling
102
     * two results apart by, and return undefined (or leave this out) to name the result after
103
     * the algorithm key.
104
     *
105
     * Two runs with the same name and the same scope are one run: a change to a setting the name
106
     * does not carry re-runs it in place. The element adds `_2`, `_3`, ... when the name is taken
107
     * by a different computation, and a name the caller passes with `as` always wins.
108
     */
109
    suggestedName?: (options: Readonly<Record<string, unknown>>) => SuggestedName | undefined;
110
    /**
111
     * What a run over a scope computes on. See {@link Algorithm.scopeInput}; `register` publishes
112
     * it as the descriptor's `scopeInput`.
113
     */
114
    scopeInput?: ScopeInputDeclaration;
115
    optionsSchema: OptionsSchema;
116
    /** @deprecated Use getZodOptionsSchema() instead */
117
    getOptionsSchema(): OptionsSchema;
118
    /** @deprecated Use hasZodOptions() instead */
119
    hasOptions(): boolean;
120
    /** NEW: Zod-based options schema for unified validation and UI metadata */
121
    zodOptionsSchema?: ZodOptionsSchema;
122
    /** Get the Zod-based options schema for this algorithm */
123
    getZodOptionsSchema(): ZodOptionsSchema;
124
    /** Check if this algorithm has a Zod-based options schema */
125
    hasZodOptions(): boolean;
126
}
127

128
// Shared with every other copy of graphty-element on the page, so a plugin registered through one
129
// reaches them all.
130
const algorithmRegistry = new SharedImplementationMap<AlgorithmClass>("algorithm");
509 ✔
131

132
/** Each authored descriptor's published copies, one per declaration, so a re-registration hands the registry the same object. */
133
const publishedDescriptors = new WeakMap<AlgorithmDescriptor, Map<ScopeInputDeclaration, AlgorithmDescriptor>>();
509 ✔
134

135
/**
136
 * The descriptor the catalogue publishes: the authored one with `scopeInput` taken from the class.
137
 * @param authored - What the plugin wrote.
138
 * @param declared - The class's `static scopeInput`, `"none"` when absent.
139
 * @param address - The class's registry address, for the refusal.
140
 * @returns The published descriptor.
141
 * @throws A `GraphtyError` with `E_BAD_COMMAND` when the authored descriptor states a different
142
 *   `scopeInput` from the class: the run would compute over one thing and the planner price another.
143
 */
144
function withScopeInput(
145
    authored: AlgorithmDescriptor,
146
    declared: ScopeInputDeclaration,
147
    address: string,
148
): AlgorithmDescriptor {
149
    if (authored.scopeInput !== undefined && authored.scopeInput !== declared) {
52 ✔
150
        throw new GraphtyError({
1 ✔
151
            code: "E_BAD_COMMAND",
152
            message:
153
                `the algorithm registered as "${address}" publishes scopeInput "${authored.scopeInput}" and its class declares ` +
154
                `"${declared}". Declare it once, as "static scopeInput", and leave it out of the descriptor.`,
155
            source: "registry",
156
            details: {
157
                kind: "algorithm",
158
                field: "descriptor.scopeInput",
159
                key: authored.key,
160
                declared,
161
                published: authored.scopeInput,
162
            },
163
        });
164
    }
165

166
    let copies = publishedDescriptors.get(authored);
51 ✔
167
    if (copies === undefined) {
51 ✔
168
        copies = new Map();
48 ✔
169
        publishedDescriptors.set(authored, copies);
48 ✔
170
    }
171

172
    let published = copies.get(declared);
51 ✔
173
    if (published === undefined) {
51 ✔
174
        published = { ...authored, scopeInput: declared };
48 ✔
175
        copies.set(declared, published);
48 ✔
176
    }
177

178
    return published;
51 ✔
179
}
180

181
/**
182
 * One piece of accelerable work, with the decision "accelerator or CPU" already taken.
183
 *
184
 * An adapter reads the snapshot it is over, runs the work through {@link run}, and writes ONE
185
 * loop over an index-aligned result whichever path produced it. The precision that comes back is
186
 * what the run publishes as `caveats.precision`: a result computed on an accelerator says `f32`
187
 * and one computed on the CPU port says `f64`, and a reader comparing two numbers has the
188
 * qualification that explains the difference.
189
 *
190
 * Exported only because it is the return type of a protected member, which declaration emit
191
 * requires to be nameable. An adapter receives one from `Algorithm.accelerated`; nothing outside
192
 * this module constructs one or needs to name it.
193
 * @internal
194
 */
195
export interface AcceleratedAlgorithmRun {
196
    /**
197
     * The snapshot the work runs over: the declared one for `"directed"`, the undirected view for
198
     * `"undirected"`, in either case with every group of parallel edges collapsed to one edge
199
     * carrying the group's summed weight. Its `ids` map is how a node id becomes the index every
200
     * result is keyed by: the declared node space for the whole graph, the scope's compact one
201
     * for an algorithm that declares a scoped input.
202
     */
203
    readonly snapshot: GraphSnapshot;
204
    /**
205
     * Declared edge index -> edge index in {@link snapshot}, or null when the edge space is the
206
     * declared one.
207
     *
208
     * This is the direction an edge-carrying result is read in: an adapter walks the element's own
209
     * edges, maps each one's `Edge.index` through this, and asks whether that index is in the
210
     * result. Read the other way (`edgeOrigin`) a merged group names only its survivor, so every
211
     * edge the reader declared but one would silently go unflagged -- both halves of a reciprocal
212
     * pair the undirected view collapsed, and every member of a group of parallel edges.
213
     */
214
    readonly edgeRemap: U32 | null;
215
    /**
216
     * Runs the work, on the accelerator when the controller said so and on the CPU port when it
217
     * did not.
218
     *
219
     * A property rather than a method, so an adapter may take it out of the object it came in --
220
     * `const { run } = this.accelerated(...)` -- which is how every one of them reads.
221
     * @param fn - The work, written once against the dispatcher.
222
     * @returns What the work produced, and the arithmetic it was produced in.
223
     * @throws Whatever the accelerator threw, with its code. A failure after the work started is
224
     * the run's failure: nothing is recomputed on the CPU.
225
     */
226
    readonly run: <T>(fn: (dispatch: AcceleratedAlgorithms, snapshot: GraphSnapshot) => Promise<T>) => Promise<{
227
        /** What `fn` returned. */
228
        readonly value: T;
229
        /** The arithmetic it was computed in. */
230
        readonly precision: AccelerationPrecision;
231
    }>;
232
}
233

234
// algorithmResults layout:
235
// {
236
//     node: {
237
//         id: {
238
//             namespace: {
239
//                 algorithm: {
240
//                     result: unknown
241
//                 }
242
//             }
243
//         }
244
//     },
245
//     edge: {
246
//         id: {
247
//             namespace: {
248
//                 algorithm: {
249
//                     result: unknown
250
//                 }
251
//             }
252
//         }
253
//     },
254
//     graph: {
255
//         namespace: {
256
//             algorithm: {
257
//                 result: unknown
258
//             }
259
//         }
260
//     }
261
// }
262

263
/**
264
 * Base class for all graph algorithms
265
 * @template TOptions - The options type for this algorithm (defaults to empty object)
266
 * @example
267
 * ```typescript
268
 * // Algorithm with options
269
 * interface PageRankOptions {
270
 *     dampingFactor: number;
271
 *     maxIterations: number;
272
 * }
273
 *
274
 * class PageRankAlgorithm extends Algorithm<PageRankOptions> {
275
 *     static optionsSchema: OptionsSchema = {
276
 *         dampingFactor: { type: 'number', default: 0.85, ... },
277
 *         maxIterations: { type: 'integer', default: 100, ... }
278
 *     };
279
 *
280
 *     async run(): Promise<void> {
281
 *         const { dampingFactor, maxIterations } = this.options;
282
 *         // ... use options
283
 *     }
284
 * }
285
 * ```
286
 */
287
export abstract class Algorithm<TOptions extends Record<string, unknown> = Record<string, unknown>> {
288
    static type: string;
289
    static namespace: string;
290

291
    /**
292
     * Options schema for this algorithm
293
     *
294
     * Subclasses should override this to define their configurable options.
295
     * An empty schema means the algorithm has no configurable options.
296
     * @deprecated Use zodOptionsSchema instead for new implementations
297
     */
298
    static optionsSchema: OptionsSchema = {};
509 ✔
299

300
    /**
301
     * NEW: Zod-based options schema with rich metadata for UI generation.
302
     *
303
     * Override in subclasses to define algorithm-specific options.
304
     * This is the new unified system that provides both validation and UI metadata.
305
     */
306
    static zodOptionsSchema?: ZodOptionsSchema;
307

308
    /**
309
     * Whether this algorithm computes over its run's scope. `"subgraph"`: the run context's
310
     * `input` hands the algorithm its scope (the compact snapshot, and the scope's masks over the
311
     * full graph), and the planner estimates the run over the scope. Absent or `"none"`: the whole
312
     * graph, of whose values the element keeps the scope's, with a caveat saying so; the planner
313
     * estimates and refuses the run as a whole-graph one.
314
     *
315
     * THE ONE DECLARATION: the input, the caveat and the published `descriptor.scopeInput` all
316
     * read it. Declare it only once every node list, edge read and count the class takes comes
317
     * from the input, because an algorithm that lists its nodes some other way would compute over
318
     * a scoped topology while reporting every node.
319
     */
320
    static scopeInput?: ScopeInputDeclaration;
321

322
    /**
323
     * How this class's input merges a group of parallel edges into one: `"sum"` when absent, the
324
     * element's reading of a repeated edge as more connection. A shortest path wants `"min"`, the
325
     * cheapest of the group. Both seams read it, and the run's caveat names it.
326
     * @internal
327
     */
328
    static parallelEdges?: SimplifyPolicy;
329

330
    protected graph: Graph;
331

332
    /**
333
     * Resolved options for this algorithm instance
334
     *
335
     * Options are resolved at construction time by:
336
     * 1. Starting with schema defaults
337
     * 2. Overriding with any provided options
338
     * 3. Validating all values against the schema
339
     *
340
     * Note: Named with underscore prefix to avoid conflicts with
341
     * existing algorithm implementations that have their own
342
     * options properties (will be removed in future refactoring).
343
     */
344
    protected _schemaOptions: TOptions;
345

346
    /**
347
     * Getter for schema options
348
     *
349
     * Algorithms that use the new schema-based options should access
350
     * options via this getter.
351
     * @returns The resolved schema options
352
     */
353
    protected get schemaOptions(): TOptions {
354
        return this._schemaOptions;
503 ✔
355
    }
356

357
    /**
358
     * Creates a new algorithm instance
359
     * @param g - The graph to run the algorithm on
360
     * @param options - Optional configuration options (uses schema defaults if not provided)
361
     */
362
    constructor(g: Graph, options?: Partial<TOptions>) {
363
        this.graph = g;
3,545 ✔
364
        this._schemaOptions = this.resolveOptions(options);
3,545 ✔
365
    }
366

367
    /**
368
     * The graph this run reads, as the object graph `@graphty/algorithms` 2.x worked on: the run's
369
     * input with parallel edges merged (weights summed), nodes in snapshot order.
370
     * @param mode - `"undirected"` merges a reciprocal pair into one edge; `"directed"` keeps
371
     *   each edge in its declared orientation.
372
     * @returns a freshly built graph
373
     * @deprecated Removed in graphty-element 4.0. Read `context.input("undirected").subgraph()`
374
     *   (or `"declared"` for `"directed"`) in `compute()`; see the custom-algorithms guide,
375
     *   "Moving from algorithmGraph()".
376
     */
377
    protected algorithmGraph(mode: AlgorithmGraphMode): LegacyGraph {
378
        return legacyGraphOf(this.input(orientationOf(mode)).subgraph(), mode);
5 ✔
379
    }
380

381
    /**
382
     * The graph this run computes over, in one orientation: its scope's compact snapshot when the
383
     * class declares {@link Algorithm.scopeInput} and runs as a run, else the whole graph. Both
384
     * seams below read through it.
385
     * @param orientation - `"declared"` or `"undirected"`.
386
     * @param options - How parallel edges merge; the class's {@link Algorithm.parallelEdges} by
387
     *   default, else `"sum"`.
388
     * @returns The input.
389
     * @internal
390
     */
391
    protected input(orientation: InputOrientation, options?: ScopedInputOptions): ElementScopedInput {
392
        const simplify = options?.simplify ?? (this.constructor as typeof Algorithm).parallelEdges;
8,568 ✔
393
        const merged = simplify === undefined ? options : { ...options, simplify };
8,568 ✔
394

395
        return createScopedInput(this.graph.getDataManager(), orientation, merged, runInputOf(this), this.columns());
8,568 ✔
396
    }
397

398
    /**
399
     * Where this algorithm's input reads the columns behind its declared "attribute" and
400
     * "partition" options, and the weight it asks for: the records and published results of the
401
     * session it runs in. Read on first use, so an input that never asks for a column never
402
     * touches the session.
403
     * @returns The reader.
404
     */
405
    private columns(): InputColumns {
406
        const statics = this.constructor as { descriptor?: AlgorithmDescriptor; type?: string };
8,568 ✔
407
        const reader = (): InputColumns =>
989 ✔
408
            sessionColumns(
12 ✔
409
                this.graph.getSession(),
410
                statics.descriptor?.options ?? [],
12 !
411
                this._schemaOptions,
412
                statics.descriptor?.key ?? statics.type ?? "",
12 !
413
            );
414

415
        return {
8,568 ✔
416
            option: (name) => reader().option(name),
6 ✔
417
            read: (graph, path, on, edgeIdAt) => reader().read(graph, path, on, edgeIdAt),
6 ✔
418
        };
419
    }
420

421
    /**
422
     * The route an algorithm with an accelerated implementation takes.
423
     *
424
     * The route for the algorithms `@graphty/algorithms` can dispatch: the work runs over the
425
     * snapshot itself, on the attached accelerator or on the index-based CPU port, and the adapter
426
     * writes one loop over an index-aligned result either way.
427
     *
428
     * THE DECISION IS TAKEN ONCE, HERE, BEFORE ANY WORK STARTS. The controller answers "the policy
429
     * is off", "no accelerator", "below `acceleration.minNodes`" or "this accelerator does not
430
     * implement that" up front, and under `acceleration="required"` it throws `E_NO_ACCELERATOR`
431
     * rather than answering quietly. A capability the element does not forward (edge betweenness,
432
     * k-core and Louvain today) never asks the controller, so it runs on the CPU and
433
     * says `f64` even under `"required"`. A call the dispatcher itself keeps on the CPU port (an
434
     * option or a graph shape the device's kernel is not defined for, such as a Katz `alpha` whose
435
     * series may diverge) runs there and says `f64` under `"auto"`; under `"required"` it throws
436
     * `E_NO_ACCELERATOR`, because the run was promised the device. After the work has started there is no second decision: a
437
     * failure from the accelerator propagates with its code and fails the run, because a number
438
     * that silently came from somewhere else is worse than no number.
439
     * @param capability - The accelerator member this work would use, such as `"pageRank"`.
440
     * @param mode - The shape this algorithm needs; see {@link AlgorithmGraphMode}. `"undirected"`
441
     *   takes the snapshot's undirected view, which is what collapses a reciprocal pair into one
442
     *   edge.
443
     * @param options - What the decision needs to know about this run.
444
     * @param options.accelerable - False when the options of this run are ones no accelerator
445
     *   answers, such as a walk that stops at a target, so the decision is the CPU port's (and
446
     *   `E_NO_ACCELERATOR` under `acceleration="required"`). A capability the element does not
447
     *   forward to an accelerator is never accelerable, whatever this says.
448
     * @param options.sources - For a run that searches from a set of sources, how many: `k` for a
449
     *   sampled run, the node count for an exact one. A count past the node count is read as the
450
     *   node count. The decision then also weighs sources times edges against the capability's
451
     *   measured floor, because that and not the node count is what the CPU run costs.
452
     * @param options.over - A graph the adapter built itself for the work to run over, such as a
453
     *   flow network. It stands in for the derived snapshot, which is then never built, and its
454
     *   edge space is its own (`edgeRemap` is null).
455
     * @returns The snapshot, the edge map onto it, and the runner.
456
     * @example
457
     * ```ts
458
     * const { snapshot, run } = this.accelerated("connectedComponents", "undirected");
459
     * const { value, precision } = await run((dispatch, s) => dispatch.connectedComponents(s));
460
     * const group = value.labels[snapshot.ids.indexOf(nodeId)];
461
     * ```
462
     */
463
    protected accelerated(
464
        capability: string,
465
        mode: AlgorithmGraphMode,
466
        options?: { accelerable?: boolean; sources?: number; over?: GraphSnapshot },
467
    ): AcceleratedAlgorithmRun {
468
        /* The input accessor derives the snapshot: the declared one or the store's cached
469
           undirected view, over the run's scope when the class declares one. It leaves the NODE
470
           space of the whole graph alone -- so a node result indexes the declared snapshot's nodes
471
           directly -- and only an EDGE result needs the map.
472

473
           THE ELEMENT SIMPLIFIES BEFORE IT DISPATCHES: a group of parallel edges becomes ONE edge carrying the
474
           group's summed weight, because a repeated edge between two nodes is MORE connection
475
           rather than the same connection -- the reading a weighted layout gives the same data.
476
           A class that reads a repeat differently says so in `parallelEdges`: a shortest path
477
           takes the cheapest edge of the group.
478
           Two things depend on it. The run agrees with the caveat `AlgorithmManager` appends over
479
           a multigraph ("N parallel edges were merged, with weights summed"), and EVERY member of
480
           a merged group carries the merged value, because they all map to the survivor through
481
           the remap. Without it a spanning tree or a route would flag one of two coincident
482
           edges and leave its twin unpainted, which reads as a rendering glitch. */
483
        const { snapshot, edgeRemap } =
484
            options?.over === undefined
2,953 ✔
485
                ? this.input(orientationOf(mode)).derived()
486
                : { snapshot: options.over, edgeRemap: null };
487
        const controller = this.graph.acceleration;
2,953 ✔
488
        const work = {
2,953 ✔
489
            capability,
490
            nodeCount: snapshot.nodeCount,
491
            edgeCount: snapshot.edgeCount,
492
            forwarded: (options?.accelerable ?? true) && forwardsAlgorithm(capability),
7,898 ✔
493
            // A graph whose edges all weigh the same (one with no weight attribute reads 1 everywhere)
494
            // is unweighted as far as the floors are concerned.
495
            weighted: snapshot.weights?.some((weight, _, all) => weight !== all[0]) ?? false,
3,319,767 ✔
496
            ...(options?.sources === undefined
2,953 ✔
497
                ? {}
498
                : { sourceEdges: Math.min(options.sources, snapshot.nodeCount) * snapshot.edgeCount }),
499
        };
500

501
        return {
2,953 ✔
502
            snapshot,
503
            edgeRemap,
504
            run: async <T>(
505
                fn: (dispatch: AcceleratedAlgorithms, s: GraphSnapshot) => Promise<T>,
506
            ): Promise<{ value: T; precision: AccelerationPrecision }> => {
507
                // A capability the element does not route to the device is not the controller's
508
                // question: asking would label a CPU answer with the device's precision, and under
509
                // "required" refuse work the element never meant to send there.
510
                if (forwardsAlgorithm(capability)) {
2,929 ✔
511
                    // The dispatcher may still answer on the CPU port with an accelerator attached --
512
                    // eigenvector centrality over a graph whose iteration the device kernel cannot
513
                    // match, say -- so the precision follows whether a member was actually reached.
514
                    let reached = false;
2,282 ✔
515
                    const outcome = await controller.run(work, (accelerator) =>
2,282 ✔
516
                        fn(
55 ✔
517
                            accelerated(
518
                                narrowAlgorithms(accelerator, () => {
519
                                    reached = true;
52 ✔
520
                                }),
521
                            ),
522
                            snapshot,
523
                        ),
524
                    );
525

526
                    if (outcome.accelerated) {
2,268 ✔
527
                        if (reached) {
50 ✔
528
                            return { value: outcome.value, precision: outcome.precision };
47 ✔
529
                        }
530

531
                        // Under "required" an answer the device did not compute is the absence that
532
                        // policy exists to make loud, however it came about.
533
                        if (controller.policy === "required") {
3 ✔
534
                            throw new GraphtyError({
1 ✔
535
                                code: "E_NO_ACCELERATOR",
536
                                message:
537
                                    `acceleration is required, but the accelerator does not answer this ` +
538
                                    `"${capability}" run as asked, so it ran on the CPU`,
539
                                source: "acceleration",
540
                                details: { policy: "required", capability, nodeCount: snapshot.nodeCount },
541
                            });
542
                        }
543

544
                        return { value: outcome.value, precision: CPU_PRECISION };
2 ✔
545
                    }
546
                }
547

548
                // The CPU port, through the SAME dispatcher: one call site, one result shape, one
549
                // loop in the adapter above.
550
                return { value: await fn(accelerated(null), snapshot), precision: CPU_PRECISION };
2,865 ✔
551
            },
552
        };
553
    }
554

555
    /**
556
     * The dense row of a node the reader named in an option.
557
     *
558
     * A search takes its source as an id and the snapshot answers in indices, so this is where the
559
     * two meet -- and where an id that names no node in the graph is reported as what it is: an
560
     * option whose value is outside the permitted range, carrying the option's name and what was
561
     * passed, rather than a silent empty result or a search from row zero.
562
     * @param snapshot - The graph the work runs over.
563
     * @param option - The option the id came from, named in the error.
564
     * @param id - The node id the reader gave.
565
     * @returns The node's dense row.
566
     * @throws A `GraphtyError` with `E_OPTION_RANGE` when the graph has no such node.
567
     */
568
    protected nodeIndex(snapshot: GraphSnapshot, option: string, id: NodeId): number {
569
        const index = snapshot.ids.indexOf(id);
1,526 ✔
570

571
        if (index === INVALID_INDEX) {
1,526 ✔
572
            throw new GraphtyError({
7 ✔
573
                code: "E_OPTION_RANGE",
574
                message: `the graph has no node "${String(id)}", so "${option}" names nothing to run from`,
575
                source: "run",
576
                details: { option, value: id },
577
            });
578
        }
579

580
        return index;
1,519 ✔
581
    }
582

583
    /**
584
     * Resolves and validates options against the schema
585
     * @param options - User-provided options (partial)
586
     * @returns Fully resolved options with defaults applied
587
     */
588
    protected resolveOptions(options?: Partial<TOptions>): TOptions {
589
        // eslint-disable-next-line @typescript-eslint/no-deprecated -- Supporting backward compatibility
590
        const schema = (this.constructor as typeof Algorithm).optionsSchema;
3,474 ✔
591

592
        // If no schema defined, return empty object (backward compatible)
593
        if (Object.keys(schema).length === 0) {
3,474 ✔
594
            return {} as TOptions;
1,253 ✔
595
        }
596

597
        return resolveOptions(schema, options as Partial<OptionsFromSchema<typeof schema>>) as TOptions;
2,221 ✔
598
    }
599

600
    /**
601
     * Gets the algorithm type
602
     * @returns The algorithm type identifier
603
     */
604
    get type(): string {
605
        return (this.constructor as typeof Algorithm).type;
332 ✔
606
    }
607

608
    /**
609
     * Gets the algorithm namespace
610
     * @returns The algorithm namespace identifier
611
     */
612
    get namespace(): string {
613
        return (this.constructor as typeof Algorithm).namespace;
1 ✔
614
    }
615

616
    abstract run(g: Graph): Promise<void>;
617

618
    /**
619
     * Compute this algorithm and publish what it produced as a result object.
620
     *
621
     * This is the entry point the run machinery calls, and it is the one that makes an algorithm
622
     * startable as a `Run`: it takes a signal it must throw from, a progress channel, a yield, and
623
     * the run id the result is published under -- and it RETURNS the result rather than writing it
624
     * somewhere a caller has to go looking for. `run()` is the 1.10 entry point beside it, which
625
     * returns nothing and can be neither watched nor stopped.
626
     *
627
     * The default refuses, because an algorithm that has not been migrated genuinely cannot answer
628
     * a run: it publishes through side effects under its own names and has no result object to
629
     * hand back. Both shipped families -- a metric and a declared algorithm -- override it.
630
     * @param _context - A signal, a progress channel and a yield.
631
     * @param runId - The id the result is published under.
632
     * @param _fields - The catalogue's descriptors for this algorithm's fields, when the caller
633
     *   holds them.
634
     * @returns The result, or undefined when there was nothing to compute.
635
     * @throws A `GraphtyError` with code `E_UNSUPPORTED` when this algorithm has no result to
636
     *   publish.
637
     */
638
    publishResult(
639
        _context: RunControls,
640
        runId: string,
641
        _fields?: readonly FieldDescriptor[],
642
    ): Promise<RunResult | undefined> {
643
        return Promise.reject(
×
644
            new GraphtyError({
645
                code: "E_UNSUPPORTED",
646
                message: `The "${this.namespace}:${this.type}" algorithm writes its result through side effects and cannot be started as a run.`,
647
                source: "run",
648
                target: { kind: "run", id: runId },
649
                details: { algorithm: `${this.namespace}:${this.type}` },
650
            }),
651
        );
652
    }
653

654
    /**
655
     * Registers an algorithm class in the global registry
656
     * @param cls - The algorithm class to register
657
     * @param options - Whether a different class under a key already taken throws instead of
658
     *   replacing it.
659
     * @returns The registered algorithm class
660
     */
661
    static register<T extends AlgorithmClass>(cls: T, options?: RegisterOptions): T {
662
        const statics = cls as unknown as Partial<AlgorithmStatics>;
18,623 ✔
663
        const t = String(statics.type);
18,623 ✔
664
        const ns = String(statics.namespace);
18,623 ✔
665

666
        /* THE CATALOGUE IS PUBLISHED BEFORE THE CLASS IS FILED, and the order is the whole
667
           point. Filing first meant a registration the catalogue refused -- a descriptor whose
668
           key disagrees with `static type`, a key a built-in already holds -- still left a
669
           runnable class behind under the address the refusal was about, so `graph.runAlgorithm`
670
           reached an algorithm that no catalogue listed and no consumer could have chosen.
671
           Published here rather than by the plugin author, so a descriptor and the class it
672
           describes cannot be registered separately: a catalogue entry whose class nothing
673
           registered is an algorithm a consumer can see, start, and then be told does not
674
           exist. */
675
        const { descriptor, cost, costUnits, version, suggestedName } = statics;
18,623 ✔
676

677
        if (descriptor !== undefined) {
18,623 ✔
678
            publishAlgorithmDescriptor(
52 ✔
679
                {
680
                    descriptor: withScopeInput(descriptor, statics.scopeInput ?? "none", `${ns}:${t}`),
103 ✔
681
                    namespace: ns,
682
                    type: t,
683
                    ...(cost === undefined ? {} : { cost }),
51 !
684
                    ...(costUnits === undefined ? {} : { costUnits }),
51 ✔
685
                    ...(version === undefined ? {} : { version }),
51 ✔
686
                    ...(suggestedName === undefined ? {} : { suggestedName }),
51 ✔
687
                },
688
                options,
689
            );
690
        }
691

692
        algorithmRegistry.set(`${ns}:${t}`, cls);
18,619 ✔
693

694
        return cls;
18,619 ✔
695
    }
696

697
    /**
698
     * Gets an algorithm instance from the registry
699
     * @param g - The graph to run the algorithm on
700
     * @param namespace - The algorithm namespace
701
     * @param type - The algorithm type
702
     * @param options - Optional algorithm-specific options to pass to constructor
703
     * @returns A new instance of the algorithm, or null if not found
704
     */
705
    static get(g: Graph, namespace: string, type: string, options?: Record<string, unknown>): Algorithm | null {
706
        const SourceClass = algorithmRegistry.get(`${namespace}:${type}`);
567 ✔
707
        if (SourceClass) {
567 ✔
708
            return new SourceClass(g, options);
565 ✔
709
        }
710

711
        return null;
2 ✔
712
    }
713

714
    /**
715
     * Gets an algorithm class from the registry
716
     * @param namespace - The algorithm namespace
717
     * @param type - The algorithm type
718
     * @returns The algorithm class, or null if not found
719
     */
720
    static getClass(namespace: string, type: string): (AlgorithmClass & AlgorithmStatics) | null {
721
        return (algorithmRegistry.get(`${namespace}:${type}`) as (AlgorithmClass & AlgorithmStatics) | null) ?? null;
82 ✔
722
    }
723

724
    /**
725
     * Get the options schema for this algorithm
726
     * @returns The options schema, or an empty object if no options defined
727
     * @deprecated Use getZodOptionsSchema() instead
728
     */
729
    static getOptionsSchema(): OptionsSchema {
730
        // eslint-disable-next-line @typescript-eslint/no-deprecated -- Implementation of deprecated method
731
        return this.optionsSchema;
50 ✔
732
    }
733

734
    /**
735
     * Check if this algorithm has configurable options
736
     * @returns true if the algorithm has at least one option defined
737
     * @deprecated Use hasZodOptions() instead
738
     */
739
    static hasOptions(): boolean {
740
        // eslint-disable-next-line @typescript-eslint/no-deprecated -- Implementation of deprecated method
741
        return Object.keys(this.optionsSchema).length > 0;
25 ✔
742
    }
743

744
    /**
745
     * Get the Zod-based options schema for this algorithm.
746
     * @returns The Zod options schema, or an empty object if no schema defined
747
     */
748
    static getZodOptionsSchema(): ZodOptionsSchema {
749
        return this.zodOptionsSchema ?? {};
11,381 ✔
750
    }
751

752
    /**
753
     * Check if this algorithm has a Zod-based options schema.
754
     * @returns true if the algorithm has a Zod options schema defined
755
     */
756
    static hasZodOptions(): boolean {
757
        return this.zodOptionsSchema !== undefined && Object.keys(this.zodOptionsSchema).length > 0;
252 ✔
758
    }
759

760
    /**
761
     * Get all registered algorithm names.
762
     * @param namespace - Optional namespace to filter by
763
     * @returns Array of algorithm names in "namespace:type" format
764
     */
765
    static getRegisteredAlgorithms(namespace?: string): string[] {
766
        const algorithms: string[] = [];
9 ✔
767
        for (const key of algorithmRegistry.keys()) {
9 ✔
768
            if (!namespace || key.startsWith(`${namespace}:`)) {
234 ✔
769
                algorithms.push(key);
208 ✔
770
            }
771
        }
772

773
        return algorithms.sort();
9 ✔
774
    }
775

776
    /**
777
     * Get all registered algorithm types.
778
     * This method is provided for API consistency with DataSource.
779
     * @returns Array of algorithm keys in "namespace:type" format
780
     * @since 1.5.0
781
     * @example
782
     * ```typescript
783
     * const types = Algorithm.getRegisteredTypes();
784
     * console.log('Available algorithms:', types);
785
     * // ['graphty:betweenness', 'graphty:closeness', 'graphty:degree', ...]
786
     * ```
787
     */
788
    static getRegisteredTypes(): string[] {
789
        return this.getRegisteredAlgorithms();
×
790
    }
791
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc