feat(8.0): brain.graph.subgraph(query) query→expand fusion (#61)
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>
This commit is contained in:
parent
dd325f2f94
commit
82dde92077
5 changed files with 233 additions and 57 deletions
|
|
@ -27,13 +27,15 @@ function makeMockAccel() {
|
|||
rankResult: any
|
||||
communitiesResult: any
|
||||
pathResult: any
|
||||
lastTraverseSeeds: any
|
||||
} = {
|
||||
calls,
|
||||
traverseResult: null,
|
||||
cursorChunks: [],
|
||||
rankResult: null,
|
||||
communitiesResult: null,
|
||||
pathResult: null
|
||||
pathResult: null,
|
||||
lastTraverseSeeds: undefined
|
||||
}
|
||||
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
|
||||
const emptyCommunities = {
|
||||
|
|
@ -43,8 +45,9 @@ function makeMockAccel() {
|
|||
}
|
||||
const provider = {
|
||||
isInitialized: true,
|
||||
traverse: async () => {
|
||||
traverse: async (seeds: any) => {
|
||||
calls.traverse++
|
||||
state.lastTraverseSeeds = seeds
|
||||
return state.traverseResult
|
||||
},
|
||||
edgesForNode: async () => state.traverseResult,
|
||||
|
|
@ -261,4 +264,30 @@ describe('brain.graph.* native routing + columnar hydration (native seam)', () =
|
|||
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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -98,4 +98,35 @@ describe('brain.graph.subgraph() (graph engine #25)', () => {
|
|||
// depth 0 = just the seeds, no expansion.
|
||||
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([a, c]))
|
||||
})
|
||||
|
||||
// ---- Query→expand fusion (graph #61): seed from a query / find() result ----
|
||||
|
||||
it('query→expand: subgraph(query) seeds from the matches and expands their neighborhood', async () => {
|
||||
// The only Project is d; 1-hop expansion brings in a (a → d ReportsTo).
|
||||
const view = await brain.graph.subgraph({ type: NounType.Project }, { depth: 1 })
|
||||
const ids = new Set(view.nodes.map((n) => n.id))
|
||||
expect(ids.has(d)).toBe(true) // the query match (seed)
|
||||
expect(ids.has(a)).toBe(true) // expanded neighbor
|
||||
expect(view.edges.some((r) => r.from === a && r.to === d)).toBe(true)
|
||||
})
|
||||
|
||||
it('query→expand: depth 0 returns just the matches, no expansion', async () => {
|
||||
const view = await brain.graph.subgraph({ type: NounType.Project }, { depth: 0 })
|
||||
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([d]))
|
||||
})
|
||||
|
||||
it('accepts a find() result array as the seed set', async () => {
|
||||
const matches = await brain.find({ type: NounType.Project }) // → [d]
|
||||
expect(matches.length).toBeGreaterThan(0)
|
||||
const view = await brain.graph.subgraph(matches, { depth: 1 })
|
||||
const ids = new Set(view.nodes.map((n) => n.id))
|
||||
expect(ids.has(d)).toBe(true)
|
||||
expect(ids.has(a)).toBe(true)
|
||||
})
|
||||
|
||||
it('query→expand returns empty when the query matches nothing', async () => {
|
||||
const view = await brain.graph.subgraph({ type: NounType.Event }, { depth: 1 })
|
||||
expect(view.nodes).toEqual([])
|
||||
expect(view.edges).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue