brainy/tests/unit/brainy/graph-subgraph.test.ts
David Snelling 82dde92077 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>
2026-06-23 13:30:30 -07:00

132 lines
6.2 KiB
TypeScript

/**
* @module tests/unit/brainy/graph-subgraph
* @description Graph engine #25: `brain.graph.subgraph()` — bounded multi-hop
* neighborhood extraction. These exercise the pure-TS fallback (no native
* GraphAccelerationProvider is registered in CI); the native path returns the
* same `GraphView` shape, cross-layer-tested against the provider.
*
* Graph under test (RelatedTo unless noted):
* e → a → b → c , a →(ReportsTo) d , a →(internal) f
*/
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'
describe('brain.graph.subgraph() (graph engine #25)', () => {
let brain: Brainy
let a: string, b: string, c: string, d: string, e: string, f: string
beforeEach(async () => {
brain = new Brainy(createTestConfig())
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.Person, subtype: 'employee', data: 'C' })
d = await brain.add({ type: NounType.Project, subtype: 'milestone', data: 'D' })
e = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'E' })
f = await brain.add({ type: NounType.Person, subtype: 'employee', data: 'F' })
await brain.relate({ from: a, to: b, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: b, to: c, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: d, type: VerbType.ReportsTo, subtype: 'direct' })
await brain.relate({ from: e, to: a, type: VerbType.RelatedTo, subtype: 'colleague' })
await brain.relate({ from: a, to: f, type: VerbType.RelatedTo, subtype: 'colleague', visibility: 'internal' })
})
afterEach(async () => {
await brain.close()
})
it('depth-1 returns the seed + immediate neighbors (both directions) with depths', async () => {
const view = await brain.graph.subgraph(a, { depth: 1 })
const byId = new Map(view.nodes.map((n) => [n.id, n]))
// a is the seed (depth 0); b, d (out) and e (in) are 1 hop. f is internal → hidden.
expect(new Set(byId.keys())).toEqual(new Set([a, b, d, e]))
expect(byId.get(a)?.depth).toBe(0)
expect(byId.get(b)?.depth).toBe(1)
expect(byId.get(e)?.depth).toBe(1)
// Edges among them.
const pairs = view.edges.map((r) => `${r.from}->${r.to}`).sort()
expect(pairs).toEqual([`${a}->${b}`, `${a}->${d}`, `${e}->${a}`].sort())
})
it('depth-2 expands one more hop (c via b), tracking depth', async () => {
const view = await brain.graph.subgraph(a, { depth: 2 })
const byId = new Map(view.nodes.map((n) => [n.id, n]))
expect(byId.has(c)).toBe(true)
expect(byId.get(c)?.depth).toBe(2)
expect(view.edges.some((r) => r.from === b && r.to === c)).toBe(true)
})
it("direction:'out' follows only outgoing edges", async () => {
const view = await brain.graph.subgraph(a, { depth: 1, direction: 'out' })
const ids = new Set(view.nodes.map((n) => n.id))
expect(ids).toEqual(new Set([a, b, d])) // e (e→a, incoming) excluded
})
it('composes with a verb-type filter', async () => {
const view = await brain.graph.subgraph(a, { depth: 1, type: VerbType.ReportsTo })
expect(view.edges.map((r) => `${r.from}->${r.to}`)).toEqual([`${a}->${d}`])
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([a, d]))
})
it('includeInternal surfaces the hidden edge/node', async () => {
const view = await brain.graph.subgraph(a, { depth: 1, includeInternal: true })
expect(view.nodes.some((n) => n.id === f)).toBe(true)
expect(view.edges.some((r) => r.to === f)).toBe(true)
})
it('hydrates node type/subtype by default; skips it with hydrateNodes:false', async () => {
const hydrated = await brain.graph.subgraph(a, { depth: 1 })
const dNode = hydrated.nodes.find((n) => n.id === d)
expect(dNode?.type).toBe(NounType.Project)
expect(dNode?.subtype).toBe('milestone')
const bare = await brain.graph.subgraph(a, { depth: 1, hydrateNodes: false })
expect(bare.nodes.every((n) => n.type === undefined)).toBe(true)
})
it('caps the result and flags truncated when maxNodes is hit', async () => {
const view = await brain.graph.subgraph(a, { depth: 2, maxNodes: 2 })
expect(view.truncated).toBe(true)
expect(view.nodes.length).toBeLessThanOrEqual(2)
})
it('accepts multiple seeds', async () => {
const view = await brain.graph.subgraph([a, c], { depth: 0 })
// 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([])
})
})