Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/unique-directed-neighbors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Deduplicate directed neighbor-node queries while preserving first edge occurrence order.
40 changes: 36 additions & 4 deletions packages/effect/src/Graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2616,13 +2616,21 @@ const getDirectedNeighbors = <N, E>(
return result
}

const getUniqueDirectedNeighbors = <N, E>(
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
nodeIndex: NodeIndex,
direction: Direction
): Array<NodeIndex> => Array.from(new Set(getDirectedNeighbors(graph, nodeIndex, direction)))

/**
* Returns the neighboring node indices for a node.
*
* **Details**
*
* 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)
*
Expand Down Expand Up @@ -2661,7 +2669,11 @@ export const neighbors: {
return getUndirectedNeighbors(graph as any, nodeIndex)
}

return getDirectedNeighbors(graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, "outgoing")
return getUniqueDirectedNeighbors(
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
nodeIndex,
"outgoing"
)
})

/**
Expand All @@ -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.
Expand All @@ -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<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, "outgoing")
return getUniqueDirectedNeighbors(
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
nodeIndex,
"outgoing"
)
})

/**
Expand All @@ -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.
Expand All @@ -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<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, "incoming")
return getUniqueDirectedNeighbors(
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
nodeIndex,
"incoming"
)
})

/**
Expand All @@ -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**
*
Expand Down Expand Up @@ -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<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, direction)
return getUniqueDirectedNeighbors(
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
nodeIndex,
direction
)
})

// =============================================================================
Expand Down
53 changes: 53 additions & 0 deletions packages/effect/test/Graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>((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<string, number>((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<string, number>((mutable) => {
const source = Graph.addNode(mutable, "source")
Expand Down
Loading