From 3be4ba96c299b04ce12ffc57898a629c0e8eb02d Mon Sep 17 00:00:00 2001 From: David Snelling Date: Thu, 23 Jul 2026 08:53:05 -0700 Subject: [PATCH] feat: vector provider identity is a required name field (hnsw-js), rendered [vector-index:] 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:] where the index identifies itself and stamped into the op-name strings journals already parse (AddToVectorIndex()). 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. --- RELEASES.md | 12 ++++- src/hnsw/hnswIndex.ts | 4 +- src/plugin.ts | 30 ++++++----- src/transaction/operations/IndexOperations.ts | 29 +++++++--- tests/unit/brainy/warm.test.ts | 1 + .../vectorIndexOperations-rename.test.ts | 54 +++++++++++++------ 6 files changed, 92 insertions(+), 38 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 113b327d..89bf38e7 100644 --- a/RELEASES.md +++ b/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:]`. **Native provider adoption is a one-line change**: + declare `readonly 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()) diff --git a/src/hnsw/hnswIndex.ts b/src/hnsw/hnswIndex.ts index dcf3ed7b..eb2acd71 100644 --- a/src/hnsw/hnswIndex.ts +++ b/src/hnsw/hnswIndex.ts @@ -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 = new Map() /** diff --git a/src/plugin.ts b/src/plugin.ts index eeb2425a..02639955 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -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()`) — 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:]` 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 removeItem(id: string): Promise diff --git a/src/transaction/operations/IndexOperations.ts b/src/transaction/operations/IndexOperations.ts index 1c0995c6..d130bb3f 100644 --- a/src/transaction/operations/IndexOperations.ts +++ b/src/transaction/operations/IndexOperations.ts @@ -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() + 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' } /** diff --git a/tests/unit/brainy/warm.test.ts b/tests/unit/brainy/warm.test.ts index 437b7785..ce213696 100644 --- a/tests/unit/brainy/warm.test.ts +++ b/tests/unit/brainy/warm.test.ts @@ -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() warmCalls = 0 searchCalls: Array<{ k?: number }> = [] diff --git a/tests/unit/transaction/vectorIndexOperations-rename.test.ts b/tests/unit/transaction/vectorIndexOperations-rename.test.ts index 80168346..bdfe58a3 100644 --- a/tests/unit/transaction/vectorIndexOperations-rename.test.ts +++ b/tests/unit/transaction/vectorIndexOperations-rename.test.ts @@ -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() - constructor(readonly providerId?: string) {} + constructor(readonly name: string) {} async addItem(item: VectorDocument): Promise { 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') }) }) })