diff --git a/RELEASES.md b/RELEASES.md index 45b7f7b1..983da445 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -50,6 +50,23 @@ no separate migration doc. **`8.0.0-rc.2` is live** — install with > carry `visibility`; whole-graph/`getNouns` streaming no longer leaks `system`/`internal` entities; > and small-page cursor walks over nouns no longer loop. No API change — just correct behavior. +> **rc.3 additions (2026-06-23):** +> - **Graph analytics on the `brain.graph` namespace.** Three intent-level reads that answer +> whole-graph questions in one call: +> - **`brain.graph.rank(opts?)`** → `{ id, score }[]` descending — "which entities matter most" +> (importance / centrality). `topK` to cap. +> - **`brain.graph.communities(opts?)`** → `{ groups: string[][], count }` — "which things group +> together". Weakly-connected components by default; `{ directed: true }` returns +> strongly-connected components. +> - **`brain.graph.path(from, to, opts?)`** → `{ nodes, relationships, cost } | null` — the best +> route between two entities. Fewest hops by default; `{ by: 'weight' }` minimizes summed edge +> weight (the 0–1 connection strength); `direction`, `type`, and `maxDepth` filters apply. +> These are **intent contracts, not algorithm contracts** — the question is the promise, the +> algorithm is the engine's choice. They transparently use the native `@soulcraft/cor` 3.0 graph +> engine when present, and fall back to pure-TS kernels (PageRank, connected components / Tarjan +> SCC, BFS / Dijkstra) otherwise. Both paths return identical shapes and respect the default +> visibility filter (opt in with `includeInternal` / `includeSystem`). + ### Headline: Database as a Value 8.0 replaces Brainy's two overlapping version-control subsystems (copy-on-write diff --git a/src/brainy.ts b/src/brainy.ts index 65a66239..1d1bf0e9 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -31,13 +31,26 @@ import { VirtualFileSystem } from './vfs/VirtualFileSystem.js' import { MetadataIndexManager } from './utils/metadataIndex.js' import { detectContentType, extractForHighlighting } from './utils/contentExtractor.js' import { GraphAdjacencyIndex } from './graph/graphAdjacencyIndex.js' +import { + connectedComponents, + stronglyConnectedComponents, + pageRank, + MinHeap +} from './graph/analyticsFallback.js' import { createPipeline } from './streaming/pipeline.js' import { configureLogger, LogLevel, prodLog } from './utils/logger.js' import { setGlobalCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js' import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js' import { PluginRegistry, isVersionedIndexProvider, isGraphAccelerationProvider } from './plugin.js' -import type { GraphAccelerationProvider, TraverseOptions, Subgraph } from './plugin.js' +import type { + GraphAccelerationProvider, + TraverseOptions, + Subgraph, + RankOptions, + CommunitiesOptions, + PathOptions +} from './plugin.js' import type { BrainyPlugin, BrainyPluginContext, @@ -94,6 +107,12 @@ import { GraphNode, SubgraphOptions, GraphExportOptions, + GraphRankOptions, + GraphRankEntry, + GraphCommunitiesOptions, + GraphCommunitiesResult, + GraphPathOptions, + GraphPathResult, GetOptions, AddManyParams, RemoveManyParams, @@ -3259,7 +3278,11 @@ export class Brainy implements BrainyInterface { return { subgraph: (seeds: string | string[], options?: SubgraphOptions) => this.graphSubgraph(seeds, options), - export: (options?: GraphExportOptions) => this.graphExport(options) + export: (options?: GraphExportOptions) => this.graphExport(options), + rank: (options?: GraphRankOptions) => this.graphRank(options), + communities: (options?: GraphCommunitiesOptions) => this.graphCommunities(options), + path: (from: string, to: string, options?: GraphPathOptions) => + this.graphPath(from, to, options) } } @@ -3580,6 +3603,309 @@ export class Brainy implements BrainyInterface { } } + // ============= GRAPH ANALYTICS (rank / communities / path) ============= + + /** + * @description Load the whole visible graph into a dense integer adjacency for + * the rank / communities fallbacks: a node-id list, an id→index map, and + * `outAdj[i]` = the dense indices of node `i`'s out-edge targets (multiplicity + * preserved). Streamed via {@link graphExport} (cursor pagination — O(N+E), no + * re-scan), so hidden tiers are excluded the same way every other read excludes + * them. Isolated nodes (no edges) are retained so they form singleton groups. + */ + private async loadAnalyticsGraph(opts: { + includeInternal?: boolean + includeSystem?: boolean + }): Promise<{ ids: string[]; outAdj: number[][] }> { + const ids: string[] = [] + const index = new Map() + const outAdj: number[][] = [] + const idxOf = (id: string): number => { + let i = index.get(id) + if (i === undefined) { + i = ids.length + ids.push(id) + index.set(id, i) + outAdj.push([]) + } + return i + } + + for await (const chunk of this.graphExport({ + includeInternal: opts.includeInternal, + includeSystem: opts.includeSystem + })) { + for (const node of chunk.nodes) idxOf(node.id) + for (const edge of chunk.edges) { + const s = idxOf(edge.from) + const t = idxOf(edge.to) + outAdj[s].push(t) + } + } + + return { ids, outAdj } + } + + /** + * @description {@link GraphApi.rank} dispatch: native ranking when a provider is + * present, else the TS PageRank fallback. + */ + private async graphRank(options?: GraphRankOptions): Promise { + await this.ensureInitialized() + const accel = this.graphAccelerationProvider() + return accel ? this.graphRankNative(accel, options) : this.graphRankFallback(options) + } + + /** TS PageRank over the dense visible adjacency; sorted descending, `topK`-capped. */ + private async graphRankFallback(options?: GraphRankOptions): Promise { + const { ids, outAdj } = await this.loadAnalyticsGraph(options ?? {}) + if (ids.length === 0) return [] + const scores = pageRank(outAdj) + const entries: GraphRankEntry[] = ids.map((id, i) => ({ id, score: scores[i] })) + entries.sort((a, b) => b.score - a.score) + return options?.topK !== undefined ? entries.slice(0, options.topK) : entries + } + + /** Native ranking → hydrate node-ints to ids (descending order preserved). */ + private async graphRankNative( + accel: GraphAccelerationProvider, + options?: GraphRankOptions + ): Promise { + const excluded = this.excludedVisibilityTiers(options ?? {}) + const opts: RankOptions = { + ...(options?.topK !== undefined && { topK: options.topK }), + ...(excluded && { excludeVisibility: excluded as unknown as string[] }) + } + const result = await accel.rank(opts) + const idMapper = this.metadataIndex.getIdMapper() + const entries: GraphRankEntry[] = [] + for (let i = 0; i < result.nodeInts.length; i++) { + const uuid = idMapper.getUuid(Number(result.nodeInts[i])) + if (uuid !== undefined) entries.push({ id: uuid, score: result.scores[i] }) + } + return options?.topK !== undefined ? entries.slice(0, options.topK) : entries + } + + /** + * @description {@link GraphApi.communities} dispatch: native grouping when a + * provider is present, else the TS connected-components fallback. + */ + private async graphCommunities( + options?: GraphCommunitiesOptions + ): Promise { + await this.ensureInitialized() + const accel = this.graphAccelerationProvider() + return accel + ? this.graphCommunitiesNative(accel, options) + : this.graphCommunitiesFallback(options) + } + + /** + * @description TS grouping: weakly-connected components by default (union-find + * over the undirected projection), or strongly-connected components when + * `directed: true` (Tarjan). Largest group first. + */ + private async graphCommunitiesFallback( + options?: GraphCommunitiesOptions + ): Promise { + const { ids, outAdj } = await this.loadAnalyticsGraph(options ?? {}) + if (ids.length === 0) return { groups: [], count: 0 } + const labels = options?.directed + ? stronglyConnectedComponents(outAdj) + : connectedComponents(outAdj) + return this.groupByLabel(ids, labels) + } + + /** Collapse parallel `(id, label)` arrays into id-groups, largest first. */ + private groupByLabel(ids: string[], labels: number[]): GraphCommunitiesResult { + const byLabel = new Map() + for (let i = 0; i < ids.length; i++) { + const label = labels[i] + const existing = byLabel.get(label) + if (existing) existing.push(ids[i]) + else byLabel.set(label, [ids[i]]) + } + const groups = [...byLabel.values()].sort((a, b) => b.length - a.length) + return { groups, count: groups.length } + } + + /** Native grouping → bucket node-ints by community label, hydrate to ids. */ + private async graphCommunitiesNative( + accel: GraphAccelerationProvider, + options?: GraphCommunitiesOptions + ): Promise { + const excluded = this.excludedVisibilityTiers(options ?? {}) + const opts: CommunitiesOptions = { + ...(options?.directed !== undefined && { directed: options.directed }), + ...(excluded && { excludeVisibility: excluded as unknown as string[] }) + } + const result = await accel.communities(opts) + const idMapper = this.metadataIndex.getIdMapper() + const byCommunity = new Map() + for (let i = 0; i < result.nodeInts.length; i++) { + const uuid = idMapper.getUuid(Number(result.nodeInts[i])) + if (uuid === undefined) continue + const community = result.communityIds[i] + const existing = byCommunity.get(community) + if (existing) existing.push(uuid) + else byCommunity.set(community, [uuid]) + } + const groups = [...byCommunity.values()].sort((a, b) => b.length - a.length) + return { groups, count: groups.length } + } + + /** + * @description {@link GraphApi.path} dispatch: native pathfinding when a provider + * is present, else the TS BFS (hops) / Dijkstra (weight) fallback. Endpoints are + * id-normalized so callers may pass natural keys. + */ + private async graphPath( + from: string, + to: string, + options?: GraphPathOptions + ): Promise { + await this.ensureInitialized() + const fromId = resolveEntityId(from) + const toId = resolveEntityId(to) + const accel = this.graphAccelerationProvider() + return accel + ? this.graphPathNative(accel, fromId, toId, options) + : this.graphPathFallback(fromId, toId, options) + } + + /** + * @description On-demand TS pathfinding — expands frontiers through the O(degree) + * `related()` adjacency (visibility / type filters applied there) rather than + * loading the whole graph, so a short path terminates early. BFS for fewest hops + * (default); Dijkstra (min-heap) for least summed edge weight (`by: 'weight'` — + * each edge costs its stored `weight`, the 0–1 connection strength, default 1.0). + */ + private async graphPathFallback( + fromId: string, + toId: string, + options?: GraphPathOptions + ): Promise { + if (fromId === toId) return { nodes: [fromId], relationships: [], cost: 0 } + + const direction = options?.direction ?? 'both' + const maxDepth = options?.maxDepth ?? Number.POSITIVE_INFINITY + const subOptions: SubgraphOptions = { + ...(options?.type !== undefined && { type: options.type }), + ...(options?.includeInternal !== undefined && { includeInternal: options.includeInternal }), + ...(options?.includeSystem !== undefined && { includeSystem: options.includeSystem }) + } + const pred = new Map() + + if (options?.by === 'weight') { + const dist = new Map([[fromId, 0]]) + const hops = new Map([[fromId, 0]]) + const settled = new Set() + const heap = new MinHeap() + heap.push(fromId, 0) + while (heap.size > 0) { + const node = heap.pop() as string + if (settled.has(node)) continue + settled.add(node) + if (node === toId) break + const depth = hops.get(node) as number + if (depth >= maxDepth) continue + const baseCost = dist.get(node) as number + const edges = await this.incidentEdges(node, direction, subOptions) + for (const edge of edges) { + const neighbor = edge.from === node ? edge.to : edge.from + if (settled.has(neighbor)) continue + // Cost = the edge's stored weight (0–1 connection strength, default 1.0). + // Non-negative by construction, so Dijkstra stays valid. + const weight = edge.weight ?? 1 + const candidate = baseCost + weight + if (!dist.has(neighbor) || candidate < (dist.get(neighbor) as number)) { + dist.set(neighbor, candidate) + hops.set(neighbor, depth + 1) + pred.set(neighbor, { prev: node, edgeId: edge.id }) + heap.push(neighbor, candidate) + } + } + } + if (!settled.has(toId)) return null + return this.reconstructPath(fromId, toId, pred, dist.get(toId) as number) + } + + // Fewest hops — breadth-first, each edge cost 1. + const visited = new Set([fromId]) + const queue: Array<{ id: string; depth: number }> = [{ id: fromId, depth: 0 }] + let head = 0 + while (head < queue.length) { + const { id, depth } = queue[head++] + if (depth >= maxDepth) continue + const edges = await this.incidentEdges(id, direction, subOptions) + for (const edge of edges) { + const neighbor = edge.from === id ? edge.to : edge.from + if (visited.has(neighbor)) continue + visited.add(neighbor) + pred.set(neighbor, { prev: id, edgeId: edge.id }) + if (neighbor === toId) return this.reconstructPath(fromId, toId, pred, depth + 1) + queue.push({ id: neighbor, depth: depth + 1 }) + } + } + return null + } + + /** Walk predecessor links from `toId` back to `fromId` into a forward route. */ + private reconstructPath( + fromId: string, + toId: string, + pred: Map, + cost: number + ): GraphPathResult { + const nodes: string[] = [] + const relationships: string[] = [] + let cursor = toId + while (cursor !== fromId) { + nodes.push(cursor) + const step = pred.get(cursor) as { prev: string; edgeId: string } + relationships.push(step.edgeId) + cursor = step.prev + } + nodes.push(fromId) + nodes.reverse() + relationships.reverse() + return { nodes, relationships, cost } + } + + /** Native pathfinding → resolve endpoints to ints, hydrate the returned route. */ + private async graphPathNative( + accel: GraphAccelerationProvider, + fromId: string, + toId: string, + options?: GraphPathOptions + ): Promise { + const fromInt = this.graphEntityInt(fromId) + const toInt = this.graphEntityInt(toId) + if (fromInt === undefined || toInt === undefined) return null + + const verbTypes = options?.type + ? (Array.isArray(options.type) ? options.type : [options.type]).map((t) => + TypeUtils.getVerbIndex(t) + ) + : undefined + const excluded = this.excludedVisibilityTiers(options ?? {}) + const pathOptions: PathOptions = { + ...(options?.direction !== undefined && { direction: options.direction }), + ...(options?.by !== undefined && { by: options.by }), + ...(verbTypes && { verbTypes }), + ...(excluded && { excludeVisibility: excluded as unknown as string[] }), + ...(options?.maxDepth !== undefined && { maxDepth: options.maxDepth }) + } + + const result = await accel.path(fromInt, toInt, pathOptions) + if (!result) return null + const nodes = this.entityIntsToUuids(Array.from(result.nodeInts)) + const relationships = ( + await this.graphIndex.verbIntsToIds(Array.from(result.edgeVerbInts)) + ).filter((id): id is string => id !== null) + return { nodes, relationships, cost: result.cost } + } + // ============= SEARCH & DISCOVERY ============= /** diff --git a/src/graph/analyticsFallback.ts b/src/graph/analyticsFallback.ts new file mode 100644 index 00000000..cf863d74 --- /dev/null +++ b/src/graph/analyticsFallback.ts @@ -0,0 +1,244 @@ +/** + * @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 + } +} diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 2a6f58ac..4de62833 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -837,14 +837,87 @@ export interface GraphExportOptions { includeEdges?: boolean } +/** + * Options for {@link GraphApi.communities}. + */ +export interface GraphCommunitiesOptions { + /** Treat edges as directed when grouping (default `false` — direction-agnostic). */ + directed?: boolean + /** Also group through `internal`-visibility nodes/edges (hidden by default). */ + includeInternal?: boolean + /** Also group through `system`-visibility nodes/edges (hidden by default). */ + includeSystem?: boolean +} + +/** + * Result of {@link GraphApi.communities} — the graph partitioned into connected + * groups of entity ids. + */ +export interface GraphCommunitiesResult { + /** Each group is the member entity ids; largest group first. */ + groups: string[][] + /** Number of distinct groups (`= groups.length`). */ + count: number +} + +/** + * Options for {@link GraphApi.rank} (importance ranking). INTENT-level only — the + * ranking ALGORITHM is the provider's choice (the TS fallback uses PageRank), so + * no algorithm-tuning knobs are exposed. + */ +export interface GraphRankOptions { + /** Return only the top-K nodes by score (default: all, descending). */ + topK?: number + /** Also rank through `internal`-visibility nodes/edges (hidden by default). */ + includeInternal?: boolean + /** Also rank through `system`-visibility nodes/edges (hidden by default). */ + includeSystem?: boolean +} + +/** One ranked entity from {@link GraphApi.rank}, descending by `score`. */ +export interface GraphRankEntry { + /** The entity id. */ + id: string + /** The importance score (relative; higher = more central). */ + score: number +} + +/** + * Options for {@link GraphApi.path} (best route between two entities). + */ +export interface GraphPathOptions { + /** Edge direction to follow (default `'both'`). */ + direction?: 'in' | 'out' | 'both' + /** Optimize for fewest `'hops'` (default) or least summed edge `'weight'`. */ + by?: 'hops' | 'weight' + /** Restrict the route to these relationship type(s). */ + type?: VerbType | VerbType[] + /** Also route through `internal`-visibility nodes/edges (hidden by default). */ + includeInternal?: boolean + /** Also route through `system`-visibility nodes/edges (hidden by default). */ + includeSystem?: boolean + /** Abandon the search past this many hops. */ + maxDepth?: number +} + +/** + * Result of {@link GraphApi.path} — the route from `from` to `to`, or `null` when + * unreachable. + */ +export interface GraphPathResult { + /** The entity ids along the route, `from` … `to` inclusive. */ + nodes: string[] + /** The relationship (verb) ids traversed, one fewer than `nodes`. */ + relationships: string[] + /** Route cost: number of hops, or summed edge weight when `by: 'weight'`. */ + cost: number +} + /** * The `brain.graph` namespace — graph-shaped reads over the knowledge graph. * Routes to a native {@link import('../plugin.js').GraphAccelerationProvider} * when one is registered, otherwise serves the same results from Brainy's * pure-TS adjacency (correct at small/medium scale). - * - * (Grows with the engine: `subgraph` + `export` ship first; `rank` / `path` / - * `communities` follow.) */ export interface GraphApi { /** @@ -874,6 +947,42 @@ export interface GraphApi { * } */ export(options?: GraphExportOptions): AsyncIterable> + + /** + * @description Rank entities by graph importance (influence / centrality) — the + * one-call answer to "which nodes matter most". The TS fallback uses PageRank; + * a native provider may use personalized PageRank / eigenvector centrality. + * @param options - `topK`, visibility opt-ins. + * @returns Entities with scores, DESCENDING (most important first). + * @example + * const top = await brain.graph.rank({ topK: 10 }) + * top[0] // { id, score } — the most central entity + */ + rank(options?: GraphRankOptions): Promise + + /** + * @description Partition the graph into connected communities (clusters of + * related entities) — "which things group together". + * @param options - `directed`, visibility opt-ins. + * @returns The groups (member ids) + their count. + * @example + * const { groups, count } = await brain.graph.communities() + */ + communities(options?: GraphCommunitiesOptions): Promise + + /** + * @description Find the best route between two entities — fewest hops (default) + * or least summed edge weight (`by: 'weight'`). The pathfinding ALGORITHM is + * the provider's choice (TS fallback: BFS for hops, Dijkstra for weight). + * @param from - Start entity id (natural keys are resolved). + * @param to - End entity id. + * @param options - Direction, `by`, type filter, `maxDepth`, visibility opt-ins. + * @returns The route (`nodes` + `relationships` + `cost`), or `null` if unreachable. + * @example + * const route = await brain.graph.path(aliceId, bobId) + * route?.nodes // [aliceId, …, bobId] + */ + path(from: string, to: string, options?: GraphPathOptions): Promise } // ============= Batch Operations ============= diff --git a/tests/unit/brainy/graph-analytics.test.ts b/tests/unit/brainy/graph-analytics.test.ts new file mode 100644 index 00000000..bfec3347 --- /dev/null +++ b/tests/unit/brainy/graph-analytics.test.ts @@ -0,0 +1,211 @@ +/** + * @module tests/unit/brainy/graph-analytics + * @description Graph analytics surface — `brain.graph.rank()` (PageRank importance), + * `brain.graph.communities()` (connected-component grouping), and + * `brain.graph.path()` (best route). These exercise the pure-TS fallbacks (no native + * GraphAccelerationProvider is registered in CI); a native provider answers the same + * INTENT (which nodes matter most / which things group / best route) and returns the + * same public shapes, cross-layer-tested against the provider. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Brainy } from '../../../src/index.js' +import { NounType, VerbType } from '../../../src/types/graphTypes.js' +import { createTestConfig } from '../../helpers/test-factory.js' + +describe('brain.graph.communities()', () => { + let brain: Brainy + let a: string, b: string, c: string, d: string, e: string, f: string + + beforeEach(async () => { + brain = new Brainy(createTestConfig()) + await brain.init() + // Two disjoint clusters + one isolated node: + // cluster 1: a → b → c + // cluster 2: d → e + // isolated: f (no edges) + a = await brain.add({ type: NounType.Person, data: 'A' }) + b = await brain.add({ type: NounType.Person, data: 'B' }) + c = await brain.add({ type: NounType.Person, data: 'C' }) + d = await brain.add({ type: NounType.Person, data: 'D' }) + e = await brain.add({ type: NounType.Person, data: 'E' }) + f = await brain.add({ type: NounType.Person, data: 'F' }) + await brain.relate({ from: a, to: b, type: VerbType.RelatedTo }) + await brain.relate({ from: b, to: c, type: VerbType.RelatedTo }) + await brain.relate({ from: d, to: e, type: VerbType.RelatedTo }) + }) + + afterEach(async () => { + await brain.close() + }) + + it('partitions the graph into connected groups (isolated nodes are singletons)', async () => { + const { groups, count } = await brain.graph.communities() + expect(count).toBe(3) + const asSets = groups.map((g) => new Set(g)) + expect(asSets).toContainEqual(new Set([a, b, c])) + expect(asSets).toContainEqual(new Set([d, e])) + expect(asSets).toContainEqual(new Set([f])) + }) + + it('orders groups largest-first', async () => { + const { groups } = await brain.graph.communities() + for (let i = 1; i < groups.length; i++) { + expect(groups[i - 1].length).toBeGreaterThanOrEqual(groups[i].length) + } + expect(groups[0]).toHaveLength(3) // the a-b-c cluster + }) + + it('directed mode groups by strong connectivity (a→b→c is NOT mutually reachable)', async () => { + // Undirected: {a,b,c} is one group. Directed: no cycle, so each is its own SCC. + const undirected = await brain.graph.communities() + expect(undirected.groups).toContainEqual(expect.arrayContaining([a, b, c])) + + const directed = await brain.graph.communities({ directed: true }) + // a,b,c split into singletons; d,e split too; f stays singleton → 6 groups. + expect(directed.count).toBe(6) + expect(directed.groups.every((g) => g.length === 1)).toBe(true) + }) + + it('directed mode keeps a cycle together as one strongly-connected community', async () => { + // x → y → z → x is a cycle: mutually reachable → one SCC even when directed. + const x = await brain.add({ type: NounType.Concept, data: 'X' }) + const y = await brain.add({ type: NounType.Concept, data: 'Y' }) + const z = await brain.add({ type: NounType.Concept, data: 'Z' }) + await brain.relate({ from: x, to: y, type: VerbType.RelatedTo }) + await brain.relate({ from: y, to: z, type: VerbType.RelatedTo }) + await brain.relate({ from: z, to: x, type: VerbType.RelatedTo }) + + const directed = await brain.graph.communities({ directed: true }) + const asSets = directed.groups.map((g) => new Set(g)) + expect(asSets).toContainEqual(new Set([x, y, z])) + }) + + it('excludes internal-visibility edges by default; includeInternal re-links', async () => { + // A hidden edge bridging the two clusters is invisible by default. + await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, visibility: 'internal' }) + + const hidden = await brain.graph.communities() + expect(hidden.count).toBe(3) // bridge hidden → still 3 groups + + const surfaced = await brain.graph.communities({ includeInternal: true }) + const asSets = surfaced.groups.map((g) => new Set(g)) + expect(asSets).toContainEqual(new Set([a, b, c, d, e])) // bridge merges the clusters + }) +}) + +describe('brain.graph.rank()', () => { + let brain: Brainy + let hub: string, a: string, b: string, c: string + + beforeEach(async () => { + brain = new Brainy(createTestConfig()) + await brain.init() + // a, b, c all point at hub → hub is the most "important" node. + hub = await brain.add({ type: NounType.Person, data: 'HUB' }) + a = await brain.add({ type: NounType.Person, data: 'A' }) + b = await brain.add({ type: NounType.Person, data: 'B' }) + c = await brain.add({ type: NounType.Person, data: 'C' }) + await brain.relate({ from: a, to: hub, type: VerbType.RelatedTo }) + await brain.relate({ from: b, to: hub, type: VerbType.RelatedTo }) + await brain.relate({ from: c, to: hub, type: VerbType.RelatedTo }) + }) + + afterEach(async () => { + await brain.close() + }) + + it('ranks the most-pointed-to node highest, descending', async () => { + const ranked = await brain.graph.rank() + expect(ranked).toHaveLength(4) + expect(ranked[0].id).toBe(hub) + for (let i = 1; i < ranked.length; i++) { + expect(ranked[i - 1].score).toBeGreaterThanOrEqual(ranked[i].score) + } + }) + + it('scores form a probability distribution (PageRank sums to ~1)', async () => { + const ranked = await brain.graph.rank() + const total = ranked.reduce((sum, r) => sum + r.score, 0) + expect(total).toBeCloseTo(1, 5) + }) + + it('topK returns only the K highest', async () => { + const top1 = await brain.graph.rank({ topK: 1 }) + expect(top1).toHaveLength(1) + expect(top1[0].id).toBe(hub) + }) + + it('returns [] on an empty graph', async () => { + const empty = new Brainy(createTestConfig()) + await empty.init() + expect(await empty.graph.rank()).toEqual([]) + await empty.close() + }) +}) + +describe('brain.graph.path()', () => { + let brain: Brainy + let a: string, b: string, c: string, d: string, isolated: string + + beforeEach(async () => { + brain = new Brainy(createTestConfig()) + await brain.init() + // Chain a → b → c → d (light edges), plus a heavy direct shortcut a → d. + // weight is a 0–1 connection strength; `by:'weight'` minimizes summed weight. + a = await brain.add({ type: NounType.Person, data: 'A' }) + b = await brain.add({ type: NounType.Person, data: 'B' }) + c = await brain.add({ type: NounType.Person, data: 'C' }) + d = await brain.add({ type: NounType.Person, data: 'D' }) + isolated = await brain.add({ type: NounType.Person, data: 'ISO' }) + await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, weight: 0.1 }) + await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, weight: 0.1 }) + await brain.relate({ from: c, to: d, type: VerbType.RelatedTo, weight: 0.1 }) + await brain.relate({ from: a, to: d, type: VerbType.RelatedTo, weight: 0.9 }) // direct but heavy + }) + + afterEach(async () => { + await brain.close() + }) + + it('finds the fewest-hops route by default', async () => { + const route = await brain.graph.path(a, d) + expect(route).not.toBeNull() + // a → d direct edge is 1 hop, the shortest. + expect(route?.nodes).toEqual([a, d]) + expect(route?.relationships).toHaveLength(1) + expect(route?.cost).toBe(1) + }) + + it('by:"weight" prefers the lighter multi-hop route over the heavy shortcut', async () => { + const route = await brain.graph.path(a, d, { by: 'weight' }) + expect(route?.nodes).toEqual([a, b, c, d]) // 0.1+0.1+0.1 = 0.3 < 0.9 + expect(route?.relationships).toHaveLength(3) + expect(route?.cost).toBeCloseTo(0.3, 6) + }) + + it('returns a zero-length route from a node to itself', async () => { + const route = await brain.graph.path(a, a) + expect(route).toEqual({ nodes: [a], relationships: [], cost: 0 }) + }) + + it('returns null when the target is unreachable', async () => { + expect(await brain.graph.path(a, isolated)).toBeNull() + }) + + it('direction:"out" cannot walk backwards up the chain', async () => { + // d has no outgoing edges, so d → a is unreachable following only out-edges. + expect(await brain.graph.path(d, a, { direction: 'out' })).toBeNull() + // …but 'both' finds it. + const both = await brain.graph.path(d, a, { direction: 'both' }) + expect(both?.nodes[0]).toBe(d) + expect(both?.nodes[both.nodes.length - 1]).toBe(a) + }) + + it('maxDepth abandons routes that are too long', async () => { + // The light route is 3 hops; cap at 1 hop → only the heavy direct shortcut. + const capped = await brain.graph.path(a, d, { by: 'weight', maxDepth: 1 }) + expect(capped?.nodes).toEqual([a, d]) + expect(capped?.cost).toBeCloseTo(0.9, 6) + }) +}) diff --git a/tests/unit/brainy/graph-native-routing.test.ts b/tests/unit/brainy/graph-native-routing.test.ts index dc90a7e1..767e87fe 100644 --- a/tests/unit/brainy/graph-native-routing.test.ts +++ b/tests/unit/brainy/graph-native-routing.test.ts @@ -19,13 +19,28 @@ import { createTestConfig } from '../../helpers/test-factory.js' /** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */ function makeMockAccel() { - const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0 } - const state: { calls: typeof calls; traverseResult: any; cursorChunks: any[] } = { + const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 } + const state: { + calls: typeof calls + traverseResult: any + cursorChunks: any[] + rankResult: any + communitiesResult: any + pathResult: any + } = { calls, traverseResult: null, - cursorChunks: [] + cursorChunks: [], + rankResult: null, + communitiesResult: null, + pathResult: null } const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) } + const emptyCommunities = { + nodeInts: new BigInt64Array(0), + communityIds: new Uint32Array(0), + communityCount: 0 + } const provider = { isInitialized: true, traverse: async () => { @@ -45,9 +60,18 @@ function makeMockAccel() { graphCursorClose: async () => { calls.cursorClose++ }, - rank: async () => empty, - communities: async () => ({ nodeInts: new BigInt64Array(0), communityIds: new Uint32Array(0), communityCount: 0 }), - path: async () => null, + rank: async () => { + calls.rank++ + return state.rankResult ?? empty + }, + communities: async () => { + calls.communities++ + return state.communitiesResult ?? emptyCommunities + }, + path: async () => { + calls.path++ + return state.pathResult + }, sample: async () => state.traverseResult, mostConnected: async () => empty } @@ -178,4 +202,63 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () = expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([x, y])) await fb.close() }) + + it('rank() routes to the native provider and hydrates node ints -> ids, order preserved', async () => { + const gei = (id: string): bigint => (brain as any).graphEntityInt(id) + // Provider returns DESCENDING scores: c, then a, then b. + mock.state.rankResult = { + nodeInts: BigInt64Array.from([gei(c), gei(a), gei(b)]), + scores: Float64Array.from([0.5, 0.3, 0.2]) + } + const ranked = await brain.graph.rank() + + expect(mock.state.calls.rank).toBe(1) // native path, not the TS PageRank fallback + expect(ranked.map((r) => r.id)).toEqual([c, a, b]) // int -> id, provider order kept + expect(ranked[0].score).toBe(0.5) + + const top2 = await brain.graph.rank({ topK: 2 }) + expect(top2.map((r) => r.id)).toEqual([c, a]) + }) + + it('communities() routes to the native provider and buckets ids by community label', async () => { + const gei = (id: string): bigint => (brain as any).graphEntityInt(id) + // a,b in community 0; c alone in community 1. + mock.state.communitiesResult = { + nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]), + communityIds: Uint32Array.from([0, 0, 1]), + communityCount: 2 + } + const { groups, count } = await brain.graph.communities() + + expect(mock.state.calls.communities).toBe(1) // native path, not the TS fallback + expect(count).toBe(2) + const asSets = groups.map((g) => new Set(g)) + expect(asSets).toContainEqual(new Set([a, b])) + expect(asSets).toContainEqual(new Set([c])) + expect(groups[0]).toHaveLength(2) // largest group first + }) + + it('path() routes to the native provider and hydrates node + verb ints', async () => { + const gei = (id: string): bigint => (brain as any).graphEntityInt(id) + const gi = (brain as any).graphIndex + const vAB = (await gi.getVerbIdsBySource(gei(a)))[0] as bigint + const vBC = (await gi.getVerbIdsBySource(gei(b)))[0] as bigint + mock.state.pathResult = { + nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]), + edgeVerbInts: BigInt64Array.from([vAB, vBC]), + cost: 2 + } + const route = await brain.graph.path(a, c) + + expect(mock.state.calls.path).toBe(1) // native path, not the TS BFS/Dijkstra fallback + expect(route?.nodes).toEqual([a, b, c]) // node ints -> ids + expect(route?.relationships).toHaveLength(2) // verb ints -> verb-id strings + expect(route?.cost).toBe(2) + }) + + it('path() returns null when the native provider reports unreachable', async () => { + mock.state.pathResult = null + expect(await brain.graph.path(a, c)).toBeNull() + expect(mock.state.calls.path).toBe(1) + }) }) diff --git a/tests/unit/graph/analyticsFallback.test.ts b/tests/unit/graph/analyticsFallback.test.ts new file mode 100644 index 00000000..9686172e --- /dev/null +++ b/tests/unit/graph/analyticsFallback.test.ts @@ -0,0 +1,163 @@ +/** + * @module tests/unit/graph/analyticsFallback + * @description Direct unit tests for the pure-TS graph-analytics kernels + * (`connectedComponents`, `stronglyConnectedComponents`, `pageRank`, `MinHeap`). + * These cover the algorithmic edge cases — empty graphs, dangling PageRank mass, + * nested SCCs, heap ordering — without the cost of a full Brainy instance. The + * `brain.graph.*` surface is tested end-to-end in graph-analytics.test.ts. + */ + +import { describe, it, expect } from 'vitest' +import { + connectedComponents, + stronglyConnectedComponents, + pageRank, + MinHeap +} from '../../../src/graph/analyticsFallback.js' + +/** Group node indices that share a label into sorted sets for stable comparison. */ +function groupsOf(labels: number[]): number[][] { + const byLabel = new Map() + labels.forEach((label, i) => { + const arr = byLabel.get(label) + if (arr) arr.push(i) + else byLabel.set(label, [i]) + }) + return [...byLabel.values()].map((g) => g.sort((a, b) => a - b)).sort((a, b) => a[0] - b[0]) +} + +describe('connectedComponents (weakly-connected, undirected projection)', () => { + it('labels an empty graph with no components', () => { + expect(connectedComponents([])).toEqual([]) + }) + + it('treats every isolated node as its own component', () => { + expect(groupsOf(connectedComponents([[], [], []]))).toEqual([[0], [1], [2]]) + }) + + it('merges across edge direction (0→1, 2→1 ⇒ one group)', () => { + // 0 → 1, 2 → 1, 3 isolated + const labels = connectedComponents([[1], [], [1], []]) + expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]]) + }) + + it('keeps disjoint clusters separate', () => { + // 0↔1 , 2↔3 + const labels = connectedComponents([[1], [0], [3], [2]]) + expect(groupsOf(labels)).toEqual([ + [0, 1], + [2, 3] + ]) + }) +}) + +describe('stronglyConnectedComponents (directed, Tarjan)', () => { + it('labels an empty graph with no components', () => { + expect(stronglyConnectedComponents([])).toEqual([]) + }) + + it('splits a directed chain into singletons (no mutual reachability)', () => { + // 0 → 1 → 2 + expect(groupsOf(stronglyConnectedComponents([[1], [2], []]))).toEqual([[0], [1], [2]]) + }) + + it('collapses a directed cycle into one SCC', () => { + // 0 → 1 → 2 → 0 + expect(groupsOf(stronglyConnectedComponents([[1], [2], [0]]))).toEqual([[0, 1, 2]]) + }) + + it('separates a cycle from the acyclic tail that feeds it', () => { + // 3 → 0 → 1 → 2 → 0 : {0,1,2} is an SCC, 3 is its own SCC + const labels = stronglyConnectedComponents([[1], [2], [0], [0]]) + expect(groupsOf(labels)).toEqual([[0, 1, 2], [3]]) + }) + + it('handles two independent cycles', () => { + // 0↔1 cycle, 2↔3 cycle + const labels = stronglyConnectedComponents([ + [1], + [0], + [3], + [2] + ]) + expect(groupsOf(labels)).toEqual([ + [0, 1], + [2, 3] + ]) + }) + + it('produces contiguous community indices', () => { + const labels = stronglyConnectedComponents([[1], [2], [0], []]) + const distinct = new Set(labels) + expect(Math.max(...labels)).toBe(distinct.size - 1) + }) +}) + +describe('pageRank', () => { + it('returns an empty vector for an empty graph', () => { + expect(pageRank([]).length).toBe(0) + }) + + it('is uniform on a symmetric cycle', () => { + // 0 → 1 → 2 → 0 — every node identical by symmetry. + const scores = pageRank([[1], [2], [0]]) + expect(scores[0]).toBeCloseTo(1 / 3, 6) + expect(scores[1]).toBeCloseTo(1 / 3, 6) + expect(scores[2]).toBeCloseTo(1 / 3, 6) + }) + + it('ranks a hub (high in-degree) above its sources', () => { + // 0 → 3, 1 → 3, 2 → 3 : node 3 is the hub. + const scores = pageRank([[3], [3], [3], []]) + expect(scores[3]).toBeGreaterThan(scores[0]) + expect(scores[3]).toBeGreaterThan(scores[1]) + expect(scores[3]).toBeGreaterThan(scores[2]) + }) + + it('redistributes dangling mass so scores sum to 1', () => { + // Node 3 is dangling (no out-edges) — its mass must not leak away. + const scores = pageRank([[3], [3], [3], []]) + const total = scores.reduce((sum, s) => sum + s, 0) + expect(total).toBeCloseTo(1, 6) + }) + + it('sums to 1 even when every node is dangling (no edges)', () => { + const scores = pageRank([[], [], []]) + const total = scores.reduce((sum, s) => sum + s, 0) + expect(total).toBeCloseTo(1, 6) + expect(scores[0]).toBeCloseTo(1 / 3, 6) + }) +}) + +describe('MinHeap', () => { + it('pops in ascending priority order', () => { + const heap = new MinHeap() + heap.push('c', 3) + heap.push('a', 1) + heap.push('b', 2) + heap.push('d', 4) + expect([heap.pop(), heap.pop(), heap.pop(), heap.pop()]).toEqual(['a', 'b', 'c', 'd']) + }) + + it('returns undefined when empty and tracks size', () => { + const heap = new MinHeap() + expect(heap.size).toBe(0) + expect(heap.pop()).toBeUndefined() + heap.push(42, 0.5) + expect(heap.size).toBe(1) + expect(heap.pop()).toBe(42) + expect(heap.size).toBe(0) + }) + + it('handles interleaved push/pop correctly', () => { + const heap = new MinHeap() + heap.push(5, 5) + heap.push(1, 1) + expect(heap.pop()).toBe(1) + heap.push(3, 3) + heap.push(2, 2) + expect(heap.pop()).toBe(2) + expect(heap.pop()).toBe(3) + expect(heap.pop()).toBe(5) + }) +})