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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/heavy-pandas-brake.md
Original file line number Diff line number Diff line change
@@ -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.

62 changes: 33 additions & 29 deletions src/algorithms/bipartite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 };
}

Expand Down
145 changes: 141 additions & 4 deletions src/algorithms/csr.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -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 */
Expand All @@ -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 {
Expand All @@ -49,6 +62,18 @@ interface CsrCacheEntry {

const csrCache = new WeakMap<GraphIndex, CsrCacheEntry>();

// 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);
Expand All @@ -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<string>(n);
const indexOf = new Map<string, number>();
for (let i = 0; i < n; i++) {
ids[i] = graph.nodes[i].id;
ids[i] = nodes[i].id;
indexOf.set(ids[i], i);
}

Expand All @@ -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) {
Expand All @@ -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]++;
Expand Down Expand Up @@ -133,6 +161,7 @@ function buildCSR(graph: Graph): GraphCSR {
}

return {
nodes,
ids,
indexOf,
outOffsets,
Expand All @@ -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<GraphCSR, ArcWeights>();

/**
* 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<GraphCSR, EdgeOrderArcs>();

/**
* 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<GraphCSR, Int32Array>();

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;
}
Loading
Loading