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).
This commit is contained in:
parent
a8b5ca0c8f
commit
f8f64780b1
19 changed files with 2160 additions and 652 deletions
792
src/brainy.ts
792
src/brainy.ts
File diff suppressed because it is too large
Load diff
|
|
@ -269,6 +269,10 @@ export type { FamilyStamp, StampMembers, StampVerdict } from './db/familyStamp.j
|
|||
export { isVersionedIndexProvider } from './plugin.js'
|
||||
export type { VersionedIndexProvider } from './plugin.js'
|
||||
export type { ProviderInvariantReport, InvariantResult, InvariantHeal } from './plugin.js'
|
||||
// The named, synchronous, O(1) health-report contract (the read gate's ONLY
|
||||
// source of truth for "can I serve right now") — see HealthReport's
|
||||
// derivation laws in plugin.ts.
|
||||
export type { HealthReport, LedgerInvariantResult, InvariantSource } from './plugin.js'
|
||||
// Optional provider self-report of outstanding background maintenance work
|
||||
// (compaction, deferred writes, etc.) — the payload type for
|
||||
// brain.maintenanceDebt(). See the measure-only-what-you-track contract on
|
||||
|
|
|
|||
102
src/plugin.ts
102
src/plugin.ts
|
|
@ -171,6 +171,66 @@ export interface ProviderInvariantReport {
|
|||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Where a {@link LedgerInvariantResult} verdict came from:
|
||||
* - `'ledger'` — decided from an exact, durable ledger (a real count, not a sample).
|
||||
* - `'deep'` — decided by a full/expensive scan (the `validateInvariants()` diagnostic path only).
|
||||
* - `'unledgered'` — this family has no ledger yet; the verdict is UNKNOWN, never healthy and never broken.
|
||||
*/
|
||||
export type InvariantSource = 'ledger' | 'deep' | 'unledgered'
|
||||
|
||||
/**
|
||||
* @description One invariant verdict inside a {@link HealthReport}. Extends
|
||||
* {@link InvariantResult} with the provenance of the verdict ({@link InvariantSource})
|
||||
* and, for a failing set-membership invariant, an exact count plus a capped sample
|
||||
* of the diverging ids — a VERDICT, never a dump. `sample` MUST be capped at 16 ids;
|
||||
* `count` is the exact number even when `sample` is truncated.
|
||||
*/
|
||||
export interface LedgerInvariantResult extends InvariantResult {
|
||||
/** Provenance of this verdict — see {@link InvariantSource}. */
|
||||
source: InvariantSource
|
||||
/** Exact count of diverging/missing items plus a capped (≤16 ids) sample. Present only on a failing set-membership invariant. */
|
||||
missing?: { count: number; sample: string[] }
|
||||
}
|
||||
|
||||
/**
|
||||
* @description The NAMED, SYNCHRONOUS, O(1) health report a provider exposes via
|
||||
* {@link MetadataIndexProvider.healthReport} / {@link GraphIndexProvider.healthReport} /
|
||||
* {@link VectorIndexProvider.healthReport}. This is the read gate's ONLY source of
|
||||
* truth for "can I serve right now" — it replaces sampled self-probes and the
|
||||
* unnamed `isReady()` latch with an exact, ledger-derived verdict.
|
||||
*
|
||||
* Derivation laws (a provider MUST honor these; brainy's read gate assumes them):
|
||||
* - `healthy` = every VERIFIED invariant in {@link invariants} holds. An invariant
|
||||
* whose family is named in {@link unledgered} is NEVER counted toward `healthy`
|
||||
* either way — it is unknown, not passing.
|
||||
* - `serving` = no verified invariant in {@link invariants} FAILS with `heal: 'rebuild'`.
|
||||
* A failure with `heal: 'repair'` or `heal: 'none'` is degraded-but-serving —
|
||||
* `serving` stays `true`. Only a `'rebuild'`-grade failure makes `serving` `false`.
|
||||
* - `validateInvariants()` remains the async DEEP diagnostic (full scans allowed,
|
||||
* `source: 'deep'` results); `healthReport()` MUST be synchronous, O(1) from
|
||||
* exact ledgers/counters, and MUST NOT throw for a well-formed provider — a
|
||||
* provider that cannot produce a safe verdict reports it as a failing invariant,
|
||||
* it does not throw (a throw is read by the gate as a CONTRACT VIOLATION, not as
|
||||
* "unknown").
|
||||
*/
|
||||
export interface HealthReport extends ProviderInvariantReport {
|
||||
/**
|
||||
* Monotonic per provider: bumps on every ledger mutation and every rebuild
|
||||
* boundary. Consumers (the read gate's narration dedup, external callers) may
|
||||
* cache a verdict per generation.
|
||||
*/
|
||||
generation: number
|
||||
/** Each checked invariant, with provenance — see {@link LedgerInvariantResult}. */
|
||||
invariants: LedgerInvariantResult[]
|
||||
/**
|
||||
* Families with no ledger yet. NAMED here so an operator can see what is not
|
||||
* yet tracked — NEVER counted as healthy (they are not verified) and NEVER
|
||||
* counted as broken (there is nothing to fail).
|
||||
*/
|
||||
unledgered: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @description A provider's self-report of its own outstanding background
|
||||
* maintenance work (compaction, deferred writes, a build-new→verify→swap in
|
||||
|
|
@ -266,6 +326,20 @@ export interface MetadataIndexProvider {
|
|||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
|
||||
* provider derives from its own exact ledgers — see {@link HealthReport} for
|
||||
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
|
||||
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
|
||||
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
|
||||
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
|
||||
* whether reads may proceed; a `false` refuses the read loudly rather than
|
||||
* triggering a rebuild. Absent → the gate falls back to `isReady?()` / the
|
||||
* size heuristic (this train's JS built-in providers stay on that interim
|
||||
* path).
|
||||
*/
|
||||
healthReport?(): HealthReport
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. A native provider returns true from the moment its
|
||||
* `init()` detects a large epoch-drift until its background
|
||||
|
|
@ -462,6 +536,20 @@ export interface GraphIndexProvider {
|
|||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
|
||||
* provider derives from its own exact ledgers — see {@link HealthReport} for
|
||||
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
|
||||
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
|
||||
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
|
||||
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
|
||||
* whether reads may proceed; a `false` refuses the read loudly rather than
|
||||
* triggering a rebuild. Absent → the gate falls back to `isReady?()` / the
|
||||
* size heuristic (this train's JS built-in providers stay on that interim
|
||||
* path).
|
||||
*/
|
||||
healthReport?(): HealthReport
|
||||
|
||||
/**
|
||||
* @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
|
||||
|
|
@ -1225,6 +1313,20 @@ export interface VectorIndexProvider {
|
|||
*/
|
||||
validateInvariants?(): Promise<ProviderInvariantReport>
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. The named, SYNCHRONOUS, O(1) health verdict this
|
||||
* provider derives from its own exact ledgers — see {@link HealthReport} for
|
||||
* the full derivation laws. MUST NOT perform I/O and MUST NOT throw for a
|
||||
* well-formed provider (brainy treats a throw as a CONTRACT VIOLATION, never
|
||||
* as "unknown"). When present, brainy's read gate (`assessProviderHealth()`)
|
||||
* reads THIS instead of `isReady()` / size heuristics: `serving` decides
|
||||
* whether reads may proceed; a `false` refuses the read loudly rather than
|
||||
* triggering a rebuild. Absent → the gate falls back to `isReady?()` / the
|
||||
* size heuristic (this train's JS built-in providers stay on that interim
|
||||
* path).
|
||||
*/
|
||||
healthReport?(): HealthReport
|
||||
|
||||
/**
|
||||
* @description OPTIONAL. A native provider returns true from the moment its
|
||||
* `init()` detects a large epoch-drift until its background
|
||||
|
|
|
|||
|
|
@ -1816,10 +1816,16 @@ export interface BrainyConfig {
|
|||
| StorageAdapter
|
||||
|
||||
/**
|
||||
* Disable the automatic index rebuild check during `init()`. By default
|
||||
* Brainy auto-decides from dataset size: small datasets rebuild missing
|
||||
* indexes inline, large datasets rebuild lazily on first query. Set `true`
|
||||
* only when an operator wants full manual control via `repairIndex()`.
|
||||
* RE-MEANT (the health-gate contract): `init()` (open) always verifies the
|
||||
* durable generation of every derived index, and a needed rebuild ALWAYS
|
||||
* runs at open — it is never deferred to the first read, regardless of
|
||||
* dataset size or this flag. There is no first-query lazy-build path
|
||||
* anymore: a read that finds a provider not serving throws a typed
|
||||
* `*NotReadyError` rather than building anything (see
|
||||
* `assessProviderHealth` / the read gate in `brainy.ts`). Setting this
|
||||
* `true` no longer defers index construction to the first query — it has
|
||||
* no effect on WHEN a needed rebuild runs. Full manual control over
|
||||
* rebuilds remains available via `repairIndex({ rebuild: [...] })`.
|
||||
*/
|
||||
disableAutoRebuild?: boolean
|
||||
|
||||
|
|
|
|||
|
|
@ -13,13 +13,28 @@
|
|||
* `size()` or `isInitialized`. When `isReady()` is absent, callers must fall back
|
||||
* to a KNOWN-ITEM PROBE (a real search/lookup that must return a known-present
|
||||
* datum) before trusting an empty result — never a `size()` proxy.
|
||||
*
|
||||
* {@link assessProviderHealth} is the NEWER, PREFERRED authority: it reads a
|
||||
* provider's NAMED, synchronous, O(1) {@link import('../plugin.js').HealthReport}
|
||||
* when one is exposed, and falls back to this file's `isReady()` classifier only
|
||||
* when the provider does not (yet) expose a health report. Read paths in
|
||||
* `brainy.ts` call `assessProviderHealth` exclusively — `assessIndexReadiness`
|
||||
* stays exported for the other call sites (`storage/baseStorage.ts`) and for the
|
||||
* fallback branch inside `assessProviderHealth` itself.
|
||||
*/
|
||||
|
||||
import type { HealthReport } from '../plugin.js'
|
||||
|
||||
/** A provider that MAY expose the honest cold-load readiness signal. */
|
||||
export interface MaybeReadyProvider {
|
||||
isReady?: () => boolean
|
||||
}
|
||||
|
||||
/** A provider that MAY expose the named, synchronous, O(1) health report. */
|
||||
export interface MaybeHealthReportingProvider {
|
||||
healthReport?: () => HealthReport
|
||||
}
|
||||
|
||||
/** Three-valued honest-readiness verdict. */
|
||||
export type IndexReadiness = 'ready' | 'not-ready' | 'unknown'
|
||||
|
||||
|
|
@ -36,3 +51,105 @@ export function assessIndexReadiness(provider: unknown): IndexReadiness {
|
|||
if (p == null || typeof p.isReady !== 'function') return 'unknown'
|
||||
return p.isReady() ? 'ready' : 'not-ready'
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Which signal {@link assessProviderHealth} actually consulted to
|
||||
* produce its verdict — surfaced so callers can narrate (and tests can pin) how
|
||||
* a provider was judged, not just what the judgment was.
|
||||
* - `'health-report'` — the provider's `healthReport()` was called (the authority).
|
||||
* - `'is-ready'` — no `healthReport()`; fell back to the provider's `isReady()`.
|
||||
* - `'size-heuristic'` — no `healthReport()` and no `isReady()`; caller must keep its own size-based heuristic.
|
||||
* - `'none'` — there was no provider to assess (`null`/`undefined`).
|
||||
*/
|
||||
export type ProviderHealthVia = 'health-report' | 'is-ready' | 'size-heuristic' | 'none'
|
||||
|
||||
/** The result of {@link assessProviderHealth}. */
|
||||
export interface ProviderHealthAssessment {
|
||||
/** The honest readiness verdict — see {@link IndexReadiness}. */
|
||||
readiness: IndexReadiness
|
||||
/** The provider's raw {@link HealthReport}, when one was obtained; `null` otherwise. */
|
||||
report: HealthReport | null
|
||||
/** Which signal produced the verdict — see {@link ProviderHealthVia}. */
|
||||
via: ProviderHealthVia
|
||||
/** Human-readable reasons: named failing invariants (with `heal`), unledgered families, or the fallback-path explanation. Empty when the provider is healthy and ready. */
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* @description THE read-gate authority. Prefers a provider's NAMED,
|
||||
* synchronous, O(1) {@link HealthReport} over the older `isReady()` / size
|
||||
* heuristics; falls back to {@link assessIndexReadiness}'s semantics only when
|
||||
* a provider does not (yet) expose `healthReport()`.
|
||||
*
|
||||
* Derivation:
|
||||
* - `healthReport()` present → call it (wrapped in try/catch). A THROW is a
|
||||
* CONTRACT VIOLATION, not "unknown": returns `readiness: 'not-ready'`,
|
||||
* `via: 'health-report'`, and a reason naming the throw — never swallowed
|
||||
* into `'unknown'`.
|
||||
* - Otherwise → `readiness = report.serving ? 'ready' : 'not-ready'`; `reasons`
|
||||
* names every invariant with `holds: false` (with its `heal`), plus an
|
||||
* `unledgered: [...]` line when {@link HealthReport.unledgered} is non-empty.
|
||||
* UNLEDGERED IS UNKNOWN: an unledgered family never flips a serving provider
|
||||
* to not-ready, and never flips a not-serving provider to ready — `serving`
|
||||
* is always the provider's own verdict, verbatim.
|
||||
* - No `healthReport()` → fall back to {@link assessIndexReadiness}'s semantics:
|
||||
* `via: 'is-ready'` when `isReady()` exists, `via: 'size-heuristic'` when
|
||||
* neither hook exists (caller must keep its own size-based heuristic),
|
||||
* `via: 'none'` when there is no provider at all.
|
||||
* @param provider - Any index provider (vector / graph / metadata) or `null`/`undefined`.
|
||||
*/
|
||||
export function assessProviderHealth(provider: unknown): ProviderHealthAssessment {
|
||||
const p = provider as (MaybeHealthReportingProvider & MaybeReadyProvider) | null | undefined
|
||||
|
||||
if (p == null) {
|
||||
return { readiness: 'unknown', report: null, via: 'none', reasons: ['no provider to assess'] }
|
||||
}
|
||||
|
||||
if (typeof p.healthReport === 'function') {
|
||||
let report: HealthReport
|
||||
try {
|
||||
report = p.healthReport()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return {
|
||||
readiness: 'not-ready',
|
||||
report: null,
|
||||
via: 'health-report',
|
||||
reasons: [`healthReport() threw: ${message} — a health-report throw is a contract violation, never read as healthy`]
|
||||
}
|
||||
}
|
||||
|
||||
const reasons: string[] = []
|
||||
for (const invariant of report.invariants) {
|
||||
if (!invariant.holds) {
|
||||
reasons.push(`${invariant.name} (heal:${invariant.heal}): ${invariant.detail}`)
|
||||
}
|
||||
}
|
||||
if (report.unledgered.length > 0) {
|
||||
reasons.push(`unledgered: ${report.unledgered.join(', ')}`)
|
||||
}
|
||||
|
||||
return {
|
||||
readiness: report.serving ? 'ready' : 'not-ready',
|
||||
report,
|
||||
via: 'health-report',
|
||||
reasons
|
||||
}
|
||||
}
|
||||
|
||||
const readiness = assessIndexReadiness(p)
|
||||
if (readiness === 'unknown') {
|
||||
return {
|
||||
readiness,
|
||||
report: null,
|
||||
via: 'size-heuristic',
|
||||
reasons: ['provider exposes neither healthReport() nor isReady() — falling back to the size heuristic']
|
||||
}
|
||||
}
|
||||
return {
|
||||
readiness,
|
||||
report: null,
|
||||
via: 'is-ready',
|
||||
reasons: readiness === 'not-ready' ? ['isReady() returned false'] : []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue