feat(8.0): zero-config finalize + cut JS quantization (config.vector = recall + persistMode)

This commit is contained in:
David Snelling 2026-06-15 10:08:51 -07:00
parent f4c5d9749f
commit f8e0079d3f
12 changed files with 348 additions and 1739 deletions

View file

@ -116,7 +116,7 @@ import type { MigrationPreview, MigrationResult, MigrateOptions } from './migrat
import { AggregationIndex } from './aggregation/AggregationIndex.js'
import { AggregateMaterializer } from './aggregation/materializer.js'
import type { AggregateDefinition, AggregateQueryParams, AggregateResult } from './types/brainy.types.js'
import { resolveJsHnswConfig } from './utils/recallPreset.js'
import { resolveJsHnswConfig, DEFAULT_RECALL } from './utils/recallPreset.js'
import * as fs from 'node:fs'
import { Db, type DbHost, type HistoricalQueryHandle } from './db/db.js'
import { GenerationStore } from './db/generationStore.js'
@ -205,6 +205,8 @@ type ResolvedBrainyConfig = Required<
| 'vector'
| 'plugins'
| 'integrations'
| 'history'
| 'eagerEmbeddings'
>
> &
Pick<
@ -215,6 +217,8 @@ type ResolvedBrainyConfig = Required<
| 'vector'
| 'plugins'
| 'integrations'
| 'history'
| 'eagerEmbeddings'
>
/**
@ -387,6 +391,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
private _readyResolve: (() => void) | null = null
private _readyReject: ((error: Error) => void) | null = null
// In-flight init() promise. Guards against concurrent initialization when
// multiple operations race to lazily initialize the same instance — all
// racers await the single in-flight run. Reset on failure so init() can
// be retried after the underlying cause (e.g. a held writer lock) clears.
private initPromise: Promise<void> | null = null
// Set once close() has torn the instance down. close() is terminal: a closed
// instance must NOT silently lazy-re-initialize on the next operation (that
// would resurrect released indexes and timers). The lazy-init convenience
// applies only to instances that were never closed.
private closed = false
// Lazy rebuild state (Production-scale lazy loading)
// Prevents race conditions when multiple queries trigger rebuild simultaneously
private lazyRebuildInProgress = false
@ -542,13 +558,42 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
/**
* Initialize Brainy - MUST be called before use
* Initialize Brainy.
*
* **Calling this explicitly is optional.** Every public operation lazily
* initializes the instance on first use (`new Brainy()` followed directly
* by `add()` / `find()` / `transact()` just works). Call `init()` yourself
* when you want to control *when* the startup cost is paid (e.g. during
* server boot rather than on the first request) or to pass init-time
* configuration overrides.
*
* Idempotent and concurrency-safe: repeat calls after success return
* immediately; calls racing an in-flight initialization await that single
* run (overrides are only applied by the run that starts initialization).
*
* @param overrides Optional configuration overrides for init
*/
async init(overrides?: Partial<BrainyConfig & { dimensions?: number }>): Promise<void> {
if (this.initialized) {
return
}
if (this.initPromise) {
return this.initPromise
}
const run = this.performInit(overrides)
this.initPromise = run
try {
await run
} finally {
this.initPromise = null
}
}
/**
* The single initialization run guarded by `init()`. Never call directly
* always go through `init()` (or any public method, which lazily inits).
*/
private async performInit(overrides?: Partial<BrainyConfig & { dimensions?: number }>): Promise<void> {
// Apply any init-time configuration overrides
if (overrides) {
@ -566,7 +611,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
...this.config,
...configOverrides,
storage: mergedStorage,
index: { ...this.config.index, ...configOverrides.index },
verbose: configOverrides.verbose ?? this.config.verbose,
silent: configOverrides.silent ?? this.config.silent
}
@ -702,32 +746,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
setMsgpackImplementation(msgpackProvider)
}
// Provider: native SQ8 approximate distance (e.g. cortex's Rust SIMD) — swaps
// the JS quantized-distance used in HNSW SQ8 reranking. Signature is
// byte-compatible with the JS distanceSQ8; falls back to JS when absent.
const sq8DistanceProvider = this.pluginRegistry.getProvider<
(a: Uint8Array, aMin: number, aMax: number, b: Uint8Array, bMin: number, bMax: number) => number
>('distance:sq8')
if (sq8DistanceProvider) {
const { setSQ8DistanceImplementation } = await import('./utils/vectorQuantization.js')
setSQ8DistanceImplementation(sq8DistanceProvider)
}
// Provider: native SQ4 approximate distance (cortex's Rust). Same swap
// pattern as SQ8; signature is byte-compatible with the JS distanceSQ4
// (4-bit quantization range, packed nibbles). Used in HNSW SQ4 reranking
// when config.hnsw.quantization.bits === 4. Falls back to JS when absent.
const sq4DistanceProvider = this.pluginRegistry.getProvider<
(
a: Uint8Array, aMin: number, aMax: number, aDim: number,
b: Uint8Array, bMin: number, bMax: number, bDim: number
) => number
>('distance:sq4')
if (sq4DistanceProvider) {
const { setSQ4DistanceImplementation } = await import('./utils/vectorQuantization.js')
setSQ4DistanceImplementation(sq4DistanceProvider)
}
// Provider: sort:topK (e.g. cortex's native partial-sort / heap-select) — swaps the
// JS result-ranking used by find() to pick the top `offset + limit` rows. The provider
// returns indices into a scores array, ordered descending with stable ties, identical
@ -831,11 +849,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
// Connect distributed components to storage
await this.connectDistributedStorage()
// Warm up if configured
if (this.config.warmup) {
await this.warmup()
}
// Register shutdown hooks for graceful count flushing (once globally)
if (!Brainy.shutdownHooksRegisteredGlobally) {
this.registerShutdownHooks()
@ -875,12 +888,34 @@ export class Brainy<T = any> implements BrainyInterface<T> {
await this._vfs.init()
this._vfsInitialized = true // Mark VFS as fully initialized
// Eager embedding initialization for cloud deployments
// When eagerEmbeddings is true, initialize the WASM embedding engine now
// instead of lazily on first embed() call. This moves the 90-140 second
// WASM compilation to container startup rather than first request.
// Recommended for: Cloud Run, Lambda, Fargate, Kubernetes
if (this.config.eagerEmbeddings && !this.pluginRegistry.hasProvider('embeddings')) {
// Eager embedding initialization.
//
// Adaptive default (8.0): the WASM embedding engine eagerly initializes
// during init() WHENEVER it is the active embedder — i.e. no native
// 'embeddings' provider has taken over — and the instance is a writer
// (not reader-mode) outside of unit tests. The WASM module (≈93MB with
// the embedded model) takes 90-140s to compile on throttled CPUs; paying
// that during boot rather than on the first embed()-driven call is the
// right default for the overwhelmingly common single-process server.
//
// Skipped automatically when:
// - a native 'embeddings' provider is registered (it owns embeddings;
// the WASM engine is dead weight),
// - reader-mode (readers don't embed — they query existing vectors),
// - unit-test mode (tests must stay fast and use the mock embedder).
//
// `eagerEmbeddings: false` is the explicit override to force lazy init
// (first-embed) even when this instance is the active embedder.
const isUnitTestMode =
process.env.BRAINY_UNIT_TEST === 'true' ||
(globalThis as { __BRAINY_UNIT_TEST__?: boolean }).__BRAINY_UNIT_TEST__ === true
const eager = this.config.eagerEmbeddings ?? true
if (
eager &&
!this.pluginRegistry.hasProvider('embeddings') &&
this.config.mode !== 'reader' &&
!isUnitTestMode
) {
console.log('Eager embedding initialization enabled...')
await embeddingManager.init()
console.log('Embedding engine ready')
@ -1026,11 +1061,24 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
/**
* Ensure Brainy is initialized
* Ensure Brainy is initialized, lazily initializing on first use.
*
* Called at the top of every public operation, so `new Brainy()` followed
* directly by `add()` / `find()` / any other call just works forgetting
* `init()` is not an error. Concurrent first operations share a single
* initialization run (see `init()`); if initialization fails, the error
* propagates to the operation that triggered it.
*
* `close()` is terminal: a closed instance throws here instead of silently
* re-initializing using a closed Brainy is a consumer bug, not a lazy-init
* opportunity.
*/
private async ensureInitialized(): Promise<void> {
if (this.closed) {
throw new Error('Brainy instance is not initialized: it was closed via close(). Create a new instance.')
}
if (!this.initialized) {
throw new Error('Brainy not initialized. Call init() first.')
await this.init()
}
}
@ -5143,6 +5191,68 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return this.generationStore.compact(options)
}
/**
* @description Resolve the effective generational-history retention policy
* from `config.history`, filling per-field defaults so a partial object
* inherits the rest. This is the single read site for the policy referenced
* by {@link autoCompactHistory}.
*
* Defaults (zero-config): keep the 100 most recent committed generations
* AND everything committed within the last 7 days; auto-compact on
* `flush()` / `close()`. A generation is reclaimed only when it is older
* than BOTH bounds and no live `Db` pin protects it.
*
* @returns The resolved policy: retention bounds plus the `autoCompact` flag.
*/
private resolveHistoryPolicy(): {
retainGenerations: number
retainMs: number
autoCompact: boolean
} {
const history = this.config.history
return {
retainGenerations: history?.retainGenerations ?? 100,
retainMs: history?.retainMs ?? 604_800_000, // 7 days
autoCompact: history?.autoCompact ?? true
}
}
/**
* @description Run history compaction with the resolved retention policy if
* `config.history.autoCompact` is on (the default). Invoked from `flush()`
* and `close()` so generational record-sets cannot accumulate unbounded
* across a long-lived writer's lifetime.
*
* Read-only instances and an explicit `autoCompact: false` skip silently.
* Pinned generations (live `Db` values) are never reclaimed the safety
* invariant lives in {@link GenerationStore.compact}. Failures are logged
* and swallowed: compaction is housekeeping and must never fail a flush or
* a clean shutdown.
*/
private async autoCompactHistory(): Promise<void> {
// Nothing to compact on a read-only instance or before init wired up the
// generation store (close() can run defensively on an uninitialized brain).
if (this.isReadOnly || !this.generationStore) {
return
}
const policy = this.resolveHistoryPolicy()
if (!policy.autoCompact) {
return
}
try {
await this.generationStore.compact({
retainGenerations: policy.retainGenerations,
retainMs: policy.retainMs
})
} catch (error) {
console.warn(
`Auto-compaction of generational history failed (non-fatal): ${
error instanceof Error ? error.message : String(error)
}`
)
}
}
/**
* @description Replace this store's ENTIRE state from a snapshot directory
* previously produced by `db.persist(path)`. Destructive: current
@ -6858,6 +6968,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
this.generationStore.persistCounterNow()
])
// 6. Auto-compact generational history per config.history (default on).
// Runs after the flush so durable state is in place; respects live
// Db pins and an explicit autoCompact: false.
await this.autoCompactHistory()
const elapsed = Date.now() - startTime
console.log(`All indexes flushed to disk in ${elapsed}ms`)
@ -10167,14 +10282,6 @@ export class Brainy<T = any> implements BrainyInterface<T> {
return this.embedder(textToEmbed)
}
/**
* Warm up the system
*/
private async warmup(): Promise<void> {
// Warm up embedder
await this.embed('warmup')
}
/**
* Explicitly warm up the embedding engine
*
@ -10300,26 +10407,22 @@ export class Brainy<T = any> implements BrainyInterface<T> {
/**
* Setup index single unified vector graph.
*
* Brainy 8.0 ships filesystem-only storage (no cloud adapters), so persistence
* mode defaults to `'immediate'` everywhere. Operators who want bulk-ingest
* speed can set `config.vector.persistMode = 'deferred'`.
* Persistence mode is auto-selected from the storage adapter (see
* {@link resolveHNSWPersistMode}): `'immediate'` on filesystem storage,
* `'deferred'` on memory storage. Operators can override with an explicit
* `config.vector.persistMode` (e.g. `'deferred'` for bulk-ingest speed on
* filesystem storage).
*/
private setupIndex(): JsHnswVectorIndex {
// 8.0 config surface: config.vector.{recall, quantization, persistMode}.
// 8.0 config surface: config.vector.{recall, persistMode}.
// The recall preset translates to HNSW knobs (M / efConstruction / efSearch)
// via resolveJsHnswConfig. No algorithm-internal knobs are exposed at the
// public surface.
// public surface. The JS index computes exact float32 distances throughout —
// there is no quantization path.
const recallKnobs = resolveJsHnswConfig(this.config.vector)
const vectorCfg = this.config.vector
const indexConfig = {
...this.config.index,
...recallKnobs,
distanceFunction: this.distance,
quantization: vectorCfg?.quantization ? {
enabled: vectorCfg.quantization.enabled ?? false,
bits: vectorCfg.quantization.bits ?? 8,
rerankMultiplier: 3
} : undefined
distanceFunction: this.distance
}
const persistMode = this.resolveHNSWPersistMode()
@ -10346,8 +10449,18 @@ export class Brainy<T = any> implements BrainyInterface<T> {
const persistMode = this.resolveHNSWPersistMode()
const vectorFactory = this.pluginRegistry.getProvider<(config: any, distance: DistanceFunction, options: any) => any>('vector')
if (vectorFactory) {
// Native providers receive the algorithm-neutral surface: the resolved
// recall preset name (per the 8.0 provider contract they translate it
// to their own internal knobs), plus the JS HNSW knob tuple for
// providers that mirror the open-core implementation. Quantization at
// scale is the native provider's own concern (e.g. DiskANN PQ) — Brainy
// exposes no quantization knob.
return vectorFactory(
{ ...this.config.index, distanceFunction: this.distance },
{
...resolveJsHnswConfig(this.config.vector),
recall: this.config.vector?.recall ?? DEFAULT_RECALL,
distanceFunction: this.distance
},
this.distance,
{ storage: this.storage, persistMode }
)
@ -10356,15 +10469,23 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
/**
* Resolve HNSW persistence mode.
* Extracted so both setupIndex() and the HNSW plugin factory path can use it.
* Resolve the vector persistence mode from the storage adapter.
*
* Brainy 8.0 ships filesystem-only; the cloud-storage smart-default
* (deferred for cloud, immediate for local) collapses to "always
* immediate unless the user overrides via config.vector.persistMode".
* Auto-selected when `config.vector.persistMode` is omitted:
* - **filesystem** (and any persistent adapter): `'immediate'` per-add
* durability is the point of a persistent backend.
* - **memory**: `'deferred'` nothing survives the process anyway, so
* per-add persistence writes are pure overhead; deferred batches them
* into `flush()` / `close()`.
*
* An explicit `config.vector.persistMode` always wins (the operator
* escape hatch for bulk-ingest pipelines on filesystem storage).
*/
private resolveHNSWPersistMode(): 'immediate' | 'deferred' {
return this.config.vector?.persistMode ?? 'immediate'
if (this.config.vector?.persistMode) {
return this.config.vector.persistMode
}
return this.getStorageType() === 'memory' ? 'deferred' : 'immediate'
}
/**
@ -10390,59 +10511,40 @@ export class Brainy<T = any> implements BrainyInterface<T> {
)
}
// Validate numeric configurations
if (config?.index?.m && (config.index.m < 1 || config.index.m > 128)) {
throw new Error(`Invalid index m parameter: ${config.index.m}. Must be between 1 and 128`)
}
if (config?.index?.efConstruction && (config.index.efConstruction < 1 || config.index.efConstruction > 1000)) {
throw new Error(`Invalid index efConstruction: ${config.index.efConstruction}. Must be between 1 and 1000`)
}
if (config?.index?.efSearch && (config.index.efSearch < 1 || config.index.efSearch > 1000)) {
throw new Error(`Invalid index efSearch: ${config.index.efSearch}. Must be between 1 and 1000`)
}
// Auto-detect distributed mode based on environment and configuration
const distributedConfig = this.autoDetectDistributed(config?.distributed)
// Distributed mode is explicit opt-in only (config or BRAINY_DISTRIBUTED
// env var) — never inferred from deployment heuristics.
const distributedConfig = this.resolveDistributedConfig(config?.distributed)
return {
storage: config?.storage || { type: 'auto' },
index: config?.index || {},
cache: config?.cache ?? true,
distributed: distributedConfig,
warmup: config?.warmup ?? false,
realtime: config?.realtime ?? false,
multiTenancy: config?.multiTenancy ?? false,
telemetry: config?.telemetry ?? false,
verbose: config?.verbose ?? false,
silent: config?.silent ?? false,
// New performance options with smart defaults
disableAutoRebuild: config?.disableAutoRebuild ?? false, // false = auto-decide based on size
disableMetrics: config?.disableMetrics ?? false,
disableAutoOptimize: config?.disableAutoOptimize ?? false,
batchWrites: config?.batchWrites ?? true,
maxConcurrentOperations: config?.maxConcurrentOperations ?? 10,
// Memory management options
// false = auto-decide based on dataset size (inline vs lazy rebuild)
disableAutoRebuild: config?.disableAutoRebuild ?? false,
// Memory management escape hatches over the RAM-derived auto limits
maxQueryLimit: config?.maxQueryLimit ?? undefined,
reservedQueryMemory: config?.reservedQueryMemory ?? undefined,
// Vector index configuration (8.0) — algorithm-neutral surface with
// `recall` preset + `persistMode` (folded in from 7.x's hnswPersistMode).
// See BRAINY-8.0-RENAME-COORDINATION § A.2 + § G.2.
vector: config?.vector ?? undefined,
// Embedding initialization - false = lazy init on first embed()
eagerEmbeddings: config?.eagerEmbeddings ?? false,
// Generational-history retention — defaults applied at the read site
// (resolveHistoryPolicy) so a partial object inherits per-field defaults.
history: config?.history ?? undefined,
// Embedding initialization — left undefined when omitted so the adaptive
// default resolves at the init() read site (eager when the WASM embedder
// is the active one on a non-reader writer outside unit tests). Explicit
// true/false always wins. See the eagerEmbeddings JSDoc in brainy.types.ts.
eagerEmbeddings: config?.eagerEmbeddings ?? undefined,
// Plugin configuration - undefined = auto-detect
plugins: config?.plugins ?? undefined,
// Integration Hub - undefined/false = disabled
integrations: config?.integrations ?? undefined,
// Migration — disabled by default, opt-in for automatic migration
autoMigrate: config?.autoMigrate ?? false,
// Subtype pairing enforcement (7.30.0) — opt-in.
// false: only per-type rules registered via brain.requireSubtype() apply.
// true: every public write path requires subtype on every type.
// { except: [...] }: strict, but listed types may omit subtype.
// Becomes the default in 8.0.0.
// Migration — auto-run by default. Pending data migrations apply inline
// during init() for small datasets (<10K entities); larger datasets
// defer with a notice so the operator can schedule brain.migrate().
autoMigrate: config?.autoMigrate ?? true,
// Subtype required-by-default (8.0 — per BRAINY-8.0-SUBTYPE-CONTRACT § C-1).
// Every write path now requires `subtype` on every type unless the consumer
// opts out explicitly. Opt-out: `requireSubtype: false` (last-resort migration
@ -10926,6 +11028,11 @@ export class Brainy<T = any> implements BrainyInterface<T> {
* This ensures deferred persistence mode data is saved
*/
async close(): Promise<void> {
// Phase 0: Auto-compact generational history per config.history (default
// on) BEFORE the generation store closes below. Respects live Db pins and
// an explicit autoCompact: false; no-op on read-only instances.
await this.autoCompactHistory()
// Phase 1: Flush ALL components in parallel to persist buffered data
// This is critical when cortex native providers buffer data in Rust memory
await Promise.all([
@ -11033,26 +11140,34 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
this.initialized = false
// close() is terminal: block lazy re-initialization on any subsequent
// operation (ensureInitialized() throws once this is set).
this.closed = true
}
/**
* Intelligently auto-detect distributed configuration
* Zero-config: Automatically determines best distributed settings
*/
private autoDetectDistributed(config?: BrainyConfig['distributed']): BrainyConfig['distributed'] {
// If explicitly disabled, respect that
if (config?.enabled === false) {
return config
}
// Auto-detect based on environment variables (common in production)
const envEnabled = process.env.BRAINY_DISTRIBUTED === 'true' ||
process.env.NODE_ENV === 'production' ||
process.env.CLUSTER_SIZE ||
process.env.KUBERNETES_SERVICE_HOST // Running in K8s
// If not explicitly configured but environment suggests distributed
if (!config && envEnabled) {
/**
* Resolve distributed-cluster configuration. **Explicit opt-in only**:
* either `config.distributed.enabled === true` or the
* `BRAINY_DISTRIBUTED=true` environment variable. Once opted in, the
* remaining fields default sensibly (64 shards, 3 replicas, raft, http,
* hostname-derived nodeId).
*
* Earlier versions auto-enabled cluster mode from deployment heuristics
* (`NODE_ENV=production`, a Kubernetes service host, `CLUSTER_SIZE`).
* That violated zero-config safety: a single-process production
* deployment the overwhelmingly common case would silently start a
* coordinator, bind a port, and run raft against itself. Deployment
* environment does not imply a multi-node Brainy cluster, so the
* heuristics are gone.
*/
private resolveDistributedConfig(config?: BrainyConfig['distributed']): BrainyConfig['distributed'] {
// Env-var opt-in for deployments that can't touch code (the ONLY
// environment signal honoured — it names brainy explicitly).
if (!config && process.env.BRAINY_DISTRIBUTED === 'true') {
return {
enabled: true,
nodeId: process.env.HOSTNAME || process.env.NODE_ID || `node-${Date.now()}`,
@ -11065,7 +11180,7 @@ export class Brainy<T = any> implements BrainyInterface<T> {
}
}
// Merge with provided config, applying intelligent defaults
// Explicit config: fill the per-field defaults.
return config ? {
...config,
nodeId: config.nodeId || process.env.HOSTNAME || `node-${Date.now()}`,

View file

@ -352,11 +352,6 @@ export function presetToBrainyConfig(preset: PresetConfig): any {
if (preset.allowDirectReads) config.allowDirectReads = true
if (preset.lazyLoadInReadOnlyMode) config.lazyLoadInReadOnlyMode = true
}
// Handle cache settings
if (preset.cache) {
config.cache = preset.cache
}
return config
}

View file

@ -96,10 +96,6 @@ export interface HNSWNoun {
vector: Vector
connections: Map<number, Set<string>> // level -> set of connected noun ids
level: number // The highest layer this noun appears in
// SQ8 quantized vector for approximate distance computation (B1 optimization)
quantizedVector?: Uint8Array // 4x smaller than float32 vector
codebookMin?: number // quantization codebook minimum
codebookMax?: number // quantization codebook maximum
// ✅ NO metadata field - stored separately for optimization
}
@ -475,12 +471,6 @@ export interface HNSWConfig {
ml: number // Maximum level
useDiskBasedIndex?: boolean // Whether to use disk-based index
maxConcurrentNeighborWrites?: number // Maximum concurrent neighbor updates during insert. Default: unlimited (full concurrency)
// SQ8 vector quantization (4x memory reduction, ~0.4% accuracy loss)
quantization?: {
enabled: boolean // default: false — preserves current behavior exactly
bits?: 8 | 4 // default: 8 (SQ8). SQ4 has a pure-JS implementation; cortex's distance:sq4 SIMD provider accelerates it.
rerankMultiplier?: number // default: 3 — over-retrieve 3x, rerank with float32
}
// Vector storage mode
vectorStorage?: 'memory' | 'lazy' // default: 'memory' — 'lazy' evicts vectors after insert
}

View file

@ -14,8 +14,6 @@ import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
import type { BaseStorage } from '../storage/baseStorage.js'
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
import { prodLog } from '../utils/logger.js'
import { quantizeSQ8, distanceSQ8 } from '../utils/vectorQuantization.js'
import type { SQ8QuantizedVector } from '../utils/vectorQuantization.js'
import type { VectorIndexProvider } from '../plugin.js'
import { ConnectionsCodec, compressedConnectionsKey } from './connectionsCodec.js'
@ -67,10 +65,8 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
private dirtyNodes: Set<string> = new Set() // Nodes with unpersisted HNSW data
private dirtySystem: boolean = false // Whether system data (entryPoint, maxLevel) needs persist
// SQ8 quantization support (B1 optimization)
private quantizationEnabled: boolean = false
private rerankMultiplier: number = 3
// Lazy vector storage (B2 optimization)
// Lazy vector storage (B2 optimization): evict the float32 vector to
// storage after insert; reload on demand via getVectorSafe() + UnifiedCache.
private vectorStorageMode: 'memory' | 'lazy' = 'memory'
constructor(
@ -87,12 +83,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
this.storage = options.storage || null
this.persistMode = options.persistMode || 'immediate'
// SQ8 quantization config (default: disabled, preserves current behavior)
if (config.quantization?.enabled) {
this.quantizationEnabled = true
this.rerankMultiplier = config.quantization.rerankMultiplier ?? 3
}
// Vector storage mode (default: 'memory', preserves current behavior)
this.vectorStorageMode = config.vectorStorage || 'memory'
@ -376,7 +366,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
// Generate random level for this noun
const nounLevel = this.getRandomLevel()
// Create new noun with optional SQ8 quantization
// Create new noun
const noun: HNSWNoun = {
id,
vector,
@ -384,14 +374,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
level: nounLevel
}
// Quantize vector if enabled (B1: 4x storage reduction)
if (this.quantizationEnabled) {
const sq8 = quantizeSQ8(vector)
noun.quantizedVector = sq8.quantized
noun.codebookMin = sq8.min
noun.codebookMax = sq8.max
}
// Initialize empty connection sets for each level
for (let level = 0; level <= nounLevel; level++) {
noun.connections.set(level, new Set<string>())
@ -664,16 +646,18 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
}
/**
* Search for nearest neighbors
* Search for nearest neighbors.
*
* When SQ8 quantization is enabled and reranking is active:
* - Phase 1: Over-retrieve k*rerankMultiplier candidates using SQ8 approximate distances
* - Phase 2: Load full float32 vectors for candidates, compute exact distances, return top k
* The JS HNSW index computes exact float32 distances throughout there is
* no approximate/rerank path. The `options.rerank` field exists only for the
* shared {@link VectorIndexProvider} contract (a native provider may use it
* for its own approximate-then-rerank pipeline); the JS index ignores it.
*
* @param queryVector Query vector
* @param k Number of results to return
* @param filter Optional filter function
* @param options Additional search options
* @param options Additional search options (`candidateIds` to restrict the
* search to a pre-filtered set; `rerank` is provider-only, ignored here)
*/
public async search(
queryVector: Vector,
@ -737,11 +721,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
let currObj = entryPoint
// SQ8: Pre-quantize query vector for fast approximate distances during traversal
if (this.quantizationEnabled) {
this._querySQ8 = quantizeSQ8(queryVector)
}
// OPTIMIZATION: Preload entry point vector
await this.preloadVectors([entryPoint.id])
@ -809,14 +788,9 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
}
}
// Determine effective rerank multiplier
const rerankActive = this.quantizationEnabled && (options?.rerank || this.rerankMultiplier > 1)
const multiplier = options?.rerank?.multiplier ?? this.rerankMultiplier
const effectiveK = rerankActive ? k * multiplier : k
// Search at level 0 with ef = effectiveK
// If we have a filter, increase ef to compensate for filtered results
const ef = filter ? Math.max(this.config.efSearch * 3, effectiveK * 3) : Math.max(this.config.efSearch, effectiveK)
// Search at level 0. If we have a filter, increase ef to compensate for
// filtered-out results.
const ef = filter ? Math.max(this.config.efSearch * 3, k * 3) : Math.max(this.config.efSearch, k)
const nearestNouns = await this.searchLayer(
queryVector,
currObj,
@ -825,36 +799,7 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
filter
)
// Phase 2: Rerank with exact float32 distances if quantization is active (B3)
if (rerankActive && nearestNouns.size > 0) {
// Clear SQ8 cache before reranking (we need exact distances now)
this._querySQ8 = null
const candidates = [...nearestNouns].slice(0, effectiveK)
// Load full float32 vectors for the candidate set
const candidateIds = candidates.map(([id]) => id)
await this.preloadVectors(candidateIds)
// Recompute exact distances
const reranked: Array<[string, number]> = []
for (const [id] of candidates) {
const noun = this.nouns.get(id)
if (!noun) continue
const exactVector = await this.getVectorSafe(noun)
const exactDist = this.distanceFunction(queryVector, exactVector)
reranked.push([id, exactDist])
}
// Sort by exact distance and return top k
reranked.sort((a, b) => a[1] - b[1])
return reranked.slice(0, k)
}
// Clear SQ8 cache
this._querySQ8 = null
// Convert to array and sort by distance
// Exact float32 distances throughout — return the top k directly.
return [...nearestNouns].slice(0, k)
}
@ -1148,19 +1093,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
* @returns number | Promise<number> - sync when cached, async when needs load
*/
private distanceSafe(queryVector: Vector, noun: HNSWNoun): number | Promise<number> {
// SQ8 fast path: use quantized distance when available (B1 optimization)
// This avoids loading full float32 vectors during graph traversal
if (this.quantizationEnabled &&
noun.quantizedVector &&
noun.codebookMin !== undefined &&
noun.codebookMax !== undefined &&
this._querySQ8) {
return distanceSQ8(
this._querySQ8.quantized, this._querySQ8.min, this._querySQ8.max,
noun.quantizedVector, noun.codebookMin, noun.codebookMax
)
}
// Try sync fast path
const nounVector = this.getVectorSync(noun)
@ -1175,9 +1107,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
)
}
// Cached SQ8 quantization of the current query vector for distanceSafe fast path
private _querySQ8: SQ8QuantizedVector | null = null
/**
* Get all nodes at a specific level for clustering
* This enables O(n) clustering using HNSW's natural hierarchy
@ -1299,14 +1228,6 @@ export class JsHnswVectorIndex implements VectorIndexProvider {
level: hnswData.level
}
// Restore SQ8 quantized data if quantization is enabled
if (this.quantizationEnabled && nounData.vector.length > 0) {
const sq8 = quantizeSQ8(nounData.vector)
noun.quantizedVector = sq8.quantized
noun.codebookMin = sq8.min
noun.codebookMax = sq8.max
}
// Restore connections from persisted data — compressed blob path
// first, legacy JSON-array fallback for indexes written before
// graph link compression landed.

View file

@ -1206,20 +1206,14 @@ export interface BrainyConfig {
}
| StorageAdapter
// Index configuration
index?: {
m?: number // HNSW M parameter
efConstruction?: number // HNSW construction parameter
efSearch?: number // HNSW search parameter
}
// Performance options
cache?: boolean | { // Enable caching
maxSize?: number
ttl?: number
}
// Distributed configuration
/**
* Distributed-cluster configuration. **Explicit opt-in only** Brainy
* never auto-enables cluster mode from environment heuristics (a single
* production process is the 90th-percentile deployment). Set
* `enabled: true` (or the `BRAINY_DISTRIBUTED=true` env var) to activate;
* the remaining fields then default sensibly (64 shards, 3 replicas,
* raft consensus, http transport, hostname-derived nodeId).
*/
distributed?: {
enabled: boolean
nodeId?: string
@ -1231,23 +1225,18 @@ export interface BrainyConfig {
transport?: 'tcp' | 'http' | 'udp'
}
// Advanced options
warmup?: boolean // Warm up on init
realtime?: boolean // Enable real-time updates
multiTenancy?: boolean // Enable service isolation
telemetry?: boolean // Send anonymous usage stats
// Performance tuning options for production
disableAutoRebuild?: boolean // Disable automatic index rebuilding on init
disableMetrics?: boolean // Completely disable metrics collection
disableAutoOptimize?: boolean // Disable automatic index optimization
batchWrites?: boolean // Enable write batching for better performance
maxConcurrentOperations?: number // Limit concurrent file operations
/**
* Disable the automatic index rebuild check during `init()`. By default
* Brainy auto-decides from dataset size: small datasets rebuild missing
* indexes inline, large datasets rebuild lazily on first query. Set `true`
* only when an operator wants full manual control via `repairIndex()`.
*/
disableAutoRebuild?: boolean
/**
* Vector index configuration (Brainy 8.0).
*
* Three knobs. No escape hatch. The algorithm-internal HNSW knobs
* Two knobs. No escape hatch. The algorithm-internal HNSW knobs
* (`M`, `efConstruction`, `efSearch`, `ml`, ) and DiskANN knobs
* (`pqM`, `searchListSize`, `alpha`, ) are deliberately not exposed
* the `recall` preset covers the legitimate quality/latency tradeoff
@ -1271,26 +1260,15 @@ export interface BrainyConfig {
*/
recall?: 'fast' | 'balanced' | 'accurate'
/**
* Vector quantization. Independent of `recall` it trades RAM for a
* small recall hit at any preset. `bits: 8` is SQ8 (4× memory reduction,
* ~0.4% recall loss). `bits: 4` is SQ4 (8× memory reduction, ~1-3% recall
* loss). Both ship in the open-core path; cortex's `distance:sq8` /
* `distance:sq4` SIMD providers accelerate them when available.
*
* Silently ignored on a native DiskANN-style provider path (it uses its
* own PQ math instead).
*/
quantization?: {
enabled?: boolean
bits?: 8 | 4
}
/**
* Vector persistence mode. `'immediate'` writes per-noun graph state on
* every `add()`; durable but slower. `'deferred'` writes only on
* `flush()` / `close()`; faster bulk ingest. Defaults to `'immediate'`
* on filesystem storage (the only persistent backend in 8.0).
* `flush()` / `close()`; faster bulk ingest.
*
* **Auto-selected from the storage adapter when omitted:** `'immediate'`
* on filesystem storage (durability is the point of a persistent
* backend), `'deferred'` on memory storage (nothing survives the process
* anyway, so per-add persistence writes are pure overhead).
*
* Brainy 7.x called this `hnswPersistMode` at the top level. The 8.0
* surface folds it under `config.vector` alongside `recall`.
@ -1298,16 +1276,60 @@ export interface BrainyConfig {
persistMode?: 'immediate' | 'deferred'
}
/**
* Generational-history retention policy (8.0 MVCC).
*
* Every `transact()` produces an immutable generation record-set that
* serves historical reads (`asOf()`, pinned `Db` values). Without
* compaction those record-sets accumulate forever, so Brainy
* **auto-compacts on every `flush()` and `close()`** with this policy.
* A generation is reclaimed only when it is older than BOTH bounds
* (and never while a live `Db` pin protects it):
*
* - `retainGenerations` keep at least the N most recent committed
* generations (default: `100`).
* - `retainMs` keep everything committed within the window
* (default: 7 days).
*
* Escape hatches: raise either bound for longer time-travel windows,
* or set `autoCompact: false` to manage history manually via
* `brain.compactHistory()`. Long-term archives belong in
* `db.persist(path)` snapshots, which compaction never touches.
*/
history?: {
/** Keep at least this many recent committed generations (default: 100). */
retainGenerations?: number
/** Keep generations committed within this window in ms (default: 604800000 = 7 days). */
retainMs?: number
/** Run compaction automatically on flush()/close() (default: true). */
autoCompact?: boolean
}
// Memory management options
maxQueryLimit?: number // Override auto-detected query result limit (max: 100000)
reservedQueryMemory?: number // Memory reserved for queries in bytes (e.g., 1073741824 = 1GB)
// Embedding initialization
// Controls when the WASM embedding engine is initialized
// - false (default): Lazy initialization on first embed() call
// - true: Eager initialization during brain.init()
// Set to true for cloud deployments (Cloud Run, Lambda) where you want
// WASM compilation to happen during container startup, not on first request
/**
* Controls when the WASM embedding engine is initialized.
*
* **Adaptive default (8.0):** when omitted, the engine eagerly initializes
* during `init()` whenever the WASM embedder is the *active* one i.e. no
* native `'embeddings'` provider is registered and this instance is a
* writer (not `mode: 'reader'`) running outside unit tests. The WASM module
* (93MB with the embedded model) takes 90-140s to compile on throttled
* CPUs, so paying that during boot rather than on the first `embed()`-driven
* call is the right default for a single-process server.
*
* The adaptive path skips itself automatically when a native embeddings
* provider owns embeddings, in reader-mode (readers query existing vectors
* and never embed), and in unit-test mode (kept fast via the mock embedder).
*
* - `true` force eager init during `init()` (the adaptive default already
* does this for the active-embedder writer case; set it explicitly to be
* unambiguous).
* - `false` explicit override to force lazy init (first `embed()` call)
* even when this instance is the active embedder.
*/
eagerEmbeddings?: boolean
// Plugin configuration
@ -1330,8 +1352,10 @@ export interface BrainyConfig {
integrations?: boolean | IntegrationsConfig
// Migration configuration
// - false/undefined (default): Log warning if pending migrations exist, but don't auto-run
// - true: Automatically run pending migrations during init() for small datasets (<10K entities)
// - true/undefined (default): Automatically run pending data migrations during
// init() for small datasets (<10K entities). Larger datasets defer with a
// notice — call brain.migrate() explicitly (optionally with backupTo).
// - false: Never auto-run; log a notice when pending migrations exist.
autoMigrate?: boolean
/**

View file

@ -17,8 +17,6 @@
* descending by score, with stable ordering for ties (lower original index first). This
* guarantees that wiring the hook in or out never changes which results a query returns or
* in what order only how fast the ranking is computed.
*
* @see setSQ8DistanceImplementation in ./vectorQuantization.js for the sibling provider-swap pattern.
*/
/**

View file

@ -1,581 +0,0 @@
/**
* Vector Quantization Utilities
*
* Standalone SQ8 (Scalar Quantization, 8-bit) functions for HNSW vector compression.
* Each vector is independently quantized using its own min/max codebook.
*
* Storage format per vector:
* - quantized: Uint8Array (dimension bytes, 4x smaller than float32)
* - min: number (codebook minimum)
* - max: number (codebook maximum)
*
* Accuracy: SQ8 introduces ~0.4% error per dimension on normalized vectors.
* Combined with reranking (3x over-retrieval + float32 rerank), recall@100
* is expected to remain >99% for typical workloads.
*/
import type { Vector } from '../coreTypes.js'
/**
* Result of SQ8 quantization
*/
export interface SQ8QuantizedVector {
quantized: Uint8Array
min: number
max: number
}
/**
* Configuration for HNSW quantization
*/
export interface HNSWQuantizationConfig {
enabled: boolean
bits: 8 | 4
rerankMultiplier: number
}
/**
* Quantize a float32 vector to SQ8 (8-bit unsigned integers).
*
* Maps [min, max] of the vector to [0, 255].
* For zero-range vectors (all values equal), all quantized values are 128.
*
* @param vector - Input float32 vector
* @returns Quantized vector with codebook (min/max)
*/
export function quantizeSQ8(vector: Vector): SQ8QuantizedVector {
const len = vector.length
let min = vector[0]
let max = vector[0]
for (let i = 1; i < len; i++) {
const v = vector[i]
if (v < min) min = v
if (v > max) max = v
}
const range = max - min
const quantized = new Uint8Array(len)
if (range === 0) {
// All values are identical — map to midpoint
quantized.fill(128)
} else {
const scale = 255 / range
for (let i = 0; i < len; i++) {
quantized[i] = Math.round((vector[i] - min) * scale)
}
}
return { quantized, min, max }
}
/**
* Dequantize an SQ8 vector back to float32.
*
* @param quantized - Uint8Array of quantized values
* @param min - Codebook minimum
* @param max - Codebook maximum
* @returns Reconstructed float32 vector
*/
export function dequantizeSQ8(quantized: Uint8Array, min: number, max: number): Vector {
const len = quantized.length
const result = new Array<number>(len)
const range = max - min
if (range === 0) {
result.fill(min)
} else {
const scale = range / 255
for (let i = 0; i < len; i++) {
result[i] = min + quantized[i] * scale
}
}
return result
}
/**
* Reference JS implementation of {@link distanceSQ8}: approximate cosine distance
* between two SQ8-quantized vectors, operating directly on the uint8 arrays without
* full dequantization. Cosine distance is `1 - (A·B / (|A| · |B|))`; for quantized
* values `q_i` with codebook `(min, max)` the actual value is
* `min + q_i * (max - min) / 255`, and the dot product / norms are computed on the
* quantized values and rescaled by the codebook parameters.
*
* Exported so callers can explicitly restore the default after swapping in a native
* implementation, and so cross-language parity tests can compare native output
* against this baseline.
*
* @param a - First quantized vector.
* @param aMin - First vector's codebook minimum.
* @param aMax - First vector's codebook maximum.
* @param b - Second quantized vector.
* @param bMin - Second vector's codebook minimum.
* @param bMax - Second vector's codebook maximum.
* @returns Approximate cosine distance in [0, 2].
*/
export function distanceSQ8Js(
a: Uint8Array,
aMin: number,
aMax: number,
b: Uint8Array,
bMin: number,
bMax: number
): number {
const len = a.length
// Compute raw integer sums for efficiency
// actual_a[i] = aMin + a[i] * aScale, where aScale = (aMax - aMin) / 255
// actual_b[i] = bMin + b[i] * bScale, where bScale = (bMax - bMin) / 255
//
// dot = sum(actual_a[i] * actual_b[i])
// = sum((aMin + a[i]*aScale) * (bMin + b[i]*bScale))
// = n*aMin*bMin + aMin*bScale*sum(b[i]) + bMin*aScale*sum(a[i]) + aScale*bScale*sum(a[i]*b[i])
//
// normA^2 = sum(actual_a[i]^2)
// = n*aMin^2 + 2*aMin*aScale*sum(a[i]) + aScale^2*sum(a[i]^2)
const aScale = (aMax - aMin) / 255
const bScale = (bMax - bMin) / 255
let sumA = 0
let sumB = 0
let sumAB = 0
let sumAA = 0
let sumBB = 0
for (let i = 0; i < len; i++) {
const ai = a[i]
const bi = b[i]
sumA += ai
sumB += bi
sumAB += ai * bi
sumAA += ai * ai
sumBB += bi * bi
}
const dot =
len * aMin * bMin +
aMin * bScale * sumB +
bMin * aScale * sumA +
aScale * bScale * sumAB
const normASq =
len * aMin * aMin +
2 * aMin * aScale * sumA +
aScale * aScale * sumAA
const normBSq =
len * bMin * bMin +
2 * bMin * bScale * sumB +
bScale * bScale * sumBB
const normA = Math.sqrt(normASq)
const normB = Math.sqrt(normBSq)
if (normA === 0 || normB === 0) {
return 1.0 // Maximum distance for zero vectors
}
const cosine = dot / (normA * normB)
// Clamp to [-1, 1] to handle floating point imprecision
return 1 - Math.max(-1, Math.min(1, cosine))
}
/** Signature of the SQ8 approximate-distance function (quantized cosine distance). */
export type SQ8DistanceFn = (
a: Uint8Array,
aMin: number,
aMax: number,
b: Uint8Array,
bMin: number,
bMax: number
) => number
/**
* Active SQ8 distance implementation. Defaults to the JS `distanceSQ8Js`; swapped to
* a native SIMD implementation by {@link setSQ8DistanceImplementation} when a cortex
* `distance:sq8` provider is registered. The native implementation is byte-compatible
* (same quantized cosine formula), so results match within float tolerance.
*/
let activeSQ8Distance: SQ8DistanceFn = distanceSQ8Js
/**
* Approximate cosine distance between two SQ8-quantized vectors, dispatched to the
* active implementation (JS by default, native when a `distance:sq8` provider is
* registered). Used in the HNSW SQ8 reranking hot path.
*
* @param a - First quantized vector.
* @param aMin - First vector's codebook minimum.
* @param aMax - First vector's codebook maximum.
* @param b - Second quantized vector.
* @param bMin - Second vector's codebook minimum.
* @param bMax - Second vector's codebook maximum.
* @returns Approximate cosine distance in [0, 2].
*/
export function distanceSQ8(
a: Uint8Array,
aMin: number,
aMax: number,
b: Uint8Array,
bMin: number,
bMax: number
): number {
return activeSQ8Distance(a, aMin, aMax, b, bMin, bMax)
}
/**
* Replace the SQ8 approximate-distance implementation at runtime (e.g. cortex's Rust
* SIMD `distance:sq8`). Called by `brainy.ts` when the provider is registered. Pass
* {@link distanceSQ8Js} to restore the JS default.
*
* @param fn - Native implementation; must match {@link SQ8DistanceFn} byte-for-byte.
*/
export function setSQ8DistanceImplementation(fn: SQ8DistanceFn): void {
activeSQ8Distance = fn
}
/**
* Serialize an SQ8 quantized vector to a compact binary format.
*
* Format: [min:float32][max:float32][quantized:uint8[]]
* Total size: 8 + dimension bytes
*
* @param sq8 - Quantized vector
* @returns ArrayBuffer with serialized data
*/
export function serializeSQ8(sq8: SQ8QuantizedVector): ArrayBuffer {
const buffer = new ArrayBuffer(8 + sq8.quantized.length)
const view = new DataView(buffer)
view.setFloat32(0, sq8.min, true) // little-endian
view.setFloat32(4, sq8.max, true)
new Uint8Array(buffer, 8).set(sq8.quantized)
return buffer
}
/**
* Deserialize an SQ8 quantized vector from binary format.
*
* @param buffer - ArrayBuffer with serialized data
* @returns Deserialized SQ8 quantized vector
*/
export function deserializeSQ8(buffer: ArrayBuffer): SQ8QuantizedVector {
const view = new DataView(buffer)
const min = view.getFloat32(0, true)
const max = view.getFloat32(4, true)
const quantized = new Uint8Array(buffer, 8)
return { quantized, min, max }
}
// ===========================================================================
// SQ4 — 4-bit scalar quantization (2.5.0 #30)
//
// Each dimension is mapped to [0, 15] (16 levels) and packed two-per-byte:
// high nibble = vector[2i], low nibble = vector[2i+1]. Achieves 8× compression
// vs float32 (vs 4× for SQ8) at the cost of higher quantization error.
//
// The format + arithmetic are byte-for-byte identical to cortex's Rust
// `quantize_sq4` / `dequantize_sq4` / `cosine_distance_sq4` (see
// `cortex/native/src/quantization.rs`). When a `distance:sq4` provider is
// registered (cortex's SIMD Rust implementation), `setSQ4DistanceImplementation`
// swaps the JS reference in for the native one; cross-language parity tests
// verify the swap is recall-equivalent within float tolerance.
// ===========================================================================
/**
* @description 4-bit scalar-quantized vector. The `quantized` buffer is packed
* two nibbles per byte (high nibble = `vector[2i]`, low nibble = `vector[2i+1]`).
* Odd dimensions place the final value in the high nibble and leave the low
* nibble zero. `dim` must be recorded explicitly because packed byte length
* `ceil(dim/2)` doesn't encode whether the trailing low nibble is data or pad.
*/
export interface SQ4QuantizedVector {
/** Packed nibbles. Length = `ceil(dim / 2)`. */
quantized: Uint8Array
/** Codebook minimum (used to reconstruct the value range on dequantization). */
min: number
/** Codebook maximum. */
max: number
/** Original vector dimensionality (needed to detect the odd-dim pad nibble). */
dim: number
}
/**
* @description Quantize a float vector to SQ4. Each dimension is mapped to
* `[0, 15]` via `round((v - min) / range * 15)`, clamped, then packed
* two-per-byte. Zero-range vectors (all values identical) map to the midpoint
* `8` in every nibble `0x88` in every byte.
*
* Byte-for-byte identical to cortex's `quantize_sq4` (Rust); cross-language
* parity tests confirm this. Use {@link distanceSQ4} for approximate cosine
* directly on the packed bytes there's no need to {@link dequantizeSQ4}
* before computing distances.
*
* @param vector - Input float vector (length must be 1).
* @returns Packed SQ4 vector with codebook + original dimension.
* @throws If `vector` is empty.
*/
export function quantizeSQ4(vector: Vector): SQ4QuantizedVector {
const dim = vector.length
if (dim === 0) {
throw new Error('quantizeSQ4: vector cannot be empty')
}
let min = vector[0]
let max = vector[0]
for (let i = 1; i < dim; i++) {
const v = vector[i]
if (v < min) min = v
if (v > max) max = v
}
const packedLen = (dim + 1) >>> 1
const quantized = new Uint8Array(packedLen)
const range = max - min
if (range === 0) {
// All values identical — every nibble = 8 (midpoint of [0, 15]).
quantized.fill(0x88)
return { quantized, min, max, dim }
}
const invRange = 15 / range
let i = 0
let byteIdx = 0
while (i + 1 < dim) {
const hi = clampNibble(Math.round((vector[i] - min) * invRange))
const lo = clampNibble(Math.round((vector[i + 1] - min) * invRange))
quantized[byteIdx++] = (hi << 4) | lo
i += 2
}
if (i < dim) {
// Odd dimension: final value in the high nibble, low nibble = 0 pad.
const hi = clampNibble(Math.round((vector[i] - min) * invRange))
quantized[byteIdx] = hi << 4
}
return { quantized, min, max, dim }
}
/**
* @description Dequantize an SQ4 packed buffer back to a float vector.
* Reverses {@link quantizeSQ4}: each nibble is mapped back to
* `min + nibble * (max - min) / 15`. The trailing pad nibble of an odd-`dim`
* vector is correctly dropped (the result has length exactly `dim`).
*
* @param quantized - Packed SQ4 buffer (length `ceil(dim/2)`).
* @param min - Codebook minimum from {@link quantizeSQ4}.
* @param max - Codebook maximum from {@link quantizeSQ4}.
* @param dim - Original vector dimensionality.
* @returns Reconstructed float vector of length `dim`.
*/
export function dequantizeSQ4(
quantized: Uint8Array,
min: number,
max: number,
dim: number
): Vector {
const result = new Array<number>(dim)
const range = max - min
if (range === 0) {
for (let i = 0; i < dim; i++) result[i] = min
return result
}
const scale = range / 15
let out = 0
for (let b = 0; b < quantized.length && out < dim; b++) {
const byte = quantized[b]
result[out++] = min + ((byte >>> 4) & 0x0f) * scale
if (out < dim) {
result[out++] = min + (byte & 0x0f) * scale
}
}
return result
}
/**
* @description Reference JS implementation of {@link distanceSQ4}: approximate
* cosine distance between two SQ4-quantized vectors. Unpacks nibbles inline,
* then accumulates the same `(sumA, sumB, sumAA, sumBB, sumAB)` integers SQ8
* uses and applies the SQ8 cosine-from-quantized formula with `15` (not `255`)
* as the quantization range. Result is byte-equivalent to cortex's
* `cosine_distance_sq4` (Rust) within float tolerance.
*
* Exported so callers can explicitly restore the JS default after a native
* swap, and so cross-language parity tests compare native output against this
* baseline.
*/
export function distanceSQ4Js(
a: Uint8Array,
aMin: number,
aMax: number,
aDim: number,
b: Uint8Array,
bMin: number,
bMax: number,
bDim: number
): number {
if (aDim !== bDim) {
throw new Error(`distanceSQ4Js: dimension mismatch ${aDim} vs ${bDim}`)
}
if (aDim === 0) {
throw new Error('distanceSQ4Js: vectors cannot be empty')
}
let sumA = 0
let sumB = 0
let sumAB = 0
let sumAA = 0
let sumBB = 0
// Walk both packed buffers nibble-by-nibble, in lock-step.
let i = 0
let byteIdx = 0
while (i + 1 < aDim) {
const aByte = a[byteIdx]
const bByte = b[byteIdx]
const aHi = (aByte >>> 4) & 0x0f
const aLo = aByte & 0x0f
const bHi = (bByte >>> 4) & 0x0f
const bLo = bByte & 0x0f
sumA += aHi + aLo
sumB += bHi + bLo
sumAB += aHi * bHi + aLo * bLo
sumAA += aHi * aHi + aLo * aLo
sumBB += bHi * bHi + bLo * bLo
i += 2
byteIdx++
}
if (i < aDim) {
// Odd dim: only the high nibble of the final byte is data.
const aHi = (a[byteIdx] >>> 4) & 0x0f
const bHi = (b[byteIdx] >>> 4) & 0x0f
sumA += aHi
sumB += bHi
sumAB += aHi * bHi
sumAA += aHi * aHi
sumBB += bHi * bHi
}
const aScale = (aMax - aMin) / 15
const bScale = (bMax - bMin) / 15
const n = aDim
const dot =
n * aMin * bMin +
aMin * bScale * sumB +
bMin * aScale * sumA +
aScale * bScale * sumAB
const normASq =
n * aMin * aMin +
2 * aMin * aScale * sumA +
aScale * aScale * sumAA
const normBSq =
n * bMin * bMin +
2 * bMin * bScale * sumB +
bScale * bScale * sumBB
const denom = Math.sqrt(normASq) * Math.sqrt(normBSq)
if (denom === 0) return 1.0
const cosine = dot / denom
return 1 - Math.max(-1, Math.min(1, cosine))
}
/** Signature of the SQ4 approximate-distance function (quantized cosine distance). */
export type SQ4DistanceFn = (
a: Uint8Array,
aMin: number,
aMax: number,
aDim: number,
b: Uint8Array,
bMin: number,
bMax: number,
bDim: number
) => number
/**
* Active SQ4 distance implementation. Defaults to the JS {@link distanceSQ4Js};
* swapped to cortex's SIMD Rust implementation by {@link setSQ4DistanceImplementation}
* when a `distance:sq4` provider is registered. The native implementation is
* byte-compatible (same quantized cosine formula with `15` as the quantization
* range), so results match within float tolerance.
*/
let activeSQ4Distance: SQ4DistanceFn = distanceSQ4Js
/**
* @description Approximate cosine distance between two SQ4-quantized vectors,
* dispatched to the active implementation (JS by default, native when
* `distance:sq4` is registered). Used in the HNSW SQ4 reranking hot path when
* `config.quantization.bits === 4`.
*/
export function distanceSQ4(
a: Uint8Array,
aMin: number,
aMax: number,
aDim: number,
b: Uint8Array,
bMin: number,
bMax: number,
bDim: number
): number {
return activeSQ4Distance(a, aMin, aMax, aDim, b, bMin, bMax, bDim)
}
/**
* @description Replace the SQ4 approximate-distance implementation at runtime
* (cortex's Rust SIMD `distance:sq4`). Called by `brainy.ts` when the provider
* is registered. Pass {@link distanceSQ4Js} to restore the JS default.
*/
export function setSQ4DistanceImplementation(fn: SQ4DistanceFn): void {
activeSQ4Distance = fn
}
/**
* @description Serialize an SQ4 quantized vector to a compact binary format.
*
* Layout: `[min: float32][max: float32][dim: uint32 LE][packed: uint8[]]`.
* Total size: `12 + ceil(dim/2)` bytes.
*
* The `dim` field is required (unlike SQ8) because the packed length alone
* doesn't disambiguate even-vs-odd original dimension. The 4-byte `dim`
* header keeps total overhead under 1% for typical 384-dim vectors.
*/
export function serializeSQ4(sq4: SQ4QuantizedVector): ArrayBuffer {
const packedLen = sq4.quantized.length
const buffer = new ArrayBuffer(12 + packedLen)
const view = new DataView(buffer)
view.setFloat32(0, sq4.min, true) // little-endian
view.setFloat32(4, sq4.max, true)
view.setUint32(8, sq4.dim, true)
new Uint8Array(buffer, 12).set(sq4.quantized)
return buffer
}
/**
* @description Deserialize an SQ4 quantized vector from the binary format
* written by {@link serializeSQ4}.
*/
export function deserializeSQ4(buffer: ArrayBuffer): SQ4QuantizedVector {
const view = new DataView(buffer)
const min = view.getFloat32(0, true)
const max = view.getFloat32(4, true)
const dim = view.getUint32(8, true)
const quantized = new Uint8Array(buffer, 12)
return { quantized, min, max, dim }
}
/** Clamp a value to `[0, 15]` and return as an integer in the 4-bit range. */
function clampNibble(n: number): number {
if (n < 0) return 0
if (n > 15) return 15
return n | 0
}