diff --git a/src/brainy.ts b/src/brainy.ts index 74377e29..d368a01a 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -352,6 +352,12 @@ export class Brainy implements BrainyInterface { // Plugin system private pluginRegistry = new PluginRegistry() + /** + * Resolved native graph-acceleration provider, cached on first `brain.graph.*` + * access. `undefined` = not yet resolved; `null` = resolved, none registered + * (use the TS fallback); otherwise the provider instance. + */ + private _graphAccelProvider: GraphAccelerationProvider | null | undefined = undefined // Sub-APIs (lazy-loaded) private _nlp?: NaturalLanguageProcessor @@ -3186,8 +3192,25 @@ export class Brainy implements BrainyInterface { /** Resolve the optional native graph-acceleration provider (feature-detected). */ private graphAccelerationProvider(): GraphAccelerationProvider | undefined { - const provider = this.pluginRegistry.getProvider('graphAcceleration') - return isGraphAccelerationProvider(provider) ? provider : undefined + if (this._graphAccelProvider !== undefined) { + return this._graphAccelProvider ?? undefined + } + // Resolve the optional 'graphAcceleration' provider once and cache it. Accept + // EITHER a ready instance OR a `(storage) => provider` factory (the convention + // the graphIndex/metadataIndex/vector providers use) — a registered factory + // would otherwise fail the isGraphAccelerationProvider duck-test and the native + // path would silently never engage. + const raw = this.pluginRegistry.getProvider('graphAcceleration') + let resolved: unknown = raw + if (typeof raw === 'function' && !isGraphAccelerationProvider(raw)) { + try { + resolved = (raw as (storage: StorageAdapter) => unknown)(this.storage) + } catch { + resolved = undefined + } + } + this._graphAccelProvider = isGraphAccelerationProvider(resolved) ? resolved : null + return this._graphAccelProvider ?? undefined } /** diff --git a/tests/unit/brainy/graph-native-routing.test.ts b/tests/unit/brainy/graph-native-routing.test.ts new file mode 100644 index 00000000..955e04c0 --- /dev/null +++ b/tests/unit/brainy/graph-native-routing.test.ts @@ -0,0 +1,181 @@ +/** + * @module tests/unit/brainy/graph-native-routing + * @description Graph engine — NATIVE seam coverage. brain.graph.subgraph/export + * route to a registered GraphAccelerationProvider and hydrate its columnar + * `Subgraph` (node ints -> ids, depth alignment, edge verb-ints -> Relations). + * In production that provider is cor's native engine, cross-layer-tested on + * bxl9000; brainy CI never registers one, so graphSubgraphNative / + * graphExportNative / hydrateNativeSubgraph + the provider-resolution + routing + * were previously UNEXERCISED — a return-shape or hydration-alignment drift would + * pass CI silently. This registers a faithful MOCK provider (returning a columnar + * Subgraph built from the brain's REAL ints, so hydration resolves to real + * entities/relations) to lock those paths. + */ + +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' + +/** A stateful mock GraphAccelerationProvider; the test sets the columnar payloads. */ +function makeMockAccel() { + const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0 } + const state: { calls: typeof calls; traverseResult: any; cursorChunks: any[] } = { + calls, + traverseResult: null, + cursorChunks: [] + } + const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) } + const provider = { + isInitialized: true, + traverse: async () => { + calls.traverse++ + return state.traverseResult + }, + edgesForNode: async () => state.traverseResult, + graphCursorOpen: async () => { + calls.cursorOpen++ + return 'mock-handle' + }, + graphCursorNext: async () => { + calls.cursorNext++ + const subgraph = state.cursorChunks.shift() + return { subgraph, done: state.cursorChunks.length === 0 } + }, + graphCursorClose: async () => { + calls.cursorClose++ + }, + pageRank: async () => empty, + connectedComponents: async () => ({ nodeInts: new BigInt64Array(0), componentIds: new Uint32Array(0), componentCount: 0 }), + shortestPath: async () => null, + neighborhoodSample: async () => state.traverseResult, + topByDegree: async () => empty + } + return { provider, state } +} + +describe('brain.graph.* native routing + columnar hydration (native seam)', () => { + let brain: Brainy + let mock: ReturnType + let a: string, b: string, c: string + + // Build a faithful columnar Subgraph from the brain's REAL ints, so brainy's + // hydration resolves the node ints to ids and the verb ints to Relations. + async function realSubgraph(withUnresolvable = false) { + const gei = (id: string): bigint => (brain as any).graphEntityInt(id) + const gi = (brain as any).graphIndex + const intA = gei(a), intB = gei(b), intC = gei(c) + const vAB = (await gi.getVerbIdsBySource(intA))[0] as bigint // verb int a->b + const vBC = (await gi.getVerbIdsBySource(intB))[0] as bigint // verb int b->c + const nodeInts = [intA, intB, intC] + const depths = [0, 1, 2] + if (withUnresolvable) { + nodeInts.push(999_999_999n) // a never-assigned int (simulates a deleted/unknown node) + depths.push(3) + } + return { + nodes: BigInt64Array.from(nodeInts), + nodeDepth: Uint8Array.from(depths), + edgeSources: BigInt64Array.from([intA, intB]), + edgeTargets: BigInt64Array.from([intB, intC]), + edgeVerbInts: BigInt64Array.from([vAB, vBC]), + edgeTypes: Uint16Array.from([0, 0]), + truncated: false + } + } + + beforeEach(async () => { + mock = makeMockAccel() + brain = new Brainy(createTestConfig()) + brain.use({ + name: 'mock-graph-accel', + activate: async (ctx: any) => { + ctx.registerProvider('graphAcceleration', mock.provider) + return true + } + } as any) + 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.Project, subtype: 'milestone', data: 'C' }) + await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' }) + await brain.relate({ from: b, to: c, type: VerbType.ParticipatesIn, subtype: 'assignment' }) + }) + + afterEach(async () => { + await brain.close() + }) + + it('subgraph() routes to the native provider and hydrates the columnar result', async () => { + mock.state.traverseResult = await realSubgraph() + const view = await brain.graph.subgraph(a, { depth: 2 }) + + expect(mock.state.calls.traverse).toBe(1) // native path, not the TS fallback + const byId = new Map(view.nodes.map((n) => [n.id, n])) + expect(new Set(byId.keys())).toEqual(new Set([a, b, c])) // node int -> id + expect(byId.get(a)?.depth).toBe(0) + expect(byId.get(c)?.depth).toBe(2) // depth column aligned to node column + expect(byId.get(c)?.type).toBe(NounType.Project) // node type hydrated via batchGet + const pairs = view.edges.map((e) => `${e.from}->${e.to}`).sort() + expect(pairs).toEqual([`${a}->${b}`, `${b}->${c}`].sort()) // verb int -> Relation + }) + + it('keeps node<->depth alignment when a node int does not resolve (deleted/unknown)', async () => { + mock.state.traverseResult = await realSubgraph(true) // appends an unresolvable int at depth 3 + const view = await brain.graph.subgraph(a, { depth: 3 }) + + const byId = new Map(view.nodes.map((n) => [n.id, n])) + // The 3 real nodes keep their CORRECT depths — the unresolvable int is dropped, + // not collapsed into the array (which would shift every later depth). + expect(byId.get(a)?.depth).toBe(0) + expect(byId.get(b)?.depth).toBe(1) + expect(byId.get(c)?.depth).toBe(2) + expect(view.nodes.length).toBe(3) + }) + + it('export() routes to the native graph cursor, hydrates chunks, and always closes', async () => { + mock.state.cursorChunks = [await realSubgraph()] + const chunks: any[] = [] + for await (const v of brain.graph.export()) chunks.push(v) + + expect(mock.state.calls.cursorOpen).toBe(1) + expect(mock.state.calls.cursorClose).toBe(1) // cursor released even on normal completion + const nodes = new Set(chunks.flatMap((c) => c.nodes.map((n: any) => n.id))) + expect(nodes).toEqual(new Set([a, b, c])) + const edges = chunks.flatMap((c) => c.edges.map((e: any) => `${e.from}->${e.to}`)).sort() + expect(edges).toEqual([`${a}->${b}`, `${b}->${c}`].sort()) + }) + + it('resolves a provider registered as a FACTORY (storage) => provider, not just an instance', async () => { + const m = makeMockAccel() + const fb = new Brainy(createTestConfig()) + fb.use({ + name: 'mock-graph-accel-factory', + activate: async (ctx: any) => { + ctx.registerProvider('graphAcceleration', () => m.provider) // factory form + return true + } + } as any) + await fb.init() + const x = await fb.add({ type: NounType.Person, subtype: 'employee', data: 'X' }) + const y = await fb.add({ type: NounType.Person, subtype: 'employee', data: 'Y' }) + await fb.relate({ from: x, to: y, type: VerbType.RelatedTo, subtype: 'colleague' }) + const gei = (id: string): bigint => (fb as any).graphEntityInt(id) + const gi = (fb as any).graphIndex + const ix = gei(x), iy = gei(y) + const vxy = (await gi.getVerbIdsBySource(ix))[0] as bigint + m.state.traverseResult = { + nodes: BigInt64Array.from([ix, iy]), + nodeDepth: Uint8Array.from([0, 1]), + edgeSources: BigInt64Array.from([ix]), + edgeTargets: BigInt64Array.from([iy]), + edgeVerbInts: BigInt64Array.from([vxy]), + edgeTypes: Uint16Array.from([0]), + truncated: false + } + const view = await fb.graph.subgraph(x, { depth: 1 }) + expect(m.state.calls.traverse).toBe(1) // factory was invoked + provider routed + expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([x, y])) + await fb.close() + }) +})