@git-stunts/git-warp 11.5.1 → 12.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +137 -10
  2. package/bin/cli/commands/registry.js +4 -0
  3. package/bin/cli/commands/reindex.js +41 -0
  4. package/bin/cli/commands/verify-index.js +59 -0
  5. package/bin/cli/infrastructure.js +7 -2
  6. package/bin/cli/schemas.js +19 -0
  7. package/bin/cli/types.js +2 -0
  8. package/index.d.ts +52 -15
  9. package/package.json +3 -2
  10. package/src/domain/WarpGraph.js +40 -0
  11. package/src/domain/errors/ShardIdOverflowError.js +28 -0
  12. package/src/domain/errors/index.js +1 -0
  13. package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
  14. package/src/domain/services/BitmapNeighborProvider.js +178 -0
  15. package/src/domain/services/CheckpointMessageCodec.js +3 -3
  16. package/src/domain/services/CheckpointService.js +77 -12
  17. package/src/domain/services/GraphTraversal.js +1239 -0
  18. package/src/domain/services/IncrementalIndexUpdater.js +765 -0
  19. package/src/domain/services/JoinReducer.js +233 -5
  20. package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
  21. package/src/domain/services/LogicalIndexBuildService.js +108 -0
  22. package/src/domain/services/LogicalIndexReader.js +315 -0
  23. package/src/domain/services/LogicalTraversal.js +321 -202
  24. package/src/domain/services/MaterializedViewService.js +379 -0
  25. package/src/domain/services/ObserverView.js +132 -69
  26. package/src/domain/services/PatchBuilderV2.js +3 -3
  27. package/src/domain/services/PropertyIndexBuilder.js +64 -0
  28. package/src/domain/services/PropertyIndexReader.js +111 -0
  29. package/src/domain/services/QueryBuilder.js +15 -44
  30. package/src/domain/services/TemporalQuery.js +128 -14
  31. package/src/domain/services/TranslationCost.js +8 -24
  32. package/src/domain/types/PatchDiff.js +90 -0
  33. package/src/domain/types/WarpTypesV2.js +4 -4
  34. package/src/domain/utils/MinHeap.js +45 -17
  35. package/src/domain/utils/canonicalCbor.js +36 -0
  36. package/src/domain/utils/fnv1a.js +20 -0
  37. package/src/domain/utils/matchGlob.js +51 -0
  38. package/src/domain/utils/roaring.js +14 -3
  39. package/src/domain/utils/shardKey.js +40 -0
  40. package/src/domain/utils/toBytes.js +17 -0
  41. package/src/domain/warp/_wiredMethods.d.ts +7 -1
  42. package/src/domain/warp/checkpoint.methods.js +21 -5
  43. package/src/domain/warp/materialize.methods.js +17 -5
  44. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  45. package/src/domain/warp/query.methods.js +83 -15
  46. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  47. package/src/ports/BlobPort.js +1 -1
  48. package/src/ports/NeighborProviderPort.js +59 -0
  49. package/src/ports/SeekCachePort.js +4 -3
@@ -0,0 +1,1239 @@
1
+ /**
2
+ * GraphTraversal — unified traversal engine for any graph backed by a
3
+ * NeighborProviderPort.
4
+ *
5
+ * Subsumes LogicalTraversal (in-memory adjacency) and DagTraversal /
6
+ * DagPathFinding / DagTopology (bitmap-backed commit DAG). One engine,
7
+ * one set of bugs, one set of fixes.
8
+ *
9
+ * ## Determinism Invariants
10
+ *
11
+ * 1. **BFS**: Nodes at equal depth visited in lexicographic nodeId order.
12
+ * 2. **DFS**: Nodes visited in lexicographic nodeId order (leftmost first
13
+ * via reverse-push).
14
+ * 3. **PQ/Dijkstra/A***: Equal-priority tie-break by lexicographic nodeId
15
+ * (ascending). Equal-cost path tie-break: update predecessor when
16
+ * `altCost === bestCost && candidatePredecessorId < currentPredecessorId`.
17
+ * 4. **Kahn (topoSort)**: Zero-indegree nodes dequeued in lexicographic
18
+ * nodeId order.
19
+ * 5. **Neighbor lists**: Every NeighborProviderPort returns edges sorted by
20
+ * (neighborId, label). Strict codepoint comparison, never localeCompare.
21
+ * 6. **Direction 'both'**: union(out, in) deduped by (neighborId, label).
22
+ * Intentionally lossy about direction.
23
+ * 7. **weightFn/heuristicFn purity**: These functions must be pure and
24
+ * deterministic for a given (from, to, label) / (nodeId, goalId)
25
+ * within a traversal run, or determinism is impossible.
26
+ * 8. **Never** rely on JS Map/Set iteration order — always explicit sort.
27
+ *
28
+ * ## Error Handling Convention
29
+ *
30
+ * - `shortestPath` returns `{ found: false, path: [], length: -1 }` on no path.
31
+ * - `weightedShortestPath`, `aStarSearch`, and `bidirectionalAStar` throw
32
+ * `TraversalError` with code `'NO_PATH'` when no path exists.
33
+ * - All start-node methods throw `TraversalError` with code `'INVALID_START'`
34
+ * when the start node does not exist in the provider.
35
+ *
36
+ * @module domain/services/GraphTraversal
37
+ */
38
+
39
+ import nullLogger from '../utils/nullLogger.js';
40
+ import TraversalError from '../errors/TraversalError.js';
41
+ import MinHeap from '../utils/MinHeap.js';
42
+ import LRUCache from '../utils/LRUCache.js';
43
+ import { checkAborted } from '../utils/cancellation.js';
44
+
45
+ /** @typedef {import('../../ports/NeighborProviderPort.js').default} NeighborProviderPort */
46
+ /** @typedef {import('../../ports/NeighborProviderPort.js').Direction} Direction */
47
+ /** @typedef {import('../../ports/NeighborProviderPort.js').NeighborEdge} NeighborEdge */
48
+ /** @typedef {import('../../ports/NeighborProviderPort.js').NeighborOptions} NeighborOptions */
49
+
50
+ /**
51
+ * @typedef {Object} TraversalStats
52
+ * @property {number} nodesVisited
53
+ * @property {number} edgesTraversed
54
+ * @property {number} cacheHits
55
+ * @property {number} cacheMisses
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} TraversalHooks
60
+ * @property {((nodeId: string, depth: number) => void)} [onVisit]
61
+ * @property {((nodeId: string, neighbors: NeighborEdge[]) => void)} [onExpand]
62
+ */
63
+
64
+ /**
65
+ * Per-run stats accumulator — avoids shared mutable state on the instance.
66
+ * @typedef {Object} RunStats
67
+ * @property {number} cacheHits
68
+ * @property {number} cacheMisses
69
+ * @property {number} edgesTraversed
70
+ */
71
+
72
+ const DEFAULT_MAX_NODES = 100000;
73
+ const DEFAULT_MAX_DEPTH = 1000;
74
+
75
+ /**
76
+ * Default edge weight function: uniform weight of 1.
77
+ * @param {string} _from
78
+ * @param {string} _to
79
+ * @param {string} _label
80
+ * @returns {number}
81
+ */
82
+ const DEFAULT_WEIGHT_FN = (_from, _to, _label) => 1;
83
+
84
+ /**
85
+ * Lexicographic nodeId comparator for MinHeap tie-breaking.
86
+ * @param {string} a
87
+ * @param {string} b
88
+ * @returns {number}
89
+ */
90
+ const lexTieBreaker = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
91
+
92
+ /**
93
+ * Distinguishes true topological cycles from maxNodes truncation.
94
+ *
95
+ * @param {Object} params
96
+ * @param {number} params.sortedLength
97
+ * @param {number} params.discoveredSize
98
+ * @param {number} params.maxNodes
99
+ * @param {boolean} params.readyRemaining
100
+ * @returns {boolean}
101
+ */
102
+ function computeTopoHasCycle({
103
+ sortedLength, discoveredSize, maxNodes, readyRemaining,
104
+ }) {
105
+ const stoppedByLimit = sortedLength >= maxNodes && readyRemaining;
106
+ return !stoppedByLimit && sortedLength < discoveredSize;
107
+ }
108
+
109
+ // ==== Section 1: Configuration & Neighbor Cache ====
110
+
111
+ export default class GraphTraversal {
112
+ /**
113
+ * @param {Object} params
114
+ * @param {NeighborProviderPort} params.provider
115
+ * @param {import('../../ports/LoggerPort.js').default} [params.logger]
116
+ * @param {number} [params.neighborCacheSize]
117
+ */
118
+ constructor({ provider, logger = nullLogger, neighborCacheSize = 256 }) {
119
+ this._provider = provider;
120
+ this._logger = logger;
121
+ /** @type {LRUCache<string, NeighborEdge[]> | null} */
122
+ this._neighborCache = provider.latencyClass === 'sync'
123
+ ? null
124
+ : new LRUCache(neighborCacheSize);
125
+ }
126
+
127
+ /**
128
+ * Creates a fresh per-run stats accumulator.
129
+ * @returns {RunStats}
130
+ * @private
131
+ */
132
+ _newRunStats() {
133
+ return { cacheHits: 0, cacheMisses: 0, edgesTraversed: 0 };
134
+ }
135
+
136
+ /**
137
+ * Builds a stats snapshot from a per-run accumulator.
138
+ * @param {number} nodesVisited
139
+ * @param {RunStats} rs
140
+ * @returns {TraversalStats}
141
+ * @private
142
+ */
143
+ _stats(nodesVisited, rs) {
144
+ return {
145
+ nodesVisited,
146
+ edgesTraversed: rs.edgesTraversed,
147
+ cacheHits: rs.cacheHits,
148
+ cacheMisses: rs.cacheMisses,
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Gets neighbors with optional LRU memoization.
154
+ *
155
+ * @param {string} nodeId
156
+ * @param {Direction} direction
157
+ * @param {RunStats} rs - Per-run stats accumulator
158
+ * @param {NeighborOptions} [options]
159
+ * @returns {Promise<NeighborEdge[]>}
160
+ * @private
161
+ */
162
+ async _getNeighbors(nodeId, direction, rs, options) {
163
+ const cache = this._neighborCache;
164
+ if (!cache) {
165
+ return await this._provider.getNeighbors(nodeId, direction, options);
166
+ }
167
+
168
+ const labelsKey = options?.labels ? JSON.stringify([...options.labels].sort()) : '*';
169
+ const key = `${nodeId}\0${direction}\0${labelsKey}`;
170
+ const cached = cache.get(key);
171
+ if (cached !== undefined) {
172
+ rs.cacheHits++;
173
+ return cached;
174
+ }
175
+ rs.cacheMisses++;
176
+ const result = await this._provider.getNeighbors(nodeId, direction, options);
177
+ cache.set(key, result);
178
+ return result;
179
+ }
180
+
181
+ // ==== Section 2: Primitive Traversals (BFS, DFS) ====
182
+
183
+ /**
184
+ * Breadth-first search.
185
+ *
186
+ * Deterministic: nodes at equal depth are visited in lexicographic nodeId order.
187
+ *
188
+ * @param {Object} params
189
+ * @param {string} params.start
190
+ * @param {Direction} [params.direction]
191
+ * @param {NeighborOptions} [params.options]
192
+ * @param {number} [params.maxNodes]
193
+ * @param {number} [params.maxDepth]
194
+ * @param {AbortSignal} [params.signal]
195
+ * @param {TraversalHooks} [params.hooks]
196
+ * @returns {Promise<{nodes: string[], stats: TraversalStats}>}
197
+ */
198
+ async bfs({
199
+ start, direction = 'out', options,
200
+ maxNodes = DEFAULT_MAX_NODES,
201
+ maxDepth = DEFAULT_MAX_DEPTH,
202
+ signal, hooks,
203
+ }) {
204
+ const rs = this._newRunStats();
205
+ await this._validateStart(start);
206
+ const visited = new Set();
207
+ /** @type {Array<{nodeId: string, depth: number}>} */
208
+ let currentLevel = [{ nodeId: start, depth: 0 }];
209
+ const result = [];
210
+
211
+ while (currentLevel.length > 0 && visited.size < maxNodes) {
212
+ // Sort current level lexicographically for deterministic order
213
+ currentLevel.sort((a, b) => (a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0));
214
+ /** @type {Array<{nodeId: string, depth: number}>} */
215
+ const nextLevel = [];
216
+ /** @type {Set<string>} — dedup within this level to avoid O(E) duplicates */
217
+ const queued = new Set();
218
+
219
+ for (const { nodeId, depth } of currentLevel) {
220
+ if (visited.size >= maxNodes) { break; }
221
+ if (visited.has(nodeId)) { continue; }
222
+ if (depth > maxDepth) { continue; }
223
+
224
+ if (visited.size % 1000 === 0) {
225
+ checkAborted(signal, 'bfs');
226
+ }
227
+
228
+ visited.add(nodeId);
229
+ result.push(nodeId);
230
+ if (hooks?.onVisit) { hooks.onVisit(nodeId, depth); }
231
+
232
+ if (depth < maxDepth) {
233
+ const neighbors = await this._getNeighbors(nodeId, direction, rs, options);
234
+ rs.edgesTraversed += neighbors.length;
235
+ if (hooks?.onExpand) { hooks.onExpand(nodeId, neighbors); }
236
+ for (const { neighborId } of neighbors) {
237
+ if (!visited.has(neighborId) && !queued.has(neighborId)) {
238
+ queued.add(neighborId);
239
+ nextLevel.push({ nodeId: neighborId, depth: depth + 1 });
240
+ }
241
+ }
242
+ }
243
+ }
244
+ currentLevel = nextLevel;
245
+ }
246
+
247
+ return { nodes: result, stats: this._stats(visited.size, rs) };
248
+ }
249
+
250
+ /**
251
+ * Depth-first search (pre-order).
252
+ *
253
+ * Deterministic: leftmost-first via reverse-push of sorted neighbors.
254
+ *
255
+ * @param {Object} params
256
+ * @param {string} params.start
257
+ * @param {Direction} [params.direction]
258
+ * @param {NeighborOptions} [params.options]
259
+ * @param {number} [params.maxNodes]
260
+ * @param {number} [params.maxDepth]
261
+ * @param {AbortSignal} [params.signal]
262
+ * @param {TraversalHooks} [params.hooks]
263
+ * @returns {Promise<{nodes: string[], stats: TraversalStats}>}
264
+ */
265
+ async dfs({
266
+ start, direction = 'out', options,
267
+ maxNodes = DEFAULT_MAX_NODES,
268
+ maxDepth = DEFAULT_MAX_DEPTH,
269
+ signal, hooks,
270
+ }) {
271
+ const rs = this._newRunStats();
272
+ await this._validateStart(start);
273
+ const visited = new Set();
274
+ /** @type {Array<{nodeId: string, depth: number}>} */
275
+ const stack = [{ nodeId: start, depth: 0 }];
276
+ const result = [];
277
+
278
+ while (stack.length > 0 && visited.size < maxNodes) {
279
+ const { nodeId, depth } = /** @type {{nodeId: string, depth: number}} */ (stack.pop());
280
+ if (visited.has(nodeId)) { continue; }
281
+ if (depth > maxDepth) { continue; }
282
+
283
+ if (visited.size % 1000 === 0) {
284
+ checkAborted(signal, 'dfs');
285
+ }
286
+
287
+ visited.add(nodeId);
288
+ result.push(nodeId);
289
+ if (hooks?.onVisit) { hooks.onVisit(nodeId, depth); }
290
+
291
+ if (depth < maxDepth) {
292
+ const neighbors = await this._getNeighbors(nodeId, direction, rs, options);
293
+ rs.edgesTraversed += neighbors.length;
294
+ if (hooks?.onExpand) { hooks.onExpand(nodeId, neighbors); }
295
+ // Reverse-push so first neighbor (lex smallest) is popped first
296
+ for (let i = neighbors.length - 1; i >= 0; i -= 1) {
297
+ if (!visited.has(neighbors[i].neighborId)) {
298
+ stack.push({ nodeId: neighbors[i].neighborId, depth: depth + 1 });
299
+ }
300
+ }
301
+ }
302
+ }
303
+
304
+ return { nodes: result, stats: this._stats(visited.size, rs) };
305
+ }
306
+
307
+ // ==== Section 3: Path-Finding (shortestPath, Dijkstra, A*, bidirectional A*) ====
308
+
309
+ /**
310
+ * Unweighted shortest path (BFS-based).
311
+ *
312
+ * @param {Object} params
313
+ * @param {string} params.start
314
+ * @param {string} params.goal
315
+ * @param {Direction} [params.direction]
316
+ * @param {NeighborOptions} [params.options]
317
+ * @param {number} [params.maxNodes]
318
+ * @param {number} [params.maxDepth]
319
+ * @param {AbortSignal} [params.signal]
320
+ * @returns {Promise<{found: boolean, path: string[], length: number, stats: TraversalStats}>}
321
+ */
322
+ async shortestPath({
323
+ start, goal, direction = 'out', options,
324
+ maxNodes = DEFAULT_MAX_NODES,
325
+ maxDepth = DEFAULT_MAX_DEPTH,
326
+ signal,
327
+ }) {
328
+ const rs = this._newRunStats();
329
+ await this._validateStart(start);
330
+ if (start === goal) {
331
+ return { found: true, path: [start], length: 0, stats: this._stats(1, rs) };
332
+ }
333
+
334
+ const visited = new Set([start]);
335
+ const parent = new Map();
336
+ /** @type {Array<{nodeId: string, depth: number}>} */
337
+ let frontier = [{ nodeId: start, depth: 0 }];
338
+
339
+ while (frontier.length > 0 && visited.size < maxNodes) {
340
+ // Sort frontier for deterministic BFS
341
+ frontier.sort((a, b) => (a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0));
342
+ /** @type {Array<{nodeId: string, depth: number}>} */
343
+ const nextFrontier = [];
344
+
345
+ for (const { nodeId, depth } of frontier) {
346
+ if (depth >= maxDepth) { continue; }
347
+ if (visited.size % 1000 === 0) {
348
+ checkAborted(signal, 'shortestPath');
349
+ }
350
+
351
+ const neighbors = await this._getNeighbors(nodeId, direction, rs, options);
352
+ rs.edgesTraversed += neighbors.length;
353
+
354
+ for (const { neighborId } of neighbors) {
355
+ if (visited.has(neighborId)) { continue; }
356
+ visited.add(neighborId);
357
+ parent.set(neighborId, nodeId);
358
+
359
+ if (neighborId === goal) {
360
+ const path = this._reconstructPath(parent, start, goal);
361
+ return { found: true, path, length: path.length - 1, stats: this._stats(visited.size, rs) };
362
+ }
363
+ nextFrontier.push({ nodeId: neighborId, depth: depth + 1 });
364
+ }
365
+ }
366
+ frontier = nextFrontier;
367
+ }
368
+
369
+ return { found: false, path: [], length: -1, stats: this._stats(visited.size, rs) };
370
+ }
371
+
372
+ /**
373
+ * Reachability check — BFS with early termination.
374
+ *
375
+ * @param {Object} params
376
+ * @param {string} params.start
377
+ * @param {string} params.goal
378
+ * @param {Direction} [params.direction]
379
+ * @param {NeighborOptions} [params.options]
380
+ * @param {number} [params.maxNodes]
381
+ * @param {number} [params.maxDepth]
382
+ * @param {AbortSignal} [params.signal]
383
+ * @returns {Promise<{reachable: boolean, stats: TraversalStats}>}
384
+ */
385
+ async isReachable({
386
+ start, goal, direction = 'out', options,
387
+ maxNodes = DEFAULT_MAX_NODES,
388
+ maxDepth = DEFAULT_MAX_DEPTH,
389
+ signal,
390
+ }) {
391
+ const rs = this._newRunStats();
392
+ if (start === goal) {
393
+ return { reachable: true, stats: this._stats(1, rs) };
394
+ }
395
+
396
+ const visited = new Set([start]);
397
+ /** @type {string[]} */
398
+ let frontier = [start];
399
+ let depth = 0;
400
+
401
+ while (frontier.length > 0 && depth < maxDepth && visited.size < maxNodes) {
402
+ if (visited.size % 1000 === 0) {
403
+ checkAborted(signal, 'isReachable');
404
+ }
405
+ /** @type {string[]} */
406
+ const nextFrontier = [];
407
+ for (const nodeId of frontier) {
408
+ const neighbors = await this._getNeighbors(nodeId, direction, rs, options);
409
+ rs.edgesTraversed += neighbors.length;
410
+ for (const { neighborId } of neighbors) {
411
+ if (neighborId === goal) {
412
+ return { reachable: true, stats: this._stats(visited.size, rs) };
413
+ }
414
+ if (!visited.has(neighborId)) {
415
+ visited.add(neighborId);
416
+ nextFrontier.push(neighborId);
417
+ }
418
+ }
419
+ }
420
+ frontier = nextFrontier;
421
+ depth++;
422
+ }
423
+
424
+ return { reachable: false, stats: this._stats(visited.size, rs) };
425
+ }
426
+
427
+ /**
428
+ * Weighted shortest path (Dijkstra's algorithm).
429
+ *
430
+ * Tie-breaking: equal-priority by lexicographic nodeId. Equal-cost
431
+ * predecessor update: when altCost === bestCost && candidatePredecessor < currentPredecessor.
432
+ *
433
+ * @param {Object} params
434
+ * @param {string} params.start
435
+ * @param {string} params.goal
436
+ * @param {Direction} [params.direction]
437
+ * @param {NeighborOptions} [params.options]
438
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [params.weightFn]
439
+ * @param {(nodeId: string) => number | Promise<number>} [params.nodeWeightFn]
440
+ * @param {number} [params.maxNodes]
441
+ * @param {AbortSignal} [params.signal]
442
+ * @returns {Promise<{path: string[], totalCost: number, stats: TraversalStats}>}
443
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
444
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
445
+ */
446
+ async weightedShortestPath({
447
+ start, goal, direction = 'out', options,
448
+ weightFn, nodeWeightFn,
449
+ maxNodes = DEFAULT_MAX_NODES,
450
+ signal,
451
+ }) {
452
+ const effectiveWeightFn = this._resolveWeightFn(weightFn, nodeWeightFn);
453
+ const rs = this._newRunStats();
454
+ await this._validateStart(start);
455
+ /** @type {Map<string, number>} */
456
+ const dist = new Map([[start, 0]]);
457
+ /** @type {Map<string, string>} */
458
+ const prev = new Map();
459
+ const visited = new Set();
460
+
461
+ const pq = new MinHeap({ tieBreaker: lexTieBreaker });
462
+ pq.insert(start, 0);
463
+
464
+ while (!pq.isEmpty() && visited.size < maxNodes) {
465
+ checkAborted(signal, 'weightedShortestPath');
466
+
467
+ const current = /** @type {string} */ (pq.extractMin());
468
+ if (visited.has(current)) { continue; }
469
+ visited.add(current);
470
+
471
+ if (current === goal) {
472
+ const path = this._reconstructPath(prev, start, goal);
473
+ return { path, totalCost: /** @type {number} */ (dist.get(goal)), stats: this._stats(visited.size, rs) };
474
+ }
475
+
476
+ const neighbors = await this._getNeighbors(current, direction, rs, options);
477
+ rs.edgesTraversed += neighbors.length;
478
+
479
+ for (const { neighborId, label } of neighbors) {
480
+ if (visited.has(neighborId)) { continue; }
481
+ const w = await effectiveWeightFn(current, neighborId, label);
482
+ const alt = /** @type {number} */ (dist.get(current)) + w;
483
+ const best = dist.has(neighborId) ? /** @type {number} */ (dist.get(neighborId)) : Infinity;
484
+
485
+ if (alt < best || (alt === best && this._shouldUpdatePredecessor(prev, neighborId, current))) {
486
+ dist.set(neighborId, alt);
487
+ prev.set(neighborId, current);
488
+ pq.insert(neighborId, alt);
489
+ }
490
+ }
491
+ }
492
+
493
+ throw new TraversalError(`No path from ${start} to ${goal}`, {
494
+ code: 'NO_PATH',
495
+ context: { start, goal },
496
+ });
497
+ }
498
+
499
+ /**
500
+ * A* search with heuristic guidance.
501
+ *
502
+ * @param {Object} params
503
+ * @param {string} params.start
504
+ * @param {string} params.goal
505
+ * @param {Direction} [params.direction]
506
+ * @param {NeighborOptions} [params.options]
507
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [params.weightFn]
508
+ * @param {(nodeId: string) => number | Promise<number>} [params.nodeWeightFn]
509
+ * @param {(nodeId: string, goalId: string) => number} [params.heuristicFn]
510
+ * @param {number} [params.maxNodes]
511
+ * @param {AbortSignal} [params.signal]
512
+ * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number, stats: TraversalStats}>}
513
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
514
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
515
+ */
516
+ async aStarSearch({
517
+ start, goal, direction = 'out', options,
518
+ weightFn, nodeWeightFn,
519
+ heuristicFn = () => 0,
520
+ maxNodes = DEFAULT_MAX_NODES,
521
+ signal,
522
+ }) {
523
+ const effectiveWeightFn = this._resolveWeightFn(weightFn, nodeWeightFn);
524
+ const rs = this._newRunStats();
525
+ await this._validateStart(start);
526
+ /** @type {Map<string, number>} */
527
+ const gScore = new Map([[start, 0]]);
528
+ /** @type {Map<string, string>} */
529
+ const prev = new Map();
530
+ const visited = new Set();
531
+
532
+ const pq = new MinHeap({ tieBreaker: lexTieBreaker });
533
+ pq.insert(start, heuristicFn(start, goal));
534
+
535
+ while (!pq.isEmpty() && visited.size < maxNodes) {
536
+ checkAborted(signal, 'aStarSearch');
537
+
538
+ const current = /** @type {string} */ (pq.extractMin());
539
+ if (visited.has(current)) { continue; }
540
+ visited.add(current);
541
+
542
+ if (current === goal) {
543
+ const path = this._reconstructPath(prev, start, goal);
544
+ return {
545
+ path,
546
+ totalCost: /** @type {number} */ (gScore.get(goal)),
547
+ nodesExplored: visited.size,
548
+ stats: this._stats(visited.size, rs),
549
+ };
550
+ }
551
+
552
+ const neighbors = await this._getNeighbors(current, direction, rs, options);
553
+ rs.edgesTraversed += neighbors.length;
554
+
555
+ for (const { neighborId, label } of neighbors) {
556
+ if (visited.has(neighborId)) { continue; }
557
+ const w = await effectiveWeightFn(current, neighborId, label);
558
+ const tentG = /** @type {number} */ (gScore.get(current)) + w;
559
+ const bestG = gScore.has(neighborId) ? /** @type {number} */ (gScore.get(neighborId)) : Infinity;
560
+
561
+ if (tentG < bestG || (tentG === bestG && this._shouldUpdatePredecessor(prev, neighborId, current))) {
562
+ gScore.set(neighborId, tentG);
563
+ prev.set(neighborId, current);
564
+ pq.insert(neighborId, tentG + heuristicFn(neighborId, goal));
565
+ }
566
+ }
567
+ }
568
+
569
+ throw new TraversalError(`No path from ${start} to ${goal}`, {
570
+ code: 'NO_PATH',
571
+ context: { start, goal, nodesExplored: visited.size },
572
+ });
573
+ }
574
+
575
+ /**
576
+ * Bidirectional A* search.
577
+ *
578
+ * **Direction is fixed:** forward expansion uses `'out'` edges, backward
579
+ * expansion uses `'in'` edges. Unlike other pathfinding methods, this one
580
+ * does not accept a `direction` parameter. This is inherent to the
581
+ * bidirectional algorithm — forward always means outgoing, backward always
582
+ * means incoming.
583
+ *
584
+ * @param {Object} params
585
+ * @param {string} params.start
586
+ * @param {string} params.goal
587
+ * @param {NeighborOptions} [params.options]
588
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [params.weightFn]
589
+ * @param {(nodeId: string) => number | Promise<number>} [params.nodeWeightFn]
590
+ * @param {(nodeId: string, goalId: string) => number} [params.forwardHeuristic]
591
+ * @param {(nodeId: string, goalId: string) => number} [params.backwardHeuristic]
592
+ * @param {number} [params.maxNodes]
593
+ * @param {AbortSignal} [params.signal]
594
+ * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number, stats: TraversalStats}>}
595
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
596
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
597
+ */
598
+ async bidirectionalAStar({
599
+ start, goal, options,
600
+ weightFn, nodeWeightFn,
601
+ forwardHeuristic = () => 0,
602
+ backwardHeuristic = () => 0,
603
+ maxNodes = DEFAULT_MAX_NODES,
604
+ signal,
605
+ }) {
606
+ const effectiveWeightFn = this._resolveWeightFn(weightFn, nodeWeightFn);
607
+ const rs = this._newRunStats();
608
+ await this._validateStart(start);
609
+ if (start === goal) {
610
+ return { path: [start], totalCost: 0, nodesExplored: 1, stats: this._stats(1, rs) };
611
+ }
612
+
613
+ const fwdG = new Map([[start, 0]]);
614
+ const fwdPrev = new Map();
615
+ const fwdVisited = new Set();
616
+ const fwdHeap = new MinHeap({ tieBreaker: lexTieBreaker });
617
+ fwdHeap.insert(start, forwardHeuristic(start, goal));
618
+
619
+ const bwdG = new Map([[goal, 0]]);
620
+ const bwdNext = new Map();
621
+ const bwdVisited = new Set();
622
+ const bwdHeap = new MinHeap({ tieBreaker: lexTieBreaker });
623
+ bwdHeap.insert(goal, backwardHeuristic(goal, start));
624
+
625
+ let mu = Infinity;
626
+ /** @type {string|null} */
627
+ let meeting = null;
628
+ let explored = 0;
629
+
630
+ while ((!fwdHeap.isEmpty() || !bwdHeap.isEmpty()) && explored < maxNodes) {
631
+ checkAborted(signal, 'bidirectionalAStar');
632
+ const fwdF = fwdHeap.peekPriority();
633
+ const bwdF = bwdHeap.peekPriority();
634
+ if (Math.min(fwdF, bwdF) >= mu) { break; }
635
+
636
+ if (fwdF <= bwdF) {
637
+ const r = await this._biAStarExpand({
638
+ heap: fwdHeap, visited: fwdVisited, gScore: fwdG, predMap: fwdPrev,
639
+ otherVisited: bwdVisited, otherG: bwdG,
640
+ weightFn: effectiveWeightFn, heuristicFn: forwardHeuristic,
641
+ target: goal, directionForNeighbors: 'out', options,
642
+ mu, meeting, rs,
643
+ });
644
+ explored += r.explored;
645
+ mu = r.mu;
646
+ meeting = r.meeting;
647
+ } else {
648
+ const r = await this._biAStarExpand({
649
+ heap: bwdHeap, visited: bwdVisited, gScore: bwdG, predMap: bwdNext,
650
+ otherVisited: fwdVisited, otherG: fwdG,
651
+ weightFn: effectiveWeightFn, heuristicFn: backwardHeuristic,
652
+ target: start, directionForNeighbors: 'in', options,
653
+ mu, meeting, rs,
654
+ });
655
+ explored += r.explored;
656
+ mu = r.mu;
657
+ meeting = r.meeting;
658
+ }
659
+ }
660
+
661
+ if (meeting === null) {
662
+ throw new TraversalError(`No path from ${start} to ${goal}`, {
663
+ code: 'NO_PATH',
664
+ context: { start, goal, nodesExplored: explored },
665
+ });
666
+ }
667
+
668
+ const path = this._reconstructBiPath(fwdPrev, bwdNext, start, goal, meeting);
669
+ return { path, totalCost: mu, nodesExplored: explored, stats: this._stats(explored, rs) };
670
+ }
671
+
672
+ /**
673
+ * Expand one node in bidirectional A*.
674
+ * @private
675
+ * @param {Object} p
676
+ * @param {MinHeap<string>} p.heap
677
+ * @param {Set<string>} p.visited
678
+ * @param {Map<string, number>} p.gScore
679
+ * @param {Map<string, string>} p.predMap
680
+ * @param {Set<string>} p.otherVisited
681
+ * @param {Map<string, number>} p.otherG
682
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} p.weightFn
683
+ * @param {(nodeId: string, goalId: string) => number} p.heuristicFn
684
+ * @param {string} p.target
685
+ * @param {Direction} p.directionForNeighbors
686
+ * @param {NeighborOptions} [p.options]
687
+ * @param {number} p.mu
688
+ * @param {string|null} p.meeting
689
+ * @param {RunStats} p.rs
690
+ * @returns {Promise<{explored: number, mu: number, meeting: string|null}>}
691
+ */
692
+ async _biAStarExpand({
693
+ heap, visited, gScore, predMap,
694
+ otherVisited, otherG,
695
+ weightFn, heuristicFn, target,
696
+ directionForNeighbors, options,
697
+ mu: inputMu, meeting: inputMeeting, rs,
698
+ }) {
699
+ const current = /** @type {string} */ (heap.extractMin());
700
+ if (visited.has(current)) {
701
+ return { explored: 0, mu: inputMu, meeting: inputMeeting };
702
+ }
703
+ visited.add(current);
704
+
705
+ let resultMu = inputMu;
706
+ let resultMeeting = inputMeeting;
707
+
708
+ if (otherVisited.has(current)) {
709
+ const cost = /** @type {number} */ (gScore.get(current)) + /** @type {number} */ (otherG.get(current));
710
+ if (cost < resultMu || (cost === resultMu && (resultMeeting === null || current < resultMeeting))) {
711
+ resultMu = cost;
712
+ resultMeeting = current;
713
+ }
714
+ }
715
+
716
+ const neighbors = await this._getNeighbors(current, directionForNeighbors, rs, options);
717
+ rs.edgesTraversed += neighbors.length;
718
+
719
+ for (const { neighborId, label } of neighbors) {
720
+ if (visited.has(neighborId)) { continue; }
721
+ const w = directionForNeighbors === 'in'
722
+ ? await weightFn(neighborId, current, label)
723
+ : await weightFn(current, neighborId, label);
724
+ const tentG = /** @type {number} */ (gScore.get(current)) + w;
725
+ const bestG = gScore.has(neighborId) ? /** @type {number} */ (gScore.get(neighborId)) : Infinity;
726
+
727
+ if (tentG < bestG || (tentG === bestG && this._shouldUpdatePredecessor(predMap, neighborId, current))) {
728
+ gScore.set(neighborId, tentG);
729
+ predMap.set(neighborId, current);
730
+ heap.insert(neighborId, tentG + heuristicFn(neighborId, target));
731
+
732
+ if (otherG.has(neighborId)) {
733
+ const total = tentG + /** @type {number} */ (otherG.get(neighborId));
734
+ if (total < resultMu || (total === resultMu && (resultMeeting === null || neighborId < resultMeeting))) {
735
+ resultMu = total;
736
+ resultMeeting = neighborId;
737
+ }
738
+ }
739
+ }
740
+ }
741
+
742
+ return { explored: 1, mu: resultMu, meeting: resultMeeting };
743
+ }
744
+
745
+ // ==== Section 4: Topology & Components (topoSort, CC, weightedLongestPath) ====
746
+
747
+ /**
748
+ * Connected component — delegates to BFS with direction 'both'.
749
+ *
750
+ * @param {Object} params
751
+ * @param {string} params.start
752
+ * @param {NeighborOptions} [params.options]
753
+ * @param {number} [params.maxNodes]
754
+ * @param {number} [params.maxDepth]
755
+ * @param {AbortSignal} [params.signal]
756
+ * @returns {Promise<{nodes: string[], stats: TraversalStats}>}
757
+ */
758
+ async connectedComponent({ start, options, maxNodes, maxDepth, signal }) {
759
+ return await this.bfs({ start, direction: 'both', options, maxNodes, maxDepth, signal });
760
+ }
761
+
762
+ /**
763
+ * Topological sort (Kahn's algorithm).
764
+ *
765
+ * Deterministic: zero-indegree nodes dequeued in lexicographic nodeId order.
766
+ *
767
+ * @param {Object} params
768
+ * @param {string | string[]} params.start - One or more start nodes
769
+ * @param {Direction} [params.direction]
770
+ * @param {NeighborOptions} [params.options]
771
+ * @param {number} [params.maxNodes]
772
+ * @param {boolean} [params.throwOnCycle]
773
+ * @param {AbortSignal} [params.signal]
774
+ * @param {boolean} [params._returnAdjList] - Private: return neighbor edge map alongside sorted (for internal reuse)
775
+ * @returns {Promise<{sorted: string[], hasCycle: boolean, stats: TraversalStats, _neighborEdgeMap?: Map<string, NeighborEdge[]>}>}
776
+ * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if throwOnCycle is true and cycle found
777
+ */
778
+ async topologicalSort({
779
+ start, direction = 'out', options,
780
+ maxNodes = DEFAULT_MAX_NODES,
781
+ throwOnCycle = false,
782
+ signal,
783
+ _returnAdjList = false,
784
+ }) {
785
+ const rs = this._newRunStats();
786
+ const starts = [...new Set(Array.isArray(start) ? start : [start])];
787
+ for (const s of starts) {
788
+ await this._validateStart(s);
789
+ }
790
+
791
+ // Phase 1: Discover all reachable nodes + compute in-degrees
792
+ /** @type {Map<string, string[]>} */
793
+ const adjList = new Map();
794
+ /** @type {Map<string, NeighborEdge[]>} — populated when _returnAdjList is true */
795
+ const neighborEdgeMap = new Map();
796
+ /** @type {Map<string, number>} */
797
+ const inDegree = new Map();
798
+ const discovered = new Set();
799
+ /** @type {string[]} */
800
+ const queue = [...starts];
801
+ let qHead = 0;
802
+ for (const s of starts) { discovered.add(s); }
803
+
804
+ while (qHead < queue.length) {
805
+ if (discovered.size % 1000 === 0) {
806
+ checkAborted(signal, 'topologicalSort');
807
+ }
808
+ const nodeId = /** @type {string} */ (queue[qHead++]);
809
+ const neighbors = await this._getNeighbors(nodeId, direction, rs, options);
810
+ rs.edgesTraversed += neighbors.length;
811
+
812
+ /** @type {string[]} */
813
+ const neighborIds = [];
814
+ for (const { neighborId } of neighbors) {
815
+ neighborIds.push(neighborId);
816
+ inDegree.set(neighborId, (inDegree.get(neighborId) || 0) + 1);
817
+ if (!discovered.has(neighborId)) {
818
+ discovered.add(neighborId);
819
+ queue.push(neighborId);
820
+ }
821
+ }
822
+ adjList.set(nodeId, neighborIds);
823
+ neighborEdgeMap.set(nodeId, neighbors);
824
+ }
825
+
826
+ // Ensure starts have in-degree entries
827
+ for (const s of starts) {
828
+ if (!inDegree.has(s)) {
829
+ inDegree.set(s, 0);
830
+ }
831
+ }
832
+
833
+ // Phase 2: Kahn's — collect zero-indegree nodes, sort them lex, yield in order
834
+ /** @type {string[]} */
835
+ const ready = [];
836
+ for (const nodeId of discovered) {
837
+ if ((inDegree.get(nodeId) || 0) === 0) {
838
+ ready.push(nodeId);
839
+ }
840
+ }
841
+ ready.sort(lexTieBreaker);
842
+
843
+ const sorted = [];
844
+ let rHead = 0;
845
+ while (rHead < ready.length && sorted.length < maxNodes) {
846
+ if (sorted.length % 1000 === 0) {
847
+ checkAborted(signal, 'topologicalSort');
848
+ }
849
+ const nodeId = /** @type {string} */ (ready[rHead++]);
850
+ sorted.push(nodeId);
851
+
852
+ const neighbors = adjList.get(nodeId) || [];
853
+ /** @type {string[]} */
854
+ const newlyReady = [];
855
+ for (const neighborId of neighbors) {
856
+ const deg = /** @type {number} */ (inDegree.get(neighborId)) - 1;
857
+ inDegree.set(neighborId, deg);
858
+ if (deg === 0) {
859
+ newlyReady.push(neighborId);
860
+ }
861
+ }
862
+ // Insert newly ready nodes in sorted position
863
+ if (newlyReady.length > 0) {
864
+ newlyReady.sort(lexTieBreaker);
865
+ // Compact consumed prefix before merge to keep rHead at 0
866
+ if (rHead > 0) {
867
+ ready.splice(0, rHead);
868
+ rHead = 0;
869
+ }
870
+ this._insertSorted(ready, newlyReady);
871
+ }
872
+ }
873
+
874
+ const hasCycle = computeTopoHasCycle({
875
+ sortedLength: sorted.length,
876
+ discoveredSize: discovered.size,
877
+ maxNodes,
878
+ readyRemaining: rHead < ready.length,
879
+ });
880
+ if (hasCycle && throwOnCycle) {
881
+ // Find a back-edge as witness
882
+ const inSorted = new Set(sorted);
883
+ /** @type {string|undefined} */
884
+ let cycleWitnessFrom;
885
+ /** @type {string|undefined} */
886
+ let cycleWitnessTo;
887
+ for (const [nodeId, neighbors] of adjList) {
888
+ if (inSorted.has(nodeId)) { continue; }
889
+ for (const neighborId of neighbors) {
890
+ if (!inSorted.has(neighborId)) {
891
+ cycleWitnessFrom = nodeId;
892
+ cycleWitnessTo = neighborId;
893
+ break;
894
+ }
895
+ }
896
+ if (cycleWitnessFrom) { break; }
897
+ }
898
+
899
+ throw new TraversalError('Graph contains a cycle', {
900
+ code: 'ERR_GRAPH_HAS_CYCLES',
901
+ context: {
902
+ nodesInCycle: discovered.size - sorted.length,
903
+ cycleWitness: cycleWitnessFrom ? { from: cycleWitnessFrom, to: cycleWitnessTo } : undefined,
904
+ },
905
+ });
906
+ }
907
+
908
+ return {
909
+ sorted,
910
+ hasCycle,
911
+ stats: this._stats(sorted.length, rs),
912
+ _neighborEdgeMap: _returnAdjList ? neighborEdgeMap : undefined,
913
+ };
914
+ }
915
+
916
+ /**
917
+ * Common ancestors — multi-source ancestor intersection.
918
+ *
919
+ * For each input node, performs a BFS backward ('in') to collect its
920
+ * ancestor set. The result is the intersection of all ancestor sets.
921
+ *
922
+ * **Self-inclusion:** The BFS from each node includes the node itself
923
+ * (depth 0). Therefore, the result may include the input nodes themselves
924
+ * if they are reachable from all other input nodes via backward edges.
925
+ * For example, if A has backward edges to B and C, and you pass
926
+ * `[A, B, C]`, then B and C may appear in the result because A's BFS
927
+ * reaches them and their own BFS includes themselves at depth 0.
928
+ *
929
+ * @param {Object} params
930
+ * @param {string[]} params.nodes - Nodes to find common ancestors of
931
+ * @param {NeighborOptions} [params.options]
932
+ * @param {number} [params.maxDepth]
933
+ * @param {number} [params.maxResults]
934
+ * @param {AbortSignal} [params.signal]
935
+ * @returns {Promise<{ancestors: string[], stats: TraversalStats}>}
936
+ */
937
+ async commonAncestors({
938
+ nodes, options,
939
+ maxDepth = DEFAULT_MAX_DEPTH,
940
+ maxResults = 100,
941
+ signal,
942
+ }) {
943
+ if (nodes.length === 0) {
944
+ return { ancestors: [], stats: this._stats(0, this._newRunStats()) };
945
+ }
946
+
947
+ // For each node, BFS backward ('in') to collect ancestors
948
+ /** @type {Map<string, number>} */
949
+ const ancestorCounts = new Map();
950
+ const requiredCount = nodes.length;
951
+ /** @type {TraversalStats} */
952
+ const totalStats = {
953
+ nodesVisited: 0,
954
+ edgesTraversed: 0,
955
+ cacheHits: 0,
956
+ cacheMisses: 0,
957
+ };
958
+
959
+ for (const nodeId of nodes) {
960
+ checkAborted(signal, 'commonAncestors');
961
+ const { nodes: ancestors, stats } = await this.bfs({
962
+ start: nodeId,
963
+ direction: 'in',
964
+ options,
965
+ maxDepth,
966
+ signal,
967
+ });
968
+ totalStats.nodesVisited += stats.nodesVisited;
969
+ totalStats.edgesTraversed += stats.edgesTraversed;
970
+ totalStats.cacheHits += stats.cacheHits;
971
+ totalStats.cacheMisses += stats.cacheMisses;
972
+ for (const a of ancestors) {
973
+ ancestorCounts.set(a, (ancestorCounts.get(a) || 0) + 1);
974
+ }
975
+ }
976
+
977
+ // Collect nodes reachable from ALL inputs, sorted lex
978
+ const common = [];
979
+ const entries = [...ancestorCounts.entries()]
980
+ .filter(([, count]) => count === requiredCount)
981
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
982
+
983
+ for (const [ancestor] of entries) {
984
+ common.push(ancestor);
985
+ if (common.length >= maxResults) { break; }
986
+ }
987
+
988
+ return { ancestors: common, stats: totalStats };
989
+ }
990
+
991
+ /**
992
+ * Weighted longest path via topological sort + DP.
993
+ *
994
+ * Only valid on DAGs. Throws ERR_GRAPH_HAS_CYCLES if graph has cycles.
995
+ *
996
+ * @param {Object} params
997
+ * @param {string} params.start
998
+ * @param {string} params.goal
999
+ * @param {Direction} [params.direction]
1000
+ * @param {NeighborOptions} [params.options]
1001
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [params.weightFn]
1002
+ * @param {(nodeId: string) => number | Promise<number>} [params.nodeWeightFn]
1003
+ * @param {number} [params.maxNodes]
1004
+ * @param {AbortSignal} [params.signal]
1005
+ * @returns {Promise<{path: string[], totalCost: number, stats: TraversalStats}>}
1006
+ * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles
1007
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
1008
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
1009
+ */
1010
+ async weightedLongestPath({
1011
+ start, goal, direction = 'out', options,
1012
+ weightFn, nodeWeightFn,
1013
+ maxNodes = DEFAULT_MAX_NODES,
1014
+ signal,
1015
+ }) {
1016
+ const effectiveWeightFn = this._resolveWeightFn(weightFn, nodeWeightFn);
1017
+ await this._validateStart(start);
1018
+ // Run topo sort first — will throw on cycles.
1019
+ // Request the neighbor edge map so the DP phase can reuse it
1020
+ // instead of re-fetching neighbors from the provider.
1021
+ const { sorted, _neighborEdgeMap } = await this.topologicalSort({
1022
+ start,
1023
+ direction,
1024
+ options,
1025
+ maxNodes,
1026
+ throwOnCycle: true,
1027
+ signal,
1028
+ _returnAdjList: true,
1029
+ });
1030
+
1031
+ const rs = this._newRunStats();
1032
+
1033
+ // DP: longest distance from start
1034
+ /** @type {Map<string, number>} */
1035
+ const dist = new Map([[start, 0]]);
1036
+ /** @type {Map<string, string>} */
1037
+ const prev = new Map();
1038
+
1039
+ for (const nodeId of sorted) {
1040
+ if (!dist.has(nodeId)) { continue; }
1041
+ // Reuse neighbor data from topo sort's discovery phase
1042
+ const neighbors = _neighborEdgeMap
1043
+ ? (_neighborEdgeMap.get(nodeId) || [])
1044
+ : await this._getNeighbors(nodeId, direction, rs, options);
1045
+ rs.edgesTraversed += neighbors.length;
1046
+
1047
+ for (const { neighborId, label } of neighbors) {
1048
+ const w = await effectiveWeightFn(nodeId, neighborId, label);
1049
+ const alt = /** @type {number} */ (dist.get(nodeId)) + w;
1050
+ const best = dist.has(neighborId) ? /** @type {number} */ (dist.get(neighborId)) : -Infinity;
1051
+
1052
+ if (alt > best || (alt === best && this._shouldUpdatePredecessor(prev, neighborId, nodeId))) {
1053
+ dist.set(neighborId, alt);
1054
+ prev.set(neighborId, nodeId);
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ if (!dist.has(goal)) {
1060
+ throw new TraversalError(`No path from ${start} to ${goal}`, {
1061
+ code: 'NO_PATH',
1062
+ context: { start, goal },
1063
+ });
1064
+ }
1065
+
1066
+ const path = this._reconstructPath(prev, start, goal);
1067
+ return { path, totalCost: /** @type {number} */ (dist.get(goal)), stats: this._stats(sorted.length, rs) };
1068
+ }
1069
+
1070
+ // ==== Private Helpers ====
1071
+
1072
+ /**
1073
+ * Builds an edge-weight-shaped resolver from a nodeWeightFn.
1074
+ *
1075
+ * Weight = cost to enter the `to` node. The start node's weight is NOT
1076
+ * counted (you're already there). Each node is resolved at most once via
1077
+ * a lazy memoization cache.
1078
+ *
1079
+ * @param {(nodeId: string) => number | Promise<number>} nodeWeightFn
1080
+ * @returns {(from: string, to: string, label: string) => number | Promise<number>}
1081
+ * @private
1082
+ */
1083
+ _buildNodeWeightResolver(nodeWeightFn) {
1084
+ /** @type {Map<string, number>} */
1085
+ const cache = new Map();
1086
+ return (_from, to, _label) => {
1087
+ const cached = cache.get(to);
1088
+ if (cached !== undefined) {
1089
+ return cached;
1090
+ }
1091
+ const result = nodeWeightFn(to);
1092
+ if (typeof result === 'number') {
1093
+ cache.set(to, result);
1094
+ return result;
1095
+ }
1096
+ // Async path: resolve promise, cache, and return
1097
+ return /** @type {Promise<number>} */ (result).then((v) => {
1098
+ cache.set(to, v);
1099
+ return v;
1100
+ });
1101
+ };
1102
+ }
1103
+
1104
+ /**
1105
+ * Resolves the effective weight function from weightFn / nodeWeightFn options.
1106
+ * Throws if both are provided.
1107
+ *
1108
+ * @param {((from: string, to: string, label: string) => number | Promise<number>) | undefined} weightFn
1109
+ * @param {((nodeId: string) => number | Promise<number>) | undefined} nodeWeightFn
1110
+ * @returns {(from: string, to: string, label: string) => number | Promise<number>}
1111
+ * @private
1112
+ */
1113
+ _resolveWeightFn(weightFn, nodeWeightFn) {
1114
+ if (weightFn && nodeWeightFn) {
1115
+ throw new TraversalError(
1116
+ 'Cannot provide both weightFn and nodeWeightFn — they are mutually exclusive',
1117
+ { code: 'E_WEIGHT_FN_CONFLICT', context: {} },
1118
+ );
1119
+ }
1120
+ if (nodeWeightFn) {
1121
+ return this._buildNodeWeightResolver(nodeWeightFn);
1122
+ }
1123
+ return weightFn ?? DEFAULT_WEIGHT_FN;
1124
+ }
1125
+
1126
+ /**
1127
+ * Validates that a start node exists in the provider.
1128
+ * Throws INVALID_START if the node is not alive.
1129
+ *
1130
+ * @param {string} nodeId
1131
+ * @returns {Promise<void>}
1132
+ * @private
1133
+ */
1134
+ async _validateStart(nodeId) {
1135
+ const exists = await this._provider.hasNode(nodeId);
1136
+ if (!exists) {
1137
+ throw new TraversalError(`Start node '${nodeId}' does not exist in the graph`, {
1138
+ code: 'INVALID_START',
1139
+ context: { nodeId },
1140
+ });
1141
+ }
1142
+ }
1143
+
1144
+ /**
1145
+ * Reconstructs a path by walking backward through a predecessor map.
1146
+ * @param {Map<string, string>} predMap
1147
+ * @param {string} start
1148
+ * @param {string} goal
1149
+ * @returns {string[]}
1150
+ * @private
1151
+ */
1152
+ _reconstructPath(predMap, start, goal) {
1153
+ const path = [goal];
1154
+ let current = goal;
1155
+ while (current !== start) {
1156
+ const pred = predMap.get(current);
1157
+ if (pred === undefined) { break; }
1158
+ path.push(pred);
1159
+ current = pred;
1160
+ }
1161
+ path.reverse();
1162
+ return path;
1163
+ }
1164
+
1165
+ /**
1166
+ * Reconstructs a bidirectional path from two predecessor maps.
1167
+ * @param {Map<string, string>} fwdPrev - Forward predecessor map
1168
+ * @param {Map<string, string>} bwdNext - Backward predecessor map (maps node → its successor toward goal)
1169
+ * @param {string} start
1170
+ * @param {string} goal
1171
+ * @param {string} meeting
1172
+ * @returns {string[]}
1173
+ * @private
1174
+ */
1175
+ _reconstructBiPath(fwdPrev, bwdNext, start, goal, meeting) {
1176
+ // Forward half: meeting → start (walk fwdPrev backward)
1177
+ const fwdHalf = [meeting];
1178
+ let cur = meeting;
1179
+ while (cur !== start && fwdPrev.has(cur)) {
1180
+ cur = /** @type {string} */ (fwdPrev.get(cur));
1181
+ fwdHalf.push(cur);
1182
+ }
1183
+ fwdHalf.reverse();
1184
+
1185
+ // Backward half: meeting → goal (walk bwdNext forward)
1186
+ cur = meeting;
1187
+ while (cur !== goal && bwdNext.has(cur)) {
1188
+ cur = /** @type {string} */ (bwdNext.get(cur));
1189
+ fwdHalf.push(cur);
1190
+ }
1191
+
1192
+ return fwdHalf;
1193
+ }
1194
+
1195
+ /**
1196
+ * Determines if a predecessor should be updated on equal cost.
1197
+ * Returns true when the candidate predecessor is lexicographically
1198
+ * smaller than the current predecessor (deterministic tie-break).
1199
+ *
1200
+ * @param {Map<string, string>} predMap
1201
+ * @param {string} nodeId
1202
+ * @param {string} candidatePred
1203
+ * @returns {boolean}
1204
+ * @private
1205
+ */
1206
+ _shouldUpdatePredecessor(predMap, nodeId, candidatePred) {
1207
+ const current = predMap.get(nodeId);
1208
+ if (current === undefined) { return true; }
1209
+ return candidatePred < current;
1210
+ }
1211
+
1212
+ /**
1213
+ * Inserts sorted items into a sorted array maintaining order.
1214
+ * Both input arrays must be sorted by lexTieBreaker.
1215
+ *
1216
+ * @param {string[]} target - Sorted array to insert into (mutated in place)
1217
+ * @param {string[]} items - Sorted items to insert
1218
+ * @private
1219
+ */
1220
+ _insertSorted(target, items) {
1221
+ // O(n+k) merge: build merged array from two sorted inputs
1222
+ const merged = [];
1223
+ let ti = 0;
1224
+ let ii = 0;
1225
+ while (ti < target.length && ii < items.length) {
1226
+ if (target[ti] <= items[ii]) {
1227
+ merged.push(target[ti++]);
1228
+ } else {
1229
+ merged.push(items[ii++]);
1230
+ }
1231
+ }
1232
+ while (ti < target.length) { merged.push(target[ti++]); }
1233
+ while (ii < items.length) { merged.push(items[ii++]); }
1234
+ target.length = 0;
1235
+ for (let i = 0; i < merged.length; i++) {
1236
+ target.push(merged[i]);
1237
+ }
1238
+ }
1239
+ }