/** * @module tests/integration/cold-graph-connected * @description BRAINY-COLD-GRAPH-CONNECTED — regression coverage for the silent-empty * graph-traversal bug. 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 source→target edges — so * `getNeighbors()` returns `[]` for EVERY source and brainy would serve that `[]` as if the * anchor were genuinely edgeless. * * The fix verifies any empty traversal against a GLOBAL known-edge sample (a real persisted * verb's `sourceId`, which by definition HAS an outgoing edge) rather than the queried anchor: * - a cold-unloaded adjacency is rebuilt from storage and the traversal re-runs ('rebuilt'); * - if even a rebuild cannot serve a known edge, it throws {@link GraphIndexNotReadyError} * instead of returning `[]`; * - a genuinely edgeless anchor (while OTHER edges exist) verifies 'live' and the empty * result stands — no spurious rebuild, no throw. * * These exercise REAL `find({ connected })` against an in-memory brain whose graph index is * instrumented to simulate the empty-then-(maybe-)rebuilt cold load — only `getNeighbors`/ * `rebuild` are 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 verify 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 (7.x requires UUID-format ids) 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 } } /** * Wrap the brain's real graph index so `getNeighbors` returns `[]` while `broken` (modelling the * cold-unloaded adjacency) and delegates to the REAL index once a rebuild flips `broken` off. * `rebuild` is counted; it heals only when `healsOnRebuild` is set. */ function instrumentGraphIndex( brain: any, opts: { broken: boolean; healsOnRebuild: boolean } ): { rebuildCalls: number } { const gi = brain.graphIndex const origGetNeighbors = gi.getNeighbors.bind(gi) const state = { broken: opts.broken, rebuildCalls: 0 } gi.getNeighbors = async (id: string, optionsOrDirection?: any): Promise => state.broken ? [] : origGetNeighbors(id, optionsOrDirection) gi.rebuild = async (): Promise => { state.rebuildCalls++ if (opts.healsOnRebuild) state.broken = false // unmask the real (already-populated) adjacency } return state } describe('BRAINY-COLD-GRAPH-CONNECTED — find({ connected }) 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) self-heals: cold-unloaded adjacency that rebuild() repopulates → correct N edges', async () => { const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) const state = instrumentGraphIndex(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 — the real edges, served after the heal }) it('(b) loud throw: cold-unloaded adjacency that rebuild() cannot repopulate → GraphIndexNotReadyError', async () => { const { brain, anchorId } = await buildBrain({ anchorEdges: true }) brains.push(brain) instrumentGraphIndex(brain, { broken: true, healsOnRebuild: false }) // rebuild never heals await expect( brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) ).rejects.toBeInstanceOf(GraphIndexNotReadyError) // NOT a silent [] }) it('(c) edgeless anchor (other edges exist): returns [] with NO rebuild and NO throw', async () => { // The anchor has no edges, but E -> F does — so the GLOBAL sample resolves and the empty stands. const { brain, anchorId } = await buildBrain({ anchorEdges: false }) brains.push(brain) const state = instrumentGraphIndex(brain, { broken: false, 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) // no spurious rebuild on a healthy adjacency }) it('(d) healthy: edges present, adjacency live → correct results, no rebuild', async () => { const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) const state = instrumentGraphIndex(brain, { broken: false, 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) executeGraphSearch re-collect path: neighbors() verify hits a transient first, the empty-result guard then heals + re-collects', async () => { // Force the FIRST verify (inside neighbors()) to fail transiently so it does NOT heal there; // the empty connectedIds set then drives executeGraphSearch's own verify, which rebuilds and // re-runs the from/to collection. This specifically exercises the 'rebuilt' → re-collect branch. const { brain, anchorId, targetIds } = await buildBrain({ anchorEdges: true }) brains.push(brain) const state = instrumentGraphIndex(brain, { broken: true, healsOnRebuild: true }) const origGetVerbs = brain.storage.getVerbs.bind(brain.storage) let firstVerbsCall = true brain.storage.getVerbs = async (params?: any) => { if (firstVerbsCall) { firstVerbsCall = false throw new Error('transient storage hiccup') } return origGetVerbs(params) } const results = await brain.find({ connected: { from: anchorId, direction: 'out' }, limit: 10 }) expect(state.rebuildCalls).toBeGreaterThanOrEqual(1) const ids = results.map((r: any) => r.id).sort() expect(ids).toEqual(targetIds.sort()) // re-collected after the heal }) })