From 89f280396d8dab9d454270598a29431b0e6050a5 Mon Sep 17 00:00:00 2001 From: Sebastian Lorenz Date: Sun, 16 Aug 2026 15:37:02 +0200 Subject: [PATCH] Graph: deduplicate directed neighbor queries --- .changeset/unique-directed-neighbors.md | 5 +++ packages/effect/src/Graph.ts | 40 +++++++++++++++++-- packages/effect/test/Graph.test.ts | 53 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 .changeset/unique-directed-neighbors.md diff --git a/.changeset/unique-directed-neighbors.md b/.changeset/unique-directed-neighbors.md new file mode 100644 index 00000000000..fd62b1615ed --- /dev/null +++ b/.changeset/unique-directed-neighbors.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Deduplicate directed neighbor-node queries while preserving first edge occurrence order. diff --git a/packages/effect/src/Graph.ts b/packages/effect/src/Graph.ts index 9bb7ffd2eec..ba6dae58147 100644 --- a/packages/effect/src/Graph.ts +++ b/packages/effect/src/Graph.ts @@ -2616,6 +2616,12 @@ const getDirectedNeighbors = ( return result } +const getUniqueDirectedNeighbors = ( + graph: Graph | MutableGraph, + nodeIndex: NodeIndex, + direction: Direction +): Array => Array.from(new Set(getDirectedNeighbors(graph, nodeIndex, direction))) + /** * Returns the neighboring node indices for a node. * @@ -2623,6 +2629,8 @@ const getDirectedNeighbors = ( * * For directed graphs, neighbors are the targets of outgoing edges. For * undirected graphs, neighbors are the other endpoints of incident edges. + * Each neighbor appears once in first edge occurrence order, including the + * queried node when it has a self-loop. * * **Example** (Getting outgoing neighbors) * @@ -2661,7 +2669,11 @@ export const neighbors: { return getUndirectedNeighbors(graph as any, nodeIndex) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "outgoing") + return getUniqueDirectedNeighbors( + graph as Graph | MutableGraph, + nodeIndex, + "outgoing" + ) }) /** @@ -2672,6 +2684,9 @@ export const neighbors: { * Use when you need the nodes reached by following outgoing edges from a node in * a directed graph. * + * Each node appears once in first outgoing edge occurrence order. A self-loop + * contributes the queried node once. + * * **Gotchas** * * Throws a `GraphError` when used with an undirected graph. @@ -2697,7 +2712,11 @@ export const successors: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get successors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "outgoing") + return getUniqueDirectedNeighbors( + graph as Graph | MutableGraph, + nodeIndex, + "outgoing" + ) }) /** @@ -2708,6 +2727,9 @@ export const successors: { * Use when you need the nodes that reach a node by following incoming edges in a * directed graph. * + * Each node appears once in first incoming edge occurrence order. A self-loop + * contributes the queried node once. + * * **Gotchas** * * Throws a `GraphError` when used with an undirected graph. @@ -2733,7 +2755,11 @@ export const predecessors: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get predecessors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, "incoming") + return getUniqueDirectedNeighbors( + graph as Graph | MutableGraph, + nodeIndex, + "incoming" + ) }) /** @@ -2743,6 +2769,8 @@ export const predecessors: { * * Use when maintaining existing code that already passes an explicit traversal * direction. New code should prefer `successors` or `predecessors`. + * Results contain each node once in first edge occurrence order, and a self-loop + * contributes the queried node once. * * **Gotchas** * @@ -2794,7 +2822,11 @@ export const neighborsDirected: { if (graph.type === "undirected") { throw new GraphError({ message: "Cannot get directed neighbors of undirected graph" }) } - return getDirectedNeighbors(graph as Graph | MutableGraph, nodeIndex, direction) + return getUniqueDirectedNeighbors( + graph as Graph | MutableGraph, + nodeIndex, + direction + ) }) // ============================================================================= diff --git a/packages/effect/test/Graph.test.ts b/packages/effect/test/Graph.test.ts index 99f095e4c67..3648620d410 100644 --- a/packages/effect/test/Graph.test.ts +++ b/packages/effect/test/Graph.test.ts @@ -2363,6 +2363,59 @@ describe("Graph", () => { expect(Graph.predecessors(graph, 1).sort()).toEqual([0, 2]) }) + it("should return unique directed neighbors in first edge order", () => { + const graph = Graph.directed((mutable) => { + for (let i = 0; i < 3; i++) { + Graph.addNode(mutable, String(i)) + } + Graph.addEdge(mutable, 0, 1, 5) + Graph.addEdge(mutable, 2, 0, 8) + Graph.addEdge(mutable, 0, 1, 1) + Graph.addEdge(mutable, 0, 0, 3) + Graph.addEdge(mutable, 1, 0, 4) + Graph.addEdge(mutable, 0, 2, 2) + Graph.addEdge(mutable, 2, 0, 7) + }) + const mutable = Graph.beginMutation(graph) + + const assertNeighbors = (input: typeof graph | typeof mutable) => { + assert.deepStrictEqual(Graph.neighbors(input, 0), [1, 0, 2]) + assert.deepStrictEqual(Graph.successors(input, 0), [1, 0, 2]) + assert.deepStrictEqual(Graph.predecessors(input, 0), [2, 0, 1]) + assert.deepStrictEqual(Graph.neighborsDirected(input, 0, "outgoing"), [1, 0, 2]) + assert.deepStrictEqual(Graph.neighborsDirected(input, 0, "incoming"), [2, 0, 1]) + } + + assertNeighbors(graph) + Array.from(Graph.bfs(graph, { start: [0] })) + assertNeighbors(graph) + assertNeighbors(mutable) + Array.from(Graph.bfs(mutable, { start: [0] })) + assertNeighbors(mutable) + }) + + it("should preserve parallel edges for topological and weighted algorithms", () => { + const graph = Graph.directed((mutable) => { + for (let i = 0; i < 3; i++) { + Graph.addNode(mutable, String(i)) + } + Graph.addEdge(mutable, 0, 1, 10) + Graph.addEdge(mutable, 0, 1, 1) + Graph.addEdge(mutable, 1, 2, 2) + }) + const mutable = Graph.beginMutation(graph) + const config = { source: 0, target: 2, cost: (weight: number) => weight } + + assert.deepStrictEqual(Array.from(Graph.indices(Graph.topo(graph))), [0, 1, 2]) + assert.deepStrictEqual(Array.from(Graph.indices(Graph.topo(mutable))), [0, 1, 2]) + assertSome(Graph.dijkstra(graph, config), { + path: [0, 1, 2], + distance: 3, + costs: [1, 2] + }) + assert.deepStrictEqual(Graph.dijkstra(mutable, config), Graph.dijkstra(graph, config)) + }) + it("should reject non-integer indexes consistently after caching traversal", () => { const graph = Graph.directed((mutable) => { const source = Graph.addNode(mutable, "source")