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
95
tests/unit/graph/graph-fastpath-honest-readiness.test.ts
Normal file
95
tests/unit/graph/graph-fastpath-honest-readiness.test.ts
Normal 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 source→target 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
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue