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.
208 lines
9.6 KiB
TypeScript
208 lines
9.6 KiB
TypeScript
/**
|
|
* @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<bigint[]> =>
|
|
state.ready ? origGetNeighbors(id, options) : []
|
|
|
|
gi.rebuild = async (): Promise<void> => {
|
|
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<bigint[]> =>
|
|
state.broken ? [] : origGetNeighbors(id, options)
|
|
|
|
gi.rebuild = async (): Promise<void> => {
|
|
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
|
|
})
|
|
})
|