diff --git a/.changeset/fair-jobs-like.md b/.changeset/fair-jobs-like.md new file mode 100644 index 00000000000..0b637adb89e --- /dev/null +++ b/.changeset/fair-jobs-like.md @@ -0,0 +1,5 @@ +--- +"effect": minor +--- + +add a `maxDepth` option to `Graph` search configuration, allowing `dfs` and `bfs` traversals to limit returned nodes by depth from the configured start nodes. diff --git a/packages/effect/src/Graph.ts b/packages/effect/src/Graph.ts index 8c787d0f5b9..1392d2aae56 100644 --- a/packages/effect/src/Graph.ts +++ b/packages/effect/src/Graph.ts @@ -4649,6 +4649,7 @@ export const entries = (walker: Walker): Iterable<[T, N]> => export interface SearchConfig { readonly start?: Array readonly direction?: Direction + readonly maxDepth?: number } /** @@ -4658,7 +4659,8 @@ export interface SearchConfig { * **Details** * * If no start nodes are supplied, the iterator is empty. The `direction` option - * chooses whether to follow outgoing or incoming edges. Throws a `GraphError` + * chooses whether to follow outgoing or incoming edges. The `maxDepth` option + * limits traversal by edge distance from the start nodes. Throws a `GraphError` * if any configured start node does not exist. * * **Example** (Traversing depth-first) @@ -4702,6 +4704,7 @@ export const dfs: { ): NodeWalker => { const start = config.start ?? [] const direction = config.direction ?? "outgoing" + const maxDepth = config.maxDepth ?? Infinity // Validate that all start nodes exist for (const nodeIndex of start) { @@ -4712,12 +4715,12 @@ export const dfs: { return new Walker((f) => ({ [Symbol.iterator]: () => { - const stack = [...start] + const stack: Array = start.map((node) => [node, 0]) const discovered = new Set() const nextMapped = () => { while (stack.length > 0) { - const current = stack.pop()! + const [current, depth] = stack.pop()! if (discovered.has(current)) { continue @@ -4730,11 +4733,13 @@ export const dfs: { continue } - const neighbors = getTraversalNeighbors(graph, current, direction) - for (let i = neighbors.length - 1; i >= 0; i--) { - const neighbor = neighbors[i] - if (!discovered.has(neighbor)) { - stack.push(neighbor) + if (depth < maxDepth) { + const neighbors = getTraversalNeighbors(graph, current, direction) + for (let i = neighbors.length - 1; i >= 0; i--) { + const neighbor = neighbors[i] + if (!discovered.has(neighbor)) { + stack.push([neighbor, depth + 1]) + } } } @@ -4756,7 +4761,8 @@ export const dfs: { * **Details** * * If no start nodes are supplied, the iterator is empty. The `direction` option - * chooses whether to follow outgoing or incoming edges. Throws a `GraphError` + * chooses whether to follow outgoing or incoming edges. The `maxDepth` option + * limits traversal by edge distance from the start nodes. Throws a `GraphError` * if any configured start node does not exist. * * **Example** (Traversing breadth-first) @@ -4800,6 +4806,7 @@ export const bfs: { ): NodeWalker => { const start = config.start ?? [] const direction = config.direction ?? "outgoing" + const maxDepth = config.maxDepth ?? Infinity // Validate that all start nodes exist for (const nodeIndex of start) { @@ -4810,20 +4817,22 @@ export const bfs: { return new Walker((f) => ({ [Symbol.iterator]: () => { - const queue = [...start] + const queue: Array = start.map((node) => [node, 0]) const discovered = new Set() const nextMapped = () => { while (queue.length > 0) { - const current = queue.shift()! + const [current, depth] = queue.shift()! if (!discovered.has(current)) { discovered.add(current) - const neighbors = getTraversalNeighbors(graph, current, direction) - for (const neighbor of neighbors) { - if (!discovered.has(neighbor)) { - queue.push(neighbor) + if (depth < maxDepth) { + const neighbors = getTraversalNeighbors(graph, current, direction) + for (const neighbor of neighbors) { + if (!discovered.has(neighbor)) { + queue.push([neighbor, depth + 1]) + } } } diff --git a/packages/effect/test/Graph.test.ts b/packages/effect/test/Graph.test.ts index d41f9a936a6..d1bd2f2632d 100644 --- a/packages/effect/test/Graph.test.ts +++ b/packages/effect/test/Graph.test.ts @@ -3009,6 +3009,49 @@ describe("Graph", () => { expect(entries).toEqual([[0, "A"], [1, "B"], [2, "C"]]) }) + it("should limit DFS traversal by maxDepth", () => { + const graph = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, "A") + const b = Graph.addNode(mutable, "B") + const c = Graph.addNode(mutable, "C") + const d = Graph.addNode(mutable, "D") + Graph.addEdge(mutable, a, b, 1) + Graph.addEdge(mutable, b, c, 2) + Graph.addEdge(mutable, c, d, 3) + }) + + const dfsIterator = Graph.dfs(graph, { start: [0], maxDepth: 1 }) + + expect(Array.from(Graph.indices(dfsIterator))).toEqual([0, 1]) + }) + + it("should limit BFS traversal by maxDepth", () => { + const graph = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, "A") + const b = Graph.addNode(mutable, "B") + const c = Graph.addNode(mutable, "C") + const d = Graph.addNode(mutable, "D") + Graph.addEdge(mutable, a, b, 1) + Graph.addEdge(mutable, a, c, 2) + Graph.addEdge(mutable, b, d, 3) + }) + + const bfsIterator = Graph.bfs(graph, { start: [0], maxDepth: 1 }) + + expect(Array.from(Graph.indices(bfsIterator))).toEqual([0, 1, 2]) + }) + + it("should only include start nodes when maxDepth is zero", () => { + const graph = Graph.directed((mutable) => { + const a = Graph.addNode(mutable, "A") + const b = Graph.addNode(mutable, "B") + Graph.addEdge(mutable, a, b, 1) + }) + + expect(Array.from(Graph.indices(Graph.dfs(graph, { start: [0], maxDepth: 0 })))).toEqual([0]) + expect(Array.from(Graph.indices(Graph.bfs(graph, { start: [0], maxDepth: 0 })))).toEqual([0]) + }) + it("should provide values() method for Topo iterator", () => { const graph = Graph.directed((mutable) => { const a = Graph.addNode(mutable, "A")