fix: honest index readiness — no silently-empty queries on a cold index
Closes the "dishonest readiness proxy" anti-pattern (Pattern A): size()>0 /
isInitialized were treated as "this index serves queries", but a cold native
index can load its COUNT before its SERVING structure, so a query returned a
silent [] indistinguishable from "no such data". A shared assessIndexReadiness()
now reads only the provider's honest isReady() signal (never size()), applied at
every site:
- Vector: a one-shot verifyVectorLive() guard on the semantic/proximity search
path (a pure semantic find({query}) has no filter, so nothing guarded it). It
prefers isReady(), else a known-vector self-match probe; self-heals via rebuild
or throws the new VectorIndexNotReadyError instead of a silent [].
- Graph: getVerbsBySource/ByTarget skip the fast path when the provider reports
not-ready (falling to the canonical shard scan), plus a one-shot probe that
self-heals a no-isReady provider whose adjacency did not cold-load.
- getIndexStatus(): folds in per-index honest `ready` (making `populated`
honest) + rebuildFailed/rebuildError/degradedIds, so a readiness probe never
200s a brain that is still warming up or degraded.
Unblocked by the native providers now reporting serving-truth (graph via
SSTable-residency readiness, vector via durableBaseLoadFailed). New export:
VectorIndexNotReadyError. getIndexStatus gains additive fields. No breaking API.
13 new tests; existing readiness guards green.
This commit is contained in:
parent
36d4e80ba2
commit
d0f69c731f
9 changed files with 654 additions and 9 deletions
115
tests/unit/vector-cold-read-guard.test.ts
Normal file
115
tests/unit/vector-cold-read-guard.test.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @module tests/unit/vector-cold-read-guard
|
||||
* @description Pattern-A / Finding 1: a pure semantic find({ query }) has no
|
||||
* filter, so verifyMetadataLive never fires — nothing guarded the vector index.
|
||||
* A cold native vector index that loaded its COUNT but not its serving structure
|
||||
* returned a silent []. verifyVectorLive() closes that: honest isReady() first,
|
||||
* else a known-vector self-match probe; self-heal (rebuild) or throw
|
||||
* VectorIndexNotReadyError — never a silent empty result.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { Brainy, NounType, VectorIndexNotReadyError } from '../../src/index.js'
|
||||
|
||||
const V = (): number[] => Array.from({ length: 384 }, (_, i) => Math.sin(i * 0.1) + 0.001)
|
||||
|
||||
describe('Vector cold-read guard (verifyVectorLive) — silent-[] on cold semantic find', () => {
|
||||
let brain: any
|
||||
beforeEach(async () => {
|
||||
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, dimensions: 384 })
|
||||
await brain.init()
|
||||
await brain.add({ vector: V(), type: NounType.Concept, metadata: { status: 'active' } })
|
||||
await brain.add({ vector: V(), type: NounType.Concept, metadata: { status: 'archived' } })
|
||||
await brain.flush()
|
||||
})
|
||||
|
||||
it('warm brain: semantic find is correct and the guard does not rebuild', async () => {
|
||||
const vi = brain.index
|
||||
let rebuilds = 0
|
||||
const origRebuild = vi.rebuild.bind(vi)
|
||||
vi.rebuild = async (...a: any[]) => { rebuilds++; return origRebuild(...a) }
|
||||
await brain.find({ query: 'anything', searchMode: 'semantic', limit: 100 })
|
||||
expect(rebuilds).toBe(0)
|
||||
expect(brain._vectorVerified).toBe(true)
|
||||
vi.rebuild = origRebuild
|
||||
})
|
||||
|
||||
it('cold index: verifyVectorLive self-heals via rebuild — semantic find is correct, NOT silent []', async () => {
|
||||
const vi = brain.index
|
||||
const origSearch = vi.search.bind(vi)
|
||||
const origRebuild = vi.rebuild.bind(vi)
|
||||
let cold = true
|
||||
brain._vectorVerified = false
|
||||
// size()>0 (count present) but search returns nothing until a rebuild warms it.
|
||||
vi.search = async (...a: any[]) => (cold ? [] : origSearch(...a))
|
||||
vi.rebuild = async (...a: any[]) => { await origRebuild(...a); cold = false }
|
||||
try {
|
||||
const res = await brain.find({ query: 'x', searchMode: 'semantic', limit: 100 })
|
||||
expect(res.length).toBeGreaterThan(0) // self-healed
|
||||
} finally {
|
||||
vi.search = origSearch; vi.rebuild = origRebuild
|
||||
}
|
||||
})
|
||||
|
||||
it('unrecoverably cold index: semantic find throws VectorIndexNotReadyError', async () => {
|
||||
const vi = brain.index
|
||||
const origSearch = vi.search.bind(vi)
|
||||
const origRebuild = vi.rebuild.bind(vi)
|
||||
brain._vectorVerified = false
|
||||
vi.search = async () => [] // always cold; rebuild can't fix it
|
||||
vi.rebuild = async () => {}
|
||||
try {
|
||||
await expect(
|
||||
brain.find({ query: 'x', searchMode: 'semantic', limit: 100 })
|
||||
).rejects.toBeInstanceOf(VectorIndexNotReadyError)
|
||||
} finally {
|
||||
vi.search = origSearch; vi.rebuild = origRebuild
|
||||
}
|
||||
})
|
||||
|
||||
it('native provider reporting isReady()===false rebuilds, then serves', async () => {
|
||||
const vi = brain.index
|
||||
const origRebuild = vi.rebuild.bind(vi)
|
||||
let ready = false
|
||||
brain._vectorVerified = false
|
||||
vi.isReady = () => ready
|
||||
vi.rebuild = async (...a: any[]) => { await origRebuild(...a); ready = true }
|
||||
try {
|
||||
const res = await brain.find({ query: 'x', searchMode: 'semantic', limit: 100 })
|
||||
expect(ready).toBe(true) // rebuild ran because isReady() was false
|
||||
expect(res).toBeDefined()
|
||||
} finally {
|
||||
delete vi.isReady; vi.rebuild = origRebuild
|
||||
}
|
||||
})
|
||||
|
||||
it('a text-only query does not trigger the vector guard', async () => {
|
||||
brain._vectorVerified = false
|
||||
await brain.find({ query: 'active', searchMode: 'text', limit: 5 })
|
||||
expect(brain._vectorVerified).toBe(false) // executeVectorSearch never called
|
||||
})
|
||||
|
||||
// Regression: the probe must check "returns ANY hit", not an exact self-match —
|
||||
// HNSW is approximate and get() may re-hydrate the vector, so a healthy
|
||||
// many-entity index would false-positive under an exact-self check, wrongly
|
||||
// rebuild, and throw VectorIndexNotReadyError on working data.
|
||||
it('a healthy many-entity brain with distinct vectors serves semantic find, never throws/rebuilds', async () => {
|
||||
const many = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, dimensions: 384 })
|
||||
await many.init()
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const v = Array.from({ length: 384 }, (_, j) => Math.sin((i * 7 + j) * 0.13) + 0.001)
|
||||
await many.add({ vector: v, type: NounType.Concept, metadata: { n: i } })
|
||||
}
|
||||
await many.flush()
|
||||
let rebuilds = 0
|
||||
const vi = (many as any).index
|
||||
const origRebuild = vi.rebuild.bind(vi)
|
||||
vi.rebuild = async (...a: any[]) => { rebuilds++; return origRebuild(...a) }
|
||||
const res = await many.find({ query: 'x', searchMode: 'semantic', limit: 10 })
|
||||
expect(res.length).toBeGreaterThan(0)
|
||||
expect(rebuilds).toBe(0)
|
||||
expect((many as any)._vectorVerified).toBe(true)
|
||||
vi.rebuild = origRebuild
|
||||
await many.close()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue