diff --git a/RELEASES.md b/RELEASES.md index 63293704..226d6580 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -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 > `@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. +> - **`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 diff --git a/src/brainy.ts b/src/brainy.ts index 87fa5dc5..a90e4094 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -108,6 +108,7 @@ import { GraphView, GraphNode, SubgraphOptions, + SubgraphSelector, GraphExportOptions, GraphRankOptions, GraphRankEntry, @@ -364,6 +365,13 @@ export class Brainy implements BrainyInterface { /** Cap for {@link Brainy.verbIntWarmCache} (~100k entries ≈ a few MB). */ 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 private originalConsole?: { log: typeof console.log @@ -3278,8 +3286,8 @@ export class Brainy implements BrainyInterface { */ get graph(): GraphApi { return { - subgraph: (seeds: string | string[], options?: SubgraphOptions) => - this.graphSubgraph(seeds, options), + subgraph: (selector: SubgraphSelector, options?: SubgraphOptions) => + this.graphSubgraph(selector, options), export: (options?: GraphExportOptions) => this.graphExport(options), rank: (options?: GraphRankOptions) => this.graphRank(options), communities: (options?: GraphCommunitiesOptions) => this.graphCommunities(options), @@ -3313,24 +3321,96 @@ export class Brainy implements BrainyInterface { /** * @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. + * else the TS BFS fallback. The selector is an id set, a `find()` result, or a + * query (query→expand fusion). Explicit ids are id-normalized so a caller may + * seed by natural key. */ private async graphSubgraph( - seeds: string | string[], + selector: SubgraphSelector, options?: SubgraphOptions ): Promise> { 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() + + // 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[]) + if (seedIds.length === 0) return { nodes: [], edges: [], truncated: false } return accel - ? this.graphSubgraphNative(accel, seedIds, depth, direction, options) + ? this.graphSubgraphNative(accel, this.seedIdsToInts(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, + depth: number, + direction: 'in' | 'out' | 'both', + options?: SubgraphOptions + ): Promise> { + // 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): 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[]): 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 * O(degree) `related()` adjacency (visibility / type / subtype filters applied @@ -3413,17 +3493,14 @@ export class Brainy implements BrainyInterface { */ private async graphSubgraphNative( accel: GraphAccelerationProvider, - seedIds: string[], + seeds: bigint[] | OpaqueIdSet, depth: number, direction: 'in' | 'out' | 'both', options?: SubgraphOptions ): Promise> { - 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 } + // Resolved int seeds may be empty (no mapped entities); an OpaqueIdSet (Buffer) + // is passed straight through — the provider owns its membership. + if (Array.isArray(seeds) && seeds.length === 0) return { nodes: [], edges: [], truncated: false } const verbTypes = options?.type ? (Array.isArray(options.type) ? options.type : [options.type]).map((t) => @@ -3445,7 +3522,7 @@ export class Brainy implements BrainyInterface { includeDepth: true } - const sub = await accel.traverse(seedInts, traverseOptions) + const sub = await accel.traverse(seeds, traverseOptions) return this.hydrateNativeSubgraph(sub, options?.hydrateNodes !== false) } @@ -4816,40 +4893,7 @@ export class Brainy implements BrainyInterface { let preResolvedAllowedIds: OpaqueIdSet | undefined if (params.where || params.type || params.subtype || params.service || params.excludeVFS) { - preResolvedFilter = {} - 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 - })) - } - } - } + preResolvedFilter = this.buildMetadataFilter(params) preResolvedMetadataIds = await this.metadataIndex.getIdsForFilter(preResolvedFilter) // Visibility hard filter — restrict the HNSW candidate set to non-hidden ids. @@ -11145,6 +11189,54 @@ export class Brainy implements BrainyInterface { 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 * diff --git a/src/types/brainy.types.ts b/src/types/brainy.types.ts index 4de62833..d3b8ec96 100644 --- a/src/types/brainy.types.ts +++ b/src/types/brainy.types.ts @@ -913,6 +913,17 @@ export interface GraphPathResult { 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 = string | string[] | Result[] | FindParams + /** * The `brain.graph` namespace — graph-shaped reads over the knowledge graph. * Routes to a native {@link import('../plugin.js').GraphAccelerationProvider} @@ -921,17 +932,23 @@ export interface GraphPathResult { */ export interface GraphApi { /** - * @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. + * @description Extract the subgraph reachable from a seed selector — the bounded + * multi-hop neighborhood, as nodes + edges. The one-call answer to "show me + * everything around this, N hops out". The seed can be entity id(s), a `find()` + * 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. * @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 + * @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> + subgraph(selector: SubgraphSelector, options?: SubgraphOptions): Promise> /** * @description Stream the WHOLE graph in one O(N+E) pass as a sequence of diff --git a/tests/unit/brainy/graph-native-routing.test.ts b/tests/unit/brainy/graph-native-routing.test.ts index 767e87fe..5e0745c7 100644 --- a/tests/unit/brainy/graph-native-routing.test.ts +++ b/tests/unit/brainy/graph-native-routing.test.ts @@ -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') + }) }) diff --git a/tests/unit/brainy/graph-subgraph.test.ts b/tests/unit/brainy/graph-subgraph.test.ts index 4eb8049a..c2a4620b 100644 --- a/tests/unit/brainy/graph-subgraph.test.ts +++ b/tests/unit/brainy/graph-subgraph.test.ts @@ -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([]) + }) })