UNPKG

effect

Version:

The missing standard library for TypeScript, for writing production-grade software.

1,817 lines (1,682 loc) 107 kB
/** * @experimental * @since 3.18.0 */ import * as Data from "./Data.js" import * as Equal from "./Equal.js" import { dual } from "./Function.js" import * as Hash from "./Hash.js" import type { Inspectable } from "./Inspectable.js" import { format, NodeInspectSymbol } from "./Inspectable.js" import * as Option from "./Option.js" import type { Pipeable } from "./Pipeable.js" import { pipeArguments } from "./Pipeable.js" import type { Mutable } from "./Types.js" /** * Unique identifier for Graph instances. * * @since 3.18.0 * @category symbol */ export const TypeId: "~effect/Graph" = "~effect/Graph" as const /** * Type identifier for Graph instances. * * @since 3.18.0 * @category symbol */ export type TypeId = typeof TypeId /** * Node index for node identification using plain numbers. * * @since 3.18.0 * @category models */ export type NodeIndex = number /** * Edge index for edge identification using plain numbers. * * @since 3.18.0 * @category models */ export type EdgeIndex = number /** * Edge data containing source, target, and user data. * * @since 3.18.0 * @category models */ export class Edge<E> extends Data.Class<{ readonly source: NodeIndex readonly target: NodeIndex readonly data: E }> {} /** * Graph type for distinguishing directed and undirected graphs. * * @since 3.18.0 * @category models */ export type Kind = "directed" | "undirected" /** * Graph prototype interface. * * @since 3.18.0 * @category models */ export interface Proto<out N, out E> extends Iterable<readonly [NodeIndex, N]>, Equal.Equal, Pipeable, Inspectable { readonly [TypeId]: TypeId readonly nodes: Map<NodeIndex, N> readonly edges: Map<EdgeIndex, Edge<E>> readonly adjacency: Map<NodeIndex, Array<EdgeIndex>> readonly reverseAdjacency: Map<NodeIndex, Array<EdgeIndex>> nextNodeIndex: NodeIndex nextEdgeIndex: EdgeIndex isAcyclic: Option.Option<boolean> } /** * Immutable graph interface. * * @since 3.18.0 * @category models */ export interface Graph<out N, out E, T extends Kind = "directed"> extends Proto<N, E> { readonly type: T readonly mutable: false } /** * Mutable graph interface. * * @since 3.18.0 * @category models */ export interface MutableGraph<out N, out E, T extends Kind = "directed"> extends Proto<N, E> { readonly type: T readonly mutable: true } /** * Directed graph type alias. * * @since 3.18.0 * @category models */ export type DirectedGraph<N, E> = Graph<N, E, "directed"> /** * Undirected graph type alias. * * @since 3.18.0 * @category models */ export type UndirectedGraph<N, E> = Graph<N, E, "undirected"> /** * Mutable directed graph type alias. * * @since 3.18.0 * @category models */ export type MutableDirectedGraph<N, E> = MutableGraph<N, E, "directed"> /** * Mutable undirected graph type alias. * * @since 3.18.0 * @category models */ export type MutableUndirectedGraph<N, E> = MutableGraph<N, E, "undirected"> // ============================================================================= // Proto Objects // ============================================================================= /** @internal */ const ProtoGraph = { [TypeId]: TypeId, [Symbol.iterator](this: Graph<any, any>) { return this.nodes[Symbol.iterator]() }, [NodeInspectSymbol](this: Graph<any, any>) { return this.toJSON() }, [Equal.symbol](this: Graph<any, any>, that: Equal.Equal): boolean { if (isGraph(that)) { if ( this.nodes.size !== that.nodes.size || this.edges.size !== that.edges.size || this.type !== that.type ) { return false } // Compare nodes for (const [nodeIndex, nodeData] of this.nodes) { if (!that.nodes.has(nodeIndex)) { return false } const otherNodeData = that.nodes.get(nodeIndex)! if (!Equal.equals(nodeData, otherNodeData)) { return false } } // Compare edges for (const [edgeIndex, edgeData] of this.edges) { if (!that.edges.has(edgeIndex)) { return false } const otherEdge = that.edges.get(edgeIndex)! if (!Equal.equals(edgeData, otherEdge)) { return false } } return true } return false }, [Hash.symbol](this: Graph<any, any>): number { let hash = Hash.string("Graph") hash = hash ^ Hash.string(this.type) hash = hash ^ Hash.number(this.nodes.size) hash = hash ^ Hash.number(this.edges.size) for (const [nodeIndex, nodeData] of this.nodes) { hash = hash ^ (Hash.hash(nodeIndex) + Hash.hash(nodeData)) } for (const [edgeIndex, edgeData] of this.edges) { hash = hash ^ (Hash.hash(edgeIndex) + Hash.hash(edgeData)) } return hash }, toJSON(this: Graph<any, any>) { return { _id: "Graph", nodeCount: this.nodes.size, edgeCount: this.edges.size, type: this.type } }, toString(this: Graph<any, any>) { return format(this) }, pipe() { return pipeArguments(this, arguments) } } // ============================================================================= // Errors // ============================================================================= /** * Error thrown when a graph operation fails. * * @since 3.18.0 * @category errors */ export class GraphError extends Data.TaggedError("GraphError")<{ readonly message: string }> {} /** @internal */ const missingNode = (node: number) => new GraphError({ message: `Node ${node} does not exist` }) // ============================================================================= // Constructors // ============================================================================= /** @internal */ export const isGraph = (u: unknown): u is Graph<unknown, unknown> => typeof u === "object" && u !== null && TypeId in u /** * Creates a directed graph, optionally with initial mutations. * * @example * ```ts * import { Graph } from "effect" * * // Directed graph with initial nodes and edges * const graph = Graph.directed<string, string>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * Graph.addEdge(mutable, a, b, "A->B") * Graph.addEdge(mutable, b, c, "B->C") * }) * ``` * * @since 3.18.0 * @category constructors */ export const directed = <N, E>(mutate?: (mutable: MutableDirectedGraph<N, E>) => void): DirectedGraph<N, E> => { const graph: Mutable<DirectedGraph<N, E>> = Object.create(ProtoGraph) graph.type = "directed" graph.nodes = new Map() graph.edges = new Map() graph.adjacency = new Map() graph.reverseAdjacency = new Map() graph.nextNodeIndex = 0 graph.nextEdgeIndex = 0 graph.isAcyclic = Option.some(true) graph.mutable = false if (mutate) { const mutable = beginMutation(graph as DirectedGraph<N, E>) mutate(mutable as MutableDirectedGraph<N, E>) return endMutation(mutable) } return graph } /** * Creates an undirected graph, optionally with initial mutations. * * @example * ```ts * import { Graph } from "effect" * * // Undirected graph with initial nodes and edges * const graph = Graph.undirected<string, string>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * Graph.addEdge(mutable, a, b, "A-B") * Graph.addEdge(mutable, b, c, "B-C") * }) * ``` * * @since 3.18.0 * @category constructors */ export const undirected = <N, E>(mutate?: (mutable: MutableUndirectedGraph<N, E>) => void): UndirectedGraph<N, E> => { const graph: Mutable<UndirectedGraph<N, E>> = Object.create(ProtoGraph) graph.type = "undirected" graph.nodes = new Map() graph.edges = new Map() graph.adjacency = new Map() graph.reverseAdjacency = new Map() graph.nextNodeIndex = 0 graph.nextEdgeIndex = 0 graph.isAcyclic = Option.some(true) graph.mutable = false if (mutate) { const mutable = beginMutation(graph) mutate(mutable as MutableUndirectedGraph<N, E>) return endMutation(mutable) } return graph } // ============================================================================= // Scoped Mutable API // ============================================================================= /** * Creates a mutable scope for safe graph mutations by copying the data structure. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>() * const mutable = Graph.beginMutation(graph) * // Now mutable can be safely modified without affecting original graph * ``` * * @since 3.18.0 * @category mutations */ export const beginMutation = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> ): MutableGraph<N, E, T> => { // Copy adjacency maps with deep cloned arrays const adjacency = new Map<NodeIndex, Array<EdgeIndex>>() const reverseAdjacency = new Map<NodeIndex, Array<EdgeIndex>>() for (const [nodeIndex, edges] of graph.adjacency) { adjacency.set(nodeIndex, [...edges]) } for (const [nodeIndex, edges] of graph.reverseAdjacency) { reverseAdjacency.set(nodeIndex, [...edges]) } const mutable: Mutable<MutableGraph<N, E, T>> = Object.create(ProtoGraph) mutable.type = graph.type mutable.nodes = new Map(graph.nodes) mutable.edges = new Map(graph.edges) mutable.adjacency = adjacency mutable.reverseAdjacency = reverseAdjacency mutable.nextNodeIndex = graph.nextNodeIndex mutable.nextEdgeIndex = graph.nextEdgeIndex mutable.isAcyclic = graph.isAcyclic mutable.mutable = true return mutable } /** * Converts a mutable graph back to an immutable graph, ending the mutation scope. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>() * const mutable = Graph.beginMutation(graph) * // ... perform mutations on mutable ... * const newGraph = Graph.endMutation(mutable) * ``` * * @since 3.18.0 * @category mutations */ export const endMutation = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T> ): Graph<N, E, T> => { const graph: Mutable<Graph<N, E, T>> = Object.create(ProtoGraph) graph.type = mutable.type graph.nodes = new Map(mutable.nodes) graph.edges = new Map(mutable.edges) graph.adjacency = mutable.adjacency graph.reverseAdjacency = mutable.reverseAdjacency graph.nextNodeIndex = mutable.nextNodeIndex graph.nextEdgeIndex = mutable.nextEdgeIndex graph.isAcyclic = mutable.isAcyclic graph.mutable = false return graph } /** * Performs scoped mutations on a graph, automatically managing the mutation lifecycle. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>() * const newGraph = Graph.mutate(graph, (mutable) => { * // Safe mutations go here * // mutable gets automatically converted back to immutable * }) * ``` * * @since 3.18.0 * @category mutations */ export const mutate: { /** * Performs scoped mutations on a graph, automatically managing the mutation lifecycle. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>() * const newGraph = Graph.mutate(graph, (mutable) => { * // Safe mutations go here * // mutable gets automatically converted back to immutable * }) * ``` * * @since 3.18.0 * @category mutations */ <N, E, T extends Kind = "directed">(f: (mutable: MutableGraph<N, E, T>) => void): (graph: Graph<N, E, T>) => Graph<N, E, T> /** * Performs scoped mutations on a graph, automatically managing the mutation lifecycle. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>() * const newGraph = Graph.mutate(graph, (mutable) => { * // Safe mutations go here * // mutable gets automatically converted back to immutable * }) * ``` * * @since 3.18.0 * @category mutations */ <N, E, T extends Kind = "directed">(graph: Graph<N, E, T>, f: (mutable: MutableGraph<N, E, T>) => void): Graph<N, E, T> } = dual(2, <N, E, T extends Kind = "directed">( graph: Graph<N, E, T>, f: (mutable: MutableGraph<N, E, T>) => void ): Graph<N, E, T> => { const mutable = beginMutation(graph) f(mutable) return endMutation(mutable) }) // ============================================================================= // Basic Node Operations // ============================================================================= /** * Adds a new node to a mutable graph and returns its index. * * @example * ```ts * import { Graph } from "effect" * * const result = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * console.log(nodeA) // NodeIndex with value 0 * console.log(nodeB) // NodeIndex with value 1 * }) * ``` * * @since 3.18.0 * @category mutations */ export const addNode = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, data: N ): NodeIndex => { const nodeIndex = mutable.nextNodeIndex // Add node data mutable.nodes.set(nodeIndex, data) // Initialize empty adjacency lists mutable.adjacency.set(nodeIndex, []) mutable.reverseAdjacency.set(nodeIndex, []) // Update graph allocators mutable.nextNodeIndex = mutable.nextNodeIndex + 1 return nodeIndex } /** * Gets the data associated with a node index, if it exists. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * Graph.addNode(mutable, "Node A") * }) * * const nodeIndex = 0 * const nodeData = Graph.getNode(graph, nodeIndex) * * if (Option.isSome(nodeData)) { * console.log(nodeData.value) // "Node A" * } * ``` * * @since 3.18.0 * @category getters */ export const getNode = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, nodeIndex: NodeIndex ): Option.Option<N> => graph.nodes.has(nodeIndex) ? Option.some(graph.nodes.get(nodeIndex)!) : Option.none() /** * Checks if a node with the given index exists in the graph. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * Graph.addNode(mutable, "Node A") * }) * * const nodeIndex = 0 * const exists = Graph.hasNode(graph, nodeIndex) * console.log(exists) // true * * const nonExistentIndex = 999 * const notExists = Graph.hasNode(graph, nonExistentIndex) * console.log(notExists) // false * ``` * * @since 3.18.0 * @category getters */ export const hasNode = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, nodeIndex: NodeIndex ): boolean => graph.nodes.has(nodeIndex) /** * Returns the number of nodes in the graph. * * @example * ```ts * import { Graph } from "effect" * * const emptyGraph = Graph.directed<string, number>() * console.log(Graph.nodeCount(emptyGraph)) // 0 * * const graphWithNodes = Graph.mutate(emptyGraph, (mutable) => { * Graph.addNode(mutable, "Node A") * Graph.addNode(mutable, "Node B") * Graph.addNode(mutable, "Node C") * }) * * console.log(Graph.nodeCount(graphWithNodes)) // 3 * ``` * * @since 3.18.0 * @category getters */ export const nodeCount = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T> ): number => graph.nodes.size /** * Finds the first node that matches the given predicate. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * Graph.addNode(mutable, "Node A") * Graph.addNode(mutable, "Node B") * Graph.addNode(mutable, "Node C") * }) * * const result = Graph.findNode(graph, (data) => data.startsWith("Node B")) * console.log(result) // Option.some(1) * * const notFound = Graph.findNode(graph, (data) => data === "Node D") * console.log(notFound) // Option.none() * ``` * * @since 3.18.0 * @category getters */ export const findNode = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, predicate: (data: N) => boolean ): Option.Option<NodeIndex> => { for (const [index, data] of graph.nodes) { if (predicate(data)) { return Option.some(index) } } return Option.none() } /** * Finds all nodes that match the given predicate. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * Graph.addNode(mutable, "Start A") * Graph.addNode(mutable, "Node B") * Graph.addNode(mutable, "Start C") * }) * * const result = Graph.findNodes(graph, (data) => data.startsWith("Start")) * console.log(result) // [0, 2] * * const empty = Graph.findNodes(graph, (data) => data === "Not Found") * console.log(empty) // [] * ``` * * @since 3.18.0 * @category getters */ export const findNodes = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, predicate: (data: N) => boolean ): Array<NodeIndex> => { const results: Array<NodeIndex> = [] for (const [index, data] of graph.nodes) { if (predicate(data)) { results.push(index) } } return results } /** * Finds the first edge that matches the given predicate. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 10) * Graph.addEdge(mutable, nodeB, nodeC, 20) * }) * * const result = Graph.findEdge(graph, (data) => data > 15) * console.log(result) // Option.some(1) * * const notFound = Graph.findEdge(graph, (data) => data > 100) * console.log(notFound) // Option.none() * ``` * * @since 3.18.0 * @category getters */ export const findEdge = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, predicate: (data: E, source: NodeIndex, target: NodeIndex) => boolean ): Option.Option<EdgeIndex> => { for (const [edgeIndex, edgeData] of graph.edges) { if (predicate(edgeData.data, edgeData.source, edgeData.target)) { return Option.some(edgeIndex) } } return Option.none() } /** * Finds all edges that match the given predicate. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 10) * Graph.addEdge(mutable, nodeB, nodeC, 20) * Graph.addEdge(mutable, nodeC, nodeA, 30) * }) * * const result = Graph.findEdges(graph, (data) => data >= 20) * console.log(result) // [1, 2] * * const empty = Graph.findEdges(graph, (data) => data > 100) * console.log(empty) // [] * ``` * * @since 3.18.0 * @category getters */ export const findEdges = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, predicate: (data: E, source: NodeIndex, target: NodeIndex) => boolean ): Array<EdgeIndex> => { const results: Array<EdgeIndex> = [] for (const [edgeIndex, edgeData] of graph.edges) { if (predicate(edgeData.data, edgeData.source, edgeData.target)) { results.push(edgeIndex) } } return results } /** * Updates a single node's data by applying a transformation function. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * Graph.addNode(mutable, "Node A") * Graph.addNode(mutable, "Node B") * Graph.updateNode(mutable, 0, (data) => data.toUpperCase()) * }) * * const nodeData = Graph.getNode(graph, 0) * console.log(nodeData) // Option.some("NODE A") * ``` * * @since 3.18.0 * @category transformations */ export const updateNode = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, index: NodeIndex, f: (data: N) => N ): void => { if (!mutable.nodes.has(index)) { return } const currentData = mutable.nodes.get(index)! const newData = f(currentData) mutable.nodes.set(index, newData) } /** * Updates a single edge's data by applying a transformation function. * * @example * ```ts * import { Graph } from "effect" * * const result = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const edgeIndex = Graph.addEdge(mutable, nodeA, nodeB, 10) * Graph.updateEdge(mutable, edgeIndex, (data) => data * 2) * }) * * const edgeData = Graph.getEdge(result, 0) * console.log(edgeData) // Option.some({ source: 0, target: 1, data: 20 }) * ``` * * @since 3.18.0 * @category mutations */ export const updateEdge = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, edgeIndex: EdgeIndex, f: (data: E) => E ): void => { if (!mutable.edges.has(edgeIndex)) { return } const currentEdge = mutable.edges.get(edgeIndex)! const newData = f(currentEdge.data) mutable.edges.set(edgeIndex, { ...currentEdge, data: newData }) } /** * Creates a new graph with transformed node data using the provided mapping function. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * Graph.addNode(mutable, "node a") * Graph.addNode(mutable, "node b") * Graph.addNode(mutable, "node c") * Graph.mapNodes(mutable, (data) => data.toUpperCase()) * }) * * const nodeData = Graph.getNode(graph, 0) * console.log(nodeData) // Option.some("NODE A") * ``` * * @since 3.18.0 * @category transformations */ export const mapNodes = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, f: (data: N) => N ): void => { // Transform existing node data in place for (const [index, data] of mutable.nodes) { const newData = f(data) mutable.nodes.set(index, newData) } } /** * Transforms all edge data in a mutable graph using the provided mapping function. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * Graph.addEdge(mutable, a, b, 10) * Graph.addEdge(mutable, b, c, 20) * Graph.mapEdges(mutable, (data) => data * 2) * }) * * const edgeData = Graph.getEdge(graph, 0) * console.log(edgeData) // Option.some({ source: 0, target: 1, data: 20 }) * ``` * * @since 3.18.0 * @category transformations */ export const mapEdges = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, f: (data: E) => E ): void => { // Transform existing edge data in place for (const [index, edgeData] of mutable.edges) { const newData = f(edgeData.data) mutable.edges.set(index, { ...edgeData, data: newData }) } } /** * Reverses all edge directions in a mutable graph by swapping source and target nodes. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * Graph.addEdge(mutable, a, b, 1) // A -> B * Graph.addEdge(mutable, b, c, 2) // B -> C * Graph.reverse(mutable) // Now B -> A, C -> B * }) * * const edge0 = Graph.getEdge(graph, 0) * console.log(edge0) // Option.some({ source: 1, target: 0, data: 1 }) - B -> A * ``` * * @since 3.18.0 * @category transformations */ export const reverse = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T> ): void => { // Reverse all edges by swapping source and target for (const [index, edgeData] of mutable.edges) { mutable.edges.set(index, { source: edgeData.target, target: edgeData.source, data: edgeData.data }) } // Clear and rebuild adjacency lists with reversed directions mutable.adjacency.clear() mutable.reverseAdjacency.clear() // Rebuild adjacency lists with reversed directions for (const [edgeIndex, edgeData] of mutable.edges) { // Add to forward adjacency (source -> target) const sourceEdges = mutable.adjacency.get(edgeData.source) || [] sourceEdges.push(edgeIndex) mutable.adjacency.set(edgeData.source, sourceEdges) // Add to reverse adjacency (target <- source) const targetEdges = mutable.reverseAdjacency.get(edgeData.target) || [] targetEdges.push(edgeIndex) mutable.reverseAdjacency.set(edgeData.target, targetEdges) } // Invalidate cycle flag since edge directions changed mutable.isAcyclic = Option.none() } /** * Filters and optionally transforms nodes in a mutable graph using a predicate function. * Nodes that return Option.none are removed along with all their connected edges. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * const a = Graph.addNode(mutable, "active") * const b = Graph.addNode(mutable, "inactive") * const c = Graph.addNode(mutable, "active") * Graph.addEdge(mutable, a, b, 1) * Graph.addEdge(mutable, b, c, 2) * * // Keep only "active" nodes and transform to uppercase * Graph.filterMapNodes(mutable, (data) => * data === "active" ? Option.some(data.toUpperCase()) : Option.none() * ) * }) * * console.log(Graph.nodeCount(graph)) // 2 (only "active" nodes remain) * ``` * * @since 3.18.0 * @category transformations */ export const filterMapNodes = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, f: (data: N) => Option.Option<N> ): void => { const nodesToRemove: Array<NodeIndex> = [] // First pass: identify nodes to remove and transform data for nodes to keep for (const [index, data] of mutable.nodes) { const result = f(data) if (Option.isSome(result)) { // Transform node data mutable.nodes.set(index, result.value) } else { // Mark for removal nodesToRemove.push(index) } } // Second pass: remove filtered out nodes and their edges for (const nodeIndex of nodesToRemove) { removeNode(mutable, nodeIndex) } } /** * Filters and optionally transforms edges in a mutable graph using a predicate function. * Edges that return Option.none are removed from the graph. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * Graph.addEdge(mutable, a, b, 5) * Graph.addEdge(mutable, b, c, 15) * Graph.addEdge(mutable, c, a, 25) * * // Keep only edges with weight >= 10 and double their weight * Graph.filterMapEdges(mutable, (data) => * data >= 10 ? Option.some(data * 2) : Option.none() * ) * }) * * console.log(Graph.edgeCount(graph)) // 2 (edges with weight 5 removed) * ``` * * @since 3.18.0 * @category transformations */ export const filterMapEdges = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, f: (data: E) => Option.Option<E> ): void => { const edgesToRemove: Array<EdgeIndex> = [] // First pass: identify edges to remove and transform data for edges to keep for (const [index, edgeData] of mutable.edges) { const result = f(edgeData.data) if (Option.isSome(result)) { // Transform edge data mutable.edges.set(index, { ...edgeData, data: result.value }) } else { // Mark for removal edgesToRemove.push(index) } } // Second pass: remove filtered out edges for (const edgeIndex of edgesToRemove) { removeEdge(mutable, edgeIndex) } } /** * Filters nodes by removing those that don't match the predicate. * This function modifies the mutable graph in place. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * Graph.addNode(mutable, "active") * Graph.addNode(mutable, "inactive") * Graph.addNode(mutable, "pending") * Graph.addNode(mutable, "active") * * // Keep only "active" nodes * Graph.filterNodes(mutable, (data) => data === "active") * }) * * console.log(Graph.nodeCount(graph)) // 2 (only "active" nodes remain) * ``` * * @since 3.18.0 * @category transformations */ export const filterNodes = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, predicate: (data: N) => boolean ): void => { const nodesToRemove: Array<NodeIndex> = [] // Identify nodes to remove for (const [index, data] of mutable.nodes) { if (!predicate(data)) { nodesToRemove.push(index) } } // Remove filtered out nodes (this also removes connected edges) for (const nodeIndex of nodesToRemove) { removeNode(mutable, nodeIndex) } } /** * Filters edges by removing those that don't match the predicate. * This function modifies the mutable graph in place. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, number>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * const c = Graph.addNode(mutable, "C") * * Graph.addEdge(mutable, a, b, 5) * Graph.addEdge(mutable, b, c, 15) * Graph.addEdge(mutable, c, a, 25) * * // Keep only edges with weight >= 10 * Graph.filterEdges(mutable, (data) => data >= 10) * }) * * console.log(Graph.edgeCount(graph)) // 2 (edge with weight 5 removed) * ``` * * @since 3.18.0 * @category transformations */ export const filterEdges = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, predicate: (data: E) => boolean ): void => { const edgesToRemove: Array<EdgeIndex> = [] // Identify edges to remove for (const [index, edgeData] of mutable.edges) { if (!predicate(edgeData.data)) { edgesToRemove.push(index) } } // Remove filtered out edges for (const edgeIndex of edgesToRemove) { removeEdge(mutable, edgeIndex) } } // ============================================================================= // Cycle Flag Management (Internal) // ============================================================================= /** @internal */ const invalidateCycleFlagOnRemoval = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T> ): void => { // Only invalidate if the graph had cycles (removing edges/nodes cannot introduce cycles in acyclic graphs) // If already unknown (null) or acyclic (true), no need to change if (Option.isSome(mutable.isAcyclic) && mutable.isAcyclic.value === false) { mutable.isAcyclic = Option.none() } } /** @internal */ const invalidateCycleFlagOnAddition = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T> ): void => { // Only invalidate if the graph was acyclic (adding edges cannot remove cycles from cyclic graphs) // If already unknown (null) or cyclic (false), no need to change if (Option.isSome(mutable.isAcyclic) && mutable.isAcyclic.value === true) { mutable.isAcyclic = Option.none() } } // ============================================================================= // Edge Operations // ============================================================================= /** * Adds a new edge to a mutable graph and returns its index. * * @example * ```ts * import { Graph } from "effect" * * const result = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const edge = Graph.addEdge(mutable, nodeA, nodeB, 42) * console.log(edge) // EdgeIndex with value 0 * }) * ``` * * @since 3.18.0 * @category mutations */ export const addEdge = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, source: NodeIndex, target: NodeIndex, data: E ): EdgeIndex => { // Validate that both nodes exist if (!mutable.nodes.has(source)) { throw missingNode(source) } if (!mutable.nodes.has(target)) { throw missingNode(target) } const edgeIndex = mutable.nextEdgeIndex // Create edge data const edgeData = new Edge({ source, target, data }) mutable.edges.set(edgeIndex, edgeData) // Update adjacency lists const sourceAdjacency = mutable.adjacency.get(source) if (sourceAdjacency !== undefined) { sourceAdjacency.push(edgeIndex) } const targetReverseAdjacency = mutable.reverseAdjacency.get(target) if (targetReverseAdjacency !== undefined) { targetReverseAdjacency.push(edgeIndex) } // For undirected graphs, add reverse connections if (mutable.type === "undirected") { const targetAdjacency = mutable.adjacency.get(target) if (targetAdjacency !== undefined) { targetAdjacency.push(edgeIndex) } const sourceReverseAdjacency = mutable.reverseAdjacency.get(source) if (sourceReverseAdjacency !== undefined) { sourceReverseAdjacency.push(edgeIndex) } } // Update allocators mutable.nextEdgeIndex = mutable.nextEdgeIndex + 1 // Only invalidate cycle flag if the graph was acyclic // Adding edges cannot remove cycles from cyclic graphs invalidateCycleFlagOnAddition(mutable) return edgeIndex } /** * Removes a node and all its incident edges from a mutable graph. * * @example * ```ts * import { Graph } from "effect" * * const result = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * Graph.addEdge(mutable, nodeA, nodeB, 42) * * // Remove nodeA and all edges connected to it * Graph.removeNode(mutable, nodeA) * }) * ``` * * @since 3.18.0 * @category mutations */ export const removeNode = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, nodeIndex: NodeIndex ): void => { // Check if node exists if (!mutable.nodes.has(nodeIndex)) { return // Node doesn't exist, nothing to remove } // Collect all incident edges for removal const edgesToRemove: Array<EdgeIndex> = [] // Get outgoing edges const outgoingEdges = mutable.adjacency.get(nodeIndex) if (outgoingEdges !== undefined) { for (const edge of outgoingEdges) { edgesToRemove.push(edge) } } // Get incoming edges const incomingEdges = mutable.reverseAdjacency.get(nodeIndex) if (incomingEdges !== undefined) { for (const edge of incomingEdges) { edgesToRemove.push(edge) } } // Remove all incident edges for (const edgeIndex of edgesToRemove) { removeEdgeInternal(mutable, edgeIndex) } // Remove the node itself mutable.nodes.delete(nodeIndex) mutable.adjacency.delete(nodeIndex) mutable.reverseAdjacency.delete(nodeIndex) // Only invalidate cycle flag if the graph wasn't already known to be acyclic // Removing nodes cannot introduce cycles in an acyclic graph invalidateCycleFlagOnRemoval(mutable) } /** * Removes an edge from a mutable graph. * * @example * ```ts * import { Graph } from "effect" * * const result = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const edge = Graph.addEdge(mutable, nodeA, nodeB, 42) * * // Remove the edge * Graph.removeEdge(mutable, edge) * }) * ``` * * @since 3.18.0 * @category mutations */ export const removeEdge = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, edgeIndex: EdgeIndex ): void => { const wasRemoved = removeEdgeInternal(mutable, edgeIndex) // Only invalidate cycle flag if an edge was actually removed // and only if the graph wasn't already known to be acyclic if (wasRemoved) { invalidateCycleFlagOnRemoval(mutable) } } /** @internal */ const removeEdgeInternal = <N, E, T extends Kind = "directed">( mutable: MutableGraph<N, E, T>, edgeIndex: EdgeIndex ): boolean => { // Get edge data const edge = mutable.edges.get(edgeIndex) if (edge === undefined) { return false // Edge doesn't exist, no mutation occurred } const { source, target } = edge // Remove from adjacency lists const sourceAdjacency = mutable.adjacency.get(source) if (sourceAdjacency !== undefined) { const index = sourceAdjacency.indexOf(edgeIndex) if (index !== -1) { sourceAdjacency.splice(index, 1) } } const targetReverseAdjacency = mutable.reverseAdjacency.get(target) if (targetReverseAdjacency !== undefined) { const index = targetReverseAdjacency.indexOf(edgeIndex) if (index !== -1) { targetReverseAdjacency.splice(index, 1) } } // For undirected graphs, remove reverse connections if (mutable.type === "undirected") { const targetAdjacency = mutable.adjacency.get(target) if (targetAdjacency !== undefined) { const index = targetAdjacency.indexOf(edgeIndex) if (index !== -1) { targetAdjacency.splice(index, 1) } } const sourceReverseAdjacency = mutable.reverseAdjacency.get(source) if (sourceReverseAdjacency !== undefined) { const index = sourceReverseAdjacency.indexOf(edgeIndex) if (index !== -1) { sourceReverseAdjacency.splice(index, 1) } } } // Remove edge data mutable.edges.delete(edgeIndex) return true // Edge was successfully removed } // ============================================================================= // Edge Query Operations // ============================================================================= /** * Gets the edge data associated with an edge index, if it exists. * * @example * ```ts * import { Graph, Option } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * Graph.addEdge(mutable, nodeA, nodeB, 42) * }) * * const edgeIndex = 0 * const edgeData = Graph.getEdge(graph, edgeIndex) * * if (Option.isSome(edgeData)) { * console.log(edgeData.value.data) // 42 * console.log(edgeData.value.source) // NodeIndex(0) * console.log(edgeData.value.target) // NodeIndex(1) * } * ``` * * @since 3.18.0 * @category getters */ export const getEdge = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, edgeIndex: EdgeIndex ): Option.Option<Edge<E>> => graph.edges.has(edgeIndex) ? Option.some(graph.edges.get(edgeIndex)!) : Option.none() /** * Checks if an edge exists between two nodes in the graph. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 42) * }) * * const nodeA = 0 * const nodeB = 1 * const nodeC = 2 * * const hasAB = Graph.hasEdge(graph, nodeA, nodeB) * console.log(hasAB) // true * * const hasAC = Graph.hasEdge(graph, nodeA, nodeC) * console.log(hasAC) // false * ``` * * @since 3.18.0 * @category getters */ export const hasEdge = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, source: NodeIndex, target: NodeIndex ): boolean => { const adjacencyList = graph.adjacency.get(source) if (adjacencyList === undefined) { return false } // Check if any edge in the adjacency list connects to the target for (const edgeIndex of adjacencyList) { const edge = graph.edges.get(edgeIndex) if (edge !== undefined && edge.target === target) { return true } } return false } /** * Returns the number of edges in the graph. * * @example * ```ts * import { Graph } from "effect" * * const emptyGraph = Graph.directed<string, number>() * console.log(Graph.edgeCount(emptyGraph)) // 0 * * const graphWithEdges = Graph.mutate(emptyGraph, (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 1) * Graph.addEdge(mutable, nodeB, nodeC, 2) * Graph.addEdge(mutable, nodeC, nodeA, 3) * }) * * console.log(Graph.edgeCount(graphWithEdges)) // 3 * ``` * * @since 3.18.0 * @category getters */ export const edgeCount = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T> ): number => graph.edges.size /** * Returns the neighboring nodes (targets of outgoing edges) for a given node. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 1) * Graph.addEdge(mutable, nodeA, nodeC, 2) * }) * * const nodeA = 0 * const nodeB = 1 * const nodeC = 2 * * const neighborsA = Graph.neighbors(graph, nodeA) * console.log(neighborsA) // [NodeIndex(1), NodeIndex(2)] * * const neighborsB = Graph.neighbors(graph, nodeB) * console.log(neighborsB) // [] * ``` * * @since 3.18.0 * @category getters */ export const neighbors = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, nodeIndex: NodeIndex ): Array<NodeIndex> => { // For undirected graphs, use the specialized helper that returns the other endpoint if (graph.type === "undirected") { return getUndirectedNeighbors(graph as any, nodeIndex) } const adjacencyList = graph.adjacency.get(nodeIndex) if (adjacencyList === undefined) { return [] } const result: Array<NodeIndex> = [] for (const edgeIndex of adjacencyList) { const edge = graph.edges.get(edgeIndex) if (edge !== undefined) { result.push(edge.target) } } return result } /** * Get neighbors of a node in a specific direction for bidirectional traversal. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.directed<string, string>((mutable) => { * const a = Graph.addNode(mutable, "A") * const b = Graph.addNode(mutable, "B") * Graph.addEdge(mutable, a, b, "A->B") * }) * * const nodeA = 0 * const nodeB = 1 * * // Get outgoing neighbors (nodes that nodeA points to) * const outgoing = Graph.neighborsDirected(graph, nodeA, "outgoing") * * // Get incoming neighbors (nodes that point to nodeB) * const incoming = Graph.neighborsDirected(graph, nodeB, "incoming") * ``` * * @since 3.18.0 * @category queries */ export const neighborsDirected = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, nodeIndex: NodeIndex, direction: Direction ): Array<NodeIndex> => { const adjacencyMap = direction === "incoming" ? graph.reverseAdjacency : graph.adjacency const adjacencyList = adjacencyMap.get(nodeIndex) if (adjacencyList === undefined) { return [] } const result: Array<NodeIndex> = [] for (const edgeIndex of adjacencyList) { const edge = graph.edges.get(edgeIndex) if (edge !== undefined) { // For incoming direction, we want the source node instead of target const neighborNode = direction === "incoming" ? edge.source : edge.target result.push(neighborNode) } } return result } // ============================================================================= // GraphViz Export // ============================================================================= /** * Configuration options for GraphViz DOT format generation from graphs. * * @since 3.18.0 * @category models */ export interface GraphVizOptions<N, E> { readonly nodeLabel?: (data: N) => string readonly edgeLabel?: (data: E) => string readonly graphName?: string } /** * Exports a graph to GraphViz DOT format for visualization. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const nodeA = Graph.addNode(mutable, "Node A") * const nodeB = Graph.addNode(mutable, "Node B") * const nodeC = Graph.addNode(mutable, "Node C") * Graph.addEdge(mutable, nodeA, nodeB, 1) * Graph.addEdge(mutable, nodeB, nodeC, 2) * Graph.addEdge(mutable, nodeC, nodeA, 3) * }) * * const dot = Graph.toGraphViz(graph) * console.log(dot) * // digraph G { * // "0" [label="Node A"]; * // "1" [label="Node B"]; * // "2" [label="Node C"]; * // "0" -> "1" [label="1"]; * // "1" -> "2" [label="2"]; * // "2" -> "0" [label="3"]; * // } * ``` * * @since 3.18.0 * @category utils */ export const toGraphViz = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, options?: GraphVizOptions<N, E> ): string => { const { edgeLabel = (data: E) => String(data), graphName = "G", nodeLabel = (data: N) => String(data) } = options ?? {} const isDirected = graph.type === "directed" const graphType = isDirected ? "digraph" : "graph" const edgeOperator = isDirected ? "->" : "--" const lines: Array<string> = [] lines.push(`${graphType} ${graphName} {`) // Add nodes for (const [nodeIndex, nodeData] of graph.nodes) { const label = nodeLabel(nodeData).replace(/"/g, "\\\"") lines.push(` "${nodeIndex}" [label="${label}"];`) } // Add edges for (const [, edgeData] of graph.edges) { const label = edgeLabel(edgeData.data).replace(/"/g, "\\\"") lines.push(` "${edgeData.source}" ${edgeOperator} "${edgeData.target}" [label="${label}"];`) } lines.push("}") return lines.join("\n") } // ============================================================================= // Mermaid Export // ============================================================================= /** * Mermaid node shape types. * * @since 3.18.0 * @category models */ export type MermaidNodeShape = | "rectangle" | "rounded" | "circle" | "diamond" | "hexagon" | "stadium" | "subroutine" | "cylindrical" /** * Mermaid diagram direction types. * * @since 3.18.0 * @category models */ export type MermaidDirection = "TB" | "TD" | "BT" | "LR" | "RL" /** * Mermaid diagram type. * * @since 3.18.0 * @category models */ export type MermaidDiagramType = "flowchart" | "graph" /** * Configuration options for Mermaid diagram generation. * * @since 3.18.0 * @category models */ export interface MermaidOptions<N, E> { readonly nodeLabel?: (data: N) => string readonly edgeLabel?: (data: E) => string readonly diagramType?: MermaidDiagramType readonly direction?: MermaidDirection readonly nodeShape?: (data: N) => MermaidNodeShape } /** @internal */ const escapeMermaidLabel = (label: string): string => { // Escape special characters for Mermaid using HTML entity codes // According to: https://mermaid.js.org/syntax/flowchart.html#special-characters-that-break-syntax return label .replace(/#/g, "#35;") .replace(/"/g, "#quot;") .replace(/</g, "#lt;") .replace(/>/g, "#gt;") .replace(/&/g, "#amp;") .replace(/\[/g, "#91;") .replace(/\]/g, "#93;") .replace(/\{/g, "#123;") .replace(/\}/g, "#125;") .replace(/\(/g, "#40;") .replace(/\)/g, "#41;") .replace(/\|/g, "#124;") .replace(/\\/g, "#92;") .replace(/\n/g, "<br/>"); } /** @internal */ const formatMermaidNode = (nodeId: string, label: string, shape: MermaidNodeShape): string => { switch (shape) { case "rectangle": return `${nodeId}["${label}"]` case "rounded": return `${nodeId}("${label}")` case "circle": return `${nodeId}(("${label}"))` case "diamond": return `${nodeId}{"${label}"}` case "hexagon": return `${nodeId}{{"${label}"}}` case "stadium": return `${nodeId}(["${label}"])` case "subroutine": return `${nodeId}[["${label}"]]` case "cylindrical": return `${nodeId}[("${label}")]` } } /** * Exports a graph to Mermaid diagram format for visualization. * * @example * ```ts * import { Graph } from "effect" * * const graph = Graph.mutate(Graph.directed<string, number>(), (mutable) => { * const app = Graph.addNode(mutable, "App") * const db = Graph.addNode(mutable, "Database") * const cache = Graph.addNode(mutable, "Cache") * Graph.addEdge(mutable, app, db, 1) * Graph.addEdge(mutable, app, cache, 2) * }) * * const mermaid = Graph.toMermaid(graph) * console.log(mermaid) * // flowchart TD * // 0["App"] * // 1["Database"] * // 2["Cache"] * // 0 -->|"1"| 1 * // 0 -->|"2"| 2 * ``` * * @since 3.18.0 * @category utils */ export const toMermaid = <N, E, T extends Kind = "directed">( graph: Graph<N, E, T> | MutableGraph<N, E, T>, options?: MermaidOptions<N, E> ): string => { // Extract and validate options with defaults const { diagramType, direction = "TD", edgeLabel = (data: E) => String(data), nodeLabel = (data: N) => String(data), nodeShape = () => "rectangle" as const } = options ?? {} // Auto-detect diagram type if not specified const finalDiagramType = diagramType ??