open-brainy/src/plugin.ts
David Snelling 5b2cbf74e5 fix: warm() metadata surface routes through the active provider (warm hook added to the metadata contract); add maintenanceDebt() observability surface
A production deployment's warm report showed metadata: 'unavailable' under a
native metadata provider. brain.warm()'s metadata leg only duck-typed the
built-in JS manager's hydrateAll() method, which a native provider has no
reason to implement.

- MetadataIndexProvider (src/plugin.ts) gains an optional warm?(): Promise<void>
  hook, mirroring the existing vector and graph provider hooks. brain.warm()
  now checks the active provider's own warm() FIRST, falls back to the JS
  manager's hydrateAll() when absent, and reports 'unavailable' only when
  neither exists -- never init() as a stand-in, since a native provider's
  init() may be a cheap verify rather than a real warm.
- Tests (tests/unit/brainy/warm.test.ts): a live provider instance shaped to
  have warm() reports 'warmed' and the hook called with no hydrateAll
  fallback; shaped to have neither hook reports 'unavailable' (pins the
  honest branch); the unmodified built-in JS manager still reports 'warmed'
  via hydrateAll(), unchanged.

Additive scope agreed mid-flight with the native-provider team: a
maintenance-debt observability seam so an operator sees a grind coming
instead of discovering it as a CPU storm.

- New optional maintenanceDebt?(): Promise<ProviderMaintenanceDebt> hook on
  all three provider contracts (vector, metadata, graph -- the same three
  warm?() lives on). ProviderMaintenanceDebt is fields-all-optional: a
  provider reports only what it truly measures (pendingBytes, pendingItems,
  lastPassCompletedAt, lastPassOutcome, converging), never an estimate
  dressed as fact.
- New public brain.maintenanceDebt(): a pure passthrough -- for each surface
  it calls only the active provider's own hook and reports the payload
  verbatim, or 'unavailable' when absent. No thresholds, no polling, no
  JS-side estimation; the provider owns the numbers, the operator owns the
  policy.
- ProviderMaintenanceDebt, MaintenanceDebtReport, and MaintenanceDebtOutcome
  are exported from the package root.
- Tests (tests/unit/brainy/maintenance-debt.test.ts): hook present reports
  'reported' with the exact payload passed through; hook absent reports
  'unavailable' on every surface; mixed surfaces resolve independently of
  each other.

RELEASES.md gains the 8.10.1 entry covering both fixes above and this
feature, including the no-hot-retry contract from the prior commit.
2026-07-24 16:02:01 -07:00

1461 lines
67 KiB
TypeScript

/**
* Brainy Plugin System
*
* Simple plugin architecture for two use cases:
* 1. Native acceleration (@soulcraft/cor)
* 2. Custom storage adapters (e.g., Redis, DynamoDB, custom backends)
*
* Plugins are loaded from an explicit `plugins: [...]` config list or
* registered manually via `brain.use()` — there is no implicit detection.
*/
import type {
StorageAdapter,
Vector,
VectorDocument,
GraphVerb,
} from './coreTypes.js'
import type { NounType, VerbType } from './types/graphTypes.js'
import type { MetadataIndexStats } from './utils/metadataIndex.js'
import type { GraphIndexStats } from './graph/graphAdjacencyIndex.js'
// Re-export the provider contracts that already live closer to their
// implementations so a plugin author (Cor) can import the *entire*
// provider surface from one stable entrypoint: `@soulcraft/brainy/plugin`.
export type { ColumnStoreProvider } from './indexes/columnStore/types.js'
export type {
AggregationProvider,
AggregateDefinition,
AggregateGroupState,
AggregateResult,
AggregateQueryParams,
GroupByDimension,
} from './types/brainy.types.js'
/**
* Plugin interface — all brainy plugins must implement this.
*/
export interface BrainyPlugin {
/** Unique plugin name (typically the npm package name) */
name: string
/**
* Optional semver range of `@soulcraft/brainy` this plugin supports
* (e.g. `'>=8.0.0 <9.0.0'` or `'^8.0.0'`). When set and the running brainy is
* OUTSIDE the range, `init()` THROWS rather than silently falling back to the
* default JS engine. This is the version-coupling guard for the native
* accelerator (`@soulcraft/cor`): brainy 8.x and cor 3.x are a matched pair,
* and a mismatch must fail loud, not degrade invisibly. Supported range
* grammar: space-separated AND of `>=`, `>`, `<=`, `<`, `=` comparators, plus
* `^X.Y.Z` (same-major floor). Prerelease tags on the running version are
* tolerated (`8.0.0-rc1` satisfies `>=8.0.0`).
*/
brainyRange?: string
/**
* Called by brainy during init() to activate the plugin.
* Return `true` if activation succeeded. Returning `false` is a graceful
* decline (brainy logs a loud warning and uses the default engine for this
* plugin's providers). THROWING aborts init — a registered plugin is always
* explicitly requested (via `config.plugins` or `brain.use()`; brainy does no
* auto-detection), so a hard failure is fatal, never a silent skip.
*/
activate(context: BrainyPluginContext): Promise<boolean>
/**
* Called when brainy.close() is invoked. Optional cleanup.
*/
deactivate?(): Promise<void>
}
/**
* Context passed to plugins during activation.
*/
export interface BrainyPluginContext {
/**
* Register a provider for a named subsystem.
*
* Well-known provider keys (used by cor):
* - 'metadataIndex' — MetadataIndexManager replacement
* - 'graphIndex' — GraphAdjacencyIndex replacement
* - 'entityIdMapper' — EntityIdMapper replacement
* - 'cache' — UnifiedCache replacement
* - 'vector' — JsHnswVectorIndex replacement (vector index engine)
* - 'roaring' — RoaringBitmap32 replacement
* - 'embeddings' — Embedding engine replacement (single text)
* - 'embedBatch' — Batch embedding engine (texts[] → vectors[])
* - 'distance' — Distance function overrides
* - 'msgpack' — Msgpack encode/decode
* - 'aggregation' — AggregationIndex replacement (incremental aggregates)
*
* Storage adapter keys:
* - 'storage:<name>' — Custom storage adapter factory
*/
registerProvider(key: string, implementation: unknown): void
/** Brainy version for compatibility checks */
readonly version: string
}
// ===========================================================================
// Provider contracts — the EXACT surface Brainy calls on each registered
// provider.
//
// These interfaces are the type-level half of the provider-parity guarantee.
// They capture only what Brainy actually invokes, so a native accelerator
// (Cor) that declares `implements MetadataIndexProvider` gets a compile
// error the moment a method Brainy depends on is dropped or its signature
// drifts. Brainy's own baseline classes (`MetadataIndexManager`,
// `GraphAdjacencyIndex`, `JsHnswVectorIndex`, `EntityIdMapper`, `UnifiedCache`)
// implement them too, so the contract can never silently diverge from the
// thing Brainy ships.
//
// Keep these in lockstep with the call sites in `brainy.ts` and the index
// classes. When Brainy starts calling a new member, add it here — every
// implementation then fails to compile until it provides the member.
// ===========================================================================
/**
* @description How a failed provider invariant should be remediated:
* - `'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. 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
* (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
}
/**
* @description A provider's self-report of its own outstanding background
* maintenance work (compaction, deferred writes, a build-new→verify→swap in
* flight, etc.) — the observability seam so an operator sees a grind coming
* (rising pending bytes/items, a stalled pass) instead of discovering it as a
* CPU storm or a timeout under transaction budget pressure. Every field is
* OPTIONAL and every field is a MEASUREMENT: a provider reports ONLY what it
* actually tracks, never an estimate dressed up as a fact. Absence of the
* {@link VectorIndexProvider.maintenanceDebt} /
* {@link GraphIndexProvider.maintenanceDebt} /
* {@link MetadataIndexProvider.maintenanceDebt} hook itself means the
* provider does not track debt at all — brainy reports that surface
* `'unavailable'` rather than inventing zeros. Brainy performs NO threshold
* checks, NO polling, and NO JS-side estimation over this payload — it is a
* pure passthrough via {@link Brainy.maintenanceDebt}; the provider owns the
* numbers and the operator owns the policy (what threshold matters, what to
* do about it).
*/
export interface ProviderMaintenanceDebt {
/** Bytes of outstanding/unmerged work, if the provider measures it (e.g. unflushed writes, unmerged segments). */
pendingBytes?: number
/** Count of outstanding items (records, segments, nodes) awaiting the provider's background pass. */
pendingItems?: number
/** Epoch millis when the provider's last maintenance pass finished, if it tracks one. */
lastPassCompletedAt?: number
/** How the last pass ended, if the provider tracks pass outcomes. */
lastPassOutcome?: 'completed' | 'partial' | 'failed'
/** `true` if the provider's own measurements show debt trending down (making progress); `false` if flat or growing; omitted if the provider can't tell. */
converging?: boolean
}
/**
* The `'metadataIndex'` provider — a drop-in for `MetadataIndexManager`.
* Brainy calls this surface via `this.metadataIndex.*` (see `brainy.ts`) and
* the transactional add/remove operations.
*/
export interface MetadataIndexProvider {
init(): Promise<void>
flush(): Promise<void>
rebuild(): Promise<void>
/**
* @description OPTIONAL. Eagerly load/fault-in backing storage (e.g. mmap
* pretouch, full sparse-index hydration) so first queries run at
* steady-state cost. Optional; absence means the provider demand-loads.
* Mirrors {@link GraphIndexProvider.warm} / the vector provider's `warm?()`
* (`src/plugin.ts` VectorIndexProvider). Distinct from `init()`: `init` is
* required and runs once automatically during brain startup; `warm` is a
* separate, explicit step a caller opts into via `brain.warm()` (or
* `warmOnOpen`) to pre-pay demand-load cost `init` left lazy. Idempotent —
* calling it more than once must be safe and cheap on a brain that is
* already warm. A provider that already loads everything eagerly in
* `init()` may implement `warm` as a no-op or omit it — `brain.warm()`
* falls back to the built-in JS manager's `hydrateAll()` duck-type when
* absent, and to an honest `'unavailable'` when neither exists.
*/
warm?(): Promise<void>
/**
* @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
* the observability seam so an operator sees outstanding background
* maintenance work (e.g. unmerged postings) BEFORE it grinds a transaction
* into a budget-busting op. Absence means this provider does not track
* debt; `brain.maintenanceDebt()` reports this surface `'unavailable'`
* rather than guessing. See {@link ProviderMaintenanceDebt} for the
* measure-only-what-you-track contract.
*/
maintenanceDebt?(): Promise<ProviderMaintenanceDebt>
/**
* @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. 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
* build-new→verify→swap has verified-and-swapped. While true, brainy SKIPS its
* own rebuild for this provider and lets the provider's non-blocking background
* migration own the index (the no-freeze path); the provider serves correct
* reads from canonical meanwhile. Mirrors {@link MetadataIndexProvider.init}
* (and `isReady?()` on the graph provider).
*/
isMigrating?(): boolean
addToIndex(id: string, entityOrMetadata: any, skipFlush?: boolean, deferWrites?: boolean): Promise<void>
removeFromIndex(id: string, metadata?: any): Promise<void>
getIds(field: string, value: any): Promise<string[]>
/**
* Resolve a `where` filter to its matching ids.
* @param filter - The filter shape (eq / allOf / anyOf / ne / range / exists / …).
* @param opts - OPTIONAL page bound for the UNSORTED `find({ type, where, limit })`
* path. Brainy passes `{ limit: offset+limit (+hidden over-fetch), offset: 0 }` and
* ALWAYS re-windows the result itself (visibility filter + `slice`), so a provider
* that honors `opts` should early-stop and return the `[0, limit)` PREFIX (it must
* NOT pre-apply `offset`). The JS index ignores `opts` and returns all matches —
* so honoring it is a pure native-side optimization that removes the O(N) FFI
* marshal at billion scale. Pairs with {@link getIdSetForFilter}.
*/
getIdsForFilter(filter: any, opts?: { limit?: number; offset?: number }): Promise<string[]>
/**
* @description OPTIONAL: resolve a `where` filter to its matching id universe as
* an {@link OpaqueIdSet} (a serialized roaring `Buffer`) WITHOUT materializing
* the ids in TypeScript — the producer half of the predicate-pushdown
* (`CTX-PUSHDOWN-ALLOWEDIDS`) and graph query→expand fusion. Brainy forwards the
* returned set opaquely to `VectorIndexProvider.search({ allowedIds })` and
* `GraphAccelerationProvider.traverse(seeds)`; it NEVER inspects it. A native
* (cor) metadata index returns its roaring filter result directly (zero
* crossing); the JS index does not implement this — Brainy falls back to
* {@link MetadataIndexProvider.getIdsForFilter} + a `ReadonlySet<string>` when
* absent. Present ⟺ the native stack is the active provider, so the matching
* native vector/graph engines can decode the same envelope.
* @param filter - The same filter shape accepted by `getIdsForFilter`.
* @returns The matching id universe as an opaque set.
*/
getIdSetForFilter?(filter: any): Promise<OpaqueIdSet>
getIdsForTextQuery(query: string): Promise<Array<{ id: string; matchCount: number }>>
getSortedIdsForFilter(filter: any, orderBy: string, order?: 'asc' | 'desc', topK?: number): Promise<string[]>
getFilterValues(field: string): Promise<string[]>
getFilterFields(): Promise<string[]>
getFieldValueForEntity(entityId: string, field: string): Promise<any>
getFieldsForType(nounType: NounType): Promise<Array<{ field: string; affinity: number; occurrences: number; totalEntities: number }>>
getFieldStatistics(): Promise<Map<string, unknown>>
getFieldsWithCardinality(): Promise<Array<{ field: string; cardinality: number; distribution: string }>>
getOptimalQueryPlan(filters: Record<string, any>): Promise<unknown>
/** Report which index path a `where` clause on `field` will hit (drives `brain.explain()`). */
explainField(field: string): Promise<{ path: 'column-store' | 'sparse-chunked' | 'none'; notes?: string }>
getCountForCriteria(field: string, value: any): Promise<number>
getEntityCountByType(type: string): number
getEntityCountByTypeEnum(type: NounType): number
getTotalEntityCount(): number
getAllEntityCounts(): Map<string, number>
getTopNounTypes(n: number): NounType[]
getTopVerbTypes(n: number): VerbType[]
getAllNounTypeCounts(): Map<NounType, number>
getAllVerbTypeCounts(): Map<VerbType, number>
getAllVFSEntityCounts(): Promise<Map<string, number>>
detectAndRepairCorruption(): Promise<void>
/**
* OPTIONAL cheap (O(1)) cold-open consistency probe — the counterpart of the
* graph cold-load guard. Returns `true` if a sampled `(field, value, int)` is
* clean, `false` if it detects the cross-bucket phantom signature (an int whose
* current value for `field` no longer equals `value`). Brainy calls it ONCE per
* brain on the first read and, on `false`, runs {@link detectAndRepairCorruption}
* to self-heal — so an already-poisoned index repairs itself on open without the
* cost of the full-scan {@link validateConsistency}. A provider that omits it is
* simply never auto-probed (no behavior change).
*/
probeConsistency?(): Promise<boolean>
validateConsistency(): Promise<{
healthy: boolean
avgEntriesPerEntity: number
entityCount: number
indexEntryCount: number
recommendation: string | null
}>
tokenize(text: string): string[]
extractTextContent(data: any): string
getStats(): Promise<MetadataIndexStats>
/**
* The shared UUID ↔ int mapper — the single source of truth for entity-int
* resolution at the provider boundary. The coordinator (`brainy.ts`) resolves
* UUID → int exactly once before every graph-index call (`getOrAssign` on
* writes, `getInt` on reads — `undefined` means "never mapped", i.e. the
* entity has no relations) and converts provider-returned ints back with
* `getUuid`. Ints are u32 today (the `EntityIdSpaceExceeded` guard enforces
* the ceiling on the JS path), so `Number(bigint)` narrowing is lossless.
*/
getIdMapper(): {
getOrAssign(uuid: string): number
getInt(uuid: string): number | undefined
getUuid(intId: number): string | undefined
}
/** The column store the coordinator delegates `where`/`orderBy` to. */
readonly columnStore: import('./indexes/columnStore/types.js').ColumnStoreProvider
}
/**
* The `'graphIndex'` provider — a drop-in for `GraphAdjacencyIndex`.
* Brainy calls this surface via `this.graphIndex.*` (some optional-chained).
*
* **8.0 u64 contract — BigInt at the boundary.** Reads take entity ints
* (from the metadata index's idMapper) and return entity/verb ints as
* `bigint[]`. The coordinator owns ALL UUID ↔ int conversion: it resolves
* UUIDs to ints once at the `brainy.ts` boundary (`getOrAssign` on writes,
* `getInt` on reads, returning empty results for unmapped UUIDs without
* calling the provider) and maps returned ints back (`getUuid` for entities,
* {@link GraphIndexProvider.verbIntsToIds} for verbs). Implementations may
* stay u32 internally — `Number(bigint)` narrowing is lossless under the
* shipped `EntityIdSpaceExceeded` u32 guard — but must speak the bigint
* contract at this surface.
*/
export interface GraphIndexProvider {
/**
* `false` until the provider has loaded its persisted state. A provider should
* self-load on first read; this flag lets it report readiness for diagnostics
* and tooling. (Brainy's graph reads route through the provider's own methods,
* which are expected to be self-loading — it is the {@link GraphAccelerationProvider}
* fast paths that gate on `isInitialized`.)
*/
readonly isInitialized: boolean
/**
* @description Returns true ONLY when the source→target EDGES are loaded (NOT
* membership/manifest count) — the honest cold-load readiness signal. brainy
* gates the graph rebuild + the connected read-time guard on it: a `false`
* forces a rebuild from storage, and a still-`false` after that rebuild raises
* a loud {@link import('./errors/brainyError.js').GraphIndexNotReadyError}
* rather than serving an empty traversal as truth. cortex >= 2.7.8 (2.x) / 3.0
* exposes it; ABSENT on older providers, where brainy falls back to a
* known-edge-sample probe.
* @returns `true` when the persisted adjacency is loaded and traversals are
* trustworthy; `false` when only the count/manifest loaded (cold-open).
*/
isReady?(): boolean
/**
* @description OPTIONAL. 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
* adjacency resolves endpoints through it — the id-mapper-before-adjacency
* order) and BEFORE the rebuild gate — so a native provider loads its
* source→target adjacency from storage and reports `isReady() === true` at the
* gate, with no spurious rebuild (the §7.1 `rebuild()==0` acceptance). Mirrors
* {@link MetadataIndexProvider.init}. The JS graph index omits it and
* self-loads its adjacency on demand.
*/
init?(): Promise<void>
/**
* @description OPTIONAL. Eagerly load/fault-in backing storage (e.g. mmap
* pretouch) so first operations run at steady-state cost. Optional;
* absence means the provider demand-loads. Distinct from `init?()`: `init`
* is called once automatically during brain startup as part of the
* readiness contract (cold-load + rebuild gating); `warm` is an explicit,
* separate readiness step a caller opts into via `brain.warm()` (or
* `warmOnOpen`) specifically to pre-pay demand-load cost that `init` left
* lazy. A provider that already loads everything eagerly in `init?()` may
* implement `warm` as a no-op or omit it — `brain.warm()` falls back to a
* best-effort read-through probe when absent.
*/
warm?(): Promise<void>
/**
* @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
* the observability seam so an operator sees outstanding background
* maintenance work (e.g. a build-new→verify→swap in flight, unmerged
* adjacency segments) BEFORE it grinds a transaction into a
* budget-busting op. Absence means this provider does not track debt;
* `brain.maintenanceDebt()` reports this surface `'unavailable'` rather
* than guessing. See {@link ProviderMaintenanceDebt} for the
* measure-only-what-you-track contract.
*/
maintenanceDebt?(): Promise<ProviderMaintenanceDebt>
/**
* @description OPTIONAL. A native provider returns true from the moment its
* `init()` detects a large epoch-drift until its background
* build-new→verify→swap has verified-and-swapped. While true, brainy SKIPS its
* own rebuild for this provider and lets the provider's non-blocking background
* migration own the index (the no-freeze path); the provider serves correct
* reads from canonical meanwhile. Mirrors `isReady?()` / `init?()`.
*/
isMigrating?(): boolean
/**
* @description Entity ints reachable from `id` (1 hop), deduped.
* @param id - The entity's interned int (from the shared idMapper).
* @param options - Direction (`'both'` default) and limit/offset pagination.
* @returns Neighbor entity ints. Empty when the entity has no edges.
*/
getNeighbors(
id: bigint,
options?: { direction?: 'in' | 'out' | 'both'; limit?: number; offset?: number }
): Promise<bigint[]>
/**
* @description Verb ints for all edges originating at `sourceInt`.
* @param sourceInt - The source entity's interned int.
* @param options - Optional limit/offset pagination.
* @returns Verb ints, resolvable via {@link GraphIndexProvider.verbIntsToIds}.
*/
getVerbIdsBySource(sourceInt: bigint, options?: { limit?: number; offset?: number }): Promise<bigint[]>
/**
* @description Verb ints for all edges pointing at `targetInt`.
* @param targetInt - The target entity's interned int.
* @param options - Optional limit/offset pagination.
* @returns Verb ints, resolvable via {@link GraphIndexProvider.verbIntsToIds}.
*/
getVerbIdsByTarget(targetInt: bigint, options?: { limit?: number; offset?: number }): Promise<bigint[]>
/**
* @description Batch reverse resolver: verb ints → verb-id strings. REQUIRED —
* the provider owns the durable verb-int interning (Brainy keeps only a
* bounded in-memory warm cache fed by `addVerb` returns and this resolver;
* pure optimization, no durability role).
* @param verbInts - Verb ints as returned by the read methods.
* @returns One entry per input, order-preserving; `null` for unknown ints.
*/
verbIntsToIds(verbInts: bigint[]): Promise<(string | null)[]>
getVerbsBatchCached(verbIds: string[]): Promise<Map<string, GraphVerb>>
/**
* @description Index one verb. The coordinator resolves both endpoint ints
* via `idMapper.getOrAssign` and mirrors them onto `verb.sourceInt` /
* `verb.targetInt` before the call.
* @param verb - The verb to index (endpoint UUIDs + derived `sourceInt`/`targetInt`).
* @param sourceInt - The source entity's interned int.
* @param targetInt - The target entity's interned int.
* @param generation - Brainy's commit generation for this write — the same
* watermark the storage layer stamps onto the record. A provider with a
* per-generation edge chain records the edge's existence at this generation
* so `db.asOf(g)` graph hops resolve historically correct endpoints; the
* JS baseline has no such chain and ignores it (graph time-travel is a
* native-provider capability — see the consistency-model doc).
* @returns The interned verb int for `verb.id` (feeds Brainy's warm cache).
*/
addVerb(verb: GraphVerb, sourceInt: bigint, targetInt: bigint, generation: bigint): Promise<bigint>
/**
* @description Remove one verb from the index by its id string. The verb's
* interned int stays reserved (ints are never recycled within a generation).
* @param verbId - The verb's UUID string.
* @param generation - Brainy's commit generation for this removal. A provider
* with a per-generation edge chain tombstones the edge at this generation
* (so it remains visible to `db.asOf(g)` for `g` before the removal); the
* JS baseline removes immediately and ignores it.
* @returns Resolves once the verb no longer appears in reads.
*/
removeVerb(verbId: string, generation: bigint): Promise<void>
rebuild(): Promise<void>
flush(): Promise<void>
close(): Promise<void>
size(): number
getStats(): GraphIndexStats
getRelationshipStats(): {
totalRelationships: number
relationshipsByType: Record<string, number>
uniqueSourceNodes: number
uniqueTargetNodes: number
totalNodes: number
}
getRelationshipCountByType(type: string): number
getTotalRelationshipCount(): number
getAllRelationshipCounts(): Map<string, number>
}
// ============= Graph Acceleration (optional native engine) =============
/**
* Opaque serialized id-set — a cor-internal roaring payload (a Node `Buffer` at
* runtime when the native engine is present), passed straight from a `find()`
* universe into `traverse` / `VectorIndexProvider.search` with NO id
* materialization in TypeScript (the O(1)-crossing query→expand win). Brainy
* NEVER inspects it; the provider version-tags the envelope and throws on a
* format mismatch (it owns integrity + the id-space-width guarantee). Typed as
* `Uint8Array` (which a Node `Buffer` satisfies) so the universal build stays
* browser-safe.
*/
export type OpaqueIdSet = Uint8Array
/** Direction of a graph traversal relative to each frontier node. */
export type GraphTraversalDirection = 'in' | 'out' | 'both'
/**
* Columnar subgraph — the shared wire format for every native graph read
* (`traverse`, `edgesForNode`, cursor chunks). Parallel typed arrays, never an
* array-of-objects, so the NAPI boundary transfers them near-zero-copy and
* Brainy maps u64↔UUID **lazily** (only for the rows a caller actually renders,
* via `EntityIdMapperProvider.entityIntsToUuids` + `GraphIndexProvider.verbIntsToIds`).
* The three `edge*` arrays are parallel (index `i` is one edge); `nodes` /
* `nodeDepth` are parallel.
*/
export interface Subgraph {
/** Discovered entity ints; resolve via `entityIntsToUuids`. */
nodes: BigInt64Array
/** Hop distance of each node from the nearest seed (parallel to `nodes`). Present iff `includeDepth`. */
nodeDepth?: Uint8Array
/** Edge source entity ints. */
edgeSources: BigInt64Array
/** Edge target entity ints. */
edgeTargets: BigInt64Array
/** Edge verb ints; resolve via `verbIntsToIds`. */
edgeVerbInts: BigInt64Array
/** Edge verb-type indices (stable TypeIdx; resolve via `TypeUtils.getVerbFromIndex`). */
edgeTypes: Uint16Array
/**
* `true` when a `maxNodes` / `maxEdges` cap truncated the result. The returned
* rows are the deterministic BFS-order prefix; page the remainder with a
* `graphCursor` (traverse stays stateless/bounded — no continuation token).
*/
truncated?: boolean
/** When `truncated`, how many nodes/edges were cut (for "+N more" affordances). */
truncatedNodeCount?: number
truncatedEdgeCount?: number
}
/** Knobs shared by the bounded traversals. */
export interface TraverseOptions {
/** Max hop distance from any seed. */
depth: number
/** Edge direction to follow (default `'both'`). */
direction?: GraphTraversalDirection
/** Restrict traversed edges to these verb-type indices (TypeIdx). */
verbTypes?: number[]
/** Restrict traversed edges to these subtypes. */
subtypes?: string[]
/**
* Visibility tiers to EXCLUDE from the frontier (default: `['internal','system']`,
* matching `related()`). Pass `[]` for an all-tiers (admin) view.
*/
excludeVisibility?: string[]
/** Cap on returned nodes (deterministic BFS-order prefix; sets `Subgraph.truncated`). */
maxNodes?: number
/** Cap on returned edges. */
maxEdges?: number
/** Include the edge arrays (`false` = nodes-only reachability). Default `true`. */
includeEdges?: boolean
/** Populate `Subgraph.nodeDepth`. Default `false`. */
includeDepth?: boolean
}
/** Options for the both-direction single-node edge read. */
export interface EdgesForNodeOptions {
/** Edge direction (default `'both'`). */
direction?: GraphTraversalDirection
verbTypes?: number[]
subtypes?: string[]
excludeVisibility?: string[]
limit?: number
}
/** Opaque server-side cursor handle (TTL-bounded; pinned to a generation at open). */
export type GraphCursorHandle = string
/** Options for opening a streaming whole-graph (or seeded) cursor. */
export interface GraphCursorOptions {
direction?: GraphTraversalDirection
excludeVisibility?: string[]
/** `'light'` = ids/types only, no metadata-join columns (viz default); `'full'` = with joins. */
projection?: 'light' | 'full'
/** Seed set to bound the walk; omitted = the whole graph. */
seeds?: bigint[] | OpaqueIdSet
/**
* Resume token from a prior chunk — used to continue after a `SnapshotExpired`
* (the pinned generation's TTL lapsed): reopen with this to resume the walk.
*/
cursor?: string
}
/** One streamed chunk of a graph cursor walk. */
export interface GraphCursorChunk {
/** The chunk's nodes + edges, columnar. */
subgraph: Subgraph
/** Opaque resume token (pass to `graphCursorOpen({ cursor })` after `SnapshotExpired`). */
cursor?: string
/** `true` once the walk is exhausted; `graphCursorNext` should not be called again. */
done: boolean
}
/** Per-node scores returned by `rank` / `mostConnected`; `scores[i]` belongs to `nodeInts[i]` (descending). */
export interface GraphScores {
nodeInts: BigInt64Array
scores: Float64Array
}
/** Community/cluster labelling; `communityIds[i]` is the group of `nodeInts[i]`. */
export interface GraphCommunities {
nodeInts: BigInt64Array
communityIds: Uint32Array
communityCount: number
}
/** A path between two nodes — the node sequence + the edges between them (or `null` if unreachable). */
export interface GraphPath {
nodeInts: BigInt64Array
edgeVerbInts: BigInt64Array
/** Cost of the path: number of hops, or summed edge weight when `by: 'weight'`. */
cost: number
}
/**
* Options for `rank` (importance / influence ranking). INTENT-level only — the
* ranking ALGORITHM is the provider's choice (the JS fallback uses PageRank; a
* native provider may use personalized PageRank, eigenvector centrality, etc.),
* so no algorithm-tuning knobs are exposed here.
*/
export interface RankOptions {
/** Return only the top-K nodes by score (default: all). */
topK?: number
/** Visibility tiers to EXCLUDE from the ranked set (default `['internal','system']`). */
excludeVisibility?: string[]
}
/** Options for `communities` (grouping related nodes). The grouping algorithm is the provider's choice. */
export interface CommunitiesOptions {
/** Treat the graph as directed when grouping (default `false` — direction-agnostic). */
directed?: boolean
excludeVisibility?: string[]
}
/** Options for `path` (best route between two nodes). */
export interface PathOptions {
direction?: GraphTraversalDirection
/** Optimize for fewest `'hops'` (default) or least summed edge `'weight'`. */
by?: 'hops' | 'weight'
/** Restrict the route to these verb-type indices (TypeIdx). */
verbTypes?: number[]
excludeVisibility?: string[]
/** Abandon the search past this many hops. */
maxDepth?: number
}
/** Options for `sample` (a representative neighborhood sample for dense-graph viz). */
export interface SampleOptions {
/** Max hop distance from any seed. */
depth: number
direction?: GraphTraversalDirection
/** Max neighbors kept per node (random, seeded for reproducibility). */
fanout: number
/** RNG seed so a given (seeds, opts, seed) yields a stable sample. */
seed?: number
excludeVisibility?: string[]
maxNodes?: number
}
/** Options for `mostConnected` (the most-connected nodes). */
export interface MostConnectedOptions {
/** Return the top-K most-connected nodes. */
topK: number
direction?: GraphTraversalDirection
excludeVisibility?: string[]
}
/**
* The `'graphAcceleration'` provider — an OPTIONAL native graph engine (the
* cor 3.0 acceleration layer). Brainy feature-detects it on the registered
* providers: when present, the public `brain.graph.*` surface and the
* `related({ node })` / `neighbors({ depth })` / `find({ connected })` paths
* route here; when absent, Brainy serves the same operations from its pure-TS
* adjacency fallback (correct, small-graph-scale). It is SEPARATE from the
* required {@link GraphIndexProvider} (which stays the bigint-adjacency
* contract) so "optional native acceleration" is explicit at the seam.
*
* **Generation-aware (8.0 time-travel).** Every read takes an optional trailing
* `generation?: bigint`; omitted = the current generation ("now"). With a
* generation, the provider resolves the graph **as of** that generation
* (`db.asOf(g).graph.*`), reading its generation-filtered adjacency rather than
* the now-only fast path.
*
* **Columnar + opaque-id-set.** Reads return the columnar {@link Subgraph};
* `traverse` / sample accept seeds as `bigint[]` OR an {@link OpaqueIdSet}
* (a `find()` universe forwarded with no id materialization — the query→expand
* fusion). Visibility tiers are excluded at the index by default.
*/
export interface GraphAccelerationProvider {
/** `false` until the native engine has loaded; Brainy probes before routing. */
readonly isInitialized: boolean
/**
* @description Bounded multi-hop expansion from `seeds`, returning the reachable
* subgraph. One call replaces Brainy's per-hop BFS round-trips.
* @param seeds - Start nodes as entity ints, OR an {@link OpaqueIdSet} (a
* `find()` universe — the frontier is intersected in id-space, zero crossing).
* @param options - Depth, direction, edge/visibility filters, and caps.
* @param generation - Optional as-of generation (omitted = now).
* @returns The reachable {@link Subgraph} (BFS-order; `truncated` set if capped).
*/
traverse(seeds: bigint[] | OpaqueIdSet, options: TraverseOptions, generation?: bigint): Promise<Subgraph>
/**
* @description All edges incident to one node, both directions, as structure +
* cor-indexed fields (Brainy hydrates full edge metadata lazily — the provider
* cannot recover verb-id strings from its interned form).
* @param nodeInt - The node's interned entity int.
* @param options - Direction, edge/visibility filters, limit.
* @param generation - Optional as-of generation.
* @returns A {@link Subgraph} of the node's incident edges.
*/
edgesForNode(nodeInt: bigint, options: EdgesForNodeOptions, generation?: bigint): Promise<Subgraph>
/**
* @description Open a snapshot-consistent streaming walk of the whole graph (or a
* seeded region) for O(N) viz loads. Pins a generation at open; the walk reads
* as-of that pin (no dup/skip under concurrent writes). Handles are TTL-bounded —
* `graphCursorNext` after expiry rejects with `SnapshotExpired`; reopen with the
* last chunk's `cursor` to resume. Call `graphCursorClose` on normal completion.
* @param options - Direction, visibility, projection mode, optional seeds / resume cursor.
* @param generation - Optional explicit as-of generation to pin (else pins current).
* @returns A handle for `graphCursorNext` / `graphCursorClose`.
*/
graphCursorOpen(options: GraphCursorOptions, generation?: bigint): Promise<GraphCursorHandle>
/**
* @description Pull the next chunk from an open cursor.
* @param handle - From `graphCursorOpen`.
* @param chunkSize - Target number of nodes/edges in this chunk.
* @returns The next {@link GraphCursorChunk}; `done: true` when exhausted.
*/
graphCursorNext(handle: GraphCursorHandle, chunkSize: number): Promise<GraphCursorChunk>
/**
* @description Release an open cursor's pinned generation and server-side state.
* @param handle - From `graphCursorOpen`.
*/
graphCursorClose(handle: GraphCursorHandle): Promise<void>
/**
* @description Rank nodes by influence/importance over the VISIBLE graph (hidden
* tiers excluded by default, so the ranking reflects the public view). The
* ranking ALGORITHM is the provider's choice — the JS fallback uses PageRank; a
* native provider may use personalized PageRank, eigenvector centrality, etc. The
* intent ("which nodes matter most") is the contract, not the algorithm.
* @param options - Top-K + visibility (intent-level; no algorithm tuning).
* @param generation - Optional as-of generation.
* @returns Per-node scores, descending.
*/
rank(options: RankOptions, generation?: bigint): Promise<GraphScores>
/**
* @description Group related nodes into communities over the VISIBLE graph. The
* grouping ALGORITHM is the provider's choice — the JS fallback uses connected
* components; a native provider may use community detection (e.g. Louvain/Leiden).
* @param options - Directed-grouping toggle + visibility.
* @param generation - Optional as-of generation.
* @returns Per-node community labels + community count.
*/
communities(options: CommunitiesOptions, generation?: bigint): Promise<GraphCommunities>
/**
* @description Find the best path between two nodes — fewest hops by default, or
* least summed edge weight with `by: 'weight'`. The pathfinding ALGORITHM is the
* provider's choice (JS fallback: BFS / Dijkstra).
* @param fromInt - Start node int.
* @param toInt - End node int.
* @param options - Direction, cost basis (`by`), verb-type filter, max depth, visibility.
* @param generation - Optional as-of generation.
* @returns The path, or `null` if `toInt` is unreachable from `fromInt`.
*/
path(fromInt: bigint, toInt: bigint, options: PathOptions, generation?: bigint): Promise<GraphPath | null>
/**
* @description A bounded, representative SAMPLE of the neighborhood around the seeds
* (capped fan-out per node) — for dense-graph viz. Seeded for reproducibility.
* @param seeds - Start nodes (ints or an {@link OpaqueIdSet}).
* @param options - Depth, fan-out, RNG seed, visibility, node cap.
* @param generation - Optional as-of generation.
* @returns The sampled {@link Subgraph}.
*/
sample(seeds: bigint[] | OpaqueIdSet, options: SampleOptions, generation?: bigint): Promise<Subgraph>
/**
* @description The top-K most-connected nodes over the VISIBLE graph. The
* connectivity metric is the provider's choice (JS fallback: edge-count degree).
* @param options - Top-K, direction, visibility.
* @param generation - Optional as-of generation.
* @returns Per-node connectivity scores, descending.
*/
mostConnected(options: MostConnectedOptions, generation?: bigint): Promise<GraphScores>
}
/**
* Optional capability interface for index providers that maintain versioned
* (generation-aware) internal state — the provider-side half of Brainy 8.0's
* generational MVCC contract. Implemented by native providers whose storage
* engines keep immutable versions (e.g. LSM snapshots); Brainy's own JS
* indexes do not implement it (they are rebuilt from the storage records,
* which carry the versioning).
*
* **Detection.** Brainy feature-detects this interface on every registered
* index provider (`'vector'`, `'metadataIndex'`, `'graphIndex'`): when a
* provider exposes `pin`/`release` functions, Brainy calls them in lockstep
* with `Db` lifecycle — `pin(g)` when a `Db` value pins generation `g`
* (`brain.now()`, `brain.transact()`, `brain.asOf()`), `release(g)` when that
* `Db` is released (explicitly or via the GC backstop). Pins are refcounted
* on the Brainy side; a provider may receive multiple `pin(g)` calls for the
* same generation and will receive exactly one matching `release(g)` per pin.
*
* **Consistency model (locked cross-team design).** Index providers are
* *post-commit appliers*: the storage-record commit (atomic manifest rename)
* is the source of truth, and provider index state is derived, applied after
* the commit point. On open, a provider compares its own persisted
* `generation()` against the store's committed generation and replays the
* gap from the storage records (or requests a rebuild) — there are no
* provider rollback hooks, because an uncommitted transaction is repaired at
* the storage layer before any index is opened. The explicit pin/release
* lifetime OVERRIDES any time-based snapshot retention the provider has
* (e.g. an LSM snapshot TTL): a pinned generation must stay readable until
* released, regardless of age.
*
* **Speculative reads.** `db.with(txData)` overlays are Brainy-side only —
* providers are never asked to read uncommitted/speculative state; they
* always serve committed generations.
*/
export interface VersionedIndexProvider {
/**
* @description The newest generation this provider's persisted index state
* reflects. Brainy compares it against the storage layer's committed
* generation on open to detect a replay gap (index behind storage after a
* crash between commit and index apply).
* @returns The provider's current generation as a `bigint` (u64 at the
* native boundary; Brainy's generation counter is a safe integer today).
*/
generation(): bigint
/**
* @description True ⇒ the provider can serve consistent reads at
* `generation` (segments retained). Combined with pin: pinning a VISIBLE
* generation guarantees it stays servable until release. Pinning an
* invisible generation is permitted (refcount-only) — Brainy serves that
* generation from canonical storage instead.
*
* Brainy consults this at pin time (the read-routing rule: a `Db` at
* generation `g` uses provider-accelerated reads when
* `isGenerationVisible(g)` was true at pin time; otherwise canonical
* generation records).
* @param generation - The generation a `Db` value is about to pin.
*/
isGenerationVisible(generation: bigint): boolean
/**
* @description Pin a generation: the provider must keep index state for
* `generation` readable until the matching {@link VersionedIndexProvider.release}
* call, overriding any time-based snapshot retention. Called once per
* Brainy-side pin (refcounted upstream — expect balanced pin/release pairs).
* @param generation - The generation a live `Db` value just pinned.
*/
pin(generation: bigint): void
/**
* @description Release one pin on `generation`. After the last release the
* provider may reclaim resources for that generation at its discretion.
* @param generation - The generation being released (matches a prior
* {@link VersionedIndexProvider.pin} call).
*/
release(generation: bigint): void
}
/**
* @description Feature-detection guard for {@link VersionedIndexProvider}.
* Brainy applies it to every registered index provider (vector, metadata,
* graph) when a `Db` value pins or releases a generation: providers exposing
* all four capability methods receive lockstep `pin`/`release` calls;
* everything else (including Brainy's own JS indexes) is skipped.
*
* @param candidate - A registered index provider instance.
* @returns Whether the candidate implements the versioned capability.
*/
export function isVersionedIndexProvider(
candidate: unknown
): candidate is VersionedIndexProvider {
if (candidate === null || typeof candidate !== 'object') {
return false
}
const c = candidate as Record<string, unknown>
return (
typeof c.generation === 'function' &&
typeof c.isGenerationVisible === 'function' &&
typeof c.pin === 'function' &&
typeof c.release === 'function'
)
}
/**
* @description Feature-detect an optional {@link GraphAccelerationProvider} (the
* native graph engine). Brainy probes the registered `'graphAcceleration'`
* provider with this: present ⇒ `brain.graph.*` routes to native `traverse` /
* cursor / analytics; absent ⇒ Brainy serves the same operations from its
* pure-TS adjacency fallback. Checks the two anchor methods (`traverse` +
* `graphCursorOpen`) — a partial implementation that lacks them is treated as
* absent (fall back rather than crash).
* @param candidate - The value registered under `'graphAcceleration'`, or undefined.
* @returns Whether `candidate` is a usable {@link GraphAccelerationProvider}.
*/
export function isGraphAccelerationProvider(
candidate: unknown
): candidate is GraphAccelerationProvider {
if (candidate === null || typeof candidate !== 'object') {
return false
}
const c = candidate as Record<string, unknown>
return typeof c.traverse === 'function' && typeof c.graphCursorOpen === 'function'
}
/**
* Columnar at-`generation` candidate vectors for the 8.0 #35 FILTERED exact-rerank.
* Brainy resolves the per-generation vector before-images for the at-gen filtered
* universe (from its canonical generation records) and ships them flat, so a native
* provider reranks against the historically-correct vectors with ZERO per-vector FFI
* crossing — without the provider duplicating the per-gen retention Brainy already does.
*
* - **Row-major:** row `i` is `vectors[i*dim .. (i+1)*dim]` and belongs to `ids[i]`.
* INVARIANT: `vectors.length === ids.length * dim` (the provider asserts + refuses on mismatch).
* - **`ids` IS the candidate set:** entity ints (interned via the shared mapper) for the
* at-gen metadata∩graph filtered universe. The provider reranks EXACTLY these
* `(id, vector)` pairs and returns top-k by distance; any `allowedIds` passed alongside
* is redundant on this path (the provider may AND it as belt-and-suspenders).
* - **`dim`** must equal the provider's configured index dimension (asserted; mismatch → refuse).
*
* Supplied only when there is a BOUNDED filtered universe; the unfiltered-deep at-gen
* case stays the provider's own retained-segment ANN ({@link VersionedIndexProvider.isGenerationVisible}
* reports false there, so Brainy falls back to its materialization overlay).
*/
export interface AtGenerationVectors {
/** Entity ints (shared-mapper interned), one per candidate; the candidate set. */
ids: BigInt64Array
/** Flat row-major vectors: `vectors[i*dim .. (i+1)*dim]` is `ids[i]`'s at-gen vector. */
vectors: Float32Array
/** Vector dimension; MUST equal the provider's configured index dim. */
dim: number
}
/**
* The object returned by the `'vector'` provider factory — Brainy's vector
* index contract. Implementations include Brainy's own JS HNSW index and any
* native acceleration provider (e.g. cor's Adaptive DiskANN).
*
* Brainy calls this surface via `this.index.*` plus the transactional add/remove
* operations. `enableCOW`, `getItem`, and `setPersistMode` are intentionally
* absent: Brainy guards each with feature-detection (`typeof x === 'function'`),
* so they are optional and not part of the required contract (Brainy's own JS
* HNSW index omits `setPersistMode`, for instance).
*
* **Provider key:** registered under `'vector'` — the only key Brainy
* consults for the vector index. The pre-8.0 `'hnsw'` and `'diskann'` keys
* are retired and never looked up.
*/
export interface VectorIndexProvider {
/**
* @description REQUIRED self-reported implementation identity, rendered as
* `[vector-index:<name>]` in prose log lines and stamped into the
* transaction op-name strings that surface in journals/timings (e.g.
* `AddToVectorIndex(hnsw-js)`) — see
* `src/transaction/operations/IndexOperations.ts`. A provider must name
* itself TRUTHFULLY (its own algorithm/engine, e.g. its plugin/package
* name) and must never inherit a default — guessing a backend name would
* resurrect the exact bug this field exists to prevent: an operator
* hunting an index that isn't the one actually running. The built-in JS
* index always sets this to `'hnsw-js'`; a native provider picks its own
* string. At the TypeScript level this field is required; a runtime
* instance from an older provider compiled against the previous optional
* `providerId` contract is tolerated (never crashes, never silently
* mislabeled) — see `resolveVectorProviderId` in
* `src/transaction/operations/IndexOperations.ts`, which stamps
* `'unknown-provider'` and emits one loud warning for that case.
*/
readonly name: string
addItem(item: VectorDocument): Promise<string>
removeItem(id: string): Promise<boolean>
search(
queryVector: Vector,
k?: number,
filter?: (id: string) => Promise<boolean>,
options?: {
rerank?: { multiplier: number }
candidateIds?: string[]
/**
* Predicate-pushdown universe — restrict the result to this id-set, applied
* INSIDE the beam walk (walk traverses all nodes, collects only allowed), which
* recovers the filtered recall that post-filtering loses. A native provider with
* cor as the metadata index gets the `find()` universe as an {@link OpaqueIdSet}
* (zero id materialization); the pure-JS path gets a `ReadonlySet<string>`. Absent
* = no restriction. Optional — a provider that doesn't pushdown ignores it and
* falls back to `filter`.
*/
allowedIds?: OpaqueIdSet | ReadonlySet<string>
/**
* As-of generation for historical (time-travel) reads — the vector-side
* mirror of the {@link GraphAccelerationProvider} trailing `generation?`.
* Omitted = "now" (the live index). When set, a {@link VersionedIndexProvider}
* serves the kNN / exact-rerank over its retained at-`generation` segments
* instead of the current vectors, so `db.asOf(g)` semantic queries need no
* O(n@G) JS-HNSW rebuild. Brainy only passes it when the provider advertised
* `isGenerationVisible(generation)` at pin time; a provider that does not
* honor it MUST refuse/fall back (never score current vectors as if at-gen) —
* Brainy then serves the historical leg from its own materialization. Brainy's
* `generation` is the same u64 counter handed to the graph index on writes.
*/
generation?: bigint
/**
* 8.0 #35 part-3: the at-`generation` candidate vectors for the FILTERED
* exact-rerank — Brainy supplies the historically-correct vectors so the
* provider need not retain them. Present only alongside `generation` on the
* filtered at-gen path; the provider reranks `atGenerationVectors.ids` by
* distance and returns top-k. See {@link AtGenerationVectors}. The built-in
* JS index ignores it (it serves "now" only).
*/
atGenerationVectors?: AtGenerationVectors
}
): Promise<Array<[string, number]>>
size(): number
clear(): void
rebuild(options?: any): Promise<void>
flush(): Promise<number>
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<void>
/**
* @description OPTIONAL. Eagerly load/fault-in backing storage (e.g. mmap
* pretouch) so first operations run at steady-state cost. Optional;
* absence means the provider demand-loads. Distinct from `init?()`: `init`
* runs automatically once during brain startup (the cold-load + rebuild
* readiness contract above); `warm` is a separate, explicit step a caller
* opts into via `brain.warm()` (or `warmOnOpen`) to pre-pay demand-load
* cost `init` left lazy — e.g. touching every mmap page rather than just
* opening the file. A provider that already loads everything eagerly in
* `init?()` may implement `warm` as a no-op or omit it — `brain.warm()`
* falls back to a best-effort probe `search()` when absent.
*/
warm?(): Promise<void>
/**
* @description OPTIONAL self-reported {@link ProviderMaintenanceDebt} —
* the observability seam so an operator sees outstanding background
* maintenance work (e.g. unflushed writes, a pending rebuild) BEFORE it
* grinds a transaction into a budget-busting op. Absence means this
* provider does not track debt; `brain.maintenanceDebt()` reports this
* surface `'unavailable'` rather than guessing. See
* {@link ProviderMaintenanceDebt} for the measure-only-what-you-track
* contract.
*/
maintenanceDebt?(): Promise<ProviderMaintenanceDebt>
/**
* @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. 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
* build-new→verify→swap has verified-and-swapped. While true, brainy SKIPS its
* own rebuild for this provider and lets the provider's non-blocking background
* migration own the index (the no-freeze path); the provider serves correct
* reads from canonical meanwhile. Mirrors `isReady?()` / `init?()` on the
* other index providers.
*/
isMigrating?(): boolean
}
/**
* The `'entityIdMapper'` provider — a drop-in for `EntityIdMapper`. Injected
* into the TypeScript `MetadataIndexManager` when a native metadata index is
* not also registered; that coordinator calls this full surface (incl.
* `getAllIntIds`, the all-ids universe for negation / `exists:false` filters).
*/
export interface EntityIdMapperProvider {
init(): Promise<void>
/**
* @description OPTIONAL: reload the uuid↔int mapping from the CURRENT storage,
* discarding in-memory state — called by `brain.restore()` AFTER the storage
* has been replaced from a snapshot and BEFORE the graph index rebuilds, so
* the graph resolves each verb endpoint through a mapper that reflects the
* snapshot's assignments (without it, native adjacency edges — keyed on these
* ints — resolve to stale/missing ints and are silently dropped). A native
* mapper reloads from its restored binary KV; the JS fallback re-reads its
* persisted metadata. Optional so a mapper that already reloads via `init()`
* stays compatible — `restore()` falls back to `init()` when this is absent.
*/
rebuild?(): Promise<void>
getOrAssign(uuid: string): number
getUuid(intId: number): string | undefined
getInt(uuid: string): number | undefined
remove(uuid: string): boolean
flush(): Promise<void>
clear(): Promise<void>
getAllIntIds(): number[]
intsIterableToUuids(ints: Iterable<number>): string[]
/**
* @description Batch reverse-resolve u64 entity ints → UUID strings — the
* bigint counterpart of {@link EntityIdMapperProvider.intsIterableToUuids},
* for the native graph engine ({@link GraphAccelerationProvider}, whose
* `Subgraph.nodes` is a `BigInt64Array`). Brainy resolves lazily — only the
* rows a caller renders — but viz/export render many at once, so one batch
* crossing beats N. Order-preserving (one entry per input). A native mapper
* resolves every int from its binary int↔uuid store; the JS fallback resolves
* assigned ints and yields `''` for a never-assigned int (which does not occur
* for graph-engine results, since those only reference assigned ints).
* @param nodeInts - Entity ints as returned in a {@link Subgraph}.
* @returns One UUID per input, in order.
*/
entityIntsToUuids(nodeInts: BigInt64Array): string[]
readonly size: number
}
/**
* The `'cache'` provider — a drop-in for `UnifiedCache`. Brainy installs it as
* the global cache (`setGlobalCache`) and calls this surface via
* `getGlobalCache()`.
*/
export interface CacheProvider {
getSync(key: string): any | undefined
set(key: string, data: any, type: 'vectors' | 'metadata' | 'embedding' | 'other', size: number, rebuildCost?: number): void
delete(key: string): boolean
deleteByPrefix(prefix: string): number
clear(type?: 'vectors' | 'metadata' | 'embedding' | 'other'): void
}
// The `'embeddings'` / `'embedBatch'` providers are function-shaped and already
// typed by the existing `EmbeddingFunction` (see `coreTypes.ts`), which Brainy
// uses at the `getProvider('embeddings')` call site. No separate interface is
// added here to avoid a duplicate, unwired contract.
/**
* The `'graph:compression'` provider — pure-function encode/decode for HNSW
* connection lists as compact delta-varint byte sequences (cor's
* `encodeConnections` / `decodeConnections`).
*
* Brainy's `JsHnswVectorIndex` consumes this via a `ConnectionsCodec` that translates
* UUIDs to stable int slots via the `EntityIdMapper`, encodes, and persists
* the compressed bytes through the binary-blob primitive. On load, the blob
* is fetched + decoded back into UUID sets — `setConnectionsCodec()` on
* `JsHnswVectorIndex` is the injection point. Read path is dual-format: when no blob
* exists for a node, the connections fall back to the legacy JSON-array path
* embedded in `saveVectorIndexData`, so pre-2.4.0 indexes keep loading unchanged
* and convergence to the compressed form happens lazily on next save.
*
* Activated only when the storage adapter exposes the binary-blob primitive
* AND the metadata index resolves a stable idMapper. Cloud adapters that
* lack a real local-path resolution still benefit, since the blob primitive
* itself works across every adapter as of brainy 7.25.0.
*/
export interface GraphCompressionProvider {
/** Encode a list of u32 ints to compact delta-varint bytes. Sorts internally. */
encode(ids: number[]): Buffer
/** Decode delta-varint bytes back to a u32 list. */
decode(data: Buffer): number[]
}
/**
* Storage adapter factory — plugins register these to provide
* new storage backends that users reference by name.
*
* Example: A Redis plugin registers 'storage:redis', then users
* can use `new Brainy({ storage: 'redis', redis: { host: '...' } })`
*/
export interface StorageAdapterFactory {
create(config: Record<string, unknown>): StorageAdapter | Promise<StorageAdapter>
name: string
}
/**
* Plugin registry — manages plugin lifecycle and provider resolution.
*/
/** Parse `X.Y.Z[-prerelease][+build]` → `[major, minor, patch]`, prerelease/build stripped. */
function parseSemverCore(version: string): [number, number, number] | null {
const core = version.trim().split('+')[0].split('-')[0]
const parts = core.split('.')
if (parts.length < 1) return null
const nums = [0, 1, 2].map((i) => {
const n = parseInt(parts[i] ?? '0', 10)
return Number.isFinite(n) ? n : NaN
})
if (nums.some((n) => Number.isNaN(n))) return null
return nums as [number, number, number]
}
/** Compare two semver cores. <0 if a<b, 0 if equal, >0 if a>b. */
function compareSemverCore(a: [number, number, number], b: [number, number, number]): number {
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) return a[i] - b[i]
}
return 0
}
/**
* Minimal, dependency-free semver-range check for plugin↔brainy version coupling
* (see {@link BrainyPlugin.brainyRange}). Brainy is a public MIT library, so we
* avoid a full `semver` dependency; the coupling we enforce is major-version
* lockstep with the native accelerator. Supports a space-separated AND of
* `>=`, `>`, `<=`, `<`, `=` comparators plus `^X.Y.Z` (same-major, >= floor).
* Prerelease tags on the running `version` are tolerated (compared by core), so
* an RC build (`8.0.0-rc1`) satisfies `>=8.0.0`.
*
* @returns true if `version` satisfies `range`; on an unparseable range, returns
* true (fail-open on a malformed declaration rather than block a valid brain).
*/
export function pluginRangeSatisfies(version: string, range: string): boolean {
const v = parseSemverCore(version)
if (!v) return false
const comparators = range.trim().split(/\s+/).filter(Boolean)
if (comparators.length === 0) return true
for (const comp of comparators) {
const m = comp.match(/^(\^|>=|<=|>|<|=)?\s*(\d+(?:\.\d+){0,2}(?:[-+].*)?)$/)
if (!m) return true // unparseable comparator → fail-open
const op = m[1] || '='
const target = parseSemverCore(m[2])
if (!target) return true
const cmp = compareSemverCore(v, target)
let ok: boolean
switch (op) {
case '>=': ok = cmp >= 0; break
case '>': ok = cmp > 0; break
case '<=': ok = cmp <= 0; break
case '<': ok = cmp < 0; break
case '^': ok = v[0] === target[0] && cmp >= 0; break // same major, >= floor
default: ok = cmp === 0 // '='
}
if (!ok) return false
}
return true
}
export class PluginRegistry {
private plugins: Map<string, BrainyPlugin> = new Map()
private providers: Map<string, unknown> = new Map()
private activated: Set<string> = new Set()
/**
* Register a plugin manually.
*/
register(plugin: BrainyPlugin): void {
this.plugins.set(plugin.name, plugin)
}
/**
* Activate all registered plugins.
*/
async activateAll(context: BrainyPluginContext): Promise<string[]> {
const activated: string[] = []
for (const [name, plugin] of this.plugins) {
if (this.activated.has(name)) continue
// Version-coupling guard. A registered plugin is ALWAYS explicitly
// requested (config.plugins or brain.use() — brainy does no
// auto-detection), so a version mismatch must FAIL LOUD, never silently
// degrade to the default JS engine. (This was the #1 cross-repo drift:
// an old @soulcraft/cor running invisibly against brainy 8.x.)
if (plugin.brainyRange && !pluginRangeSatisfies(context.version, plugin.brainyRange)) {
throw new Error(
`[brainy] Plugin "${name}" supports brainy ${plugin.brainyRange}, but this is brainy ` +
`${context.version}. The engines must be version-matched (brainy 8.x ↔ @soulcraft/cor 3.x); ` +
`install a compatible ${name} (or brainy). brainy will NOT silently run the default engine ` +
`in place of a mismatched accelerator.`
)
}
let success: boolean
try {
success = await plugin.activate(context)
} catch (error) {
// Hard activation failure on an explicitly-requested plugin is fatal,
// not a swallow-and-fall-back-to-JS.
throw new Error(
`[brainy] Plugin "${name}" failed to activate: ` +
`${error instanceof Error ? error.message : String(error)}`
)
}
if (success) {
this.activated.add(name)
activated.push(name)
} else {
// Documented graceful decline (activate() → false). Surface it loudly so
// a silent degrade to the default engine never goes unnoticed.
console.warn(
`[brainy] Plugin "${name}" declined activation (activate() returned false); ` +
`the default engine is in use for its providers.`
)
}
}
return activated
}
/**
* Deactivate all plugins (called during close()).
*/
async deactivateAll(): Promise<void> {
for (const [name, plugin] of this.plugins) {
if (!this.activated.has(name)) continue
try {
await plugin.deactivate?.()
this.activated.delete(name)
} catch {
// Non-fatal
}
}
}
/**
* Get a registered provider by key.
*/
getProvider<T = unknown>(key: string): T | undefined {
return this.providers.get(key) as T | undefined
}
/**
* Check if a provider is registered.
*/
hasProvider(key: string): boolean {
return this.providers.has(key)
}
/**
* Register a provider (called by plugins via BrainyPluginContext).
*/
registerProvider(key: string, implementation: unknown): void {
this.providers.set(key, implementation)
}
/**
* Get a storage adapter factory by name.
*/
getStorageFactory(name: string): StorageAdapterFactory | undefined {
return this.providers.get(`storage:${name}`) as StorageAdapterFactory | undefined
}
/** Get active plugin names */
getActivePlugins(): string[] {
return [...this.activated]
}
/** Check if any plugins are active */
hasActivePlugins(): boolean {
return this.activated.size > 0
}
}