feat(8.0): brain.graph.subgraph() + native-provider routing

Introduces the brain.graph namespace and its first op, subgraph() — bounded
multi-hop neighborhood extraction returning a hydrated GraphView (nodes with hop
depth + type/subtype, edges as Relations, a truncated flag). The one-call answer
to 'show me everything around this node, N hops out' the graph-viz path needs.

- brain.graph.subgraph(seeds, { depth, direction, type, subtype, includeInternal,
  includeSystem, maxNodes, maxEdges, hydrateNodes }). seeds: id or id[].
- Feature-detect routing: when a GraphAccelerationProvider is registered
  (isGraphAccelerationProvider on the 'graphAcceleration' provider) it routes to
  native traverse() and hydrates the columnar Subgraph (node ints↔ids paired with
  depth position-preserving, edge verb ints → Relations); otherwise a pure-TS BFS
  fallback expands each frontier through the O(degree) related() adjacency.
- Both paths produce the identical GraphView, so CI exercises the shape via the
  fallback; the native path is cross-layer-tested against cor's provider.
- New public types: GraphView, GraphNode, SubgraphOptions, GraphApi (the surface
  grows: export/rank/path/communities follow). isGraphAccelerationProvider guard
  added to plugin.ts.

Test: graph-subgraph.test.ts — depth tracking, direction, type filter, default-
hides-internal, node hydration on/off, maxNodes truncation, multi-seed.
This commit is contained in:
David Snelling 2026-06-21 09:23:33 -07:00
parent d4de48d004
commit 8c2b57a488
4 changed files with 417 additions and 1 deletions

View file

@ -36,7 +36,8 @@ 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 } from './plugin.js'
import { PluginRegistry, isVersionedIndexProvider, isGraphAccelerationProvider } from './plugin.js'
import type { GraphAccelerationProvider, TraverseOptions } from './plugin.js'
import type {
BrainyPlugin,
BrainyPluginContext,
@ -88,6 +89,10 @@ import {
FindParams,
SimilarParams,
RelatedParams,
GraphApi,
GraphView,
GraphNode,
SubgraphOptions,
GetOptions,
AddManyParams,
RemoveManyParams,
@ -3156,6 +3161,213 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return this.verbsToRelations(result.items)
}
// ============= GRAPH VIEWS (brain.graph.*) =============
/**
* @description The `brain.graph` namespace graph-shaped reads. `subgraph`
* extracts the multi-hop neighborhood around seed entities; the surface grows
* with the engine (streaming `export`, `rank`, `path`, `communities`). Routes
* to a registered native {@link GraphAccelerationProvider} when present, else
* serves the same results from Brainy's pure-TS adjacency. Reads are live
* ("now"); historical graph views come from `db.asOf(g).graph.*` (a later slice).
* @returns The {@link GraphApi} bound to this brain.
* @example
* const view = await brain.graph.subgraph(personId, { depth: 2 })
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
*/
get graph(): GraphApi<T> {
return {
subgraph: (seeds: string | string[], options?: SubgraphOptions) =>
this.graphSubgraph(seeds, options)
}
}
/** Resolve the optional native graph-acceleration provider (feature-detected). */
private graphAccelerationProvider(): GraphAccelerationProvider | undefined {
const provider = this.pluginRegistry.getProvider<unknown>('graphAcceleration')
return isGraphAccelerationProvider(provider) ? provider : undefined
}
/**
* @description {@link GraphApi.subgraph} dispatch: native engine when present,
* else the TS BFS fallback. Seeds are id-normalized so a caller may seed by
* natural key.
*/
private async graphSubgraph(
seeds: string | string[],
options?: SubgraphOptions
): Promise<GraphView<T>> {
await this.ensureInitialized()
const seedIds = (Array.isArray(seeds) ? seeds : [seeds]).map((s) => resolveEntityId(s))
const depth = Math.max(0, options?.depth ?? 1)
const direction = options?.direction ?? 'both'
const accel = this.graphAccelerationProvider()
return accel
? this.graphSubgraphNative(accel, seedIds, depth, direction, options)
: this.graphSubgraphFallback(seedIds, depth, direction, options)
}
/**
* @description Pure-TS subgraph BFS expands each frontier through the
* O(degree) `related()` adjacency (visibility / type / subtype filters applied
* there), dedupes edges, tracks hop depth, and honors `maxNodes` / `maxEdges`
* caps. Correct at small/medium scale; the native provider is the at-scale path.
*/
private async graphSubgraphFallback(
seedIds: string[],
depth: number,
direction: 'in' | 'out' | 'both',
options?: SubgraphOptions
): Promise<GraphView<T>> {
const maxNodes = options?.maxNodes
const maxEdges = options?.maxEdges
const nodeDepth = new Map<string, number>()
for (const id of seedIds) nodeDepth.set(id, 0)
const edgesById = new Map<string, Relation<T>>()
let truncated = false
let frontier = [...seedIds]
for (let d = 1; d <= depth && frontier.length > 0 && !truncated; d++) {
const next: string[] = []
for (const nodeId of frontier) {
const incident = await this.incidentEdges(nodeId, direction, options)
for (const edge of incident) {
if (!edgesById.has(edge.id)) {
if (maxEdges !== undefined && edgesById.size >= maxEdges) {
truncated = true
break
}
edgesById.set(edge.id, edge)
}
const neighbor = edge.from === nodeId ? edge.to : edge.from
if (!nodeDepth.has(neighbor)) {
if (maxNodes !== undefined && nodeDepth.size >= maxNodes) {
truncated = true
continue
}
nodeDepth.set(neighbor, d)
next.push(neighbor)
}
}
if (truncated) break
}
frontier = next
}
return this.buildGraphView(nodeDepth, [...edgesById.values()], options, truncated)
}
/** One frontier hop's incident edges in the requested direction (filters applied by `related()`). */
private incidentEdges(
nodeId: string,
direction: 'in' | 'out' | 'both',
options?: SubgraphOptions
): Promise<Relation<T>[]> {
const shared: RelatedParams = {
type: options?.type,
subtype: options?.subtype,
includeInternal: options?.includeInternal,
includeSystem: options?.includeSystem,
limit: options?.maxEdges ?? 100000
}
if (direction === 'out') return this.related({ ...shared, from: nodeId })
if (direction === 'in') return this.related({ ...shared, to: nodeId })
return this.related({ ...shared, node: nodeId })
}
/**
* @description Native subgraph: resolve seeds to ints, call the provider's
* `traverse`, then hydrate the columnar `Subgraph` (node ints ids paired with
* their depth, edge verb ints `Relation`s) into a {@link GraphView}. The TS
* fallback produces the same view, so Brainy CI exercises this shape via the
* fallback; the native path itself is cross-layer-tested against the provider.
*/
private async graphSubgraphNative(
accel: GraphAccelerationProvider,
seedIds: string[],
depth: number,
direction: 'in' | 'out' | 'both',
options?: SubgraphOptions
): Promise<GraphView<T>> {
const seedInts: bigint[] = []
for (const id of seedIds) {
const int = this.graphEntityInt(id)
if (int !== undefined) seedInts.push(int)
}
if (seedInts.length === 0) return { nodes: [], edges: [], truncated: false }
const verbTypes = options?.type
? (Array.isArray(options.type) ? options.type : [options.type]).map((t) =>
TypeUtils.getVerbIndex(t)
)
: undefined
const excluded = this.excludedVisibilityTiers(options ?? {})
const traverseOptions: TraverseOptions = {
depth,
direction,
...(verbTypes && { verbTypes }),
...(options?.subtype !== undefined && {
subtypes: Array.isArray(options.subtype) ? options.subtype : [options.subtype]
}),
...(excluded && { excludeVisibility: excluded as unknown as string[] }),
...(options?.maxNodes !== undefined && { maxNodes: options.maxNodes }),
...(options?.maxEdges !== undefined && { maxEdges: options.maxEdges }),
includeEdges: true,
includeDepth: true
}
const sub = await accel.traverse(seedInts, traverseOptions)
// Edge hydration: verb ints → ids → Relation objects.
const verbIds = (await this.graphIndex.verbIntsToIds(Array.from(sub.edgeVerbInts))).filter(
(id): id is string => id !== null
)
const verbsMap = await this.graphIndex.getVerbsBatchCached(verbIds)
const edges = this.verbsToRelations([...verbsMap.values()])
// Node hydration: pair each node int with its depth (position-preserving —
// entityIntsToUuids drops deleted ints, which would desync the depth column).
const idMapper = this.metadataIndex.getIdMapper()
const nodeDepth = new Map<string, number>()
for (let i = 0; i < sub.nodes.length; i++) {
const uuid = idMapper.getUuid(Number(sub.nodes[i]))
if (uuid !== undefined) nodeDepth.set(uuid, sub.nodeDepth ? sub.nodeDepth[i] : 0)
}
return this.buildGraphView(nodeDepth, edges, options, sub.truncated ?? false)
}
/**
* @description Assemble a {@link GraphView} from discovered node depths + edges,
* optionally hydrating each node's `type` / `subtype` in one batch read
* (`hydrateNodes`, default `true`).
*/
private async buildGraphView(
nodeDepth: Map<string, number>,
edges: Relation<T>[],
options: SubgraphOptions | undefined,
truncated: boolean
): Promise<GraphView<T>> {
const ids = [...nodeDepth.keys()]
let entities: Map<string, Entity<T>> | undefined
if (options?.hydrateNodes !== false && ids.length > 0) {
entities = await this.batchGet(ids)
}
const nodes: GraphNode[] = ids.map((id) => {
const entity = entities?.get(id)
return {
id,
...(entity && {
type: entity.type,
...(entity.subtype !== undefined && { subtype: entity.subtype })
}),
depth: nodeDepth.get(id)
}
})
return { nodes, edges, truncated }
}
// ============= SEARCH & DISCOVERY =============
/**