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.
This commit is contained in:
parent
3236a01bef
commit
ebe06cdf33
7 changed files with 937 additions and 57 deletions
75
tests/unit/brainy/lazy-notready-honor.test.ts
Normal file
75
tests/unit/brainy/lazy-notready-honor.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* @module tests/unit/brainy/lazy-notready-honor
|
||||
* @description THE SILENT-EMPTY TRAP pin (found during a fleet adoption,
|
||||
* SELF-ENGINE-PAIR-STANDARD): under `disableAutoRebuild: true`, the lazy
|
||||
* first-query path (`ensureIndexesLoaded`) assessed ONLY the vector index's
|
||||
* readiness — a native METADATA provider reporting not-ready (its strand
|
||||
* report) never blocked the completion latch, so the promised lazy rebuild
|
||||
* never fired and every `find()` silently returned `[]` on a populated
|
||||
* store (measured: 52 entities durable-but-unqueryable, first query
|
||||
* 0ms/0 rows). The law: a not-ready report from ANY provider falls through
|
||||
* to the rebuild — never a silent empty.
|
||||
*
|
||||
* White-box provider-double pattern per tests/unit/brainy/migration-deference.
|
||||
*/
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest'
|
||||
import { Brainy } from '../../../src/index.js'
|
||||
import { NounType } from '../../../src/types/graphTypes.js'
|
||||
import { createTestConfig } from '../../helpers/test-factory.js'
|
||||
|
||||
interface BrainInternals {
|
||||
index: { size(): number }
|
||||
metadataIndex: { isReady?: () => boolean }
|
||||
lazyRebuildCompleted: boolean
|
||||
ensureIndexesLoaded(): Promise<void>
|
||||
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
|
||||
}
|
||||
|
||||
const brains: Brainy[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const b of brains.splice(0)) await b.close().catch(() => {})
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function warmLazyBrain(): Promise<{ brain: Brainy; internals: BrainInternals }> {
|
||||
const brain = new Brainy(createTestConfig({ disableAutoRebuild: true }))
|
||||
await brain.init()
|
||||
brains.push(brain)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await brain.add({ data: `row ${i}`, type: NounType.Document, metadata: { i } })
|
||||
}
|
||||
const internals = brain as unknown as BrainInternals
|
||||
internals.lazyRebuildCompleted = false // simulate the cold first query
|
||||
return { brain, internals }
|
||||
}
|
||||
|
||||
describe('lazy path honors EVERY provider’s not-ready report', () => {
|
||||
it('a not-ready METADATA provider blocks the completion latch and fires the rebuild', async () => {
|
||||
const { internals } = await warmLazyBrain()
|
||||
|
||||
// The trap's shape: vector side looks fine (populated), metadata
|
||||
// provider says NOT ready — the old gate latched complete here.
|
||||
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => false
|
||||
const rebuildSpy = vi
|
||||
.spyOn(internals, 'rebuildIndexesIfNeeded')
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
|
||||
expect(rebuildSpy, 'not-ready metadata provider must fire the lazy rebuild').toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('control: all providers ready/unknown+populated → latch completes, no rebuild', async () => {
|
||||
const { internals } = await warmLazyBrain()
|
||||
;(internals.metadataIndex as { isReady?: () => boolean }).isReady = () => true
|
||||
const rebuildSpy = vi
|
||||
.spyOn(internals, 'rebuildIndexesIfNeeded')
|
||||
.mockResolvedValue(undefined)
|
||||
|
||||
await internals.ensureIndexesLoaded()
|
||||
|
||||
expect(rebuildSpy).not.toHaveBeenCalled()
|
||||
expect(internals.lazyRebuildCompleted).toBe(true)
|
||||
})
|
||||
})
|
||||
366
tests/unit/hnsw/update-item-atomic.test.ts
Normal file
366
tests/unit/hnsw/update-item-atomic.test.ts
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
/**
|
||||
* @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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in a new issue