fix(8.0): never serve a silent [] from find({connected}) on a cold-loaded graph
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.
This commit is contained in:
parent
93f61dbc79
commit
229b0679fc
4 changed files with 542 additions and 98 deletions
388
src/brainy.ts
388
src/brainy.ts
|
|
@ -161,7 +161,7 @@ import {
|
||||||
import { GenerationStore } from './db/generationStore.js'
|
import { GenerationStore } from './db/generationStore.js'
|
||||||
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
|
import { isDeterministicEmbedMode } from './embeddings/deterministicEmbedMode.js'
|
||||||
import { GenerationConflictError } from './db/errors.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 { MemoryStorage } from './storage/adapters/memoryStorage.js'
|
||||||
import type {
|
import type {
|
||||||
CompactHistoryOptions,
|
CompactHistoryOptions,
|
||||||
|
|
@ -403,6 +403,10 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
private _indexRebuildFailed: Error | null = null
|
private _indexRebuildFailed: Error | null = null
|
||||||
/** One-shot guard so the metadata cold-open consistency probe runs once per brain. */
|
/** One-shot guard so the metadata cold-open consistency probe runs once per brain. */
|
||||||
private _metadataConsistencyProbed = false
|
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)
|
// Sub-APIs (lazy-loaded)
|
||||||
private _nlp?: NaturalLanguageProcessor
|
private _nlp?: NaturalLanguageProcessor
|
||||||
|
|
@ -2664,6 +2668,153 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
return this.entityIntsToUuids(neighborInts)
|
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<void> {
|
||||||
|
const idMapper = this.metadataIndex.getIdMapper() as { rebuild?: () => Promise<void> }
|
||||||
|
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<boolean> =>
|
||||||
|
(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<T = any> implements BrainyInterface<T> {
|
||||||
paramsOrId?: string | RelatedParams
|
paramsOrId?: string | RelatedParams
|
||||||
): Promise<Relation<T>[]> {
|
): Promise<Relation<T>[]> {
|
||||||
await this.ensureInitialized()
|
await this.ensureInitialized()
|
||||||
|
await this.verifyGraphAdjacencyLive()
|
||||||
|
|
||||||
// Handle string ID shorthand: related(id) -> related({ from: id })
|
// Handle string ID shorthand: related(id) -> related({ from: id })
|
||||||
const rawParams = typeof paramsOrId === 'string'
|
const rawParams = typeof paramsOrId === 'string'
|
||||||
|
|
@ -6916,10 +7068,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
// JS mapper has no `rebuild()` and needs no reload: MetadataIndex.rebuild()
|
// JS mapper has no `rebuild()` and needs no reload: MetadataIndex.rebuild()
|
||||||
// re-derives it from the restored entities via append-only getOrAssign,
|
// re-derives it from the restored entities via append-only getOrAssign,
|
||||||
// consistently with the bitmaps it builds.
|
// consistently with the bitmaps it builds.
|
||||||
const idMapper = this.metadataIndex.getIdMapper() as { rebuild?: () => Promise<void> }
|
await this.hydrateIdMapperForGraphRebuild()
|
||||||
if (typeof idMapper.rebuild === 'function') {
|
|
||||||
await idMapper.rebuild()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every in-memory index is now stale — rebuild all three from the restored
|
// Every in-memory index is now stale — rebuild all three from the restored
|
||||||
// canonical records (each rebuild clears its own state first). The graph
|
// canonical records (each rebuild clears its own state first). The graph
|
||||||
|
|
@ -11041,6 +11190,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
}
|
}
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
await this.ensureInitialized()
|
await this.ensureInitialized()
|
||||||
|
await this.verifyGraphAdjacencyLive()
|
||||||
|
|
||||||
const direction = options?.direction || 'both'
|
const direction = options?.direction || 'both'
|
||||||
const limit = options?.limit
|
const limit = options?.limit
|
||||||
|
|
@ -11577,111 +11727,133 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
|
|
||||||
const connectedIds = new Set<string>()
|
const connectedIds = new Set<string>()
|
||||||
|
|
||||||
if (subtypeFilter !== undefined) {
|
// Population is extracted into a closure so it can be re-run VERBATIM after a
|
||||||
// Multi-hop BFS with per-hop (verbType, subtype) predicate. Works on the
|
// cold-load heal (verdict 'rebuilt') — re-executing the SAME traversal once the
|
||||||
// open-core JS path at any depth. When a graph-index provider exposes a
|
// adjacency is actually loaded, without changing any collection semantics.
|
||||||
// faster `findConnectedSubtype` (native BFS with the same semantics),
|
const populate = async (): Promise<void> => {
|
||||||
// `nativeSubtypeBfs` routes through it transparently — same pattern as
|
connectedIds.clear()
|
||||||
// every other provider hook.
|
if (subtypeFilter !== undefined) {
|
||||||
const subtypeArr = Array.isArray(subtypeFilter) ? subtypeFilter : [subtypeFilter]
|
// Multi-hop BFS with per-hop (verbType, subtype) predicate. Works on the
|
||||||
const subtypeSet = new Set(subtypeArr)
|
// 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
|
// Native fast path (D.3): single verb type + single subtype + outgoing
|
||||||
// walk match the native BFS semantics exactly (out-edges only, source
|
// walk match the native BFS semantics exactly (out-edges only, source
|
||||||
// excluded, visited-set cycle guard). Entity ints in, entity ints out —
|
// excluded, visited-set cycle guard). Entity ints in, entity ints out —
|
||||||
// UUID conversion stays at this boundary. Returns null when the query
|
// UUID conversion stays at this boundary. Returns null when the query
|
||||||
// shape (or provider) can't take the native route.
|
// shape (or provider) can't take the native route.
|
||||||
const nativeSubtypeBfs = async (
|
const nativeSubtypeBfs = async (
|
||||||
anchor: string,
|
anchor: string,
|
||||||
walk: 'in' | 'out' | 'both'
|
walk: 'in' | 'out' | 'both'
|
||||||
): Promise<Set<string> | null> => {
|
): Promise<Set<string> | null> => {
|
||||||
if (walk !== 'out') return null
|
if (walk !== 'out') return null
|
||||||
if (via === undefined || Array.isArray(via)) return null
|
if (via === undefined || Array.isArray(via)) return null
|
||||||
if (subtypeArr.length !== 1) return null
|
if (subtypeArr.length !== 1) return null
|
||||||
const provider = this.graphIndex as Partial<{
|
const provider = this.graphIndex as Partial<{
|
||||||
findConnectedSubtype(
|
findConnectedSubtype(
|
||||||
sourceInt: bigint,
|
sourceInt: bigint,
|
||||||
verbTypeIndex: number,
|
verbTypeIndex: number,
|
||||||
subtype: string | null,
|
subtype: string | null,
|
||||||
depth: number,
|
depth: number,
|
||||||
limit?: number | null
|
limit?: number | null
|
||||||
): Promise<bigint[]>
|
): Promise<bigint[]>
|
||||||
}>
|
}>
|
||||||
if (typeof provider.findConnectedSubtype !== 'function') return null
|
if (typeof provider.findConnectedSubtype !== 'function') return null
|
||||||
|
|
||||||
const anchorInt = this.graphEntityInt(anchor)
|
const anchorInt = this.graphEntityInt(anchor)
|
||||||
if (anchorInt === undefined) return new Set() // unmapped → no relations
|
if (anchorInt === undefined) return new Set() // unmapped → no relations
|
||||||
|
|
||||||
const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType)
|
const verbTypeIndex = TypeUtils.getVerbIndex(via as VerbType)
|
||||||
// No limit: match the JS BFS exactly — overall result limiting happens
|
// No limit: match the JS BFS exactly — overall result limiting happens
|
||||||
// downstream against existingResults.
|
// downstream against existingResults.
|
||||||
const reachedInts = await provider.findConnectedSubtype(
|
const reachedInts = await provider.findConnectedSubtype(
|
||||||
anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null
|
anchorInt, verbTypeIndex, subtypeArr[0], effectiveDepth, null
|
||||||
)
|
)
|
||||||
return new Set(this.entityIntsToUuids(reachedInts))
|
return new Set(this.entityIntsToUuids(reachedInts))
|
||||||
}
|
}
|
||||||
|
|
||||||
const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise<Set<string>> => {
|
const bfsWithSubtype = async (anchor: string, walk: 'in' | 'out' | 'both'): Promise<Set<string>> => {
|
||||||
const nativeResult = await nativeSubtypeBfs(anchor, walk)
|
const nativeResult = await nativeSubtypeBfs(anchor, walk)
|
||||||
if (nativeResult !== null) return nativeResult
|
if (nativeResult !== null) return nativeResult
|
||||||
|
|
||||||
const visited = new Set<string>([anchor])
|
const visited = new Set<string>([anchor])
|
||||||
const reached = new Set<string>()
|
const reached = new Set<string>()
|
||||||
let frontier = new Set<string>([anchor])
|
let frontier = new Set<string>([anchor])
|
||||||
|
|
||||||
for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) {
|
for (let hop = 0; hop < effectiveDepth && frontier.size > 0; hop++) {
|
||||||
const nextFrontier = new Set<string>()
|
const nextFrontier = new Set<string>()
|
||||||
for (const node of frontier) {
|
for (const node of frontier) {
|
||||||
// Walk outgoing edges (when direction includes outbound) and incoming
|
// Walk outgoing edges (when direction includes outbound) and incoming
|
||||||
// edges (when direction includes inbound). Both = union.
|
// edges (when direction includes inbound). Both = union.
|
||||||
const dirs: Array<'from' | 'to'> = []
|
const dirs: Array<'from' | 'to'> = []
|
||||||
if (walk === 'out' || walk === 'both') dirs.push('from')
|
if (walk === 'out' || walk === 'both') dirs.push('from')
|
||||||
if (walk === 'in' || walk === 'both') dirs.push('to')
|
if (walk === 'in' || walk === 'both') dirs.push('to')
|
||||||
|
|
||||||
for (const dir of dirs) {
|
for (const dir of dirs) {
|
||||||
const edges = await this.related({
|
const edges = await this.related({
|
||||||
...(dir === 'from' ? { from: node } : { to: node }),
|
...(dir === 'from' ? { from: node } : { to: node }),
|
||||||
...(via && { type: via as VerbType | VerbType[] }),
|
...(via && { type: via as VerbType | VerbType[] }),
|
||||||
subtype: subtypeArr,
|
subtype: subtypeArr,
|
||||||
limit: 10000
|
limit: 10000
|
||||||
})
|
})
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
// Defensive: related() honors the subtype filter, but check
|
// Defensive: related() honors the subtype filter, but check
|
||||||
// again in case a future impl widens it.
|
// again in case a future impl widens it.
|
||||||
if (edge.subtype !== undefined && !subtypeSet.has(edge.subtype)) continue
|
if (edge.subtype !== undefined && !subtypeSet.has(edge.subtype)) continue
|
||||||
const neighbor = dir === 'from' ? edge.to : edge.from
|
const neighbor = dir === 'from' ? edge.to : edge.from
|
||||||
if (visited.has(neighbor)) continue
|
if (visited.has(neighbor)) continue
|
||||||
visited.add(neighbor)
|
visited.add(neighbor)
|
||||||
reached.add(neighbor)
|
reached.add(neighbor)
|
||||||
nextFrontier.add(neighbor)
|
nextFrontier.add(neighbor)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
frontier = nextFrontier
|
||||||
}
|
}
|
||||||
frontier = nextFrontier
|
return reached
|
||||||
}
|
}
|
||||||
return reached
|
|
||||||
}
|
|
||||||
|
|
||||||
if (from) {
|
if (from) {
|
||||||
for (const id of await bfsWithSubtype(from, direction)) connectedIds.add(id)
|
for (const id of await bfsWithSubtype(from, direction)) connectedIds.add(id)
|
||||||
}
|
}
|
||||||
if (to) {
|
if (to) {
|
||||||
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
||||||
for (const id of await bfsWithSubtype(to, reverse)) connectedIds.add(id)
|
for (const id of await bfsWithSubtype(to, reverse)) connectedIds.add(id)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No subtype filter — fast path via neighbors(), which does the same
|
// No subtype filter — fast path via neighbors(), which does the same
|
||||||
// depth-aware BFS but only filters by verbType.
|
// depth-aware BFS but only filters by verbType.
|
||||||
const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise<string[]> =>
|
const collect = (id: string, dir: 'incoming' | 'outgoing' | 'both'): Promise<string[]> =>
|
||||||
this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via })
|
this.neighbors(id, { direction: dir, depth: effectiveDepth, verbType: via })
|
||||||
|
|
||||||
if (from) {
|
if (from) {
|
||||||
for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id)
|
for (const id of await collect(from, toNeighborDir(direction))) connectedIds.add(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to) {
|
if (to) {
|
||||||
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
const reverse: 'in' | 'out' | 'both' = direction === 'in' ? 'out' : direction === 'out' ? 'in' : 'both'
|
||||||
for (const id of await collect(to, toNeighborDir(reverse))) connectedIds.add(id)
|
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<T = any> implements BrainyInterface<T> {
|
||||||
const hnswIndexSize = this.index.size()
|
const hnswIndexSize = this.index.size()
|
||||||
const graphIndexSize = await this.graphIndex.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 =
|
const needsRebuild =
|
||||||
metadataStats.totalEntries === 0 ||
|
metadataStats.totalEntries === 0 ||
|
||||||
hnswIndexSize === 0 ||
|
hnswIndexSize === 0 ||
|
||||||
graphIndexSize === 0
|
needsGraphRebuild
|
||||||
|
|
||||||
if (!needsRebuild && !force) {
|
if (!needsRebuild && !force) {
|
||||||
// All indexes already populated, no rebuild needed
|
// All indexes already populated, no rebuild needed
|
||||||
|
|
@ -12841,13 +13023,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
||||||
console.log(`${rebuildReason} - rebuilding all indexes from persisted data...`)
|
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
|
// Rebuild all 3 indexes in parallel for performance
|
||||||
// Indexes load their data from storage (no recomputation)
|
// Indexes load their data from storage (no recomputation)
|
||||||
const rebuildStartTime = Date.now()
|
const rebuildStartTime = Date.now()
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
metadataStats.totalEntries === 0 ? this.metadataIndex.rebuild() : Promise.resolve(),
|
metadataStats.totalEntries === 0 ? this.metadataIndex.rebuild() : Promise.resolve(),
|
||||||
hnswIndexSize === 0 ? this.index.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
|
const rebuildDuration = Date.now() - rebuildStartTime
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export type BrainyErrorType =
|
||||||
| 'RETRY_EXHAUSTED'
|
| 'RETRY_EXHAUSTED'
|
||||||
| 'VALIDATION'
|
| 'VALIDATION'
|
||||||
| 'FIELD_NOT_INDEXED'
|
| 'FIELD_NOT_INDEXED'
|
||||||
|
| 'GRAPH_INDEX_NOT_READY'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom error class for Brainy operations
|
* Custom error class for Brainy operations
|
||||||
|
|
@ -223,3 +224,32 @@ export class BrainyError extends Error {
|
||||||
return BrainyError.storage(error.message, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,20 @@ export interface GraphIndexProvider {
|
||||||
*/
|
*/
|
||||||
readonly isInitialized: boolean
|
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.
|
* @description Entity ints reachable from `id` (1 hop), deduped.
|
||||||
* @param id - The entity's interned int (from the shared idMapper).
|
* @param id - The entity's interned int (from the shared idMapper).
|
||||||
|
|
|
||||||
208
tests/integration/cold-graph-connected-8.0.test.ts
Normal file
208
tests/integration/cold-graph-connected-8.0.test.ts
Normal file
|
|
@ -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<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
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue