diff --git a/.changeset/steady-graphs-harden.md b/.changeset/steady-graphs-harden.md new file mode 100644 index 0000000..1c578ac --- /dev/null +++ b/.changeset/steady-graphs-harden.md @@ -0,0 +1,5 @@ +--- +'@statelyai/graph': minor +--- + +Add unweighted-distance and explicit connectivity APIs. Harden weighted arithmetic, low-link traversal, transform callbacks, and bulk deletion. diff --git a/README.md b/README.md index 8eaaca8..46e6f56 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ const neighbors = getNeighbors(graph, 'a'); // adjacent nodes const roots = getSources(graph); // nodes with no incoming edges ``` -Batch operations (`addEntities`, `deleteEntities`, `updateEntities`) let you apply multiple changes at once. +Batch operations (`addEntities`, `deleteEntities`, `updateEntities`) let you apply multiple changes at once. `deleteEntities` accepts any iterable of IDs, collects it before mutation, and filters nodes/edges in one pass. Every mutable CRUD operation has an immutable counterpart: @@ -209,7 +209,7 @@ const parsed = GraphSchema.parse(unknownValue); -Includes traversal (BFS, DFS, preorder/postorder), pathfinding (shortest path, ordered shortest simple paths, simple paths, all-pairs shortest paths, A*, bidirectional Dijkstra), Eulerian paths/circuits, centrality/link analysis (degree, closeness, betweenness, PageRank, HITS, eigenvector, Katz), community detection (Louvain, label propagation, Girvan-Newman, greedy modularity, modularity scoring), flow & cuts (`getMaxFlow`, `getMinCut`), bipartite analysis (`isBipartite`, Hopcroft–Karp `getMaximumBipartiteMatching`), k-cores (`getCoreNumbers`, `getKCore`), graph coloring (`getGraphColoring`, `isValidColoring`), planarity testing (`isPlanar`), approximate TSP tours (`getTSPTour`) and Steiner trees (`getSteinerTree`), cycle detection, connected/strongly-connected components, bridges, articulation points, biconnected components, dominator trees, transitive reduction, isomorphism, topological sort, minimum spanning tree, and seeded graph generators (`createCompleteGraph`, `createGridGraph`, `createRandomGraph`, `createWattsStrogatzGraph`, `createBarabasiAlbertGraph`). Many algorithms have lazy generator variants (`gen*`) for early exit. See [docs/algorithms.md](./docs/algorithms.md) for the full reference. +Includes traversal (BFS, DFS, preorder/postorder), unweighted hop distances, pathfinding (shortest path, ordered shortest simple paths, simple paths, all-pairs shortest paths, A*, bidirectional Dijkstra), Eulerian paths/circuits, centrality/link analysis (degree, closeness, betweenness, PageRank, HITS, eigenvector, Katz), community detection (Louvain, label propagation, Girvan-Newman, greedy modularity, modularity scoring), flow & cuts (`getMaxFlow`, `getMinCut`), bipartite analysis (`isBipartite`, Hopcroft–Karp `getMaximumBipartiteMatching`), k-cores (`getCoreNumbers`, `getKCore`), graph coloring (`getGraphColoring`, `isValidColoring`), planarity testing (`isPlanar`), approximate TSP tours (`getTSPTour`) and Steiner trees (`getSteinerTree`), cycle detection, weak/strong connectivity and components, bridges, articulation points, biconnected components, dominator trees, transitive reduction, isomorphism, topological sort, minimum spanning tree, and seeded graph generators (`createCompleteGraph`, `createGridGraph`, `createRandomGraph`, `createWattsStrogatzGraph`, `createBarabasiAlbertGraph`). Many algorithms have lazy generator variants (`gen*`) for early exit. See [docs/algorithms.md](./docs/algorithms.md) for the full reference. Hot algorithm loops (centrality, components) run on an internal compressed-sparse-row snapshot — cached and invalidated transparently like the rest of the index — so they stay fast on large graphs without changing the plain-JSON model. Algorithm results are differential-tested against graphology on seeded random graphs. @@ -224,6 +224,8 @@ import { getCycles, getTopologicalSort, getConnectedComponents, + getUnweightedDistances, + isStronglyConnected, getMinimumSpanningTree, getPageRank, getLouvainCommunities, @@ -267,6 +269,8 @@ getShortestPath(graph, { }); // shortest path from any matching source getTopologicalSort(graph); // topological order (or null) getConnectedComponents(graph); // connected components +getUnweightedDistances(graph, 'a'); // reachable node IDs → hop counts +isStronglyConnected(graph); // every node reaches every other node getMinimumSpanningTree(graph, { getWeight: (e) => e.weight ?? 1 }); // MST getPageRank(graph); // link analysis scores getLouvainCommunities(graph); // community detection (Louvain) @@ -409,6 +413,7 @@ Beyond classic graph algorithms, the library also includes utilities for evolvin - `getDiff()`, `getPatches()`, `getPatchedGraph()` (immutable), and `updateGraphWithPatches()` (mutable) for graph change tracking - `genRandomWalk()`, `genWeightedRandomWalk()`, and coverage helpers for model-based testing and simulation - `getSubgraph()`, `getFilteredGraph()`, `getMappedGraph()`, `getNeighborhood()`, `getReversedGraph()`, and `getLineGraph()` for structural transforms +- Mapping/filtering transforms capture node and edge collections before callbacks run, so callback-driven structural mutation cannot produce a torn result - `getGraphUnion()`, `getGraphIntersection()`, `getGraphDifference()`, `getGraphSymmetricDifference()`, `getDisjointUnion()`, and `getGraphComplement()` for graph set operations Binary set operations match nodes and edges by stable ID, require matching graph modes, and retain graph metadata from the left operand. Union and intersection use right-side entity data when IDs conflict. Disjoint union keeps left IDs and deterministically remaps right-side collisions. diff --git a/docs/algorithms.md b/docs/algorithms.md index 809b8c6..dae434b 100644 --- a/docs/algorithms.md +++ b/docs/algorithms.md @@ -26,12 +26,15 @@ Active BFS, DFS, and postorder generators snapshot graph structure when iteratio |---|---|---|---| | `hasPath(graph, sourceId, targetId)` | Reachability | O(n + m) | BFS, mode-aware. `hasPath(g, a, a)` is `true`. | | `isConnected(graph)` | Single weak component? | O(n + m) | Empty graph is connected. | +| `isWeaklyConnected(graph)` | Single weak component? | O(n + m) | Explicit alias for weak connectivity; ignores edge direction. | +| `isStronglyConnected(graph)` | Every node reaches every other node? | O(n + m) | Empty graph is strongly connected; non-directed edges are mutual. | +| `getUnweightedDistances(graph, sourceId, opts?)` | Reachable node IDs → minimum hop count | O(n + m) | `direction` is outgoing (default), incoming, or undirected. Unknown source returns an empty map. | | `isTree(graph)` | Connected + acyclic + exactly `n − 1` edges | O(n + m) | Directed diamonds and parallel edges are not trees. Empty/single-node graphs are trees. | | `getConnectedComponents(graph)` | Weakly-connected components | O(n + m) | Every edge connects regardless of mode/direction. CSR-backed. | | `getStronglyConnectedComponents(graph)` | SCCs (Tarjan) | O(n + m) | Non-directed edges count as mutual reachability. Recursive — deep graphs may hit stack limits. | -| `getBridges(graph)` | Edges whose removal disconnects | O(n + m) | Treats the graph as undirected. Result sorted by id. | -| `getArticulationPoints(graph)` | Cut vertices | O(n + m) | Undirected semantics; sorted by id. | -| `getBiconnectedComponents(graph)` | Biconnected components (node arrays) | O(n + m) | Articulation points appear in multiple components. | +| `getBridges(graph)` | Edges whose removal disconnects | O(n + m) | Iterative, stack-safe low-link traversal over the undirected projection. Result sorted by id. | +| `getArticulationPoints(graph)` | Cut vertices | O(n + m) | Iterative undirected semantics; sorted by id. | +| `getBiconnectedComponents(graph)` | Biconnected components (node arrays) | O(n + m) | Handles parallel edges and self-loop singleton components; articulation points may repeat. | ## Cycles & DAG @@ -57,7 +60,7 @@ Active BFS, DFS, and postorder generators snapshot graph structure when iteratio | `genSimplePaths(graph, opts?)` / `getSimplePaths(...)` / `getSimplePath(...)` | All (or first) simple paths from `from` (optionally to `to`) | exponential (output-sensitive) | `from` accepts a node ID or predicate; predicates independently fan out from every matching node in graph order. DFS with backtracking; without `to`, every non-empty simple path is yielded. | | `getJoinedPath(headPath, tailPath)` | Concatenated `GraphPath` | O(steps) | Throws unless head ends where tail starts. | -**Negative-weight contract.** Dijkstra, A*, and the bidirectional/early-exit searches may legitimately finish without ever scanning a negative edge — so they assert "no negative weights" **up front**: O(1) via the CSR's cached `firstNegativeEdge` flag for the default weight, or one O(m) sweep when a custom `getWeight` is supplied. They throw with a pointer to `{ algorithm: 'bellman-ford' }` (O(n·m), handles negative edges; negative *cycles* still throw). +**Numeric contract.** Every shortest-path weight and intermediate cost must remain finite; `NaN`, infinities, and arithmetic overflow throw. Dijkstra, A*, and bidirectional/early-exit searches also assert "no negative weights" **up front**: O(1) via cached CSR flags for default weights, or one O(m) sweep for custom `getWeight`. Bellman–Ford handles finite negative edges; negative cycles still throw. ## Path sets & coverage @@ -93,7 +96,7 @@ whose output can itself be O(m²). | Function | Computes | Complexity | Notes | |---|---|---|---| -| `getMinimumSpanningTree(graph, opts?)` | New `Graph` containing the MST/forest edges | Prim (default) O(m log m); `algorithm: 'kruskal'` O(m log m) | Mode-aware: non-directed edges are candidates in both directions; directed edges only source→target (Prim). All nodes are kept; weight from `opts.getWeight ?? edge.weight ?? 1`. | +| `getMinimumSpanningTree(graph, opts?)` | New `Graph` containing the MST/forest edges | Prim (default) O(m log m); `algorithm: 'kruskal'` O(m log m) | Mode-aware: non-directed edges are candidates in both directions; directed edges only source→target (Prim). All nodes are kept; weights must be finite. | ## Centrality @@ -126,7 +129,7 @@ All community algorithms treat the graph as **undirected** regardless of mode. O | Function | Computes | Complexity | Notes | |---|---|---|---| -| `getMaxFlow(graph, { from, to, getCapacity? })` | `{ value, flows, cutEdges }` | Edmonds–Karp O(n·m²) | Capacity defaults to `edge.weight ?? 1`; negative capacity throws. Directed edges carry flow source→target only; non-directed edges become two independent opposite arcs each with full capacity. `flows` is net flow per edge id (positive = source→target). Self-loops carry nothing. | +| `getMaxFlow(graph, { from, to, getCapacity? })` | `{ value, flows, cutEdges }` | Edmonds–Karp O(n·m²) | Capacity defaults to `edge.weight ?? 1`; non-finite/negative capacity and total-flow overflow throw. Directed edges carry flow source→target only; non-directed edges become two independent opposite arcs each with full capacity. `flows` is net flow per edge id (positive = source→target). Self-loops carry nothing. | | `getMinCut(graph, { source, sink, getCapacity? })` | `{ value, cutEdges, partition }` | same solver | Max-flow-min-cut: `partition.source` = residual-reachable side (in `graph.nodes` order); `Σ cap(cutEdges) === value`. | ## Bipartite @@ -179,7 +182,7 @@ Walk generators yield `GraphStep`s lazily and honor effective edge modes (non-di ## Performance notes -- **CSR snapshot.** Hot loops (BFS/DFS, components, shortest paths, centrality, cores, bipartite) run on a compressed-sparse-row snapshot of the graph's *traversable arcs* (`src/algorithms/csr.ts`): flat `Int32Array`s addressed by node position, so traversal pays no string hashing or Map lookups. Directed edges contribute one arc; non-directed edges contribute both. It also caches a `firstNegativeEdge` flag for O(1) negative-weight assertions. +- **CSR snapshot.** Hot loops (BFS/DFS, components, shortest paths, centrality, cores, bipartite) run on a compressed-sparse-row snapshot of the graph's *traversable arcs* (`src/algorithms/csr.ts`): flat `Int32Array`s addressed by node position, so traversal pays no string hashing or Map lookups. Directed edges contribute one arc; non-directed edges contribute both. It caches invalid/negative default-weight flags for O(1) assertions. - **Auto-invalidation.** The CSR is cached per `GraphIndex` and revalidated against the index `version` and `graph.mode` — O(1) per access. Replacing `nodes`/`edges` arrays, length changes, and all `add*`/`delete*`/`update*` API mutations are detected automatically. - **`invalidateIndex(graph)`** is only needed after *direct in-place field mutation* (e.g. `edge.sourceId = 'x'`, `node.parentId = 'y'`), which is not O(1)-detectable. The same staleness contract covers both the index and the CSR. - Algorithm results are differential-tested against graphology on seeded random graphs; see [./benchmarks.md](./benchmarks.md) for throughput comparisons. diff --git a/src/algorithms.ts b/src/algorithms.ts index f53cffa..2f20a91 100644 --- a/src/algorithms.ts +++ b/src/algorithms.ts @@ -6,9 +6,12 @@ export { dfs, isAcyclic, getConnectedComponents, + getUnweightedDistances, getTopologicalSort, hasPath, isConnected, + isWeaklyConnected, + isStronglyConnected, isTree, } from './algorithms/traversal'; diff --git a/src/algorithms/connectivity.ts b/src/algorithms/connectivity.ts index 245a865..22efef7 100644 --- a/src/algorithms/connectivity.ts +++ b/src/algorithms/connectivity.ts @@ -1,212 +1,165 @@ import type { Graph, GraphEdge, GraphNode } from '../types'; -import { getIndex } from '../indexing'; - -interface TraversalState { - time: number; - disc: Map; - low: Map; - edgeStack: string[]; - bridges: Set; - articulationPoints: Set; - components: Array>; - nodeById: Map>; - edgeById: Map>; -} - -function getUndirectedNeighbors( - graph: Graph, - nodeId: string, -): Array<{ nodeId: string; edgeId: string }> { - const idx = getIndex(graph); - const neighbors: Array<{ nodeId: string; edgeId: string }> = []; - - for (const edgeId of idx.outEdges.get(nodeId) ?? []) { - const edgeIndex = idx.edgeById.get(edgeId); - if (edgeIndex !== undefined) { - neighbors.push({ - nodeId: graph.edges[edgeIndex].targetId, - edgeId, - }); - } - } - for (const edgeId of idx.inEdges.get(nodeId) ?? []) { - const edgeIndex = idx.edgeById.get(edgeId); - if (edgeIndex !== undefined) { - neighbors.push({ - nodeId: graph.edges[edgeIndex].sourceId, - edgeId, - }); - } - } - - return neighbors; +interface Neighbor { + nodeId: string; + edgeId: string; } -function popComponentUntil( - state: TraversalState, - stopEdgeId: string, -): void { - const nodeIds = new Set(); - - while (state.edgeStack.length > 0) { - const edgeId = state.edgeStack.pop()!; - const edge = state.edgeById.get(edgeId); - if (edge) { - nodeIds.add(edge.sourceId); - nodeIds.add(edge.targetId); - } - if (edgeId === stopEdgeId) { - break; - } - } +interface Frame { + nodeId: string; + parentId: string | null; + parentEdgeId: string | null; + nextNeighbor: number; + childCount: number; +} - if (nodeIds.size > 0) { - state.components.push(nodeIds); - } +interface ConnectivityResult { + bridges: GraphEdge[]; + articulationPoints: GraphNode[]; + biconnectedComponents: GraphNode[][]; } -function finalizeRemainingComponent(state: TraversalState): void { - if (state.edgeStack.length === 0) { - return; +/** Iterative Tarjan low-link analysis with multigraph and self-loop support. */ +function analyzeConnectivity(graph: Graph): ConnectivityResult { + const nodeById = new Map(graph.nodes.map((node) => [node.id, node])); + const edgeById = new Map(graph.edges.map((edge) => [edge.id, edge])); + const adjacency = new Map(); + for (const node of graph.nodes) adjacency.set(node.id, []); + for (const edge of graph.edges) { + if (!adjacency.has(edge.sourceId) || !adjacency.has(edge.targetId)) continue; + adjacency.get(edge.sourceId)!.push({ nodeId: edge.targetId, edgeId: edge.id }); + if (edge.sourceId !== edge.targetId) { + adjacency.get(edge.targetId)!.push({ nodeId: edge.sourceId, edgeId: edge.id }); + } } - const nodeIds = new Set(); - while (state.edgeStack.length > 0) { - const edge = state.edgeById.get(state.edgeStack.pop()!); - if (edge) { - nodeIds.add(edge.sourceId); - nodeIds.add(edge.targetId); + const discovered = new Map(); + const low = new Map(); + const bridgeIds = new Set(); + const articulationIds = new Set(); + const edgeStack: string[] = []; + const componentIds: Set[] = []; + const selfLoopNodes = new Set(); + let time = 0; + + const popComponent = (stopEdgeId?: string): void => { + const nodes = new Set(); + while (edgeStack.length > 0) { + const edgeId = edgeStack.pop()!; + const edge = edgeById.get(edgeId); + if (edge) { + nodes.add(edge.sourceId); + nodes.add(edge.targetId); + } + if (edgeId === stopEdgeId) break; } - } + if (nodes.size > 0) componentIds.push(nodes); + }; - if (nodeIds.size > 0) { - state.components.push(nodeIds); - } -} + for (const root of graph.nodes) { + if (discovered.has(root.id)) continue; + discovered.set(root.id, ++time); + low.set(root.id, time); + const stack: Frame[] = [{ + nodeId: root.id, + parentId: null, + parentEdgeId: null, + nextNeighbor: 0, + childCount: 0, + }]; + + while (stack.length > 0) { + const frame = stack[stack.length - 1]; + const neighbors = adjacency.get(frame.nodeId)!; + if (frame.nextNeighbor < neighbors.length) { + const neighbor = neighbors[frame.nextNeighbor++]; + if (neighbor.edgeId === frame.parentEdgeId) continue; + + if (neighbor.nodeId === frame.nodeId) { + if (!selfLoopNodes.has(frame.nodeId)) { + selfLoopNodes.add(frame.nodeId); + componentIds.push(new Set([frame.nodeId])); + } + continue; + } -function traverseConnectivity( - graph: Graph, - nodeId: string, - parentEdgeId: string | null, - state: TraversalState, -): void { - state.time += 1; - state.disc.set(nodeId, state.time); - state.low.set(nodeId, state.time); - - let childCount = 0; - - for (const neighbor of getUndirectedNeighbors(graph, nodeId)) { - if (neighbor.edgeId === parentEdgeId) continue; - - if (!state.disc.has(neighbor.nodeId)) { - childCount += 1; - state.edgeStack.push(neighbor.edgeId); - traverseConnectivity(graph, neighbor.nodeId, neighbor.edgeId, state); - state.low.set( - nodeId, - Math.min(state.low.get(nodeId)!, state.low.get(neighbor.nodeId)!), - ); + if (!discovered.has(neighbor.nodeId)) { + frame.childCount++; + edgeStack.push(neighbor.edgeId); + discovered.set(neighbor.nodeId, ++time); + low.set(neighbor.nodeId, time); + stack.push({ + nodeId: neighbor.nodeId, + parentId: frame.nodeId, + parentEdgeId: neighbor.edgeId, + nextNeighbor: 0, + childCount: 0, + }); + continue; + } - if (state.low.get(neighbor.nodeId)! > state.disc.get(nodeId)!) { - state.bridges.add(neighbor.edgeId); + if (discovered.get(neighbor.nodeId)! < discovered.get(frame.nodeId)!) { + edgeStack.push(neighbor.edgeId); + low.set( + frame.nodeId, + Math.min(low.get(frame.nodeId)!, discovered.get(neighbor.nodeId)!), + ); + } + continue; } - if (state.low.get(neighbor.nodeId)! >= state.disc.get(nodeId)!) { - if (parentEdgeId !== null) { - state.articulationPoints.add(nodeId); - } - // Pop for the root's children too, so each child subtree forms its - // own biconnected component instead of being lumped together. - popComponentUntil(state, neighbor.edgeId); + stack.pop(); + if (frame.parentId === null) { + if (frame.childCount > 1) articulationIds.add(frame.nodeId); + popComponent(); + continue; } - } else if (state.disc.get(neighbor.nodeId)! < state.disc.get(nodeId)!) { - state.edgeStack.push(neighbor.edgeId); - state.low.set( - nodeId, - Math.min(state.low.get(nodeId)!, state.disc.get(neighbor.nodeId)!), + + low.set( + frame.parentId, + Math.min(low.get(frame.parentId)!, low.get(frame.nodeId)!), ); + if (low.get(frame.nodeId)! > discovered.get(frame.parentId)!) { + bridgeIds.add(frame.parentEdgeId!); + } + if (low.get(frame.nodeId)! >= discovered.get(frame.parentId)!) { + const parentFrame = stack[stack.length - 1]; + if (parentFrame.parentId !== null) articulationIds.add(frame.parentId); + popComponent(frame.parentEdgeId!); + } } } - if (parentEdgeId === null && childCount > 1) { - state.articulationPoints.add(nodeId); - } -} + const components = componentIds + .map((ids) => + [...ids].sort((a, b) => a.localeCompare(b)).map((id) => nodeById.get(id)!), + ) + .sort((a, b) => a[0].id.localeCompare(b[0].id)); -function analyzeConnectivity(graph: Graph): TraversalState { - const state: TraversalState = { - time: 0, - disc: new Map(), - low: new Map(), - edgeStack: [], - bridges: new Set(), - articulationPoints: new Set(), - components: [], - nodeById: new Map(graph.nodes.map((node) => [node.id, node])), - edgeById: new Map(graph.edges.map((edge) => [edge.id, edge])), + return { + bridges: graph.edges + .filter((edge) => bridgeIds.has(edge.id)) + .sort((a, b) => a.id.localeCompare(b.id)) as GraphEdge[], + articulationPoints: graph.nodes + .filter((node) => articulationIds.has(node.id)) + .sort((a, b) => a.id.localeCompare(b.id)), + biconnectedComponents: components, }; - - for (const node of graph.nodes) { - if (state.disc.has(node.id)) continue; - traverseConnectivity(graph, node.id, null, state); - finalizeRemainingComponent(state); - } - - return state; } -/** - * Returns bridge edges whose removal disconnects the graph. - * - * Connectivity algorithms in this module treat the graph as undirected. - */ +/** Returns bridge edges whose removal disconnects the undirected projection. */ export function getBridges(graph: Graph): GraphEdge[] { - if (graph.edges.length === 0) { - return []; - } - - const state = analyzeConnectivity(graph); - return [...state.bridges] - .map((edgeId) => state.edgeById.get(edgeId)!) - .sort((a, b) => a.id.localeCompare(b.id)); + return analyzeConnectivity(graph).bridges; } -/** - * Returns articulation points (cut vertices) for the graph. - * - * Connectivity algorithms in this module treat the graph as undirected. - */ +/** Returns cut vertices in graph node order. */ export function getArticulationPoints(graph: Graph): GraphNode[] { - if (graph.nodes.length === 0) { - return []; - } - - const state = analyzeConnectivity(graph); - return [...state.articulationPoints] - .map((nodeId) => state.nodeById.get(nodeId)!) - .sort((a, b) => a.id.localeCompare(b.id)); + return analyzeConnectivity(graph).articulationPoints; } -/** - * Returns biconnected components as arrays of nodes. - * - * Articulation points may appear in multiple returned components. - */ +/** Returns biconnected node components; articulation points may repeat. */ export function getBiconnectedComponents( graph: Graph, ): GraphNode[][] { - if (graph.edges.length === 0) { - return []; - } - - const state = analyzeConnectivity(graph); - return state.components - .map((component) => - [...component] - .map((nodeId) => state.nodeById.get(nodeId)!) - .sort((a, b) => a.id.localeCompare(b.id)), - ) - .sort((a, b) => a[0].id.localeCompare(b[0].id)); + return analyzeConnectivity(graph).biconnectedComponents; } diff --git a/src/algorithms/csr.ts b/src/algorithms/csr.ts index c18a25b..640ab55 100644 --- a/src/algorithms/csr.ts +++ b/src/algorithms/csr.ts @@ -46,6 +46,8 @@ export interface GraphCSR { * weight; custom `getWeight` callbacks need their own scan. */ firstNegativeEdge: number; + /** Index of the first non-finite default edge weight, or -1. */ + firstNonFiniteWeightEdge: number; /** * Whether any edge's *effective* mode is not `'directed'` (dangling edges * included). Lets directed-only algorithms (topological sort) bail out in @@ -104,10 +106,15 @@ function buildCSR(graph: Graph): GraphCSR { const outCounts = new Int32Array(n); const inCounts = new Int32Array(n); let firstNegativeEdge = -1; + let firstNonFiniteWeightEdge = -1; let hasNonDirected = false; for (let e = 0; e < m; e++) { const edge = graph.edges[e]; - if (firstNegativeEdge === -1 && (edge.weight ?? 1) < 0) { + const weight = edge.weight ?? 1; + if (firstNonFiniteWeightEdge === -1 && !Number.isFinite(weight)) { + firstNonFiniteWeightEdge = e; + } + if (firstNegativeEdge === -1 && weight < 0) { firstNegativeEdge = e; } const nd = getEdgeMode(graph, edge) !== 'directed' ? 1 : 0; @@ -171,6 +178,7 @@ function buildCSR(graph: Graph): GraphCSR { inOrigins, inEdgeIndex, firstNegativeEdge, + firstNonFiniteWeightEdge, hasNonDirected, }; } diff --git a/src/algorithms/flow.ts b/src/algorithms/flow.ts index 7618a5d..23cd349 100644 --- a/src/algorithms/flow.ts +++ b/src/algorithms/flow.ts @@ -2,13 +2,14 @@ import type { Graph, GraphEdge } from '../types'; import { getIndex } from '../indexing'; import { getEdgeMode } from '../mode'; import { throwIfAborted } from './abort'; +import { addFiniteNumbers, assertFiniteNumber } from './numeric'; export interface MaxFlowOptions { /** Source node id. */ from: string; /** Sink node id. */ to: string; - /** Edge capacity accessor. Defaults to `edge.weight ?? 1`. */ + /** Finite non-negative edge capacity. Defaults to `edge.weight ?? 1`. */ getCapacity?: (edge: GraphEdge) => number; /** Abort signal, checked once per augmenting path. Throws `signal.reason`. */ signal?: AbortSignal; @@ -28,7 +29,7 @@ export interface MinCutOptions { source: string; /** Sink node id. */ sink: string; - /** Edge capacity accessor. Defaults to `edge.weight ?? 1`. */ + /** Finite non-negative edge capacity. Defaults to `edge.weight ?? 1`. */ getCapacity?: (edge: GraphEdge) => number; /** Abort signal, checked once per augmenting path. Throws `signal.reason`. */ signal?: AbortSignal; @@ -108,7 +109,10 @@ function solveMaxFlow( } for (const edge of graph.edges) { - const capacity = getCapacity(edge as GraphEdge); + const capacity = assertFiniteNumber( + getCapacity(edge as GraphEdge), + `${caller}: capacity for edge "${edge.id}"`, + ); if (capacity < 0) { throw new Error( `${caller}: edge "${edge.id}" has negative capacity ${capacity} — capacities must be >= 0; fix edge.weight or provide a non-negative getCapacity`, @@ -163,7 +167,7 @@ function solveMaxFlow( arcs[ai ^ 1].flow -= bottleneck; v = arcs[ai ^ 1].to; } - value += bottleneck; + value = addFiniteNumbers(value, bottleneck, `${caller}: total flow`); } // --- Net flow per edge id --- @@ -172,7 +176,11 @@ function solveMaxFlow( ) as Record; for (const arc of arcs) { if (arc.edgeId !== undefined && arc.flow > 0) { - flows[arc.edgeId] += arc.sign! * arc.flow; + flows[arc.edgeId] = addFiniteNumbers( + flows[arc.edgeId], + arc.sign! * arc.flow, + `${caller}: flow for edge "${arc.edgeId}"`, + ); } } @@ -221,6 +229,7 @@ function solveMaxFlow( * * Pass `options.signal` to cancel: the abort is checked once per augmenting * path and throws `signal.reason`. + * Capacities and every accumulated flow value must remain finite. * * @example * ```ts diff --git a/src/algorithms/numeric.ts b/src/algorithms/numeric.ts new file mode 100644 index 0000000..333ac55 --- /dev/null +++ b/src/algorithms/numeric.ts @@ -0,0 +1,18 @@ +export function assertFiniteNumber(value: number, context: string): number { + if (!Number.isFinite(value)) { + throw new Error(`${context} must return a finite number; received ${value}`); + } + return value; +} + +export function addFiniteNumbers( + left: number, + right: number, + context: string, +): number { + const result = left + right; + if (!Number.isFinite(result)) { + throw new Error(`${context} exceeds the finite number range`); + } + return result; +} diff --git a/src/algorithms/paths.ts b/src/algorithms/paths.ts index 6c5701e..770237d 100644 --- a/src/algorithms/paths.ts +++ b/src/algorithms/paths.ts @@ -20,6 +20,23 @@ import { } from './shared'; import { getArcWeights, getCSR, getEdgeOrderArcs } from './csr'; import { throwIfAborted } from './abort'; +import { addFiniteNumbers, assertFiniteNumber } from './numeric'; + +function assertFiniteEdgeWeight( + graph: Graph, + edgeIndex: number, + weight: number, + algorithmName: string, +): number { + return assertFiniteNumber( + weight, + `${algorithmName}: weight for edge "${graph.edges[edgeIndex].id}"`, + ); +} + +function addPathCost(left: number, right: number, algorithmName: string): number { + return addFiniteNumbers(left, right, `${algorithmName}: path cost`); +} /** Cold path: load the offending edge and throw the negative-weight error. */ function throwNegativeWeight( @@ -170,18 +187,13 @@ function computeShortestDistances( const stopAt = stopAtId !== undefined ? csr.indexOf.get(stopAtId) : undefined; let stopDistance = Infinity; - // An early-exit search may finish without scanning a reachable negative - // edge, so the throw-on-negative contract needs an up-front check; the - // full search keeps its scan-time checks (identical observable behavior) - if (stopAt !== undefined) { - assertNoNegativeWeights( - graph, - csr, - getWeight, - 'Dijkstra', - "Use { algorithm: 'bellman-ford' } instead.", - ); - } + assertNoNegativeWeights( + graph, + csr, + getWeight, + 'Dijkstra', + "Use { algorithm: 'bellman-ford' } instead.", + ); const useBFS = !getWeight && !graph.edges.some((edge) => edge.weight !== undefined); @@ -228,9 +240,14 @@ function computeShortestDistances( visited[u] = 1; for (let a = csr.outOffsets[u]; a < csr.outOffsets[u + 1]; a++) { - const weight = arcWeights - ? arcWeights[a] - : getWeight!(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); + const weight = assertFiniteEdgeWeight( + graph, + csr.outEdgeIndex[a], + arcWeights + ? arcWeights[a] + : getWeight!(graph.edges[csr.outEdgeIndex[a]] as GraphEdge), + 'Dijkstra', + ); if (weight < 0) { throwNegativeWeight( graph, @@ -241,7 +258,7 @@ function computeShortestDistances( ); } const v = csr.outTargets[a]; - const nextDistance = distance + weight; + const nextDistance = addPathCost(distance, weight, 'Dijkstra'); if (nextDistance < distArr[v]) { distArr[v] = nextDistance; @@ -280,6 +297,8 @@ function bellmanFordTyped( }; } + assertFiniteWeights(graph, csr, getWeight, 'Bellman-Ford'); + // Cached compact arcs in edge order; custom weights overlay the endpoints const arcs = getEdgeOrderArcs(graph, csr); const arcCount = arcs.count; @@ -304,7 +323,7 @@ function bellmanFordTyped( for (let a = 0; a < arcCount; a++) { const du = distArr[arcFrom[a]]; if (du === Infinity) continue; - const nextDistance = du + arcWeight[a]; + const nextDistance = addPathCost(du, arcWeight[a], 'Bellman-Ford'); const t = arcTo[a]; const existing = distArr[t]; if (nextDistance < existing) { @@ -331,7 +350,7 @@ function bellmanFordTyped( for (let a = 0; a < arcCount; a++) { const du = distArr[arcFrom[a]]; if (du === Infinity) continue; - if (du + arcWeight[a] < distArr[arcTo[a]]) { + if (addPathCost(du, arcWeight[a], 'Bellman-Ford') < distArr[arcTo[a]]) { throw new Error( 'Graph contains a negative-weight cycle reachable from the source node', ); @@ -522,6 +541,8 @@ function bellmanFordSinglePath( } if (target === undefined) return undefined; + assertFiniteWeights(graph, csr, getWeight, 'Bellman-Ford'); + const n = csr.ids.length; const arcs = getEdgeOrderArcs(graph, csr); const arcCount = arcs.count; @@ -546,7 +567,7 @@ function bellmanFordSinglePath( for (let a = 0; a < arcCount; a++) { const du = distArr[arcFrom[a]]; if (du === Infinity) continue; - const nextDistance = du + arcWeight[a]; + const nextDistance = addPathCost(du, arcWeight[a], 'Bellman-Ford'); const t = arcTo[a]; if (nextDistance < distArr[t]) { distArr[t] = nextDistance; @@ -561,7 +582,7 @@ function bellmanFordSinglePath( for (let a = 0; a < arcCount; a++) { const du = distArr[arcFrom[a]]; if (du === Infinity) continue; - if (du + arcWeight[a] < distArr[arcTo[a]]) { + if (addPathCost(du, arcWeight[a], 'Bellman-Ford') < distArr[arcTo[a]]) { throw new Error( 'Graph contains a negative-weight cycle reachable from the source node', ); @@ -598,13 +619,24 @@ function assertNoNegativeWeights( let offending: GraphEdge | undefined; let weight = 0; if (getWeight === undefined) { + if (csr.firstNonFiniteWeightEdge !== -1) { + const edge = graph.edges[csr.firstNonFiniteWeightEdge] as GraphEdge; + assertFiniteEdgeWeight( + graph, + csr.firstNonFiniteWeightEdge, + edge.weight ?? 1, + algorithmName, + ); + } if (csr.firstNegativeEdge !== -1) { offending = graph.edges[csr.firstNegativeEdge] as GraphEdge; weight = offending.weight ?? 1; } } else { - for (const edge of graph.edges) { + for (let edgeIndex = 0; edgeIndex < graph.edges.length; edgeIndex++) { + const edge = graph.edges[edgeIndex] as GraphEdge; const w = getWeight(edge as GraphEdge); + assertFiniteEdgeWeight(graph, edgeIndex, w, algorithmName); if (w < 0) { offending = edge as GraphEdge; weight = w; @@ -619,6 +651,33 @@ function assertNoNegativeWeights( } } +function assertFiniteWeights( + graph: Graph, + csr: ReturnType, + getWeight: ((edge: GraphEdge) => number) | undefined, + algorithmName: string, +): void { + if (getWeight === undefined) { + if (csr.firstNonFiniteWeightEdge === -1) return; + const edge = graph.edges[csr.firstNonFiniteWeightEdge] as GraphEdge; + assertFiniteEdgeWeight( + graph, + csr.firstNonFiniteWeightEdge, + edge.weight ?? 1, + algorithmName, + ); + return; + } + for (let edgeIndex = 0; edgeIndex < graph.edges.length; edgeIndex++) { + assertFiniteEdgeWeight( + graph, + edgeIndex, + getWeight(graph.edges[edgeIndex] as GraphEdge), + algorithmName, + ); + } +} + /** * Bidirectional Dijkstra for a single source→target query. Forward search * runs on the traversable arcs, backward search on the reverse arcs; `mu` @@ -697,7 +756,7 @@ function bidirectionalShortestPath( ? arcWeights.out[a] : getWeight!(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); const v = csr.outTargets[a]; - const next = d + weight; + const next = addPathCost(d, weight, 'Dijkstra'); if (next < distF[v]) { distF[v] = next; predF[v] = u; @@ -706,9 +765,12 @@ function bidirectionalShortestPath( } // distB[v] is the cost of a real backward path (tentative or settled), // so next + distB[v] is the cost of a real s→t path - if (distB[v] !== Infinity && next + distB[v] < mu) { - mu = next + distB[v]; - meet = v; + if (distB[v] !== Infinity) { + const candidate = addPathCost(next, distB[v], 'Dijkstra'); + if (candidate < mu) { + mu = candidate; + meet = v; + } } } }; @@ -723,16 +785,19 @@ function bidirectionalShortestPath( ? arcWeights.in[a] : getWeight!(graph.edges[csr.inEdgeIndex[a]] as GraphEdge); const v = csr.inOrigins[a]; - const next = d + weight; + const next = addPathCost(d, weight, 'Dijkstra'); if (next < distB[v]) { distB[v] = next; predB[v] = u; predBEdge[v] = csr.inEdgeIndex[a]; pqB.push(next, v); } - if (distF[v] !== Infinity && next + distF[v] < mu) { - mu = next + distF[v]; - meet = v; + if (distF[v] !== Infinity) { + const candidate = addPathCost(next, distF[v], 'Dijkstra'); + if (candidate < mu) { + mu = candidate; + meet = v; + } } } }; @@ -743,7 +808,7 @@ function bidirectionalShortestPath( // A side running dry means its dist array is final everywhere reachable, // so mu already equals the optimum (or stays Infinity: no path) if (topF === undefined || topB === undefined) break; - if (topF + topB >= mu) break; + if (addPathCost(topF, topB, 'Dijkstra') >= mu) break; if (topF <= topB) scanForward(); else scanBackward(); } @@ -1161,6 +1226,7 @@ function floydWarshallAllPaths( const nodes = graph.nodes; const nodeCount = nodes.length; const csr = getCSR(graph); // positions match graph.nodes order + assertFiniteWeights(graph, csr, getWeight, 'Floyd-Warshall'); const INF = Infinity; // Flat n×n distance matrix; tie predecessors as flat (fromPos, edgeIndex) @@ -1177,7 +1243,12 @@ function floydWarshallAllPaths( const s = csr.indexOf.get(edge.sourceId); const t = csr.indexOf.get(edge.targetId); if (s === undefined || t === undefined) continue; - const edgeWeight = weight(edge); + const edgeWeight = assertFiniteEdgeWeight( + graph, + e, + weight(edge), + 'Floyd-Warshall', + ); const forward = s * nodeCount + t; if (edgeWeight < dist[forward]) { dist[forward] = edgeWeight; @@ -1214,7 +1285,7 @@ function floydWarshallAllPaths( for (let j = 0; j < nodeCount; j++) { const dkj = dist[rowK + j]; if (dkj === INF) continue; - const nextDistance = dik + dkj; + const nextDistance = addPathCost(dik, dkj, 'Floyd-Warshall'); const cell = rowI + j; const current = dist[cell]; if (nextDistance < current) { @@ -1356,10 +1427,6 @@ export function getAStarPath( if (sourceNi === undefined) return undefined; if (!idx.nodeById.has(targetId)) return undefined; - if (sourceId === targetId) { - return { source: graph.nodes[sourceNi], steps: [] }; - } - const csr = getCSR(graph); const n = csr.ids.length; const source = csr.indexOf.get(sourceId)!; @@ -1384,6 +1451,11 @@ export function getAStarPath( "Use getShortestPath with { algorithm: 'bellman-ford' } instead.", ); + if (sourceId === targetId) { + getHeuristic(sourceId); + return { source: graph.nodes[sourceNi], steps: [] }; + } + gScore[source] = 0; openSet.push(getHeuristic(sourceId), source); @@ -1408,19 +1480,28 @@ export function getAStarPath( closed[current] = 1; for (let a = csr.outOffsets[current]; a < csr.outOffsets[current + 1]; a++) { - const weight = arcWeights - ? arcWeights.out[a] - : getWeight(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); + const weight = assertFiniteEdgeWeight( + graph, + csr.outEdgeIndex[a], + arcWeights + ? arcWeights.out[a] + : getWeight(graph.edges[csr.outEdgeIndex[a]] as GraphEdge), + 'A*', + ); const neighbor = csr.outTargets[a]; if (closed[neighbor]) continue; - const tentativeScore = gScore[current] + weight; + const tentativeScore = addPathCost(gScore[current], weight, 'A*'); if (tentativeScore < gScore[neighbor]) { cameFromPos[neighbor] = current; cameFromEdge[neighbor] = csr.outEdgeIndex[a]; gScore[neighbor] = tentativeScore; openSet.push( - tentativeScore + getHeuristic(csr.ids[neighbor]), + addPathCost( + tentativeScore, + getHeuristic(csr.ids[neighbor]), + 'A*', + ), neighbor, ); } diff --git a/src/algorithms/spanning-tree.ts b/src/algorithms/spanning-tree.ts index f454265..98d08f7 100644 --- a/src/algorithms/spanning-tree.ts +++ b/src/algorithms/spanning-tree.ts @@ -4,13 +4,26 @@ import { createGraph } from '../graph'; import { toNodeConfig, toEdgeConfig } from '../config'; import { getEdgeMode } from '../mode'; import { MinPriorityQueue } from './shared'; +import { assertFiniteNumber } from './numeric'; export function getMinimumSpanningTree( graph: Graph, opts?: MSTOptions, ): Graph { const algorithm = opts?.algorithm ?? 'prim'; - const getWeight = opts?.getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); + const weightAccessor = + opts?.getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); + const weights = new Map(); + for (const edge of graph.edges) { + weights.set( + edge.id, + assertFiniteNumber( + weightAccessor(edge as GraphEdge), + `getMinimumSpanningTree: weight for edge "${edge.id}"`, + ), + ); + } + const getWeight = (edge: GraphEdge) => weights.get(edge.id)!; const mstEdges = algorithm === 'kruskal' diff --git a/src/algorithms/traversal.ts b/src/algorithms/traversal.ts index 0445371..9338f21 100644 --- a/src/algorithms/traversal.ts +++ b/src/algorithms/traversal.ts @@ -3,6 +3,7 @@ import type { GraphNode, TraversalDirection, TraversalSearchOptions, + UnweightedDistanceOptions, } from '../types'; import { getIndex } from '../indexing'; import { @@ -791,6 +792,54 @@ export function getConnectedComponents(graph: Graph): GraphNode[][] { return components; } +/** + * Returns minimum hop counts from `sourceId` to every reachable node. + * Unknown sources return an empty map. Results follow BFS discovery order. + */ +export function getUnweightedDistances( + graph: Graph, + sourceId: string, + options?: UnweightedDistanceOptions, +): Map { + const csr = getCSR(graph); + const source = csr.indexOf.get(sourceId); + const result = new Map(); + if (source === undefined) return result; + + const direction = options?.direction ?? 'outgoing'; + const distances = new Int32Array(csr.ids.length).fill(-1); + const queue = new Int32Array(csr.ids.length); + distances[source] = 0; + queue[0] = source; + let head = 0; + let tail = 1; + + while (head < tail) { + const node = queue[head++]; + const distance = distances[node]; + result.set(csr.ids[node], distance); + + if (direction !== 'incoming') { + for (let arc = csr.outOffsets[node]; arc < csr.outOffsets[node + 1]; arc++) { + const neighbor = csr.outTargets[arc]; + if (distances[neighbor] !== -1) continue; + distances[neighbor] = distance + 1; + queue[tail++] = neighbor; + } + } + if (direction !== 'outgoing') { + for (let arc = csr.inOffsets[node]; arc < csr.inOffsets[node + 1]; arc++) { + const neighbor = csr.inOrigins[arc]; + if (distances[neighbor] !== -1) continue; + distances[neighbor] = distance + 1; + queue[tail++] = neighbor; + } + } + } + + return result; +} + /** * Returns a topological ordering of the graph's nodes, or `null` if no such * ordering exists. @@ -863,6 +912,16 @@ export function isConnected(graph: Graph): boolean { return getConnectedComponents(graph).length <= 1; } +/** Returns whether the graph has at most one weakly connected component. */ +export function isWeaklyConnected(graph: Graph): boolean { + return isConnected(graph); +} + +/** Returns whether every node is reachable from every other node. */ +export function isStronglyConnected(graph: Graph): boolean { + return getStronglyConnectedComponents(graph).length <= 1; +} + /** * Returns whether the graph is a tree: connected, acyclic, and with exactly * `nodes.length - 1` edges (so directed diamonds and parallel edges are not diff --git a/src/graph.ts b/src/graph.ts index 218cd99..309661d 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -874,6 +874,8 @@ export function addEntities( /** * **Mutable.** Delete entities by id(s). Automatically detects whether each id * is a node or edge. Node deletions cascade to children and connected edges. + * The iterable is collected before mutation, then nodes and edges are filtered + * once, so an iterator over the same graph is safe. * * @example * ```ts @@ -887,17 +889,55 @@ export function addEntities( */ export function deleteEntities( graph: Graph, - ids: string | string[], + ids: string | Iterable, opts?: DeleteNodeOptions, ): void { - const idArray = Array.isArray(ids) ? ids : [ids]; + // Collect before mutation so callers may pass an iterator over this graph. + const idArray = typeof ids === 'string' ? [ids] : Array.from(ids); + const idx = getIndex(graph); + const nodeIds = new Set(); + const edgeIds = new Set(); for (const id of idArray) { - if (hasNode(graph, id)) { - deleteNode(graph, id, opts); - } else if (hasEdge(graph, id)) { - deleteEdge(graph, id); + if (idx.nodeById.has(id)) nodeIds.add(id); + else if (idx.edgeById.has(id)) edgeIds.add(id); + } + + if (!opts?.reparent) { + const pending = [...nodeIds]; + for (let index = 0; index < pending.length; index++) { + for (const childId of idx.childNodes.get(pending[index]) ?? []) { + if (nodeIds.has(childId)) continue; + nodeIds.add(childId); + pending.push(childId); + } + } + } else if (nodeIds.size > 0) { + const parentById = new Map( + graph.nodes.map((node) => [node.id, node.parentId ?? null]), + ); + for (const node of graph.nodes) { + if (nodeIds.has(node.id) || !node.parentId || !nodeIds.has(node.parentId)) { + continue; + } + let parentId: string | null = node.parentId; + const seen = new Set(); + while (parentId !== null && nodeIds.has(parentId) && !seen.has(parentId)) { + seen.add(parentId); + parentId = parentById.get(parentId) ?? null; + } + node.parentId = parentId; } } + + if (nodeIds.size === 0 && edgeIds.size === 0) return; + graph.nodes = graph.nodes.filter((node) => !nodeIds.has(node.id)); + graph.edges = graph.edges.filter( + (edge) => + !edgeIds.has(edge.id) && + !nodeIds.has(edge.sourceId) && + !nodeIds.has(edge.targetId), + ); + invalidateIndex(graph); } // Batch update operations @@ -1023,7 +1063,7 @@ export function getGraphWithEntities( /** Return a graph copy without the identified nodes and edges. */ export function getGraphWithoutEntities( graph: Graph, - ids: string | string[], + ids: string | Iterable, opts?: DeleteNodeOptions, ): Graph { const next = getGraphMutationCopy(graph); @@ -1145,7 +1185,7 @@ export class GraphInstance { addEntities(entities: EntitiesConfig) { return addEntities(this.graph, entities); } - deleteEntities(ids: string | string[], opts?: DeleteNodeOptions) { + deleteEntities(ids: string | Iterable, opts?: DeleteNodeOptions) { return deleteEntities(this.graph, ids, opts); } updateEntities(updates: EntitiesUpdate) { diff --git a/src/index.ts b/src/index.ts index c07445b..930c401 100644 --- a/src/index.ts +++ b/src/index.ts @@ -44,6 +44,7 @@ export type { TraversalOptions, TraversalDirection, TraversalSearchOptions, + UnweightedDistanceOptions, PostorderOptions, MSTOptions, AllPairsShortestPathsOptions, @@ -180,11 +181,14 @@ export { dfs, isAcyclic, getConnectedComponents, + getUnweightedDistances, getTopologicalSort, hasPath, getJoinedPath, joinPaths, isConnected, + isWeaklyConnected, + isStronglyConnected, isTree, getShortestPath, getShortestPaths, diff --git a/src/transforms.ts b/src/transforms.ts index 5cba886..6b18ac5 100644 --- a/src/transforms.ts +++ b/src/transforms.ts @@ -381,6 +381,7 @@ export interface FilteredGraphOptions { * preserved; only `data` changes. Returning `undefined` clears `data`. * * Keep mapped data JSON-serializable — no functions, classes, or symbols. + * Node and edge collections are snapshotted before callbacks run. * * @example * ```ts @@ -402,11 +403,15 @@ export function getMappedGraph( graph: Graph, options: MappedGraphOptions, ): Graph { + // Callbacks may structurally mutate the source graph. Capture both + // collections before invoking either callback so the result is coherent. + const sourceNodes = graph.nodes.slice(); + const sourceEdges = graph.edges.slice(); return createGraph({ id: graph.id, mode: graph.mode, initialNodeId: graph.initialNodeId ?? undefined, - nodes: graph.nodes.map((n) => { + nodes: sourceNodes.map((n) => { const config = toNodeConfig(n) as NodeConfig; if (options.node) { const data = options.node(n); @@ -415,7 +420,7 @@ export function getMappedGraph( } return config as NodeConfig; }), - edges: graph.edges.map((e) => { + edges: sourceEdges.map((e) => { const config = toEdgeConfig(e) as EdgeConfig; if (options.edge) { const data = options.edge(e); @@ -435,6 +440,7 @@ export function getMappedGraph( * predicates. Dropping a node also drops its incident edges; parent and * initial-node references to dropped nodes are removed (as in * {@link getSubgraph}). + * Node and edge collections are snapshotted before predicates run. * * @example * ```ts @@ -456,9 +462,13 @@ export function getFilteredGraph( graph: Graph, options: FilteredGraphOptions, ): Graph { + // Keep node and edge selection on one structural snapshot even when a + // predicate mutates the source graph through the public mutation API. + const sourceNodes = graph.nodes.slice(); + const sourceEdges = graph.edges.slice(); const nodes = options.node - ? graph.nodes.filter((n) => options.node!(n)) - : graph.nodes; + ? sourceNodes.filter((n) => options.node!(n)) + : sourceNodes; const nodeIdSet = new Set(nodes.map((n) => n.id)); return createGraph({ @@ -469,7 +479,7 @@ export function getFilteredGraph( ? graph.initialNodeId : undefined, nodes: nodes.map((n) => toScopedNodeConfig(n, nodeIdSet)), - edges: graph.edges + edges: sourceEdges .filter( (e) => nodeIdSet.has(e.sourceId) && diff --git a/src/types.ts b/src/types.ts index 4a4f4a6..5474f0e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -432,7 +432,7 @@ export interface EdgeCoveragePathsResult< export interface ShortestSimplePathsOptions { from: string; to: string; - /** Edge weight function. Default: `(e) => e.weight ?? 1`. */ + /** Finite edge weight function. Default: `(e) => e.weight ?? 1`. */ getWeight?: (edge: GraphEdge) => number; /** Maximum paths to yield/return. Omit to enumerate every simple path by cost. */ limit?: number; @@ -452,7 +452,7 @@ export interface PathOptions { from?: NodeSelector; /** Target node ID. If omitted → paths to all reachable nodes */ to?: string; - /** Edge weight function. Default: `(e) => e.weight ?? 1`. */ + /** Finite edge weight function. Default: `(e) => e.weight ?? 1`. */ getWeight?: (edge: GraphEdge) => number; /** Algorithm to use. Default: 'dijkstra'. Use 'bellman-ford' for negative weights. */ algorithm?: 'dijkstra' | 'bellman-ford'; @@ -463,7 +463,7 @@ export interface SinglePathOptions { from?: NodeSelector; /** Target node ID. Required for single-path queries. */ to: string; - /** Edge weight function. Default: `(e) => e.weight ?? 1`. */ + /** Finite edge weight function. Default: `(e) => e.weight ?? 1`. */ getWeight?: (edge: GraphEdge) => number; /** Algorithm to use. Default: 'dijkstra'. Use 'bellman-ford' for negative weights. */ algorithm?: 'dijkstra' | 'bellman-ford'; @@ -474,7 +474,7 @@ export interface AStarOptions { from: NodeSelector; /** Target node ID. */ to: string; - /** Edge weight function. Default: `(e) => e.weight ?? 1`. */ + /** Finite edge weight function. Default: `(e) => e.weight ?? 1`. */ getWeight?: (edge: GraphEdge) => number; /** * Heuristic function estimating cost from a node to the target. @@ -493,6 +493,12 @@ export interface TraversalOptions { export type TraversalDirection = 'outgoing' | 'incoming' | 'undirected'; +/** Options for unweighted shortest-hop distances. */ +export interface UnweightedDistanceOptions { + /** Edge direction to follow. Default: `'outgoing'`. */ + direction?: TraversalDirection; +} + /** Options for lazy breadth-first and depth-first graph traversal. */ export interface TraversalSearchOptions { /** One or more source node IDs. Unknown IDs are ignored. */ @@ -513,7 +519,7 @@ export interface PostorderOptions export interface MSTOptions { /** Algorithm to use. Default: 'prim'. */ algorithm?: 'prim' | 'kruskal'; - /** Edge weight function. Default: `(e) => e.weight ?? 1`. */ + /** Finite edge weight function. Default: `(e) => e.weight ?? 1`. */ getWeight?: (edge: GraphEdge) => number; } diff --git a/tests/graph-hardening.test.ts b/tests/graph-hardening.test.ts new file mode 100644 index 0000000..09e6bfe --- /dev/null +++ b/tests/graph-hardening.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { + createGraph, + deleteEntities, + getAllPairsShortestPaths, + getAStarPath, + getArticulationPoints, + getBiconnectedComponents, + getBridges, + getFilteredGraph, + getMappedGraph, + getMinimumSpanningTree, + getShortestPath, + getShortestPaths, + getUnweightedDistances, + isStronglyConnected, + isWeaklyConnected, +} from '../src'; +import { getMaxFlow } from '../src/algorithms'; + +describe('finite weighted arithmetic', () => { + const pathGraph = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b', weight: Number.MAX_VALUE }, + { id: 'bc', sourceId: 'b', targetId: 'c', weight: Number.MAX_VALUE }, + ], + }); + + it.each([NaN, Infinity, -Infinity])( + 'rejects a non-finite shortest-path weight: %s', + (weight) => { + expect(() => + getShortestPath(pathGraph, { + from: 'a', + to: 'c', + getWeight: () => weight, + }), + ).toThrow(/finite/); + }, + ); + + it('rejects shortest-path cost overflow', () => { + expect(() => getShortestPath(pathGraph, { from: 'a', to: 'c' })).toThrow( + /finite number range/, + ); + }); + + it('applies the finite contract to Bellman-Ford, Floyd-Warshall, and A*', () => { + expect(() => + getShortestPaths(pathGraph, { + from: 'a', + algorithm: 'bellman-ford', + getWeight: () => NaN, + }), + ).toThrow(/finite/); + expect(() => + getAllPairsShortestPaths(pathGraph, { algorithm: 'floyd-warshall' }), + ).toThrow(/finite number range/); + expect(() => + getAStarPath(pathGraph, { + from: 'a', + to: 'c', + heuristic: () => 0, + }), + ).toThrow(/finite number range/); + expect(() => + getAStarPath(pathGraph, { + from: 'a', + to: 'a', + heuristic: () => NaN, + }), + ).toThrow(/finite/); + }); + + it('rejects non-finite minimum-spanning-tree weights', () => { + expect(() => + getMinimumSpanningTree(pathGraph, { getWeight: () => NaN }), + ).toThrow(/finite/); + }); + + it('rejects non-finite capacities and total-flow overflow', () => { + const flowGraph = createGraph({ + nodes: [{ id: 's' }, { id: 't' }], + edges: [ + { id: 'a', sourceId: 's', targetId: 't' }, + { id: 'b', sourceId: 's', targetId: 't' }, + ], + }); + expect(() => + getMaxFlow(flowGraph, { + from: 's', + to: 't', + getCapacity: () => Infinity, + }), + ).toThrow(/finite/); + expect(() => + getMaxFlow(flowGraph, { + from: 's', + to: 't', + getCapacity: () => Number.MAX_VALUE, + }), + ).toThrow(/finite number range/); + }); +}); + +describe('unweighted reachability', () => { + const graph = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b' }, + { id: 'cb', sourceId: 'c', targetId: 'b' }, + ], + }); + + it('returns hop distances in each traversal direction', () => { + expect([...getUnweightedDistances(graph, 'a')]).toEqual([ + ['a', 0], + ['b', 1], + ]); + expect([ + ...getUnweightedDistances(graph, 'b', { direction: 'incoming' }), + ]).toEqual([ + ['b', 0], + ['a', 1], + ['c', 1], + ]); + expect([ + ...getUnweightedDistances(graph, 'a', { direction: 'undirected' }), + ]).toEqual([ + ['a', 0], + ['b', 1], + ['c', 2], + ]); + expect(getUnweightedDistances(graph, 'missing').size).toBe(0); + }); + + it('distinguishes weak and strong connectivity', () => { + expect(isWeaklyConnected(graph)).toBe(true); + expect(isStronglyConnected(graph)).toBe(false); + + const cycle = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b' }, + { id: 'bc', sourceId: 'b', targetId: 'c' }, + { id: 'ca', sourceId: 'c', targetId: 'a' }, + ], + }); + expect(isStronglyConnected(cycle)).toBe(true); + }); +}); + +describe('low-link hardening', () => { + it('includes a self-loop as a singleton biconnected component', () => { + const graph = createGraph({ + mode: 'undirected', + nodes: [{ id: 'a' }], + edges: [{ id: 'loop', sourceId: 'a', targetId: 'a' }], + }); + expect(getBiconnectedComponents(graph)).toEqual([[graph.nodes[0]]]); + expect(getBridges(graph)).toEqual([]); + expect(getArticulationPoints(graph)).toEqual([]); + }); + + it('is stack-safe on a deep chain', () => { + const count = 20_000; + const graph = createGraph({ + mode: 'undirected', + nodes: Array.from({ length: count }, (_, index) => ({ id: `n${index}` })), + edges: Array.from({ length: count - 1 }, (_, index) => ({ + id: `e${index}`, + sourceId: `n${index}`, + targetId: `n${index + 1}`, + })), + }); + expect(getBridges(graph)).toHaveLength(count - 1); + expect(getArticulationPoints(graph)).toHaveLength(count - 2); + }); +}); + +describe('mutation-stable transforms and bulk deletion', () => { + it('maps a structural snapshot when a callback mutates the source', () => { + const graph = createGraph({ + nodes: [{ id: 'a', data: 1 }, { id: 'b', data: 2 }], + edges: [{ id: 'ab', sourceId: 'a', targetId: 'b', data: 3 }], + }); + const mapped = getMappedGraph(graph, { + node: (node) => { + if (node.id === 'a') deleteEntities(graph, 'b'); + return node.data * 2; + }, + edge: (edge) => edge.data * 2, + }); + expect(mapped.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(mapped.edges.map((edge) => edge.id)).toEqual(['ab']); + }); + + it('filters a structural snapshot when a callback mutates the source', () => { + const graph = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'ab', sourceId: 'a', targetId: 'b' }], + }); + const filtered = getFilteredGraph(graph, { + node: (node) => { + if (node.id === 'a') deleteEntities(graph, 'b'); + return true; + }, + }); + expect(filtered.nodes.map((node) => node.id)).toEqual(['a', 'b']); + expect(filtered.edges.map((edge) => edge.id)).toEqual(['ab']); + }); + + it('accepts a same-graph-backed iterable and deletes in one batch', () => { + const graph = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b' }, + { id: 'bc', sourceId: 'b', targetId: 'c' }, + ], + }); + deleteEntities( + graph, + (function* () { + for (const node of graph.nodes) { + if (node.id !== 'c') yield node.id; + } + })(), + ); + expect(graph.nodes.map((node) => node.id)).toEqual(['c']); + expect(graph.edges).toEqual([]); + }); +});