fix(graph): the verb fast paths honour every requested type, source, and target
Some checks are pending
CI / Node 22 (push) Waiting to run
CI / Node 24 (push) Waiting to run
CI / Integration + conformance (Node 22) (push) Waiting to run
CI / Bun (latest) (push) Waiting to run

related() with a verb-type ARRAY returned edges for only the first type —
the storage fast paths collapsed `verbType` (and, in their sibling blocks,
`sourceId` and `targetId`) arrays to their first element, silently
dropping the rest of the ask. Every consumer passing a verb list
under-traversed with no error and no narration: the same quiet-loss class
as the graph-first paging defect, one seam over.

All four fast paths now union over the full requested set, deduped by
edge id, before the metadata filters and pagination run. Pinned in
tests/integration/related-verb-array.test.ts: the second requested type's
edge returns in both array orders, on the anchor side, the target side,
and the type-only path; a one-element array equals the scalar; no
duplicates on overlap; pagination walks the union consistently.
This commit is contained in:
David Snelling 2026-09-01 12:20:08 -07:00
parent 5e3b343a0e
commit 67862266c8
2 changed files with 123 additions and 19 deletions

View file

@ -2942,19 +2942,33 @@ export abstract class BaseStorage extends BaseStorageAdapter {
!options.filter.service && !options.filter.service &&
!options.filter.metadata !options.filter.metadata
) { ) {
const sourceId = Array.isArray(options.filter.sourceId) const sourceIds = Array.isArray(options.filter.sourceId)
? options.filter.sourceId[0] ? options.filter.sourceId
: options.filter.sourceId : [options.filter.sourceId]
const verbType = Array.isArray(options.filter.verbType) // EVERY requested verb type is honoured — an array used to collapse to
? options.filter.verbType[0] // its first element here, silently dropping the rest of the ask.
: options.filter.verbType const verbTypes = new Set(
Array.isArray(options.filter.verbType)
? options.filter.verbType
: [options.filter.verbType]
)
// Get verbs by source, then filter by type (O(1) graph lookup + O(n) type filter), // Get verbs by source (union over every requested source), filter by the
// then apply the subtype / visibility metadata filters on the candidate set. // requested type SET (O(1) graph lookup + O(n) type filter), then apply
const verbsBySource = await this.getVerbsBySource_internal(sourceId) // the subtype / visibility metadata filters on the candidate set.
const bySource: HNSWVerbWithMetadata[] = []
const seenVerbIds = new Set<string>()
for (const oneSource of sourceIds) {
for (const v of await this.getVerbsBySource_internal(oneSource)) {
if (!seenVerbIds.has(v.id)) {
seenVerbIds.add(v.id)
bySource.push(v)
}
}
}
const filteredVerbs = this.applyVerbMetadataFilters( const filteredVerbs = this.applyVerbMetadataFilters(
verbsBySource.filter(v => v.verb === verbType), bySource.filter(v => verbTypes.has(v.verb)),
options.filter options.filter
) )
@ -3061,16 +3075,25 @@ export abstract class BaseStorage extends BaseStorageAdapter {
!options.filter.service && !options.filter.service &&
!options.filter.metadata !options.filter.metadata
) { ) {
const verbType = Array.isArray(options.filter.verbType) // EVERY requested verb type is honoured — an array used to collapse to
? options.filter.verbType[0] // its first element here, silently dropping the rest of the ask.
: options.filter.verbType const verbTypes = Array.isArray(options.filter.verbType)
? options.filter.verbType
: [options.filter.verbType]
// Get verbs by type directly (hydrated with metadata), then apply the // Get verbs by each requested type (hydrated with metadata), deduped by
// subtype / visibility metadata filters on the candidate set. // id, then apply the subtype / visibility metadata filters on the set.
const verbsByType = this.applyVerbMetadataFilters( const byType: HNSWVerbWithMetadata[] = []
await this.getVerbsByType_internal(verbType), const seenTypeVerbIds = new Set<string>()
options.filter for (const oneType of verbTypes) {
) for (const v of await this.getVerbsByType_internal(oneType)) {
if (!seenTypeVerbIds.has(v.id)) {
seenTypeVerbIds.add(v.id)
byType.push(v)
}
}
}
const verbsByType = this.applyVerbMetadataFilters(byType, options.filter)
// Apply pagination // Apply pagination
const paginatedVerbs = verbsByType.slice(offset, offset + limit) const paginatedVerbs = verbsByType.slice(offset, offset + limit)

View file

@ -0,0 +1,81 @@
/**
* @module tests/integration/related-verb-array
* @description related() honours EVERY verb type in an array (10.4.9).
*
* The storage fast paths for `sourceId + verbType` and `verbType` collapsed a
* verb-type ARRAY to its first element `related({ from, type: [a, b] })`
* silently returned only `a` edges, whichever order the array came in. The
* same quiet-loss class as the graph-first paging defect, one seam over.
* These pins seed a store where the SECOND requested type's edge must come
* back, on every path the collapse lived in.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
import { Brainy } from '../../src/brainy'
import { NounType, VerbType } from '../../src/types/graphTypes'
import { v5 } from '../../src/universal/uuid'
describe('related() with a verb-type array returns every requested type', () => {
let brain: Brainy<any>
beforeAll(async () => {
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
await brain.init()
for (const id of ['a', 'b', 'c', 'd']) {
await brain.add({ id, data: `node ${id}`, type: NounType.Person })
}
await brain.relate({ from: 'a', to: 'b', type: VerbType.Supports })
await brain.relate({ from: 'a', to: 'c', type: VerbType.RelatedTo })
await brain.relate({ from: 'a', to: 'd', type: VerbType.Knows })
await brain.relate({ from: 'b', to: 'c', type: VerbType.RelatedTo })
})
afterAll(async () => {
brain = null as any
})
it('from + type array: the second type\'s edge comes back, both orders', async () => {
for (const types of [
[VerbType.Supports, VerbType.RelatedTo],
[VerbType.RelatedTo, VerbType.Supports]
]) {
const edges = await brain.related({ from: 'a', type: types })
const targets = new Set(edges.map((e) => e.to))
expect(targets.has(v5('b')), `types [${types}] missing Supports edge`).toBe(true)
expect(targets.has(v5('c')), `types [${types}] missing RelatedTo edge`).toBe(true)
expect(targets.has(v5('d'))).toBe(false)
expect(edges).toHaveLength(2)
}
})
it('a single-element array behaves exactly like the scalar', async () => {
const scalar = await brain.related({ from: 'a', type: VerbType.Supports })
const array = await brain.related({ from: 'a', type: [VerbType.Supports] })
expect(array.map((e) => e.id).sort()).toEqual(scalar.map((e) => e.id).sort())
expect(array).toHaveLength(1)
})
it('no duplicate edges when types overlap the same edge set', async () => {
const edges = await brain.related({
from: 'a',
type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows]
})
const ids = edges.map((e) => e.id)
expect(new Set(ids).size).toBe(ids.length)
expect(edges).toHaveLength(3)
})
it('type-only asks (no anchor) honour the whole array too', async () => {
const edges = await brain.related({ type: [VerbType.Supports, VerbType.Knows] })
const verbs = new Set(edges.map((e) => e.type))
expect(verbs.has(VerbType.Supports)).toBe(true)
expect(verbs.has(VerbType.Knows)).toBe(true)
expect(edges).toHaveLength(2)
})
it('pagination stays consistent across the union', async () => {
const page1 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2 })
const page2 = await brain.related({ from: 'a', type: [VerbType.Supports, VerbType.RelatedTo, VerbType.Knows], limit: 2, offset: 2 })
const all = [...page1, ...page2].map((e) => e.id)
expect(new Set(all).size).toBe(3)
})
})