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
214
src/brainy.ts
214
src/brainy.ts
|
|
@ -36,7 +36,8 @@ import { configureLogger, LogLevel, prodLog } from './utils/logger.js'
|
|||
import { setGlobalCache } from './utils/unifiedCache.js'
|
||||
import type { UnifiedCache } from './utils/unifiedCache.js'
|
||||
import { rankIndicesByScore, reorderByIndices } from './utils/resultRanking.js'
|
||||
import { PluginRegistry, isVersionedIndexProvider } from './plugin.js'
|
||||
import { PluginRegistry, isVersionedIndexProvider, isGraphAccelerationProvider } from './plugin.js'
|
||||
import type { GraphAccelerationProvider, TraverseOptions } from './plugin.js'
|
||||
import type {
|
||||
BrainyPlugin,
|
||||
BrainyPluginContext,
|
||||
|
|
@ -88,6 +89,10 @@ import {
|
|||
FindParams,
|
||||
SimilarParams,
|
||||
RelatedParams,
|
||||
GraphApi,
|
||||
GraphView,
|
||||
GraphNode,
|
||||
SubgraphOptions,
|
||||
GetOptions,
|
||||
AddManyParams,
|
||||
RemoveManyParams,
|
||||
|
|
@ -3156,6 +3161,213 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
return this.verbsToRelations(result.items)
|
||||
}
|
||||
|
||||
// ============= GRAPH VIEWS (brain.graph.*) =============
|
||||
|
||||
/**
|
||||
* @description The `brain.graph` namespace — graph-shaped reads. `subgraph`
|
||||
* extracts the multi-hop neighborhood around seed entities; the surface grows
|
||||
* with the engine (streaming `export`, `rank`, `path`, `communities`). Routes
|
||||
* to a registered native {@link GraphAccelerationProvider} when present, else
|
||||
* serves the same results from Brainy's pure-TS adjacency. Reads are live
|
||||
* ("now"); historical graph views come from `db.asOf(g).graph.*` (a later slice).
|
||||
* @returns The {@link GraphApi} bound to this brain.
|
||||
* @example
|
||||
* const view = await brain.graph.subgraph(personId, { depth: 2 })
|
||||
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
|
||||
*/
|
||||
get graph(): GraphApi<T> {
|
||||
return {
|
||||
subgraph: (seeds: string | string[], options?: SubgraphOptions) =>
|
||||
this.graphSubgraph(seeds, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* @description {@link GraphApi.subgraph} dispatch: native engine when present,
|
||||
* else the TS BFS fallback. Seeds are id-normalized so a caller may seed by
|
||||
* natural key.
|
||||
*/
|
||||
private async graphSubgraph(
|
||||
seeds: string | string[],
|
||||
options?: SubgraphOptions
|
||||
): Promise<GraphView<T>> {
|
||||
await this.ensureInitialized()
|
||||
const seedIds = (Array.isArray(seeds) ? seeds : [seeds]).map((s) => resolveEntityId(s))
|
||||
const depth = Math.max(0, options?.depth ?? 1)
|
||||
const direction = options?.direction ?? 'both'
|
||||
|
||||
const accel = this.graphAccelerationProvider()
|
||||
return accel
|
||||
? this.graphSubgraphNative(accel, seedIds, depth, direction, options)
|
||||
: this.graphSubgraphFallback(seedIds, depth, direction, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Pure-TS subgraph BFS — expands each frontier through the
|
||||
* O(degree) `related()` adjacency (visibility / type / subtype filters applied
|
||||
* there), dedupes edges, tracks hop depth, and honors `maxNodes` / `maxEdges`
|
||||
* caps. Correct at small/medium scale; the native provider is the at-scale path.
|
||||
*/
|
||||
private async graphSubgraphFallback(
|
||||
seedIds: string[],
|
||||
depth: number,
|
||||
direction: 'in' | 'out' | 'both',
|
||||
options?: SubgraphOptions
|
||||
): Promise<GraphView<T>> {
|
||||
const maxNodes = options?.maxNodes
|
||||
const maxEdges = options?.maxEdges
|
||||
const nodeDepth = new Map<string, number>()
|
||||
for (const id of seedIds) nodeDepth.set(id, 0)
|
||||
const edgesById = new Map<string, Relation<T>>()
|
||||
let truncated = false
|
||||
|
||||
let frontier = [...seedIds]
|
||||
for (let d = 1; d <= depth && frontier.length > 0 && !truncated; d++) {
|
||||
const next: string[] = []
|
||||
for (const nodeId of frontier) {
|
||||
const incident = await this.incidentEdges(nodeId, direction, options)
|
||||
for (const edge of incident) {
|
||||
if (!edgesById.has(edge.id)) {
|
||||
if (maxEdges !== undefined && edgesById.size >= maxEdges) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
edgesById.set(edge.id, edge)
|
||||
}
|
||||
const neighbor = edge.from === nodeId ? edge.to : edge.from
|
||||
if (!nodeDepth.has(neighbor)) {
|
||||
if (maxNodes !== undefined && nodeDepth.size >= maxNodes) {
|
||||
truncated = true
|
||||
continue
|
||||
}
|
||||
nodeDepth.set(neighbor, d)
|
||||
next.push(neighbor)
|
||||
}
|
||||
}
|
||||
if (truncated) break
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
|
||||
return this.buildGraphView(nodeDepth, [...edgesById.values()], options, truncated)
|
||||
}
|
||||
|
||||
/** One frontier hop's incident edges in the requested direction (filters applied by `related()`). */
|
||||
private incidentEdges(
|
||||
nodeId: string,
|
||||
direction: 'in' | 'out' | 'both',
|
||||
options?: SubgraphOptions
|
||||
): Promise<Relation<T>[]> {
|
||||
const shared: RelatedParams = {
|
||||
type: options?.type,
|
||||
subtype: options?.subtype,
|
||||
includeInternal: options?.includeInternal,
|
||||
includeSystem: options?.includeSystem,
|
||||
limit: options?.maxEdges ?? 100000
|
||||
}
|
||||
if (direction === 'out') return this.related({ ...shared, from: nodeId })
|
||||
if (direction === 'in') return this.related({ ...shared, to: nodeId })
|
||||
return this.related({ ...shared, node: nodeId })
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Native subgraph: resolve seeds to ints, call the provider's
|
||||
* `traverse`, then hydrate the columnar `Subgraph` (node ints → ids paired with
|
||||
* their depth, edge verb ints → `Relation`s) into a {@link GraphView}. The TS
|
||||
* fallback produces the same view, so Brainy CI exercises this shape via the
|
||||
* fallback; the native path itself is cross-layer-tested against the provider.
|
||||
*/
|
||||
private async graphSubgraphNative(
|
||||
accel: GraphAccelerationProvider,
|
||||
seedIds: string[],
|
||||
depth: number,
|
||||
direction: 'in' | 'out' | 'both',
|
||||
options?: SubgraphOptions
|
||||
): Promise<GraphView<T>> {
|
||||
const seedInts: bigint[] = []
|
||||
for (const id of seedIds) {
|
||||
const int = this.graphEntityInt(id)
|
||||
if (int !== undefined) seedInts.push(int)
|
||||
}
|
||||
if (seedInts.length === 0) return { nodes: [], edges: [], truncated: false }
|
||||
|
||||
const verbTypes = options?.type
|
||||
? (Array.isArray(options.type) ? options.type : [options.type]).map((t) =>
|
||||
TypeUtils.getVerbIndex(t)
|
||||
)
|
||||
: undefined
|
||||
const excluded = this.excludedVisibilityTiers(options ?? {})
|
||||
const traverseOptions: TraverseOptions = {
|
||||
depth,
|
||||
direction,
|
||||
...(verbTypes && { verbTypes }),
|
||||
...(options?.subtype !== undefined && {
|
||||
subtypes: Array.isArray(options.subtype) ? options.subtype : [options.subtype]
|
||||
}),
|
||||
...(excluded && { excludeVisibility: excluded as unknown as string[] }),
|
||||
...(options?.maxNodes !== undefined && { maxNodes: options.maxNodes }),
|
||||
...(options?.maxEdges !== undefined && { maxEdges: options.maxEdges }),
|
||||
includeEdges: true,
|
||||
includeDepth: true
|
||||
}
|
||||
|
||||
const sub = await accel.traverse(seedInts, traverseOptions)
|
||||
|
||||
// Edge hydration: verb ints → ids → Relation objects.
|
||||
const verbIds = (await this.graphIndex.verbIntsToIds(Array.from(sub.edgeVerbInts))).filter(
|
||||
(id): id is string => id !== null
|
||||
)
|
||||
const verbsMap = await this.graphIndex.getVerbsBatchCached(verbIds)
|
||||
const edges = this.verbsToRelations([...verbsMap.values()])
|
||||
|
||||
// Node hydration: pair each node int with its depth (position-preserving —
|
||||
// entityIntsToUuids drops deleted ints, which would desync the depth column).
|
||||
const idMapper = this.metadataIndex.getIdMapper()
|
||||
const nodeDepth = new Map<string, number>()
|
||||
for (let i = 0; i < sub.nodes.length; i++) {
|
||||
const uuid = idMapper.getUuid(Number(sub.nodes[i]))
|
||||
if (uuid !== undefined) nodeDepth.set(uuid, sub.nodeDepth ? sub.nodeDepth[i] : 0)
|
||||
}
|
||||
|
||||
return this.buildGraphView(nodeDepth, edges, options, sub.truncated ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Assemble a {@link GraphView} from discovered node depths + edges,
|
||||
* optionally hydrating each node's `type` / `subtype` in one batch read
|
||||
* (`hydrateNodes`, default `true`).
|
||||
*/
|
||||
private async buildGraphView(
|
||||
nodeDepth: Map<string, number>,
|
||||
edges: Relation<T>[],
|
||||
options: SubgraphOptions | undefined,
|
||||
truncated: boolean
|
||||
): Promise<GraphView<T>> {
|
||||
const ids = [...nodeDepth.keys()]
|
||||
let entities: Map<string, Entity<T>> | undefined
|
||||
if (options?.hydrateNodes !== false && ids.length > 0) {
|
||||
entities = await this.batchGet(ids)
|
||||
}
|
||||
const nodes: GraphNode[] = ids.map((id) => {
|
||||
const entity = entities?.get(id)
|
||||
return {
|
||||
id,
|
||||
...(entity && {
|
||||
type: entity.type,
|
||||
...(entity.subtype !== undefined && { subtype: entity.subtype })
|
||||
}),
|
||||
depth: nodeDepth.get(id)
|
||||
}
|
||||
})
|
||||
return { nodes, edges, truncated }
|
||||
}
|
||||
|
||||
// ============= SEARCH & DISCOVERY =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -681,6 +681,27 @@ export function isVersionedIndexProvider(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Feature-detect an optional {@link GraphAccelerationProvider} (the
|
||||
* native graph engine). Brainy probes the registered `'graphAcceleration'`
|
||||
* provider with this: present ⇒ `brain.graph.*` routes to native `traverse` /
|
||||
* cursor / analytics; absent ⇒ Brainy serves the same operations from its
|
||||
* pure-TS adjacency fallback. Checks the two anchor methods (`traverse` +
|
||||
* `graphCursorOpen`) — a partial implementation that lacks them is treated as
|
||||
* absent (fall back rather than crash).
|
||||
* @param candidate - The value registered under `'graphAcceleration'`, or undefined.
|
||||
* @returns Whether `candidate` is a usable {@link GraphAccelerationProvider}.
|
||||
*/
|
||||
export function isGraphAccelerationProvider(
|
||||
candidate: unknown
|
||||
): candidate is GraphAccelerationProvider {
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
return false
|
||||
}
|
||||
const c = candidate as Record<string, unknown>
|
||||
return typeof c.traverse === 'function' && typeof c.graphCursorOpen === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* The object returned by the `'vector'` provider factory — Brainy's vector
|
||||
* index contract. Implementations include Brainy's own JS HNSW index and any
|
||||
|
|
|
|||
|
|
@ -763,6 +763,88 @@ export interface RelatedParams {
|
|||
service?: string
|
||||
}
|
||||
|
||||
// ============= Graph views (brain.graph.*) =============
|
||||
|
||||
/**
|
||||
* A node in a {@link GraphView} — a lightweight reference (id + classification +
|
||||
* hop depth), NOT a full {@link Entity}. Graph reads are structure-first; resolve
|
||||
* a node to its full entity with `get(id)` when you need its `data` / metadata.
|
||||
*/
|
||||
export interface GraphNode {
|
||||
/** Entity id. */
|
||||
id: string
|
||||
/** Entity type (present when the view hydrates node classification). */
|
||||
type?: NounType
|
||||
/** Per-product subtype (present when hydrated). */
|
||||
subtype?: string
|
||||
/** Hop distance from the nearest seed (0 = a seed); present on subgraph results. */
|
||||
depth?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A hydrated view of a region of the graph — the discovered nodes plus the edges
|
||||
* among them, in id form (the public face of the provider's columnar int form).
|
||||
*/
|
||||
export interface GraphView<T = any> {
|
||||
/** The nodes in this region (seeds + everything reached). */
|
||||
nodes: GraphNode[]
|
||||
/** The edges among `nodes`, as full {@link Relation} objects. */
|
||||
edges: Relation<T>[]
|
||||
/** `true` when a `maxNodes` / `maxEdges` cap truncated the result. */
|
||||
truncated?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link GraphApi.subgraph}.
|
||||
*/
|
||||
export interface SubgraphOptions {
|
||||
/** Max hop distance from any seed (default `1`). */
|
||||
depth?: number
|
||||
/** Edge direction to follow (default `'both'`). */
|
||||
direction?: 'in' | 'out' | 'both'
|
||||
/** Restrict traversed edges to these relationship type(s). */
|
||||
type?: VerbType | VerbType[]
|
||||
/** Restrict traversed edges to these subtype(s). */
|
||||
subtype?: string | string[]
|
||||
/** Also traverse through `internal`-visibility edges/nodes (hidden by default). */
|
||||
includeInternal?: boolean
|
||||
/** Also traverse through `system`-visibility edges/nodes (hidden by default). */
|
||||
includeSystem?: boolean
|
||||
/** Cap on returned nodes (sets `GraphView.truncated`). */
|
||||
maxNodes?: number
|
||||
/** Cap on returned edges. */
|
||||
maxEdges?: number
|
||||
/**
|
||||
* Hydrate each node's `type` / `subtype` (one batch read). Default `true`;
|
||||
* set `false` to skip it when you only need graph structure (ids + edges).
|
||||
*/
|
||||
hydrateNodes?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The `brain.graph` namespace — graph-shaped reads over the knowledge graph.
|
||||
* Routes to a native {@link import('../plugin.js').GraphAccelerationProvider}
|
||||
* when one is registered, otherwise serves the same results from Brainy's
|
||||
* pure-TS adjacency (correct at small/medium scale).
|
||||
*
|
||||
* (Grows with the engine: `subgraph` ships first; `export` / `rank` / `path` /
|
||||
* `communities` follow.)
|
||||
*/
|
||||
export interface GraphApi<T = any> {
|
||||
/**
|
||||
* @description Extract the subgraph reachable from one or more seed entities —
|
||||
* the bounded multi-hop neighborhood, as nodes + edges. The one-call answer to
|
||||
* "show me everything around this node, N hops out".
|
||||
* @param seeds - A seed entity id, or an array of them.
|
||||
* @param options - Depth, direction, edge/visibility filters, and caps.
|
||||
* @returns The hydrated {@link GraphView}.
|
||||
* @example
|
||||
* const view = await brain.graph.subgraph(personId, { depth: 2 })
|
||||
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
|
||||
*/
|
||||
subgraph(seeds: string | string[], options?: SubgraphOptions): Promise<GraphView<T>>
|
||||
}
|
||||
|
||||
// ============= Batch Operations =============
|
||||
|
||||
/**
|
||||
|
|
|
|||
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