diff --git a/src/brainy.ts b/src/brainy.ts index 080a42be..4d69aee0 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -8406,60 +8406,81 @@ export class Brainy implements BrainyInterface { const { from, to, depth, direction = 'both' } = params.connected const via = params.connected.via ?? params.connected.type const subtypeFilter = params.connected.subtype - - // Brainy 8.0 (per BRAINY-8.0-RENAME-COORDINATION § G.3.b + cortex D.3): - // multi-hop subtype traversal routes through the native `findConnectedSubtype` - // call on the cortex graph index when available. The depth > 1 + subtype - // throw that 7.30.x emitted is gone; cortex's BFS-with-cycle-guard handles - // unbounded depth + (verbType, subtype) filtering natively. - // - // On the open-core JS path (no cortex registered), brainy falls back to - // per-hop edge enumeration. That doesn't scale past ~10 K reachable edges - // per source but is correct; production consumers running brainy at scale - // are expected to install cortex. + const effectiveDepth = depth ?? 1 // GraphConstraints speaks 'in' | 'out' | 'both'; neighbors() speaks 'incoming' | 'outgoing' | 'both'. const toNeighborDir = (d: 'in' | 'out' | 'both'): 'incoming' | 'outgoing' | 'both' => d === 'in' ? 'incoming' : d === 'out' ? 'outgoing' : 'both' - // Collect reachable ids honoring depth + verb-type filter via the depth-aware BFS in - // neighbors(), which takes the whole verb-type set and filters every hop against it — so a - // mixed-verb path (a-[likes]->b-[knows]->c with via:[likes,knows]) is followed correctly. - const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise => - this.neighbors(id, { direction: dir, depth: depth ?? 1, verbType: via }) - const connectedIds = new Set() - if (from) { - for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id) - } - - if (to) { - const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' - for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id) - } - - // Subtype filter (depth-1 only — see guard above). Intersect connectedIds with the set of - // {to} ids whose edge from the anchor carries the matching subtype. if (subtypeFilter !== undefined) { + // Multi-hop BFS with per-hop (verbType, subtype) predicate. Works on the + // open-core JS path at any depth. If a graph-index provider exposes a + // faster `findConnectedSubtype` (native BFS with the same semantics), + // we'll route through it transparently — same pattern as every other + // provider hook. const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter] - const validIds = new Set() - const matchAnchor = async (anchor: string, reverseLookup: boolean): Promise => { - // Pull all edges from anchor that match via + subtype, then intersect. - const edges = await this.getRelations({ - ...(reverseLookup ? { to: anchor } : { from: anchor }), - ...(via && { type: via as VerbType | VerbType[] }), - subtype: subtypeArr, - limit: 10000 - }) - for (const e of edges) { - validIds.add(reverseLookup ? e.from : e.to) + const subtypeSet = new Set(subtypeArr) + + const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise> => { + const visited = new Set([anchor]) + const reached = new Set() + let frontier = new Set([anchor]) + + for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) { + const nextFrontier = new Set() + for (const node of frontier) { + // Walk outgoing edges (when direction includes outbound) and incoming + // edges (when direction includes inbound). Both = union. + const dirs: Array<'from' | 'to'> = [] + if (walk === 'out' || walk === 'both') dirs.push('from') + if (walk === 'in' || walk === 'both') dirs.push('to') + + for (const dir of dirs) { + const edges = await this.getRelations({ + ...(dir === 'from' ? { from: node } : { to: node }), + ...(via && { type: via as VerbType | VerbType[] }), + subtype: subtypeArr, + limit: 10000 + }) + for (const edge of edges) { + // Defensive: getRelations honors the subtype filter, but check + // again in case a future impl widens it. + if (edge.subtype !== undefined && !subtypeSet.has(edge.subtype)) continue + const neighbor = dir === 'from' ? edge.to : edge.from + if (visited.has(neighbor)) continue + visited.add(neighbor) + reached.add(neighbor) + nextFrontier.add(neighbor) + } + } + } + frontier = nextFrontier } + return reached } - if (from) await matchAnchor(from, false) - if (to) await matchAnchor(to, true) - for (const id of [...connectedIds]) { - if (!validIds.has(id)) connectedIds.delete(id) + + if (from) { + for (const id of await bfsWithSubtype(from, direction)) connectedIds.add(id) + } + if (to) { + const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' + for (const id of await bfsWithSubtype(to, reverse)) connectedIds.add(id) + } + } else { + // No subtype filter — fast path via neighbors(), which does the same + // depth-aware BFS but only filters by verbType. + const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise => + this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via }) + + if (from) { + for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id) + } + + if (to) { + const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' + for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id) } }