feat(8.0): graph analytics — brain.graph.rank / communities / path

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>
This commit is contained in:
David Snelling 2026-06-23 12:32:03 -07:00
parent 4d0b64f455
commit 632d90aac5
7 changed files with 1164 additions and 11 deletions

View file

@ -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<T = any> implements BrainyInterface<T> {
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<T = any> implements BrainyInterface<T> {
}
}
// ============= 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 idindex 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<string, number>()
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<GraphRankEntry[]> {
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<GraphRankEntry[]> {
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<GraphRankEntry[]> {
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<GraphCommunitiesResult> {
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<GraphCommunitiesResult> {
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<number, string[]>()
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<GraphCommunitiesResult> {
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<number, string[]>()
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<GraphPathResult | null> {
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 01 connection strength, default 1.0).
*/
private async graphPathFallback(
fromId: string,
toId: string,
options?: GraphPathOptions
): Promise<GraphPathResult | null> {
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<string, { prev: string; edgeId: string }>()
if (options?.by === 'weight') {
const dist = new Map<string, number>([[fromId, 0]])
const hops = new Map<string, number>([[fromId, 0]])
const settled = new Set<string>()
const heap = new MinHeap<string>()
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 (01 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<string>([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<string, { prev: string; edgeId: string }>,
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<GraphPathResult | null> {
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 =============
/**