open-brainy/tests/unit/brainy/migration-deference.test.ts

254 lines
12 KiB
TypeScript
Raw Normal View History

/**
* @module tests/unit/brainy/migration-deference
* @description rc.8 no-freeze auto-upgrade hooks the deference seam that lets a
* native provider run an ONLINE, background build-newverifyswap index migration
* while brainy stays out of the way (never a minutes-long blocking
* rebuild-on-open / first-query on a large-brain epoch-drift upgrade).
*
* Three hooks, locked with the cor team:
*
* - Hook 1 (deference): an OPTIONAL sync `isMigrating(): boolean` on each index
* provider (metadata / vector / graph). While it returns true, brainy SKIPS its
* own rebuild of that index both in {@link Brainy.rebuildIndexesIfNeeded}
* (even under epoch-drift or `size()===0`) and on the large-path first-query
* lazy force-rebuild. A NON-migrating sibling still rebuilds when it needs to.
* - Hook 2: the public `brain.stampBrainFormat()` the provider calls once its
* background migration has verified-and-swapped, authoring the shared
* `_system/brain-format.json` marker.
* - Hook 3: the marker module is re-exported at `@soulcraftlabs/brainy/brain-format`
* so cor reads the SAME `EXPECTED_INDEX_EPOCH` / `CURRENT_DATA_FORMAT` constants
* (single source of truth, no duplicated value).
*
* These paths are never exercised by brainy CI (cor registers no provider there),
* so the deference gates and the public stamp are pinned here with a white-box
* test-double provider the same pattern as the cold-graph / handshake tests.
*/
import { describe, it, expect, afterEach, vi } from 'vitest'
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
import { Brainy, VectorIndexNotReadyError } from '../../../src/index.js'
import { NounType } from '../../../src/types/graphTypes.js'
import { createTestConfig } from '../../helpers/test-factory.js'
import { BaseStorage } from '../../../src/storage/baseStorage.js'
import {
BRAIN_FORMAT_PATH,
CURRENT_DATA_FORMAT,
EXPECTED_INDEX_EPOCH
} from '../../../src/storage/brainFormat.js'
const CURRENT_MARKER = { dataFormat: CURRENT_DATA_FORMAT, indexEpoch: EXPECTED_INDEX_EPOCH }
/** The white-box surface this suite drives on a live brain instance. */
interface BrainInternals {
index: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
metadataIndex: { rebuild(...a: unknown[]): Promise<unknown> }
graphIndex: { size(): number; rebuild(...a: unknown[]): Promise<unknown> }
_indexEpochStale: boolean
rebuildIndexesIfNeeded(force?: boolean): Promise<void>
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
ensureIndexesLoaded(): void
storage: { readRawObject(p: string): Promise<unknown> }
}
const brains: Brainy[] = []
/** Open (and track) a warmed memory brain holding `count` document entities. */
async function makeWarmBrain(count = 2, extraConfig: Record<string, unknown> = {}): Promise<Brainy> {
const brain = new Brainy(createTestConfig(extraConfig))
await brain.init()
brains.push(brain)
for (let i = 0; i < count; i++) {
await brain.add({ data: `doc-${i}`, type: NounType.Document, metadata: { k: i } })
}
return brain
}
/** Cast a brain to its white-box internals. */
function internalsOf(brain: Brainy): BrainInternals {
return brain as unknown as BrainInternals
}
/** Force `provider.isMigrating()` to a fixed value (the native provider's deference flag). */
function setMigrating(provider: object, value: boolean): void {
;(provider as { isMigrating?: () => boolean }).isMigrating = () => value
}
afterEach(async () => {
for (const brain of brains.splice(0)) {
try {
await brain.close()
} catch {
// already closed by the test
}
}
vi.restoreAllMocks()
})
describe('rc.8 no-freeze migration deference (isMigrating / stampBrainFormat / brain-format export)', () => {
// --- Hook 1: per-index deference in rebuildIndexesIfNeeded ----------------
it('metadata provider isMigrating(): its rebuild is skipped under epoch-drift; vector + graph siblings still rebuild', async () => {
const brain = await makeWarmBrain()
const internals = internalsOf(brain)
const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)
setMigrating(internals.metadataIndex, true)
// Epoch-drift would normally force ALL three to rebuild past the warm fast path.
internals._indexEpochStale = true
await internals.rebuildIndexesIfNeeded()
// The migrating provider owns its index — brainy does NOT rebuild it.
expect(miSpy).toHaveBeenCalledTimes(0)
// Non-migrating siblings still rebuild under the drift.
expect(idxSpy).toHaveBeenCalledTimes(1)
expect(giSpy).toHaveBeenCalledTimes(1)
// The marker is NOT advanced while a migration is in flight — cor stamps it
// when its build-new→verify→swap completes, so the stale flag stays set.
expect(internals._indexEpochStale).toBe(true)
})
it('vector provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + graph siblings still rebuild', async () => {
const brain = await makeWarmBrain()
const internals = internalsOf(brain)
const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)
setMigrating(internals.index, true)
internals._indexEpochStale = true
await internals.rebuildIndexesIfNeeded()
expect(idxSpy).toHaveBeenCalledTimes(0)
expect(miSpy).toHaveBeenCalledTimes(1)
expect(giSpy).toHaveBeenCalledTimes(1)
expect(internals._indexEpochStale).toBe(true)
})
it('graph provider isMigrating(): its rebuild is skipped under epoch-drift; metadata + vector siblings still rebuild', async () => {
const brain = await makeWarmBrain()
const internals = internalsOf(brain)
const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)
setMigrating(internals.graphIndex, true)
internals._indexEpochStale = true
await internals.rebuildIndexesIfNeeded()
expect(giSpy).toHaveBeenCalledTimes(0)
expect(miSpy).toHaveBeenCalledTimes(1)
expect(idxSpy).toHaveBeenCalledTimes(1)
expect(internals._indexEpochStale).toBe(true)
})
fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers A production deployment measured ~48 seconds on EVERY reopen of an 11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory size()/count, which read 0 for a durable-but-not-resident index, so it re-read every entity file to rebuild from scratch. At GA we gave only the GRAPH provider a readiness contract (init() eager cold-load + isReady() honest signal) so it would never eat that spurious rebuild; the vector and metadata providers never got it, and brainy never even eager-inited the vector provider. Complete the contract symmetrically: - plugin.ts: VectorIndexProvider gains optional init()+isReady(); MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider. Additive and optional; a provider that exposes nothing keeps today's behavior. - brainy.ts: eager-init every provider that exposes init() (after metadata init() so the id-mapper is hydrated first), then decide per leg in precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady() -> a per-leg empty fallback. The old instant fast-path keyed off this.index.size()>0, a dishonest proxy that skipped the metadata/graph checks whenever the vector was warm and never fired on a real cold process anyway; removed. The per-leg fallbacks differ because "empty" means different things: the JS vector's rebuild() IS its load, so size()===0 correctly triggers it; the id-mapper backs metadata, so totalEntries===0 (past the empty-store return) is a real load failure; but entities do not imply edges, so a graph size()===0 is a valid empty state, not a load failure. - The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a full canonical verb scan on every boot (baseStorage._initializeGraphIndex loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it self-heals from canonical only when the durable state is genuinely missing). This removes an O(E)-per-open cost every filesystem consumer paid. - LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship count, and resets to an honest-empty state on load failure — a tree can no longer claim persisted relationships while holding none (the silent-empty cold-load class the query-time guards exist to prevent). Verified end-to-end against a built brain: a warm reopen (with edges and edgeless) reloads only the JS vector; the graph and metadata cold-load with no rebuild, and queries return correct results. New tests in cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal still fires); migration-deference updated to drive size-based deference through the vector, the leg where empty->rebuild remains correct. Pairs with the native provider's isReady()/init() implementation — brainy's gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00
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)
const miSpy = vi.spyOn(internals.metadataIndex, 'rebuild').mockResolvedValue(undefined)
const idxSpy = vi.spyOn(internals.index, 'rebuild').mockResolvedValue(undefined)
const giSpy = vi.spyOn(internals.graphIndex, 'rebuild').mockResolvedValue(undefined)
fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers A production deployment measured ~48 seconds on EVERY reopen of an 11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory size()/count, which read 0 for a durable-but-not-resident index, so it re-read every entity file to rebuild from scratch. At GA we gave only the GRAPH provider a readiness contract (init() eager cold-load + isReady() honest signal) so it would never eat that spurious rebuild; the vector and metadata providers never got it, and brainy never even eager-inited the vector provider. Complete the contract symmetrically: - plugin.ts: VectorIndexProvider gains optional init()+isReady(); MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider. Additive and optional; a provider that exposes nothing keeps today's behavior. - brainy.ts: eager-init every provider that exposes init() (after metadata init() so the id-mapper is hydrated first), then decide per leg in precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady() -> a per-leg empty fallback. The old instant fast-path keyed off this.index.size()>0, a dishonest proxy that skipped the metadata/graph checks whenever the vector was warm and never fired on a real cold process anyway; removed. The per-leg fallbacks differ because "empty" means different things: the JS vector's rebuild() IS its load, so size()===0 correctly triggers it; the id-mapper backs metadata, so totalEntries===0 (past the empty-store return) is a real load failure; but entities do not imply edges, so a graph size()===0 is a valid empty state, not a load failure. - The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a full canonical verb scan on every boot (baseStorage._initializeGraphIndex loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it self-heals from canonical only when the durable state is genuinely missing). This removes an O(E)-per-open cost every filesystem consumer paid. - LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship count, and resets to an honest-empty state on load failure — a tree can no longer claim persisted relationships while holding none (the silent-empty cold-load class the query-time guards exist to prevent). Verified end-to-end against a built brain: a warm reopen (with edges and edgeless) reloads only the JS vector; the graph and metadata cold-load with no rebuild, and queries return correct results. New tests in cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal still fires); migration-deference updated to drive size-based deference through the vector, the leg where empty->rebuild remains correct. Pairs with the native provider's isReady()/init() implementation — brainy's gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00
// 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
vi.spyOn(internals.index, 'size').mockReturnValue(0)
fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers A production deployment measured ~48 seconds on EVERY reopen of an 11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory size()/count, which read 0 for a durable-but-not-resident index, so it re-read every entity file to rebuild from scratch. At GA we gave only the GRAPH provider a readiness contract (init() eager cold-load + isReady() honest signal) so it would never eat that spurious rebuild; the vector and metadata providers never got it, and brainy never even eager-inited the vector provider. Complete the contract symmetrically: - plugin.ts: VectorIndexProvider gains optional init()+isReady(); MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider. Additive and optional; a provider that exposes nothing keeps today's behavior. - brainy.ts: eager-init every provider that exposes init() (after metadata init() so the id-mapper is hydrated first), then decide per leg in precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady() -> a per-leg empty fallback. The old instant fast-path keyed off this.index.size()>0, a dishonest proxy that skipped the metadata/graph checks whenever the vector was warm and never fired on a real cold process anyway; removed. The per-leg fallbacks differ because "empty" means different things: the JS vector's rebuild() IS its load, so size()===0 correctly triggers it; the id-mapper backs metadata, so totalEntries===0 (past the empty-store return) is a real load failure; but entities do not imply edges, so a graph size()===0 is a valid empty state, not a load failure. - The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a full canonical verb scan on every boot (baseStorage._initializeGraphIndex loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it self-heals from canonical only when the durable state is genuinely missing). This removes an O(E)-per-open cost every filesystem consumer paid. - LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship count, and resets to an honest-empty state on load failure — a tree can no longer claim persisted relationships while holding none (the silent-empty cold-load class the query-time guards exist to prevent). Verified end-to-end against a built brain: a warm reopen (with edges and edgeless) reloads only the JS vector; the graph and metadata cold-load with no rebuild, and queries return correct results. New tests in cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal still fires); migration-deference updated to drive size-based deference through the vector, the leg where empty->rebuild remains correct. Pairs with the native provider's isReady()/init() implementation — brainy's gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00
// Migrating: the provider's background swap owns the index, so the
// size()===0 load trigger is suppressed.
setMigrating(internals.index, true)
await internals.rebuildIndexesIfNeeded()
expect(idxSpy).toHaveBeenCalledTimes(0)
fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers A production deployment measured ~48 seconds on EVERY reopen of an 11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory size()/count, which read 0 for a durable-but-not-resident index, so it re-read every entity file to rebuild from scratch. At GA we gave only the GRAPH provider a readiness contract (init() eager cold-load + isReady() honest signal) so it would never eat that spurious rebuild; the vector and metadata providers never got it, and brainy never even eager-inited the vector provider. Complete the contract symmetrically: - plugin.ts: VectorIndexProvider gains optional init()+isReady(); MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider. Additive and optional; a provider that exposes nothing keeps today's behavior. - brainy.ts: eager-init every provider that exposes init() (after metadata init() so the id-mapper is hydrated first), then decide per leg in precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady() -> a per-leg empty fallback. The old instant fast-path keyed off this.index.size()>0, a dishonest proxy that skipped the metadata/graph checks whenever the vector was warm and never fired on a real cold process anyway; removed. The per-leg fallbacks differ because "empty" means different things: the JS vector's rebuild() IS its load, so size()===0 correctly triggers it; the id-mapper backs metadata, so totalEntries===0 (past the empty-store return) is a real load failure; but entities do not imply edges, so a graph size()===0 is a valid empty state, not a load failure. - The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a full canonical verb scan on every boot (baseStorage._initializeGraphIndex loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it self-heals from canonical only when the durable state is genuinely missing). This removes an O(E)-per-open cost every filesystem consumer paid. - LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship count, and resets to an honest-empty state on load failure — a tree can no longer claim persisted relationships while holding none (the silent-empty cold-load class the query-time guards exist to prevent). Verified end-to-end against a built brain: a warm reopen (with edges and edgeless) reloads only the JS vector; the graph and metadata cold-load with no rebuild, and queries return correct results. New tests in cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal still fires); migration-deference updated to drive size-based deference through the vector, the leg where empty->rebuild remains correct. Pairs with the native provider's isReady()/init() implementation — brainy's gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00
// Metadata has entries; the graph has no edges; neither drifted → no rebuild.
expect(miSpy).toHaveBeenCalledTimes(0)
fix: cold-open no longer re-derives durable indexes — complete the readiness contract for all three providers A production deployment measured ~48 seconds on EVERY reopen of an 11k-entity brain. Root cause: brainy's rebuild gate decided from in-memory size()/count, which read 0 for a durable-but-not-resident index, so it re-read every entity file to rebuild from scratch. At GA we gave only the GRAPH provider a readiness contract (init() eager cold-load + isReady() honest signal) so it would never eat that spurious rebuild; the vector and metadata providers never got it, and brainy never even eager-inited the vector provider. Complete the contract symmetrically: - plugin.ts: VectorIndexProvider gains optional init()+isReady(); MetadataIndexProvider gains isReady() — mirroring GraphIndexProvider. Additive and optional; a provider that exposes nothing keeps today's behavior. - brainy.ts: eager-init every provider that exposes init() (after metadata init() so the id-mapper is hydrated first), then decide per leg in precedence order — migrating (skip) -> epoch drift (rebuild) -> isReady() -> a per-leg empty fallback. The old instant fast-path keyed off this.index.size()>0, a dishonest proxy that skipped the metadata/graph checks whenever the vector was warm and never fired on a real cold process anyway; removed. The per-leg fallbacks differ because "empty" means different things: the JS vector's rebuild() IS its load, so size()===0 correctly triggers it; the id-mapper backs metadata, so totalEntries===0 (past the empty-store return) is a real load failure; but entities do not imply edges, so a graph size()===0 is a valid empty state, not a load failure. - The JS graph now COLD-LOADS its durable LSM instead of re-deriving from a full canonical verb scan on every boot (baseStorage._initializeGraphIndex loads the persisted SSTables via a new GraphAdjacencyIndex.init(); it self-heals from canonical only when the durable state is genuinely missing). This removes an O(E)-per-open cost every filesystem consumer paid. - LSMTree.loadManifest loads its SSTables BEFORE publishing the relationship count, and resets to an honest-empty state on load failure — a tree can no longer claim persisted relationships while holding none (the silent-empty cold-load class the query-time guards exist to prevent). Verified end-to-end against a built brain: a warm reopen (with edges and edgeless) reloads only the JS vector; the graph and metadata cold-load with no rebuild, and queries return correct results. New tests in cold-open-rebuild-gate.test.ts pin the contract (isReady() defers, self-heal still fires); migration-deference updated to drive size-based deference through the vector, the leg where empty->rebuild remains correct. Pairs with the native provider's isReady()/init() implementation — brainy's gate defers only to a signal the provider exposes.
2026-07-07 10:39:00 -07:00
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)
})
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
// --- Hook 1: read-gate deference (RE-POINTED — the health-gate law retired
// the first-query lazy force-rebuild entirely: ensureIndexesLoaded() is now
// a pure CHECK that never calls rebuildIndexesIfNeeded, migrating or not.
// What survives from the original law is the DEFERENCE itself: a migrating
// provider's report is never judged by the gate — it neither throws nor
// rebuilds — while the exact same not-ready report on a NON-migrating
// provider throws the typed error instead of ever rebuilding.) ------------
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
it('the read gate defers to a migrating vector provider — a not-ready report neither throws nor rebuilds', async () => {
const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
const internals = internalsOf(brain)
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
// Simulate a not-ready live vector index (cor is mid-swap, serving canonical).
;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false
setMigrating(internals.index, true)
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
expect(() => internals.ensureIndexesLoaded()).not.toThrow()
// A query during cor's background swap must not trigger brainy's own
// rebuild — reads never rebuild in any case, migrating or not.
expect(rebuildSpy).toHaveBeenCalledTimes(0)
})
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
it('the read gate THROWS for the same not-ready vector provider once migration clears (control)', async () => {
const brain = await makeWarmBrain(2, { disableAutoRebuild: true })
const internals = internalsOf(brain)
const rebuildSpy = vi.spyOn(internals, 'rebuildIndexesIfNeeded').mockResolvedValue(undefined)
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
;(internals.index as unknown as { isReady?: () => boolean }).isReady = () => false
// No isMigrating → not deferring.
feat(health): the gate reads the named report — reads refuse loudly, never rebuild; open serves before it returns; the ceremony door The read gate stops consulting the unnamed isReady() boolean: every provider may expose healthReport() (sync, O(1), composed from exact ledgers — HealthReport with a monotonic generation, per-invariant source ledger|deep|unledgered, missing {count, sample}), and one readiness authority (assessProviderHealth) derives the verdict. Unledgered families are UNKNOWN — never healthy, never broken; a report that throws is a loud not-ready, never a shrug. Reads at the four index choke points refuse with the typed NotReady errors, narrated once per (provider, generation) — a read NEVER starts a store walk: - the first-read lazy build retires (open builds instead, regardless of size — the ≥10k deferral and the "lazy loading on first query" branch go; disableAutoRebuild is re-meant honestly in its docs); - the verify*Live read-path rebuild triggers retire (refuse-or-serve); - the read-time consistency probe that could launch a dark rebuild from an ordinary find() retires; - repairIndex({ rebuild: ['metadata'|'graph'|'vector'] | 'all' }) is the one explicit door: rebuilds the named leg unconditionally and reports rebuilt per family; bare repairIndex() stays report-driven. test(lifecycle): the biography lane — a store's whole life, refereed tests/lifecycle/: an independent shadow model referees every read after every chapter (founding, a working day, clean restart, crash, repair, second life). Chapters 1-3 green. Chapters 4-6 assert the true contract and are marked .fails as a release-blocking finding (the kill-matrix convention): after a crash + adopt reopen the metadata index computes its 'catchup' watermark verdict and nothing consumes it — find() serves the pre-crash index while canonical and counts recover. The catchup wiring is the cure; a passing .fails will force the marker's removal. The lane runs in the integration gate (config + coverage guard).
2026-08-24 12:45:51 -07:00
expect(() => internals.ensureIndexesLoaded()).toThrow(VectorIndexNotReadyError)
// Still never rebuilds — the gate refuses loudly instead.
expect(rebuildSpy).toHaveBeenCalledTimes(0)
})
// --- Hook 2: public stampBrainFormat() -----------------------------------
it('brain.stampBrainFormat() writes _system/brain-format.json with the current {dataFormat, indexEpoch}', async () => {
const brain = new Brainy(createTestConfig())
await brain.init()
brains.push(brain)
const internals = internalsOf(brain)
// Spy AFTER init so only the stamp call's write is observed (init already
// stamped the fresh brain).
const writeSpy = vi.spyOn(BaseStorage.prototype, 'writeRawObject')
await brain.stampBrainFormat()
const stampWrites = writeSpy.mock.calls.filter(([p]) => p === BRAIN_FORMAT_PATH)
expect(stampWrites.length).toBe(1)
expect(stampWrites[0][1]).toEqual(CURRENT_MARKER)
// And the marker is durable on disk at the current epoch.
const onDisk = await internals.storage.readRawObject(BRAIN_FORMAT_PATH)
expect(onDisk).toEqual(CURRENT_MARKER)
})
// --- Hook 3: marker module export ----------------------------------------
it('the brain-format marker module exports the compiled epoch + data-format constants', () => {
// cor imports these from '@soulcraftlabs/brainy/brain-format' (Hook 3) so both
// sides share ONE source of truth — no duplicated constant to drift.
// Epoch 3: the namespace-law key split (bare user keys · literal
// 'system.<field>' scalars, 2026-08-03) — every brain rebuilds onto the
// frozen keys at first open. (Epoch 2 same day: `level` indexability.)
expect(EXPECTED_INDEX_EPOCH).toBe(3)
expect(CURRENT_DATA_FORMAT).toBe('8.0')
})
})