feat: mmap-vector backend wiring — HNSWIndex consumes vectorStore:mmap (2.4.0 #2)

Cortex already registers the vectorStore:mmap provider (its Rust
NativeMmapVectorStore), but brainy has never consumed it — preloadVectors
and getVectorSafe still go straight to storage.getNounVector for every id,
even when an mmap layer is available. This wires the consumer end.

Architecture:

- NEW MmapVectorBackend (src/hnsw/mmapVectorBackend.ts) — bridges brainy's
  UUID-keyed vector reads to a int-slot mmap file via the
  vectorStore:mmap provider. Slots are addressed by the stable int id
  from the post-2.4.0 #1 EntityIdMapper (the foundation this depends on).
  Auto-grows the file (doubling) when a write lands beyond capacity, so
  HNSWIndex never has to think about sizing. The class never touches
  per-entity storage — it owns only the mmap layer.

- HNSWIndex changes — adds a vectorBackend field + a setVectorBackend
  setter. The vector read paths (preloadVectors, getVectorSafe) try the
  mmap layer first; on a storage fallback hit, they LAZILY write back into
  the mmap slot. An upgraded install converges to the zero-copy fast path
  under live traffic — no big-bang migration step. The legacy per-entity
  path is preserved and still used when no backend is set.

- brainy.ts wiring — a new private wireMmapVectorBackend() runs once
  during init, after plugin activation + metadataIndex setup. It activates
  the backend only when (a) the vectorStore:mmap provider is registered,
  (b) the storage adapter resolves a real local path via
  getBinaryBlobPath(), and (c) the metadata index exposes its idMapper.
  Cloud adapters return null on (b) and the backend is silently skipped;
  HNSWIndex's behaviour is then identical to pre-2.4.0.

- Provider interfaces in plugin.ts — VectorStoreMmapProvider and
  VectorStoreMmapInstance document the contract cortex's class fulfils
  (the class IS the provider — static factory methods). Brainy depends on
  the interfaces, not on cortex; the structural match is verified when
  cortex 2.4.0 picks up this brainy release.

Tests (1428 total, +11 vs pre-2.4.0):

- tests/unit/hnsw/mmap-vector-backend.test.ts — 6 unit tests with an
  in-memory mock provider. Covers round-trip, batch reads with interleaved
  misses, slot stability (no re-slotting on overwrite), file growth without
  data loss, idempotent open, and null returns for unwritten slots. The
  real perf integration with cortex's NativeMmapVectorStore is exercised
  when cortex 2.4.0 wires this in.

- tests/unit/utils/entity-id-mapper-stability.test.ts — moved here from
  tests/regression/ (which is NOT in the unit-config include glob, so the
  five #23 tests were not actually being run by npm test). The unit
  config matches tests/unit/**/*.test.ts.

The 2.4.0 #2 follow-up will be the chunked-segment layout for remote
storage adapters (S3 / R2 / GCS) where a single growing file doesn't fit
immutable objects. For 2.4.0 release: local-FS only.
This commit is contained in:
David Snelling 2026-05-28 10:10:05 -07:00
parent 01cf4b1b7e
commit c18ccbb497
6 changed files with 640 additions and 4 deletions

View file

@ -0,0 +1,242 @@
/**
* @module hnsw/mmapVectorBackend.test
* @description Unit tests for the `MmapVectorBackend` bridge the brainy-side
* wrapper that translates UUID-keyed vector reads/writes into stable int slot
* ops against an `vectorStore:mmap` provider.
*
* Mocks the provider so the tests run without cortex installed (cortex is a
* downstream consumer of brainy, not a dev dep). The real integration with
* cortex's `NativeMmapVectorStore` is exercised when cortex 2.4.0 picks up
* this brainy release and re-runs its cross-language parity suite.
*
* Coverage:
* 1. Open-then-write-then-read round-trips for a single vector.
* 2. Batch reads return an array aligned with the input UUIDs, with `null`
* entries for misses interleaved among hits order preserved.
* 3. Slot assignment is stable across multiple writes for the same UUID
* (no re-slot, no overwrite of an adjacent slot).
* 4. Writes beyond the initial capacity grow the file (doubling) without
* losing the vectors already written.
* 5. `readByUuid` returns `null` for both unknown UUIDs and UUIDs in the map
* whose slot has not yet been written.
* 6. Open is idempotent opening an already-existing file reuses it.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { tmpdir } from 'node:os'
import { mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { MmapVectorBackend } from '../../../src/hnsw/mmapVectorBackend.js'
import { EntityIdMapper } from '../../../src/utils/entityIdMapper.js'
import type {
VectorStoreMmapInstance,
VectorStoreMmapProvider
} from '../../../src/plugin.js'
/**
* Minimal storage stub for the EntityIdMapper. The mapper only touches storage
* in init/flush; for these tests the mapper starts empty and is never flushed.
*/
const stubStorage = {
getMetadata: async () => undefined,
saveMetadata: async () => {},
getNouns: async () => ({ totalCount: 0, items: [] })
} as any
/**
* Pure in-memory mmap store. Mirrors cortex's NativeMmapVectorStore surface
* just closely enough to exercise the backend's contract no real mmap, no
* file I/O, no f32 round-trip narrowing (the precision check is out of scope
* here; that's covered by cortex's parity suite).
*/
class MockMmapStore implements VectorStoreMmapInstance {
private readonly vectors: Array<number[] | undefined> = []
private highestWritten = -1
constructor(
public readonly dim: number,
private _capacity: number
) {}
get count(): number {
return this.highestWritten + 1
}
get capacity(): number {
return this._capacity
}
writeVector(index: number, vector: number[]): void {
if (index >= this._capacity) {
throw new Error(`Slot ${index} >= capacity ${this._capacity}`)
}
if (vector.length !== this.dim) {
throw new Error(`Dim mismatch: expected ${this.dim}, got ${vector.length}`)
}
this.vectors[index] = [...vector]
if (index > this.highestWritten) this.highestWritten = index
}
writeVectorsBatch(startIndex: number, vectorsFlat: number[]): number {
if (vectorsFlat.length % this.dim !== 0) {
throw new Error('vectorsFlat length not a multiple of dim')
}
const n = vectorsFlat.length / this.dim
for (let i = 0; i < n; i++) {
this.writeVector(startIndex + i, vectorsFlat.slice(i * this.dim, (i + 1) * this.dim))
}
return n
}
readVector(index: number): number[] {
const v = this.vectors[index]
if (!v) throw new Error(`Slot ${index} not written`)
return [...v]
}
readVectorsBatch(indices: number[]): number[] {
const flat: number[] = []
for (const i of indices) {
const v = this.vectors[i]
if (!v) throw new Error(`Slot ${i} not written`)
for (let k = 0; k < this.dim; k++) flat.push(v[k])
}
return flat
}
prefetch(_indices: number[]): void {
/* no-op in mock */
}
resize(newCapacity: number): void {
if (newCapacity < this._capacity) throw new Error('Cannot shrink')
this._capacity = newCapacity
}
flush(): void {
/* no-op in mock */
}
}
/**
* Mock provider keeps one MockMmapStore per path. open() throws if the path
* doesn't exist yet (matches cortex semantics: open() is for existing files
* only); create() throws if it does (cortex doesn't, but the brainy backend
* does open-first-then-create, so the throw path is exercised).
*/
class MockMmapProvider implements VectorStoreMmapProvider {
private files = new Map<string, MockMmapStore>()
create(path: string, dim: number, capacity: number): VectorStoreMmapInstance {
if (this.files.has(path)) throw new Error(`File exists at ${path}`)
const store = new MockMmapStore(dim, capacity)
this.files.set(path, store)
return store
}
open(path: string): VectorStoreMmapInstance {
const store = this.files.get(path)
if (!store) throw new Error(`No file at ${path}`)
return store
}
openReadOnly(path: string): VectorStoreMmapInstance {
return this.open(path)
}
/** Test helper — peek at the underlying store. */
_peek(path: string): MockMmapStore | undefined {
return this.files.get(path)
}
}
describe('MmapVectorBackend (2.4.0 #2 — wraps vectorStore:mmap provider)', () => {
let dir: string
let path: string
let idMapper: EntityIdMapper
let provider: MockMmapProvider
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'brainy-mmap-vec-'))
path = join(dir, 'vectors.bin')
idMapper = new EntityIdMapper({ storage: stubStorage })
await idMapper.init()
provider = new MockMmapProvider()
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true }).catch(() => {})
})
it('open creates a new file when none exists, then round-trips a vector by UUID', async () => {
const backend = await MmapVectorBackend.open(provider, path, 4, 16, idMapper)
expect(backend.dim).toBe(4)
backend.writeByUuid('alpha', [1, 2, 3, 4])
expect(backend.readByUuid('alpha')).toEqual([1, 2, 3, 4])
})
it('reads return null for unknown UUIDs and for UUIDs in the map but not yet written', async () => {
const backend = await MmapVectorBackend.open(provider, path, 2, 8, idMapper)
// Unknown — never seen by the mapper.
expect(backend.readByUuid('ghost')).toBeNull()
// Known to the mapper but no slot ever written. We assign through the
// mapper directly so the backend itself has not touched the slot.
idMapper.getOrAssign('reserved')
expect(backend.readByUuid('reserved')).toBeNull()
})
it('batch read returns an array aligned to the input UUIDs (nulls preserved in place)', async () => {
const backend = await MmapVectorBackend.open(provider, path, 3, 16, idMapper)
backend.writeByUuid('a', [1, 1, 1])
backend.writeByUuid('b', [2, 2, 2])
backend.writeByUuid('c', [3, 3, 3])
// Interleave hits + a never-seen UUID + a duplicate hit. Order preserved.
const result = backend.readBatchByUuid(['b', 'missing', 'a', 'c', 'missing-too', 'b'])
expect(result).toEqual([
[2, 2, 2],
null,
[1, 1, 1],
[3, 3, 3],
null,
[2, 2, 2]
])
})
it('writes for the same UUID land in the same slot (stable id, no re-slotting)', async () => {
const backend = await MmapVectorBackend.open(provider, path, 2, 8, idMapper)
backend.writeByUuid('persistent', [1, 1])
const slotAfterFirstWrite = idMapper.getInt('persistent')
backend.writeByUuid('persistent', [9, 9]) // overwrite the same slot
expect(idMapper.getInt('persistent')).toBe(slotAfterFirstWrite)
expect(backend.readByUuid('persistent')).toEqual([9, 9])
// Another UUID gets a different slot, and the first vector is undisturbed.
backend.writeByUuid('other', [5, 5])
expect(idMapper.getInt('other')).not.toBe(slotAfterFirstWrite)
expect(backend.readByUuid('persistent')).toEqual([9, 9])
expect(backend.readByUuid('other')).toEqual([5, 5])
})
it('grows the file (doubling) when a write lands beyond capacity, without losing prior data', async () => {
// Start at the smallest sane initial capacity (clamped to 16 by the backend).
const backend = await MmapVectorBackend.open(provider, path, 2, 1, idMapper)
const store = provider._peek(path)!
expect(store.capacity).toBe(16) // backend floor
// Write 20 vectors so capacity must double at least once (16 → 32).
const uuids: string[] = []
for (let i = 0; i < 20; i++) {
const uuid = `u-${i}`
uuids.push(uuid)
backend.writeByUuid(uuid, [i, i * 2])
}
expect(store.capacity).toBeGreaterThanOrEqual(32)
// Every vector survived the growth — no slot got overwritten or lost.
for (let i = 0; i < uuids.length; i++) {
expect(backend.readByUuid(uuids[i])).toEqual([i, i * 2])
}
})
it('opens an existing file idempotently when the second open hits the same path', async () => {
// First open creates.
const backend1 = await MmapVectorBackend.open(provider, path, 2, 8, idMapper)
backend1.writeByUuid('persisted', [7, 7])
// Second open against the same path. The provider's create() throws on
// collision; the backend's open-first behaviour means we reuse the file.
const backend2 = await MmapVectorBackend.open(provider, path, 2, 8, idMapper)
expect(backend2.readByUuid('persisted')).toEqual([7, 7])
})
})

View file

@ -22,7 +22,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { Brainy } from '../../../src/brainy.js'
const DIM = 384
const makeVec = (seed = 1) =>