diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 6c4c1558..0a553d46 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -181,6 +181,27 @@ context.registerProvider('vector', (config, distanceFn, options) => { }) ``` +#### The readiness contract (all three index providers) + +A provider that **persists its derived index** should implement the optional readiness +members so a warm reopen never pays a redundant rebuild-from-canonical: + +- **`init?(): Promise`** — eager cold-load. Brainy awaits it once during + `brain.init()`, after the metadata provider's `init()` (the id-mapper hydrates first) + and **before the rebuild gate**. +- **`isReady?(): boolean`** — honest durability signal. `true` ⇔ the persisted index is + loaded (or cheaply demand-loadable) and consistent with what was last persisted. When + exposed, the rebuild gate defers to this signal **instead of** the `size() === 0` / + `totalEntries === 0` heuristics — a disk-native index may report 0 resident entries + while fully durable. Never return `true` if the durable state failed to load: the + signal is honest in both directions, and a not-ready provider gets its rebuild even + when `size() > 0`. +- **`isMigrating?(): boolean`** — while `true`, the provider owns its index (background + migration); brainy skips its rebuild entirely. + +Providers that implement none of these keep the size/count heuristics — correct for +engines whose `rebuild()` *is* their load path (like brainy's built-in JS vector index). + #### `metadataIndex` **Type:** `(storage: StorageAdapter) => MetadataIndexManager-compatible` diff --git a/src/brainy.ts b/src/brainy.ts index 2af17329..a1fb9eab 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -961,14 +961,21 @@ export class Brainy implements BrainyInterface { this.graphIndex = graphIndex } - // Eager graph cold-load (cor contract). The native graph provider lazily - // defers loading its source→target adjacency, so isReady() would report - // false at the rebuild gate below and force a spurious rebuild (failing the - // §7.1 rebuild()==0 acceptance). Trigger the eager cold-load now — AFTER - // metadataIndex.init() above (the id-mapper is hydrated, so a native int - // adjacency resolves endpoints through it: the CTX-BR-RESTORE-REBUILD order) - // and BEFORE rebuildIndexesIfNeeded. Optional: the JS graph has no init() - // and self-loads its adjacency on demand. + // Eager cold-load (readiness contract). A provider that persists its + // derived state exposes init?(): trigger the load NOW — AFTER + // metadataIndex.init() above (the id-mapper is hydrated first, so a + // native int-keyed index resolves endpoints/slots through it — the + // CTX-BR-RESTORE-REBUILD order) and BEFORE the rebuild gate — so a + // durable index reports its real size()/isReady() at the gate instead + // of eating a spurious rebuild-from-canonical on every open (§7.1 + // rebuild()==0). Vector first, then graph. JS engines: the JS vector + // index has no init() (rebuild() IS its load path); the JS graph's + // init() already cold-loaded its LSM inside storage.getGraphIndex() + // (idempotent here). + const vectorWithInit = this.index as { init?: () => Promise } + if (typeof vectorWithInit.init === 'function') { + await vectorWithInit.init() + } const graphWithInit = this.graphIndex as { init?: () => Promise } if (typeof graphWithInit.init === 'function') { await graphWithInit.init() @@ -13398,20 +13405,13 @@ export class Brainy implements BrainyInterface { return } - // OPTIMIZATION: Instant check - if index already has data, skip immediately - // This gives 0s startup for warm restarts (vs 50-100ms of async checks). - // The epoch-drift guard suppresses this fast path: an index that loaded - // eagerly (size()>0) but whose on-disk format predates this build - // (`_indexEpochStale`) must still rebuild — that is the exact gap this - // handshake closes (rebuild on a format change, not only on size()===0). - if (this.index.size() > 0 && !force && !this._indexEpochStale) { - if (!this.config.silent) { - console.log( - `✅ Index already populated (${this.index.size().toLocaleString()} entities) - 0s startup!` - ) - } - return - } + // No instant fast-path here: the honest per-leg readiness checks below + // are all O(1) (one bounded storage sample + each provider's size()/ + // isReady()), and this method runs exactly once per open (init calls it; + // the lazy path passes force=true). The removed shortcut keyed off + // `this.index.size() > 0`, a dishonest proxy — it skipped the metadata + // and graph checks whenever the vector happened to be warm, and it never + // fired on a real cold process (the JS vector size is 0 until it loads). // BUG #2 FIX: Don't trust counts - check actual storage instead // Counts can be lost/corrupted in container restarts @@ -13437,17 +13437,23 @@ export class Brainy implements BrainyInterface { // Check if indexes need rebuilding const metadataStats = await this.metadataIndex.getStats() 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()) + // Readiness contract: when a provider exposes isReady(), that honest + // signal REPLACES the size/count heuristic below — an mmap/disk-native + // index legitimately reports 0 resident entries while fully durable on + // disk, and rebuilding it from canonical re-reads every entity file on + // every boot (the 48-seconds-per-restart class a production deployment + // hit). The signal is honest in BOTH directions: a provider whose + // durable state failed to load returns false and gets its rebuild even + // when size() > 0 (the silent-empty cold-load failure). Providers + // without isReady() keep the exact prior empty-heuristics. + const providerReady = (leg: unknown): boolean | undefined => { + const candidate = leg as { isReady?: () => boolean } + return typeof candidate.isReady === 'function' ? candidate.isReady() : undefined + } + const metadataReady = providerReady(this.metadataIndex) + const vectorReady = providerReady(this.index) + const graphReady = providerReady(this.graphIndex) // Epoch-drift trigger: a format-version change makes EVERY derived index // suspect even when each is non-empty, so it forces a rebuild of all @@ -13466,16 +13472,38 @@ export class Brainy implements BrainyInterface { const graphMigrating = this.providerIsMigrating(this.graphIndex) const anyMigrating = metadataMigrating || vectorMigrating || graphMigrating + // Per-leg decision, in precedence order: a migrating provider owns its + // index (skip) → epoch drift forces a rebuild → an exposed isReady() + // decides → otherwise a per-leg fallback. The fallbacks differ by leg + // because "empty" means different things: + // - METADATA: past the empty-store early-return, entities exist, so the + // id-mapper SHOULD have loaded entries — totalEntries===0 is a real + // load-failure signal, so rebuild (self-heal from canonical). + // - VECTOR: the JS vector has no passive cold-load — rebuild() IS its + // load path — so size()===0 correctly triggers the load. + // - GRAPH: entities do NOT imply edges, so size()===0 is a VALID empty + // state, not a load failure. The JS graph cold-loads (and self-heals + // against canonical) inside storage.getGraphIndex() BEFORE this gate, + // so it is already authoritative here; re-deriving would be spurious + // (a full O(E) verb scan on every open of an edgeless brain). It + // therefore rebuilds only on epoch drift or a native !isReady(). + // (verifyGraphAdjacencyLive is the query-time backstop.) const shouldRebuildMetadata = - (metadataStats.totalEntries === 0 || epochStale) && !metadataMigrating - const shouldRebuildVector = (hnswIndexSize === 0 || epochStale) && !vectorMigrating - const shouldRebuildGraph = (needsGraphRebuild || epochStale) && !graphMigrating + !metadataMigrating && + (epochStale || + (metadataReady !== undefined ? !metadataReady : metadataStats.totalEntries === 0)) + const shouldRebuildVector = + !vectorMigrating && + (epochStale || (vectorReady !== undefined ? !vectorReady : hnswIndexSize === 0)) + const shouldRebuildGraph = + !graphMigrating && + (epochStale || (graphReady !== undefined ? !graphReady : false)) const needsRebuild = shouldRebuildMetadata || shouldRebuildVector || shouldRebuildGraph if (!needsRebuild && !force) { - // All indexes already populated (or owned by a background migration), no - // rebuild needed. + // All indexes report current — durably loaded (isReady/size), or owned + // by a background migration. No rebuild needed. return } @@ -13501,7 +13529,18 @@ export class Brainy implements BrainyInterface { : '🔄 Auto-rebuild explicitly enabled' if (!this.config.silent) { - console.log(`${rebuildReason} - rebuilding all indexes from persisted data...`) + // Name exactly which legs rebuild — "all indexes" was a lie whenever + // the durable legs were skipped (e.g. only the JS vector index loads + // here on a warm reopen), and it misread as a whole-brain rebuild in + // consumer boot logs. + const rebuildingLegs = [ + shouldRebuildMetadata && 'metadata', + shouldRebuildVector && 'vector', + shouldRebuildGraph && 'graph' + ] + .filter(Boolean) + .join(' + ') + console.log(`${rebuildReason} - loading/rebuilding ${rebuildingLegs || 'no'} index(es) from persisted data...`) } // Before the graph rebuild, hydrate the entity id-mapper from the persisted diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index 4dae3c43..816856b1 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -195,6 +195,25 @@ export class GraphAdjacencyIndex implements GraphIndexProvider { return next } + /** + * Eager cold-load of the persisted adjacency (readiness contract). + * + * Loads the LSM manifests + SSTables so `size()` reports the durable edge + * count at the rebuild gate — a warm reopen must load the persisted index, + * never re-derive it from a full canonical verb scan (the every-boot O(E) + * cost this method exists to eliminate). Idempotent; the lazy read paths + * call the same `ensureInitialized()` on demand. + * + * NOTE: the JS index deliberately does NOT expose `isReady()`. That signal + * (see `GraphIndexProvider.isReady`) asserts "traversals are trustworthy", + * which this side cannot honestly promise without comparing against the + * canonical store — the query-time known-edge probe + * (`verifyGraphAdjacencyLive` Strategy 2) remains the JS trust check. + */ + async init(): Promise { + await this.ensureInitialized() + } + /** * Initialize the graph index (lazy initialization) * Added defensive auto-rebuild check for verbIdSet consistency diff --git a/src/graph/lsm/LSMTree.ts b/src/graph/lsm/LSMTree.ts index 9abfa353..bfd48df4 100644 --- a/src/graph/lsm/LSMTree.ts +++ b/src/graph/lsm/LSMTree.ts @@ -547,13 +547,26 @@ export class LSMTree { const data = metadata.data as PersistedManifestData this.manifest.sstables = new Map(Object.entries(data.sstables || {})) this.manifest.lastCompaction = data.lastCompaction || Date.now() - this.manifest.totalRelationships = data.totalRelationships || 0 - // Load SSTables from storage + // Load SSTables from storage BEFORE publishing the persisted count. + // If the SSTable load throws, `size()` must keep reporting 0 — a tree + // that claims its persisted relationships while holding none serves + // silent-empty traversals as truth (the cold-load swallow class), and + // downstream self-heal keys off the honest 0. await this.loadSSTables() + this.manifest.totalRelationships = data.totalRelationships || 0 } } catch (error) { - prodLog.debug('LSMTree: No existing manifest found, starting fresh') + // Reset anything partially loaded — an honest empty tree triggers the + // rebuild/self-heal paths; a half-loaded one masks them. (An absent + // manifest on a fresh store also lands here: empty is correct.) + this.manifest.sstables = new Map() + this.manifest.totalRelationships = 0 + this.sstablesByLevel.clear() + prodLog.debug( + `LSMTree(${this.config.storagePrefix}): no loadable manifest/SSTables — starting empty ` + + `(${error instanceof Error ? error.message : String(error)})` + ) } } diff --git a/src/plugin.ts b/src/plugin.ts index 25d4f156..12455b3a 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -125,6 +125,20 @@ export interface MetadataIndexProvider { flush(): Promise rebuild(): Promise + /** + * @description OPTIONAL honest durability signal (readiness contract, + * mirrors `isReady?()` on the graph and vector providers). `true` ⇔ the + * persisted field postings are loaded (or cheaply demand-loadable) and + * consistent with what the provider last persisted — a rebuild from the + * canonical records would be redundant. When exposed, the rebuild gate + * defers to this signal INSTEAD of the `getStats().totalEntries === 0` + * heuristic (a durable provider may legitimately report 0 resident entries + * on a cold open while its postings sit loadable on disk). Absent → the + * gate keeps the count heuristic. Never return `true` when the durable + * state failed to load. + */ + isReady?(): boolean + /** * @description OPTIONAL. A native provider returns true from the moment its * `init()` detects a large epoch-drift until its background @@ -902,6 +916,33 @@ export interface VectorIndexProvider { flush(): Promise getPersistMode(): 'immediate' | 'deferred' + /** + * @description OPTIONAL eager cold-load (readiness contract, mirrors + * {@link GraphIndexProvider.init}). Called once during brain init — AFTER the + * metadata provider's `init()` (the id-mapper is hydrated first, so a + * provider whose vector slots resolve through interned ints reads a complete + * mapping) and BEFORE the rebuild gate — so a durable provider loads (or + * verifies it can demand-load) its persisted index and reports + * `isReady() === true` at the gate instead of eating a spurious + * rebuild-from-canonical on every open. The built-in JS index omits it: + * `rebuild()` IS its load path. + */ + init?(): Promise + + /** + * @description OPTIONAL honest durability signal (readiness contract, + * mirrors {@link GraphIndexProvider.isReady}). `true` ⇔ the persisted + * derived index is loaded (or cheaply demand-loadable) and consistent with + * what the provider last persisted — a rebuild from the canonical records + * would be redundant work. When exposed, the rebuild gate defers to this + * signal INSTEAD of the `size() === 0` heuristic (an mmap/disk-native index + * may legitimately report 0 resident entries while fully durable). Absent → + * the gate keeps the size heuristic. Never return `true` when the durable + * state failed to load — that converts a recoverable rebuild into silent + * empty results. + */ + isReady?(): boolean + /** * @description OPTIONAL. A native provider returns true from the moment its * `init()` detects a large epoch-drift until its background diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index af312790..22149afb 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -2429,11 +2429,26 @@ export abstract class BaseStorage extends BaseStorageAdapter { // invalidateGraphIndex); on first init Brainy wires it right after. this.graphIndex = new GraphAdjacencyIndex(this, {}, this.graphEntityIdResolver) - // Check if we need to rebuild from existing data - const sampleVerbs = await this.getVerbs({ pagination: { limit: 1 } }) - if (sampleVerbs.items.length > 0) { - prodLog.info('Found existing verbs, rebuilding graph index...') - await this.graphIndex.rebuild() + // Load the PERSISTED adjacency first (LSM manifests + SSTables). A warm + // reopen must load the durable index it already built — the previous + // "any verb exists → rebuild()" check here re-derived the whole graph + // from a full canonical verb scan on EVERY boot, an O(E) cost that + // dominated real deployments' startup. + await this.graphIndex.init() + + // Self-heal only when the durable state is genuinely missing: canonical + // records exist but the loaded index is empty (first open on pre-index + // data, a deleted/corrupt _graph dir, or the LSM load failing loud). + // One O(1) probe replaces the unconditional O(E) re-derive. + if (this.graphIndex.size() === 0) { + const sampleVerbs = await this.getVerbs({ pagination: { limit: 1 } }) + if (sampleVerbs.items.length > 0) { + prodLog.warn( + 'GraphAdjacencyIndex: canonical verbs exist but the persisted adjacency is empty — ' + + 'rebuilding from storage (one-time self-heal).' + ) + await this.graphIndex.rebuild() + } } return this.graphIndex diff --git a/tests/unit/brainy/migration-deference.test.ts b/tests/unit/brainy/migration-deference.test.ts index 758751fa..6471b4ef 100644 --- a/tests/unit/brainy/migration-deference.test.ts +++ b/tests/unit/brainy/migration-deference.test.ts @@ -148,7 +148,7 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b expect(internals._indexEpochStale).toBe(true) }) - it('a migrating provider is skipped even when its size()===0; a non-migrating size()===0 sibling still rebuilds', async () => { + it('a migrating provider is skipped even though its empty-signal would trigger a rebuild; clearing the flag lets the empty leg rebuild', async () => { const brain = await makeWarmBrain() const internals = internalsOf(brain) @@ -156,22 +156,29 @@ describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / b const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined) const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined) - // No epoch drift this time: the rebuild trigger is purely "index is empty". + // No epoch drift: the only rebuild trigger is a leg's own empty signal. The + // JS vector's rebuild() IS its load path, so size()===0 is its trigger — + // the leg where "empty → rebuild" is architecturally correct — so we drive + // deference through it. (The JS graph cold-loads before this gate, so its + // size()===0 is a valid empty state, not a rebuild trigger; graph deference + // is covered by the epoch-drift case above.) internals._indexEpochStale = false - // Vector index reports empty AND is migrating → its background swap owns it. vi.spyOn(internals.index, 'size').mockReturnValue(0) + + // Migrating: the provider's background swap owns the index, so the + // size()===0 load trigger is suppressed. setMigrating(internals.index, true) - // Graph index reports empty and is NOT migrating → brainy must rebuild it. - vi.spyOn(internals.graphIndex, 'size').mockReturnValue(0) - await internals.rebuildIndexesIfNeeded() - - // size()===0 would normally force the vector rebuild — deference suppresses it. expect(idxSpy).toHaveBeenCalledTimes(0) - // The empty, non-migrating graph sibling still rebuilds. - expect(giSpy).toHaveBeenCalledTimes(1) - // Metadata has entries and no drift → no rebuild needed. + // Metadata has entries; the graph has no edges; neither drifted → no rebuild. expect(miSpy).toHaveBeenCalledTimes(0) + expect(giSpy).toHaveBeenCalledTimes(0) + + // Clearing the flag: the same empty, non-migrating vector now rebuilds — and + // this second call re-evaluating at all confirms the gate is not latched. + setMigrating(internals.index, false) + await internals.rebuildIndexesIfNeeded() + expect(idxSpy).toHaveBeenCalledTimes(1) }) // --- Hook 1: large-path first-query lazy force-rebuild deference ---------- diff --git a/tests/unit/cold-open-rebuild-gate.test.ts b/tests/unit/cold-open-rebuild-gate.test.ts new file mode 100644 index 00000000..136cfa75 --- /dev/null +++ b/tests/unit/cold-open-rebuild-gate.test.ts @@ -0,0 +1,213 @@ +/** + * Cold-open rebuild gate — the readiness contract (8.0.12). + * + * A production deployment measured 48 seconds on EVERY boot because the + * rebuild gate keyed off in-memory size()/count heuristics that are dishonest + * for durable indexes: a disk-native provider legitimately reports 0 resident + * entries while fully durable, and the JS graph loaded nothing at gate time + * (its LSM initialized lazily) — so `rebuildIndexesIfNeeded()` re-derived + * indexes from a full canonical scan on every open. + * + * The contract pinned here: + * - JS reopen: the graph must COLD-LOAD its persisted LSM (no re-derive; the + * old `_initializeGraphIndex` rebuilt from a full verb scan every boot), + * the metadata leg must not rebuild (id-mapper signal is honest), and + * queries stay correct. The JS vector leg still runs `rebuild()` — that IS + * its load path. + * - A vector provider exposing `isReady() === true` is never rebuilt, even + * at `size() === 0`; `isReady() === false` gets its rebuild. `init?()` is + * eagerly awaited before the gate. + */ +import { describe, it, expect, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { Brainy, NounType, VerbType } from '../../src/index.js' +import { GraphAdjacencyIndex } from '../../src/graph/graphAdjacencyIndex.js' +import { MetadataIndexManager } from '../../src/utils/metadataIndex.js' + +const tmpDirs: string[] = [] +function mkTmp(): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-cold-open-')) + tmpDirs.push(d) + return d +} +afterEach(() => { + for (const d of tmpDirs.splice(0)) fs.rmSync(d, { recursive: true, force: true }) +}) + +const V = () => Array.from({ length: 384 }, () => Math.random()) + +/** Populate a brain with nouns + edges and close it. */ +async function buildBrain(dir: string, n = 12): Promise { + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + await brain.init() + const ids: string[] = [] + for (let i = 0; i < n; i++) { + ids.push( + await brain.add({ + data: `entity ${i}`, + type: NounType.Concept, + subtype: 's', + metadata: { wave: i % 3 }, + vector: V() + }) + ) + } + for (let i = 0; i + 1 < ids.length; i++) { + await brain.relate({ from: ids[i], to: ids[i + 1], type: VerbType.RelatedTo, subtype: 's' }) + } + await brain.close() + return ids +} + +describe('Cold-open rebuild gate (readiness contract)', () => { + it('JS reopen: graph cold-loads its LSM (no re-derive), metadata skips, queries correct', async () => { + const dir = mkTmp() + const ids = await buildBrain(dir) + + // Spy on the two rebuilds that must NOT run on a healthy reopen. + const graphRebuilds: number[] = [] + const metadataRebuilds: number[] = [] + const origGraphRebuild = GraphAdjacencyIndex.prototype.rebuild + const origMetaRebuild = MetadataIndexManager.prototype.rebuild + GraphAdjacencyIndex.prototype.rebuild = async function (...args: any[]) { + graphRebuilds.push(1) + return origGraphRebuild.apply(this, args as any) + } + MetadataIndexManager.prototype.rebuild = async function (...args: any[]) { + metadataRebuilds.push(1) + return origMetaRebuild.apply(this, args as any) + } + try { + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + await brain.init() + + expect(graphRebuilds.length).toBe(0) // persisted LSM loaded — no O(E) re-derive + expect(metadataRebuilds.length).toBe(0) // id-mapper signal honest — no rebuild + expect(await brain.graphIndex.size()).toBeGreaterThan(0) // durable edges COLD-LOADED at boot + + // Correctness after the load — the whole point of not rebuilding. + const byWhere = await brain.find({ type: NounType.Concept, where: { wave: 1 } }) + expect(byWhere.length).toBe(4) // waves 1,4,7,10 of 12 + const connected = await brain.find({ connected: { from: ids[0], depth: 1 } }) + expect(connected.length).toBe(1) + await brain.close() + } finally { + GraphAdjacencyIndex.prototype.rebuild = origGraphRebuild + MetadataIndexManager.prototype.rebuild = origMetaRebuild + } + }) + + it('a vector provider with isReady()===true is never rebuilt, even at size()===0 (init eagerly awaited)', async () => { + const dir = mkTmp() + await buildBrain(dir, 6) + + const calls = { init: 0, rebuild: 0 } + const stubVector = { + addItem: async (item: any) => item?.id ?? 'stub-id', + removeItem: async () => true, + search: async () => [] as Array<[string, number]>, + size: () => 0, // disk-native posture: durable, zero resident + clear: () => {}, + rebuild: async () => { + calls.rebuild++ + }, + flush: async () => 0, + getPersistMode: () => 'deferred' as const, + init: async () => { + calls.init++ + }, + isReady: () => true + } + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } }) + await brain.init() + + expect(calls.init).toBe(1) // the eager cold-load ran before the gate + expect(calls.rebuild).toBe(0) // isReady()===true replaced the size()===0 heuristic + await brain.close() + }) + + it('a vector provider with isReady()===false gets its rebuild (honest in both directions)', async () => { + const dir = mkTmp() + await buildBrain(dir, 6) + + const calls = { rebuild: 0 } + let ready = false + const stubVector = { + addItem: async (item: any) => item?.id ?? 'stub-id', + removeItem: async () => true, + search: async () => [] as Array<[string, number]>, + size: () => 999, // even a non-zero size must NOT mask a not-ready provider + clear: () => {}, + rebuild: async () => { + calls.rebuild++ + ready = true // rebuild restores readiness + }, + flush: async () => 0, + getPersistMode: () => 'deferred' as const, + isReady: () => ready + } + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + brain.use({ name: 'fake-native-vector', activate: async (ctx: any) => { ctx.registerProvider('vector', () => stubVector); return true } }) + await brain.init() + + expect(calls.rebuild).toBe(1) + await brain.close() + }) + + it('self-heal survives: durable graph state deleted → one rebuild from canonical on reopen', async () => { + const dir = mkTmp() + const ids = await buildBrain(dir, 6) + + // Simulate lost durable graph state: remove the persisted LSM artifacts + // (metadata channel keys live under _system hash buckets — nuke the graph + // manifests via the storage API instead of guessing paths). + const wipe: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + await wipe.init() + // Clear both LSM trees' persisted manifests through the live index, then + // close WITHOUT letting them re-flush a fresh manifest state. + const gi: any = wipe.graphIndex + await gi.lsmTreeVerbsBySource.clear?.() + await gi.lsmTreeVerbsByTarget.clear?.() + await wipe.close() + + const brain: any = new Brainy({ + requireSubtype: false, + storage: { type: 'filesystem', path: dir }, + plugins: [], + silent: true + }) + await brain.init() + // Canonical verbs still exist → the boot self-heal must have restored the adjacency. + const connected = await brain.find({ connected: { from: ids[0], depth: 1 } }) + expect(connected.length).toBe(1) + await brain.close() + }) +})