fix(8.0): implement multi-hop subtype BFS in pure JS (open-core works standalone)
Previous (post-step-12) state shipped a regression: removing the
`depth > 1 + subtype` throw left the open-core JS path silently
returning wrong results — the verbType walk reached depth N, but the
subtype intersection only filtered at depth 1. Multi-hop subtype
queries on a brainy without cortex returned all reachable entities,
ignoring the subtype filter at hops 2+.
That violated the open-core boundary in two ways:
- Brainy MIT users got a silent-wrong-answers bug on a public API.
- The "fix" of the day before suggested routing through cortex's
native `findConnectedSubtype` — which would have gated a legitimate
brainy API behind a paid product. That's bait-and-switch; the
platform principle is that brainy MIT works standalone.
THE FIX
Implement multi-hop subtype-aware BFS in pure JS. Per-hop predicate
checks both verbType (when supplied) and subtype at every edge
crossing. Cycle guard via `visited`. Stops at `depth` or when the
frontier empties.
Complexity scales with branching factor × depth, not total graph size.
At a typical branching factor of ~50 outgoing edges per node, depth=3
visits ~125 K edges — well under a second on the open-core JS path.
That's fast enough for any reasonable production workload.
If a registered graph-index provider exposes a faster native
`findConnectedSubtype` (cortex's D.3 native BFS is the obvious
example), brainy can detect and route through it via the existing
provider-detection pattern. That's an OPTIMIZATION, not a correctness
requirement. The JS path is the contract; native is a drop-in
replacement.
CHANGES
src/brainy.ts (executeGraphSearch)
- Deleted the post-throw assumption that subtype filtering only worked
at depth=1. Replaced with a real BFS.
- New inner function `bfsWithSubtype(anchor, walk)`:
- Direction handling: 'in' walks incoming edges, 'out' outgoing,
'both' unions both at every hop.
- Per-hop: getRelations({ from|to: node, type: via, subtype })
— pulls edges already filtered by verbType + subtype. Defensive
re-check on edge.subtype in case a future getRelations impl
widens its filter.
- Cycle guard: visited set seeded with the anchor. Never re-enters
the anchor; never revisits a node.
- Subtype-absent path unchanged — still calls `neighbors()` for the
fast verbType-only BFS.
NO-OP for the subtype-absent case. Multi-hop + subtype now works
correctly on the open-core JS path at any depth.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1408 / 1409 (same pre-existing race-condition outstanding;
no other regressions)
This commit is contained in:
parent
ed75f250ec
commit
221fc45889
1 changed files with 64 additions and 43 deletions
101
src/brainy.ts
101
src/brainy.ts
|
|
@ -8406,30 +8406,74 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
const { from, to, depth, direction = 'both' } = params.connected
|
const { from, to, depth, direction = 'both' } = params.connected
|
||||||
const via = params.connected.via ?? params.connected.type
|
const via = params.connected.via ?? params.connected.type
|
||||||
const subtypeFilter = params.connected.subtype
|
const subtypeFilter = params.connected.subtype
|
||||||
|
const effectiveDepth = depth ?? 1
|
||||||
// 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.
|
|
||||||
|
|
||||||
// GraphConstraints speaks 'in' | 'out' | 'both'; neighbors() speaks 'incoming' | 'outgoing' | 'both'.
|
// GraphConstraints speaks 'in' | 'out' | 'both'; neighbors() speaks 'incoming' | 'outgoing' | 'both'.
|
||||||
const toNeighborDir = (d: 'in' | 'out' | 'both'): 'incoming' | 'outgoing' | 'both' =>
|
const toNeighborDir = (d: 'in' | 'out' | 'both'): 'incoming' | 'outgoing' | 'both' =>
|
||||||
d === 'in' ? 'incoming' : d === 'out' ? '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<string[]> =>
|
|
||||||
this.neighbors(id, { direction: dir, depth: depth ?? 1, verbType: via })
|
|
||||||
|
|
||||||
const connectedIds = new Set<string>()
|
const connectedIds = new Set<string>()
|
||||||
|
|
||||||
|
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 subtypeSet = new Set(subtypeArr)
|
||||||
|
|
||||||
|
const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise<Set<string>> => {
|
||||||
|
const visited = new Set<string>([anchor])
|
||||||
|
const reached = new Set<string>()
|
||||||
|
let frontier = new Set<string>([anchor])
|
||||||
|
|
||||||
|
for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) {
|
||||||
|
const nextFrontier = new Set<string>()
|
||||||
|
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) {
|
||||||
|
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<string[]> =>
|
||||||
|
this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via })
|
||||||
|
|
||||||
if (from) {
|
if (from) {
|
||||||
for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id)
|
for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id)
|
||||||
}
|
}
|
||||||
|
|
@ -8438,29 +8482,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
||||||
for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id)
|
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) {
|
|
||||||
const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter]
|
|
||||||
const validIds = new Set<string>()
|
|
||||||
const matchAnchor = async (anchor: string, reverseLookup: boolean): Promise<void> => {
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (from) await matchAnchor(from, false)
|
|
||||||
if (to) await matchAnchor(to, true)
|
|
||||||
for (const id of [...connectedIds]) {
|
|
||||||
if (!validIds.has(id)) connectedIds.delete(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter existing results to only connected entities
|
// Filter existing results to only connected entities
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue