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:
David Snelling 2026-07-13 13:08:52 -07:00
parent 36d4e80ba2
commit d0f69c731f
9 changed files with 654 additions and 9 deletions

View file

@ -0,0 +1,59 @@
/**
* @module tests/unit/get-index-status-readiness
* @description Pattern-A / Finding 9: getIndexStatus reported `populated: size>0`
* and only `migrating`, so a native index that loaded its count but not its
* serving structure reported populated:true a k8s readiness probe would 200 a
* brain that serves []. It now folds in the honest isReady() signal + the
* _indexRebuildFailed / _indexDegradedIds degraded states (mirroring
* validateIndexConsistency / checkHealth).
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy, NounType } from '../../src/index.js'
describe('getIndexStatus honest readiness (Finding 9)', () => {
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({ data: 'x', type: NounType.Concept })
await brain.flush()
})
it('a not-ready provider makes populated honest (false) and exposes ready:false', async () => {
brain.index.isReady = () => false // count present, serving structure NOT loaded
const status = await brain.getIndexStatus()
expect(status.hnswIndex.populated).toBe(false)
expect(status.hnswIndex.ready).toBe(false)
delete brain.index.isReady
})
it('a ready provider reports populated:true + ready:true', async () => {
brain.index.isReady = () => true
const status = await brain.getIndexStatus()
expect(status.hnswIndex.populated).toBe(true)
expect(status.hnswIndex.ready).toBe(true)
delete brain.index.isReady
})
it('rebuildFailed / rebuildError surface the init-degraded state', async () => {
brain._indexRebuildFailed = new Error('boom')
const status = await brain.getIndexStatus()
expect(status.rebuildFailed).toBe(true)
expect(status.rebuildError).toBe('boom')
brain._indexRebuildFailed = null
})
it('degradedIds surfaces the adopt-forward degraded set', async () => {
brain._indexDegradedIds.add('00000000-0000-4000-8000-0000000000de')
const status = await brain.getIndexStatus()
expect(status.degradedIds).toBe(1)
brain._indexDegradedIds.clear()
})
it('a JS-baseline provider (no isReady) omits ready and falls back to size>0', async () => {
const status = await brain.getIndexStatus()
expect(status.hnswIndex.ready).toBeUndefined()
expect(status.hnswIndex.populated).toBe(brain.index.size() > 0)
})
})

View file

@ -0,0 +1,95 @@
/**
* @module tests/unit/graph/graph-fastpath-honest-readiness
* @description Pattern-A / Finding 2: getVerbsBySource/ByTarget gated the graph
* fast path on `graphIndex.isInitialized` (a proxy that reads true once the
* manifest/count loaded even if the sourcetarget adjacency did NOT the fast
* path then returned a silent []). The honest gate skips the fast path when the
* provider reports isReady()===false, falling to the correct canonical shard
* scan; and a one-shot probe self-heals a no-isReady provider whose adjacency
* did not cold-load.
*/
import { describe, it, expect, beforeEach } from 'vitest'
import { Brainy, NounType, VerbType } from '../../../src/index.js'
describe('graph fast-path honest readiness (Finding 2)', () => {
let brain: any
let storage: any
let a: string
let b: string
let c: string
beforeEach(async () => {
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, dimensions: 384 })
await brain.init()
a = await brain.add({ data: 'a', type: NounType.Concept })
b = await brain.add({ data: 'b', type: NounType.Concept })
c = await brain.add({ data: 'c', type: NounType.Concept })
await brain.relate({ from: a, to: b, type: VerbType.Contains })
await brain.relate({ from: a, to: c, type: VerbType.Contains })
await brain.flush()
storage = brain.storage
// Warm + wire the storage graph index (lazy) so we can stub it.
await storage.getVerbsBySource(a)
})
it('not-ready provider → shard scan returns the REAL edges, not a silent []', async () => {
const gi = storage.graphIndex
// Simulate a cold native provider: count/manifest loaded (isInitialized) but
// the source→target adjacency is NOT (isReady false; fast-path lookup empty).
gi.isReady = () => false
const origBySource = gi.getVerbIdsBySource.bind(gi)
gi.getVerbIdsBySource = async () => [] // cold adjacency — old fast path trusted this
storage._graphFastPathProbed = true // isolate the GATE (probe skips isReady-capable anyway)
try {
const verbs = await storage.getVerbsBySource(a)
// Honest gate skipped the cold fast path → canonical shard scan → real edges.
expect(verbs.length).toBe(2)
} finally {
delete gi.isReady
gi.getVerbIdsBySource = origBySource
}
})
it('ready provider → fast path is used and an empty result is trusted', async () => {
const gi = storage.graphIndex
gi.isReady = () => true
let fastPathCalls = 0
const origBySource = gi.getVerbIdsBySource.bind(gi)
gi.getVerbIdsBySource = async (...args: any[]) => { fastPathCalls++; return origBySource(...args) }
storage._graphFastPathProbed = true
try {
// `c` has no OUTGOING edges — a genuinely empty result via the fast path.
const verbs = await storage.getVerbsBySource(c)
expect(verbs).toEqual([])
expect(fastPathCalls).toBeGreaterThan(0) // fast path was taken, not a shard scan
} finally {
delete gi.isReady
gi.getVerbIdsBySource = origBySource
}
})
it('no-isReady provider whose adjacency did not cold-load → probe rebuilds once', async () => {
const gi = storage.graphIndex
if ('isReady' in gi) delete gi.isReady // force the "unknown" (no honest signal) path
// Force the probe to see a cold adjacency: a known edge resolves to no ints.
const origBySource = gi.getVerbIdsBySource.bind(gi)
let cold = true
gi.getVerbIdsBySource = async (...args: any[]) => (cold ? [] : origBySource(...args))
let rebuilds = 0
const origRebuild = gi.rebuild.bind(gi)
gi.rebuild = async (...args: any[]) => { rebuilds++; cold = false; return origRebuild(...args) }
storage._graphFastPathProbed = false // re-arm the one-shot probe
try {
await storage.getVerbsBySource(a)
expect(rebuilds).toBe(1) // probe detected cold adjacency + rebuilt once
expect(storage._graphFastPathProbed).toBe(true) // latched — no second rebuild
rebuilds = 0
await storage.getVerbsByTarget(b)
expect(rebuilds).toBe(0) // probe does not re-run
} finally {
gi.getVerbIdsBySource = origBySource
gi.rebuild = origRebuild
}
})
})

View 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()
})
})