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:
parent
d4de48d004
commit
8c2b57a488
4 changed files with 417 additions and 1 deletions
214
src/brainy.ts
214
src/brainy.ts
|
|
@ -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 =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue