diff --git a/src/brainy.ts b/src/brainy.ts index b133ee8e..590d21f1 100644 --- a/src/brainy.ts +++ b/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 implements BrainyInterface { 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 { + 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('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> { + 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> { + const maxNodes = options?.maxNodes + const maxEdges = options?.maxEdges + const nodeDepth = new Map() + for (const id of seedIds) nodeDepth.set(id, 0) + const edgesById = new Map>() + 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[]> { + 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> { + 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() + 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, + edges: Relation[], + options: SubgraphOptions | undefined, + truncated: boolean + ): Promise> { + const ids = [...nodeDepth.keys()] + let entities: Map> | 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 ============= /** diff --git a/src/plugin.ts b/src/plugin.ts index 66ad7184..0eeedbd0 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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 + return typeof c.traverse === 'function' && typeof c.graphCursorOpen === 'function' +} + /** * The object returned by the `'vector'` provider factory — Brainy's vector * index contract. Implementations include Brainy's own JS HNSW index and any diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 32b99715..7d8debdc 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -763,6 +763,88 @@ export interface RelatedParams { 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 { + /** The nodes in this region (seeds + everything reached). */ + nodes: GraphNode[] + /** The edges among `nodes`, as full {@link Relation} objects. */ + edges: Relation[] + /** `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 { + /** + * @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> +} + // ============= Batch Operations ============= /** diff --git a/tests/unit/brainy/graph-subgraph.test.ts b/tests/unit/brainy/graph-subgraph.test.ts new file mode 100644 index 00000000..4eb8049a --- /dev/null +++ b/tests/unit/brainy/graph-subgraph.test.ts @@ -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])) + }) +})