diff --git a/.changeset/heavy-pandas-brake.md b/.changeset/heavy-pandas-brake.md new file mode 100644 index 0000000..cf73d00 --- /dev/null +++ b/.changeset/heavy-pandas-brake.md @@ -0,0 +1,16 @@ +--- +'@statelyai/graph': minor +--- + +Large performance overhaul of the algorithm hot paths: + +- `getStronglyConnectedComponents` is now an iterative typed-array Tarjan over the CSR (stack-safe, ~15x faster). +- `getTopologicalSort` is a CSR Kahn's pass with cached in-degrees (~4x faster, no more O(n²) queue). +- `isBipartite`/`getMaximumBipartiteMatching` 2-color directly over the cached CSR with no per-call adjacency rebuild (~60x faster on repeated queries). +- Bellman-Ford (`algorithm: 'bellman-ford'`) relaxes cached compact arc arrays; single-pair queries skip tie-predecessor bookkeeping entirely (~15x faster). +- Floyd-Warshall all-pairs uses a flat distance matrix with copy-on-write tie-predecessor lists and O(length) path materialization (~4x faster). +- `genBFS`/`genDFS`/`genPostorder` are hand-rolled chunked iterators (identical order and laziness semantics, no generator resume machinery; ~1.5-2x faster full traversals), and traversal snapshots now reuse the CSR's node snapshot instead of copying `graph.nodes` per call. +- Dijkstra / A* / bidirectional search read default edge weights from a cached per-arc `Float64Array` instead of loading edge objects in the inner loop. +- `getDegree` serves from a per-version degree map (one hashed lookup per call), and repeated indexed queries against the same graph skip the WeakMap via a one-entry memo. +- All-targets shortest-path reconstruction (`genShortestPaths`) materializes each path once via a shared backtracking buffer instead of per-level array spreads. + diff --git a/src/algorithms/bipartite.ts b/src/algorithms/bipartite.ts index a71f73a..d79e157 100644 --- a/src/algorithms/bipartite.ts +++ b/src/algorithms/bipartite.ts @@ -26,34 +26,17 @@ interface ColoringConflict { * proves the graph is not bipartite. */ function getTwoColoring(graph: Graph): TwoColoring | ColoringConflict { + // 2-color straight over the cached CSR: the union of out-arcs and in-arcs + // covers every edge in both directions regardless of mode, so no separate + // undirected adjacency needs to be built (or allocated) per call. const csr = getCSR(graph); const n = csr.ids.length; const m = graph.edges.length; - // Undirected adjacency with the originating edge index per arc. - const degree = new Int32Array(n); - for (let e = 0; e < m; e++) { - const edge = graph.edges[e]; - if (edge.sourceId === edge.targetId) { - return { conflictEdgeId: edge.id }; - } - degree[csr.indexOf.get(edge.sourceId)!]++; - degree[csr.indexOf.get(edge.targetId)!]++; - } - const offsets = new Int32Array(n + 1); - for (let i = 0; i < n; i++) offsets[i + 1] = offsets[i] + degree[i]; - const targets = new Int32Array(offsets[n]); - const arcEdge = new Int32Array(offsets[n]); - const cursor = Int32Array.from(offsets.subarray(0, n)); - for (let e = 0; e < m; e++) { - const edge = graph.edges[e]; - const s = csr.indexOf.get(edge.sourceId)!; - const t = csr.indexOf.get(edge.targetId)!; - targets[cursor[s]] = t; - arcEdge[cursor[s]++] = e; - targets[cursor[t]] = s; - arcEdge[cursor[t]++] = e; - } + const outOffsets = csr.outOffsets; + const outTargets = csr.outTargets; + const inOffsets = csr.inOffsets; + const inOrigins = csr.inOrigins; const colors = new Int8Array(n).fill(-1); const queue = new Int32Array(n); @@ -65,18 +48,39 @@ function getTwoColoring(graph: Graph): TwoColoring | ColoringConflict { let tail = 1; while (head < tail) { const u = queue[head++]; - for (let a = offsets[u]; a < offsets[u + 1]; a++) { - const v = targets[a]; + const next = (1 - colors[u]) as 0 | 1; + for (let a = outOffsets[u]; a < outOffsets[u + 1]; a++) { + const v = outTargets[a]; + if (colors[v] === -1) { + colors[v] = next; + queue[tail++] = v; + } else if (colors[v] !== next) { + return { conflictEdgeId: graph.edges[csr.outEdgeIndex[a]].id }; + } + } + for (let a = inOffsets[u]; a < inOffsets[u + 1]; a++) { + const v = inOrigins[a]; if (colors[v] === -1) { - colors[v] = (1 - colors[u]) as 0 | 1; + colors[v] = next; queue[tail++] = v; - } else if (colors[v] === colors[u]) { - return { conflictEdgeId: graph.edges[arcEdge[a]].id }; + } else if (colors[v] !== next) { + return { conflictEdgeId: graph.edges[csr.inEdgeIndex[a]].id }; } } } } + // Self-loops between existing nodes surface as arc conflicts above; a + // self-loop with a *dangling* endpoint contributes no arcs, so a final + // edge sweep keeps the previous "self-loops are never bipartite" contract. + // Only runs when the coloring succeeded — the hot early-exit path skips it. + for (let e = 0; e < m; e++) { + const edge = graph.edges[e]; + if (edge.sourceId === edge.targetId) { + return { conflictEdgeId: edge.id }; + } + } + return { colors }; } diff --git a/src/algorithms/csr.ts b/src/algorithms/csr.ts index 76d7fd3..c18a25b 100644 --- a/src/algorithms/csr.ts +++ b/src/algorithms/csr.ts @@ -1,5 +1,5 @@ -import type { Graph, GraphMode } from '../types'; -import { getIndex, type GraphIndex } from '../indexing'; +import type { Graph, GraphMode, GraphNode } from '../types'; +import { getIndex, onIndexNodeReplaced, type GraphIndex } from '../indexing'; import { getEdgeMode } from '../mode'; /** @@ -19,6 +19,13 @@ import { getEdgeMode } from '../mode'; * in-place field mutation requires `invalidateIndex()` (same as the index). */ export interface GraphCSR { + /** + * Snapshot of `graph.nodes` at build time (same positions as the arcs). + * Traversal iterators serve node objects from here, so an in-flight + * iterator is insulated from later structural mutations without paying a + * per-iterator array copy. + */ + nodes: GraphNode[]; /** node position → node id (same order as `graph.nodes`) */ ids: string[]; /** node id → node position */ @@ -39,6 +46,12 @@ export interface GraphCSR { * weight; custom `getWeight` callbacks need their own scan. */ firstNegativeEdge: number; + /** + * Whether any edge's *effective* mode is not `'directed'` (dangling edges + * included). Lets directed-only algorithms (topological sort) bail out in + * O(1) instead of re-scanning every edge per call. + */ + hasNonDirected: boolean; } interface CsrCacheEntry { @@ -49,6 +62,18 @@ interface CsrCacheEntry { const csrCache = new WeakMap(); +// updateNode replaces the node object without touching the arrays, so no +// version/staleness check can catch it — patch the cached snapshot slot +// directly (positions are stable while node count is unchanged). +onIndexNodeReplaced((idx, arrayIndex, node) => { + const cached = csrCache.get(idx); + if (cached === undefined) return; + const nodes = cached.csr.nodes; + if (arrayIndex < nodes.length && nodes[arrayIndex].id === node.id) { + nodes[arrayIndex] = node; + } +}); + /** Get or lazily (re)build the CSR snapshot for a graph. */ export function getCSR(graph: Graph): GraphCSR { const idx = getIndex(graph); @@ -64,10 +89,11 @@ export function getCSR(graph: Graph): GraphCSR { function buildCSR(graph: Graph): GraphCSR { const n = graph.nodes.length; const m = graph.edges.length; + const nodes = graph.nodes.slice(); const ids = new Array(n); const indexOf = new Map(); for (let i = 0; i < n; i++) { - ids[i] = graph.nodes[i].id; + ids[i] = nodes[i].id; indexOf.set(ids[i], i); } @@ -78,11 +104,14 @@ function buildCSR(graph: Graph): GraphCSR { const outCounts = new Int32Array(n); const inCounts = new Int32Array(n); let firstNegativeEdge = -1; + let hasNonDirected = false; for (let e = 0; e < m; e++) { const edge = graph.edges[e]; if (firstNegativeEdge === -1 && (edge.weight ?? 1) < 0) { firstNegativeEdge = e; } + const nd = getEdgeMode(graph, edge) !== 'directed' ? 1 : 0; + if (nd) hasNonDirected = true; const s = indexOf.get(edge.sourceId); const t = indexOf.get(edge.targetId); if (s === undefined || t === undefined) { @@ -93,7 +122,6 @@ function buildCSR(graph: Graph): GraphCSR { } srcPos[e] = s; tgtPos[e] = t; - const nd = getEdgeMode(graph, edge) !== 'directed' ? 1 : 0; nonDirected[e] = nd; outCounts[s]++; inCounts[t]++; @@ -133,6 +161,7 @@ function buildCSR(graph: Graph): GraphCSR { } return { + nodes, ids, indexOf, outOffsets, @@ -142,5 +171,113 @@ function buildCSR(graph: Graph): GraphCSR { inOrigins, inEdgeIndex, firstNegativeEdge, + hasNonDirected, }; } + +/** + * Default arc weights (`edge.weight ?? 1`) for the CSR's out-arcs and + * in-arcs, as flat `Float64Array`s aligned with `outEdgeIndex`/`inEdgeIndex`. + * + * Weighted hot loops (Dijkstra, A*, bidirectional search) read these instead + * of loading the edge object per arc — no property loads, no `?? 1` megamorphic + * hits, and the arrays persist across calls. Cached per CSR snapshot, so the + * staleness contract is inherited: `updateEdge` weight changes bump the index + * version, which rebuilds the CSR and thereby this cache. Only used when the + * caller did not supply a custom `getWeight`. + */ +export interface ArcWeights { + out: Float64Array; + in: Float64Array; +} + +const arcWeightCache = new WeakMap(); + +/** + * Compact traversable arcs in *edge order*: one arc per directed edge, plus a + * reverse arc per non-directed edge (immediately after its forward arc). + * Endpoints are CSR positions; `weight` holds the default (`edge.weight ?? 1`). + * This is the layout edge-relaxation algorithms (Bellman-Ford) want — cached + * per CSR snapshot so repeated queries skip the id→position conversion. + */ +export interface EdgeOrderArcs { + count: number; + from: Int32Array; + to: Int32Array; + /** Index into `graph.edges` per arc. */ + edge: Int32Array; + weight: Float64Array; +} + +const edgeOrderArcCache = new WeakMap(); + +/** + * Edge-list in-degrees per node position (dangling *sources* still count + * toward an existing target, unlike the CSR arcs which skip such edges). + * Kahn-style algorithms copy this instead of re-scanning the edge list — + * the id→position Map lookups per edge are the expensive part. + */ +const inDegreeCache = new WeakMap(); + +export function getEdgeListInDegrees(graph: Graph, csr: GraphCSR): Int32Array { + const cached = inDegreeCache.get(csr); + if (cached) return cached; + const inDegree = new Int32Array(csr.ids.length); + for (const edge of graph.edges) { + const t = csr.indexOf.get(edge.targetId); + if (t !== undefined) inDegree[t]++; + } + inDegreeCache.set(csr, inDegree); + return inDegree; +} + +export function getEdgeOrderArcs(graph: Graph, csr: GraphCSR): EdgeOrderArcs { + const cached = edgeOrderArcCache.get(csr); + if (cached) return cached; + + const m = graph.edges.length; + const from = new Int32Array(2 * m); + const to = new Int32Array(2 * m); + const edgeIndex = new Int32Array(2 * m); + const weight = new Float64Array(2 * m); + let count = 0; + for (let e = 0; e < m; e++) { + const edge = graph.edges[e]; + const s = csr.indexOf.get(edge.sourceId); + const t = csr.indexOf.get(edge.targetId); + if (s === undefined || t === undefined) continue; // dangling — no arc + const w = edge.weight ?? 1; + from[count] = s; + to[count] = t; + weight[count] = w; + edgeIndex[count++] = e; + if (getEdgeMode(graph, edge) !== 'directed') { + from[count] = t; + to[count] = s; + weight[count] = w; + edgeIndex[count++] = e; + } + } + + const arcs: EdgeOrderArcs = { count, from, to, edge: edgeIndex, weight }; + edgeOrderArcCache.set(csr, arcs); + return arcs; +} + +export function getArcWeights(graph: Graph, csr: GraphCSR): ArcWeights { + const cached = arcWeightCache.get(csr); + if (cached) return cached; + + const edges = graph.edges; + const out = new Float64Array(csr.outEdgeIndex.length); + for (let a = 0; a < out.length; a++) { + out[a] = edges[csr.outEdgeIndex[a]].weight ?? 1; + } + const inW = new Float64Array(csr.inEdgeIndex.length); + for (let a = 0; a < inW.length; a++) { + inW[a] = edges[csr.inEdgeIndex[a]].weight ?? 1; + } + const weights: ArcWeights = { out, in: inW }; + arcWeightCache.set(csr, weights); + return weights; +} diff --git a/src/algorithms/paths.ts b/src/algorithms/paths.ts index 1f07cc3..6c5701e 100644 --- a/src/algorithms/paths.ts +++ b/src/algorithms/paths.ts @@ -15,13 +15,26 @@ import { getEffectiveModeKind, getNeighborEdges, getNeighborEdgesAll, - getNeighborIds, resolveFrom, resolveFromIds, } from './shared'; -import { getCSR } from './csr'; +import { getArcWeights, getCSR, getEdgeOrderArcs } from './csr'; import { throwIfAborted } from './abort'; +/** Cold path: load the offending edge and throw the negative-weight error. */ +function throwNegativeWeight( + graph: Graph, + edgeIndex: number, + weight: number, + algorithmName: string, + remedy: string, +): never { + const edge = graph.edges[edgeIndex]; + throw new Error( + `Negative edge weight ${weight} on edge "${edge.sourceId}->${edge.targetId}" (id "${edge.id}"): ${algorithmName} requires non-negative weights. ${remedy}`, + ); +} + /** * Flat binary min-heap of `(distance, node position)` entries in parallel * typed arrays. The Dijkstra/A* hot loops push one entry per relaxation, so @@ -197,7 +210,9 @@ function computeShortestDistances( } } } else { - const effectiveWeight = getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); + // Default weights come from the CSR's cached per-arc Float64Array — no + // edge-object loads in the hot loop. Custom getWeight loads the edge. + const arcWeights = getWeight ? undefined : getArcWeights(graph, csr).out; const visited = new Uint8Array(n); const pq = new TypedMinHeap(n); pq.push(0, source); @@ -213,11 +228,16 @@ function computeShortestDistances( visited[u] = 1; for (let a = csr.outOffsets[u]; a < csr.outOffsets[u + 1]; a++) { - const edge = graph.edges[csr.outEdgeIndex[a]] as GraphEdge; - const weight = effectiveWeight(edge); + const weight = arcWeights + ? arcWeights[a] + : getWeight!(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); if (weight < 0) { - throw new Error( - `Negative edge weight ${weight} on edge "${edge.sourceId}->${edge.targetId}" (id "${edge.id}"): Dijkstra requires non-negative weights. Use { algorithm: 'bellman-ford' } instead.`, + throwNegativeWeight( + graph, + csr.outEdgeIndex[a], + weight, + 'Dijkstra', + "Use { algorithm: 'bellman-ford' } instead.", ); } const v = csr.outTargets[a]; @@ -238,124 +258,87 @@ function computeShortestDistances( } /** - * Bellman-Ford adapted to the typed-array result shape. The O(VE) relaxation - * dominates, so the id→position conversion here is noise — and it keeps a - * single reconstruction path for both algorithms. + * Bellman-Ford over compact typed arc arrays. Arcs are laid out in edge + * order (forward, then the reverse arc for non-directed edges) so the + * relaxation order — and therefore tie-predecessor order — matches the + * classic edge-list formulation. Weights are evaluated once per arc. */ function bellmanFordTyped( graph: Graph, sourceId: string, getWeight?: (edge: GraphEdge) => number, ): ShortestDistancesResult { - const { dist, prev } = bellmanFord(graph, sourceId, getWeight); const csr = getCSR(graph); - const idx = getIndex(graph); const n = csr.ids.length; - const distArr = new Float64Array(n).fill(Infinity); - const prevArr: Array = new Array(n); - - for (const [id, distance] of dist) { - const pos = csr.indexOf.get(id); - if (pos === undefined) continue; - distArr[pos] = distance; - const pairs: number[] = []; - for (const { from, edge } of prev.get(id) ?? []) { - const fromPos = csr.indexOf.get(from); - const edgeIndex = idx.edgeById.get(edge.id); - if (fromPos === undefined || edgeIndex === undefined) continue; - pairs.push(fromPos, edgeIndex); - } - prevArr[pos] = pairs; + const source = csr.indexOf.get(sourceId); + if (source === undefined) { + return { + source: -1, + distArr: new Float64Array(0), + prevArr: [], + stopDistance: Infinity, + }; } - return { - source: csr.indexOf.get(sourceId) ?? -1, - distArr, - prevArr, - stopDistance: Infinity, - }; -} - -function bellmanFord( - graph: Graph, - sourceId: string, - getWeight?: (edge: GraphEdge) => number, -): { - dist: Map; - prev: Map }>>; -} { - const dist = new Map(); - const prev = new Map }>>(); - const effectiveWeight = getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); - - for (const node of graph.nodes) { - dist.set(node.id, Infinity); - prev.set(node.id, []); - } - dist.set(sourceId, 0); - - const directedEdges: Array<{ - fromId: string; - toId: string; - edge: GraphEdge; - }> = []; - for (const edge of graph.edges) { - directedEdges.push({ - fromId: edge.sourceId, - toId: edge.targetId, - edge: edge as GraphEdge, - }); - if (getEdgeMode(graph, edge) !== 'directed') { - directedEdges.push({ - fromId: edge.targetId, - toId: edge.sourceId, - edge: edge as GraphEdge, - }); + // Cached compact arcs in edge order; custom weights overlay the endpoints + const arcs = getEdgeOrderArcs(graph, csr); + const arcCount = arcs.count; + const arcFrom = arcs.from; + const arcTo = arcs.to; + const arcEdge = arcs.edge; + let arcWeight = arcs.weight; + if (getWeight) { + arcWeight = new Float64Array(arcCount); + for (let a = 0; a < arcCount; a++) { + arcWeight[a] = getWeight(graph.edges[arcEdge[a]] as GraphEdge); } } - for (let i = 0; i < graph.nodes.length - 1; i++) { - let changed = false; - for (const { fromId, toId, edge } of directedEdges) { - const distance = dist.get(fromId)!; - if (distance === Infinity) continue; - const weight = effectiveWeight(edge); - const nextDistance = distance + weight; - const existing = dist.get(toId)!; + const distArr = new Float64Array(n).fill(Infinity); + const prevArr: Array = new Array(n); + distArr[source] = 0; + prevArr[source] = []; + for (let round = 1; round < n; round++) { + let changed = false; + for (let a = 0; a < arcCount; a++) { + const du = distArr[arcFrom[a]]; + if (du === Infinity) continue; + const nextDistance = du + arcWeight[a]; + const t = arcTo[a]; + const existing = distArr[t]; if (nextDistance < existing) { - dist.set(toId, nextDistance); - prev.set(toId, [{ from: fromId, edge }]); + distArr[t] = nextDistance; + prevArr[t] = [arcFrom[a], arcEdge[a]]; changed = true; } else if (nextDistance === existing && existing !== Infinity) { - const predecessors = prev.get(toId)!; - if (!predecessors.some((entry) => entry.from === fromId && entry.edge === edge)) { - predecessors.push({ from: fromId, edge }); + const pairs = prevArr[t]!; + const from = arcFrom[a]; + const edgeIndex = arcEdge[a]; + let seen = false; + for (let k = 0; k < pairs.length; k += 2) { + if (pairs[k] === from && pairs[k + 1] === edgeIndex) { + seen = true; + break; + } } + if (!seen) pairs.push(from, edgeIndex); } } if (!changed) break; } - for (const { fromId, toId, edge } of directedEdges) { - const distance = dist.get(fromId)!; - if (distance === Infinity) continue; - const weight = effectiveWeight(edge); - if (distance + weight < dist.get(toId)!) { + for (let a = 0; a < arcCount; a++) { + const du = distArr[arcFrom[a]]; + if (du === Infinity) continue; + if (du + arcWeight[a] < distArr[arcTo[a]]) { throw new Error( 'Graph contains a negative-weight cycle reachable from the source node', ); } } - for (const [id, distance] of dist) { - if (distance === Infinity) { - dist.delete(id); - prev.delete(id); - } - } - - return { dist, prev }; + return { source, distArr, prevArr, stopDistance: Infinity }; } function* reconstructPathsAt( @@ -364,42 +347,47 @@ function* reconstructPathsAt( sourceNode: GraphNode, sourcePos: number, targetPos: number, - onPath: Set = new Set(), ): Generator> { - if (targetPos === sourcePos) { - yield { source: sourceNode, steps: [] }; - return; - } + // Walk the predecessor pairs backward from the target with one shared step + // buffer; each complete path is materialized exactly once (one O(length) + // reversed copy), instead of re-spreading the prefix at every recursion + // level. Track nodes on the current partial path — zero-weight cycles can + // make the predecessor structure cyclic via equal-distance tie + // predecessors, so never revisit a node already on the path being built. + const stepsBackward: GraphStep[] = []; + const onPath = new Set(); + + function* walk(pos: number): Generator> { + if (pos === sourcePos) { + const length = stepsBackward.length; + const steps = new Array>(length); + for (let s = 0; s < length; s++) { + steps[s] = stepsBackward[length - 1 - s]; + } + yield { source: sourceNode, steps }; + return; + } - const pairs = prevArr[targetPos]; - if (!pairs || pairs.length === 0) return; - - // CSR positions are `graph.nodes` positions, so no id lookup is needed. - const targetNode = graph.nodes[targetPos] as GraphNode; - - // Track nodes on the current partial path — zero-weight cycles can make - // the predecessor structure cyclic via equal-distance tie predecessors, - // so never revisit a node already on the path being reconstructed. - onPath.add(targetPos); - for (let k = 0; k < pairs.length; k += 2) { - const fromPos = pairs[k]; - if (onPath.has(fromPos)) continue; - const edge = graph.edges[pairs[k + 1]] as GraphEdge; - for (const prefix of reconstructPathsAt( - graph, - prevArr, - sourceNode, - sourcePos, - fromPos, - onPath, - )) { - yield { - source: sourceNode, - steps: [...prefix.steps, { edge, node: targetNode }], - }; + const pairs = prevArr[pos]; + if (!pairs || pairs.length === 0) return; + + // CSR positions are `graph.nodes` positions, so no id lookup is needed. + const node = graph.nodes[pos] as GraphNode; + onPath.add(pos); + for (let k = 0; k < pairs.length; k += 2) { + const fromPos = pairs[k]; + if (onPath.has(fromPos)) continue; + stepsBackward.push({ + edge: graph.edges[pairs[k + 1]] as GraphEdge, + node, + }); + yield* walk(fromPos); + stepsBackward.pop(); } + onPath.delete(pos); } - onPath.delete(targetPos); + + yield* walk(targetPos); } export function* genShortestPaths( @@ -496,18 +484,102 @@ export function getShortestPath( } // Single-pair queries use bidirectional Dijkstra — on random/small-world // graphs the two half-balls meet long before a unidirectional search would - // reach the target. Bellman-Ford (negative weights) keeps the full search. + // reach the target. Bellman-Ford (negative weights) keeps the full + // relaxation but skips tie-predecessor bookkeeping for the one path. + const sourceId = resolveFrom( + graph, + typeof opts.from === 'string' ? { from: opts.from } : undefined, + ); if (opts.algorithm !== 'bellman-ford') { - const sourceId = resolveFrom( - graph, - typeof opts.from === 'string' ? { from: opts.from } : undefined, - ); return bidirectionalShortestPath(graph, sourceId, opts.to, opts.getWeight); } - for (const path of genShortestPaths(graph, opts)) { - return path; + return bellmanFordSinglePath(graph, sourceId, opts.to, opts.getWeight); +} + +/** + * Single-pair Bellman-Ford: same relaxation (and negative-cycle contract) as + * the all-targets search, but with scalar predecessors — the returned path + * matches the first path {@link genShortestPaths} would yield, because that + * enumeration follows the predecessor recorded by the last strict improvement. + */ +function bellmanFordSinglePath( + graph: Graph, + sourceId: string, + targetId: string, + getWeight?: (edge: GraphEdge) => number, +): GraphPath | undefined { + const csr = getCSR(graph); + const source = csr.indexOf.get(sourceId); + const target = csr.indexOf.get(targetId); + if (source === undefined) { + // Unknown source: only the explicit self-path exists + return sourceId === targetId + ? { + source: graph.nodes.find((node) => node.id === sourceId)!, + steps: [], + } + : undefined; } - return undefined; + if (target === undefined) return undefined; + + const n = csr.ids.length; + const arcs = getEdgeOrderArcs(graph, csr); + const arcCount = arcs.count; + const arcFrom = arcs.from; + const arcTo = arcs.to; + const arcEdge = arcs.edge; + let arcWeight = arcs.weight; + if (getWeight) { + arcWeight = new Float64Array(arcCount); + for (let a = 0; a < arcCount; a++) { + arcWeight[a] = getWeight(graph.edges[arcEdge[a]] as GraphEdge); + } + } + + const distArr = new Float64Array(n).fill(Infinity); + const prevNode = new Int32Array(n).fill(-1); + const prevEdge = new Int32Array(n).fill(-1); + distArr[source] = 0; + + for (let round = 1; round < n; round++) { + let changed = false; + for (let a = 0; a < arcCount; a++) { + const du = distArr[arcFrom[a]]; + if (du === Infinity) continue; + const nextDistance = du + arcWeight[a]; + const t = arcTo[a]; + if (nextDistance < distArr[t]) { + distArr[t] = nextDistance; + prevNode[t] = arcFrom[a]; + prevEdge[t] = arcEdge[a]; + changed = true; + } + } + if (!changed) break; + } + + for (let a = 0; a < arcCount; a++) { + const du = distArr[arcFrom[a]]; + if (du === Infinity) continue; + if (du + arcWeight[a] < distArr[arcTo[a]]) { + throw new Error( + 'Graph contains a negative-weight cycle reachable from the source node', + ); + } + } + + if (distArr[target] === Infinity) return undefined; + + const sourceNode = graph.nodes[source] as GraphNode; + const steps: GraphStep[] = []; + for (let v = target; v !== source; v = prevNode[v]) { + steps.push({ + edge: graph.edges[prevEdge[v]] as GraphEdge, + node: graph.nodes[v] as GraphNode, + }); + } + steps.reverse(); + return { source: sourceNode, steps }; } /** @@ -576,7 +648,7 @@ function bidirectionalShortestPath( "Use { algorithm: 'bellman-ford' } instead.", ); - const effectiveWeight = getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); + const arcWeights = getWeight ? undefined : getArcWeights(graph, csr); const n = csr.ids.length; const distF = new Float64Array(n).fill(Infinity); const distB = new Float64Array(n).fill(Infinity); @@ -621,8 +693,9 @@ function bidirectionalShortestPath( pqF.pop(); settledF[u] = 1; for (let a = csr.outOffsets[u]; a < csr.outOffsets[u + 1]; a++) { - const edge = graph.edges[csr.outEdgeIndex[a]] as GraphEdge; - const weight = effectiveWeight(edge); + const weight = arcWeights + ? arcWeights.out[a] + : getWeight!(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); const v = csr.outTargets[a]; const next = d + weight; if (next < distF[v]) { @@ -646,8 +719,9 @@ function bidirectionalShortestPath( pqB.pop(); settledB[u] = 1; for (let a = csr.inOffsets[u]; a < csr.inOffsets[u + 1]; a++) { - const edge = graph.edges[csr.inEdgeIndex[a]] as GraphEdge; - const weight = effectiveWeight(edge); + const weight = arcWeights + ? arcWeights.in[a] + : getWeight!(graph.edges[csr.inEdgeIndex[a]] as GraphEdge); const v = csr.inOrigins[a]; const next = d + weight; if (next < distB[v]) { @@ -772,47 +846,68 @@ export function getSimplePath( export function getStronglyConnectedComponents( graph: Graph, ): GraphNode[][] { - const idx = getIndex(graph); - let indexCounter = 0; - const nodeIndex = new Map(); - const lowlink = new Map(); - const onStack = new Set(); - const stack: string[] = []; + // Iterative Tarjan over the CSR out-arcs (non-directed edges contribute + // arcs both ways there, i.e. mutual reachability). One pass, typed-array + // state, no recursion — stack-safe on deep graphs. + const csr = getCSR(graph); + const n = csr.ids.length; + const nodes = graph.nodes; + const outOffsets = csr.outOffsets; + const outTargets = csr.outTargets; const result: GraphNode[][] = []; - function strongconnect(id: string): void { - nodeIndex.set(id, indexCounter); - lowlink.set(id, indexCounter); - indexCounter++; - stack.push(id); - onStack.add(id); - - // getNeighborIds traverses non-directed (undirected/bidirectional) edges - // in both directions — such edges imply mutual reachability. - for (const neighborId of getNeighborIds(graph, id)) { - if (!nodeIndex.has(neighborId)) { - strongconnect(neighborId); - lowlink.set(id, Math.min(lowlink.get(id)!, lowlink.get(neighborId)!)); - } else if (onStack.has(neighborId)) { - lowlink.set(id, Math.min(lowlink.get(id)!, nodeIndex.get(neighborId)!)); + const order = new Int32Array(n).fill(-1); // discovery index; -1 = unvisited + const lowlink = new Int32Array(n); + const onStack = new Uint8Array(n); + const sccStack = new Int32Array(n); + let sccTop = 0; + // Explicit DFS call stack: node + its next-arc cursor per frame + const frameNodes = new Int32Array(n); + const frameArcs = new Int32Array(n); + let counter = 0; + + for (let root = 0; root < n; root++) { + if (order[root] !== -1) continue; + let top = 0; + frameNodes[0] = root; + frameArcs[0] = outOffsets[root]; + order[root] = lowlink[root] = counter++; + sccStack[sccTop++] = root; + onStack[root] = 1; + + while (top >= 0) { + const u = frameNodes[top]; + const a = frameArcs[top]; + if (a < outOffsets[u + 1]) { + frameArcs[top] = a + 1; + const v = outTargets[a]; + if (order[v] === -1) { + order[v] = lowlink[v] = counter++; + sccStack[sccTop++] = v; + onStack[v] = 1; + top++; + frameNodes[top] = v; + frameArcs[top] = outOffsets[v]; + } else if (onStack[v] && order[v] < lowlink[u]) { + lowlink[u] = order[v]; + } + } else { + if (lowlink[u] === order[u]) { + const component: GraphNode[] = []; + for (;;) { + const w = sccStack[--sccTop]; + onStack[w] = 0; + component.push(nodes[w]); + if (w === u) break; + } + result.push(component); + } + top--; + if (top >= 0 && lowlink[u] < lowlink[frameNodes[top]]) { + lowlink[frameNodes[top]] = lowlink[u]; + } } } - - if (lowlink.get(id) === nodeIndex.get(id)) { - const component: GraphNode[] = []; - let neighborId: string; - do { - neighborId = stack.pop()!; - onStack.delete(neighborId); - const ni = idx.nodeById.get(neighborId); - if (ni !== undefined) component.push(graph.nodes[ni]); - } while (neighborId !== id); - result.push(component); - } - } - - for (const node of graph.nodes) { - if (!nodeIndex.has(node.id)) strongconnect(node.id); } return result; @@ -1062,59 +1157,97 @@ function floydWarshallAllPaths( getWeight?: (edge: GraphEdge) => number, signal?: AbortSignal, ): GraphPath[] { - const idx = getIndex(graph); const weight = getWeight ?? ((edge: GraphEdge) => edge.weight ?? 1); - const nodeIds = graph.nodes.map((node) => node.id); - const nodeCount = nodeIds.length; - - const indexOf = new Map(); - for (let i = 0; i < nodeCount; i++) indexOf.set(nodeIds[i], i); - + const nodes = graph.nodes; + const nodeCount = nodes.length; + const csr = getCSR(graph); // positions match graph.nodes order const INF = Infinity; - const dist: number[][] = Array.from({ length: nodeCount }, () => - Array(nodeCount).fill(INF), - ); - const prev: Array }>>> = - Array.from({ length: nodeCount }, () => - Array.from({ length: nodeCount }, () => []), - ); - for (let i = 0; i < nodeCount; i++) dist[i][i] = 0; - - for (const edge of graph.edges) { - const source = indexOf.get(edge.sourceId)!; - const target = indexOf.get(edge.targetId)!; - const edgeWeight = weight(edge as GraphEdge); - if (edgeWeight < dist[source][target]) { - dist[source][target] = edgeWeight; - prev[source][target] = [{ from: source, edge: edge as GraphEdge }]; - } else if (edgeWeight === dist[source][target] && edgeWeight < INF) { - prev[source][target].push({ from: source, edge: edge as GraphEdge }); + // Flat n×n distance matrix; tie predecessors as flat (fromPos, edgeIndex) + // pair lists. On a strict improvement the winning list is *shared* (not + // cloned); `owned` tracks which slots may be appended to in place, and a + // shared list is cloned on first write (copy-on-write). + const dist = new Float64Array(nodeCount * nodeCount).fill(INF); + const prev: Array = new Array(nodeCount * nodeCount); + const owned = new Uint8Array(nodeCount * nodeCount); + for (let i = 0; i < nodeCount; i++) dist[i * nodeCount + i] = 0; + + for (let e = 0; e < graph.edges.length; e++) { + const edge = graph.edges[e] as GraphEdge; + 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 forward = s * nodeCount + t; + if (edgeWeight < dist[forward]) { + dist[forward] = edgeWeight; + prev[forward] = [s, e]; + owned[forward] = 1; + } else if (edgeWeight === dist[forward] && edgeWeight < INF && s !== t) { + // A tying self-loop (zero-weight, s === t) is never recorded: it can't + // extend any enumerated path — only cycle it — and a recorded diagonal + // predecessor would propagate through tie merging into self-referential + // lists. (Negative self-loops still take the strict-improvement branch + // above and surface via the negative-cycle check.) + prev[forward]!.push(s, e); } if (getEdgeMode(graph, edge) !== 'directed') { - if (edgeWeight < dist[target][source]) { - dist[target][source] = edgeWeight; - prev[target][source] = [{ from: target, edge: edge as GraphEdge }]; - } else if (edgeWeight === dist[target][source] && edgeWeight < INF) { - prev[target][source].push({ from: target, edge: edge as GraphEdge }); + const backward = t * nodeCount + s; + if (edgeWeight < dist[backward]) { + dist[backward] = edgeWeight; + prev[backward] = [t, e]; + owned[backward] = 1; + } else if (edgeWeight === dist[backward] && edgeWeight < INF && s !== t) { + prev[backward]!.push(t, e); } } } for (let k = 0; k < nodeCount; k++) { throwIfAborted(signal); + const rowK = k * nodeCount; for (let i = 0; i < nodeCount; i++) { + const dik = dist[i * nodeCount + k]; + if (dik === INF) continue; + const rowI = i * nodeCount; for (let j = 0; j < nodeCount; j++) { - if (dist[i][k] === INF || dist[k][j] === INF) continue; - const nextDistance = dist[i][k] + dist[k][j]; - if (nextDistance < dist[i][j]) { - dist[i][j] = nextDistance; - prev[i][j] = prev[k][j].map((entry) => ({ ...entry })); - } else if (nextDistance === dist[i][j] && nextDistance < INF) { - for (const entry of prev[k][j]) { - if (!prev[i][j].some((existing) => existing.edge.id === entry.edge.id)) { - prev[i][j].push({ ...entry }); + const dkj = dist[rowK + j]; + if (dkj === INF) continue; + const nextDistance = dik + dkj; + const cell = rowI + j; + const current = dist[cell]; + if (nextDistance < current) { + dist[cell] = nextDistance; + prev[cell] = prev[rowK + j]; + owned[cell] = 0; // shared with row k — clone before any append + } else if (nextDistance === current && nextDistance < INF) { + const incoming = prev[rowK + j]; + if (incoming === undefined || incoming.length === 0) continue; + // Shared reference (a prior strict improvement copied row k's + // list): contents are identical, merging is a no-op + if (incoming === prev[cell]) continue; + let pairs = prev[cell]; + if (pairs === undefined) { + prev[cell] = pairs = []; + owned[cell] = 1; + } + // Merge with dedup by edge index (matches the previous + // edge-id-based dedup; edge indices are unique per edge) + for (let p = 1; p < incoming.length; p += 2) { + let seen = false; + for (let q = 1; q < pairs.length; q += 2) { + if (pairs[q] === incoming[p]) { + seen = true; + break; + } + } + if (!seen) { + if (!owned[cell]) { + prev[cell] = pairs = pairs.slice(); + owned[cell] = 1; + } + pairs.push(incoming[p - 1], incoming[p]); } } } @@ -1125,66 +1258,60 @@ function floydWarshallAllPaths( // A negative self-distance means a negative cycle: all-pairs shortest // paths are undefined and reconstruction would loop forever. for (let i = 0; i < nodeCount; i++) { - if (dist[i][i] < 0) { + if (dist[i * nodeCount + i] < 0) { throw new Error( - `Negative cycle detected through node "${nodeIds[i]}": all-pairs shortest paths are undefined. ` + + `Negative cycle detected through node "${nodes[i].id}": all-pairs shortest paths are undefined. ` + `Remove the negative cycle, or use getShortestPaths with { algorithm: 'bellman-ford' } per source to locate it.`, ); } } + // Enumerate every tie path per pair by walking the predecessor lists + // backward from the target with one shared step buffer — each emitted path + // costs a single O(length) copy, not a spread per recursion level. const results: GraphPath[] = []; - for (let i = 0; i < nodeCount; i++) { - const sourceNi = idx.nodeById.get(nodeIds[i]); - if (sourceNi === undefined) continue; - const sourceNode = graph.nodes[sourceNi]; - - for (let j = 0; j < nodeCount; j++) { - if (i === j || dist[i][j] === INF) continue; - results.push( - ...fwReconstruct(graph, prev, nodeIds, sourceNode, i, j), - ); + const edges = graph.edges; + const stepsBackward: GraphStep[] = []; + // Zero-weight cycles can make tie-predecessor lists cyclic; never revisit + // a node already on the path being built (same guard as reconstructPathsAt) + const onPath = new Set(); + let sourceIdx = 0; + let sourceNode = nodes[0] as GraphNode; + + const collect = (j: number): void => { + if (j === sourceIdx) { + const length = stepsBackward.length; + const steps = new Array>(length); + for (let s = 0; s < length; s++) { + steps[s] = stepsBackward[length - 1 - s]; + } + results.push({ source: sourceNode, steps }); + return; } - } - - return results; -} - -function fwReconstruct( - graph: Graph, - prev: Array }>>>, - nodeIds: string[], - sourceNode: GraphNode, - sourceIdx: number, - targetIdx: number, -): GraphPath[] { - if (sourceIdx === targetIdx) { - return [{ source: sourceNode, steps: [] }]; - } - - const predecessors = prev[sourceIdx][targetIdx]; - if (predecessors.length === 0) return []; - - const idx = getIndex(graph); - const targetNi = idx.nodeById.get(nodeIds[targetIdx]); - if (targetNi === undefined) return []; - const targetNode = graph.nodes[targetNi]; - - const results: GraphPath[] = []; - for (const { from, edge } of predecessors) { - const prefixPaths = fwReconstruct( - graph, - prev, - nodeIds, - sourceNode, - sourceIdx, - from, - ); - for (const prefix of prefixPaths) { - results.push({ - source: sourceNode, - steps: [...prefix.steps, { edge, node: targetNode }], + const pairs = prev[sourceIdx * nodeCount + j]; + if (pairs === undefined || pairs.length === 0) return; + const targetNode = nodes[j] as GraphNode; + onPath.add(j); + for (let p = 0; p < pairs.length; p += 2) { + const from = pairs[p]; + if (onPath.has(from)) continue; + stepsBackward.push({ + edge: edges[pairs[p + 1]] as GraphEdge, + node: targetNode, }); + collect(from); + stepsBackward.pop(); + } + onPath.delete(j); + }; + + for (let i = 0; i < nodeCount; i++) { + sourceIdx = i; + sourceNode = nodes[i] as GraphNode; + const rowI = i * nodeCount; + for (let j = 0; j < nodeCount; j++) { + if (i === j || dist[rowI + j] === INF) continue; + collect(j); } } @@ -1237,6 +1364,7 @@ export function getAStarPath( const n = csr.ids.length; const source = csr.indexOf.get(sourceId)!; const target = csr.indexOf.get(targetId)!; + const arcWeights = opts.getWeight ? undefined : getArcWeights(graph, csr); const gScore = new Float64Array(n).fill(Infinity); // Predecessor as (fromPos, edgeIndex); -1 = none @@ -1280,8 +1408,9 @@ export function getAStarPath( closed[current] = 1; for (let a = csr.outOffsets[current]; a < csr.outOffsets[current + 1]; a++) { - const edge = graph.edges[csr.outEdgeIndex[a]] as GraphEdge; - const weight = getWeight(edge); + const weight = arcWeights + ? arcWeights.out[a] + : getWeight(graph.edges[csr.outEdgeIndex[a]] as GraphEdge); const neighbor = csr.outTargets[a]; if (closed[neighbor]) continue; diff --git a/src/algorithms/traversal.ts b/src/algorithms/traversal.ts index 919ccfa..0445371 100644 --- a/src/algorithms/traversal.ts +++ b/src/algorithms/traversal.ts @@ -12,7 +12,7 @@ import { } from './shared'; import { getEdgeMode } from '../mode'; import { genCycles, getStronglyConnectedComponents } from './paths'; -import { getCSR } from './csr'; +import { getCSR, getEdgeListInDegrees } from './csr'; import { getSubgraph } from '../transforms'; function getTraversalOptions( @@ -56,7 +56,10 @@ function getStartPositions( } function getTraversalNodes(graph: Graph): GraphNode[] { - return [...graph.nodes]; + // The CSR carries a build-time snapshot of graph.nodes; reusing it keeps + // in-flight iterators insulated from later structural mutations without a + // per-iterator array copy (mutations rebuild the CSR for fresh iterators). + return getCSR(graph).nodes as GraphNode[]; } function getReachableWithinRadius( @@ -100,55 +103,227 @@ function getReachableWithinRadius( return reached; } -export function* genBFS( - graph: Graph, - startOrOptions: string | TraversalSearchOptions, -): Generator> { - const csr = getCSR(graph); - const nodes = getTraversalNodes(graph); - const options = getTraversalOptions(startOrOptions); - const starts = getStartPositions(csr.indexOf, options.from); - if (starts.length === 0) return; +/** + * Runtime base class providing the ES iterator-helper prototype (`.map`, + * `.take`, …) when the host supports it. The traversal iterators below are + * hand-rolled rather than generator functions: a plain `next()` method avoids + * the generator resume machinery, which dominates full-graph traversals. + * Setup stays lazy (first `next()` call) to match generator semantics — + * including where validation errors are thrown. + */ +const IteratorBase = ((globalThis as any).Iterator ?? + class {}) as new () => object; + +const DONE: IteratorReturnResult = { + value: undefined, + done: true, +}; + +abstract class LazyTraversalIterator extends IteratorBase { + protected started = false; + protected finished = false; + protected csr!: ReturnType; + protected nodes!: GraphNode[]; + protected direction!: TraversalDirection; + protected radius!: number; + protected starts!: number[]; + // Hot CSR views + direction flags, hoisted once at setup + protected useOut = true; + protected useIn = false; + protected outOffsets!: Int32Array; + protected outTargets!: Int32Array; + protected inOffsets!: Int32Array; + protected inOrigins!: Int32Array; + + constructor( + protected graph: Graph, + private startOrOptions: string | TraversalSearchOptions, + ) { + super(); + } - const n = csr.ids.length; - const visited = new Uint8Array(n); - const queue = new Int32Array(n); - const depths = new Float64Array(n); - let head = 0; - let tail = 0; - for (const start of starts) { - visited[start] = 1; - queue[tail++] = start; + /** + * Run one-time setup, matching generator error semantics: if setup throws + * (e.g. radius validation), the error surfaces on this `next()` call and + * the iterator is permanently exhausted — a later `next()` returns done + * instead of touching half-initialized state. + */ + protected ensureStarted(): void { + this.started = true; + try { + this.setup(); + } catch (error) { + this.finished = true; + throw error; + } } - while (head < tail) { - const u = queue[head++]; - yield nodes[u]; - if (depths[u] >= options.radius) continue; + protected setup(): void { + this.csr = getCSR(this.graph); + this.nodes = getTraversalNodes(this.graph); + const options = getTraversalOptions(this.startOrOptions); + this.direction = options.direction; + this.radius = options.radius; + this.starts = getStartPositions(this.csr.indexOf, options.from); + this.useOut = options.direction !== 'incoming'; + this.useIn = options.direction !== 'outgoing'; + this.outOffsets = this.csr.outOffsets; + this.outTargets = this.csr.outTargets; + this.inOffsets = this.csr.inOffsets; + this.inOrigins = this.csr.inOrigins; + this.onSetup(); + } - if (options.direction !== 'incoming') { - for (let i = csr.outOffsets[u]; i < csr.outOffsets[u + 1]; i++) { - const v = csr.outTargets[i]; - if (!visited[v]) { - visited[v] = 1; - depths[v] = depths[u] + 1; - queue[tail++] = v; + protected abstract onSetup(): void; + abstract next(): IteratorResult, undefined>; + + /** + * Drop any buffered output. Subclasses with fast serve paths that bypass + * the `finished` flag override this so a closed iterator (via `return()` + * or `throw()`) cannot keep serving, matching generator semantics. + */ + protected close(): void {} + + return(value?: undefined): IteratorResult, undefined> { + this.close(); + this.finished = true; + return { value, done: true }; + } + + throw(error?: unknown): IteratorResult, undefined> { + this.close(); + this.finished = true; + throw error; + } + + [Symbol.iterator](): this { + return this; + } +} + +/** + * Traversal iterators batch work in chunks so a full traversal pays no + * per-yield overhead, while the chunk *ramps up* (INITIAL_CHUNK, doubling to + * MAX_CHUNK) so an early-exiting consumer stays effectively lazy: the first + * `next()` does ~8 nodes of work, not a full batch. The yield sequence is + * unchanged either way — batching only moves *when* neighbor expansion runs, + * never its order. + */ +const INITIAL_CHUNK = 8; +const MAX_CHUNK = 1024; + +class BfsIterator extends LazyTraversalIterator { + private visited!: Uint8Array; + private queue!: Int32Array; + private depths: Int32Array | undefined; + private head = 0; + private tail = 0; + private expandCursor = 0; + private chunk = INITIAL_CHUNK; + + protected onSetup(): void { + const n = this.csr.ids.length; + this.visited = new Uint8Array(n); + this.queue = new Int32Array(n); + // Depth tracking is only needed for finite radii + this.depths = this.radius === Infinity ? undefined : new Int32Array(n); + for (const start of this.starts) { + this.visited[start] = 1; + this.queue[this.tail++] = start; + } + } + + private expandChunk(): void { + const visited = this.visited; + const queue = this.queue; + const depths = this.depths; + const outOffsets = this.outOffsets; + const outTargets = this.outTargets; + const inOffsets = this.inOffsets; + const inOrigins = this.inOrigins; + const useOut = this.useOut; + const useIn = this.useIn; + const radius = this.radius; + let cursor = this.expandCursor; + let tail = this.tail; + const limit = Math.min(cursor + this.chunk, tail); + if (this.chunk < MAX_CHUNK) this.chunk *= 2; + + while (cursor < limit) { + const u = queue[cursor++]; + let nextDepth = 0; + if (depths !== undefined) { + if (depths[u] >= radius) continue; + nextDepth = depths[u] + 1; + } + if (useOut) { + for (let i = outOffsets[u]; i < outOffsets[u + 1]; i++) { + const v = outTargets[i]; + if (!visited[v]) { + visited[v] = 1; + if (depths !== undefined) depths[v] = nextDepth; + queue[tail++] = v; + } } } - } - if (options.direction !== 'outgoing') { - for (let i = csr.inOffsets[u]; i < csr.inOffsets[u + 1]; i++) { - const v = csr.inOrigins[i]; - if (!visited[v]) { - visited[v] = 1; - depths[v] = depths[u] + 1; - queue[tail++] = v; + if (useIn) { + for (let i = inOffsets[u]; i < inOffsets[u + 1]; i++) { + const v = inOrigins[i]; + if (!visited[v]) { + visited[v] = 1; + if (depths !== undefined) depths[v] = nextDepth; + queue[tail++] = v; + } } } } + + this.expandCursor = cursor; + this.tail = tail; + } + + // Kept tiny so engines can inline it into for..of loops (letting escape + // analysis elide the result allocation); everything else lives in nextSlow. + next(): IteratorResult, undefined> { + // Hot path: nodes before the expansion cursor are settled queue entries + const head = this.head; + if (head < this.expandCursor) { + this.head = head + 1; + return { value: this.nodes[this.queue[head]], done: false }; + } + return this.nextSlow(); + } + + private nextSlow(): IteratorResult, undefined> { + if (this.finished) return DONE; + if (!this.started) this.ensureStarted(); + while (this.expandCursor <= this.head && this.expandCursor < this.tail) { + this.expandChunk(); + } + if (this.head >= this.tail) { + this.finished = true; + return DONE; + } + return { value: this.nodes[this.queue[this.head++]], done: false }; + } + + protected override close(): void { + // Neutralize the hot serve path for a closed iterator + this.head = 0; + this.expandCursor = 0; + this.tail = 0; } } +export function genBFS( + graph: Graph, + startOrOptions: string | TraversalSearchOptions, +): Generator> { + return new BfsIterator(graph, startOrOptions) as unknown as Generator< + GraphNode + >; +} + /** * @deprecated Use {@link genBFS}. */ @@ -159,139 +334,265 @@ export function* bfs( yield* genBFS(graph, startOrOptions); } -export function* genDFS( - graph: Graph, - startOrOptions: string | TraversalSearchOptions, -): Generator> { - const csr = getCSR(graph); - const nodes = getTraversalNodes(graph); - const options = getTraversalOptions(startOrOptions); - const starts = getStartPositions(csr.indexOf, options.from); - if (starts.length === 0) return; - - const reached = - options.radius === Infinity - ? undefined - : getReachableWithinRadius( - csr, - starts, - options.direction, - options.radius, - ); - const visited = new Uint8Array(csr.ids.length); - const stack: number[] = []; - for (let i = starts.length - 1; i >= 0; i--) { - stack.push(starts[i]); - } - - while (stack.length > 0) { - const node = stack.pop()!; - if (visited[node]) continue; - visited[node] = 1; - yield nodes[node]; - - if (options.direction !== 'incoming') { - for (let i = csr.outOffsets[node]; i < csr.outOffsets[node + 1]; i++) { - const neighbor = csr.outTargets[i]; - if (!visited[neighbor] && (reached === undefined || reached[neighbor])) { - stack.push(neighbor); +/** + * The classic duplicate-push stack DFS loop runs with pure local state + * filling a yield buffer; `next()` then serves from the buffer. Same + * sequence as yielding from inside the loop, but the hot loop carries no + * per-yield overhead, and the chunk ramp keeps early-exiting consumers + * effectively lazy (see INITIAL_CHUNK/MAX_CHUNK above). + */ +class DfsIterator extends LazyTraversalIterator { + private visited!: Uint8Array; + private reached: Uint8Array | undefined; + // Every arc pushes its head at most once, so `starts + relevant arcs` + // bounds the stack — a preallocated Int32Array, no growth checks. + private stack!: Int32Array; + private stackSize = 0; + // Resolved node objects, filled by the traversal loop (where the deref is + // cache-adjacent) and served by next() with minimal work + private buffer: Array> = new Array(MAX_CHUNK); + private bufferLength = 0; + private bufferPos = 0; + private chunk = INITIAL_CHUNK; + + protected onSetup(): void { + const csr = this.csr; + this.visited = new Uint8Array(csr.ids.length); + this.reached = + this.radius === Infinity + ? undefined + : getReachableWithinRadius(csr, this.starts, this.direction, this.radius); + let capacity = this.starts.length; + if (this.useOut) capacity += csr.outTargets.length; + if (this.useIn) capacity += csr.inOrigins.length; + this.stack = new Int32Array(capacity); + for (let i = this.starts.length - 1; i >= 0; i--) { + this.stack[this.stackSize++] = this.starts[i]; + } + } + + private fillBuffer(): void { + const visited = this.visited; + const reached = this.reached; + const stack = this.stack; + const buffer = this.buffer; + const nodes = this.nodes; + const useOut = this.useOut; + const useIn = this.useIn; + const outOffsets = this.outOffsets; + const outTargets = this.outTargets; + const inOffsets = this.inOffsets; + const inOrigins = this.inOrigins; + let top = this.stackSize; + let produced = 0; + const chunk = this.chunk; + if (chunk < MAX_CHUNK) this.chunk = chunk * 2; + + while (produced < chunk && top > 0) { + const node = stack[--top]; + if (visited[node]) continue; + visited[node] = 1; + buffer[produced++] = nodes[node]; + + if (useOut) { + const end = outOffsets[node + 1]; + if (reached === undefined) { + for (let i = outOffsets[node]; i < end; i++) { + const neighbor = outTargets[i]; + if (!visited[neighbor]) stack[top++] = neighbor; + } + } else { + for (let i = outOffsets[node]; i < end; i++) { + const neighbor = outTargets[i]; + if (!visited[neighbor] && reached[neighbor]) stack[top++] = neighbor; + } } } - } - if (options.direction !== 'outgoing') { - for (let i = csr.inOffsets[node]; i < csr.inOffsets[node + 1]; i++) { - const neighbor = csr.inOrigins[i]; - if (!visited[neighbor] && (reached === undefined || reached[neighbor])) { - stack.push(neighbor); + if (useIn) { + const end = inOffsets[node + 1]; + if (reached === undefined) { + for (let i = inOffsets[node]; i < end; i++) { + const neighbor = inOrigins[i]; + if (!visited[neighbor]) stack[top++] = neighbor; + } + } else { + for (let i = inOffsets[node]; i < end; i++) { + const neighbor = inOrigins[i]; + if (!visited[neighbor] && reached[neighbor]) stack[top++] = neighbor; + } } } } + + this.stackSize = top; + this.bufferLength = produced; + this.bufferPos = 0; + } + + // Kept tiny so engines can inline it into for..of loops (letting escape + // analysis elide the result allocation); everything else lives in nextSlow. + next(): IteratorResult, undefined> { + const pos = this.bufferPos; + if (pos < this.bufferLength) { + this.bufferPos = pos + 1; + return { value: this.buffer[pos], done: false }; + } + return this.nextSlow(); + } + + private nextSlow(): IteratorResult, undefined> { + if (this.finished) return DONE; + if (!this.started) this.ensureStarted(); + this.fillBuffer(); + if (this.bufferLength === 0) { + this.finished = true; + return DONE; + } + this.bufferPos = 1; + return { value: this.buffer[0], done: false }; + } + + protected override close(): void { + // Drop buffered nodes so a closed iterator cannot keep serving + this.bufferPos = 0; + this.bufferLength = 0; } } -/** - * Lazily yields nodes after their reachable descendants. - * - * The active traversal retains its CSR and node-position snapshots, so later - * structural mutations are visible only to fresh generators. - */ -export function* genPostorder( +export function genDFS( graph: Graph, startOrOptions: string | TraversalSearchOptions, ): Generator> { - const csr = getCSR(graph); - const nodes = getTraversalNodes(graph); - const options = getTraversalOptions(startOrOptions); - const starts = getStartPositions(csr.indexOf, options.from); - if (starts.length === 0) return; - - const reached = - options.radius === Infinity - ? undefined - : getReachableWithinRadius( - csr, - starts, - options.direction, - options.radius, - ); - const discovered = new Uint8Array(csr.ids.length); - const stackNodes = new Int32Array(csr.ids.length); - const stackOutCursors = new Int32Array(csr.ids.length); - const stackInCursors = new Int32Array(csr.ids.length); - let stackSize = 0; - - const addStackNode = (node: number) => { - const top = stackSize++; - discovered[node] = 1; - stackNodes[top] = node; - stackOutCursors[top] = csr.outOffsets[node]; - stackInCursors[top] = csr.inOffsets[node]; - }; + return new DfsIterator(graph, startOrOptions) as unknown as Generator< + GraphNode + >; +} - for (const start of starts) { - if (discovered[start]) continue; - addStackNode(start); - - while (stackSize > 0) { - const top = stackSize - 1; - const node = stackNodes[top]; - let neighbor = -1; - - if (options.direction !== 'incoming') { - while (stackOutCursors[top] < csr.outOffsets[node + 1]) { - const candidate = csr.outTargets[stackOutCursors[top]++]; - if ( - !discovered[candidate] && - (reached === undefined || reached[candidate]) - ) { - neighbor = candidate; - break; - } +class PostorderIterator extends LazyTraversalIterator { + private discovered!: Uint8Array; + private reached: Uint8Array | undefined; + private stackNodes!: Int32Array; + private stackOutCursors!: Int32Array; + private stackInCursors!: Int32Array; + private stackSize = 0; + private startCursor = 0; + + protected onSetup(): void { + const n = this.csr.ids.length; + this.discovered = new Uint8Array(n); + this.reached = + this.radius === Infinity + ? undefined + : getReachableWithinRadius( + this.csr, + this.starts, + this.direction, + this.radius, + ); + this.stackNodes = new Int32Array(n); + this.stackOutCursors = new Int32Array(n); + this.stackInCursors = new Int32Array(n); + } + + private push(node: number): void { + const top = this.stackSize++; + this.discovered[node] = 1; + this.stackNodes[top] = node; + this.stackOutCursors[top] = this.csr.outOffsets[node]; + this.stackInCursors[top] = this.csr.inOffsets[node]; + } + + next(): IteratorResult, undefined> { + if (this.finished) return DONE; + if (!this.started) this.ensureStarted(); + const discovered = this.discovered; + const reached = this.reached; + const stackNodes = this.stackNodes; + const stackOutCursors = this.stackOutCursors; + const stackInCursors = this.stackInCursors; + const outOffsets = this.outOffsets; + const outTargets = this.outTargets; + const inOffsets = this.inOffsets; + const inOrigins = this.inOrigins; + + for (;;) { + if (this.stackSize === 0) { + // Move on to the next undiscovered start root + while ( + this.startCursor < this.starts.length && + discovered[this.starts[this.startCursor]] + ) { + this.startCursor++; } + if (this.startCursor >= this.starts.length) { + this.finished = true; + return DONE; + } + this.push(this.starts[this.startCursor++]); } - if (neighbor === -1 && options.direction !== 'outgoing') { - while (stackInCursors[top] < csr.inOffsets[node + 1]) { - const candidate = csr.inOrigins[stackInCursors[top]++]; - if ( - !discovered[candidate] && - (reached === undefined || reached[candidate]) - ) { - neighbor = candidate; - break; + + while (this.stackSize > 0) { + const top = this.stackSize - 1; + const node = stackNodes[top]; + let neighbor = -1; + + if (this.useOut) { + const end = outOffsets[node + 1]; + let cursor = stackOutCursors[top]; + while (cursor < end) { + const candidate = outTargets[cursor++]; + if ( + !discovered[candidate] && + (reached === undefined || reached[candidate]) + ) { + neighbor = candidate; + break; + } } + stackOutCursors[top] = cursor; + } + if (neighbor === -1 && this.useIn) { + const end = inOffsets[node + 1]; + let cursor = stackInCursors[top]; + while (cursor < end) { + const candidate = inOrigins[cursor++]; + if ( + !discovered[candidate] && + (reached === undefined || reached[candidate]) + ) { + neighbor = candidate; + break; + } + } + stackInCursors[top] = cursor; } - } - if (neighbor !== -1) { - addStackNode(neighbor); - } else { - stackSize--; - yield nodes[node]; + if (neighbor !== -1) { + this.push(neighbor); + } else { + this.stackSize--; + return { value: this.nodes[node], done: false }; + } } } } } +/** + * Lazily yields nodes after their reachable descendants. + * + * The active traversal retains its CSR and node-position snapshots, so later + * structural mutations are visible only to fresh generators. + */ +export function genPostorder( + graph: Graph, + startOrOptions: string | TraversalSearchOptions, +): Generator> { + return new PostorderIterator(graph, startOrOptions) as unknown as Generator< + GraphNode + >; +} + /** * @deprecated Use {@link genDFS}. */ @@ -499,35 +800,33 @@ export function getConnectedComponents(graph: Graph): GraphNode[][] { * precedence, i.e. a 2-cycle — so the function returns `null`. */ export function getTopologicalSort(graph: Graph): GraphNode[] | null { - for (const edge of graph.edges) { - if (getEdgeMode(graph, edge) !== 'directed') return null; - } + // Kahn's algorithm over the CSR arcs with a typed-array ring queue. The + // CSR's cached hasNonDirected flag makes the mode bail-out O(1) per call. + const csr = getCSR(graph); + if (csr.hasNonDirected) return null; - const idx = getIndex(graph); - const inDegree = new Map(); - for (const node of graph.nodes) inDegree.set(node.id, 0); - for (const edge of graph.edges) { - inDegree.set(edge.targetId, (inDegree.get(edge.targetId) ?? 0) + 1); - } + const n = csr.ids.length; + const outOffsets = csr.outOffsets; + const outTargets = csr.outTargets; + // Edge-list in-degrees (cached per CSR) so an edge with a dangling + // *source* still blocks its target, matching the previous behavior where + // such targets never reached degree 0. + const inDegree = getEdgeListInDegrees(graph, csr).slice(); - const queue: string[] = []; - for (const [id, degree] of inDegree) { - if (degree === 0) queue.push(id); + const queue = new Int32Array(n); + let head = 0; + let tail = 0; + for (let i = 0; i < n; i++) { + if (inDegree[i] === 0) queue[tail++] = i; } const result: GraphNode[] = []; - while (queue.length > 0) { - const id = queue.shift()!; - const ni = idx.nodeById.get(id); - if (ni !== undefined) result.push(graph.nodes[ni]); - - for (const eid of idx.outEdges.get(id) ?? []) { - const ai = idx.edgeById.get(eid); - if (ai === undefined) continue; - const targetId = graph.edges[ai].targetId; - const nextDegree = (inDegree.get(targetId) ?? 1) - 1; - inDegree.set(targetId, nextDegree); - if (nextDegree === 0) queue.push(targetId); + while (head < tail) { + const u = queue[head++]; + result.push(graph.nodes[u]); + for (let a = outOffsets[u]; a < outOffsets[u + 1]; a++) { + const v = outTargets[a]; + if (--inDegree[v] === 0) queue[tail++] = v; } } diff --git a/src/graph.ts b/src/graph.ts index 319dd52..218cd99 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -25,6 +25,7 @@ import { indexAddNode, indexAddEdge, indexReparentNode, + indexReplaceNode, indexUpdateEdgeEndpoints, touchIndex, } from './indexing'; @@ -697,6 +698,8 @@ export function updateNode( } applyOptionalUpdates(updated, update, NODE_OPTIONAL_KEYS); graph.nodes[arrayIdx] = updated; + // Derived caches holding node objects (CSR snapshot) patch this slot + indexReplaceNode(idx, arrayIdx, updated); // Update hierarchy index if parentId changed if (update.parentId !== undefined && updated.parentId !== oldParentId) { diff --git a/src/indexing.ts b/src/indexing.ts index f312966..a87a55d 100644 --- a/src/indexing.ts +++ b/src/indexing.ts @@ -24,12 +24,32 @@ export interface GraphIndex { * index object identity + this version to revalidate in O(1). */ version: number; + /** + * Lazy per-node degree cache (see `getDegree` in queries.ts). Stored on the + * index itself so a degree sweep pays one property load, not an extra + * WeakMap lookup, per call. Revalidated against `version` + `graph.mode`. + */ + degrees?: { + version: number; + mode: Graph['mode']; + /** node id → degree; one hashed lookup per getDegree call */ + byId: Map; + }; } // WeakMap cache const indexes = new WeakMap(); +// One-entry memo in front of the WeakMap: point queries (getDegree, +// getNode, …) are typically issued in bursts against one graph, and a +// WeakRef deref + pointer compare is measurably cheaper than a WeakMap +// lookup in such sweeps. Both refs are weak, so the memo never extends the +// lifetime of a graph (the index itself is kept alive by the WeakMap value +// exactly as long as its graph is). +let lastGraphRef: WeakRef | undefined; +let lastIdxRef: WeakRef | undefined; + // Public API /** @@ -58,7 +78,10 @@ const indexes = new WeakMap(); * ``` */ export function getIndex(graph: Graph): GraphIndex { - let idx = indexes.get(graph); + const memoGraph = lastGraphRef?.deref(); + let idx = + (memoGraph === graph ? lastIdxRef?.deref() : undefined) ?? + indexes.get(graph); // Rebuild when the arrays were replaced (immutable-style update) or // counts changed — the cached index describes different arrays. if ( @@ -70,6 +93,11 @@ export function getIndex(graph: Graph): GraphIndex { ) { idx = buildIndex(graph); indexes.set(graph, idx); + lastIdxRef = new WeakRef(idx); + if (memoGraph !== graph) lastGraphRef = new WeakRef(graph); + } else if (memoGraph !== graph) { + lastGraphRef = new WeakRef(graph); + lastIdxRef = new WeakRef(idx); } return idx; } @@ -94,6 +122,10 @@ export function getIndex(graph: Graph): GraphIndex { */ export function invalidateIndex(graph: Graph): void { indexes.delete(graph); + if (lastGraphRef?.deref() === graph) { + lastGraphRef = undefined; + lastIdxRef = undefined; + } } // Full rebuild @@ -137,6 +169,35 @@ function buildIndex(graph: Graph): GraphIndex { }; } +// Node-replacement notifications — updateNode swaps the node object in +// place (same id, same position, arrays untouched), which no staleness +// check can see. Derived caches that captured node *objects* (the CSR's +// node snapshot) subscribe here and patch the one slot in O(1) instead of +// rebuilding or serving the stale object. + +type NodeReplacedListener = ( + idx: GraphIndex, + arrayIndex: number, + node: GraphNode, +) => void; + +const nodeReplacedListeners: NodeReplacedListener[] = []; + +export function onIndexNodeReplaced(listener: NodeReplacedListener): void { + nodeReplacedListeners.push(listener); +} + +/** Notify derived caches that `graph.nodes[arrayIndex]` was replaced. */ +export function indexReplaceNode( + idx: GraphIndex, + arrayIndex: number, + node: GraphNode, +): void { + for (const listener of nodeReplacedListeners) { + listener(idx, arrayIndex, node); + } +} + // Incremental updates — used by mutation functions in graph.ts export function indexAddNode( diff --git a/src/queries.ts b/src/queries.ts index 5b02d70..624d88c 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -45,6 +45,29 @@ function getNonDirectedSelfLoopCounts( return counts; } +/** + * Per-node degree map, cached on the index per version so a degree sweep over + * all nodes costs a single hashed lookup per call instead of re-deriving + * adjacency-list lengths (and self-loop corrections) each time. + */ +function getDegrees(graph: Graph, idx: GraphIndex): Map { + const cached = idx.degrees; + if (cached && cached.version === idx.version && cached.mode === graph.mode) { + return cached.byId; + } + const byId = new Map(); + // outEdges/inEdges are seeded together per node, so their key order matches + for (const [id, outList] of idx.outEdges) { + byId.set(id, outList.length + (idx.inEdges.get(id)?.length ?? 0)); + } + for (const [id, count] of getNonDirectedSelfLoopCounts(graph, idx)) { + const existing = byId.get(id); + if (existing !== undefined) byId.set(id, existing - count); + } + idx.degrees = { version: idx.version, mode: graph.mode, byId }; + return byId; +} + // --- Edge queries --- /** @@ -306,7 +329,11 @@ export function getDegree(graph: Graph, nodeId: string): number { // O(1) per call (amortized): an incident non-self-loop edge contributes // exactly one entry across the two lists regardless of mode, a directed // self-loop both entries (counts twice, intended), and a non-directed - // self-loop both entries but should count once — subtract the cached count. + // self-loop both entries but should count once — the cached per-node + // degree array folds all of that in. + const degree = getDegrees(graph, idx).get(nodeId); + if (degree !== undefined) return degree; + // Unknown node id: preserve the adjacency-list formula const out = idx.outEdges.get(nodeId); const inE = idx.inEdges.get(nodeId); const selfLoops = getNonDirectedSelfLoopCounts(graph, idx); diff --git a/tests/algorithms.test.ts b/tests/algorithms.test.ts index 7ff62ed..420797c 100644 --- a/tests/algorithms.test.ts +++ b/tests/algorithms.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createGraph, deleteNode } from '../src/graph'; +import { createGraph, deleteNode, updateNode } from '../src/graph'; import { genBFS, genDFS, @@ -211,6 +211,58 @@ describe('BFS / DFS', () => { }); }); +describe('traversal iterator closing', () => { + it('iterators are exhausted after throw() and return(), like generators', () => { + const g = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b' }, + { id: 'bc', sourceId: 'b', targetId: 'c' }, + ], + }); + for (const traverse of [genBFS, genDFS, genPostorder]) { + const thrown = traverse(g, 'a'); + thrown.next(); + expect(() => thrown.throw(new Error('boom'))).toThrow('boom'); + expect(thrown.next().done).toBe(true); + + const returned = traverse(g, 'a'); + returned.next(); + expect(returned.return(undefined).done).toBe(true); + expect(returned.next().done).toBe(true); + } + }); + + it('iterators are exhausted after a setup error, like generators', () => { + const g = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'ab', sourceId: 'a', targetId: 'b' }], + }); + for (const traverse of [genBFS, genDFS, genPostorder]) { + const it = traverse(g, { from: 'a', radius: -1 }); + expect(() => it.next()).toThrow(RangeError); + expect(it.next().done).toBe(true); + } + }); +}); + +describe('traversal after updateNode', () => { + it('fresh traversals see the replaced node object', () => { + const g = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [{ id: 'e', sourceId: 'a', targetId: 'b' }], + }); + // Warm the cached CSR (and its node snapshot) before the update + expect([...genBFS(g, 'a')][0].label).toBe(null); + + updateNode(g, 'a', { label: 'new' }); + + expect([...genBFS(g, 'a')][0].label).toBe('new'); + expect([...genDFS(g, 'a')][0].label).toBe('new'); + expect([...genPostorder(g, 'a')][1].label).toBe('new'); + }); +}); + describe('genPostorder', () => { it('lazily yields a canonical postorder', () => { expect([...genPostorder(makeDAG(), 'a')].map((node) => node.id)).toEqual([ diff --git a/tests/new-features.test.ts b/tests/new-features.test.ts index ca62a71..b3327c0 100644 --- a/tests/new-features.test.ts +++ b/tests/new-features.test.ts @@ -716,4 +716,39 @@ describe('getAllPairsShortestPaths (additional)', () => { }); expect(dPaths.length).toBe(fwPaths.length); }); + + it('floyd-warshall tolerates zero-weight self-loops', () => { + const g = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }], + edges: [ + { id: 'loop', sourceId: 'a', targetId: 'a', weight: 0 }, + { id: 'ab', sourceId: 'a', targetId: 'b', weight: 1 }, + ], + }); + const paths = getAllPairsShortestPaths(g, { algorithm: 'floyd-warshall' }); + // One a→b path; the zero-weight self-loop never appears in any path + expect(paths).toHaveLength(1); + expect(paths[0].steps.map((s) => s.edge.id)).toEqual(['ab']); + }); + + it('floyd-warshall terminates on zero-weight directed cycles', () => { + const g = createGraph({ + nodes: [{ id: 'a' }, { id: 'b' }, { id: 'c' }], + edges: [ + { id: 'ab', sourceId: 'a', targetId: 'b', weight: 0 }, + { id: 'ba', sourceId: 'b', targetId: 'a', weight: 0 }, + { id: 'bc', sourceId: 'b', targetId: 'c', weight: 1 }, + ], + }); + const paths = getAllPairsShortestPaths(g, { algorithm: 'floyd-warshall' }); + // Every path is simple: no node repeats within a single path + for (const path of paths) { + const seen = new Set([path.source.id]); + for (const step of path.steps) { + expect(seen.has(step.node.id)).toBe(false); + seen.add(step.node.id); + } + } + expect(paths.length).toBeGreaterThanOrEqual(4); // a↔b, a→c, b→c at minimum + }); });