feat: validateIndexConsistency delegates to provider invariants (ADR-004 Pass 3)
validateIndexConsistency() was blind to native providers — it only ran the JS metadata index's own check, so a native provider whose manifest/segments/counts had diverged still read as "healthy". Per ADR-004 §6 it now feature-detects and aggregates each provider's optional validateInvariants() (a never-throwing, <50ms self-report of its own cross-layer invariants), names any failing invariant with its numbers in the recommendation, and exposes the per-provider reports. A provider that violates the never-throw contract is surfaced as unhealthy, never swallowed. repairIndex() now reconciles NATIVE derived state from canonical too: it consults each provider's invariants and calls rebuild() on any whose failing invariant asks for heal:'rebuild' — the native counterpart of detectAndRepairCorruption(). New provider surface: optional validateInvariants(); new exported types ProviderInvariantReport / InvariantResult / InvariantHeal. Additive, no break. Cor implements the hook in 3.0.15 (M2); brainy builds against the shape now (feature-detected, inert until a provider exposes it). 5 tests.
This commit is contained in:
parent
7b75f932d4
commit
6bcb54f0d9
4 changed files with 299 additions and 19 deletions
126
src/brainy.ts
126
src/brainy.ts
|
|
@ -190,7 +190,7 @@ import type {
|
|||
HistoryVersion
|
||||
} from './db/types.js'
|
||||
import { stableDeepEqual } from './db/stableEqual.js'
|
||||
import type { VersionedIndexProvider } from './plugin.js'
|
||||
import type { VersionedIndexProvider, ProviderInvariantReport } from './plugin.js'
|
||||
import type { Operation, TransactionFunction } from './transaction/types.js'
|
||||
|
||||
/**
|
||||
|
|
@ -12530,33 +12530,96 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
entityCount: number
|
||||
indexEntryCount: number
|
||||
recommendation: string | null
|
||||
/** Cross-layer (ADR-004 §6): each provider's own invariant self-report, when
|
||||
* it exposes validateInvariants(). Absent providers are simply not listed. */
|
||||
providers?: ProviderInvariantReport[]
|
||||
}> {
|
||||
await this.ensureInitialized()
|
||||
const result = await this.metadataIndex.validateConsistency()
|
||||
// Fold in a non-fatal index-rebuild failure recorded at init() so the degraded
|
||||
// state is observable through the same health surface (not just the console).
|
||||
|
||||
// Cross-layer integrity (ADR-004 §6): validateConsistency() above only sees the
|
||||
// JS metadata index — it is BLIND to a native provider whose manifest ↔
|
||||
// segments ↔ counts have diverged. Delegate to each provider's own
|
||||
// validateInvariants() and aggregate, so "healthy-while-broken" is impossible.
|
||||
const providers = await this.collectProviderInvariants()
|
||||
const brokenProviders = providers.filter((p) => !p.healthy)
|
||||
|
||||
let healthy = result.healthy
|
||||
const notes: string[] = []
|
||||
if (result.recommendation) notes.push(result.recommendation)
|
||||
|
||||
if (this._indexRebuildFailed) {
|
||||
return {
|
||||
...result,
|
||||
healthy: false,
|
||||
recommendation:
|
||||
`Index rebuild failed at init() (degraded — queries may be incomplete): ` +
|
||||
`${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.` +
|
||||
(result.recommendation ? ` Also: ${result.recommendation}` : '')
|
||||
}
|
||||
healthy = false
|
||||
notes.unshift(
|
||||
`Index rebuild failed at init() (degraded — queries may be incomplete): ` +
|
||||
`${this._indexRebuildFailed.message}. Rebuild the indexes and re-open.`
|
||||
)
|
||||
}
|
||||
if (this._indexDegradedIds.size > 0) {
|
||||
return {
|
||||
...result,
|
||||
healthy: false,
|
||||
recommendation:
|
||||
`${this._indexDegradedIds.size} record(s) committed via adopt-forward ` +
|
||||
healthy = false
|
||||
notes.unshift(
|
||||
`${this._indexDegradedIds.size} record(s) committed via adopt-forward ` +
|
||||
`failed-rollback recovery — the derived index may be incomplete for them. ` +
|
||||
`Run repairIndex() to reconcile.` +
|
||||
(result.recommendation ? ` Also: ${result.recommendation}` : '')
|
||||
`Run repairIndex() to reconcile.`
|
||||
)
|
||||
}
|
||||
for (const p of brokenProviders) {
|
||||
healthy = false
|
||||
const failing = p.invariants.filter((i) => !i.holds)
|
||||
notes.unshift(
|
||||
`Provider '${p.provider}' reports ${failing.length} failing invariant(s): ` +
|
||||
failing.map((i) => `${i.name} (${i.detail}; heal=${i.heal})`).join('; ') +
|
||||
`. Run repairIndex() to reconcile from canonical.`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
healthy,
|
||||
recommendation: notes.length > 0 ? notes.join(' ') : null,
|
||||
...(providers.length > 0 ? { providers } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Feature-detect + call each index provider's OPTIONAL
|
||||
* `validateInvariants()` (ADR-004 §6) and collect the reports. The contract is
|
||||
* that `validateInvariants()` NEVER throws and is bounded <50ms — but if a
|
||||
* provider violates that, the throw is turned into an UNHEALTHY synthetic
|
||||
* report (loud), never swallowed into "healthy". Providers that do not expose
|
||||
* the hook are simply omitted (the JS baseline validates via
|
||||
* `metadataIndex.validateConsistency()`).
|
||||
* @returns One report per provider that exposes `validateInvariants()`.
|
||||
*/
|
||||
private async collectProviderInvariants(): Promise<ProviderInvariantReport[]> {
|
||||
const reports: ProviderInvariantReport[] = []
|
||||
const providers: unknown[] = [this.metadataIndex, this.index, this.graphIndex]
|
||||
for (const provider of providers) {
|
||||
const fn = (provider as { validateInvariants?: () => Promise<ProviderInvariantReport> } | null)
|
||||
?.validateInvariants
|
||||
if (typeof fn !== 'function') continue
|
||||
try {
|
||||
const report = await fn.call(provider)
|
||||
if (report && Array.isArray(report.invariants)) reports.push(report)
|
||||
} catch (err) {
|
||||
reports.push({
|
||||
provider: 'unknown',
|
||||
healthy: false,
|
||||
serving: false,
|
||||
invariants: [
|
||||
{
|
||||
name: 'validate-invariants-threw',
|
||||
holds: false,
|
||||
detail: `validateInvariants() threw (contract violation — it must never throw): ${(err as Error).message}`,
|
||||
heal: 'rebuild'
|
||||
}
|
||||
],
|
||||
checkedAt: Date.now(),
|
||||
durationMs: 0
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
return reports
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -15112,6 +15175,31 @@ export class Brainy<T = any> implements BrainyInterface<T> {
|
|||
`Writes are re-enabled.`
|
||||
)
|
||||
}
|
||||
// Cross-layer repair (ADR-004 §6): repairIndex must reconcile NATIVE derived
|
||||
// state from canonical, not just the JS metadata index. Consult each provider's
|
||||
// own validateInvariants() and rebuild any whose failing invariant asks for it
|
||||
// (heal: 'rebuild') — the native counterpart of detectAndRepairCorruption().
|
||||
for (const provider of [this.metadataIndex, this.index, this.graphIndex]) {
|
||||
const p = provider as {
|
||||
validateInvariants?: () => Promise<ProviderInvariantReport>
|
||||
rebuild?: () => Promise<void>
|
||||
} | null
|
||||
if (!p || typeof p.validateInvariants !== 'function' || typeof p.rebuild !== 'function') continue
|
||||
let report: ProviderInvariantReport
|
||||
try {
|
||||
report = await p.validateInvariants()
|
||||
} catch {
|
||||
continue // a throwing validateInvariants is surfaced by validateIndexConsistency; skip repair here
|
||||
}
|
||||
if (report.healthy) continue
|
||||
if (report.invariants.some((i) => !i.holds && i.heal === 'rebuild')) {
|
||||
prodLog.warn(
|
||||
`[Brainy] repairIndex(): provider '${report.provider}' has a failing invariant ` +
|
||||
`requiring a rebuild — reconciling its derived state from canonical.`
|
||||
)
|
||||
await p.rebuild()
|
||||
}
|
||||
}
|
||||
// detectAndRepairCorruption() above rebuilt the derived indexes from
|
||||
// canonical, so any adopt-forward degraded ids and a non-fatal init
|
||||
// rebuild failure are now reconciled — clear the queryable degraded state
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ export type {
|
|||
// Optional provider capability for generation-aware native indexes
|
||||
export { isVersionedIndexProvider } from './plugin.js'
|
||||
export type { VersionedIndexProvider } from './plugin.js'
|
||||
export type { ProviderInvariantReport, InvariantResult, InvariantHeal } from './plugin.js'
|
||||
// Optional native graph-acceleration engine (cor 3.0) — the published provider
|
||||
// contract + its columnar wire types. Brainy feature-detects an implementation
|
||||
// and falls back to its pure-TS adjacency when absent.
|
||||
|
|
|
|||
|
|
@ -115,6 +115,62 @@ export interface BrainyPluginContext {
|
|||
// implementation then fails to compile until it provides the member.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* @description How a failed provider invariant should be remediated (ADR-004 §6):
|
||||
* - `'none'` — informational; the invariant held or nothing to do.
|
||||
* - `'repair'` — a targeted, cheap fix exists (e.g. re-derive a count/manifest field).
|
||||
* - `'rebuild'` — the derived state must be rebuilt from canonical (`provider.rebuild()`).
|
||||
*/
|
||||
export type InvariantHeal = 'none' | 'repair' | 'rebuild'
|
||||
|
||||
/**
|
||||
* @description The result of ONE provider invariant check (ADR-004 §6). A failure
|
||||
* (`holds === false`) NAMES what diverged, with numbers, so it is diagnosable
|
||||
* from the report alone — never a bare boolean. `name` is a stable kebab-case id
|
||||
* for telemetry / remediation routing.
|
||||
*/
|
||||
export interface InvariantResult {
|
||||
/** Stable kebab-case id, e.g. `'manifest-residency'` / `'posted-count-floor'`. */
|
||||
name: string
|
||||
/** `true` when the invariant holds. */
|
||||
holds: boolean
|
||||
/** Human-readable detail; on failure, names the divergence WITH numbers. */
|
||||
detail: string
|
||||
/** The value the invariant expected (optional, for diagnosis). */
|
||||
expected?: unknown
|
||||
/** The value actually observed (optional, for diagnosis). */
|
||||
actual?: unknown
|
||||
/** How a failure should be remediated. Ignored when `holds === true`. */
|
||||
heal: InvariantHeal
|
||||
}
|
||||
|
||||
/**
|
||||
* @description A provider's self-report of its own cross-layer invariants
|
||||
* (ADR-004 §6 — the `validateInvariants()` hook). Contract:
|
||||
* - It NEVER throws — a failure is DATA (`healthy: false` + a failing invariant),
|
||||
* not an exception.
|
||||
* - It is BOUNDED (<50ms): residency checks + O(1) counts only, NO canonical
|
||||
* walks — safe to call on a live brain, repeatedly.
|
||||
* - `serving` = can the provider answer queries right now (the `isReady()` truth);
|
||||
* `healthy` = do ALL invariants hold. A provider can be `serving` while an
|
||||
* invariant flags a latent divergence, or `healthy` but not-yet-`serving` on a
|
||||
* cold open.
|
||||
*/
|
||||
export interface ProviderInvariantReport {
|
||||
/** Which provider produced this report, e.g. `'vector'` / `'graph'` / `'metadata'` / `'column'`. */
|
||||
provider: string
|
||||
/** `true` iff every invariant in {@link invariants} holds. */
|
||||
healthy: boolean
|
||||
/** `true` iff the provider can serve queries now (the `isReady()` truth). */
|
||||
serving: boolean
|
||||
/** Each checked invariant and its verdict. */
|
||||
invariants: InvariantResult[]
|
||||
/** Epoch millis when the check ran. */
|
||||
checkedAt: number
|
||||
/** How long the check took (must stay well under 50ms). */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The `'metadataIndex'` provider — a drop-in for `MetadataIndexManager`.
|
||||
* Brainy calls this surface via `this.metadataIndex.*` (see `brainy.ts`) and
|
||||
|
|
@ -139,6 +195,18 @@ export interface MetadataIndexProvider {
|
|||
*/
|
||||
isReady?(): boolean
|
||||
|
||||
/**
|
||||
* @description OPTIONAL (ADR-004 §6). The provider's self-report of its own
|
||||
* cross-layer invariants (manifest ↔ segments ↔ counts residency/coherence).
|
||||
* MUST NOT throw — a failure is DATA (`healthy: false` + a failing invariant).
|
||||
* MUST be bounded (<50ms): residency + O(1) counts only, NO canonical walks, so
|
||||
* brainy's {@link } `validateIndexConsistency()` can call it on a live brain.
|
||||
* Absent → brainy skips this provider in the cross-layer check (feature-detected).
|
||||
* `repairIndex()` maps any failing invariant with `heal: 'rebuild'` to this
|
||||
* provider's `rebuild()`.
|
||||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. A native provider returns true from the moment its
|
||||
* `init()` detects a large epoch-drift until its background
|
||||
|
|
@ -289,6 +357,18 @@ export interface GraphIndexProvider {
|
|||
*/
|
||||
isReady?(): boolean
|
||||
|
||||
/**
|
||||
* @description OPTIONAL (ADR-004 §6). The provider's self-report of its own
|
||||
* cross-layer invariants (manifest ↔ segments ↔ counts residency/coherence).
|
||||
* MUST NOT throw — a failure is DATA (`healthy: false` + a failing invariant).
|
||||
* MUST be bounded (<50ms): residency + O(1) counts only, NO canonical walks, so
|
||||
* brainy's {@link } `validateIndexConsistency()` can call it on a live brain.
|
||||
* Absent → brainy skips this provider in the cross-layer check (feature-detected).
|
||||
* `repairIndex()` maps any failing invariant with `heal: 'rebuild'` to this
|
||||
* provider's `rebuild()`.
|
||||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL eager cold-load. Called once during brain init — AFTER
|
||||
* the metadata provider's `init()` (so the id-mapper is hydrated; a native int
|
||||
|
|
@ -943,6 +1023,18 @@ export interface VectorIndexProvider {
|
|||
*/
|
||||
isReady?(): boolean
|
||||
|
||||
/**
|
||||
* @description OPTIONAL (ADR-004 §6). The provider's self-report of its own
|
||||
* cross-layer invariants (manifest ↔ segments ↔ counts residency/coherence).
|
||||
* MUST NOT throw — a failure is DATA (`healthy: false` + a failing invariant).
|
||||
* MUST be bounded (<50ms): residency + O(1) counts only, NO canonical walks, so
|
||||
* brainy's {@link } `validateIndexConsistency()` can call it on a live brain.
|
||||
* Absent → brainy skips this provider in the cross-layer check (feature-detected).
|
||||
* `repairIndex()` maps any failing invariant with `heal: 'rebuild'` to this
|
||||
* provider's `rebuild()`.
|
||||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. A native provider returns true from the moment its
|
||||
* `init()` detects a large epoch-drift until its background
|
||||
|
|
|
|||
99
tests/unit/validate-invariants-delegation.test.ts
Normal file
99
tests/unit/validate-invariants-delegation.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* @module tests/unit/validate-invariants-delegation
|
||||
* @description Pass 3 (ADR-004 §6): validateIndexConsistency() was blind to native
|
||||
* providers — it only saw the JS metadata index, so a native manifest↔segments↔count
|
||||
* divergence read as "healthy". It now feature-detects + aggregates each provider's
|
||||
* validateInvariants(), and repairIndex() maps a failing invariant with heal:'rebuild'
|
||||
* to that provider's rebuild(). "healthy-while-broken must be impossible."
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { Brainy, NounType } from '../../src/index.js'
|
||||
import type { ProviderInvariantReport } from '../../src/index.js'
|
||||
|
||||
const healthyReport = (provider: string): ProviderInvariantReport => ({
|
||||
provider,
|
||||
healthy: true,
|
||||
serving: true,
|
||||
invariants: [{ name: 'manifest-residency', holds: true, detail: 'ok', heal: 'none' }],
|
||||
checkedAt: 1,
|
||||
durationMs: 1
|
||||
})
|
||||
|
||||
const brokenReport = (provider: string): ProviderInvariantReport => ({
|
||||
provider,
|
||||
healthy: false,
|
||||
serving: true,
|
||||
invariants: [
|
||||
{ name: 'manifest-residency', holds: true, detail: 'ok', heal: 'none' },
|
||||
{
|
||||
name: 'posted-count-floor',
|
||||
holds: false,
|
||||
detail: 'posted 2304 < canonical 2354',
|
||||
expected: 2354,
|
||||
actual: 2304,
|
||||
heal: 'rebuild'
|
||||
}
|
||||
],
|
||||
checkedAt: 1,
|
||||
durationMs: 2
|
||||
})
|
||||
|
||||
describe('validateIndexConsistency delegates to provider validateInvariants() (Pass 3)', () => {
|
||||
let brain: any
|
||||
beforeEach(async () => {
|
||||
process.env.BRAINY_DETERMINISTIC_EMBEDDINGS = 'true'
|
||||
brain = new Brainy({ requireSubtype: false, storage: { type: 'memory' }, dimensions: 384 })
|
||||
await brain.init()
|
||||
await brain.add({ data: 'x', type: NounType.Concept })
|
||||
await brain.flush()
|
||||
})
|
||||
|
||||
it('a broken provider report makes the store unhealthy and names the failing invariant', async () => {
|
||||
brain.index.validateInvariants = async () => brokenReport('vector')
|
||||
const v = await brain.validateIndexConsistency()
|
||||
expect(v.healthy).toBe(false)
|
||||
expect(v.recommendation).toMatch(/posted-count-floor/)
|
||||
expect(v.recommendation).toMatch(/posted 2304 < canonical 2354/)
|
||||
expect(v.recommendation).toMatch(/repairIndex\(\)/)
|
||||
expect(v.providers?.some((p: ProviderInvariantReport) => p.provider === 'vector' && !p.healthy)).toBe(true)
|
||||
delete brain.index.validateInvariants
|
||||
})
|
||||
|
||||
it('all-healthy provider reports do not flip the store unhealthy', async () => {
|
||||
brain.index.validateInvariants = async () => healthyReport('vector')
|
||||
brain.graphIndex.validateInvariants = async () => healthyReport('graph')
|
||||
const v = await brain.validateIndexConsistency()
|
||||
expect(v.healthy).toBe(true)
|
||||
expect(v.providers?.length).toBe(2)
|
||||
delete brain.index.validateInvariants
|
||||
delete brain.graphIndex.validateInvariants
|
||||
})
|
||||
|
||||
it('a validateInvariants() that THROWS is surfaced as unhealthy, never swallowed', async () => {
|
||||
brain.index.validateInvariants = async () => { throw new Error('provider blew up') }
|
||||
const v = await brain.validateIndexConsistency()
|
||||
expect(v.healthy).toBe(false)
|
||||
expect(v.recommendation).toMatch(/validate-invariants-threw/)
|
||||
delete brain.index.validateInvariants
|
||||
})
|
||||
|
||||
it('providers without validateInvariants() are omitted (JS baseline unchanged)', async () => {
|
||||
const v = await brain.validateIndexConsistency()
|
||||
expect(v.providers).toBeUndefined()
|
||||
expect(typeof v.healthy).toBe('boolean')
|
||||
})
|
||||
|
||||
it('repairIndex() rebuilds a provider whose failing invariant asks for it', async () => {
|
||||
let rebuilt = false
|
||||
brain.index.validateInvariants = async () => (rebuilt ? healthyReport('vector') : brokenReport('vector'))
|
||||
const origRebuild = brain.index.rebuild.bind(brain.index)
|
||||
brain.index.rebuild = async (...a: any[]) => { rebuilt = true; return origRebuild(...a) }
|
||||
await brain.repairIndex()
|
||||
expect(rebuilt).toBe(true)
|
||||
// After repair, the store validates healthy again.
|
||||
const v = await brain.validateIndexConsistency()
|
||||
expect(v.providers?.find((p: ProviderInvariantReport) => p.provider === 'vector')?.healthy).toBe(true)
|
||||
brain.index.rebuild = origRebuild
|
||||
delete brain.index.validateInvariants
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue