diff --git a/src/brainy.ts b/src/brainy.ts index 23f3d4f1..6be20683 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -3299,25 +3299,30 @@ export class Brainy implements BrainyInterface { /** Resolve the optional native graph-acceleration provider (feature-detected). */ private graphAccelerationProvider(): GraphAccelerationProvider | 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 + // Resolve + cache the provider INSTANCE once. 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. + if (this._graphAccelProvider === undefined) { + 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 } - this._graphAccelProvider = isGraphAccelerationProvider(resolved) ? resolved : null - return this._graphAccelProvider ?? undefined + const provider = this._graphAccelProvider ?? undefined + // Readiness gate — checked LIVE each call, never cached: the native engine sets + // `isInitialized` false until its engine + cursor have loaded (the cold-start / + // rebuild window). Until then, route to the pure-TS path rather than calling a + // not-ready provider (which throws) — and re-engage the native path automatically + // the moment `isInitialized` flips true. Gates EVERY `brain.graph.*` route + // (subgraph/export/rank/communities/path + the query→expand fusion) at once. + return provider && provider.isInitialized ? provider : undefined } /** diff --git a/tests/unit/brainy/graph-native-routing.test.ts b/tests/unit/brainy/graph-native-routing.test.ts index 5e0745c7..dfa4912f 100644 --- a/tests/unit/brainy/graph-native-routing.test.ts +++ b/tests/unit/brainy/graph-native-routing.test.ts @@ -18,7 +18,7 @@ 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() { +function makeMockAccel(opts: { isInitialized?: boolean } = {}) { const calls = { traverse: 0, cursorOpen: 0, cursorNext: 0, cursorClose: 0, rank: 0, communities: 0, path: 0 } const state: { calls: typeof calls @@ -44,7 +44,7 @@ function makeMockAccel() { communityCount: 0 } const provider = { - isInitialized: true, + isInitialized: opts.isInitialized ?? true, traverse: async (seeds: any) => { calls.traverse++ state.lastTraverseSeeds = seeds @@ -290,4 +290,45 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () = expect(Array.isArray(mock.state.lastTraverseSeeds)).toBe(true) // bigint[], not a Buffer expect(typeof (mock.state.lastTraverseSeeds as unknown[])[0]).toBe('bigint') }) + + // Readiness gate (cor boundary-audit item #1): the native engine reports + // isInitialized=false during its cold-start/rebuild window. Brainy must route to the + // pure-TS path then — NOT call the not-ready provider (which would throw) — across the + // whole brain.graph.* surface, and re-engage the native path once it flips true. + it('routes to the TS fallback (never calls the provider) while accel.isInitialized is false', async () => { + const notReady = makeMockAccel({ isInitialized: false }) + const b = new Brainy(createTestConfig()) + b.use({ + name: 'mock-graph-accel-not-ready', + activate: async (ctx: any) => { + ctx.registerProvider('graphAcceleration', notReady.provider) + return true + } + } as any) + await b.init() + const p = await b.add({ type: NounType.Person, subtype: 'employee', data: 'P' }) + const q = await b.add({ type: NounType.Person, subtype: 'employee', data: 'Q' }) + await b.relate({ from: p, to: q, type: VerbType.RelatedTo }) + + // Every brain.graph.* route + the query→expand fusion must NOT throw and must serve + // from the TS fallback while the engine is cold. + const sub = await b.graph.subgraph(p, { depth: 1 }) + const ranked = await b.graph.rank() + const comm = await b.graph.communities() + const route = await b.graph.path(p, q) + const fused = await b.graph.subgraph({ type: NounType.Person }, { depth: 1 }) + + // The provider was never touched — all served by the JS fallback. + expect(notReady.state.calls.traverse).toBe(0) + expect(notReady.state.calls.rank).toBe(0) + expect(notReady.state.calls.communities).toBe(0) + expect(notReady.state.calls.path).toBe(0) + // And the TS fallback returned real answers (not throws / empties from a cold engine). + expect(sub.nodes.some((n) => n.id === p)).toBe(true) + expect(ranked.length).toBeGreaterThan(0) + expect(comm.count).toBeGreaterThan(0) + expect(route?.nodes).toEqual([p, q]) + expect(fused.nodes.some((n) => n.id === p)).toBe(true) + await b.close() + }) })