fix(graph): the verb fast paths honour every requested type, source, and target
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:
parent
5e3b343a0e
commit
6a89adc468
2 changed files with 163 additions and 39 deletions
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -2985,16 +2999,22 @@ 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)
|
// EVERY requested source is honoured — an array used to collapse to
|
||||||
? options.filter.sourceId[0]
|
// its first element here, silently dropping the rest of the ask.
|
||||||
: options.filter.sourceId
|
const onlySourceIds = Array.isArray(options.filter.sourceId)
|
||||||
|
? options.filter.sourceId
|
||||||
// Get verbs by source directly (hydrated with metadata), then apply the
|
: [options.filter.sourceId]
|
||||||
// subtype / visibility metadata filters on the O(degree) candidate set.
|
const sourceUnion: HNSWVerbWithMetadata[] = []
|
||||||
const verbsBySource = this.applyVerbMetadataFilters(
|
const seenSourceVerbIds = new Set<string>()
|
||||||
await this.getVerbsBySource_internal(sourceId),
|
for (const oneSource of onlySourceIds) {
|
||||||
options.filter
|
for (const v of await this.getVerbsBySource_internal(oneSource)) {
|
||||||
)
|
if (!seenSourceVerbIds.has(v.id)) {
|
||||||
|
seenSourceVerbIds.add(v.id)
|
||||||
|
sourceUnion.push(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const verbsBySource = this.applyVerbMetadataFilters(sourceUnion, options.filter)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = verbsBySource.slice(offset, offset + limit)
|
const paginatedVerbs = verbsBySource.slice(offset, offset + limit)
|
||||||
|
|
@ -3023,16 +3043,22 @@ export abstract class BaseStorage extends BaseStorageAdapter {
|
||||||
!options.filter.service &&
|
!options.filter.service &&
|
||||||
!options.filter.metadata
|
!options.filter.metadata
|
||||||
) {
|
) {
|
||||||
const targetId = Array.isArray(options.filter.targetId)
|
// EVERY requested target is honoured — an array used to collapse to
|
||||||
? options.filter.targetId[0]
|
// its first element here, silently dropping the rest of the ask.
|
||||||
: options.filter.targetId
|
const onlyTargetIds = Array.isArray(options.filter.targetId)
|
||||||
|
? options.filter.targetId
|
||||||
// Get verbs by target directly (hydrated with metadata), then apply the
|
: [options.filter.targetId]
|
||||||
// subtype / visibility metadata filters on the O(degree) candidate set.
|
const targetUnion: HNSWVerbWithMetadata[] = []
|
||||||
const verbsByTarget = this.applyVerbMetadataFilters(
|
const seenTargetVerbIds = new Set<string>()
|
||||||
await this.getVerbsByTarget_internal(targetId),
|
for (const oneTarget of onlyTargetIds) {
|
||||||
options.filter
|
for (const v of await this.getVerbsByTarget_internal(oneTarget)) {
|
||||||
)
|
if (!seenTargetVerbIds.has(v.id)) {
|
||||||
|
seenTargetVerbIds.add(v.id)
|
||||||
|
targetUnion.push(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const verbsByTarget = this.applyVerbMetadataFilters(targetUnion, options.filter)
|
||||||
|
|
||||||
// Apply pagination
|
// Apply pagination
|
||||||
const paginatedVerbs = verbsByTarget.slice(offset, offset + limit)
|
const paginatedVerbs = verbsByTarget.slice(offset, offset + limit)
|
||||||
|
|
@ -3061,16 +3087,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)
|
||||||
|
|
|
||||||
89
tests/integration/related-verb-array.test.ts
Normal file
89
tests/integration/related-verb-array.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
/**
|
||||||
|
* @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('to + type array: the target side honours every type too', async () => {
|
||||||
|
const edges = await brain.related({ to: 'c', type: [VerbType.RelatedTo, VerbType.Supports] })
|
||||||
|
const froms = new Set(edges.map((e) => e.from))
|
||||||
|
expect(froms.has(v5('a'))).toBe(true)
|
||||||
|
expect(froms.has(v5('b'))).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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue