feat: vector provider identity is a required name field (hnsw-js), rendered [vector-index:<name>]
Reconciles the vector-index rename to the ruled three-layer naming: the provider contract's identity field is now a REQUIRED readonly name (was optional providerId), self-reported and truthful, rendered as [vector-index:<name>] where the index identifies itself and stamped into the op-name strings journals already parse (AddToVectorIndex(<name>)). The built-in JS engine names itself hnsw-js. A runtime provider instance compiled against the previous optional contract is tolerated - never crashed on, never silently mislabeled: it stamps unknown-provider and emits one loud warning naming the missing field. Graph index operations keep their static names (they never interpolate provider identity), and no public API exports a provider-routed hnsw-carrying name, so no deprecation shim is required.
This commit is contained in:
parent
55b867c998
commit
3be4ba96c2
6 changed files with 92 additions and 38 deletions
12
RELEASES.md
12
RELEASES.md
|
|
@ -82,10 +82,20 @@ demand-load cost OFF the transaction path.
|
|||
`AddToVectorIndex(...)`/`RemoveFromVectorIndex(...)` — the old names hard-coded an
|
||||
algorithm that may not be the one actually running (a non-HNSW native vector provider
|
||||
emitting `"RemoveFromHNSW"` has sent an operator hunting an index that doesn't exist).
|
||||
The parenthesized suffix names the ACTIVE backend: `js-hnsw` for the built-in engine, or
|
||||
The parenthesized suffix names the ACTIVE backend: `hnsw-js` for the built-in engine, or
|
||||
the native provider's own identity when it self-identifies. **If you parse these op-name
|
||||
strings** (log processors, journal tooling), update your matcher from
|
||||
`AddToHNSW`/`RemoveFromHNSW` to `AddToVectorIndex(`/`RemoveFromVectorIndex(`.
|
||||
- **Provider identity is now a REQUIRED `name` field** on the vector provider contract
|
||||
(`VectorIndexProvider.name`, `src/plugin.ts`) — every implementation self-reports its own
|
||||
identity truthfully (its algorithm/engine), never inheriting a default. It renders as the
|
||||
op-name suffix above and, wherever the vector index identifies itself in prose log lines,
|
||||
as the tag `[vector-index:<name>]`. **Native provider adoption is a one-line change**:
|
||||
declare `readonly name = '<your-provider-name>'`. A provider instance that still lacks
|
||||
`name` at runtime (an older native build compiled against the previous, optional field) is
|
||||
never crashed on and never silently mislabeled: it stamps `unknown-provider` and emits one
|
||||
loud warning naming the missing field, so the gap is discoverable instead of a permanent
|
||||
fossil label in every journal line.
|
||||
|
||||
## v8.9.0 — 2026-07-19 (flush is durability-only: history maintenance moves to close())
|
||||
|
||||
|
|
|
|||
|
|
@ -54,8 +54,8 @@ export class HnswFlushError extends Error {
|
|||
* acceleration provider).
|
||||
*/
|
||||
export class JsHnswVectorIndex implements VectorIndexProvider {
|
||||
/** Self-identifies as the built-in JS fallback engine — see {@link VectorIndexProvider.providerId}. */
|
||||
readonly providerId = 'js-hnsw'
|
||||
/** Self-identifies as the built-in JS fallback engine — see {@link VectorIndexProvider.name}. */
|
||||
readonly name = 'hnsw-js'
|
||||
|
||||
private nouns: Map<string, HNSWNoun> = new Map()
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -962,20 +962,24 @@ export interface AtGenerationVectors {
|
|||
*/
|
||||
export interface VectorIndexProvider {
|
||||
/**
|
||||
* @description OPTIONAL backend identity, stamped into the transaction
|
||||
* op-name strings that surface in journals/timings (e.g.
|
||||
* `AddToVectorIndex(js-hnsw)` vs `AddToVectorIndex(<providerId>)`) — see
|
||||
* `src/transaction/operations/IndexOperations.ts`. Absent → the op-name
|
||||
* string reports `unknown-provider` rather than guessing a backend name
|
||||
* (guessing 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 `'js-hnsw'`; a native provider
|
||||
* sets its own identity here (e.g. its plugin/package name) so operators
|
||||
* reading a journal see the backend that actually ran, never a
|
||||
* backend-specific fossil name from whichever engine wrote the original
|
||||
* op classes.
|
||||
* @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 providerId?: string
|
||||
readonly name: string
|
||||
|
||||
addItem(item: VectorDocument): Promise<string>
|
||||
removeItem(id: string): Promise<boolean>
|
||||
|
|
|
|||
|
|
@ -16,19 +16,34 @@ import type { Operation, RollbackAction } from '../types.js'
|
|||
|
||||
/**
|
||||
* Backend identity stamped into an operation's emitted `name` string (e.g.
|
||||
* `AddToVectorIndex(js-hnsw)`), resolved from the provider's own
|
||||
* {@link VectorIndexProvider.providerId} when it self-identifies.
|
||||
* `AddToVectorIndex(hnsw-js)`), resolved from the provider's own
|
||||
* {@link VectorIndexProvider.name} self-report.
|
||||
*
|
||||
* These operation classes are backend-neutral (the vector index they wrap may
|
||||
* be Brainy's own JS HNSW fallback OR a native acceleration provider), but
|
||||
* their names surface directly in consumer-visible transaction journals and
|
||||
* timings. A provider that hasn't set `providerId` resolves to
|
||||
* `'unknown-provider'` rather than guessing — silently defaulting to the JS
|
||||
* engine's name here is exactly the class of bug this stamping exists to
|
||||
* prevent (an operator diagnosing a backend that isn't the one that ran).
|
||||
* timings. `name` is REQUIRED at the TypeScript level — but a native provider
|
||||
* instance compiled against the previous (pre-required) contract can still
|
||||
* reach this function at runtime without it. That legacy case is tolerated,
|
||||
* never crashed on and never silently mislabeled: it resolves to
|
||||
* `'unknown-provider'` and emits ONE loud warning naming the missing contract
|
||||
* field, so the fix (implement `name`) is discoverable rather than a silent
|
||||
* fossil label in every journal line thereafter.
|
||||
*/
|
||||
const warnedMissingName = new WeakSet<VectorIndexProvider>()
|
||||
|
||||
function resolveVectorProviderId(index: VectorIndexProvider): string {
|
||||
return index.providerId ?? 'unknown-provider'
|
||||
const name = (index as { name?: unknown }).name
|
||||
if (typeof name === 'string') return name
|
||||
if (!warnedMissingName.has(index)) {
|
||||
warnedMissingName.add(index)
|
||||
console.warn(
|
||||
'[vector-index] provider is missing the required `name` field (VectorIndexProvider.name, ' +
|
||||
'required since 8.10.0) — stamping "unknown-provider" in transaction op names until the ' +
|
||||
'provider declares its own identity.'
|
||||
)
|
||||
}
|
||||
return 'unknown-provider'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ const V = (seed = 1): number[] => Array.from({ length: DIM }, (_, i) => Math.sin
|
|||
* `AddToVectorIndexOperation` / `brain.warm()` call through.
|
||||
*/
|
||||
class FakeVectorProvider implements VectorIndexProvider {
|
||||
readonly name = 'fake-vector-provider'
|
||||
readonly items = new Map<string, Vector>()
|
||||
warmCalls = 0
|
||||
searchCalls: Array<{ k?: number }> = []
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@
|
|||
* journals and timings — a fossil "HNSW" name misdirects an operator running
|
||||
* a native (non-HNSW) vector provider. Verifies:
|
||||
* 1. rollback wiring stayed byte-identical under the rename,
|
||||
* 2. the emitted `name` stamps the active backend — `'js-hnsw'` for
|
||||
* Brainy's own JS index, a provider's own `providerId` when it
|
||||
* self-identifies, and the honest `'unknown-provider'` literal when
|
||||
* neither applies (never a silently-wrong guess).
|
||||
* 2. the emitted `name` stamps the active backend — `'hnsw-js'` for
|
||||
* Brainy's own JS index, a provider's own required `name` when it
|
||||
* self-identifies, and the tolerant-loud `'unknown-provider'` literal
|
||||
* (plus one console.warn) when a runtime instance lacks `name`
|
||||
* altogether (an older native provider compiled against the previous,
|
||||
* optional `providerId` contract) — never a silently-wrong guess.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import {
|
||||
AddToVectorIndexOperation,
|
||||
RemoveFromVectorIndexOperation,
|
||||
|
|
@ -28,7 +30,7 @@ import { JsHnswVectorIndex } from '../../../src/hnsw/hnswIndex.js'
|
|||
*/
|
||||
class FakeVectorProvider implements VectorIndexProvider {
|
||||
readonly items = new Map<string, Vector>()
|
||||
constructor(readonly providerId?: string) {}
|
||||
constructor(readonly name: string) {}
|
||||
|
||||
async addItem(item: VectorDocument): Promise<string> {
|
||||
this.items.set(item.id, item.vector)
|
||||
|
|
@ -115,16 +117,20 @@ describe('Vector index transaction operations — backend-neutral rename', () =>
|
|||
})
|
||||
|
||||
describe('backend stamping in the emitted op name', () => {
|
||||
it('stamps the real JS HNSW index as js-hnsw (self-identifying providerId)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('stamps the real JS HNSW index as hnsw-js (self-identifying name)', () => {
|
||||
const index = new JsHnswVectorIndex()
|
||||
const addOp = new AddToVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
||||
const removeOp = new RemoveFromVectorIndexOperation(index, 'id-1', [1, 2, 3])
|
||||
|
||||
expect(addOp.name).toBe('AddToVectorIndex(js-hnsw)')
|
||||
expect(removeOp.name).toBe('RemoveFromVectorIndex(js-hnsw)')
|
||||
expect(addOp.name).toBe('AddToVectorIndex(hnsw-js)')
|
||||
expect(removeOp.name).toBe('RemoveFromVectorIndex(hnsw-js)')
|
||||
})
|
||||
|
||||
it('stamps a self-identifying provider\'s own providerId, never "HNSW"', () => {
|
||||
it('stamps a self-identifying provider\'s own name, never "HNSW"', () => {
|
||||
const provider = new FakeVectorProvider('acme-diskann')
|
||||
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||||
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||||
|
|
@ -137,16 +143,34 @@ describe('Vector index transaction operations — backend-neutral rename', () =>
|
|||
expect(removeOp.name).not.toContain('HNSW')
|
||||
})
|
||||
|
||||
it('honestly reports "unknown-provider" rather than guessing when a provider omits providerId', () => {
|
||||
const provider = new FakeVectorProvider(undefined)
|
||||
const addOp = new AddToVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||||
const removeOp = new RemoveFromVectorIndexOperation(provider, 'id-1', [1, 2, 3])
|
||||
it('tolerant-loud: stamps "unknown-provider" and warns exactly once when a runtime provider lacks the required `name` (an older native provider compiled against the previous optional `providerId` contract)', () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
// Simulate a native provider compiled before `name` was required — the
|
||||
// TypeScript contract requires `name`, but `as unknown as` mirrors what
|
||||
// actually reaches this code at runtime from an un-rebuilt native addon.
|
||||
const legacyProvider = {
|
||||
addItem: async (item: VectorDocument) => item.id,
|
||||
removeItem: async () => true,
|
||||
search: async () => [],
|
||||
size: () => 0,
|
||||
clear: () => {},
|
||||
rebuild: async () => {},
|
||||
flush: async () => 0,
|
||||
getPersistMode: () => 'immediate' as const
|
||||
} as unknown as VectorIndexProvider
|
||||
|
||||
const addOp = new AddToVectorIndexOperation(legacyProvider, 'id-1', [1, 2, 3])
|
||||
const removeOp = new RemoveFromVectorIndexOperation(legacyProvider, 'id-1', [1, 2, 3])
|
||||
|
||||
expect(addOp.name).toBe('AddToVectorIndex(unknown-provider)')
|
||||
expect(removeOp.name).toBe('RemoveFromVectorIndex(unknown-provider)')
|
||||
// Never silently falls back to the JS engine's name for a provider it
|
||||
// knows nothing about — that's the exact fossil-naming bug being fixed.
|
||||
expect(addOp.name).not.toContain('js-hnsw')
|
||||
expect(addOp.name).not.toContain('hnsw-js')
|
||||
// Loud, not silent — but exactly once per provider instance, not once
|
||||
// per op stamped against it.
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1)
|
||||
expect(warnSpy.mock.calls[0][0]).toContain('name')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue