removeItem() scanned the ENTIRE corpus on every delete to find nodes that referenced the removed id (and to repair edges left asymmetric by pruning), so a delete was O(N) and a bulk delete O(N²) — on the open-core JS vector path that serves when no native provider is registered. Maintain a reverse-adjacency index (`target → level → set of nodes that link to target`), so removeItem touches only the removed node's actual in-neighbors: O(in-degree) per delete, O(N·degree) for a bulk delete. The index is lazily built, maintained incrementally at every forward-edge mutation (add-link and prune), and invalidated (rebuilt on next use) by the bulk paths (cold-load restore + clear). New tests assert the three invariants that matter for a reverse index: no dangling references to deleted nodes, the incrementally- maintained index exactly equals a fresh rebuild from the live adjacency, and search still returns the survivors' true nearest neighbours (exact vs brute force). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
/**
|
|
* @module tests/unit/hnsw/remove-reverse-adjacency
|
|
* @description Correctness guard for the reverse-adjacency index that makes
|
|
* `removeItem` O(in-degree) instead of O(N) (and bulk delete O(N·degree) instead
|
|
* of O(N²)). The risk of a reverse index is staleness — a missed maintenance site
|
|
* leaves a dangling reference to a deleted node or a stale referrer. These tests
|
|
* exercise mixed add/delete workloads and assert three invariants:
|
|
*
|
|
* 1. No surviving node's connections reference a deleted id (no dangling edges).
|
|
* 2. The incrementally-maintained reverse index matches a fresh rebuild from the
|
|
* live adjacency — i.e. link/prune/remove maintenance is exactly consistent.
|
|
* 3. Search still returns the true nearest neighbours of the surviving set
|
|
* (verified exactly against a brute-force scan on a complete small graph).
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
|
|
import { euclideanDistance } from '../../../src/utils/index.js'
|
|
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
|
|
|
const DIM = 8
|
|
|
|
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 deterministic vector pointing in a pseudo-random direction (well-connected graph). */
|
|
function vec(idx: number): number[] {
|
|
const rand = seededRand(idx + 1)
|
|
return Array.from({ length: DIM }, () => rand() * 2 - 1)
|
|
}
|
|
|
|
type Noun = { id: string; vector: number[]; connections: Map<number, Set<string>> }
|
|
|
|
function nounsOf(index: JsHnswVectorIndex): Map<string, Noun> {
|
|
return (index as unknown as { nouns: Map<string, Noun> }).nouns
|
|
}
|
|
|
|
/** Flatten a reverse index (or a freshly-derived one) to sorted `target|level|source` triples. */
|
|
function triplesFromIncoming(inc: Map<string, Map<number, Set<string>>>): string[] {
|
|
const out: string[] = []
|
|
for (const [target, byLevel] of inc) {
|
|
for (const [level, sources] of byLevel) {
|
|
for (const source of sources) out.push(`${target}|${level}|${source}`)
|
|
}
|
|
}
|
|
return out.sort()
|
|
}
|
|
|
|
/** Derive the ground-truth reverse index directly from the live forward adjacency. */
|
|
function triplesFromAdjacency(nouns: Map<string, Noun>): string[] {
|
|
const out: string[] = []
|
|
for (const [nodeId, node] of nouns) {
|
|
for (const [level, targets] of node.connections) {
|
|
for (const target of targets) out.push(`${target}|${level}|${nodeId}`)
|
|
}
|
|
}
|
|
return out.sort()
|
|
}
|
|
|
|
function assertNoDanglingRefs(nouns: Map<string, Noun>): void {
|
|
for (const [nodeId, node] of nouns) {
|
|
for (const [level, targets] of node.connections) {
|
|
for (const target of targets) {
|
|
expect(nouns.has(target), `node ${nodeId} (L${level}) links to deleted ${target}`).toBe(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function assertReverseIndexConsistent(index: JsHnswVectorIndex): void {
|
|
// ensureIncoming() returns the live (incrementally-maintained) index, building it
|
|
// only if it was invalidated. The triples it yields must equal the triples derived
|
|
// straight from the forward adjacency — empty leftover sets contribute nothing.
|
|
const live = (index as unknown as { ensureIncoming: () => Map<string, Map<number, Set<string>>> }).ensureIncoming()
|
|
expect(triplesFromIncoming(live)).toEqual(triplesFromAdjacency(nounsOf(index)))
|
|
}
|
|
|
|
function makeIndex(M: number): JsHnswVectorIndex {
|
|
return new JsHnswVectorIndex(
|
|
{ M, efConstruction: 200, efSearch: 64, ml: 16 },
|
|
euclideanDistance,
|
|
{ useParallelization: false, storage: new MemoryStorage() }
|
|
)
|
|
}
|
|
|
|
describe('HNSW removeItem — reverse-adjacency correctness', () => {
|
|
it('leaves no dangling references and a consistent reverse index after interleaved add/delete', async () => {
|
|
const index = makeIndex(16)
|
|
const live = new Set<string>()
|
|
|
|
// Add 120, then interleave: delete every 3rd as we keep adding — exercises the
|
|
// incremental maintenance (link + prune on add, unlink on remove) continuously.
|
|
for (let i = 0; i < 120; i++) {
|
|
const id = `n-${i}`
|
|
await index.addItem({ id, vector: vec(i) })
|
|
live.add(id)
|
|
if (i % 3 === 0 && i > 0) {
|
|
const victim = `n-${i - 1}`
|
|
await index.removeItem(victim)
|
|
live.delete(victim)
|
|
}
|
|
}
|
|
|
|
// Now delete ~half of the survivors in one burst (the O(N²)-prone path).
|
|
const survivors = [...live]
|
|
for (let i = 0; i < survivors.length; i += 2) {
|
|
await index.removeItem(survivors[i])
|
|
live.delete(survivors[i])
|
|
}
|
|
|
|
const nouns = nounsOf(index)
|
|
expect(nouns.size).toBe(live.size)
|
|
assertNoDanglingRefs(nouns)
|
|
assertReverseIndexConsistent(index)
|
|
})
|
|
|
|
it('removing the entry point repeatedly stays consistent', async () => {
|
|
const index = makeIndex(8)
|
|
for (let i = 0; i < 40; i++) await index.addItem({ id: `e-${i}`, vector: vec(i) })
|
|
|
|
// Delete whatever is currently the entry point, 20 times.
|
|
for (let k = 0; k < 20; k++) {
|
|
const ep = (index as unknown as { entryPointId: string | null }).entryPointId
|
|
if (!ep) break
|
|
await index.removeItem(ep)
|
|
assertNoDanglingRefs(nounsOf(index))
|
|
}
|
|
assertReverseIndexConsistent(index)
|
|
})
|
|
|
|
it('search returns the true nearest neighbours of the surviving set (exact, complete graph)', async () => {
|
|
// M ≥ N-1 ⇒ the small graph is complete and never prunes, so HNSW recall is 100%
|
|
// and we can assert the survivors\' exact top-k against a brute-force scan.
|
|
const index = makeIndex(64)
|
|
const all: Array<{ id: string; v: number[] }> = []
|
|
for (let i = 0; i < 24; i++) {
|
|
const v = vec(i + 1000)
|
|
all.push({ id: `s-${i}`, v })
|
|
await index.addItem({ id: `s-${i}`, vector: v })
|
|
}
|
|
|
|
const deleted = new Set(['s-2', 's-5', 's-9', 's-14', 's-20'])
|
|
for (const id of deleted) await index.removeItem(id)
|
|
|
|
const survivors = all.filter((x) => !deleted.has(x.id))
|
|
const query = vec(7).map((x) => x * 0.5) // an arbitrary deterministic query point
|
|
const k = 5
|
|
|
|
const bruteTopK = survivors
|
|
.map((x) => ({ id: x.id, d: euclideanDistance(query, x.v) }))
|
|
.sort((a, b) => a.d - b.d)
|
|
.slice(0, k)
|
|
.map((x) => x.id)
|
|
|
|
const got = (await index.search(query, k)).map(([id]) => id)
|
|
|
|
expect(got).toEqual(bruteTopK)
|
|
// And none of the deleted ids can ever surface.
|
|
expect(got.some((id) => deleted.has(id))).toBe(false)
|
|
assertNoDanglingRefs(nounsOf(index))
|
|
})
|
|
})
|