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:
parent
4d0b64f455
commit
632d90aac5
7 changed files with 1164 additions and 11 deletions
330
src/brainy.ts
330
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<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 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<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 0–1 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 (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<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 =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
244
src/graph/analyticsFallback.ts
Normal file
244
src/graph/analyticsFallback.ts
Normal file
|
|
@ -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<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
|
||||
}
|
||||
}
|
||||
|
|
@ -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<T = any> {
|
||||
/**
|
||||
|
|
@ -874,6 +947,42 @@ export interface GraphApi<T = any> {
|
|||
* }
|
||||
*/
|
||||
export(options?: GraphExportOptions): AsyncIterable<GraphView<T>>
|
||||
|
||||
/**
|
||||
* @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<GraphRankEntry[]>
|
||||
|
||||
/**
|
||||
* @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<GraphCommunitiesResult>
|
||||
|
||||
/**
|
||||
* @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<GraphPathResult | null>
|
||||
}
|
||||
|
||||
// ============= Batch Operations =============
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue