@git-stunts/git-warp 11.5.1 → 12.0.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 (46) hide show
  1. package/README.md +142 -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 +49 -12
  9. package/package.json +2 -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 +138 -47
  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/TemporalQuery.js +128 -14
  30. package/src/domain/types/PatchDiff.js +90 -0
  31. package/src/domain/types/WarpTypesV2.js +4 -4
  32. package/src/domain/utils/MinHeap.js +45 -17
  33. package/src/domain/utils/canonicalCbor.js +36 -0
  34. package/src/domain/utils/fnv1a.js +20 -0
  35. package/src/domain/utils/roaring.js +14 -3
  36. package/src/domain/utils/shardKey.js +40 -0
  37. package/src/domain/utils/toBytes.js +17 -0
  38. package/src/domain/warp/_wiredMethods.d.ts +7 -1
  39. package/src/domain/warp/checkpoint.methods.js +21 -5
  40. package/src/domain/warp/materialize.methods.js +17 -5
  41. package/src/domain/warp/materializeAdvanced.methods.js +142 -3
  42. package/src/domain/warp/query.methods.js +78 -12
  43. package/src/infrastructure/adapters/CasSeekCacheAdapter.js +26 -5
  44. package/src/ports/BlobPort.js +1 -1
  45. package/src/ports/NeighborProviderPort.js +59 -0
  46. package/src/ports/SeekCachePort.js +4 -3
@@ -1,11 +1,18 @@
1
1
  /**
2
2
  * LogicalTraversal - Traversal utilities for the logical WARP graph.
3
3
  *
4
+ * **Deprecated**: delegates to GraphTraversal + AdjacencyNeighborProvider
5
+ * internally. The public API is unchanged for backward compatibility.
6
+ * New code should use GraphTraversal directly.
7
+ *
4
8
  * Provides deterministic BFS/DFS/shortestPath/connectedComponent over
5
9
  * the materialized logical graph (node/edge OR-Sets), not the Git DAG.
6
10
  */
7
11
 
8
12
  import TraversalError from '../errors/TraversalError.js';
13
+ import GraphTraversal from './GraphTraversal.js';
14
+ import AdjacencyNeighborProvider from './AdjacencyNeighborProvider.js';
15
+ import { orsetElements } from '../crdt/ORSet.js';
9
16
 
10
17
  const DEFAULT_MAX_DEPTH = 1000;
11
18
 
@@ -33,15 +40,15 @@ function assertDirection(direction) {
33
40
  * Normalizes a label filter into a Set for efficient lookup.
34
41
  *
35
42
  * Accepts a single label string, an array of labels, or undefined. Returns
36
- * a Set containing the label(s) or null if no filter is specified.
43
+ * a Set containing the label(s) or undefined if no filter is specified.
37
44
  *
38
45
  * @param {string|string[]|undefined} labelFilter - The label filter to normalize
39
- * @returns {Set<string>|null} A Set of labels for filtering, or null if no filter
46
+ * @returns {Set<string>|undefined} A Set of labels for filtering, or undefined if no filter
40
47
  * @throws {TraversalError} If labelFilter is neither a string, array, nor undefined
41
48
  */
42
49
  function normalizeLabelFilter(labelFilter) {
43
50
  if (labelFilter === undefined) {
44
- return null;
51
+ return undefined;
45
52
  }
46
53
  if (Array.isArray(labelFilter)) {
47
54
  return new Set(labelFilter);
@@ -55,73 +62,10 @@ function normalizeLabelFilter(labelFilter) {
55
62
  });
56
63
  }
57
64
 
58
- /**
59
- * Filters a list of neighbor edges by label.
60
- *
61
- * If no label set is provided (null), returns all neighbors unchanged.
62
- * If an empty label set is provided, returns an empty array.
63
- * Otherwise, returns only edges whose label is in the set.
64
- *
65
- * @param {Array<{neighborId: string, label: string}>} neighbors - The list of neighbor edges to filter
66
- * @param {Set<string>|null} labelSet - The set of allowed labels, or null to allow all
67
- * @returns {Array<{neighborId: string, label: string}>} The filtered list of neighbor edges
68
- */
69
- function filterByLabel(neighbors, labelSet) {
70
- if (!labelSet) {
71
- return neighbors;
72
- }
73
- if (labelSet.size === 0) {
74
- return [];
75
- }
76
- return neighbors.filter((edge) => labelSet.has(edge.label));
77
- }
78
-
79
- /**
80
- * Retrieves neighbors of a node based on direction and label filter.
81
- *
82
- * Returns outgoing neighbors for 'out', incoming neighbors for 'in', or
83
- * a merged and sorted list of both for 'both'. Results are filtered by
84
- * label if a label set is provided.
85
- *
86
- * For 'both' direction, neighbors are sorted first by neighborId, then by label,
87
- * ensuring deterministic traversal order.
88
- *
89
- * @param {Object} params - The neighbor lookup parameters
90
- * @param {string} params.nodeId - The node ID to get neighbors for
91
- * @param {'out'|'in'|'both'} params.direction - The edge direction to follow
92
- * @param {Object} params.adjacency - The adjacency structure from materialized graph
93
- * @param {Map<string, Array<{neighborId: string, label: string}>>} params.adjacency.outgoing - Outgoing edge map
94
- * @param {Map<string, Array<{neighborId: string, label: string}>>} params.adjacency.incoming - Incoming edge map
95
- * @param {Set<string>|null} params.labelSet - The set of allowed labels, or null to allow all
96
- * @returns {Array<{neighborId: string, label: string}>} The list of neighbor edges
97
- */
98
- function getNeighbors({ nodeId, direction, adjacency, labelSet }) {
99
- const outgoing = filterByLabel(adjacency.outgoing.get(nodeId) || [], labelSet);
100
- const incoming = filterByLabel(adjacency.incoming.get(nodeId) || [], labelSet);
101
-
102
- if (direction === 'out') {
103
- return outgoing;
104
- }
105
- if (direction === 'in') {
106
- return incoming;
107
- }
108
-
109
- const merged = outgoing.concat(incoming);
110
- merged.sort((a, b) => {
111
- if (a.neighborId !== b.neighborId) {
112
- return a.neighborId < b.neighborId ? -1 : 1;
113
- }
114
- return a.label < b.label ? -1 : a.label > b.label ? 1 : 0;
115
- });
116
- return merged;
117
- }
118
-
119
65
  /**
120
66
  * Deterministic graph traversal engine for the materialized WARP graph.
121
67
  *
122
- * Provides BFS, DFS, shortest path (Dijkstra/A*), topological sort, and
123
- * connected component algorithms over the logical node/edge OR-Sets.
124
- * All traversals produce deterministic results via sorted neighbor ordering.
68
+ * @deprecated Use GraphTraversal + AdjacencyNeighborProvider directly.
125
69
  */
126
70
  export default class LogicalTraversal {
127
71
  /**
@@ -134,26 +78,60 @@ export default class LogicalTraversal {
134
78
  }
135
79
 
136
80
  /**
137
- * Prepares common traversal state by materializing the graph and validating inputs.
81
+ * Prepares a GraphTraversal engine backed by the current adjacency.
82
+ * Does NOT validate any start node — use this for methods that accept
83
+ * multiple starts or no start at all (topologicalSort, commonAncestors).
138
84
  *
139
- * This private method is called by all traversal methods to ensure the graph is
140
- * materialized, the start node exists, and options are normalized.
85
+ * @private
86
+ * @param {Object} opts - The traversal options
87
+ * @param {'out'|'in'|'both'} [opts.dir] - Edge direction to follow
88
+ * @param {string|string[]} [opts.labelFilter] - Edge label(s) to include
89
+ * @param {number} [opts.maxDepth] - Maximum depth to traverse
90
+ * @returns {Promise<{engine: GraphTraversal, direction: 'out'|'in'|'both', options: {labels?: Set<string>}|undefined, depthLimit: number}>}
91
+ * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION)
92
+ * @throws {TraversalError} If the labelFilter is invalid (INVALID_LABEL_FILTER)
93
+ */
94
+ async _prepareEngine({ dir, labelFilter, maxDepth }) {
95
+ // Private access: _materializeGraph is a WarpGraph internal.
96
+ // This coupling will be removed when the LogicalTraversal facade is sunset
97
+ // and callers migrate to GraphTraversal + NeighborProvider directly.
98
+ const materialized = await /** @type {{ _materializeGraph: () => Promise<{state: {nodeAlive: import('../crdt/ORSet.js').ORSet}, adjacency: {outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}}> }} */ (this._graph)._materializeGraph();
99
+
100
+ const direction = assertDirection(dir);
101
+ const labelSet = normalizeLabelFilter(labelFilter);
102
+ const { adjacency, state } = materialized;
103
+ const depthLimit = maxDepth ?? DEFAULT_MAX_DEPTH;
104
+
105
+ const provider = new AdjacencyNeighborProvider({
106
+ outgoing: adjacency.outgoing,
107
+ incoming: adjacency.incoming,
108
+ aliveNodes: new Set(orsetElements(state.nodeAlive)),
109
+ });
110
+ const engine = new GraphTraversal({ provider });
111
+
112
+ /** @type {{labels?: Set<string>}|undefined} */
113
+ const options = labelSet ? { labels: labelSet } : undefined;
114
+
115
+ return { engine, direction, options, depthLimit };
116
+ }
117
+
118
+ /**
119
+ * Prepares a GraphTraversal engine and validates a single start node.
141
120
  *
142
121
  * @private
143
122
  * @param {string} start - The starting node ID for traversal
144
- * @param {Object} options - The traversal options to normalize
145
- * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
146
- * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
147
- * @param {number} [options.maxDepth] - Maximum depth to traverse
148
- * @returns {Promise<{dir: 'out'|'in'|'both', labelSet: Set<string>|null, adjacency: {outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}, depthLimit: number}>}
149
- * The normalized traversal parameters
123
+ * @param {Object} opts - The traversal options
124
+ * @param {'out'|'in'|'both'} [opts.dir] - Edge direction to follow
125
+ * @param {string|string[]} [opts.labelFilter] - Edge label(s) to include
126
+ * @param {number} [opts.maxDepth] - Maximum depth to traverse
127
+ * @returns {Promise<{engine: GraphTraversal, direction: 'out'|'in'|'both', options: {labels?: Set<string>}|undefined, depthLimit: number}>}
150
128
  * @throws {TraversalError} If the start node is not found (NODE_NOT_FOUND)
151
129
  * @throws {TraversalError} If the direction is invalid (INVALID_DIRECTION)
152
130
  * @throws {TraversalError} If the labelFilter is invalid (INVALID_LABEL_FILTER)
153
131
  */
154
- async _prepare(start, { dir, labelFilter, maxDepth }) {
155
- const materialized = await /** @type {{ _materializeGraph: () => Promise<{adjacency: {outgoing: Map<string, Array<{neighborId: string, label: string}>>, incoming: Map<string, Array<{neighborId: string, label: string}>>}}> }} */ (this._graph)._materializeGraph();
156
-
132
+ async _prepare(start, opts) {
133
+ const prepared = await this._prepareEngine(opts);
134
+ // Note: engine also validates via provider.hasNode — redundant but harmless.
157
135
  if (!(await this._graph.hasNode(start))) {
158
136
  throw new TraversalError(`Start node not found: ${start}`, {
159
137
  code: 'NODE_NOT_FOUND',
@@ -161,12 +139,7 @@ export default class LogicalTraversal {
161
139
  });
162
140
  }
163
141
 
164
- const resolvedDir = assertDirection(dir);
165
- const labelSet = normalizeLabelFilter(labelFilter);
166
- const { adjacency } = materialized;
167
- const depthLimit = maxDepth ?? DEFAULT_MAX_DEPTH;
168
-
169
- return { dir: resolvedDir, labelSet, adjacency, depthLimit };
142
+ return prepared;
170
143
  }
171
144
 
172
145
  /**
@@ -181,42 +154,15 @@ export default class LogicalTraversal {
181
154
  * @throws {TraversalError} If the start node is not found or direction is invalid
182
155
  */
183
156
  async bfs(start, options = {}) {
184
- const { dir, labelSet, adjacency, depthLimit } = await this._prepare(start, options);
185
- const visited = new Set();
186
- const queue = [{ nodeId: start, depth: 0 }];
187
- const result = [];
188
-
189
- while (queue.length > 0) {
190
- const current = /** @type {{nodeId: string, depth: number}} */ (queue.shift());
191
- if (visited.has(current.nodeId)) {
192
- continue;
193
- }
194
- if (current.depth > depthLimit) {
195
- continue;
196
- }
197
-
198
- visited.add(current.nodeId);
199
- result.push(current.nodeId);
200
-
201
- if (current.depth === depthLimit) {
202
- continue;
203
- }
204
-
205
- const neighbors = getNeighbors({
206
- nodeId: current.nodeId,
207
- direction: dir,
208
- adjacency,
209
- labelSet,
210
- });
211
-
212
- for (const edge of neighbors) {
213
- if (!visited.has(edge.neighborId)) {
214
- queue.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
215
- }
216
- }
217
- }
218
-
219
- return result;
157
+ const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options);
158
+ const { nodes } = await engine.bfs({
159
+ start,
160
+ direction,
161
+ options: opts,
162
+ maxDepth: depthLimit,
163
+ maxNodes: Infinity,
164
+ });
165
+ return await Promise.resolve(nodes);
220
166
  }
221
167
 
222
168
  /**
@@ -231,43 +177,15 @@ export default class LogicalTraversal {
231
177
  * @throws {TraversalError} If the start node is not found or direction is invalid
232
178
  */
233
179
  async dfs(start, options = {}) {
234
- const { dir, labelSet, adjacency, depthLimit } = await this._prepare(start, options);
235
- const visited = new Set();
236
- const stack = [{ nodeId: start, depth: 0 }];
237
- const result = [];
238
-
239
- while (stack.length > 0) {
240
- const current = /** @type {{nodeId: string, depth: number}} */ (stack.pop());
241
- if (visited.has(current.nodeId)) {
242
- continue;
243
- }
244
- if (current.depth > depthLimit) {
245
- continue;
246
- }
247
-
248
- visited.add(current.nodeId);
249
- result.push(current.nodeId);
250
-
251
- if (current.depth === depthLimit) {
252
- continue;
253
- }
254
-
255
- const neighbors = getNeighbors({
256
- nodeId: current.nodeId,
257
- direction: dir,
258
- adjacency,
259
- labelSet,
260
- });
261
-
262
- for (let i = neighbors.length - 1; i >= 0; i -= 1) {
263
- const edge = neighbors[i];
264
- if (!visited.has(edge.neighborId)) {
265
- stack.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
266
- }
267
- }
268
- }
269
-
270
- return result;
180
+ const { engine, direction, options: opts, depthLimit } = await this._prepare(start, options);
181
+ const { nodes } = await engine.dfs({
182
+ start,
183
+ direction,
184
+ options: opts,
185
+ maxDepth: depthLimit,
186
+ maxNodes: Infinity,
187
+ });
188
+ return await Promise.resolve(nodes);
271
189
  }
272
190
 
273
191
  /**
@@ -285,68 +203,269 @@ export default class LogicalTraversal {
285
203
  * @throws {TraversalError} If the start node is not found or direction is invalid
286
204
  */
287
205
  async shortestPath(from, to, options = {}) {
288
- const { dir, labelSet, adjacency, depthLimit } = await this._prepare(from, options);
206
+ const { engine, direction, options: opts, depthLimit } = await this._prepare(from, options);
207
+ const { found, path, length } = await engine.shortestPath({
208
+ start: from,
209
+ goal: to,
210
+ direction,
211
+ options: opts,
212
+ maxDepth: depthLimit,
213
+ maxNodes: Infinity,
214
+ });
215
+ return await Promise.resolve({ found, path, length });
216
+ }
289
217
 
290
- if (from === to) {
291
- return { found: true, path: [from], length: 0 };
292
- }
218
+ /**
219
+ * Connected component (undirected by default).
220
+ *
221
+ * @param {string} start - Starting node ID
222
+ * @param {Object} [options] - Traversal options
223
+ * @param {number} [options.maxDepth] - Maximum depth to traverse (default: 1000)
224
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
225
+ * @returns {Promise<string[]>} Node IDs in visit order
226
+ * @throws {TraversalError} If the start node is not found
227
+ */
228
+ async connectedComponent(start, options = {}) {
229
+ return await this.bfs(start, { ...options, dir: 'both' });
230
+ }
293
231
 
294
- const visited = new Set();
295
- const queue = [{ nodeId: from, depth: 0 }];
296
- const parent = new Map();
232
+ /**
233
+ * Reachability check BFS with early termination.
234
+ *
235
+ * Non-existent nodes are simply unreachable (no NODE_NOT_FOUND throw).
236
+ *
237
+ * @param {string} from - Source node ID
238
+ * @param {string} to - Target node ID
239
+ * @param {Object} [options] - Traversal options
240
+ * @param {number} [options.maxDepth] - Maximum search depth
241
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
242
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
243
+ * @param {AbortSignal} [options.signal] - Abort signal
244
+ * @returns {Promise<{reachable: boolean}>}
245
+ */
246
+ async isReachable(from, to, options = {}) {
247
+ const { engine, direction, options: opts, depthLimit } = await this._prepareEngine(options);
248
+ const { reachable } = await engine.isReachable({
249
+ start: from,
250
+ goal: to,
251
+ direction,
252
+ options: opts,
253
+ maxDepth: depthLimit,
254
+ maxNodes: Infinity,
255
+ signal: options.signal,
256
+ });
257
+ return { reachable };
258
+ }
297
259
 
298
- visited.add(from);
260
+ /**
261
+ * Weighted shortest path (Dijkstra's algorithm).
262
+ *
263
+ * @param {string} from - Source node ID
264
+ * @param {string} to - Target node ID
265
+ * @param {Object} [options] - Traversal options
266
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
267
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
268
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
269
+ * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
270
+ * @param {AbortSignal} [options.signal] - Abort signal
271
+ * @returns {Promise<{path: string[], totalCost: number}>}
272
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
273
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
274
+ */
275
+ async weightedShortestPath(from, to, options = {}) {
276
+ const { engine, direction, options: opts } = await this._prepare(from, options);
277
+ const { path, totalCost } = await engine.weightedShortestPath({
278
+ start: from,
279
+ goal: to,
280
+ direction,
281
+ options: opts,
282
+ weightFn: options.weightFn,
283
+ nodeWeightFn: options.nodeWeightFn,
284
+ maxNodes: Infinity,
285
+ signal: options.signal,
286
+ });
287
+ return { path, totalCost };
288
+ }
299
289
 
300
- while (queue.length > 0) {
301
- const current = /** @type {{nodeId: string, depth: number}} */ (queue.shift());
302
- if (current.depth >= depthLimit) {
303
- continue;
304
- }
290
+ /**
291
+ * A* search with heuristic guidance.
292
+ *
293
+ * @param {string} from - Source node ID
294
+ * @param {string} to - Target node ID
295
+ * @param {Object} [options] - Traversal options
296
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
297
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
298
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
299
+ * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
300
+ * @param {(nodeId: string, goalId: string) => number} [options.heuristicFn] - Heuristic function
301
+ * @param {AbortSignal} [options.signal] - Abort signal
302
+ * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>}
303
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
304
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
305
+ */
306
+ async aStarSearch(from, to, options = {}) {
307
+ const { engine, direction, options: opts } = await this._prepare(from, options);
308
+ const { path, totalCost, nodesExplored } = await engine.aStarSearch({
309
+ start: from,
310
+ goal: to,
311
+ direction,
312
+ options: opts,
313
+ weightFn: options.weightFn,
314
+ nodeWeightFn: options.nodeWeightFn,
315
+ heuristicFn: options.heuristicFn,
316
+ maxNodes: Infinity,
317
+ signal: options.signal,
318
+ });
319
+ return { path, totalCost, nodesExplored };
320
+ }
305
321
 
306
- const neighbors = getNeighbors({
307
- nodeId: current.nodeId,
308
- direction: dir,
309
- adjacency,
310
- labelSet,
322
+ /**
323
+ * Bidirectional A* search.
324
+ *
325
+ * Direction is fixed: forward uses 'out', backward uses 'in'.
326
+ *
327
+ * @param {string} from - Source node ID
328
+ * @param {string} to - Target node ID
329
+ * @param {Object} [options] - Traversal options
330
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
331
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
332
+ * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
333
+ * @param {(nodeId: string, goalId: string) => number} [options.forwardHeuristic] - Forward heuristic
334
+ * @param {(nodeId: string, goalId: string) => number} [options.backwardHeuristic] - Backward heuristic
335
+ * @param {AbortSignal} [options.signal] - Abort signal
336
+ * @returns {Promise<{path: string[], totalCost: number, nodesExplored: number}>}
337
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
338
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
339
+ */
340
+ async bidirectionalAStar(from, to, options = {}) {
341
+ const { engine, options: opts } = await this._prepareEngine(options);
342
+
343
+ if (!(await this._graph.hasNode(from))) {
344
+ throw new TraversalError(`Start node not found: ${from}`, {
345
+ code: 'NODE_NOT_FOUND',
346
+ context: { start: from },
311
347
  });
348
+ }
312
349
 
313
- for (const edge of neighbors) {
314
- if (visited.has(edge.neighborId)) {
315
- continue;
316
- }
317
- visited.add(edge.neighborId);
318
- parent.set(edge.neighborId, current.nodeId);
350
+ const { path, totalCost, nodesExplored } = await engine.bidirectionalAStar({
351
+ start: from,
352
+ goal: to,
353
+ options: opts,
354
+ weightFn: options.weightFn,
355
+ nodeWeightFn: options.nodeWeightFn,
356
+ forwardHeuristic: options.forwardHeuristic,
357
+ backwardHeuristic: options.backwardHeuristic,
358
+ maxNodes: Infinity,
359
+ signal: options.signal,
360
+ });
361
+ return { path, totalCost, nodesExplored };
362
+ }
319
363
 
320
- if (edge.neighborId === to) {
321
- const path = [to];
322
- /** @type {string|undefined} */
323
- let cursor = current.nodeId;
324
- while (cursor) {
325
- path.push(cursor);
326
- cursor = parent.get(cursor);
327
- }
328
- path.reverse();
329
- return { found: true, path, length: path.length - 1 };
330
- }
364
+ /**
365
+ * Topological sort (Kahn's algorithm).
366
+ *
367
+ * @param {string|string[]} start - One or more start nodes
368
+ * @param {Object} [options] - Traversal options
369
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
370
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
371
+ * @param {boolean} [options.throwOnCycle] - Whether to throw on cycle detection
372
+ * @param {AbortSignal} [options.signal] - Abort signal
373
+ * @returns {Promise<{sorted: string[], hasCycle: boolean}>}
374
+ * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if throwOnCycle and cycle found
375
+ * @throws {TraversalError} code 'NODE_NOT_FOUND' if a start node does not exist
376
+ */
377
+ async topologicalSort(start, options = {}) {
378
+ const { engine, direction, options: opts } = await this._prepareEngine(options);
379
+
380
+ // Validate each start node
381
+ const starts = Array.isArray(start) ? start : [start];
382
+ for (const s of starts) {
383
+ if (!(await this._graph.hasNode(s))) {
384
+ throw new TraversalError(`Start node not found: ${s}`, {
385
+ code: 'NODE_NOT_FOUND',
386
+ context: { start: s },
387
+ });
388
+ }
389
+ }
390
+
391
+ const { sorted, hasCycle } = await engine.topologicalSort({
392
+ start,
393
+ direction,
394
+ options: opts,
395
+ maxNodes: Infinity,
396
+ throwOnCycle: options.throwOnCycle,
397
+ signal: options.signal,
398
+ });
399
+ return { sorted, hasCycle };
400
+ }
331
401
 
332
- queue.push({ nodeId: edge.neighborId, depth: current.depth + 1 });
402
+ /**
403
+ * Common ancestors — multi-source ancestor intersection.
404
+ *
405
+ * Direction is fixed to 'in' (backward BFS).
406
+ *
407
+ * @param {string[]} nodes - Nodes to find common ancestors of
408
+ * @param {Object} [options] - Traversal options
409
+ * @param {number} [options.maxDepth] - Maximum search depth
410
+ * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
411
+ * @param {number} [options.maxResults] - Maximum number of results
412
+ * @param {AbortSignal} [options.signal] - Abort signal
413
+ * @returns {Promise<{ancestors: string[]}>}
414
+ * @throws {TraversalError} code 'NODE_NOT_FOUND' if a node does not exist
415
+ */
416
+ async commonAncestors(nodes, options = {}) {
417
+ const { engine, options: opts, depthLimit } = await this._prepareEngine(options);
418
+
419
+ // Validate each node
420
+ for (const n of nodes) {
421
+ if (!(await this._graph.hasNode(n))) {
422
+ throw new TraversalError(`Node not found: ${n}`, {
423
+ code: 'NODE_NOT_FOUND',
424
+ context: { node: n },
425
+ });
333
426
  }
334
427
  }
335
428
 
336
- return { found: false, path: [], length: -1 };
429
+ const { ancestors } = await engine.commonAncestors({
430
+ nodes,
431
+ options: opts,
432
+ maxDepth: depthLimit,
433
+ maxResults: options.maxResults,
434
+ signal: options.signal,
435
+ });
436
+ return { ancestors };
337
437
  }
338
438
 
339
439
  /**
340
- * Connected component (undirected by default).
440
+ * Weighted longest path via topological sort + DP.
341
441
  *
342
- * @param {string} start - Starting node ID
442
+ * Only valid on DAGs.
443
+ *
444
+ * @param {string} from - Source node ID
445
+ * @param {string} to - Target node ID
343
446
  * @param {Object} [options] - Traversal options
344
- * @param {number} [options.maxDepth] - Maximum depth to traverse (default: 1000)
447
+ * @param {'out'|'in'|'both'} [options.dir] - Edge direction to follow
345
448
  * @param {string|string[]} [options.labelFilter] - Edge label(s) to include
346
- * @returns {Promise<string[]>} Node IDs in visit order
347
- * @throws {TraversalError} If the start node is not found
449
+ * @param {(from: string, to: string, label: string) => number | Promise<number>} [options.weightFn] - Edge weight function
450
+ * @param {(nodeId: string) => number | Promise<number>} [options.nodeWeightFn] - Node weight function (mutually exclusive with weightFn)
451
+ * @param {AbortSignal} [options.signal] - Abort signal
452
+ * @returns {Promise<{path: string[], totalCost: number}>}
453
+ * @throws {TraversalError} code 'ERR_GRAPH_HAS_CYCLES' if graph has cycles
454
+ * @throws {TraversalError} code 'NO_PATH' if unreachable
455
+ * @throws {TraversalError} code 'E_WEIGHT_FN_CONFLICT' if both weightFn and nodeWeightFn provided
348
456
  */
349
- async connectedComponent(start, options = {}) {
350
- return await this.bfs(start, { ...options, dir: 'both' });
457
+ async weightedLongestPath(from, to, options = {}) {
458
+ const { engine, direction, options: opts } = await this._prepare(from, options);
459
+ const { path, totalCost } = await engine.weightedLongestPath({
460
+ start: from,
461
+ goal: to,
462
+ direction,
463
+ options: opts,
464
+ weightFn: options.weightFn,
465
+ nodeWeightFn: options.nodeWeightFn,
466
+ maxNodes: Infinity,
467
+ signal: options.signal,
468
+ });
469
+ return { path, totalCost };
351
470
  }
352
471
  }