feat(8.0): brain.graph.subgraph() + native-provider routing
Introduces the brain.graph namespace and its first op, subgraph() — bounded
multi-hop neighborhood extraction returning a hydrated GraphView (nodes with hop
depth + type/subtype, edges as Relations, a truncated flag). The one-call answer
to 'show me everything around this node, N hops out' the graph-viz path needs.
- brain.graph.subgraph(seeds, { depth, direction, type, subtype, includeInternal,
includeSystem, maxNodes, maxEdges, hydrateNodes }). seeds: id or id[].
- Feature-detect routing: when a GraphAccelerationProvider is registered
(isGraphAccelerationProvider on the 'graphAcceleration' provider) it routes to
native traverse() and hydrates the columnar Subgraph (node ints↔ids paired with
depth position-preserving, edge verb ints → Relations); otherwise a pure-TS BFS
fallback expands each frontier through the O(degree) related() adjacency.
- Both paths produce the identical GraphView, so CI exercises the shape via the
fallback; the native path is cross-layer-tested against cor's provider.
- New public types: GraphView, GraphNode, SubgraphOptions, GraphApi (the surface
grows: export/rank/path/communities follow). isGraphAccelerationProvider guard
added to plugin.ts.
Test: graph-subgraph.test.ts — depth tracking, direction, type filter, default-
hides-internal, node hydration on/off, maxNodes truncation, multi-seed.
This commit is contained in:
parent
d4de48d004
commit
8c2b57a488
4 changed files with 417 additions and 1 deletions
101
tests/unit/brainy/graph-subgraph.test.ts
Normal file
101
tests/unit/brainy/graph-subgraph.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* @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]))
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue