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
|
|
@ -73,6 +73,13 @@ no separate migration doc. **`8.0.0-rc.2` is live** — install with
|
||||||
> matches that DO pass the filter, rather than coming back empty. No API change. With the native
|
> matches that DO pass the filter, rather than coming back empty. No API change. With the native
|
||||||
> `@soulcraft/cor` 3.0 stack the matched universe is forwarded as an opaque roaring buffer with
|
> `@soulcraft/cor` 3.0 stack the matched universe is forwarded as an opaque roaring buffer with
|
||||||
> zero id materialization in TypeScript; the pure-JS path restricts the beam walk with a string set.
|
> zero id materialization in TypeScript; the pure-JS path restricts the beam walk with a string set.
|
||||||
|
> - **`brain.graph.subgraph()` accepts a query (query→expand).** The seed selector now takes 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. With the native engine a metadata-only query's matched universe is handed to
|
||||||
|
> the traversal as an opaque set with no id materialization in TypeScript (the query→expand
|
||||||
|
> fusion); the pure-JS path materializes the matched ids and expands from them. Existing
|
||||||
|
> id-seeded calls are unchanged.
|
||||||
|
|
||||||
### Headline: Database as a Value
|
### Headline: Database as a Value
|
||||||
|
|
||||||
|
|
|
||||||
192
src/brainy.ts
192
src/brainy.ts
|
|
@ -108,6 +108,7 @@ import {
|
||||||
GraphView,
|
GraphView,
|
||||||
GraphNode,
|
GraphNode,
|
||||||
SubgraphOptions,
|
SubgraphOptions,
|
||||||
|
SubgraphSelector,
|
||||||
GraphExportOptions,
|
GraphExportOptions,
|
||||||
GraphRankOptions,
|
GraphRankOptions,
|
||||||
GraphRankEntry,
|
GraphRankEntry,
|
||||||
|
|
@ -364,6 +365,13 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
/** Cap for {@link Brainy.verbIntWarmCache} (~100k entries ≈ a few MB). */
|
/** Cap for {@link Brainy.verbIntWarmCache} (~100k entries ≈ a few MB). */
|
||||||
private static readonly VERB_INT_WARM_CACHE_MAX = 100_000
|
private static readonly VERB_INT_WARM_CACHE_MAX = 100_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default cap on how many `find()` matches seed a `graph.subgraph(query)` when the
|
||||||
|
* caller pins no explicit `limit` — bounds the materialized seed set on the JS /
|
||||||
|
* non-opaque path (the native opaque path forwards the whole universe, uncapped).
|
||||||
|
*/
|
||||||
|
private static readonly QUERY_SEED_CAP = 10_000
|
||||||
|
|
||||||
// Silent mode state
|
// Silent mode state
|
||||||
private originalConsole?: {
|
private originalConsole?: {
|
||||||
log: typeof console.log
|
log: typeof console.log
|
||||||
|
|
@ -3278,8 +3286,8 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
*/
|
*/
|
||||||
get graph(): GraphApi<T> {
|
get graph(): GraphApi<T> {
|
||||||
return {
|
return {
|
||||||
subgraph: (seeds: string | string[], options?: SubgraphOptions) =>
|
subgraph: (selector: SubgraphSelector<T>, options?: SubgraphOptions) =>
|
||||||
this.graphSubgraph(seeds, options),
|
this.graphSubgraph(selector, options),
|
||||||
export: (options?: GraphExportOptions) => this.graphExport(options),
|
export: (options?: GraphExportOptions) => this.graphExport(options),
|
||||||
rank: (options?: GraphRankOptions) => this.graphRank(options),
|
rank: (options?: GraphRankOptions) => this.graphRank(options),
|
||||||
communities: (options?: GraphCommunitiesOptions) => this.graphCommunities(options),
|
communities: (options?: GraphCommunitiesOptions) => this.graphCommunities(options),
|
||||||
|
|
@ -3313,24 +3321,96 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description {@link GraphApi.subgraph} dispatch: native engine when present,
|
* @description {@link GraphApi.subgraph} dispatch: native engine when present,
|
||||||
* else the TS BFS fallback. Seeds are id-normalized so a caller may seed by
|
* else the TS BFS fallback. The selector is an id set, a `find()` result, or a
|
||||||
* natural key.
|
* query (query→expand fusion). Explicit ids are id-normalized so a caller may
|
||||||
|
* seed by natural key.
|
||||||
*/
|
*/
|
||||||
private async graphSubgraph(
|
private async graphSubgraph(
|
||||||
seeds: string | string[],
|
selector: SubgraphSelector<T>,
|
||||||
options?: SubgraphOptions
|
options?: SubgraphOptions
|
||||||
): Promise<GraphView<T>> {
|
): Promise<GraphView<T>> {
|
||||||
await this.ensureInitialized()
|
await this.ensureInitialized()
|
||||||
const seedIds = (Array.isArray(seeds) ? seeds : [seeds]).map((s) => resolveEntityId(s))
|
|
||||||
const depth = Math.max(0, options?.depth ?? 1)
|
const depth = Math.max(0, options?.depth ?? 1)
|
||||||
const direction = options?.direction ?? 'both'
|
const direction = options?.direction ?? 'both'
|
||||||
|
|
||||||
const accel = this.graphAccelerationProvider()
|
const accel = this.graphAccelerationProvider()
|
||||||
|
|
||||||
|
// Query selector (a FindParams object — not a string / array) → query→expand.
|
||||||
|
if (selector && typeof selector === 'object' && !Array.isArray(selector)) {
|
||||||
|
return this.graphSubgraphFromQuery(accel, selector, depth, direction, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Id set: a string, an array of ids, or a `find()` result (Result[]). The
|
||||||
|
// FindParams (query) case returned above, so the remaining union is the id forms.
|
||||||
|
const seedIds = this.selectorToSeedIds(selector as string | string[] | Result<T>[])
|
||||||
|
if (seedIds.length === 0) return { nodes: [], edges: [], truncated: false }
|
||||||
return accel
|
return accel
|
||||||
? this.graphSubgraphNative(accel, seedIds, depth, direction, options)
|
? this.graphSubgraphNative(accel, this.seedIdsToInts(seedIds), depth, direction, options)
|
||||||
: this.graphSubgraphFallback(seedIds, depth, direction, options)
|
: this.graphSubgraphFallback(seedIds, depth, direction, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Query→expand fusion (graph #61): run the query, then expand the
|
||||||
|
* subgraph from every match. When the native engine is present AND the query is a
|
||||||
|
* pure metadata filter (no vector/text/proximity criteria) AND the metadata
|
||||||
|
* provider exposes the opaque-set producer, the matched universe is forwarded to
|
||||||
|
* `traverse` as an {@link OpaqueIdSet} with NO id materialization in TS — the
|
||||||
|
* O(1)-crossing path. Otherwise the query is materialized via `find()` and its
|
||||||
|
* result ids seed the traversal (native or TS fallback).
|
||||||
|
*/
|
||||||
|
private async graphSubgraphFromQuery(
|
||||||
|
accel: GraphAccelerationProvider | undefined,
|
||||||
|
selector: FindParams<T>,
|
||||||
|
depth: number,
|
||||||
|
direction: 'in' | 'out' | 'both',
|
||||||
|
options?: SubgraphOptions
|
||||||
|
): Promise<GraphView<T>> {
|
||||||
|
// Native opaque fast path: a metadata-only universe never leaves the engine.
|
||||||
|
if (accel && !this.hasVectorOrTextCriteria(selector)) {
|
||||||
|
const filter = this.buildMetadataFilter(selector)
|
||||||
|
const mip = this.metadataIndex as unknown as MetadataIndexProvider
|
||||||
|
if (filter && typeof mip.getIdSetForFilter === 'function') {
|
||||||
|
const universe = await mip.getIdSetForFilter(filter)
|
||||||
|
return this.graphSubgraphNative(accel, universe, depth, direction, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// General path: materialize the matched ids, then expand from them. The find
|
||||||
|
// default limit (10) is too small to seed a neighborhood, so seed from ALL
|
||||||
|
// matches up to a bounded cap unless the caller pinned an explicit limit.
|
||||||
|
const matches = await this.find({
|
||||||
|
...selector,
|
||||||
|
limit: selector.limit ?? Brainy.QUERY_SEED_CAP
|
||||||
|
})
|
||||||
|
const seedIds = matches.map((m) => m.id)
|
||||||
|
if (seedIds.length === 0) return { nodes: [], edges: [], truncated: false }
|
||||||
|
return accel
|
||||||
|
? this.graphSubgraphNative(accel, this.seedIdsToInts(seedIds), depth, direction, options)
|
||||||
|
: this.graphSubgraphFallback(seedIds, depth, direction, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a query needs vector/text/proximity search (not a pure metadata filter). */
|
||||||
|
private hasVectorOrTextCriteria(params: FindParams<T>): boolean {
|
||||||
|
return Boolean(
|
||||||
|
(params.query && params.query.trim() !== '') || params.vector || params.near
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize an id-set selector (id / id[] / `find()` result) to canonical seed ids. */
|
||||||
|
private selectorToSeedIds(selector: string | string[] | Result<T>[]): string[] {
|
||||||
|
const arr = Array.isArray(selector) ? selector : [selector]
|
||||||
|
return arr.map((s) => resolveEntityId(typeof s === 'string' ? s : s.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve seed ids to graph entity ints, dropping any that were never mapped. */
|
||||||
|
private seedIdsToInts(seedIds: string[]): bigint[] {
|
||||||
|
const ints: bigint[] = []
|
||||||
|
for (const id of seedIds) {
|
||||||
|
const int = this.graphEntityInt(id)
|
||||||
|
if (int !== undefined) ints.push(int)
|
||||||
|
}
|
||||||
|
return ints
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Pure-TS subgraph BFS — expands each frontier through the
|
* @description Pure-TS subgraph BFS — expands each frontier through the
|
||||||
* O(degree) `related()` adjacency (visibility / type / subtype filters applied
|
* O(degree) `related()` adjacency (visibility / type / subtype filters applied
|
||||||
|
|
@ -3413,17 +3493,14 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
*/
|
*/
|
||||||
private async graphSubgraphNative(
|
private async graphSubgraphNative(
|
||||||
accel: GraphAccelerationProvider,
|
accel: GraphAccelerationProvider,
|
||||||
seedIds: string[],
|
seeds: bigint[] | OpaqueIdSet,
|
||||||
depth: number,
|
depth: number,
|
||||||
direction: 'in' | 'out' | 'both',
|
direction: 'in' | 'out' | 'both',
|
||||||
options?: SubgraphOptions
|
options?: SubgraphOptions
|
||||||
): Promise<GraphView<T>> {
|
): Promise<GraphView<T>> {
|
||||||
const seedInts: bigint[] = []
|
// Resolved int seeds may be empty (no mapped entities); an OpaqueIdSet (Buffer)
|
||||||
for (const id of seedIds) {
|
// is passed straight through — the provider owns its membership.
|
||||||
const int = this.graphEntityInt(id)
|
if (Array.isArray(seeds) && seeds.length === 0) return { nodes: [], edges: [], truncated: false }
|
||||||
if (int !== undefined) seedInts.push(int)
|
|
||||||
}
|
|
||||||
if (seedInts.length === 0) return { nodes: [], edges: [], truncated: false }
|
|
||||||
|
|
||||||
const verbTypes = options?.type
|
const verbTypes = options?.type
|
||||||
? (Array.isArray(options.type) ? options.type : [options.type]).map((t) =>
|
? (Array.isArray(options.type) ? options.type : [options.type]).map((t) =>
|
||||||
|
|
@ -3445,7 +3522,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
includeDepth: true
|
includeDepth: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const sub = await accel.traverse(seedInts, traverseOptions)
|
const sub = await accel.traverse(seeds, traverseOptions)
|
||||||
return this.hydrateNativeSubgraph(sub, options?.hydrateNodes !== false)
|
return this.hydrateNativeSubgraph(sub, options?.hydrateNodes !== false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4816,40 +4893,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
let preResolvedAllowedIds: OpaqueIdSet | undefined
|
let preResolvedAllowedIds: OpaqueIdSet | undefined
|
||||||
|
|
||||||
if (params.where || params.type || params.subtype || params.service || params.excludeVFS) {
|
if (params.where || params.type || params.subtype || params.service || params.excludeVFS) {
|
||||||
preResolvedFilter = {}
|
preResolvedFilter = this.buildMetadataFilter(params)
|
||||||
if (params.where) {
|
|
||||||
Object.assign(preResolvedFilter, params.where)
|
|
||||||
// Alias: where.type → where.noun (storage field name for entity type)
|
|
||||||
if ('type' in preResolvedFilter && !('noun' in preResolvedFilter)) {
|
|
||||||
preResolvedFilter.noun = preResolvedFilter.type
|
|
||||||
delete preResolvedFilter.type
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (params.service) preResolvedFilter.service = params.service
|
|
||||||
if (params.excludeVFS === true) {
|
|
||||||
preResolvedFilter.vfsType = { exists: false }
|
|
||||||
preResolvedFilter.isVFSEntity = { ne: true }
|
|
||||||
}
|
|
||||||
// Subtype (top-level standard field — fast path).
|
|
||||||
// Must be assigned BEFORE the type-array expansion below.
|
|
||||||
if (params.subtype !== undefined) {
|
|
||||||
preResolvedFilter.subtype = Array.isArray(params.subtype)
|
|
||||||
? { oneOf: params.subtype }
|
|
||||||
: params.subtype
|
|
||||||
}
|
|
||||||
if (params.type) {
|
|
||||||
const types = Array.isArray(params.type) ? params.type : [params.type]
|
|
||||||
if (types.length === 1) {
|
|
||||||
preResolvedFilter.noun = types[0]
|
|
||||||
} else {
|
|
||||||
preResolvedFilter = {
|
|
||||||
anyOf: types.map(type => ({
|
|
||||||
noun: type,
|
|
||||||
...preResolvedFilter
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
preResolvedMetadataIds = await this.metadataIndex.getIdsForFilter(preResolvedFilter)
|
preResolvedMetadataIds = await this.metadataIndex.getIdsForFilter(preResolvedFilter)
|
||||||
|
|
||||||
// Visibility hard filter — restrict the HNSW candidate set to non-hidden ids.
|
// Visibility hard filter — restrict the HNSW candidate set to non-hidden ids.
|
||||||
|
|
@ -11145,6 +11189,54 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Build the MetadataIndex filter object from a query's structured
|
||||||
|
* criteria (`where` / `type` / `subtype` / `service` / `excludeVFS`) — the shared
|
||||||
|
* filter shape consumed by `getIdsForFilter` / `getIdSetForFilter`. Applies the
|
||||||
|
* `where.type → noun` alias and expands a multi-type filter into an `anyOf`.
|
||||||
|
* @param params - The structured query criteria.
|
||||||
|
* @returns The filter object, or `null` when no structured criteria are present.
|
||||||
|
*/
|
||||||
|
private buildMetadataFilter(params: {
|
||||||
|
where?: any
|
||||||
|
type?: NounType | NounType[]
|
||||||
|
subtype?: string | string[]
|
||||||
|
service?: string
|
||||||
|
excludeVFS?: boolean
|
||||||
|
}): any | null {
|
||||||
|
if (!(params.where || params.type || params.subtype || params.service || params.excludeVFS)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let filter: any = {}
|
||||||
|
if (params.where) {
|
||||||
|
Object.assign(filter, params.where)
|
||||||
|
// Alias: where.type → where.noun (storage field name for entity type)
|
||||||
|
if ('type' in filter && !('noun' in filter)) {
|
||||||
|
filter.noun = filter.type
|
||||||
|
delete filter.type
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (params.service) filter.service = params.service
|
||||||
|
if (params.excludeVFS === true) {
|
||||||
|
filter.vfsType = { exists: false }
|
||||||
|
filter.isVFSEntity = { ne: true }
|
||||||
|
}
|
||||||
|
// Subtype (top-level standard field — fast path). Assigned BEFORE the type-array
|
||||||
|
// expansion below so the spread into each anyOf branch carries it through.
|
||||||
|
if (params.subtype !== undefined) {
|
||||||
|
filter.subtype = Array.isArray(params.subtype) ? { oneOf: params.subtype } : params.subtype
|
||||||
|
}
|
||||||
|
if (params.type) {
|
||||||
|
const types = Array.isArray(params.type) ? params.type : [params.type]
|
||||||
|
if (types.length === 1) {
|
||||||
|
filter.noun = types[0]
|
||||||
|
} else {
|
||||||
|
filter = { anyOf: types.map((type) => ({ noun: type, ...filter })) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute vector search component
|
* Execute vector search component
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -913,6 +913,17 @@ export interface GraphPathResult {
|
||||||
cost: number
|
cost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What {@link GraphApi.subgraph} expands from — "the things to grow a neighborhood
|
||||||
|
* around". One of:
|
||||||
|
* - an entity id, or an array of them (the explicit id set),
|
||||||
|
* - a {@link Result} array (a `find()` result — its entities become the seeds), or
|
||||||
|
* - a {@link FindParams} query (the query→expand fusion: run the query, expand from
|
||||||
|
* every match). With the native engine a metadata-only query's matched universe is
|
||||||
|
* forwarded to the traversal as an opaque set with no id materialization.
|
||||||
|
*/
|
||||||
|
export type SubgraphSelector<T = any> = string | string[] | Result<T>[] | FindParams<T>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `brain.graph` namespace — graph-shaped reads over the knowledge graph.
|
* The `brain.graph` namespace — graph-shaped reads over the knowledge graph.
|
||||||
* Routes to a native {@link import('../plugin.js').GraphAccelerationProvider}
|
* Routes to a native {@link import('../plugin.js').GraphAccelerationProvider}
|
||||||
|
|
@ -921,17 +932,23 @@ export interface GraphPathResult {
|
||||||
*/
|
*/
|
||||||
export interface GraphApi<T = any> {
|
export interface GraphApi<T = any> {
|
||||||
/**
|
/**
|
||||||
* @description Extract the subgraph reachable from one or more seed entities —
|
* @description Extract the subgraph reachable from a seed selector — the bounded
|
||||||
* the bounded multi-hop neighborhood, as nodes + edges. The one-call answer to
|
* multi-hop neighborhood, as nodes + edges. The one-call answer to "show me
|
||||||
* "show me everything around this node, N hops out".
|
* everything around this, N hops out". The seed can be entity id(s), a `find()`
|
||||||
* @param seeds - A seed entity id, or an array of them.
|
* result, or a {@link FindParams} query (query→expand: run the query, then expand
|
||||||
|
* from every match — with the native engine the matched universe never leaves
|
||||||
|
* the engine's representation).
|
||||||
|
* @param selector - Seed id(s), a `find()` result, or a query. See {@link SubgraphSelector}.
|
||||||
* @param options - Depth, direction, edge/visibility filters, and caps.
|
* @param options - Depth, direction, edge/visibility filters, and caps.
|
||||||
* @returns The hydrated {@link GraphView}.
|
* @returns The hydrated {@link GraphView}.
|
||||||
* @example
|
* @example
|
||||||
* const view = await brain.graph.subgraph(personId, { depth: 2 })
|
* const view = await brain.graph.subgraph(personId, { depth: 2 })
|
||||||
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
|
* // view.nodes = the 2-hop neighborhood; view.edges = the relations among them
|
||||||
|
* @example
|
||||||
|
* // Query→expand: the neighborhood of everything matching a filter.
|
||||||
|
* const cluster = await brain.graph.subgraph({ where: { team: 'platform' } }, { depth: 1 })
|
||||||
*/
|
*/
|
||||||
subgraph(seeds: string | string[], options?: SubgraphOptions): Promise<GraphView<T>>
|
subgraph(selector: SubgraphSelector<T>, options?: SubgraphOptions): Promise<GraphView<T>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Stream the WHOLE graph in one O(N+E) pass as a sequence of
|
* @description Stream the WHOLE graph in one O(N+E) pass as a sequence of
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,15 @@ function makeMockAccel() {
|
||||||
rankResult: any
|
rankResult: any
|
||||||
communitiesResult: any
|
communitiesResult: any
|
||||||
pathResult: any
|
pathResult: any
|
||||||
|
lastTraverseSeeds: any
|
||||||
} = {
|
} = {
|
||||||
calls,
|
calls,
|
||||||
traverseResult: null,
|
traverseResult: null,
|
||||||
cursorChunks: [],
|
cursorChunks: [],
|
||||||
rankResult: null,
|
rankResult: null,
|
||||||
communitiesResult: null,
|
communitiesResult: null,
|
||||||
pathResult: null
|
pathResult: null,
|
||||||
|
lastTraverseSeeds: undefined
|
||||||
}
|
}
|
||||||
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
|
const empty = { nodeInts: new BigInt64Array(0), scores: new Float64Array(0) }
|
||||||
const emptyCommunities = {
|
const emptyCommunities = {
|
||||||
|
|
@ -43,8 +45,9 @@ function makeMockAccel() {
|
||||||
}
|
}
|
||||||
const provider = {
|
const provider = {
|
||||||
isInitialized: true,
|
isInitialized: true,
|
||||||
traverse: async () => {
|
traverse: async (seeds: any) => {
|
||||||
calls.traverse++
|
calls.traverse++
|
||||||
|
state.lastTraverseSeeds = seeds
|
||||||
return state.traverseResult
|
return state.traverseResult
|
||||||
},
|
},
|
||||||
edgesForNode: async () => 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(await brain.graph.path(a, c)).toBeNull()
|
||||||
expect(mock.state.calls.path).toBe(1)
|
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.
|
// depth 0 = just the seeds, no expansion.
|
||||||
expect(new Set(view.nodes.map((n) => n.id))).toEqual(new Set([a, c]))
|
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