test(8.0): cover the native graph seam + make provider resolution factory-tolerant
The brain.graph.subgraph/export NATIVE routing (graphSubgraphNative / graphExportNative / hydrateNativeSubgraph + provider resolution) had ZERO brainy CI coverage — in production it's exercised only cross-layer against cor's engine, so a columnar return-shape or hydration-alignment drift would pass brainy CI silently. This registers a faithful MOCK GraphAccelerationProvider returning a columnar Subgraph built from the brain's REAL ints, locking the seam: - subgraph() routes native (traverse called) and hydrates node int->id, the nodeDepth column aligned to the node column, node type via batchGet, and edge verb-int->Relation via verbIntsToIds + getVerbsBatchCached. - node<->depth alignment is preserved when a node int does NOT resolve (deleted/unknown) — the unresolvable int is dropped, not collapsed (which would shift every later depth). This is the exact hydration risk the release audit flagged. - export() routes to the graph cursor, hydrates chunks, and ALWAYS closes the handle. Also fixes a latent contract bug found writing the test: graphAccelerationProvider() only accepted a ready instance, so a provider registered as a (storage)=>provider FACTORY (the convention graphIndex/metadataIndex/vector use) would fail the duck-test and the native path would silently never engage. Now resolves instance OR factory, cached. Covered by the factory-registration test.
This commit is contained in:
parent
89c4b7016c
commit
29410bc568
2 changed files with 206 additions and 2 deletions
|
|
@ -352,6 +352,12 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
|
||||
// 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<T = any> implements BrainyInterface<T> {
|
|||
|
||||
/** 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
|
||||
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<unknown>('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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
181
tests/unit/brainy/graph-native-routing.test.ts
Normal file
181
tests/unit/brainy/graph-native-routing.test.ts
Normal file
|
|
@ -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<typeof makeMockAccel>
|
||||
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()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue