From 229b0679fc5d09e63dc7324095da17b4918338d2 Mon Sep 17 00:00:00 2001 From: David Snelling Date: Tue, 30 Jun 2026 09:30:11 -0700 Subject: [PATCH] fix(8.0): never serve a silent [] from find({connected}) on a cold-loaded graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8.0 shipped with no cold-graph guard (the 7.33.4 fix was never ported), so on a cold open of a large brain where the native adjacency reports membership but its source->target edges did not load, find({connected})/neighbors()/related() could silently return [] for persisted edges. Add the converged isReady() contract: GraphIndexProvider.isReady?(): boolean is the honest cold-load readiness signal (true ONLY when edges are loaded), so brainy gates on it instead of the lying membership-size() proxy. verifyGraphAdjacencyLive() checks it — Strategy 1: false -> hydrate the id-mapper, rebuild from storage, re-check, throw GraphIndexNotReadyError if still not ready; providers without isReady() fall back to a global known-edge-sample probe (Strategy 2, not the queried anchor, so a genuinely edgeless node still returns []). rebuildIndexesIfNeeded gates the graph rebuild on it too, with the id-mapper hydrated before the adjacency rebuild on the lazy cold-open path (the CTX-BR-RESTORE-REBUILD ordering, shared with restore()). executeGraphSearch re-verifies before trusting an empty connected result and re-collects on a heal. 6-case integration test + 1718 unit green. --- src/brainy.ts | 388 +++++++++++++----- src/errors/brainyError.ts | 30 ++ src/plugin.ts | 14 + .../cold-graph-connected-8.0.test.ts | 208 ++++++++++ 4 files changed, 542 insertions(+), 98 deletions(-) create mode 100644 tests/integration/cold-graph-connected-8.0.test.ts diff --git a/src/brainy.ts b/src/brainy.ts index 3ad695c3..e8a38607 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -161,7 +161,7 @@ import { import { GenerationStore } from './db/generationStore.js' import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js' import { GenerationConflictError } from './db/errors.js' -import { BrainyError } from './errors/brainyError.js' +import { BrainyError, GraphIndexNotReadyError } from './errors/brainyError.js' import { MemoryStorage } from './storage/adapters/memoryStorage.js' import type { CompactHistoryOptions, @@ -403,6 +403,10 @@ export class Brainy implements BrainyInterface { private _indexRebuildFailed: Error | null = null /** One-shot guard so the metadata cold-open consistency probe runs once per brain. */ private _metadataConsistencyProbed = false + /** Graph-adjacency cold-load consistency: verified-live this session (one-shot). */ + private _graphAdjacencyVerified = false + /** Re-entrancy guard: a verify (rebuild → reads) is in flight. */ + private _graphAdjacencyVerifying = false // Sub-APIs (lazy-loaded) private _nlp?: NaturalLanguageProcessor @@ -2664,6 +2668,153 @@ export class Brainy implements BrainyInterface { return this.entityIntsToUuids(neighborInts) } + /** + * @description Hydrate the entity id-mapper from persisted state BEFORE a graph + * rebuild. A native int-keyed adjacency resolves every verb endpoint + * (`sourceId`/`targetId` → stable int) through this mapper, so it must reflect + * the persisted int assignments before `graphIndex.rebuild()` runs — otherwise + * edges resolve to stale/missing ints and are silently dropped + * (CTX-BR-RESTORE-REBUILD). The JS mapper has no `rebuild()` and needs no + * reload (it is re-derived by `MetadataIndex.rebuild()` via append-only + * `getOrAssign`), so this is a no-op for it. Mirrors the ordering in + * {@link restore}. + */ + private async hydrateIdMapperForGraphRebuild(): Promise { + const idMapper = this.metadataIndex.getIdMapper() as { rebuild?: () => Promise } + if (typeof idMapper.rebuild === 'function') { + await idMapper.rebuild() + } + } + + /** + * @description Verify that the graph adjacency is actually LIVE before a graph read trusts + * its result. A native graph index can load its relationship COUNT (manifest) on a cold open + * of a LARGE brain (≥10k nouns, which skips the eager index rebuild) but NOT its + * source→target adjacency, so `getNeighbors()` returns `[]` for EVERY source even though + * edges are persisted — and `find({ connected })` / `neighbors()` / `related()` would serve + * that `[]` as if it were truth. + * + * Two detection strategies, in order of honesty: + * - **Preferred (8.0 contract):** the provider exposes a sync `isReady()` that is true ONLY + * when the edges are loaded. `false` → hydrate the id-mapper (a native int adjacency + * resolves endpoints through it), rebuild from storage, and re-check `isReady()`; if it is + * still `false`, throw {@link GraphIndexNotReadyError} rather than returning `[]`. + * - **Fallback (providers without `isReady()`):** a GLOBAL known-edge sample (a real + * persisted verb's `sourceId`, which by definition HAS an outgoing edge) — NOT any queried + * anchor, because brainy cannot cheaply tell "adjacency unloaded" from "this node is + * genuinely edgeless" per-anchor. If that known-edge source resolves to no neighbors, the + * adjacency did not load: rebuild and re-probe; if even that fails, throw. + * + * @returns `'live'` when the adjacency is already trustworthy (or there is genuinely nothing + * to verify), or `'rebuilt'` when a cold-unloaded adjacency was just healed from storage — + * in which case callers that observed an empty result must RE-RUN their collection. + * @throws {GraphIndexNotReadyError} when the index claims edges but cannot serve a known + * persisted edge (or stays not-ready) even after a rebuild. + */ + private async verifyGraphAdjacencyLive(): Promise<'live' | 'rebuilt'> { + if (this._graphAdjacencyVerified) return 'live' + // Re-entrancy: rebuild() can trigger reads (neighbors/related) that call back into this + // guard. While a verify is in flight, short-circuit so we cannot recurse into rebuild(). + if (this._graphAdjacencyVerifying) return 'live' + this._graphAdjacencyVerifying = true + try { + const gi = this.graphIndex as GraphAdjacencyIndex & { isReady?: () => boolean } + + // ── Strategy 1: honest isReady() signal (cortex >= 2.7.8 / 3.0) ────────── + if (typeof gi.isReady === 'function') { + if (gi.isReady()) { + this._graphAdjacencyVerified = true + return 'live' + } + // Not ready: the edges did not load on open. Hydrate the id-mapper, then rebuild. + if (!this.config.silent) { + console.warn( + `[Brainy] Graph adjacency reports not-ready (isReady() === false) — the persisted ` + + `adjacency did not load on open. Rebuilding from storage…` + ) + } + await this.hydrateIdMapperForGraphRebuild() + await this.graphIndex.rebuild() + if (gi.isReady()) { + this._graphAdjacencyVerified = true + return 'rebuilt' + } + throw new GraphIndexNotReadyError( + `Graph adjacency index reports not-ready even after a rebuild — the persisted ` + + `adjacency could not be loaded. find({ connected }), neighbors() and related() ` + + `cannot be served reliably for this brain.` + ) + } + + // ── Strategy 2: known-edge-sample probe (providers without isReady()) ──── + const claimed = await this.graphIndex.size() + if (!claimed || claimed <= 0) return 'live' // no edges claimed — nothing to verify + + const sample = await this.storage.getVerbs({ pagination: { limit: 1 } }) + const verb = sample.items?.[0] + if (!verb || !verb.sourceId) { + // no edges in storage — stale count, harmless; don't re-probe on every read. + this._graphAdjacencyVerified = true + return 'live' + } + + // Resolve the known edge's source through THIS brain's id-mapper. An unmapped source means + // the sample is not one of this brain's own edges — e.g. a shared on-disk store reused + // across instances surfaces a foreign verb whose UUID this brain's resident mapper never + // interned. We cannot prove a cold-unloaded adjacency from such a sample, so treat it as + // INCONCLUSIVE: mark verified and return 'live' rather than rebuilding/throwing. (The honest + // cold-load signal for native providers is isReady(), checked above; the JS baseline keeps + // its mapper resident, so its OWN edges always resolve — the targeted 7.x failure mode, + // "mapper loaded but adjacency empty", still resolves the source and is detected below.) + const sourceInt = this.graphEntityInt(verb.sourceId) + if (sourceInt === undefined) { + this._graphAdjacencyVerified = true + return 'live' + } + + // Ask the adjacency for ONE neighbor of the (mapped) known-edge source. + const probeKnownSource = async (): Promise => + (await this.graphIndex.getNeighbors(sourceInt, { limit: 1 })).length > 0 + + if (await probeKnownSource()) { + this._graphAdjacencyVerified = true + return 'live' // adjacency is live — the common case + } + + // INCONSISTENT: the index reports edges but a KNOWN-mapped persisted edge's source has none → + // the adjacency did not load on open. Hydrate the mapper and rebuild from storage. + if (!this.config.silent) { + console.warn( + `[Brainy] Graph adjacency reports ${claimed} relationship(s) but a persisted edge ` + + `resolves to none — the persisted adjacency did not load on open. Rebuilding from storage…` + ) + } + await this.hydrateIdMapperForGraphRebuild() + await this.graphIndex.rebuild() + + if (await probeKnownSource()) { + this._graphAdjacencyVerified = true + return 'rebuilt' + } + throw new GraphIndexNotReadyError( + `Graph adjacency index reports ${claimed} relationship(s) but returns no edges even ` + + `after a rebuild — the persisted adjacency could not be loaded. find({ connected }), ` + + `neighbors() and related() cannot be served reliably for this brain.` + ) + } catch (err) { + if (err instanceof GraphIndexNotReadyError) throw err + // A transient probe/rebuild failure must not break the actual query NOR be + // masked as "no data". Allow a re-check on the next graph read and fall through. + this._graphAdjacencyVerified = false + if (!this.config.silent) { + console.warn(`[Brainy] Graph adjacency consistency check skipped (transient): ${err}`) + } + return 'live' + } finally { + this._graphAdjacencyVerifying = false + } + } + // ------------------------------------------------------------------------- /** @@ -3197,6 +3348,7 @@ export class Brainy implements BrainyInterface { paramsOrId?: string | RelatedParams ): Promise[]> { await this.ensureInitialized() + await this.verifyGraphAdjacencyLive() // Handle string ID shorthand: related(id) -> related({ from: id }) const rawParams = typeof paramsOrId === 'string' @@ -6916,10 +7068,7 @@ export class Brainy implements BrainyInterface { // JS mapper has no `rebuild()` and needs no reload: MetadataIndex.rebuild() // re-derives it from the restored entities via append-only getOrAssign, // consistently with the bitmaps it builds. - const idMapper = this.metadataIndex.getIdMapper() as { rebuild?: () => Promise } - if (typeof idMapper.rebuild === 'function') { - await idMapper.rebuild() - } + await this.hydrateIdMapperForGraphRebuild() // Every in-memory index is now stale — rebuild all three from the restored // canonical records (each rebuild clears its own state first). The graph @@ -11041,6 +11190,7 @@ export class Brainy implements BrainyInterface { } ): Promise { await this.ensureInitialized() + await this.verifyGraphAdjacencyLive() const direction = options?.direction || 'both' const limit = options?.limit @@ -11577,111 +11727,133 @@ export class Brainy implements BrainyInterface { const connectedIds = new Set() - if (subtypeFilter !== undefined) { - // Multi-hop BFS with per-hop (verbType, subtype) predicate. Works on the - // open-core JS path at any depth. When a graph-index provider exposes a - // faster `findConnectedSubtype` (native BFS with the same semantics), - // `nativeSubtypeBfs` routes through it transparently — same pattern as - // every other provider hook. - const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter] - const subtypeSet = new Set(subtypeArr) + // Population is extracted into a closure so it can be re-run VERBATIM after a + // cold-load heal (verdict 'rebuilt') — re-executing the SAME traversal once the + // adjacency is actually loaded, without changing any collection semantics. + const populate = async (): Promise => { + connectedIds.clear() + if (subtypeFilter !== undefined) { + // Multi-hop BFS with per-hop (verbType, subtype) predicate. Works on the + // open-core JS path at any depth. When a graph-index provider exposes a + // faster `findConnectedSubtype` (native BFS with the same semantics), + // `nativeSubtypeBfs` routes through it transparently — same pattern as + // every other provider hook. + const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter] + const subtypeSet = new Set(subtypeArr) - // Native fast path (D.3): single verb type + single subtype + outgoing - // walk match the native BFS semantics exactly (out-edges only, source - // excluded, visited-set cycle guard). Entity ints in, entity ints out — - // UUID conversion stays at this boundary. Returns null when the query - // shape (or provider) can't take the native route. - const nativeSubtypeBfs = async ( - anchor: string, - walk: 'in' | 'out' | 'both' - ): Promise | null> => { - if (walk !== 'out') return null - if (via === undefined || Array.isArray(via)) return null - if (subtypeArr.length !== 1) return null - const provider = this.graphIndex as Partial<{ - findConnectedSubtype( - sourceInt: bigint, - verbTypeIndex: number, - subtype: string | null, - depth: number, - limit?: number | null - ): Promise - }> - if (typeof provider.findConnectedSubtype !== 'function') return null + // Native fast path (D.3): single verb type + single subtype + outgoing + // walk match the native BFS semantics exactly (out-edges only, source + // excluded, visited-set cycle guard). Entity ints in, entity ints out — + // UUID conversion stays at this boundary. Returns null when the query + // shape (or provider) can't take the native route. + const nativeSubtypeBfs = async ( + anchor: string, + walk: 'in' | 'out' | 'both' + ): Promise | null> => { + if (walk !== 'out') return null + if (via === undefined || Array.isArray(via)) return null + if (subtypeArr.length !== 1) return null + const provider = this.graphIndex as Partial<{ + findConnectedSubtype( + sourceInt: bigint, + verbTypeIndex: number, + subtype: string | null, + depth: number, + limit?: number | null + ): Promise + }> + if (typeof provider.findConnectedSubtype !== 'function') return null - const anchorInt = this.graphEntityInt(anchor) - if (anchorInt === undefined) return new Set() // unmapped → no relations + const anchorInt = this.graphEntityInt(anchor) + if (anchorInt === undefined) return new Set() // unmapped → no relations - const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType) - // No limit: match the JS BFS exactly — overall result limiting happens - // downstream against existingResults. - const reachedInts = await provider.findConnectedSubtype( - anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null - ) - return new Set(this.entityIntsToUuids(reachedInts)) - } + const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType) + // No limit: match the JS BFS exactly — overall result limiting happens + // downstream against existingResults. + const reachedInts = await provider.findConnectedSubtype( + anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null + ) + return new Set(this.entityIntsToUuids(reachedInts)) + } - const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise> => { - const nativeResult = await nativeSubtypeBfs(anchor, walk) - if (nativeResult !== null) return nativeResult + const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise> => { + const nativeResult = await nativeSubtypeBfs(anchor, walk) + if (nativeResult !== null) return nativeResult - const visited = new Set([anchor]) - const reached = new Set() - let frontier = new Set([anchor]) + const visited = new Set([anchor]) + const reached = new Set() + let frontier = new Set([anchor]) - for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) { - const nextFrontier = new Set() - for (const node of frontier) { - // Walk outgoing edges (when direction includes outbound) and incoming - // edges (when direction includes inbound). Both = union. - const dirs: Array<'from' | 'to'> = [] - if (walk === 'out' || walk === 'both') dirs.push('from') - if (walk === 'in' || walk === 'both') dirs.push('to') + for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) { + const nextFrontier = new Set() + for (const node of frontier) { + // Walk outgoing edges (when direction includes outbound) and incoming + // edges (when direction includes inbound). Both = union. + const dirs: Array<'from' | 'to'> = [] + if (walk === 'out' || walk === 'both') dirs.push('from') + if (walk === 'in' || walk === 'both') dirs.push('to') - for (const dir of dirs) { - const edges = await this.related({ - ...(dir === 'from' ? { from: node } : { to: node }), - ...(via && { type: via as VerbType | VerbType[] }), - subtype: subtypeArr, - limit: 10000 - }) - for (const edge of edges) { - // Defensive: related() honors the subtype filter, but check - // again in case a future impl widens it. - if (edge.subtype !== undefined && !subtypeSet.has(edge.subtype)) continue - const neighbor = dir === 'from' ? edge.to : edge.from - if (visited.has(neighbor)) continue - visited.add(neighbor) - reached.add(neighbor) - nextFrontier.add(neighbor) + for (const dir of dirs) { + const edges = await this.related({ + ...(dir === 'from' ? { from: node } : { to: node }), + ...(via && { type: via as VerbType | VerbType[] }), + subtype: subtypeArr, + limit: 10000 + }) + for (const edge of edges) { + // Defensive: related() honors the subtype filter, but check + // again in case a future impl widens it. + if (edge.subtype !== undefined && !subtypeSet.has(edge.subtype)) continue + const neighbor = dir === 'from' ? edge.to : edge.from + if (visited.has(neighbor)) continue + visited.add(neighbor) + reached.add(neighbor) + nextFrontier.add(neighbor) + } } } + frontier = nextFrontier } - frontier = nextFrontier + return reached } - return reached - } - if (from) { - for (const id of await bfsWithSubtype(from, direction)) connectedIds.add(id) - } - if (to) { - const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' - for (const id of await bfsWithSubtype(to, reverse)) connectedIds.add(id) - } - } else { - // No subtype filter — fast path via neighbors(), which does the same - // depth-aware BFS but only filters by verbType. - const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise => - this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via }) + if (from) { + for (const id of await bfsWithSubtype(from, direction)) connectedIds.add(id) + } + if (to) { + const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' + for (const id of await bfsWithSubtype(to, reverse)) connectedIds.add(id) + } + } else { + // No subtype filter — fast path via neighbors(), which does the same + // depth-aware BFS but only filters by verbType. + const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise => + this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via }) - if (from) { - for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id) - } + if (from) { + for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id) + } - if (to) { - const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' - for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id) + if (to) { + const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both' + for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id) + } + } + } + + await populate() + + // Cold-load guard: an empty connected set is suspicious. The native adjacency can report + // size()>0 (or isReady()===false) on a cold open yet have loaded NO source→target edges — so + // traversal silently returns []. Re-verify against the honest isReady() signal (or, for older + // providers, a GLOBAL known-edge sample — NOT the queried anchor, which may be genuinely + // edgeless). If the adjacency was dead and a rebuild healed it, re-collect; if it stays dead, + // verifyGraphAdjacencyLive() throws GraphIndexNotReadyError. A genuinely edgeless anchor + // verifies 'live' and the empty result stands — no spurious rebuild/throw. + if (connectedIds.size === 0) { + const verdict = await this.verifyGraphAdjacencyLive() + if (verdict === 'rebuilt') { + await populate() } } @@ -12806,10 +12978,20 @@ export class Brainy implements BrainyInterface { const hnswIndexSize = this.index.size() const graphIndexSize = await this.graphIndex.size() + // Graph rebuild fires when the adjacency is empty OR when the provider's + // honest readiness signal says the edges did not load on a cold open — the + // native int adjacency can report size()>0 (count/manifest loaded) yet have + // NO source→target edges, the silent-empty cold-load failure. Providers + // without isReady() keep the exact prior behavior (size()===0 only). + const graphIsReady = this.graphIndex as GraphAdjacencyIndex & { isReady?: () => boolean } + const needsGraphRebuild = + graphIndexSize === 0 || + (typeof graphIsReady.isReady === 'function' && !graphIsReady.isReady()) + const needsRebuild = metadataStats.totalEntries === 0 || hnswIndexSize === 0 || - graphIndexSize === 0 + needsGraphRebuild if (!needsRebuild && !force) { // All indexes already populated, no rebuild needed @@ -12841,13 +13023,23 @@ export class Brainy implements BrainyInterface { console.log(`${rebuildReason} - rebuilding all indexes from persisted data...`) } + // Before the graph rebuild, hydrate the entity id-mapper from the persisted + // snapshot. A native int-keyed adjacency resolves every verb endpoint through + // this mapper, so it must reflect the persisted int assignments BEFORE + // graphIndex.rebuild() runs — otherwise edges resolve to stale/missing ints + // and are silently dropped (CTX-BR-RESTORE-REBUILD). Mirrors restore()'s + // ordering; a no-op for the JS mapper (re-derived by metadataIndex.rebuild()). + if (needsGraphRebuild) { + await this.hydrateIdMapperForGraphRebuild() + } + // Rebuild all 3 indexes in parallel for performance // Indexes load their data from storage (no recomputation) const rebuildStartTime = Date.now() await Promise.all([ metadataStats.totalEntries === 0 ? this.metadataIndex.rebuild() : Promise.resolve(), hnswIndexSize === 0 ? this.index.rebuild() : Promise.resolve(), - graphIndexSize === 0 ? this.graphIndex.rebuild() : Promise.resolve() + needsGraphRebuild ? this.graphIndex.rebuild() : Promise.resolve() ]) const rebuildDuration = Date.now() - rebuildStartTime diff --git a/src/errors/brainyError.ts b/src/errors/brainyError.ts index 350e98ba..712a19c7 100644 --- a/src/errors/brainyError.ts +++ b/src/errors/brainyError.ts @@ -11,6 +11,7 @@ export type BrainyErrorType = | 'RETRY_EXHAUSTED' | 'VALIDATION' | 'FIELD_NOT_INDEXED' + | 'GRAPH_INDEX_NOT_READY' /** * Custom error class for Brainy operations @@ -223,3 +224,32 @@ export class BrainyError extends Error { return BrainyError.storage(error.message, error) } } + +/** + * Thrown when the graph adjacency index reports that relationships exist (its + * persisted manifest/count loaded, or its readiness signal says otherwise) but + * the source→target adjacency itself did NOT load — so graph traversals + * (`find({ connected })`, `neighbors()`, `related()`) would otherwise return an + * EMPTY array indistinguishable from "no edges". + * + * On 8.0 brainy detects this on the first graph read via the provider's honest + * sync `isReady()` signal (true ONLY when the edges are loaded; see + * {@link import('../plugin.js').GraphIndexProvider.isReady}); for older providers + * that do not expose it, it falls back to a known-edge-sample probe (one persisted + * verb + one neighbor lookup). Either way it attempts a rebuild from storage and + * raises this LOUD, catchable error only if even that cannot make the adjacency + * ready — replacing silent data-invisibility with a clear failure. + * + * Observed with a native graph provider whose cold-open adjacency load is + * swallowed on certain storage adapters; the fix is upstream in the provider, + * but Brainy refuses to serve `[]` as if it were truth. + */ +export class GraphIndexNotReadyError extends BrainyError { + constructor(message: string, originalError?: Error) { + super(message, 'GRAPH_INDEX_NOT_READY', false, originalError) + this.name = 'GraphIndexNotReadyError' + if (Error.captureStackTrace) { + Error.captureStackTrace(this, GraphIndexNotReadyError) + } + } +} diff --git a/src/plugin.ts b/src/plugin.ts index d274d831..6ba52b74 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -250,6 +250,20 @@ export interface GraphIndexProvider { */ readonly isInitialized: boolean + /** + * @description Returns true ONLY when the source→target EDGES are loaded (NOT + * membership/manifest count) — the honest cold-load readiness signal. brainy + * gates the graph rebuild + the connected read-time guard on it: a `false` + * forces a rebuild from storage, and a still-`false` after that rebuild raises + * a loud {@link import('./errors/brainyError.js').GraphIndexNotReadyError} + * rather than serving an empty traversal as truth. cortex >= 2.7.8 (2.x) / 3.0 + * exposes it; ABSENT on older providers, where brainy falls back to a + * known-edge-sample probe. + * @returns `true` when the persisted adjacency is loaded and traversals are + * trustworthy; `false` when only the count/manifest loaded (cold-open). + */ + isReady?(): boolean + /** * @description Entity ints reachable from `id` (1 hop), deduped. * @param id - The entity's interned int (from the shared idMapper). diff --git a/tests/integration/cold-graph-connected-8.0.test.ts b/tests/integration/cold-graph-connected-8.0.test.ts new file mode 100644 index 00000000..71ae345d --- /dev/null +++ b/tests/integration/cold-graph-connected-8.0.test.ts @@ -0,0 +1,208 @@ +/** + * @module tests/integration/cold-graph-connected-8.0 + * @description BRAINY-COLD-GRAPH-CONNECTED (8.0) — regression coverage for the silent-empty + * graph-traversal bug, gated on the converged 8.0 contract: a sync `graphIndex.isReady()` that + * is true ONLY when the source→target EDGES are loaded (NOT the membership/manifest count). + * + * On the FIRST `find({ connected })` after a cold process start of a LARGE brain (≥10k nouns, + * which skips the eager index rebuild), a native graph adjacency can reload its relationship + * COUNT (so `size() > 0`) but NOT its edges — so `getNeighbors()` returns `[]` for EVERY source + * and brainy would serve that `[]` as if the anchor were genuinely edgeless. + * + * The 8.0 guard (`verifyGraphAdjacencyLive`) prefers the honest `isReady()` signal: + * - `isReady() === false` → hydrate the id-mapper, rebuild from storage, re-check; a still-false + * `isReady()` throws {@link GraphIndexNotReadyError} instead of returning `[]` ('rebuilt' when + * the rebuild heals it); + * - a genuinely edgeless anchor with `isReady() === true` verifies 'live' and the empty result + * stands — no spurious rebuild, no throw; + * - a provider WITHOUT `isReady()` falls back to the shipped 7.x known-edge-sample probe. + * + * These exercise REAL `find({ connected })` against an in-memory brain whose graph index is + * instrumented with a test-double `isReady()` (and, for the fallback case, an empty-then-healed + * `getNeighbors`). Only the readiness/edge surface is wrapped; the underlying real adjacency + * (built by `relate()`) is unmasked once a rebuild "heals" it. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { Brainy } from '../../src/index.js' +import { NounType, VerbType } from '../../src/types/graphTypes.js' +import { GraphIndexNotReadyError } from '../../src/errors/brainyError.js' +import { createTestConfig } from '../helpers/test-factory.js' + +/** + * Build a real in-memory brain. Always seeds an UNRELATED edge (`E -> F`) so a GLOBAL known-edge + * sample exists for the fallback probe even when the queried anchor is genuinely edgeless. When + * `anchorEdges` is set, the anchor links out to three target nouns via `Knows`. Ids are + * brainy-generated and returned for assertions. + */ +async function buildBrain( + opts: { anchorEdges: boolean } +): Promise<{ brain: any; anchorId: string; targetIds: string[] }> { + const brain: any = new Brainy(createTestConfig({ silent: true })) + await brain.init() + + // Unrelated edge — guarantees storage.getVerbs() always yields a known-edge source. + const eId = await brain.add({ data: 'node E', type: NounType.Person }) + const fId = await brain.add({ data: 'node F', type: NounType.Person }) + await brain.relate({ from: eId, to: fId, type: VerbType.Knows }) + + const anchorId = await brain.add({ data: 'anchor', type: NounType.Person }) + + const targetIds: string[] = [] + if (opts.anchorEdges) { + for (const label of ['B', 'C', 'D']) { + const tId = await brain.add({ data: `node ${label}`, type: NounType.Person }) + await brain.relate({ from: anchorId, to: tId, type: VerbType.Knows }) + targetIds.push(tId) + } + } + + // Fresh cold-start state: nothing verified yet. + brain._graphAdjacencyVerified = false + return { brain, anchorId, targetIds } +} + +/** + * Instrument the brain's real graph index with a test-double `isReady()` (the 8.0 contract) plus + * an edge surface that goes empty while NOT ready. `getNeighbors` returns `[]` while `!ready` + * (modelling the cold-unloaded adjacency) and delegates to the REAL index once a rebuild flips + * `ready` on. `rebuild` is counted; it heals (`ready = true`) only when `healsOnRebuild` is set. + * Pass `failFirstRebuild` to make the FIRST rebuild throw a transient error (without healing) so + * the empty-result re-collect path in executeGraphSearch is exercised. + */ +function instrumentIsReady( + brain: any, + opts: { ready: boolean; healsOnRebuild: boolean; failFirstRebuild?: boolean } +): { rebuildCalls: number } { + const gi = brain.graphIndex + const origGetNeighbors = gi.getNeighbors.bind(gi) + const state = { ready: opts.ready, rebuildCalls: 0 } + + gi.isReady = (): boolean => state.ready + + gi.getNeighbors = async (id: bigint, options?: any): Promise => + state.ready ? origGetNeighbors(id, options) : [] + + gi.rebuild = async (): Promise => { + state.rebuildCalls++ + if (opts.failFirstRebuild && state.rebuildCalls === 1) { + throw new Error('transient rebuild hiccup') + } + if (opts.healsOnRebuild) state.ready = true // unmask the real (already-populated) adjacency + } + + return state +} + +/** + * Fallback instrumentation — a provider WITHOUT `isReady()` (older cortex / JS baseline). Wraps + * `getNeighbors` to return `[]` while `broken` and delegates to the REAL index once a rebuild + * heals it. This is the shipped 7.x known-edge-sample probe path on 8.0. + */ +function instrumentNoIsReady( + brain: any, + opts: { broken: boolean; healsOnRebuild: boolean } +): { rebuildCalls: number } { + const gi = brain.graphIndex + // Ensure the provider does NOT expose isReady() — the default JS provider doesn't. + expect(typeof gi.isReady).toBe('undefined') + const origGetNeighbors = gi.getNeighbors.bind(gi) + const state = { broken: opts.broken, rebuildCalls: 0 } + + gi.getNeighbors = async (id: bigint, options?: any): Promise => + state.broken ? [] : origGetNeighbors(id, options) + + gi.rebuild = async (): Promise => { + state.rebuildCalls++ + if (opts.healsOnRebuild) state.broken = false + } + + return state +} + +describe('BRAINY-COLD-GRAPH-CONNECTED 8.0 — isReady()-gated, never serves a silent []', () => { + let brains: any[] = [] + afterEach(async () => { + for (const b of brains) { + try { + await b.close() + } catch { + /* best-effort cleanup */ + } + } + brains = [] + }) + + it('(a) isReady() false → rebuild heals it true → find({ connected }) returns correct N (rebuilt)', async () => { + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + const state = instrumentIsReady(brain, { ready: false, healsOnRebuild: true }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) // detected not-ready + healed it + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // B, C, D — the real edges, served after the heal + }) + + it('(b) isReady() stays false after rebuild → throws GraphIndexNotReadyError (NOT a silent [])', async () => { + const { brain, anchorId } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + instrumentIsReady(brain, { ready: false, healsOnRebuild: false }) // rebuild never makes it ready + + await expect( + brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + ).rejects.toBeInstanceOf(GraphIndexNotReadyError) + }) + + it('(c) edgeless anchor + isReady() true → returns [] with NO rebuild and NO throw', async () => { + // The anchor has no edges, but E -> F does — the adjacency is genuinely loaded (ready). + const { brain, anchorId } = await buildBrain({ anchorEdges: false }) + brains.push(brain) + const state = instrumentIsReady(brain, { ready: true, healsOnRebuild: false }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(results).toEqual([]) // genuinely edgeless — empty is the truth + expect(state.rebuildCalls).toBe(0) // ready adjacency → no spurious rebuild + }) + + it('(d) healthy isReady() true → correct results, NO rebuild', async () => { + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + const state = instrumentIsReady(brain, { ready: true, healsOnRebuild: false }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBe(0) + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) + }) + + it('(e) provider WITHOUT isReady() → falls back to the known-edge-sample probe (self-heals)', async () => { + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + const state = instrumentNoIsReady(brain, { broken: true, healsOnRebuild: true }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) // detected the empty adjacency + healed it + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // B, C, D — served after the heal + }) + + it('(f) executeGraphSearch re-collect: a transient first rebuild leaves connectedIds empty; the empty-result guard then heals + re-collects', async () => { + // First verify (inside neighbors()) hits a transient rebuild failure → returns 'live' without + // healing, so getNeighbors stays empty and connectedIds is empty. The empty connectedIds set + // then drives executeGraphSearch's own verify, whose rebuild now heals → 'rebuilt' → re-collect. + const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) + brains.push(brain) + const state = instrumentIsReady(brain, { ready: false, healsOnRebuild: true, failFirstRebuild: true }) + + const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) + + expect(state.rebuildCalls).toBeGreaterThanOrEqual(2) // first transient, second heals + const ids = results.map((r: any) => r.id).sort() + expect(ids).toEqual(targetIds.sort()) // re-collected after the heal + }) +})