Adds three intent-level graph reads to the `brain.graph` namespace, each
native-dispatched to the optional `@soulcraft/cor` 3.0 graph engine when present
and served from pure-TS kernels otherwise (identical public shapes, default
visibility filter respected on both paths):
- `rank(opts?)` → `{ id, score }[]` descending — importance / centrality.
TS fallback: PageRank power-iteration with dangling-mass redistribution.
- `communities(opts?)` → `{ groups, count }` — connected grouping. TS fallback:
union-find weakly-connected components, or iterative Tarjan SCC when
`{ directed: true }`.
- `path(from, to, opts?)` → `{ nodes, relationships, cost } | null` — best route.
TS fallback: BFS for fewest hops, Dijkstra (min-heap) for least summed edge
weight (`by: 'weight'`); on-demand frontier expansion so short paths terminate
early. `direction` / `type` / `maxDepth` filters apply.
These are intent contracts, not algorithm contracts — the question is the
promise, the algorithm is the engine's choice.
Pure kernels live in src/graph/analyticsFallback.ts (PageRank, connected
components, Tarjan SCC, MinHeap) — unit-tested in isolation. The full surface is
tested end-to-end through the TS fallback, and the native dispatch + int↔uuid
hydration paths are covered by a mock provider in graph-native-routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
244 lines
8.4 KiB
TypeScript
244 lines
8.4 KiB
TypeScript
/**
|
|
* @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<ReadonlyArray<number>>
|
|
|
|
/**
|
|
* @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<number>(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<number>(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<number>(n).fill(-1)
|
|
const low = new Array<number>(n).fill(0)
|
|
const onStack = new Array<boolean>(n).fill(false)
|
|
const comp = new Array<number>(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<number>(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<T> {
|
|
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
|
|
}
|
|
}
|