Skip to content

Commit b16dad2

Browse files
fubhyeffect-bot
authored andcommitted
Backport graph fixes (#6276)
1 parent 3e59443 commit b16dad2

3 files changed

Lines changed: 336 additions & 77 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"effect": minor
3+
---
4+
5+
Add `Graph.successors` and `Graph.predecessors`, deprecate `Graph.neighborsDirected`, and fix graph algorithm edge cases around reversal, undirected edge queries, shortest-path weight validation, topological sort initials, and strongly connected components.

packages/effect/src/Graph.ts

Lines changed: 149 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,29 @@ export const mapEdges = <N, E, T extends Kind = "directed">(
843843
}
844844
}
845845

846+
/** @internal */
847+
const rebuildAdjacency = <N, E, T extends Kind = "directed">(
848+
mutable: MutableGraph<N, E, T>
849+
): void => {
850+
mutable.adjacency.clear()
851+
mutable.reverseAdjacency.clear()
852+
853+
for (const nodeIndex of mutable.nodes.keys()) {
854+
mutable.adjacency.set(nodeIndex, [])
855+
mutable.reverseAdjacency.set(nodeIndex, [])
856+
}
857+
858+
for (const [edgeIndex, edgeData] of mutable.edges) {
859+
mutable.adjacency.get(edgeData.source)!.push(edgeIndex)
860+
mutable.reverseAdjacency.get(edgeData.target)!.push(edgeIndex)
861+
862+
if (mutable.type === "undirected") {
863+
mutable.adjacency.get(edgeData.target)!.push(edgeIndex)
864+
mutable.reverseAdjacency.get(edgeData.source)!.push(edgeIndex)
865+
}
866+
}
867+
}
868+
846869
/**
847870
* Reverses all edge directions in a mutable graph by swapping source and target nodes.
848871
*
@@ -869,6 +892,10 @@ export const mapEdges = <N, E, T extends Kind = "directed">(
869892
export const reverse = <N, E, T extends Kind = "directed">(
870893
mutable: MutableGraph<N, E, T>
871894
): void => {
895+
if (mutable.type === "undirected") {
896+
return
897+
}
898+
872899
// Reverse all edges by swapping source and target
873900
for (const [index, edgeData] of mutable.edges) {
874901
mutable.edges.set(index, {
@@ -878,22 +905,7 @@ export const reverse = <N, E, T extends Kind = "directed">(
878905
})
879906
}
880907

881-
// Clear and rebuild adjacency lists with reversed directions
882-
mutable.adjacency.clear()
883-
mutable.reverseAdjacency.clear()
884-
885-
// Rebuild adjacency lists with reversed directions
886-
for (const [edgeIndex, edgeData] of mutable.edges) {
887-
// Add to forward adjacency (source -> target)
888-
const sourceEdges = mutable.adjacency.get(edgeData.source) || []
889-
sourceEdges.push(edgeIndex)
890-
mutable.adjacency.set(edgeData.source, sourceEdges)
891-
892-
// Add to reverse adjacency (target <- source)
893-
const targetEdges = mutable.reverseAdjacency.get(edgeData.target) || []
894-
targetEdges.push(edgeIndex)
895-
mutable.reverseAdjacency.set(edgeData.target, targetEdges)
896-
}
908+
rebuildAdjacency(mutable)
897909

898910
// Invalidate cycle flag since edge directions changed
899911
mutable.isAcyclic = Option.none()
@@ -1423,8 +1435,11 @@ export const hasEdge = <N, E, T extends Kind = "directed">(
14231435
// Check if any edge in the adjacency list connects to the target
14241436
for (const edgeIndex of adjacencyList) {
14251437
const edge = graph.edges.get(edgeIndex)
1426-
if (edge !== undefined && edge.target === target) {
1427-
return true
1438+
if (edge !== undefined) {
1439+
const neighbor = graph.type === "undirected" && edge.target === source ? edge.source : edge.target
1440+
if (neighbor === target) {
1441+
return true
1442+
}
14281443
}
14291444
}
14301445

@@ -1460,6 +1475,31 @@ export const edgeCount = <N, E, T extends Kind = "directed">(
14601475
graph: Graph<N, E, T> | MutableGraph<N, E, T>
14611476
): number => graph.edges.size
14621477

1478+
const getDirectedNeighbors = <N, E>(
1479+
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
1480+
nodeIndex: NodeIndex,
1481+
direction: Direction
1482+
): Array<NodeIndex> => {
1483+
const adjacencyMap = direction === "incoming"
1484+
? graph.reverseAdjacency
1485+
: graph.adjacency
1486+
1487+
const adjacencyList = adjacencyMap.get(nodeIndex)
1488+
if (adjacencyList === undefined) {
1489+
return []
1490+
}
1491+
1492+
const result: Array<NodeIndex> = []
1493+
for (const edgeIndex of adjacencyList) {
1494+
const edge = graph.edges.get(edgeIndex)
1495+
if (edge !== undefined) {
1496+
result.push(direction === "incoming" ? edge.source : edge.target)
1497+
}
1498+
}
1499+
1500+
return result
1501+
}
1502+
14631503
/**
14641504
* Returns the neighboring nodes (targets of outgoing edges) for a given node.
14651505
*
@@ -1498,24 +1538,47 @@ export const neighbors = <N, E, T extends Kind = "directed">(
14981538
return getUndirectedNeighbors(graph as any, nodeIndex)
14991539
}
15001540

1501-
const adjacencyList = graph.adjacency.get(nodeIndex)
1502-
if (adjacencyList === undefined) {
1503-
return []
1504-
}
1541+
return getDirectedNeighbors(graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, "outgoing")
1542+
}
15051543

1506-
const result: Array<NodeIndex> = []
1507-
for (const edgeIndex of adjacencyList) {
1508-
const edge = graph.edges.get(edgeIndex)
1509-
if (edge !== undefined) {
1510-
result.push(edge.target)
1511-
}
1544+
/**
1545+
* Returns the outgoing neighbor node indices for a node in a directed graph.
1546+
*
1547+
* Throws a `GraphError` when used with an undirected graph.
1548+
*
1549+
* @since 3.22.0
1550+
* @category queries
1551+
*/
1552+
export const successors = <N, E>(
1553+
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
1554+
nodeIndex: NodeIndex
1555+
): Array<NodeIndex> => {
1556+
if ((graph as Graph<N, E, Kind> | MutableGraph<N, E, Kind>).type === "undirected") {
1557+
throw new GraphError({ message: "Cannot get successors of undirected graph" })
15121558
}
1559+
return getDirectedNeighbors(graph, nodeIndex, "outgoing")
1560+
}
15131561

1514-
return result
1562+
/**
1563+
* Returns the incoming neighbor node indices for a node in a directed graph.
1564+
*
1565+
* Throws a `GraphError` when used with an undirected graph.
1566+
*
1567+
* @since 3.22.0
1568+
* @category queries
1569+
*/
1570+
export const predecessors = <N, E>(
1571+
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
1572+
nodeIndex: NodeIndex
1573+
): Array<NodeIndex> => {
1574+
if ((graph as Graph<N, E, Kind> | MutableGraph<N, E, Kind>).type === "undirected") {
1575+
throw new GraphError({ message: "Cannot get predecessors of undirected graph" })
1576+
}
1577+
return getDirectedNeighbors(graph, nodeIndex, "incoming")
15151578
}
15161579

15171580
/**
1518-
* Get neighbors of a node in a specific direction for bidirectional traversal.
1581+
* Get directed neighbors of a node in a specific direction.
15191582
*
15201583
* @example
15211584
* ```ts
@@ -1537,36 +1600,19 @@ export const neighbors = <N, E, T extends Kind = "directed">(
15371600
* const incoming = Graph.neighborsDirected(graph, nodeB, "incoming")
15381601
* ```
15391602
*
1603+
* @deprecated Use {@link successors} for outgoing neighbors or {@link predecessors} for incoming neighbors.
15401604
* @since 3.18.0
15411605
* @category queries
15421606
*/
1543-
export const neighborsDirected = <N, E, T extends Kind = "directed">(
1544-
graph: Graph<N, E, T> | MutableGraph<N, E, T>,
1607+
export const neighborsDirected = <N, E>(
1608+
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
15451609
nodeIndex: NodeIndex,
15461610
direction: Direction
15471611
): Array<NodeIndex> => {
1548-
const adjacencyMap = direction === "incoming"
1549-
? graph.reverseAdjacency
1550-
: graph.adjacency
1551-
1552-
const adjacencyList = adjacencyMap.get(nodeIndex)
1553-
if (adjacencyList === undefined) {
1554-
return []
1555-
}
1556-
1557-
const result: Array<NodeIndex> = []
1558-
for (const edgeIndex of adjacencyList) {
1559-
const edge = graph.edges.get(edgeIndex)
1560-
if (edge !== undefined) {
1561-
// For incoming direction, we want the source node instead of target
1562-
const neighborNode = direction === "incoming"
1563-
? edge.source
1564-
: edge.target
1565-
result.push(neighborNode)
1566-
}
1612+
if ((graph as Graph<N, E, Kind> | MutableGraph<N, E, Kind>).type === "undirected") {
1613+
throw new GraphError({ message: "Cannot get directed neighbors of undirected graph" })
15671614
}
1568-
1569-
return result
1615+
return getDirectedNeighbors(graph, nodeIndex, direction)
15701616
}
15711617

15721618
// =============================================================================
@@ -1961,7 +2007,11 @@ export const isAcyclic = <N, E, T extends Kind = "directed">(
19612007
recursionStack.add(node)
19622008

19632009
// Get neighbors for this node
1964-
const nodeNeighbors = Array.from(neighborsDirected(graph, node, "outgoing"))
2010+
const nodeNeighbors = getDirectedNeighbors(
2011+
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
2012+
node,
2013+
"outgoing"
2014+
)
19652015
stack[stack.length - 1] = [node, nodeNeighbors, 0, false]
19662016
continue
19672017
}
@@ -2112,7 +2162,7 @@ const getTraversalNeighbors = <N, E, T extends Kind>(
21122162
): Array<NodeIndex> =>
21132163
graph.type === "undirected"
21142164
? getUndirectedNeighbors(graph as any, nodeIndex)
2115-
: neighborsDirected(graph, nodeIndex, direction)
2165+
: getDirectedNeighbors(graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">, nodeIndex, direction)
21162166

21172167
const getTraversableNeighbor = <N, E, T extends Kind>(
21182168
graph: Graph<N, E, T> | MutableGraph<N, E, T>,
@@ -2202,9 +2252,13 @@ export const connectedComponents = <N, E>(
22022252
* @since 3.18.0
22032253
* @category algorithms
22042254
*/
2205-
export const stronglyConnectedComponents = <N, E, T extends Kind = "directed">(
2206-
graph: Graph<N, E, T> | MutableGraph<N, E, T>
2255+
export const stronglyConnectedComponents = <N, E>(
2256+
graph: Graph<N, E, "directed"> | MutableGraph<N, E, "directed">
22072257
): Array<Array<NodeIndex>> => {
2258+
if ((graph as Graph<N, E, Kind> | MutableGraph<N, E, Kind>).type === "undirected") {
2259+
throw new GraphError({ message: "Cannot find strongly connected components of undirected graph" })
2260+
}
2261+
22082262
const visited = new Set<NodeIndex>()
22092263
const finishOrder: Array<NodeIndex> = []
22102264
// Iterate directly over node keys
@@ -2230,7 +2284,7 @@ export const stronglyConnectedComponents = <N, E, T extends Kind = "directed">(
22302284
}
22312285

22322286
visited.add(node)
2233-
const nodeNeighborsList = neighbors(graph, node)
2287+
const nodeNeighborsList = getDirectedNeighbors(graph, node, "outgoing")
22342288
stack[stack.length - 1] = [node, nodeNeighborsList, 0, false]
22352289
continue
22362290
}
@@ -2348,6 +2402,22 @@ export interface BellmanFordConfig<E> {
23482402
cost: (edgeData: E) => number
23492403
}
23502404

2405+
const validateNonNegativeEdgeWeights = <N, E, T extends Kind = "directed">(
2406+
graph: Graph<N, E, T> | MutableGraph<N, E, T>,
2407+
cost: (edgeData: E) => number,
2408+
algorithm: string
2409+
): Map<EdgeIndex, number> => {
2410+
const edgeWeights = new Map<EdgeIndex, number>()
2411+
for (const [edgeIndex, edgeData] of graph.edges) {
2412+
const weight = cost(edgeData.data)
2413+
if (weight < 0 || Number.isNaN(weight)) {
2414+
throw new GraphError({ message: `${algorithm} requires non-negative edge weights` })
2415+
}
2416+
edgeWeights.set(edgeIndex, weight)
2417+
}
2418+
return edgeWeights
2419+
}
2420+
23512421
/**
23522422
* Find the shortest path between two nodes using Dijkstra's algorithm.
23532423
*
@@ -2390,6 +2460,8 @@ export const dijkstra = <N, E, T extends Kind = "directed">(
23902460
throw missingNode(target)
23912461
}
23922462

2463+
const edgeWeights = validateNonNegativeEdgeWeights(graph, cost, "Dijkstra's algorithm")
2464+
23932465
// Early return if source equals target
23942466
if (source === target) {
23952467
return Option.some({
@@ -2450,12 +2522,7 @@ export const dijkstra = <N, E, T extends Kind = "directed">(
24502522
const edge = graph.edges.get(edgeIndex)
24512523
if (edge !== undefined) {
24522524
const neighbor = getTraversableNeighbor(graph, currentNode, edge)
2453-
const weight = cost(edge.data)
2454-
2455-
// Validate non-negative weights
2456-
if (weight < 0) {
2457-
throw new Error(`Dijkstra's algorithm requires non-negative edge weights, found ${weight}`)
2458-
}
2525+
const weight = edgeWeights.get(edgeIndex)!
24592526

24602527
const newDistance = currentDistance + weight
24612528
const neighborDistance = distances.get(neighbor)!
@@ -2709,6 +2776,8 @@ export const astar = <N, E, T extends Kind = "directed">(
27092776
throw missingNode(target)
27102777
}
27112778

2779+
const edgeWeights = validateNonNegativeEdgeWeights(graph, cost, "A* algorithm")
2780+
27122781
// Early return if source equals target
27132782
if (source === target) {
27142783
return Option.some({
@@ -2784,12 +2853,7 @@ export const astar = <N, E, T extends Kind = "directed">(
27842853
const edge = graph.edges.get(edgeIndex)
27852854
if (edge !== undefined) {
27862855
const neighbor = getTraversableNeighbor(graph, currentNode, edge)
2787-
const weight = cost(edge.data)
2788-
2789-
// Validate non-negative weights
2790-
if (weight < 0) {
2791-
throw new Error(`A* algorithm requires non-negative edge weights, found ${weight}`)
2792-
}
2856+
const weight = edgeWeights.get(edgeIndex)!
27932857

27942858
const tentativeGScore = currentGScore + weight
27952859
const neighborGScore = gScore.get(neighbor)!
@@ -3468,6 +3532,7 @@ export const topo = <N, E, T extends Kind = "directed">(
34683532
[Symbol.iterator]: () => {
34693533
const inDegree = new Map<NodeIndex, number>()
34703534
const remaining = new Set<NodeIndex>()
3535+
const initialSet = new Set(initials)
34713536
const queue = [...initials]
34723537

34733538
// Initialize in-degree counts
@@ -3482,12 +3547,15 @@ export const topo = <N, E, T extends Kind = "directed">(
34823547
inDegree.set(edgeData.target, currentInDegree + 1)
34833548
}
34843549

3485-
// Add nodes with zero in-degree to queue if no initials provided
3486-
if (initials.length === 0) {
3487-
for (const [nodeIndex, degree] of inDegree) {
3488-
if (degree === 0) {
3489-
queue.push(nodeIndex)
3490-
}
3550+
for (const nodeIndex of initials) {
3551+
if (inDegree.get(nodeIndex)! !== 0) {
3552+
throw new GraphError({ message: `Initial node ${nodeIndex} has incoming edges` })
3553+
}
3554+
}
3555+
3556+
for (const [nodeIndex, degree] of inDegree) {
3557+
if (degree === 0 && !initialSet.has(nodeIndex)) {
3558+
queue.push(nodeIndex)
34913559
}
34923560
}
34933561

@@ -3499,7 +3567,11 @@ export const topo = <N, E, T extends Kind = "directed">(
34993567
remaining.delete(current)
35003568

35013569
// Process outgoing edges, reducing in-degree of targets
3502-
const neighbors = neighborsDirected(graph, current, "outgoing")
3570+
const neighbors = getDirectedNeighbors(
3571+
graph as Graph<N, E, "directed"> | MutableGraph<N, E, "directed">,
3572+
current,
3573+
"outgoing"
3574+
)
35033575
for (const neighbor of neighbors) {
35043576
if (remaining.has(neighbor)) {
35053577
const currentInDegree = inDegree.get(neighbor) || 0

0 commit comments

Comments
 (0)