@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.
- package/README.md +137 -10
- package/bin/cli/commands/registry.js +4 -0
- package/bin/cli/commands/reindex.js +41 -0
- package/bin/cli/commands/verify-index.js +59 -0
- package/bin/cli/infrastructure.js +7 -2
- package/bin/cli/schemas.js +19 -0
- package/bin/cli/types.js +2 -0
- package/index.d.ts +52 -15
- package/package.json +3 -2
- package/src/domain/WarpGraph.js +40 -0
- package/src/domain/errors/ShardIdOverflowError.js +28 -0
- package/src/domain/errors/index.js +1 -0
- package/src/domain/services/AdjacencyNeighborProvider.js +140 -0
- package/src/domain/services/BitmapNeighborProvider.js +178 -0
- package/src/domain/services/CheckpointMessageCodec.js +3 -3
- package/src/domain/services/CheckpointService.js +77 -12
- package/src/domain/services/GraphTraversal.js +1239 -0
- package/src/domain/services/IncrementalIndexUpdater.js +765 -0
- package/src/domain/services/JoinReducer.js +233 -5
- package/src/domain/services/LogicalBitmapIndexBuilder.js +323 -0
- package/src/domain/services/LogicalIndexBuildService.js +108 -0
- package/src/domain/services/LogicalIndexReader.js +315 -0
- package/src/domain/services/LogicalTraversal.js +321 -202
- package/src/domain/services/MaterializedViewService.js +379 -0
- package/src/domain/services/ObserverView.js +132 -69
- package/src/domain/services/PatchBuilderV2.js +3 -3
- package/src/domain/services/PropertyIndexBuilder.js +64 -0
- package/src/domain/services/PropertyIndexReader.js +111 -0
- package/src/domain/services/QueryBuilder.js +15 -44
- package/src/domain/services/TemporalQuery.js +128 -14
- package/src/domain/services/TranslationCost.js +8 -24
- package/src/domain/types/PatchDiff.js +90 -0
- package/src/domain/types/WarpTypesV2.js +4 -4
- package/src/domain/utils/MinHeap.js +45 -17
- package/src/domain/utils/canonicalCbor.js +36 -0
- package/src/domain/utils/fnv1a.js +20 -0
- package/src/domain/utils/matchGlob.js +51 -0
- package/src/domain/utils/roaring.js +14 -3
- package/src/domain/utils/shardKey.js +40 -0
- package/src/domain/utils/toBytes.js +17 -0
- package/src/domain/warp/_wiredMethods.d.ts +7 -1
- package/src/domain/warp/checkpoint.methods.js +21 -5
- package/src/domain/warp/materialize.methods.js +17 -5
- package/src/domain/warp/materializeAdvanced.methods.js +142 -3
- package/src/domain/warp/query.methods.js +83 -15
- package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
- package/src/ports/BlobPort.js +1 -1
- package/src/ports/NeighborProviderPort.js +59 -0
- 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
|
+
}
|