The subgraph seed selector now accepts not just entity id(s) but a find() result
or a FindParams query — brain.graph.subgraph({ where: { team: 'platform' } },
{ depth: 1 }) runs the query and expands the neighborhood of every match in one
call. Existing id-seeded calls are unchanged.
The win is the native query→expand path: for a metadata-only query, the matched
universe is forwarded to traverse() as an OpaqueIdSet (a roaring Buffer) with NO
id materialization in TypeScript — the find() result never leaves the engine's
representation (the O(1)-crossing the cor 3.0 contract is built around). The
pure-JS path (or any query carrying vector/text/proximity criteria) materializes
the matched ids via find() and seeds the traversal from them, capped at a bounded
default when the caller pins no limit.
- New public `SubgraphSelector<T>` union (id | id[] | Result[] | FindParams).
- find()'s metadata filter-builder extracted to a shared `buildMetadataFilter`
(reused by the opaque universe producer); behavior unchanged.
- graphSubgraphNative generalized to accept `bigint[] | OpaqueIdSet` seeds.
Tested: JS query→expand + Result[] selector + empty-match in graph-subgraph, and
the native opaque pass-through (Buffer reaches traverse unmaterialized) vs the
find()-materialized bigint[] seeds via the mock provider in graph-native-routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
/**
|
|
* @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, rank: 0, communities: 0, path: 0 }
|
|
const state: {
|
|
calls: typeof calls
|
|
traverseResult: any
|
|
cursorChunks: any[]
|
|
rankResult: any
|
|
communitiesResult: any
|
|
pathResult: any
|
|
lastTraverseSeeds: any
|
|
} = {
|
|
calls,
|
|
traverseResult: null,
|
|
cursorChunks: [],
|
|
rankResult: null,
|
|
communitiesResult: null,
|
|
pathResult: null,
|
|
lastTraverseSeeds: undefined
|
|
}
|
|
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
|
|
const emptyCommunities = {
|
|
nodeInts: new BigInt64Array(0),
|
|
communityIds: new Uint32Array(0),
|
|
communityCount: 0
|
|
}
|
|
const provider = {
|
|
isInitialized: true,
|
|
traverse: async (seeds: any) => {
|
|
calls.traverse++
|
|
state.lastTraverseSeeds = seeds
|
|
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++
|
|
},
|
|
rank: async () => {
|
|
calls.rank++
|
|
return state.rankResult ?? empty
|
|
},
|
|
communities: async () => {
|
|
calls.communities++
|
|
return state.communitiesResult ?? emptyCommunities
|
|
},
|
|
path: async () => {
|
|
calls.path++
|
|
return state.pathResult
|
|
},
|
|
sample: async () => state.traverseResult,
|
|
mostConnected: 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()
|
|
})
|
|
|
|
it('rank() routes to the native provider and hydrates node ints -> ids, order preserved', async () => {
|
|
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
|
|
// Provider returns DESCENDING scores: c, then a, then b.
|
|
mock.state.rankResult = {
|
|
nodeInts: BigInt64Array.from([gei(c), gei(a), gei(b)]),
|
|
scores: Float64Array.from([0.5, 0.3, 0.2])
|
|
}
|
|
const ranked = await brain.graph.rank()
|
|
|
|
expect(mock.state.calls.rank).toBe(1) // native path, not the TS PageRank fallback
|
|
expect(ranked.map((r) => r.id)).toEqual([c, a, b]) // int -> id, provider order kept
|
|
expect(ranked[0].score).toBe(0.5)
|
|
|
|
const top2 = await brain.graph.rank({ topK: 2 })
|
|
expect(top2.map((r) => r.id)).toEqual([c, a])
|
|
})
|
|
|
|
it('communities() routes to the native provider and buckets ids by community label', async () => {
|
|
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
|
|
// a,b in community 0; c alone in community 1.
|
|
mock.state.communitiesResult = {
|
|
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
|
|
communityIds: Uint32Array.from([0, 0, 1]),
|
|
communityCount: 2
|
|
}
|
|
const { groups, count } = await brain.graph.communities()
|
|
|
|
expect(mock.state.calls.communities).toBe(1) // native path, not the TS fallback
|
|
expect(count).toBe(2)
|
|
const asSets = groups.map((g) => new Set(g))
|
|
expect(asSets).toContainEqual(new Set([a, b]))
|
|
expect(asSets).toContainEqual(new Set([c]))
|
|
expect(groups[0]).toHaveLength(2) // largest group first
|
|
})
|
|
|
|
it('path() routes to the native provider and hydrates node + verb ints', async () => {
|
|
const gei = (id: string): bigint => (brain as any).graphEntityInt(id)
|
|
const gi = (brain as any).graphIndex
|
|
const vAB = (await gi.getVerbIdsBySource(gei(a)))[0] as bigint
|
|
const vBC = (await gi.getVerbIdsBySource(gei(b)))[0] as bigint
|
|
mock.state.pathResult = {
|
|
nodeInts: BigInt64Array.from([gei(a), gei(b), gei(c)]),
|
|
edgeVerbInts: BigInt64Array.from([vAB, vBC]),
|
|
cost: 2
|
|
}
|
|
const route = await brain.graph.path(a, c)
|
|
|
|
expect(mock.state.calls.path).toBe(1) // native path, not the TS BFS/Dijkstra fallback
|
|
expect(route?.nodes).toEqual([a, b, c]) // node ints -> ids
|
|
expect(route?.relationships).toHaveLength(2) // verb ints -> verb-id strings
|
|
expect(route?.cost).toBe(2)
|
|
})
|
|
|
|
it('path() returns null when the native provider reports unreachable', async () => {
|
|
mock.state.pathResult = null
|
|
expect(await brain.graph.path(a, c)).toBeNull()
|
|
expect(mock.state.calls.path).toBe(1)
|
|
})
|
|
|
|
it('subgraph(query) forwards the metadata universe to traverse as an OpaqueIdSet (query→expand #61)', async () => {
|
|
// The native metadata index would return its roaring filter result as a Buffer;
|
|
// stub that producer and assert it reaches traverse WITHOUT id materialization.
|
|
const sentinel = new Uint8Array([0x01, 0x02, 0x03])
|
|
;(brain as any).metadataIndex.getIdSetForFilter = async () => sentinel
|
|
mock.state.traverseResult = await realSubgraph()
|
|
|
|
await brain.graph.subgraph({ type: NounType.Person }, { depth: 1 })
|
|
|
|
expect(mock.state.calls.traverse).toBe(1)
|
|
// The opaque Buffer is the traverse seed argument — passed straight through.
|
|
expect(mock.state.lastTraverseSeeds).toBe(sentinel)
|
|
})
|
|
|
|
it('subgraph(query) materializes seeds via find() when no opaque producer exists', async () => {
|
|
// No getIdSetForFilter on the (real) metadata index → general path: find() runs,
|
|
// its matched ids resolve to entity ints, and those seed the native traverse.
|
|
mock.state.traverseResult = await realSubgraph()
|
|
|
|
await brain.graph.subgraph({ type: NounType.Person }, { depth: 1 })
|
|
|
|
expect(mock.state.calls.traverse).toBe(1)
|
|
expect(Array.isArray(mock.state.lastTraverseSeeds)).toBe(true) // bigint[], not a Buffer
|
|
expect(typeof (mock.state.lastTraverseSeeds as unknown[])[0]).toBe('bigint')
|
|
})
|
|
})
|