/** * @module graph/analyticsFallback * @description Pure-TS graph-analytics kernels — the fallback implementations * behind `brain.graph.rank` / `brain.graph.communities` when no native * {@link import('../plugin.js').GraphAccelerationProvider} is registered. Each * operates on a dense integer adjacency (`outAdj[i]` = the target indices of node * `i`'s out-edges, multiplicity preserved) so callers can build it once from the * UUID graph and run any kernel. * * These are INTENT-level fallbacks: the public API promises the QUESTION * ("which nodes matter most", "which things group together") — these answer it * with the standard textbook algorithm (PageRank, connected components, * Tarjan SCC). A native provider may answer the same intent with a different * algorithm (personalized PageRank, Louvain, etc.); correctness of the INTENT, * not the algorithm, is the contract. * * Correct at small/medium scale (the native provider is the at-scale path). All * graph walks are iterative (explicit stacks/queues) so a deep or wide graph * never blows the call stack. */ /** Adjacency where `outAdj[i]` lists the target node indices of `i`'s out-edges. */ export type DenseAdjacency = ReadonlyArray> /** * @description Label each node with the id of its WEAKLY-connected component * (edges treated as undirected) via union-find with path compression + a single * pass over the adjacency. Two nodes share a label iff one can reach the other * ignoring edge direction. * @param outAdj - Dense out-adjacency. * @returns `labels[i]` = the component representative of node `i` (labels are * stable per component but NOT necessarily contiguous — group by equality). */ export function connectedComponents(outAdj: DenseAdjacency): number[] { const n = outAdj.length const parent = new Array(n) for (let i = 0; i < n; i++) parent[i] = i const find = (x: number): number => { let root = x while (parent[root] !== root) root = parent[root] // Path compression: point every node on the chain straight at the root. while (parent[x] !== root) { const next = parent[x] parent[x] = root x = next } return root } for (let i = 0; i < n; i++) { const neighbors = outAdj[i] for (let k = 0; k < neighbors.length; k++) { const ra = find(i) const rb = find(neighbors[k]) if (ra !== rb) parent[ra] = rb } } const labels = new Array(n) for (let i = 0; i < n; i++) labels[i] = find(i) return labels } /** * @description Label each node with its STRONGLY-connected component (directed — * two nodes share a label iff each is reachable from the other following edge * direction) via an iterative Tarjan's algorithm. * @param outAdj - Dense out-adjacency. * @returns `labels[i]` = the SCC index of node `i` (contiguous `0..count-1`). */ export function stronglyConnectedComponents(outAdj: DenseAdjacency): number[] { const n = outAdj.length const index = new Array(n).fill(-1) const low = new Array(n).fill(0) const onStack = new Array(n).fill(false) const comp = new Array(n).fill(-1) const sccStack: number[] = [] let nextIndex = 0 let compCount = 0 for (let start = 0; start < n; start++) { if (index[start] !== -1) continue // Explicit DFS stack of frames; `pi` is the next out-edge to visit for `node`. const callStack: Array<{ node: number; pi: number }> = [{ node: start, pi: 0 }] while (callStack.length > 0) { const frame = callStack[callStack.length - 1] const v = frame.node if (frame.pi === 0) { index[v] = low[v] = nextIndex++ sccStack.push(v) onStack[v] = true } if (frame.pi < outAdj[v].length) { const w = outAdj[v][frame.pi] frame.pi++ if (index[w] === -1) { callStack.push({ node: w, pi: 0 }) } else if (onStack[w]) { if (index[w] < low[v]) low[v] = index[w] } } else { // All edges of v explored — if v roots an SCC, pop it off the SCC stack. if (low[v] === index[v]) { for (;;) { const w = sccStack.pop() as number onStack[w] = false comp[w] = compCount if (w === v) break } compCount++ } callStack.pop() if (callStack.length > 0) { const parent = callStack[callStack.length - 1].node if (low[v] < low[parent]) low[parent] = low[v] } } } } return comp } /** Tuning knobs for {@link pageRank}. */ export interface PageRankOptions { /** Teleport/damping factor (default `0.85`). */ damping?: number /** Max power-iterations before stopping (default `100`). */ maxIterations?: number /** L1 convergence threshold on the score vector (default `1e-6`). */ tolerance?: number } /** * @description PageRank importance score per node via power-iteration. Dangling * nodes (no out-edges) redistribute their mass uniformly so the score vector * stays a probability distribution (sums to 1). Edge multiplicity is honored * (a node with two edges to the same target sends it twice the share). * @param outAdj - Dense out-adjacency. * @param options - Damping / iteration / tolerance knobs. * @returns `scores[i]` = node `i`'s PageRank (higher = more central). */ export function pageRank(outAdj: DenseAdjacency, options?: PageRankOptions): Float64Array { const n = outAdj.length if (n === 0) return new Float64Array(0) const damping = options?.damping ?? 0.85 const maxIterations = options?.maxIterations ?? 100 const tolerance = options?.tolerance ?? 1e-6 const outDegree = new Array(n) for (let i = 0; i < n; i++) outDegree[i] = outAdj[i].length let rank = new Float64Array(n) rank.fill(1 / n) const teleport = (1 - damping) / n for (let iter = 0; iter < maxIterations; iter++) { // Mass stranded on dangling nodes this round, spread uniformly to everyone. let danglingMass = 0 for (let i = 0; i < n; i++) if (outDegree[i] === 0) danglingMass += rank[i] const danglingShare = (damping * danglingMass) / n const next = new Float64Array(n) next.fill(teleport + danglingShare) for (let i = 0; i < n; i++) { const degree = outDegree[i] if (degree === 0) continue const share = (damping * rank[i]) / degree const neighbors = outAdj[i] for (let k = 0; k < neighbors.length; k++) next[neighbors[k]] += share } let delta = 0 for (let i = 0; i < n; i++) delta += Math.abs(next[i] - rank[i]) rank = next if (delta < tolerance) break } return rank } /** * @description A minimal binary min-heap (priority queue) — used by the weighted * shortest-path (Dijkstra) fallback to pop the lowest-cost frontier node in * O(log n). Stable enough for pathfinding; ties pop in unspecified order. */ export class MinHeap { private readonly items: Array<{ value: T; priority: number }> = [] /** Number of queued items. */ get size(): number { return this.items.length } /** * @description Insert `value` with the given `priority` (lower pops first). * @param value - The payload. * @param priority - Ordering key (ascending). */ push(value: T, priority: number): void { const items = this.items items.push({ value, priority }) let i = items.length - 1 while (i > 0) { const parent = (i - 1) >> 1 if (items[parent].priority <= items[i].priority) break const tmp = items[parent] items[parent] = items[i] items[i] = tmp i = parent } } /** * @description Remove and return the lowest-priority value. * @returns The min value, or `undefined` when empty. */ pop(): T | undefined { const items = this.items if (items.length === 0) return undefined const top = items[0].value const last = items.pop() as { value: T; priority: number } if (items.length > 0) { items[0] = last let i = 0 for (;;) { const left = 2 * i + 1 const right = 2 * i + 2 let smallest = i if (left < items.length && items[left].priority < items[smallest].priority) smallest = left if (right < items.length && items[right].priority < items[smallest].priority) smallest = right if (smallest === i) break const tmp = items[smallest] items[smallest] = items[i] items[i] = tmp i = smallest } } return top } }