166 lines
6 KiB
TypeScript
166 lines
6 KiB
TypeScript
|
|
/**
|
||
|
|
* @module tests/integration/find-connected-order
|
||
|
|
* @description The graph-first law for `find({ connected })` (10.4.8).
|
||
|
|
*
|
||
|
|
* With `connected` present the neighbour set is the candidate universe: it is
|
||
|
|
* resolved from the adjacency first, the metadata filter is evaluated over
|
||
|
|
* those ids only, and the page is cut last. The earlier order materialized the
|
||
|
|
* whole-store filtered id list, paged it, hydrated the page, and only then
|
||
|
|
* intersected with the neighbours — so a neighbour outside the first page of
|
||
|
|
* the filtered STORE was silently dropped, and every call paid O(store).
|
||
|
|
*
|
||
|
|
* These pins hold both halves. The answer: every matching neighbour is
|
||
|
|
* reachable by paging, a non-neighbour never appears, a negation (`missing`)
|
||
|
|
* is evaluated over the neighbours, `orderBy` sorts the whole neighbour set
|
||
|
|
* before the page is cut, and the vector leg walks the neighbours only. The
|
||
|
|
* cost shape: the metadata index is asked about the neighbour ids only, and
|
||
|
|
* hydration is one page — never the store.
|
||
|
|
*/
|
||
|
|
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'
|
||
|
|
import { Brainy } from '../../src/brainy'
|
||
|
|
import { NounType, VerbType } from '../../src/types/graphTypes'
|
||
|
|
import { v5 } from '../../src/universal/uuid'
|
||
|
|
import { generateTestVector } from '../helpers/test-factory'
|
||
|
|
|
||
|
|
/** Matching rows that are NOT neighbours — added FIRST, so the whole-store filtered list leads with them. */
|
||
|
|
const NOISE = 120
|
||
|
|
/** Matching rows that ARE neighbours of the anchor. */
|
||
|
|
const NEIGHBOURS = 30
|
||
|
|
/** Neighbours carrying `retracted: true` — excluded by the `missing` negation. */
|
||
|
|
const RETRACTED = 4
|
||
|
|
|
||
|
|
describe('find({ connected }) is graph-first: neighbours → filter → page', () => {
|
||
|
|
let brain: Brainy<any>
|
||
|
|
const anchor = 'anchor'
|
||
|
|
const sharedVector = generateTestVector()
|
||
|
|
const neighbourIds = new Set(Array.from({ length: NEIGHBOURS }, (_, i) => v5(`nb-${i}`)))
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' } })
|
||
|
|
await brain.init()
|
||
|
|
await brain.add({
|
||
|
|
id: anchor,
|
||
|
|
data: 'the anchor',
|
||
|
|
type: NounType.Person,
|
||
|
|
metadata: { kind: 'anchor' },
|
||
|
|
vector: generateTestVector()
|
||
|
|
})
|
||
|
|
for (let i = 0; i < NOISE; i++) {
|
||
|
|
await brain.add({
|
||
|
|
id: `noise-${i}`,
|
||
|
|
data: `noise ${i}`,
|
||
|
|
type: NounType.Person,
|
||
|
|
metadata: { kind: 'note', rank: 1000 + i },
|
||
|
|
vector: sharedVector
|
||
|
|
})
|
||
|
|
}
|
||
|
|
for (let i = 0; i < NEIGHBOURS; i++) {
|
||
|
|
await brain.add({
|
||
|
|
id: `nb-${i}`,
|
||
|
|
data: `neighbour ${i}`,
|
||
|
|
type: NounType.Person,
|
||
|
|
metadata: { kind: 'note', rank: i + 1, ...(i < RETRACTED ? { retracted: true } : {}) },
|
||
|
|
vector: sharedVector
|
||
|
|
})
|
||
|
|
await brain.relate({ from: anchor, to: `nb-${i}`, type: VerbType.Knows })
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
brain = null as any
|
||
|
|
})
|
||
|
|
|
||
|
|
it('returns the matching neighbours page by page — none dropped, never a non-neighbour', async () => {
|
||
|
|
const seen = new Set<string>()
|
||
|
|
for (let offset = 0; offset <= NEIGHBOURS; offset += 10) {
|
||
|
|
const page = await brain.find({
|
||
|
|
connected: { from: anchor, direction: 'out' },
|
||
|
|
where: { kind: 'note' },
|
||
|
|
limit: 10,
|
||
|
|
offset
|
||
|
|
})
|
||
|
|
expect(page).toHaveLength(offset < NEIGHBOURS ? 10 : 0)
|
||
|
|
for (const r of page) {
|
||
|
|
expect(neighbourIds.has(r.entity.id)).toBe(true)
|
||
|
|
expect(seen.has(r.entity.id)).toBe(false)
|
||
|
|
seen.add(r.entity.id)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
expect(seen.size).toBe(NEIGHBOURS)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('evaluates a negation (`missing`) over the neighbour set, not the store', async () => {
|
||
|
|
const results = await brain.find({
|
||
|
|
connected: { from: anchor, direction: 'out' },
|
||
|
|
where: { kind: 'note', retracted: { missing: true } },
|
||
|
|
limit: 100
|
||
|
|
})
|
||
|
|
expect(results).toHaveLength(NEIGHBOURS - RETRACTED)
|
||
|
|
for (const r of results) {
|
||
|
|
expect(neighbourIds.has(r.entity.id)).toBe(true)
|
||
|
|
expect(r.entity.metadata.retracted).toBeUndefined()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
it('asks the metadata index about the neighbour ids only, and hydrates one page', async () => {
|
||
|
|
const index = (brain as any).metadataIndex
|
||
|
|
const within = vi.spyOn(index, 'filterIdsWithin')
|
||
|
|
const hydrate = vi.spyOn(brain as any, 'batchGet')
|
||
|
|
try {
|
||
|
|
const results = await brain.find({
|
||
|
|
connected: { from: anchor, direction: 'out' },
|
||
|
|
where: { kind: 'note' },
|
||
|
|
limit: 10
|
||
|
|
})
|
||
|
|
expect(results).toHaveLength(10)
|
||
|
|
expect(within).toHaveBeenCalledTimes(1)
|
||
|
|
const askedIds = within.mock.calls[0][1] as string[]
|
||
|
|
expect(askedIds).toHaveLength(NEIGHBOURS)
|
||
|
|
for (const id of askedIds) expect(neighbourIds.has(id)).toBe(true)
|
||
|
|
expect(hydrate).toHaveBeenCalledTimes(1)
|
||
|
|
expect(hydrate.mock.calls[0][0]).toHaveLength(10)
|
||
|
|
} finally {
|
||
|
|
within.mockRestore()
|
||
|
|
hydrate.mockRestore()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
it('orders the WHOLE neighbour set before cutting the page', async () => {
|
||
|
|
const results = await brain.find({
|
||
|
|
connected: { from: anchor, direction: 'out' },
|
||
|
|
where: { kind: 'note' },
|
||
|
|
orderBy: 'rank',
|
||
|
|
order: 'desc',
|
||
|
|
limit: 5
|
||
|
|
})
|
||
|
|
expect(results.map((r) => r.entity.metadata.rank)).toEqual([30, 29, 28, 27, 26])
|
||
|
|
})
|
||
|
|
|
||
|
|
it('walks the vector leg over the neighbours only', async () => {
|
||
|
|
const results = await brain.find({
|
||
|
|
vector: sharedVector,
|
||
|
|
connected: { from: anchor, direction: 'out' },
|
||
|
|
where: { kind: 'note' },
|
||
|
|
limit: 5
|
||
|
|
})
|
||
|
|
expect(results).toHaveLength(5)
|
||
|
|
for (const r of results) expect(neighbourIds.has(r.entity.id)).toBe(true)
|
||
|
|
})
|
||
|
|
|
||
|
|
it('an anchor without neighbours answers [] before the filter is asked', async () => {
|
||
|
|
const index = (brain as any).metadataIndex
|
||
|
|
const within = vi.spyOn(index, 'filterIdsWithin')
|
||
|
|
try {
|
||
|
|
const results = await brain.find({
|
||
|
|
connected: { from: 'noise-0', direction: 'out' },
|
||
|
|
where: { kind: 'note' },
|
||
|
|
limit: 10
|
||
|
|
})
|
||
|
|
expect(results).toEqual([])
|
||
|
|
expect(within).not.toHaveBeenCalled()
|
||
|
|
} finally {
|
||
|
|
within.mockRestore()
|
||
|
|
}
|
||
|
|
})
|
||
|
|
})
|