brainy/tests/unit/hnsw/allowed-ids-pushdown.test.ts
David Snelling dd325f2f94 feat(8.0): vector allowedIds predicate-pushdown into find() (#46)
Filtered semantic search now keeps its recall. A find({ query, where }) that
pairs vector search with a metadata filter restricts the HNSW beam walk to the
matching candidates INSIDE the traversal (walk-all, collect-allowed) rather than
filtering the top-k afterward — so a query whose nearest vectors are all filtered
out still returns the best matches that DO pass the filter, instead of empty.

- JS HNSW search() honors the 8.0 `allowedIds: OpaqueIdSet | ReadonlySet<string>`
  contract param (ANDs with candidateIds; ignores the opaque Buffer form it can't
  decode — only a native provider consumes that).
- MetadataIndexProvider gains an OPTIONAL `getIdSetForFilter(filter): OpaqueIdSet`
  producer — the native (cor) metadata index returns its roaring filter result as
  a serialized buffer; the JS index does not implement it.
- find() forwards the matched universe to the vector walk: the opaque buffer
  (zero id materialization) when the native producer is present, alongside the
  materialized visibility-precise candidateIds for the JS index. Visibility stays
  correct via the existing post-search hard filter.

Tested: JS recall/restriction/AND/opaque-ignore on the index directly, plus
find() forwarding the opaque universe through to the beam walk via a stubbed
native producer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:18:34 -07:00

176 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @module tests/unit/hnsw/allowed-ids-pushdown
* @description 8.0 #46 (CTX-PUSHDOWN-ALLOWEDIDS) — the vector-search predicate
* pushdown. Two layers:
*
* 1. The JS HNSW index honors `allowedIds: ReadonlySet<string>` by restricting the
* beam walk to allowed nodes INSIDE the traversal (walk-all, collect-allowed) —
* the semantics that recover the filtered recall a naive top-k-then-filter loses.
* An `allowedIds` arriving as an `OpaqueIdSet` (a native/cor roaring Buffer) is
* opaque to the JS index and ignored — only a native provider decodes it.
*
* 2. `find()` forwards the matched universe to the vector beam walk as an
* `OpaqueIdSet` (zero id materialization) when the active metadata provider
* exposes the `getIdSetForFilter` producer — the native-stack zero-crossing path.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
import { euclideanDistance } from '../../../src/utils/index.js'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
import { Brainy } from '../../../src/index.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
const DIM = 8
/** Deterministic PRNG (mulberry32) so the synthetic graph is identical every run. */
function seededRand(seed: number): () => number {
let s = seed >>> 0
return () => {
s = (s + 0x6d2b79f5) | 0
let t = Math.imul(s ^ (s >>> 15), 1 | s)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* A vector at the requested distance-from-origin pointing in a deterministic RANDOM
* direction (so the HNSW graph is well-connected — diverse directions, not a
* degenerate axis cluster). The query is the origin, so distance-from-query equals
* `magnitude` exactly — letting the test place nodes in clean distance bands.
*/
function vecAt(magnitude: number, idx: number): number[] {
const rand = seededRand(idx + 1)
const dir = Array.from({ length: DIM }, () => rand() * 2 - 1)
const norm = Math.hypot(...dir) || 1
return dir.map((x) => (x / norm) * magnitude)
}
describe('#46 JS HNSW allowedIds pushdown (recall-preserving)', () => {
let index: JsHnswVectorIndex
const query = new Array(DIM).fill(0)
// Two clean distance bands: the disallowed nodes are STRICTLY closer to the query
// than the allowed targets. A naive top-k-then-filter returns only the disallowed
// (then drops them ⇒ empty); the pushdown must reach the allowed targets. `M` ≥ N1
// makes the small graph complete, so reachability is guaranteed and the test asserts
// the FILTER mechanism (walk-all, collect-allowed), not emergent HNSW connectivity.
const disallowed: string[] = []
const targets: string[] = []
beforeEach(async () => {
index = new JsHnswVectorIndex(
{ M: 16, efConstruction: 200, efSearch: 50, ml: 16 },
euclideanDistance,
{ useParallelization: false, storage: new MemoryStorage() }
)
let n = 0
for (let i = 0; i < 5; i++) {
const id = `disallowed-${i}`
disallowed.push(id)
await index.addItem({ id, vector: vecAt(0.3, n++) }) // closer band
}
for (let i = 0; i < 5; i++) {
const id = `target-${i}`
targets.push(id)
await index.addItem({ id, vector: vecAt(0.8, n++) }) // farther band
}
})
afterEach(() => {
disallowed.length = 0
targets.length = 0
})
it('without restriction, the top-k are all disallowed (so post-filtering would lose recall)', async () => {
const results = await index.search(query, 3)
const ids = results.map(([id]) => id)
// Every nearest neighbor is in the (closer) disallowed band → naive "filter after" = 0 hits.
expect(ids.length).toBe(3)
expect(ids.every((id) => disallowed.includes(id))).toBe(true)
})
it('with allowedIds, the walk reaches the allowed targets the naive path would miss', async () => {
const allowed = new Set(targets)
const results = await index.search(query, 3, undefined, { allowedIds: allowed })
const ids = results.map(([id]) => id)
expect(ids.length).toBe(3)
expect(ids.some((id) => disallowed.includes(id))).toBe(false) // no disallowed leaks through
expect(ids.every((id) => allowed.has(id))).toBe(true) // only allowed targets
})
it('ANDs allowedIds with candidateIds when both are given', async () => {
const results = await index.search(query, 5, undefined, {
candidateIds: [targets[0], targets[1], disallowed[0]],
allowedIds: new Set([targets[0], disallowed[0], disallowed[1]])
})
const ids = results.map(([id]) => id)
// Intersection = { target-0, disallowed-0 } — nothing outside it may appear.
expect(ids.every((id) => id === targets[0] || id === disallowed[0])).toBe(true)
expect(ids).toContain(targets[0])
})
it('ignores an OpaqueIdSet (Uint8Array) — only a native provider can decode it', async () => {
const opaque = new Uint8Array([0xca, 0xfe, 0x01, 0x02])
const results = await index.search(query, 3, undefined, { allowedIds: opaque })
const ids = results.map(([id]) => id)
// Unrestricted behavior: the opaque buffer is not interpreted as a filter.
expect(ids.every((id) => disallowed.includes(id))).toBe(true)
})
})
describe('#46 find() forwards the opaque universe to the vector beam walk', () => {
let brain: Brainy
beforeEach(async () => {
brain = new Brainy(createTestConfig())
await brain.init()
await brain.add({ type: NounType.Document, data: 'machine learning notes', metadata: { author: 'alice' } })
await brain.add({ type: NounType.Document, data: 'unrelated cooking', metadata: { author: 'bob' } })
})
afterEach(async () => {
await brain.close()
})
it('passes the metadata getIdSetForFilter() OpaqueIdSet straight through as allowedIds', async () => {
const sentinel = new Uint8Array([0xab, 0xcd]) // stand-in for cor's roaring Buffer
// Stub the native producer the JS metadata manager does not implement.
;(brain as any).metadataIndex.getIdSetForFilter = async () => sentinel
// Record what the vector index actually receives, then run the real search.
const calls: any[] = []
const realSearch = (brain as any).index.search.bind((brain as any).index)
;(brain as any).index.search = async (vec: any, k: any, f: any, opts: any) => {
if (opts) calls.push(opts)
return realSearch(vec, k, f, opts)
}
await brain.find({ query: 'machine learning', where: { author: 'alice' } })
const withUniverse = calls.find((o) => o.allowedIds !== undefined)
expect(withUniverse).toBeDefined()
// The opaque Buffer is forwarded verbatim (same reference — never materialized/copied).
expect(withUniverse.allowedIds).toBe(sentinel)
// The materialized candidateIds path is still present for the JS index.
expect(withUniverse.candidateIds).toBeDefined()
})
it('omits allowedIds when the metadata provider has no opaque producer (JS path)', async () => {
// No getIdSetForFilter stub — the JS manager genuinely lacks it.
const calls: any[] = []
const realSearch = (brain as any).index.search.bind((brain as any).index)
;(brain as any).index.search = async (vec: any, k: any, f: any, opts: any) => {
if (opts) calls.push(opts)
return realSearch(vec, k, f, opts)
}
await brain.find({ query: 'machine learning', where: { author: 'alice' } })
const withOpts = calls.find((o) => o.candidateIds !== undefined)
expect(withOpts).toBeDefined()
expect(withOpts.allowedIds).toBeUndefined() // JS path: materialized candidateIds only
})
})