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 { setGlobalCache } from './utils/unifiedCache.js'
import type { UnifiedCache } from './utils/unifiedCache.js' import type { UnifiedCache } from './utils/unifiedCache.js'
import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.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 { import type {
BrainyPlugin, BrainyPlugin,
BrainyPluginContext, BrainyPluginContext,
@ -88,6 +89,10 @@ import {
FindParams, FindParams,
SimilarParams, SimilarParams,
RelatedParams, RelatedParams,
GraphApi,
GraphView,
GraphNode,
SubgraphOptions,
GetOptions, GetOptions,
AddManyParams, AddManyParams,
RemoveManyParams, RemoveManyParams,
@ -3156,6 +3161,213 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return this.verbsToRelations(result.items) 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 ============= // ============= SEARCH & DISCOVERY =============
/** /**

View file

@ -681,6 +681,27 @@ export function isVersionedIndexProvider(
) )
} }
/**
* @description Feature-detect an optional {@link GraphAccelerationProvider} (the
* native graph engine). Brainy probes the registered `'graphAcceleration'`
* provider with this: present `brain.graph.*` routes to native `traverse` /
* cursor / analytics; absent Brainy serves the same operations from its
* pure-TS adjacency fallback. Checks the two anchor methods (`traverse` +
* `graphCursorOpen`) a partial implementation that lacks them is treated as
* absent (fall back rather than crash).
* @param candidate - The value registered under `'graphAcceleration'`, or undefined.
* @returns Whether `candidate` is a usable {@link GraphAccelerationProvider}.
*/
export function isGraphAccelerationProvider(
candidate: unknown
): candidate is GraphAccelerationProvider {
if (candidate === null || typeof candidate !== 'object') {
return false
}
const c = candidate as Record<string, unknown>
return typeof c.traverse === 'function' && typeof c.graphCursorOpen === 'function'
}
/** /**
* The object returned by the `'vector'` provider factory Brainy's vector * The object returned by the `'vector'` provider factory Brainy's vector
* index contract. Implementations include Brainy's own JS HNSW index and any * index contract. Implementations include Brainy's own JS HNSW index and any

View file

@ -763,6 +763,88 @@ export interface RelatedParams {
service?: string service?: string
} }
// ============= Graph views (brain.graph.*) =============
/**
* A node in a {@link GraphView} a lightweight reference (id + classification +
* hop depth), NOT a full {@link Entity}. Graph reads are structure-first; resolve
* a node to its full entity with `get(id)` when you need its `data` / metadata.
*/
export interface GraphNode {
/** Entity id. */
id: string
/** Entity type (present when the view hydrates node classification). */
type?: NounType
/** Per-product subtype (present when hydrated). */
subtype?: string
/** Hop distance from the nearest seed (0 = a seed); present on subgraph results. */
depth?: number
}
/**
* A hydrated view of a region of the graph the discovered nodes plus the edges
* among them, in id form (the public face of the provider's columnar int form).
*/
export interface GraphView<T = any> {
/** The nodes in this region (seeds + everything reached). */
nodes: GraphNode[]
/** The edges among `nodes`, as full {@link Relation} objects. */
edges: Relation<T>[]
/** `true` when a `maxNodes` / `maxEdges` cap truncated the result. */
truncated?: boolean
}
/**
* Options for {@link GraphApi.subgraph}.
*/
export interface SubgraphOptions {
/** Max hop distance from any seed (default `1`). */
depth?: number
/** Edge direction to follow (default `'both'`). */
direction?: 'in' | 'out' | 'both'
/** Restrict traversed edges to these relationship type(s). */
type?: VerbType | VerbType[]
/** Restrict traversed edges to these subtype(s). */
subtype?: string | string[]
/** Also traverse through `internal`-visibility edges/nodes (hidden by default). */
includeInternal?: boolean
/** Also traverse through `system`-visibility edges/nodes (hidden by default). */
includeSystem?: boolean
/** Cap on returned nodes (sets `GraphView.truncated`). */
maxNodes?: number
/** Cap on returned edges. */
maxEdges?: number
/**
* Hydrate each node's `type` / `subtype` (one batch read). Default `true`;
* set `false` to skip it when you only need graph structure (ids + edges).
*/
hydrateNodes?: boolean
}
/**
* 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` ships first; `export` / `rank` / `path` /
* `communities` follow.)
*/
export interface GraphApi<T = any> {
/**
* @description Extract the subgraph reachable from one or more seed entities
* the bounded multi-hop neighborhood, as nodes + edges. The one-call answer to
* "show me everything around this node, N hops out".
* @param seeds - A seed entity id, or an array of them.
* @param options - Depth, direction, edge/visibility filters, and caps.
* @returns The hydrated {@link GraphView}.
* @example
* const view = await brain.graph.subgraph(personId, { depth: 2 })
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
*/
subgraph(seeds: string | string[], options?: SubgraphOptions): Promise<GraphView<T>>
}
// ============= Batch Operations ============= // ============= Batch Operations =============
/** /**

View file

@ -0,0 +1,101 @@
/**
* @module tests/unit/brainy/graph-subgraph
* @description Graph engine #25: `brain.graph.subgraph()` bounded multi-hop
* neighborhood extraction. These exercise the pure-TS fallback (no native
* GraphAccelerationProvider is registered in CI); the native path returns the
* same `GraphView` shape, cross-layer-tested against the provider.
*
* Graph under test (RelatedTo unless noted):
* e a b c , a (ReportsTo) d , a (internal) f
*/
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.subgraph() (graph engine #25)', () => {
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()
a = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'A' })
b = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'B' })
c = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'C' })
d = await brain.add({ type: NounType.Project, subtype: 'milestone', data: 'D' })
e = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'E' })
f = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'F' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'direct' })
await brain.relate({ from: e, to: a, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: f, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
})
afterEach(async () => {
await brain.close()
})
it('depth-1 returns the seed + immediate neighbors (both directions) with depths', async () => {
const view = await brain.graph.subgraph(a, { depth: 1 })
const byId = new Map(view.nodes.map((n) => [n.id, n]))
// a is the seed (depth 0); b, d (out) and e (in) are 1 hop. f is internal → hidden.
expect(new Set(byId.keys())).toEqual(new Set([a, b, d, e]))
expect(byId.get(a)?.depth).toBe(0)
expect(byId.get(b)?.depth).toBe(1)
expect(byId.get(e)?.depth).toBe(1)
// Edges among them.
const pairs = view.edges.map((r) => `${r.from}->${r.to}`).sort()
expect(pairs).toEqual([`${a}->${b}`, `${a}->${d}`, `${e}->${a}`].sort())
})
it('depth-2 expands one more hop (c via b), tracking depth', async () => {
const view = await brain.graph.subgraph(a, { depth: 2 })
const byId = new Map(view.nodes.map((n) => [n.id, n]))
expect(byId.has(c)).toBe(true)
expect(byId.get(c)?.depth).toBe(2)
expect(view.edges.some((r) => r.from === b && r.to === c)).toBe(true)
})
it("direction:'out' follows only outgoing edges", async () => {
const view = await brain.graph.subgraph(a, { depth: 1, direction: 'out' })
const ids = new Set(view.nodes.map((n) => n.id))
expect(ids).toEqual(new Set([a, b, d])) // e (e→a, incoming) excluded
})
it('composes with a verb-type filter', async () => {
const view = await brain.graph.subgraph(a, { depth: 1, type: VerbType.ReportsTo })
expect(view.edges.map((r) => `${r.from}->${r.to}`)).toEqual([`${a}->${d}`])
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([a, d]))
})
it('includeInternal surfaces the hidden edge/node', async () => {
const view = await brain.graph.subgraph(a, { depth: 1, includeInternal: true })
expect(view.nodes.some((n) => n.id === f)).toBe(true)
expect(view.edges.some((r) => r.to === f)).toBe(true)
})
it('hydrates node type/subtype by default; skips it with hydrateNodes:false', async () => {
const hydrated = await brain.graph.subgraph(a, { depth: 1 })
const dNode = hydrated.nodes.find((n) => n.id === d)
expect(dNode?.type).toBe(NounType.Project)
expect(dNode?.subtype).toBe('milestone')
const bare = await brain.graph.subgraph(a, { depth: 1, hydrateNodes: false })
expect(bare.nodes.every((n) => n.type === undefined)).toBe(true)
})
it('caps the result and flags truncated when maxNodes is hit', async () => {
const view = await brain.graph.subgraph(a, { depth: 2, maxNodes: 2 })
expect(view.truncated).toBe(true)
expect(view.nodes.length).toBeLessThanOrEqual(2)
})
it('accepts multiple seeds', async () => {
const view = await brain.graph.subgraph([a, c], { depth: 0 })
// depth 0 = just the seeds, no expansion.
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([a, c]))
})
})