open-brainy/tests/unit/hnsw/update-item-atomic.test.ts
David Snelling ebe06cdf33
Some checks failed
CI / Node 22 (push) Successful in 12m10s
CI / Node 24 (push) Has been cancelled
CI / Bun (latest) (push) Has been cancelled
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:

- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
  production shape — a type-only update re-indexed an unchanged vector,
  remove+add did pure damage); changed vector → the node NEVER leaves the
  index: synchronous vector swap first (every query from that instant sees
  correct distances), then unlink/relink at the node's existing level via
  shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
  entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
  provider updateItem (native seam flagged — their side ships updateItem,
  then the adjacent remove+add fallback is dead code). Both update staging
  sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
  disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
  a not-ready native METADATA provider never blocked the completion latch
  and every find() silently returned [] on a populated store. All three
  providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
  service class, budgets, lifecycle, narration, and the cited pin per row;
  owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
  downgrade contract) per the lifecycle-sprint choreography.

Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00

366 lines
14 KiB
TypeScript

/**
* @module tests/unit/hnsw/update-item-atomic
* @description Guard for the atomic vector-index update: a row must NEVER be
* absent from vector search during an update. The historical update path
* staged a remove followed by an add as two separately-awaited transaction
* operations — between them the row was in NEITHER index (dark to semantic
* recall while perfectly visible to metadata reads; observed as seconds-long
* flicker in a production deployment). The structural cure verified here:
*
* 1. `JsHnswVectorIndex.updateItem` — same vector (element-wise) is a pure
* no-op (the production flicker shape: a type-only update re-indexing an
* UNCHANGED vector); a changed vector swaps in place, the node never
* leaving the map (white-box probe at the first internal step after the
* synchronous swap), including when the node IS the entry point.
* 2. `ReplaceInVectorIndexOperation` — one transaction leg that prefers the
* provider's in-place `updateItem`, with a remove+add-ADJACENT fallback
* for providers that have not shipped it; rollback restores the declared
* before-vector on both branches.
* 3. The brain's update path — with the JS index carrying `updateItem`,
* `removeItem` is never called during `brain.update()`, for the
* type-only shape AND for a genuine vector change.
*/
import { describe, it, expect, vi } from 'vitest'
import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
import { ReplaceInVectorIndexOperation } from '../../../src/transaction/operations/IndexOperations.js'
import type { VectorIndexProvider } from '../../../src/plugin.js'
import type { Vector, VectorDocument } from '../../../src/coreTypes.js'
import { euclideanDistance } from '../../../src/utils/index.js'
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
import { Brainy } from '../../../src/brainy'
import { createAddParams, createTestConfig } from '../../helpers/test-factory'
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>>; level: number }
function nounsOf(index: JsHnswVectorIndex): Map<string, Noun> {
return (index as unknown as { nouns: Map<string, Noun> }).nouns
}
/** Flatten a reverse index 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 assertReverseIndexConsistent(index: JsHnswVectorIndex): void {
const live = (
index as unknown as { ensureIncoming: () => Map<string, Map<number, Set<string>>> }
).ensureIncoming()
expect(triplesFromIncoming(live)).toEqual(triplesFromAdjacency(nounsOf(index)))
}
function assertNoSelfLoops(index: JsHnswVectorIndex, id: string): void {
const node = nounsOf(index).get(id)!
for (const [level, targets] of node.connections) {
expect(targets.has(id), `self-loop at level ${level}`).toBe(false)
}
}
function makeIndex(M = 16): JsHnswVectorIndex {
return new JsHnswVectorIndex(
{ M, efConstruction: 200, efSearch: 64, ml: 16 },
euclideanDistance,
{ useParallelization: false, storage: new MemoryStorage() }
)
}
async function fillIndex(index: JsHnswVectorIndex, count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await index.addItem({ id: `n-${i}`, vector: vec(i) })
}
}
describe('JsHnswVectorIndex.updateItem — atomic in-place vector update', () => {
it('same vector (element-wise equal, fresh array) is a pure no-op: no remove, no relink, still searchable', async () => {
const index = makeIndex()
await fillIndex(index, 30)
const target = 'n-7'
const sameVector = [...vec(7)] // fresh array, identical elements
const before = await index.search(vec(7), 1)
expect(before[0][0]).toBe(target)
const removeSpy = vi.spyOn(index, 'removeItem')
const nodeBefore = nounsOf(index).get(target)!
const connectionsBefore = nodeBefore.connections // reference — a relink replaces it
await index.updateItem({ id: target, vector: sameVector })
expect(removeSpy).not.toHaveBeenCalled()
expect(index.size()).toBe(30)
// No relink happened: the connections map is the SAME object, untouched.
expect(nounsOf(index).get(target)!.connections).toBe(connectionsBefore)
const after = await index.search(vec(7), 1)
expect(after[0][0]).toBe(target)
expect(after[0][1]).toBeCloseTo(0, 10)
removeSpy.mockRestore()
})
it('changed vector: node never leaves the map (probe fires after the synchronous swap), removeItem never called, findable by the NEW vector', async () => {
const index = makeIndex()
await fillIndex(index, 40)
const target = 'n-5'
const newVector = vec(500)
// White-box probe: ensureIncoming is the FIRST internal step of the unlink
// walk, i.e. the first thing updateItem does after the synchronous vector
// swap. At that instant the node must (a) still be in the map and (b)
// already carry the NEW vector — the visibility-atomic ordering.
const inner = index as unknown as {
nouns: Map<string, Noun>
ensureIncoming: () => Map<string, Map<number, Set<string>>>
}
const origEnsure = inner.ensureIncoming.bind(index)
let probed = false
let presentDuring = false
let swappedFirst = false
;(index as any).ensureIncoming = function () {
if (!probed) {
probed = true
presentDuring = inner.nouns.has(target)
swappedFirst = inner.nouns.get(target)?.vector === newVector
}
return origEnsure()
}
const removeSpy = vi.spyOn(index, 'removeItem')
await index.updateItem({ id: target, vector: newVector })
delete (index as any).ensureIncoming // restore the prototype method
expect(probed).toBe(true)
expect(presentDuring).toBe(true)
expect(swappedFirst).toBe(true)
expect(removeSpy).not.toHaveBeenCalled()
expect(index.size()).toBe(40)
expect(nounsOf(index).has(target)).toBe(true)
// Findable by search with the NEW vector, at distance ~0.
const got = await index.search(newVector, 1)
expect(got[0][0]).toBe(target)
expect(got[0][1]).toBeCloseTo(0, 10)
// The relink left the graph bookkeeping exactly consistent.
assertNoSelfLoops(index, target)
assertReverseIndexConsistent(index)
removeSpy.mockRestore()
})
it('keeps the node at its existing level (never releveled by an update)', async () => {
const index = makeIndex()
await fillIndex(index, 30)
const target = 'n-3'
const levelBefore = nounsOf(index).get(target)!.level
await index.updateItem({ id: target, vector: vec(600) })
expect(nounsOf(index).get(target)!.level).toBe(levelBefore)
expect(index.getMaxLevel()).toBeGreaterThanOrEqual(levelBefore)
})
it('updating the ENTRY POINT in place keeps it valid — entry id and maxLevel unchanged, graph never stranded', async () => {
const index = makeIndex()
await fillIndex(index, 40)
const entryId = index.getEntryPointId()!
const maxLevelBefore = index.getMaxLevel()
const newVector = vec(700)
await index.updateItem({ id: entryId, vector: newVector })
// Entry-point bookkeeping must not regress.
expect(index.getEntryPointId()).toBe(entryId)
expect(index.getMaxLevel()).toBe(maxLevelBefore)
expect(index.size()).toBe(40)
// The entry point itself is findable by its new vector...
const gotEntry = await index.search(newVector, 1)
expect(gotEntry[0][0]).toBe(entryId)
// ...and the REST of the graph is still reachable through it (a stranded,
// edgeless entry point would make every other node invisible).
const otherId = [...nounsOf(index).keys()].find((id) => id !== entryId)!
const otherIdx = Number(otherId.slice(2))
const gotOther = await index.search(vec(otherIdx), 1)
expect(gotOther[0][0]).toBe(otherId)
assertNoSelfLoops(index, entryId)
assertReverseIndexConsistent(index)
})
it('absent id delegates to addItem (plain insert)', async () => {
const index = makeIndex()
await fillIndex(index, 10)
await index.updateItem({ id: 'fresh', vector: vec(900) })
expect(index.size()).toBe(11)
const got = await index.search(vec(900), 1)
expect(got[0][0]).toBe('fresh')
})
})
describe('ReplaceInVectorIndexOperation — one atomic transaction leg', () => {
it('uses the provider updateItem path and rolls back to the old vector in place', async () => {
const index = makeIndex()
await fillIndex(index, 30)
const target = 'n-9'
const oldVector = vec(9)
const newVector = vec(800)
const removeSpy = vi.spyOn(index, 'removeItem')
const op = new ReplaceInVectorIndexOperation(index, target, oldVector, newVector)
expect(op.name).toBe('ReplaceInVectorIndex(hnsw-js)')
const rollback = await op.execute()
expect(removeSpy).not.toHaveBeenCalled()
expect((await index.search(newVector, 1))[0][0]).toBe(target)
await rollback()
expect(removeSpy).not.toHaveBeenCalled()
expect(index.size()).toBe(30)
// Old vector restored, element-wise, and searchable again.
const restored = nounsOf(index).get(target)!.vector
expect(restored.length).toBe(oldVector.length)
for (let i = 0; i < oldVector.length; i++) {
expect(restored[i]).toBe(oldVector[i])
}
const back = await index.search(oldVector, 1)
expect(back[0][0]).toBe(target)
expect(back[0][1]).toBeCloseTo(0, 10)
removeSpy.mockRestore()
})
it('falls back to remove+add ADJACENT within the single op for a provider without updateItem, and rolls back the same way', async () => {
// A provider that has not shipped updateItem — the temporary seam: the
// pair stays adjacent inside ONE op (no other transaction operation can
// interleave), until the provider ships its own in-place updateItem.
const calls: string[] = []
const store = new Map<string, Vector>()
const legacyProvider = {
name: 'legacy-native',
addItem: async (item: VectorDocument) => {
calls.push(`add:${item.id}`)
store.set(item.id, item.vector)
return item.id
},
removeItem: async (id: string) => {
calls.push(`remove:${id}`)
return store.delete(id)
},
search: async () => [],
size: () => store.size,
clear: () => store.clear(),
rebuild: async () => {},
flush: async () => 0,
getPersistMode: () => 'immediate' as const
} as unknown as VectorIndexProvider
store.set('x', [1, 0])
const op = new ReplaceInVectorIndexOperation(legacyProvider, 'x', [1, 0], [0, 1])
const rollback = await op.execute()
expect(calls).toEqual(['remove:x', 'add:x'])
expect(store.get('x')).toEqual([0, 1])
await rollback()
expect(calls).toEqual(['remove:x', 'add:x', 'remove:x', 'add:x'])
expect(store.get('x')).toEqual([1, 0])
})
})
describe('brain.update() — the update path stages ONE atomic vector-index leg', () => {
it('a type-only update (unchanged vector — the production flicker shape) never calls removeItem on the vector index', async () => {
const brain = new Brainy(createTestConfig())
await brain.init()
try {
const id = await brain.add(
createAddParams({ data: 'atomic flicker guard entity', type: 'thing' })
)
const index = (brain as unknown as { index: JsHnswVectorIndex }).index
const removeSpy = vi.spyOn(index, 'removeItem')
const sizeBefore = index.size()
await brain.update({ id, type: 'document' })
expect(removeSpy).not.toHaveBeenCalled()
expect(index.size()).toBe(sizeBefore)
const updated = await brain.get(id)
expect(updated).not.toBeNull()
expect(updated!.type).toBe('document')
removeSpy.mockRestore()
} finally {
await brain.close()
}
})
it('a genuine vector change on update also never calls removeItem (in-place replace)', async () => {
const brain = new Brainy(createTestConfig())
await brain.init()
try {
const id = await brain.add(
createAddParams({ data: 'vector change stays visible', type: 'thing' })
)
const existing = await brain.get(id, { includeVectors: true })
// Same dimensionality, guaranteed-different content.
const changed = existing!.vector.map((x: number, i: number) => (i === 0 ? x + 0.25 : x))
const index = (brain as unknown as { index: JsHnswVectorIndex }).index
const removeSpy = vi.spyOn(index, 'removeItem')
await brain.update({ id, vector: changed })
expect(removeSpy).not.toHaveBeenCalled()
expect(nounsOf(index).has(id)).toBe(true)
removeSpy.mockRestore()
} finally {
await brain.close()
}
})
})