2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* HNSW (Hierarchical Navigable Small World) Index implementation
|
|
|
|
|
|
* Based on the paper: "Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs"
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import {
|
|
|
|
|
|
DistanceFunction,
|
|
|
|
|
|
HNSWConfig,
|
|
|
|
|
|
HNSWNoun,
|
|
|
|
|
|
Vector,
|
|
|
|
|
|
VectorDocument
|
|
|
|
|
|
} from '../coreTypes.js'
|
|
|
|
|
|
import { euclideanDistance, calculateDistancesBatch } from '../utils/index.js'
|
2025-10-10 11:15:17 -07:00
|
|
|
|
import type { BaseStorage } from '../storage/baseStorage.js'
|
2025-10-10 14:09:30 -07:00
|
|
|
|
import { getGlobalCache, UnifiedCache } from '../utils/unifiedCache.js'
|
|
|
|
|
|
import { prodLog } from '../utils/logger.js'
|
feat(8.0): #35 part-3 — supply at-gen candidate vectors for the native exact-rerank
Completes brainy's #35 side. When the at-gen vector gate serves a filtered
semantic read, Brainy now ships the historically-correct candidate vectors so the
native provider reranks against them with zero per-vector FFI crossing — the
provider need not duplicate the per-generation retention Brainy already does.
- New `AtGenerationVectors { ids: BigInt64Array; vectors: Float32Array; dim }` on
the VectorIndexProvider.search options bag (row-major: vectors[i*dim..] == ids[i];
invariant vectors.length === ids.length*dim; ids = the candidate set; dim must
match the index dim). The built-in JS index ignores it.
- buildAtGenerationVectors resolves each universe id's vector AS OF the generation
(the record before-image, or live getNoun when untouched since the pin — not
readNounRaw, whose canonical path is empty under lazy-vector eviction), interns
the id via the shared mapper, and packs row-major. Absent/vectorless/wrong-dim
ids are dropped (not vector-rankable). Bounded by the filtered universe.
Shape + the four clarifications (row-major, ids-are-the-candidate-set, dim-asserted,
filtered-path-only) confirmed with cor in the handoff #35 thread. Inert until cor's
native at-gen rerank advertises isGenerationVisible (honesty guard).
Tested: the mock versioned provider now asserts it receives atGenerationVectors with
the row-major invariant, correct dim, and ids resolving back to the at-gen universe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:50:41 -07:00
|
|
|
|
import type { VectorIndexProvider, OpaqueIdSet, AtGenerationVectors } from '../plugin.js'
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
import { ConnectionsCodec, compressedConnectionsKey } from './connectionsCodec.js'
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
import {
|
|
|
|
|
|
computeWatermarkVerdict,
|
|
|
|
|
|
makeProjectionStamp,
|
|
|
|
|
|
readStampedWatermark,
|
|
|
|
|
|
type WatermarkVerdict,
|
|
|
|
|
|
type WatermarkVerdictResult
|
|
|
|
|
|
} from '../utils/projectionWatermark.js'
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Storage key for the JS HNSW projection's watermark stamp — a sidecar
|
|
|
|
|
|
* record beside the artifact (per-node vector-index records + connection
|
|
|
|
|
|
* blobs + the entryPoint/maxLevel system record). Written LAST in
|
|
|
|
|
|
* {@link JsHnswVectorIndex.flush} so stamp-after-data ordering holds for
|
|
|
|
|
|
* every byte the stamp certifies.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export const HNSW_INDEX_STAMP_KEY = '__index_hnsw_watermark__'
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// Default HNSW parameters
|
|
|
|
|
|
const DEFAULT_CONFIG: HNSWConfig = {
|
|
|
|
|
|
M: 16, // Max number of connections per noun
|
|
|
|
|
|
efConstruction: 200, // Size of a dynamic candidate list during construction
|
|
|
|
|
|
efSearch: 50, // Size of a dynamic candidate list during search
|
|
|
|
|
|
ml: 16 // Max level
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Thrown by {@link JsHnswVectorIndex.flush} when one or more dirty
|
|
|
|
|
|
* nodes (or the system record) could not be persisted. The failed nodes remain
|
|
|
|
|
|
* in the dirty set for the next flush; this error tells the caller the flush did
|
|
|
|
|
|
* NOT achieve durability instead of a node-count that lies. Mandate: loud
|
|
|
|
|
|
* errors, never quiet losses.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export class HnswFlushError extends Error {
|
|
|
|
|
|
constructor(
|
|
|
|
|
|
public readonly failedNodeCount: number,
|
|
|
|
|
|
public readonly systemFailed: boolean,
|
|
|
|
|
|
public override readonly cause?: Error
|
|
|
|
|
|
) {
|
|
|
|
|
|
super(
|
|
|
|
|
|
`HNSW flush did not achieve durability: ${failedNodeCount} node(s) failed to ` +
|
|
|
|
|
|
`persist${systemFailed ? ' and the system record (entryPoint/maxLevel) failed' : ''}. ` +
|
|
|
|
|
|
`Failed nodes remain dirty for retry.` +
|
|
|
|
|
|
(cause ? ` First error: ${cause.message}` : '')
|
|
|
|
|
|
)
|
|
|
|
|
|
this.name = 'HnswFlushError'
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: export provider contracts for the plugin surface brainy consumes
Native accelerators (cortex) register providers for metadataIndex, graphIndex,
hnsw, entityIdMapper, cache, columnStore, and aggregation. Until now the exact
method/property surface brainy calls on each was implicit — a provider could
drop a member brainy depends on and only fail at runtime when that path ran.
This defines and exports the provider contracts from the stable
@soulcraft/brainy/plugin entrypoint:
- MetadataIndexProvider, GraphIndexProvider, HnswProvider,
EntityIdMapperProvider, CacheProvider — each typed as exactly the surface
brainy calls (optional/feature-detected members like HNSW setPersistMode and
enableCOW are intentionally excluded).
- Re-exports ColumnStoreProvider and AggregationProvider (and the aggregate
types) from the same entrypoint so a plugin author can import the whole
provider surface from one place.
Brainy's own baseline classes now `implements` these contracts
(MetadataIndexManager, GraphAdjacencyIndex, HNSWIndex, EntityIdMapper,
UnifiedCache), so the interfaces can never silently diverge from what brainy
ships — and any provider that declares `implements` gets a compile error the
moment brainy starts requiring a new member.
The embeddings/embedBatch providers stay typed by the existing EmbeddingFunction
(no duplicate interface added). Type-only changes; no runtime behavior change.
2026-05-27 14:45:40 -07:00
|
|
|
|
/**
|
2026-06-09 12:58:26 -07:00
|
|
|
|
* Implements {@link VectorIndexProvider}: the vector-index surface Brainy calls
|
2026-06-09 13:07:56 -07:00
|
|
|
|
* on whatever the `'vector'` factory returns (its own `JsHnswVectorIndex`, or a native
|
2026-06-09 12:58:26 -07:00
|
|
|
|
* acceleration provider).
|
feat: export provider contracts for the plugin surface brainy consumes
Native accelerators (cortex) register providers for metadataIndex, graphIndex,
hnsw, entityIdMapper, cache, columnStore, and aggregation. Until now the exact
method/property surface brainy calls on each was implicit — a provider could
drop a member brainy depends on and only fail at runtime when that path ran.
This defines and exports the provider contracts from the stable
@soulcraft/brainy/plugin entrypoint:
- MetadataIndexProvider, GraphIndexProvider, HnswProvider,
EntityIdMapperProvider, CacheProvider — each typed as exactly the surface
brainy calls (optional/feature-detected members like HNSW setPersistMode and
enableCOW are intentionally excluded).
- Re-exports ColumnStoreProvider and AggregationProvider (and the aggregate
types) from the same entrypoint so a plugin author can import the whole
provider surface from one place.
Brainy's own baseline classes now `implements` these contracts
(MetadataIndexManager, GraphAdjacencyIndex, HNSWIndex, EntityIdMapper,
UnifiedCache), so the interfaces can never silently diverge from what brainy
ships — and any provider that declares `implements` gets a compile error the
moment brainy starts requiring a new member.
The embeddings/embedBatch providers stay typed by the existing EmbeddingFunction
(no duplicate interface added). Type-only changes; no runtime behavior change.
2026-05-27 14:45:40 -07:00
|
|
|
|
*/
|
2026-06-09 13:07:56 -07:00
|
|
|
|
export class JsHnswVectorIndex implements VectorIndexProvider {
|
2026-07-23 08:53:05 -07:00
|
|
|
|
/** Self-identifies as the built-in JS fallback engine — see {@link VectorIndexProvider.name}. */
|
|
|
|
|
|
readonly name = 'hnsw-js'
|
feat: warm contract (warm/warmOnOpen/provider warm hook), configurable transact budget floor, backend-neutral vector index op names
Three pieces addressing the cold-restart-write incident where a production
deployment's first writes after every restart (33-35s each on a cold page
cache) blew the op-count-scaled transact budget mid-batch: every write is
itself a multi-op transaction, so one cold operation consumed the whole
budget, the gate before the next operation tripped, and the write rolled
back atomically - refused, retried, and refused again until the page cache
warmed passively.
- The budget's start-gating contract is now explicit and pinned: it gates
STARTING the next operation, never rolling back completed work for
elapsed time (the shipped schedule since 8.7.0, now stated in contract
JSDoc, guarded by code for operation 0, and enforced by regression
tests). The 30s floor is configurable via transactionBudgetFloorMs for
stores whose cold operations legitimately run long.
- New brain.warm() eagerly loads the vector index, metadata index, and
graph adjacency so first operations after a cold restart run at
steady-state cost. Returns a WarmReport with an honest per-surface
outcome (warmed / probed / unavailable) - never reports a probe as a
warm. warmOnOpen: true runs it during init(). New optional provider hook
warm() on the vector and graph plugin contracts.
- Vector-index transaction op classes renamed from the backend-specific
AddToHNSWOperation / RemoveFromHNSWOperation to backend-neutral
AddToVectorIndexOperation / RemoveFromVectorIndexOperation, stamping the
active backend into the emitted op-name string (AddToVectorIndex(js-hnsw)
vs a native provider's own identity) so journals never misdirect an
operator toward an index that isn't running.
2026-07-22 16:42:26 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
private nouns: Map<string, HNSWNoun> = new Map()
|
2026-06-29 11:06:22 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Reverse adjacency: `target id → (level → set of node ids that link TO target)`.
|
|
|
|
|
|
* Lets `removeItem` find a node's in-neighbors in O(in-degree) instead of scanning
|
|
|
|
|
|
* the whole corpus (which made bulk delete O(N²)). Lazily built on first need and
|
|
|
|
|
|
* maintained incrementally on link/prune/remove; `null` means "stale — rebuild on
|
|
|
|
|
|
* next use" (set by every bulk path: cold-load restore + `clear()`).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private incoming: Map<string, Map<number, Set<string>>> | null = null
|
2025-08-26 12:32:21 -07:00
|
|
|
|
private entryPointId: string | null = null
|
|
|
|
|
|
private maxLevel = 0
|
2025-09-22 15:45:35 -07:00
|
|
|
|
// Track high-level nodes for O(1) entry point selection
|
|
|
|
|
|
private highLevelNodes = new Map<number, Set<string>>() // level -> node IDs
|
|
|
|
|
|
private readonly MAX_TRACKED_LEVELS = 10 // Only track top levels for memory efficiency
|
2025-08-26 12:32:21 -07:00
|
|
|
|
private config: HNSWConfig
|
|
|
|
|
|
private distanceFunction: DistanceFunction
|
|
|
|
|
|
private dimension: number | null = null
|
|
|
|
|
|
private useParallelization: boolean = true // Whether to use parallelization for performance-critical operations
|
2026-01-27 15:38:21 -08:00
|
|
|
|
private storage: BaseStorage | null = null // Storage adapter for HNSW persistence
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Universal memory management
|
2025-10-10 14:09:30 -07:00
|
|
|
|
private unifiedCache: UnifiedCache // Shared cache with Graph and Metadata indexes
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Always-adaptive caching - no "mode" concept, system adapts automatically
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
// Optional connections codec (2.4.0 #3). When set, node-persist writes the
|
|
|
|
|
|
// node's per-level connection sets as a single delta-varint-compressed
|
|
|
|
|
|
// binary blob (typically ~4× smaller than the legacy JSON-UUID-array
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
// shape), plus an empty `connections: {}` in saveVectorIndexData as the marker.
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
// The read path tries to load the blob first; missing blob → legacy
|
|
|
|
|
|
// connections field. Format convergence is lazy: pre-2.4.0 nodes still
|
|
|
|
|
|
// load via the legacy path, then write the compressed form on next dirty
|
|
|
|
|
|
// save, so the migration converges under live traffic with no big-bang.
|
|
|
|
|
|
private connectionsCodec: ConnectionsCodec | null = null
|
|
|
|
|
|
|
2025-11-01 11:56:11 -07:00
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Deferred HNSW persistence for cloud storage performance
|
2025-12-02 14:20:04 -08:00
|
|
|
|
// In deferred mode, HNSW connections are only persisted on flush/close
|
|
|
|
|
|
// This reduces GCS operations from 70 to 2-3 per add() (30-50× faster)
|
|
|
|
|
|
private persistMode: 'immediate' | 'deferred' = 'immediate'
|
|
|
|
|
|
private dirtyNodes: Set<string> = new Set() // Nodes with unpersisted HNSW data
|
|
|
|
|
|
private dirtySystem: boolean = false // Whether system data (entryPoint, maxLevel) needs persist
|
|
|
|
|
|
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
// --- Watermark stamp state (see utils/projectionWatermark for the law) ---
|
|
|
|
|
|
/** Generation handed in via {@link stampWatermark}, awaiting the next flush. */
|
|
|
|
|
|
private pendingWatermark: number | null = null
|
|
|
|
|
|
/** Last watermark durably stamped by this instance or loaded on rebuild. */
|
|
|
|
|
|
private stampedWatermark: number | null = null
|
|
|
|
|
|
/** The three-way verdict computed at load; null until rebuild() runs. */
|
|
|
|
|
|
private loadVerdict: WatermarkVerdictResult | null = null
|
|
|
|
|
|
|
2026-06-15 10:08:51 -07:00
|
|
|
|
// Lazy vector storage (B2 optimization): evict the float32 vector to
|
|
|
|
|
|
// storage after insert; reload on demand via getVectorSafe() + UnifiedCache.
|
2026-01-31 12:41:53 -08:00
|
|
|
|
private vectorStorageMode: 'memory' | 'lazy' = 'memory'
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
constructor(
|
|
|
|
|
|
config: Partial<HNSWConfig> = {},
|
|
|
|
|
|
distanceFunction: DistanceFunction = euclideanDistance,
|
2025-12-02 14:20:04 -08:00
|
|
|
|
options: { useParallelization?: boolean; storage?: BaseStorage; persistMode?: 'immediate' | 'deferred' } = {}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
) {
|
|
|
|
|
|
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
|
|
|
|
this.distanceFunction = distanceFunction
|
|
|
|
|
|
this.useParallelization =
|
|
|
|
|
|
options.useParallelization !== undefined
|
|
|
|
|
|
? options.useParallelization
|
|
|
|
|
|
: true
|
2025-10-10 11:15:17 -07:00
|
|
|
|
this.storage = options.storage || null
|
2025-12-02 14:20:04 -08:00
|
|
|
|
this.persistMode = options.persistMode || 'immediate'
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
2026-01-31 12:41:53 -08:00
|
|
|
|
// Vector storage mode (default: 'memory', preserves current behavior)
|
|
|
|
|
|
this.vectorStorageMode = config.vectorStorage || 'memory'
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// Use SAME UnifiedCache as Graph and Metadata for fair memory competition
|
|
|
|
|
|
this.unifiedCache = getGlobalCache()
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Set whether to use parallelization for performance-critical operations
|
|
|
|
|
|
*/
|
|
|
|
|
|
public setUseParallelization(useParallelization: boolean): void {
|
|
|
|
|
|
this.useParallelization = useParallelization
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Inject (or detach) the connections codec. When set, node
|
|
|
|
|
|
* persistence writes a delta-varint-compressed binary blob alongside an
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
* empty `connections: {}` marker in saveVectorIndexData; the load path reads the
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
* blob and decodes. Null reverts to the legacy JSON-array path. Wiring is
|
|
|
|
|
|
* done by brainy.ts when the `graph:compression` provider is registered
|
|
|
|
|
|
* AND the storage adapter exposes the binary-blob primitive AND the
|
|
|
|
|
|
* metadata index has a stable idMapper.
|
|
|
|
|
|
*/
|
|
|
|
|
|
public setConnectionsCodec(codec: ConnectionsCodec | null): void {
|
|
|
|
|
|
this.connectionsCodec = codec
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Get whether parallelization is enabled
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getUseParallelization(): boolean {
|
|
|
|
|
|
return this.useParallelization
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-02 14:20:04 -08:00
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Flush dirty HNSW data to storage
|
2025-12-02 14:20:04 -08:00
|
|
|
|
*
|
|
|
|
|
|
* In deferred persistence mode, HNSW connections are tracked as dirty but not
|
|
|
|
|
|
* immediately persisted. Call flush() to persist all pending changes.
|
|
|
|
|
|
*
|
|
|
|
|
|
* This is automatically called by:
|
|
|
|
|
|
* - brain.close()
|
|
|
|
|
|
* - brain.flush()
|
|
|
|
|
|
* - Process shutdown (SIGTERM/SIGINT)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns Number of nodes flushed
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async flush(): Promise<number> {
|
|
|
|
|
|
if (!this.storage) {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.dirtyNodes.size === 0 && !this.dirtySystem) {
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
// Nothing dirty — but a pending watermark still stamps: every byte it
|
|
|
|
|
|
// certifies is already durable, so stamp-after-data holds trivially.
|
|
|
|
|
|
await this.writePendingStamp()
|
2025-12-02 14:20:04 -08:00
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const startTime = Date.now()
|
|
|
|
|
|
const nodeCount = this.dirtyNodes.size
|
|
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
// Batch persist all dirty nodes concurrently. A node whose connections FAIL
|
|
|
|
|
|
// to persist must stay dirty (retried on the next flush) — clearing it would
|
|
|
|
|
|
// silently drop the write forever. Track failures; only successfully-
|
|
|
|
|
|
// persisted (or deleted) nodes leave the dirty set, so nodes added to it
|
|
|
|
|
|
// during this flush are preserved.
|
|
|
|
|
|
const failedNodes = new Set<string>()
|
|
|
|
|
|
let firstError: Error | null = null
|
|
|
|
|
|
|
2025-12-02 14:20:04 -08:00
|
|
|
|
if (this.dirtyNodes.size > 0) {
|
|
|
|
|
|
const batchSize = 50 // Reasonable batch size for cloud storage
|
|
|
|
|
|
const nodeIds = Array.from(this.dirtyNodes)
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < nodeIds.length; i += batchSize) {
|
|
|
|
|
|
const batch = nodeIds.slice(i, i + batchSize)
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
const promises = batch.map(async nodeId => {
|
2025-12-02 14:20:04 -08:00
|
|
|
|
const noun = this.nouns.get(nodeId)
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
if (!noun) return // Node was deleted — drop it from the dirty set.
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.persistNodeConnections(nodeId, noun)
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
failedNodes.add(nodeId)
|
|
|
|
|
|
if (firstError === null) firstError = error as Error
|
|
|
|
|
|
prodLog.error(`[HNSW flush] Failed to persist node ${nodeId}: ${(error as Error).message}`)
|
|
|
|
|
|
}
|
2025-12-02 14:20:04 -08:00
|
|
|
|
})
|
|
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
await Promise.all(promises)
|
2025-12-02 14:20:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
// Remove only nodes that were persisted (or deleted mid-flush); keep the
|
|
|
|
|
|
// failed ones dirty for the next attempt.
|
|
|
|
|
|
for (const nodeId of nodeIds) {
|
|
|
|
|
|
if (!failedNodes.has(nodeId)) this.dirtyNodes.delete(nodeId)
|
|
|
|
|
|
}
|
2025-12-02 14:20:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
// Persist system data if dirty — keep it dirty on failure so the next flush
|
|
|
|
|
|
// retries rather than losing the entry-point/maxLevel update.
|
|
|
|
|
|
let systemFailed = false
|
2025-12-02 14:20:04 -08:00
|
|
|
|
if (this.dirtySystem) {
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.saveHNSWSystem({
|
|
|
|
|
|
entryPointId: this.entryPointId,
|
|
|
|
|
|
maxLevel: this.maxLevel
|
|
|
|
|
|
})
|
|
|
|
|
|
this.dirtySystem = false
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
systemFailed = true
|
|
|
|
|
|
if (firstError === null) firstError = error as Error
|
|
|
|
|
|
prodLog.error(`[HNSW flush] Failed to persist system data: ${(error as Error).message}`)
|
|
|
|
|
|
}
|
2025-12-02 14:20:04 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const duration = Date.now() - startTime
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
|
|
|
|
|
|
// Loud failure: if ANY node or the system record could not be persisted the
|
|
|
|
|
|
// flush did not achieve durability. Throw so callers (close(), explicit
|
|
|
|
|
|
// flush(), the flush-request watcher) see the failure instead of a success
|
|
|
|
|
|
// count that lies. The failed nodes/system stay dirty for retry.
|
|
|
|
|
|
if (failedNodes.size > 0 || systemFailed) {
|
|
|
|
|
|
throw new HnswFlushError(failedNodes.size, systemFailed, firstError ?? undefined)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
// STAMP-AFTER-DATA: the watermark stamp is the LAST write of the flush —
|
|
|
|
|
|
// it lands only after every dirty node and the system record persisted
|
|
|
|
|
|
// (the throw above guarantees it). A crash anywhere earlier leaves the
|
|
|
|
|
|
// artifact behind-stamped or unstamped, which verdicts as catchup/rescan
|
|
|
|
|
|
// on the next open — never a wrong adopt.
|
|
|
|
|
|
await this.writePendingStamp()
|
|
|
|
|
|
|
2025-12-02 14:20:04 -08:00
|
|
|
|
if (nodeCount > 0) {
|
|
|
|
|
|
prodLog.info(`[HNSW] Flushed ${nodeCount} dirty nodes in ${duration}ms`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nodeCount
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Record the committed generation this projection reflects.
|
|
|
|
|
|
* The stamp is NOT written here — it is written as the final storage write
|
|
|
|
|
|
* of the next {@link flush} (stamp-after-data ordering is a module
|
|
|
|
|
|
* guarantee, not a caller obligation). The coordinator calls this with the
|
|
|
|
|
|
* store's committed generation right before flushing.
|
|
|
|
|
|
* @param generation - The committed generation every flushed byte reflects.
|
|
|
|
|
|
*/
|
|
|
|
|
|
public stampWatermark(generation: number): void {
|
|
|
|
|
|
this.pendingWatermark = generation
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The projection's current watermark: the stamp loaded at
|
|
|
|
|
|
* rebuild (or the last stamp durably written by this instance). Null =
|
|
|
|
|
|
* unstamped (legacy artifact, first boot, or stamping never wired).
|
|
|
|
|
|
*/
|
|
|
|
|
|
public watermark(): number | null {
|
|
|
|
|
|
return this.stampedWatermark
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The three-way adoption verdict computed at load —
|
|
|
|
|
|
* `'adopt'` (stamped == committed, zero work), `'catchup'` (stamped <
|
|
|
|
|
|
* committed; the gap from {@link watermarkGap} awaits an incremental
|
|
|
|
|
|
* fold), `'rescan'` (unstamped or stamped above committed — never
|
|
|
|
|
|
* trusted). Null until rebuild() has run. Computed and exposed only; no
|
|
|
|
|
|
* load behavior changes ride on it yet — today's rebuild triggers are
|
|
|
|
|
|
* unchanged.
|
|
|
|
|
|
*/
|
|
|
|
|
|
public watermarkVerdict(): WatermarkVerdict | null {
|
|
|
|
|
|
return this.loadVerdict?.verdict ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The catch-up window `(from, to]` when the load verdict was
|
|
|
|
|
|
* `'catchup'`; null otherwise.
|
|
|
|
|
|
*/
|
|
|
|
|
|
public watermarkGap(): { from: number; to: number } | null {
|
|
|
|
|
|
return this.loadVerdict?.gap ?? null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Write the pending watermark stamp as a sidecar record —
|
|
|
|
|
|
* always called AFTER the data it certifies is durable. The stamp carries
|
|
|
|
|
|
* the vector-space identity this module can honestly assert: dimensions
|
|
|
|
|
|
* only (no embedding-model id is reachable from the index — it never sees
|
|
|
|
|
|
* the embedder). A stamp-write failure is fail-safe (unstamped/behind →
|
|
|
|
|
|
* rescan/catchup on next open, never a wrong adopt) but is said out loud
|
|
|
|
|
|
* and the pending stamp is retained for the next flush.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async writePendingStamp(): Promise<void> {
|
|
|
|
|
|
if (this.pendingWatermark === null || !this.storage) return
|
|
|
|
|
|
const watermark = this.pendingWatermark
|
|
|
|
|
|
try {
|
|
|
|
|
|
await this.storage.saveMetadata(HNSW_INDEX_STAMP_KEY, {
|
|
|
|
|
|
noun: 'IndexWatermark',
|
|
|
|
|
|
...makeProjectionStamp(watermark, { dimensions: this.dimension })
|
|
|
|
|
|
})
|
|
|
|
|
|
this.stampedWatermark = watermark
|
|
|
|
|
|
this.pendingWatermark = null
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
prodLog.error(
|
|
|
|
|
|
`[HNSW] failed to write watermark stamp (generation ${watermark}) — ` +
|
|
|
|
|
|
`artifact stays behind-stamped (safe: verdicts catchup/rescan, never wrong-adopt); ` +
|
|
|
|
|
|
`retrying on next flush:`,
|
|
|
|
|
|
error
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Read the artifact's stamp and compute the three-way verdict
|
|
|
|
|
|
* against the store's committed generation. Unstamped state on a stamped
|
|
|
|
|
|
* store verdicts `'rescan'` LOUDLY — never a silent adopt.
|
|
|
|
|
|
*
|
|
|
|
|
|
* MIGRATION COST: existing pre-stamp brains verdict `'rescan'` exactly
|
|
|
|
|
|
* once (that open re-derives via the rebuild it is already running); the
|
|
|
|
|
|
* next flush stamps them, and every later open adopts.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param artifactPresent - Whether a persisted artifact exists at all (a
|
|
|
|
|
|
* system record was found); gates loud-vs-quiet on the rescan verdict so
|
|
|
|
|
|
* first boots don't scream.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async loadWatermarkVerdict(artifactPresent: boolean): Promise<void> {
|
|
|
|
|
|
if (!this.storage) return
|
|
|
|
|
|
const committed = this.storage.committedGeneration?.() ?? null
|
|
|
|
|
|
let stamped: number | null = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const record = await this.storage.getMetadata(HNSW_INDEX_STAMP_KEY)
|
|
|
|
|
|
stamped = readStampedWatermark(record)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// An unreadable stamp is unstamped — the fail-safe direction.
|
|
|
|
|
|
stamped = null
|
|
|
|
|
|
}
|
|
|
|
|
|
const result = computeWatermarkVerdict(stamped, committed)
|
|
|
|
|
|
this.loadVerdict = result
|
|
|
|
|
|
this.stampedWatermark = stamped
|
|
|
|
|
|
|
|
|
|
|
|
if (result.verdict === 'rescan') {
|
|
|
|
|
|
if (artifactPresent || stamped !== null) {
|
|
|
|
|
|
prodLog.warn(
|
|
|
|
|
|
`[HNSW] watermark verdict: RESCAN — persisted index is ` +
|
|
|
|
|
|
(stamped === null
|
|
|
|
|
|
? 'unstamped (legacy pre-stamp artifact, or a crash between data and stamp)'
|
|
|
|
|
|
: `stamped at generation ${stamped}, ABOVE the store's committed generation ${committed}`) +
|
|
|
|
|
|
` — never adopting unverifiable state`
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
prodLog.debug('[HNSW] watermark verdict: rescan (no persisted artifact — first boot)')
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (result.verdict === 'catchup') {
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`[HNSW] watermark verdict: catchup — index stamped at generation ${stamped}, ` +
|
|
|
|
|
|
`store committed at ${committed}; the (${stamped}, ${committed}] window awaits ` +
|
|
|
|
|
|
`an incremental fold (verdict exposed; the fold lands with the coordinator's wiring)`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Persist one node's connections. When the connections codec is
|
|
|
|
|
|
* wired AND the storage adapter exposes `saveBinaryBlob`, the per-level
|
|
|
|
|
|
* connection sets are encoded into a single compact buffer and stored as a
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
* binary blob; `saveVectorIndexData` then records the node's level and an empty
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
* `connections: {}` as the marker. Otherwise the legacy JSON-array path is
|
|
|
|
|
|
* taken — the format every brainy release before 2.4.0 wrote. The two are
|
|
|
|
|
|
* intentionally NOT dual-written: one or the other, never both, so format
|
|
|
|
|
|
* convergence is unambiguous on the read side.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Shared by all three save sites (deferred flush, immediate-mode new-entity
|
|
|
|
|
|
* persist, immediate-mode neighbor update) so the codec branch is exercised
|
|
|
|
|
|
* uniformly regardless of which path triggered the write.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async persistNodeConnections(nodeId: string, noun: HNSWNoun): Promise<void> {
|
|
|
|
|
|
if (!this.storage) return
|
|
|
|
|
|
|
|
|
|
|
|
const storageWithBlob = this.storage as unknown as {
|
|
|
|
|
|
saveBinaryBlob?: (key: string, data: Buffer) => Promise<void>
|
|
|
|
|
|
}
|
|
|
|
|
|
const canCompress =
|
|
|
|
|
|
this.connectionsCodec !== null &&
|
|
|
|
|
|
typeof storageWithBlob.saveBinaryBlob === 'function'
|
|
|
|
|
|
|
|
|
|
|
|
if (canCompress) {
|
|
|
|
|
|
const encoded = this.connectionsCodec!.encode(noun.connections)
|
|
|
|
|
|
await storageWithBlob.saveBinaryBlob!(compressedConnectionsKey(nodeId), encoded)
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
await this.storage.saveVectorIndexData(nodeId, {
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
level: noun.level,
|
|
|
|
|
|
connections: {}
|
|
|
|
|
|
})
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const connectionsObj: Record<string, string[]> = {}
|
|
|
|
|
|
for (const [level, nounIds] of noun.connections.entries()) {
|
|
|
|
|
|
connectionsObj[level.toString()] = Array.from(nounIds)
|
|
|
|
|
|
}
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
await this.storage.saveVectorIndexData(nodeId, {
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
level: noun.level,
|
|
|
|
|
|
connections: connectionsObj
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Restore one node's connections from persisted storage. Tries
|
|
|
|
|
|
* the compressed-blob path first (when codec wired AND blob primitive
|
|
|
|
|
|
* available); a present non-empty buffer is decoded and populates
|
|
|
|
|
|
* `noun.connections`. A missing blob (no payload at the key) means this node
|
|
|
|
|
|
* was last persisted under the legacy format, so the legacy
|
|
|
|
|
|
* `hnswData.connections` field is used instead.
|
|
|
|
|
|
*
|
|
|
|
|
|
* On any decode error the legacy field is also used as a safety net — the
|
|
|
|
|
|
* codec's `decode` throws on truncated input, which would otherwise leave
|
|
|
|
|
|
* the node with an empty connections Map (orphaned from the graph).
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async restoreNodeConnections(
|
|
|
|
|
|
nodeId: string,
|
|
|
|
|
|
hnswData: { level: number; connections: Record<string, string[]> },
|
|
|
|
|
|
noun: HNSWNoun
|
|
|
|
|
|
): Promise<void> {
|
2026-06-29 11:06:22 -07:00
|
|
|
|
// Bulk load sets connections directly (bypassing the link path that maintains
|
|
|
|
|
|
// the reverse index) — mark it stale so the next removeItem rebuilds it.
|
|
|
|
|
|
this.incoming = null
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
const storageWithBlob = this.storage as unknown as {
|
|
|
|
|
|
loadBinaryBlob?: (key: string) => Promise<Buffer | null>
|
|
|
|
|
|
}
|
|
|
|
|
|
const canDecompress =
|
|
|
|
|
|
this.connectionsCodec !== null &&
|
|
|
|
|
|
typeof storageWithBlob.loadBinaryBlob === 'function'
|
|
|
|
|
|
|
|
|
|
|
|
if (canDecompress) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const buf = await storageWithBlob.loadBinaryBlob!(compressedConnectionsKey(nodeId))
|
|
|
|
|
|
if (buf && buf.length > 0) {
|
|
|
|
|
|
const decoded = this.connectionsCodec!.decode(buf)
|
|
|
|
|
|
noun.connections = decoded
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
prodLog.debug(`HNSW: compressed connections decode failed for ${nodeId}; falling back to legacy`, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Legacy fallback: JSON UUID arrays in the HNSW data object.
|
|
|
|
|
|
for (const [levelStr, nounIds] of Object.entries(hnswData.connections)) {
|
|
|
|
|
|
const level = parseInt(levelStr, 10)
|
|
|
|
|
|
noun.connections.set(level, new Set<string>(nounIds as string[]))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-02 14:20:04 -08:00
|
|
|
|
/**
|
|
|
|
|
|
* Get the number of dirty (unpersisted) nodes
|
|
|
|
|
|
* Useful for monitoring and debugging
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getDirtyNodeCount(): number {
|
|
|
|
|
|
return this.dirtyNodes.size
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the current persist mode
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getPersistMode(): 'immediate' | 'deferred' {
|
|
|
|
|
|
return this.persistMode
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Calculate distances between a query vector and multiple vectors in parallel
|
|
|
|
|
|
* This is used to optimize performance for search operations
|
|
|
|
|
|
* Uses optimized batch processing for optimal performance
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param queryVector The query vector
|
|
|
|
|
|
* @param vectors Array of vectors to compare against
|
|
|
|
|
|
* @returns Array of distances
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async calculateDistancesInParallel(
|
|
|
|
|
|
queryVector: Vector,
|
|
|
|
|
|
vectors: Array<{ id: string; vector: Vector }>
|
|
|
|
|
|
): Promise<Array<{ id: string; distance: number }>> {
|
|
|
|
|
|
// If parallelization is disabled or there are very few vectors, use sequential processing
|
|
|
|
|
|
if (!this.useParallelization || vectors.length < 10) {
|
|
|
|
|
|
return vectors.map((item) => ({
|
|
|
|
|
|
id: item.id,
|
|
|
|
|
|
distance: this.distanceFunction(queryVector, item.vector)
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Extract just the vectors from the input array
|
|
|
|
|
|
const vectorsOnly = vectors.map((item) => item.vector)
|
|
|
|
|
|
|
|
|
|
|
|
// Use optimized batch distance calculation
|
|
|
|
|
|
const distances = await calculateDistancesBatch(
|
|
|
|
|
|
queryVector,
|
|
|
|
|
|
vectorsOnly,
|
|
|
|
|
|
this.distanceFunction
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Map the distances back to their IDs
|
|
|
|
|
|
return vectors.map((item, index) => ({
|
|
|
|
|
|
id: item.id,
|
|
|
|
|
|
distance: distances[index]
|
|
|
|
|
|
}))
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error(
|
|
|
|
|
|
'Error in batch distance calculation, falling back to sequential processing:',
|
|
|
|
|
|
error
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Fall back to sequential processing if batch calculation fails
|
|
|
|
|
|
return vectors.map((item) => ({
|
|
|
|
|
|
id: item.id,
|
|
|
|
|
|
distance: this.distanceFunction(queryVector, item.vector)
|
|
|
|
|
|
}))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Add a vector to the index
|
2026-08-10 09:29:06 -07:00
|
|
|
|
*
|
|
|
|
|
|
* @param generation - Brainy's commit generation for this write (contract
|
|
|
|
|
|
* parity with `VectorIndexProvider.addItem`). This JS index serves "now"
|
|
|
|
|
|
* only — no per-record delta log, no natural slot — so the value is
|
|
|
|
|
|
* accepted and ignored; a native provider stamps its durable records
|
|
|
|
|
|
* with it. The JS twin adopts stamping with the watermark train.
|
2025-08-26 12:32:21 -07:00
|
|
|
|
*/
|
2026-08-10 09:29:06 -07:00
|
|
|
|
public async addItem(item: VectorDocument, generation?: bigint): Promise<string> {
|
|
|
|
|
|
void generation // Contract parity — the JS index keeps no per-write log.
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// Check if item is defined
|
|
|
|
|
|
if (!item) {
|
|
|
|
|
|
throw new Error('Item is undefined or null')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const { id, vector } = item
|
|
|
|
|
|
|
|
|
|
|
|
// Check if vector is defined
|
|
|
|
|
|
if (!vector) {
|
|
|
|
|
|
throw new Error('Vector is undefined or null')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Set dimension on first insert
|
|
|
|
|
|
if (this.dimension === null) {
|
|
|
|
|
|
this.dimension = vector.length
|
|
|
|
|
|
} else if (vector.length !== this.dimension) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Vector dimension mismatch: expected ${this.dimension}, got ${vector.length}`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Generate random level for this noun
|
|
|
|
|
|
const nounLevel = this.getRandomLevel()
|
|
|
|
|
|
|
2026-06-15 10:08:51 -07:00
|
|
|
|
// Create new noun
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const noun: HNSWNoun = {
|
|
|
|
|
|
id,
|
|
|
|
|
|
vector,
|
|
|
|
|
|
connections: new Map(),
|
|
|
|
|
|
level: nounLevel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Initialize empty connection sets for each level
|
|
|
|
|
|
for (let level = 0; level <= nounLevel; level++) {
|
|
|
|
|
|
noun.connections.set(level, new Set<string>())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If this is the first noun, make it the entry point
|
|
|
|
|
|
if (this.nouns.size === 0) {
|
|
|
|
|
|
this.entryPointId = id
|
|
|
|
|
|
this.maxLevel = nounLevel
|
|
|
|
|
|
this.nouns.set(id, noun)
|
2026-01-27 15:38:21 -08:00
|
|
|
|
|
fix: spine hardening pass 1 (part) — count symmetry, honest partial-load, flush durability, read-fault propagation
Write/index-spine hardening, first batch of Pass 1. Each fix restores an
invariant the surrounding code already intended; every one has a
fail-before/pass-after test.
- Pattern C, finding 5 (baseStorage): delete now decrements the user-facing
scalar total symmetrically — deleteNounMetadata was decrementing only the
per-type bucket, deleteVerbMetadata neither the bucket nor the scalar, so
getNounCount()/getVerbCount() inflated permanently (the stale scalar wins
pagination via Math.max and is persisted). Invariant now holds:
scalar total === Σ per-type across add/update/delete and reopen.
- Pattern A, finding 3 (graph/lsm/LSMTree): a partial SSTable-load failure no
longer publishes the manifest's full relationship count as healthy. Any
per-SSTable load failure throws after the batch, which resets to honest-empty
and lets the existing size()===0 self-heal rebuild run — size()/isHealthy()
can no longer lie about a partial load.
- Pattern B, finding 6 (hnsw/hnswIndex): deferred flush() no longer clears
dirty nodes whose connections failed to persist — failed nodes stay in the
retry set, and flush() throws HnswFlushError instead of returning a lying
node count. The immediate-mode first-noun saveHNSWSystem is un-swallowed, so
addItem() rejects rather than returning an id for a rootless index.
- Pattern B, finding 11 (part — storage reads): new shared isAbsentError()
helper (utils/errorClassification, ENOENT-only absence) applied to
loadBinaryBlob and readObjectFromPath — a real IO fault (EIO/EACCES/EMFILE)
now propagates loudly instead of masquerading as "absent", which had driven
needless rebuilds / empty reads (loadBinaryBlob feeds the native provider).
Regression: 78 green across the 3 new suites + db-mvcc, generationStore,
temporal-vfs, rollback-trapdoor, restore-nondestructive. Full gate runs before
the Pass-1 release (David-gated). Remaining Pass 1: finding 11 getNoun/getVerb
legs, finding 4 (ColumnStore), finding 8 (pending-flush), finding 10 (degraded),
finding 7 (clear). Pattern A guards (1,2,9) as a follow-up release.
2026-07-13 08:50:07 -07:00
|
|
|
|
// Persist system data for first noun (previously skipped). Surface a
|
|
|
|
|
|
// persist failure loudly — the entry point is the root of the whole index;
|
|
|
|
|
|
// silently dropping it while addItem() returns the id would strand every
|
|
|
|
|
|
// future search on a rootless index. Mandate: never a quiet loss.
|
2026-01-27 15:38:21 -08:00
|
|
|
|
if (this.storage && this.persistMode === 'immediate') {
|
|
|
|
|
|
await this.storage.saveHNSWSystem({
|
|
|
|
|
|
entryPointId: this.entryPointId,
|
|
|
|
|
|
maxLevel: this.maxLevel
|
|
|
|
|
|
})
|
|
|
|
|
|
} else if (this.persistMode === 'deferred') {
|
|
|
|
|
|
this.dirtySystem = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
return id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Find entry point
|
|
|
|
|
|
if (!this.entryPointId) {
|
2025-11-25 14:34:19 -08:00
|
|
|
|
// No entry point but nouns exist - corrupted state, recover by using this item
|
|
|
|
|
|
// This shouldn't normally happen as first item sets entry point above
|
2025-08-26 12:32:21 -07:00
|
|
|
|
this.entryPointId = id
|
|
|
|
|
|
this.maxLevel = nounLevel
|
|
|
|
|
|
this.nouns.set(id, noun)
|
|
|
|
|
|
return id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const entryPoint = this.nouns.get(this.entryPointId)
|
|
|
|
|
|
if (!entryPoint) {
|
2025-11-25 14:34:19 -08:00
|
|
|
|
// Entry point was deleted but ID not updated - recover by using new item
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// If the entry point doesn't exist, treat this as the first noun
|
|
|
|
|
|
this.entryPointId = id
|
|
|
|
|
|
this.maxLevel = nounLevel
|
|
|
|
|
|
this.nouns.set(id, noun)
|
|
|
|
|
|
return id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// Wire the node into the graph: greedy descent + per-level linking.
|
|
|
|
|
|
// Extracted to linkNode so updateItem's in-place relink runs the SAME
|
|
|
|
|
|
// insertion linking (one implementation, never a diverging copy).
|
|
|
|
|
|
await this.linkNode(noun, entryPoint)
|
|
|
|
|
|
|
|
|
|
|
|
// Update max level and entry point if needed
|
|
|
|
|
|
if (nounLevel > this.maxLevel) {
|
|
|
|
|
|
this.maxLevel = nounLevel
|
|
|
|
|
|
this.entryPointId = id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add noun to the index
|
|
|
|
|
|
this.nouns.set(id, noun)
|
|
|
|
|
|
|
|
|
|
|
|
// Track high-level nodes for O(1) entry point selection
|
|
|
|
|
|
if (nounLevel >= 2 && nounLevel <= this.MAX_TRACKED_LEVELS) {
|
|
|
|
|
|
if (!this.highLevelNodes.has(nounLevel)) {
|
|
|
|
|
|
this.highLevelNodes.set(nounLevel, new Set())
|
|
|
|
|
|
}
|
|
|
|
|
|
this.highLevelNodes.get(nounLevel)!.add(id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Lazy vector eviction (B2: graph-only memory after insert)
|
|
|
|
|
|
// After graph construction completes, evict the full vector from memory.
|
|
|
|
|
|
// Future searches will load vectors on-demand via getVectorSafe() + UnifiedCache.
|
|
|
|
|
|
if (this.vectorStorageMode === 'lazy' && this.storage) {
|
|
|
|
|
|
noun.vector = [] // Release float32 vector from memory
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Persist HNSW graph data to storage
|
|
|
|
|
|
// Respect persistMode setting
|
|
|
|
|
|
if (this.storage && this.persistMode === 'immediate') {
|
|
|
|
|
|
// IMMEDIATE MODE: Original behavior - persist new entity and system data.
|
|
|
|
|
|
// Goes through the per-node helper so the compressed-blob branch fires
|
|
|
|
|
|
// identically here vs. the deferred-flush + neighbor-update paths.
|
|
|
|
|
|
await this.persistNodeConnections(id, noun).catch((error) => {
|
|
|
|
|
|
console.error(`Failed to persist HNSW data for ${id}:`, error)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Persist system data (entry point and max level)
|
|
|
|
|
|
await this.storage.saveHNSWSystem({
|
|
|
|
|
|
entryPointId: this.entryPointId,
|
|
|
|
|
|
maxLevel: this.maxLevel
|
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
console.error('Failed to persist HNSW system data:', error)
|
|
|
|
|
|
})
|
|
|
|
|
|
} else if (this.persistMode === 'deferred') {
|
|
|
|
|
|
// DEFERRED MODE: Track dirty nodes for later batch persistence
|
|
|
|
|
|
this.dirtyNodes.add(id)
|
|
|
|
|
|
this.dirtySystem = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return id
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description The insertion LINKING phase shared by {@link addItem} and
|
|
|
|
|
|
* {@link updateItem}: greedy-descend from `entryPoint` through the levels
|
|
|
|
|
|
* above `noun.level`, then at each level from `min(noun.level, maxLevel)`
|
|
|
|
|
|
* down to 0 find `efConstruction` candidates, select the M nearest, and
|
|
|
|
|
|
* create bidirectional edges — maintaining the reverse-adjacency index via
|
|
|
|
|
|
* {@link addIncoming} and re-pruning any neighbor pushed over M.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Persistence follows the caller's mode exactly as the historical inline
|
|
|
|
|
|
* addItem code did: `'immediate'` persists each touched neighbor's
|
|
|
|
|
|
* connections concurrently (batched by `maxConcurrentNeighborWrites`);
|
|
|
|
|
|
* `'deferred'` marks each touched neighbor dirty for the next flush.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Does NOT touch index membership (`this.nouns`), the entry point, or
|
|
|
|
|
|
* `maxLevel` — the caller owns that bookkeeping: addItem inserts a NEW node
|
|
|
|
|
|
* afterwards and may raise maxLevel; updateItem relinks an EXISTING node in
|
|
|
|
|
|
* place whose level was already counted, so nothing may change. `noun.vector`
|
|
|
|
|
|
* must be the live in-memory vector at call time; both callers guarantee it
|
|
|
|
|
|
* (lazy-mode eviction happens only after linking completes).
|
|
|
|
|
|
*
|
|
|
|
|
|
* A `neighborId === noun.id` candidate is skipped defensively: during
|
|
|
|
|
|
* updateItem the node is already IN `this.nouns` (visibility-atomicity —
|
|
|
|
|
|
* unlike addItem, which links before inserting), and a self-edge must never
|
|
|
|
|
|
* be creatable no matter what the traversal surfaces.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async linkNode(noun: HNSWNoun, entryPoint: HNSWNoun): Promise<void> {
|
|
|
|
|
|
const { id, vector } = noun
|
|
|
|
|
|
const nounLevel = noun.level
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
let currObj = entryPoint
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
|
|
|
|
|
// Calculate distance to entry point (handles lazy loading + sync fast path)
|
|
|
|
|
|
let currDist = await Promise.resolve(this.distanceSafe(vector, entryPoint))
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// Traverse the graph from top to bottom to find the closest noun
|
|
|
|
|
|
for (let level = this.maxLevel; level > nounLevel; level--) {
|
|
|
|
|
|
let changed = true
|
|
|
|
|
|
while (changed) {
|
|
|
|
|
|
changed = false
|
|
|
|
|
|
|
|
|
|
|
|
// Check all neighbors at current level
|
|
|
|
|
|
const connections = currObj.connections.get(level) || new Set<string>()
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// OPTIMIZATION: Preload neighbor vectors for parallel loading
|
|
|
|
|
|
if (connections.size > 0) {
|
|
|
|
|
|
await this.preloadVectors(Array.from(connections))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) {
|
|
|
|
|
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const distToNeighbor = await Promise.resolve(this.distanceSafe(vector, neighbor))
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
if (distToNeighbor < currDist) {
|
|
|
|
|
|
currDist = distToNeighbor
|
|
|
|
|
|
currObj = neighbor
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// For each level from nounLevel down to 0
|
|
|
|
|
|
for (let level = Math.min(nounLevel, this.maxLevel); level >= 0; level--) {
|
|
|
|
|
|
// Find ef nearest elements using greedy search
|
|
|
|
|
|
const nearestNouns = await this.searchLayer(
|
|
|
|
|
|
vector,
|
|
|
|
|
|
currObj,
|
|
|
|
|
|
this.config.efConstruction,
|
|
|
|
|
|
level
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Select M nearest neighbors
|
|
|
|
|
|
const neighbors = this.selectNeighbors(
|
|
|
|
|
|
vector,
|
|
|
|
|
|
nearestNouns,
|
|
|
|
|
|
this.config.M
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Add bidirectional connections
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// PERFORMANCE OPTIMIZATION: Collect all neighbor updates for concurrent execution
|
2025-10-29 16:10:40 -07:00
|
|
|
|
const neighborUpdates: Array<{
|
|
|
|
|
|
neighborId: string
|
|
|
|
|
|
promise: Promise<void>
|
|
|
|
|
|
}> = []
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
for (const [neighborId, _] of neighbors) {
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
if (neighborId === id) {
|
|
|
|
|
|
// Never self-link (see method JSDoc — reachable only via updateItem)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) {
|
|
|
|
|
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-01 11:56:11 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
noun.connections.get(level)!.add(neighborId)
|
2026-06-29 11:06:22 -07:00
|
|
|
|
this.addIncoming(neighborId, level, id) // forward edge id → neighborId
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// Add reverse connection
|
|
|
|
|
|
if (!neighbor.connections.has(level)) {
|
|
|
|
|
|
neighbor.connections.set(level, new Set<string>())
|
|
|
|
|
|
}
|
|
|
|
|
|
neighbor.connections.get(level)!.add(id)
|
2026-06-29 11:06:22 -07:00
|
|
|
|
this.addIncoming(id, level, neighborId) // forward edge neighborId → id
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// Ensure neighbor doesn't have too many connections
|
|
|
|
|
|
if (neighbor.connections.get(level)!.size > this.config.M) {
|
2025-10-10 14:09:30 -07:00
|
|
|
|
await this.pruneConnections(neighbor, level)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Persist updated neighbor HNSW data
|
fix: resolve HNSW concurrency race condition across all storage adapters
Fixes critical P0 bug causing data corruption during bulk imports with 50+ concurrent operations. The non-atomic read-modify-write pattern in saveHNSWData() combined with fire-and-forget neighbor updates was causing 16-32 concurrent writes per entity, resulting in lost HNSW connections and corrupted graph structure.
**Root Cause:**
- saveHNSWData() used non-atomic read-modify-write
- HNSW neighbor updates fired without await (16-32 concurrent writes/entity)
- Popular nodes became hotspots (100 concurrent imports = 3,400 concurrent saveHNSWData calls)
- Result: Lost neighbor connections, 0 search results
**Atomic Write Strategies by Adapter:**
FileSystemStorage:
- Atomic rename with temp files
- Write to {file}.tmp.{timestamp}.{random}
- POSIX-guaranteed atomic rename(temp, final)
GCSStorage:
- Optimistic locking with generation numbers
- preconditionOpts: { ifGenerationMatch }
- 5 retries with exponential backoff (50ms→800ms)
S3/R2/AzureStorage:
- ETag-based optimistic locking
- IfMatch/conditions preconditions
- 5 retries with exponential backoff
MemoryStorage + OPFSStorage:
- Mutex locks per entity path
- Serializes async operations even in single-threaded environments
HNSW Index:
- Changed fire-and-forget .catch() to await
- Serializes 16-32 neighbor updates per entity
- Trade-off: 20-30% slower bulk import vs 100% data integrity
**Sharding Compatibility:**
- ✅ Works with deterministic UUID sharding (256 shards, always on)
- ✅ Works with distributed multi-node sharding (optional)
- ✅ All atomic strategies work in both single-node and distributed deployments
**Index Impact:**
- Only HNSW index modified (saveHNSWData, saveHNSWSystem)
- Other 4 indexes unaffected (Metadata, Graph Adjacency, Deleted Items, Entity ID Mapper)
- No regression risk - isolated code paths
**Testing:**
- 8/8 unit tests passing (real concurrent operations, no mocks)
- Tests verify data integrity after 20 concurrent updates
- Tests verify temp file cleanup and mutex serialization
**Files Modified:**
- All 8 storage adapters (FileSystem, GCS, S3, R2, Azure, Memory, OPFS)
- HNSW Index (neighbor update serialization)
- New test: tests/unit/storage/hnswConcurrency.test.ts (8 passing tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 15:24:20 -07:00
|
|
|
|
//
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Deferred persistence mode for cloud storage performance
|
2025-12-02 14:20:04 -08:00
|
|
|
|
// In deferred mode, we track dirty nodes instead of persisting immediately
|
|
|
|
|
|
// This reduces GCS operations from 70 to 2-3 per add() (30-50× faster)
|
|
|
|
|
|
if (this.storage && this.persistMode === 'immediate') {
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
// IMMEDIATE MODE: Original behavior - persist each neighbor update.
|
|
|
|
|
|
// Goes through the per-node helper so the compressed-blob branch
|
|
|
|
|
|
// fires identically here vs. the deferred-flush path.
|
2025-10-29 16:10:40 -07:00
|
|
|
|
neighborUpdates.push({
|
|
|
|
|
|
neighborId,
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
promise: this.persistNodeConnections(neighborId, neighbor)
|
2025-10-29 16:10:40 -07:00
|
|
|
|
})
|
2025-12-02 14:20:04 -08:00
|
|
|
|
} else if (this.persistMode === 'deferred') {
|
|
|
|
|
|
// DEFERRED MODE: Track dirty nodes for later batch persistence
|
|
|
|
|
|
this.dirtyNodes.add(neighborId)
|
2025-10-29 16:10:40 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-02 14:20:04 -08:00
|
|
|
|
// Execute all neighbor updates concurrently (only in immediate mode)
|
|
|
|
|
|
if (neighborUpdates.length > 0 && this.persistMode === 'immediate') {
|
2025-10-29 16:10:40 -07:00
|
|
|
|
const batchSize = this.config.maxConcurrentNeighborWrites || neighborUpdates.length
|
|
|
|
|
|
const allFailures: Array<{ result: PromiseRejectedResult; neighborId: string }> = []
|
|
|
|
|
|
|
|
|
|
|
|
// Process in chunks if batch size specified
|
|
|
|
|
|
for (let i = 0; i < neighborUpdates.length; i += batchSize) {
|
|
|
|
|
|
const batch = neighborUpdates.slice(i, i + batchSize)
|
|
|
|
|
|
const results = await Promise.allSettled(batch.map(u => u.promise))
|
|
|
|
|
|
|
|
|
|
|
|
// Track failures for monitoring (storage adapters already retried 5× each)
|
|
|
|
|
|
const batchFailures = results
|
|
|
|
|
|
.map((result, idx) => ({ result, neighborId: batch[idx].neighborId }))
|
|
|
|
|
|
.filter(({ result }) => result.status === 'rejected')
|
|
|
|
|
|
.map(({ result, neighborId }) => ({
|
|
|
|
|
|
result: result as PromiseRejectedResult,
|
|
|
|
|
|
neighborId
|
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
|
|
allFailures.push(...batchFailures)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (allFailures.length > 0) {
|
|
|
|
|
|
console.warn(
|
|
|
|
|
|
`[HNSW] ${allFailures.length}/${neighborUpdates.length} neighbor updates failed after retries (entity: ${id}, level: ${level})`
|
|
|
|
|
|
)
|
|
|
|
|
|
// Log first failure for debugging
|
|
|
|
|
|
console.error(
|
|
|
|
|
|
`[HNSW] First failure (neighbor: ${allFailures[0].neighborId}):`,
|
|
|
|
|
|
allFailures[0].result.reason
|
|
|
|
|
|
)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Update entry point for the next level
|
|
|
|
|
|
if (nearestNouns.size > 0) {
|
|
|
|
|
|
const [nearestId, nearestDist] = [...nearestNouns][0]
|
|
|
|
|
|
if (nearestDist < currDist) {
|
|
|
|
|
|
currDist = nearestDist
|
|
|
|
|
|
const nearestNoun = this.nouns.get(nearestId)
|
|
|
|
|
|
if (!nearestNoun) {
|
|
|
|
|
|
console.error(
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
`Nearest noun with ID ${nearestId} not found in linkNode`
|
2025-08-26 12:32:21 -07:00
|
|
|
|
)
|
|
|
|
|
|
// Keep the current object as is
|
|
|
|
|
|
} else {
|
|
|
|
|
|
currObj = nearestNoun
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @description Atomically replace an item's vector IN PLACE — the row is
|
|
|
|
|
|
* NEVER absent from the index during an update. The historical shape staged
|
|
|
|
|
|
* a remove followed by an add as two separately-awaited transaction
|
|
|
|
|
|
* operations; between them the row was in NEITHER index — dark to semantic
|
|
|
|
|
|
* recall while perfectly visible to metadata reads (observed as seconds-long
|
|
|
|
|
|
* production flicker in a downstream deployment). Mandate: a row that
|
|
|
|
|
|
* exists must never be invisible to a read path, even transiently.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Behavior:
|
|
|
|
|
|
* - id not in the index → delegates to {@link addItem} (plain insert).
|
|
|
|
|
|
* - SAME vector (element-wise equal) → pure no-op. This is the production
|
|
|
|
|
|
* flicker shape: a type-only update re-indexes an UNCHANGED vector, so the
|
|
|
|
|
|
* old remove+add did pure damage. (In lazy vector-storage mode the
|
|
|
|
|
|
* comparison baseline is whatever {@link getVectorSafe} serves — the
|
|
|
|
|
|
* cache, or the persisted record; if the caller already rewrote the
|
|
|
|
|
|
* record with the new vector before calling in, equality may report "no
|
|
|
|
|
|
* change" and skip the relink. Query correctness is unaffected either
|
|
|
|
|
|
* way — distances always use the live vector — the graph edges just keep
|
|
|
|
|
|
* their pre-update geometry, which HNSW tolerates by construction.)
|
|
|
|
|
|
* - DIFFERENT vector → the node never leaves `this.nouns`:
|
|
|
|
|
|
* 1. `node.vector` is swapped SYNCHRONOUSLY first (and the shared vector
|
|
|
|
|
|
* cache updated in the same tick), so from that point every query sees
|
|
|
|
|
|
* the node with correct distances;
|
|
|
|
|
|
* 2. its old edges are unlinked via the same reverse-adjacency walk
|
|
|
|
|
|
* removeItem uses ({@link unlinkNodeEdges}) — the node stays in the
|
|
|
|
|
|
* map and KEEPS its level;
|
|
|
|
|
|
* 3. the insertion linking re-runs at the node's EXISTING level
|
|
|
|
|
|
* ({@link linkNode}). Entry-point cases: if the node IS the entry
|
|
|
|
|
|
* point it REMAINS the entry point (still valid — same id, same
|
|
|
|
|
|
* level); the relink traversal then starts from another node via
|
|
|
|
|
|
* {@link resolveRelinkStart}, because the node's own edges were just
|
|
|
|
|
|
* cleared and a traversal starting AT it would find nothing and link
|
|
|
|
|
|
* nothing — stranding the whole graph behind an edgeless entry point.
|
|
|
|
|
|
* maxLevel never regresses: the node keeps its level and its
|
|
|
|
|
|
* membership, so the remove-side relevel bookkeeping never runs.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Persistence mirrors {@link addItem}'s tail for the node itself plus the
|
|
|
|
|
|
* in-neighbors whose connection sets changed during the unlink:
|
|
|
|
|
|
* `'immediate'` persists their connections now; `'deferred'` marks them
|
|
|
|
|
|
* dirty for the next flush. The system record (entry point + maxLevel) is
|
|
|
|
|
|
* NOT rewritten — an in-place update changes neither.
|
2026-08-10 09:29:06 -07:00
|
|
|
|
*
|
|
|
|
|
|
* @param generation - Brainy's commit generation for this write (contract
|
|
|
|
|
|
* parity with the feature-detected `updateItem` provider capability).
|
|
|
|
|
|
* Accepted and ignored — the JS index keeps no per-write log.
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
*/
|
2026-08-10 09:29:06 -07:00
|
|
|
|
public async updateItem(item: VectorDocument, generation?: bigint): Promise<void> {
|
|
|
|
|
|
void generation // Contract parity — the JS index keeps no per-write log.
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
if (!item) {
|
|
|
|
|
|
throw new Error('Item is undefined or null')
|
|
|
|
|
|
}
|
|
|
|
|
|
const { id, vector } = item
|
|
|
|
|
|
if (!vector) {
|
|
|
|
|
|
throw new Error('Vector is undefined or null')
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
const node = this.nouns.get(id)
|
|
|
|
|
|
if (!node) {
|
|
|
|
|
|
// Absent → plain insert.
|
|
|
|
|
|
await this.addItem(item)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2025-09-22 15:45:35 -07:00
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
if (this.dimension === null) {
|
|
|
|
|
|
this.dimension = vector.length
|
|
|
|
|
|
} else if (vector.length !== this.dimension) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Vector dimension mismatch: expected ${this.dimension}, got ${vector.length}`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fast path: element-wise-equal vector → NOTHING to do (the production
|
|
|
|
|
|
// flicker shape — a type-only update re-indexing an unchanged vector).
|
|
|
|
|
|
// getVectorSafe handles the lazy-evicted case (loads from cache/storage).
|
|
|
|
|
|
const current = await this.getVectorSafe(node)
|
|
|
|
|
|
if (current.length === vector.length) {
|
|
|
|
|
|
let same = true
|
|
|
|
|
|
for (let i = 0; i < vector.length; i++) {
|
|
|
|
|
|
if (current[i] !== vector[i]) {
|
|
|
|
|
|
same = false
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
2025-09-22 15:45:35 -07:00
|
|
|
|
}
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
if (same) return
|
2025-09-22 15:45:35 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// (1) Visibility-atomic swap: from this synchronous assignment on, every
|
|
|
|
|
|
// query sees the node with correct distances. The shared vector cache is
|
|
|
|
|
|
// updated in the same tick so the lazy-mode read path can never serve the
|
|
|
|
|
|
// stale vector either.
|
|
|
|
|
|
node.vector = vector
|
|
|
|
|
|
this.unifiedCache.set(`hnsw:vector:${id}`, vector, 'vectors', vector.length * 4, 50)
|
|
|
|
|
|
|
|
|
|
|
|
// (2) Unlink the old edges — the node stays in the map, keeps its level.
|
|
|
|
|
|
const touchedReferrers = await this.unlinkNodeEdges(node)
|
|
|
|
|
|
node.connections = new Map()
|
|
|
|
|
|
for (let level = 0; level <= node.level; level++) {
|
|
|
|
|
|
node.connections.set(level, new Set<string>())
|
2026-01-31 12:41:53 -08:00
|
|
|
|
}
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// The node's own reverse entry is rebuilt by the relink below.
|
|
|
|
|
|
this.incoming?.delete(id)
|
2026-01-31 12:41:53 -08:00
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// (3) Relink at the node's EXISTING level (see JSDoc for the entry-point
|
|
|
|
|
|
// reasoning). A single-node index has nothing to link to — trivially done.
|
|
|
|
|
|
const start = this.resolveRelinkStart(id)
|
|
|
|
|
|
if (start) {
|
|
|
|
|
|
await this.linkNode(node, start)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Persistence — addItem's tail, minus the system record (entry point and
|
|
|
|
|
|
// maxLevel are untouched by an in-place update). Unlink-touched referrers
|
|
|
|
|
|
// are included so the persisted graph converges on the live one instead of
|
|
|
|
|
|
// keeping their pre-update edge sets forever.
|
2025-12-02 14:20:04 -08:00
|
|
|
|
if (this.storage && this.persistMode === 'immediate') {
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
await this.persistNodeConnections(id, node).catch((error) => {
|
2025-10-10 11:15:17 -07:00
|
|
|
|
console.error(`Failed to persist HNSW data for ${id}:`, error)
|
|
|
|
|
|
})
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
for (const refId of touchedReferrers) {
|
|
|
|
|
|
const ref = this.nouns.get(refId)
|
|
|
|
|
|
if (!ref) continue
|
|
|
|
|
|
await this.persistNodeConnections(refId, ref).catch((error) => {
|
|
|
|
|
|
console.error(`Failed to persist HNSW data for ${refId}:`, error)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
2025-12-02 14:20:04 -08:00
|
|
|
|
} else if (this.persistMode === 'deferred') {
|
|
|
|
|
|
this.dirtyNodes.add(id)
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
for (const refId of touchedReferrers) {
|
|
|
|
|
|
this.dirtyNodes.add(refId)
|
|
|
|
|
|
}
|
2025-10-10 11:15:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// Lazy vector eviction — same contract as addItem: after graph work
|
|
|
|
|
|
// completes the float32 vector leaves memory; reads serve from the
|
|
|
|
|
|
// (just-updated) cache or the persisted record.
|
|
|
|
|
|
if (this.vectorStorageMode === 'lazy' && this.storage) {
|
|
|
|
|
|
node.vector = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @description Pick the traversal start for an in-place relink
|
|
|
|
|
|
* ({@link updateItem} step 3): the current entry point — unless that IS the
|
|
|
|
|
|
* node being relinked. Its edges were just unlinked, so a traversal
|
|
|
|
|
|
* starting there would see an empty neighborhood and produce zero links,
|
|
|
|
|
|
* stranding the graph behind an edgeless entry point. In that case (or when
|
|
|
|
|
|
* the entry point is missing/stale) fall back to the best OTHER node:
|
|
|
|
|
|
* highest tracked level first (the same O(1) heuristic as
|
|
|
|
|
|
* {@link recoverEntryPointO1}), then any other node. Returns null when the
|
|
|
|
|
|
* node is the only one in the index — nothing to link to, trivially valid.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private resolveRelinkStart(excludeId: string): HNSWNoun | null {
|
|
|
|
|
|
if (this.entryPointId && this.entryPointId !== excludeId) {
|
|
|
|
|
|
const entry = this.nouns.get(this.entryPointId)
|
|
|
|
|
|
if (entry) return entry
|
|
|
|
|
|
}
|
|
|
|
|
|
for (let level = this.MAX_TRACKED_LEVELS; level >= 2; level--) {
|
|
|
|
|
|
const nodesAtLevel = this.highLevelNodes.get(level)
|
|
|
|
|
|
if (!nodesAtLevel) continue
|
|
|
|
|
|
for (const nodeId of nodesAtLevel) {
|
|
|
|
|
|
if (nodeId !== excludeId) {
|
|
|
|
|
|
const candidate = this.nouns.get(nodeId)
|
|
|
|
|
|
if (candidate) return candidate
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const [nodeId, candidate] of this.nouns) {
|
|
|
|
|
|
if (nodeId !== excludeId) return candidate
|
|
|
|
|
|
}
|
|
|
|
|
|
return null
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 15:36:49 -08:00
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* O(1) entry point recovery using highLevelNodes index.
|
2025-11-25 15:36:49 -08:00
|
|
|
|
* At any reasonable scale (1000+ nodes), level 2+ nodes are guaranteed to exist.
|
|
|
|
|
|
* For tiny indexes with only level 0-1 nodes, any node works as entry point.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private recoverEntryPointO1(): { id: string | null; level: number } {
|
|
|
|
|
|
// O(1) recovery: check highLevelNodes from highest to lowest level
|
|
|
|
|
|
for (let level = this.MAX_TRACKED_LEVELS; level >= 2; level--) {
|
|
|
|
|
|
const nodesAtLevel = this.highLevelNodes.get(level)
|
|
|
|
|
|
if (nodesAtLevel && nodesAtLevel.size > 0) {
|
|
|
|
|
|
for (const nodeId of nodesAtLevel) {
|
|
|
|
|
|
if (this.nouns.has(nodeId)) {
|
|
|
|
|
|
return { id: nodeId, level }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// No high-level nodes - use any available node (works fine for HNSW)
|
|
|
|
|
|
const firstNode = this.nouns.keys().next().value
|
|
|
|
|
|
return { id: firstNode ?? null, level: 0 }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
2026-06-15 10:08:51 -07:00
|
|
|
|
* Search for nearest neighbors.
|
2026-01-31 12:41:53 -08:00
|
|
|
|
*
|
2026-06-15 10:08:51 -07:00
|
|
|
|
* 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.
|
2026-01-31 12:41:53 -08:00
|
|
|
|
*
|
|
|
|
|
|
* @param queryVector Query vector
|
|
|
|
|
|
* @param k Number of results to return
|
|
|
|
|
|
* @param filter Optional filter function
|
2026-06-23 13:18:34 -07:00
|
|
|
|
* @param options Additional search options (`candidateIds` / `allowedIds` to
|
|
|
|
|
|
* restrict the search to a pre-filtered set; `rerank` is provider-only, ignored here)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
*/
|
|
|
|
|
|
public async search(
|
|
|
|
|
|
queryVector: Vector,
|
|
|
|
|
|
k: number = 10,
|
2026-01-31 12:41:53 -08:00
|
|
|
|
filter?: (id: string) => Promise<boolean>,
|
2026-06-23 13:18:34 -07:00
|
|
|
|
options?: {
|
|
|
|
|
|
rerank?: { multiplier: number }
|
|
|
|
|
|
candidateIds?: string[]
|
|
|
|
|
|
allowedIds?: OpaqueIdSet | ReadonlySet<string>
|
feat(8.0): asOf at-gen vector defer — provider-served historical semantic search (#35)
A filtered semantic read at a historical generation (db.asOf(g).find({ query, where }))
no longer unconditionally rebuilds an ephemeral JS-HNSW over every at-g vector
(O(n@G), the OOM ceiling the 2026-06-24 scaling audit flagged). When the vector
index is a VersionedIndexProvider that advertises isGenerationVisible(g), Brainy:
1. resolves the at-g metadata∩graph universe from the record-overlay path (no
materialization — it's the metadata-only historical find),
2. routes the vector leg to the provider with { allowedIds, generation }, and
3. composes the at-g entities ranked by the provider's at-g vector distance.
Without a versioned provider (the JS index, or a native one that refuses the
generation) it falls through to the existing materialization — unchanged.
- Seam (A): VectorIndexProvider.search gains an optional `generation?: bigint`
("omitted = now"), the vector mirror of the graph provider's trailing-gen + the
#46 allowedIds field. JsHnswVectorIndex accepts and ignores it (no per-gen
segments → refuse/fall-back posture; Brainy never routes a historical read there).
- Gate: Db.find tries the native at-gen vector path before materialize(); host
exposes canServeVectorAtGeneration + vectorSearchAtGeneration.
- generation is Brainy's u64 commit counter — the same value handed to the graph
index on writes (graphWriteGeneration), so it maps 1:1 to the native side.
Decided lockstep with the cor team (handoff #35 thread: (A) + vector-only). The
gen-g candidate-vector supply for cor's exact-rerank (part-3) is a separate,
non-blocking seam still being confirmed; the honesty guard keeps this path inactive
(falls through to materialize) until cor's native at-gen rerank is live.
Tested: provider routing + at-gen universe correctness (excludes future-born,
applies the filter) + page window via a mock versioned provider; seam-ignore on the
JS index; 38 db-mvcc/db-temporal historical-read tests green (materialize fallback).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:52:09 -07:00
|
|
|
|
// 8.0 #35 at-gen seam: the JS index has no retained per-generation segments
|
|
|
|
|
|
// (it only ever holds "now"), so it ignores `generation` — exactly the
|
|
|
|
|
|
// refuse/fall-back posture the contract requires. Brainy never routes a
|
|
|
|
|
|
// historical read here (its defer-gate checks `isGenerationVisible` first),
|
|
|
|
|
|
// so an ignored `generation` cannot silently return now-vectors-as-at-gen.
|
|
|
|
|
|
generation?: bigint
|
feat(8.0): #35 part-3 — supply at-gen candidate vectors for the native exact-rerank
Completes brainy's #35 side. When the at-gen vector gate serves a filtered
semantic read, Brainy now ships the historically-correct candidate vectors so the
native provider reranks against them with zero per-vector FFI crossing — the
provider need not duplicate the per-generation retention Brainy already does.
- New `AtGenerationVectors { ids: BigInt64Array; vectors: Float32Array; dim }` on
the VectorIndexProvider.search options bag (row-major: vectors[i*dim..] == ids[i];
invariant vectors.length === ids.length*dim; ids = the candidate set; dim must
match the index dim). The built-in JS index ignores it.
- buildAtGenerationVectors resolves each universe id's vector AS OF the generation
(the record before-image, or live getNoun when untouched since the pin — not
readNounRaw, whose canonical path is empty under lazy-vector eviction), interns
the id via the shared mapper, and packs row-major. Absent/vectorless/wrong-dim
ids are dropped (not vector-rankable). Bounded by the filtered universe.
Shape + the four clarifications (row-major, ids-are-the-candidate-set, dim-asserted,
filtered-path-only) confirmed with cor in the handoff #35 thread. Inert until cor's
native at-gen rerank advertises isGenerationVisible (honesty guard).
Tested: the mock versioned provider now asserts it receives atGenerationVectors with
the row-major invariant, correct dim, and ids resolving back to the at-gen universe.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:50:41 -07:00
|
|
|
|
// 8.0 #35 part-3: at-gen candidate vectors for the native exact-rerank. The JS
|
|
|
|
|
|
// index serves "now" only and never receives this (gated off upstream); ignored.
|
|
|
|
|
|
atGenerationVectors?: AtGenerationVectors
|
2026-06-23 13:18:34 -07:00
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
): Promise<Array<[string, number]>> {
|
|
|
|
|
|
if (this.nouns.size === 0) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-23 13:18:34 -07:00
|
|
|
|
// Metadata-first candidate restriction. Build a filter from the pre-resolved
|
|
|
|
|
|
// universe(s) when the caller didn't pass an explicit one. Both `candidateIds`
|
|
|
|
|
|
// (legacy string[]) and the 8.0 `allowedIds` predicate-pushdown param restrict
|
|
|
|
|
|
// the SAME way here — applied INSIDE the beam walk (searchLayer keeps every
|
|
|
|
|
|
// neighbor as a traversal candidate but only COLLECTS allowed ids), which is
|
|
|
|
|
|
// what recovers the filtered recall that post-filtering loses. An `allowedIds`
|
|
|
|
|
|
// that arrives as an OpaqueIdSet (a native/cor roaring Buffer) is opaque to the
|
|
|
|
|
|
// JS index — it cannot decode it — so it is ignored here; only the native
|
|
|
|
|
|
// provider consumes that form. A `ReadonlySet<string>` is honored. When both
|
|
|
|
|
|
// `candidateIds` and a Set `allowedIds` are present they AND together.
|
|
|
|
|
|
if (!filter) {
|
|
|
|
|
|
const universes: ReadonlySet<string>[] = []
|
|
|
|
|
|
if (options?.candidateIds && options.candidateIds.length > 0) {
|
|
|
|
|
|
universes.push(new Set(options.candidateIds))
|
|
|
|
|
|
}
|
|
|
|
|
|
const allowed = options?.allowedIds
|
|
|
|
|
|
if (
|
|
|
|
|
|
allowed &&
|
|
|
|
|
|
!(allowed instanceof Uint8Array) &&
|
|
|
|
|
|
typeof (allowed as ReadonlySet<string>).has === 'function'
|
|
|
|
|
|
) {
|
|
|
|
|
|
universes.push(allowed as ReadonlySet<string>)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (universes.length === 1) {
|
|
|
|
|
|
const universe = universes[0]
|
|
|
|
|
|
filter = async (id: string) => universe.has(id)
|
|
|
|
|
|
} else if (universes.length > 1) {
|
|
|
|
|
|
filter = async (id: string) => universes.every((u) => u.has(id))
|
|
|
|
|
|
}
|
2026-02-01 16:23:49 -08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// Check if query vector is defined
|
|
|
|
|
|
if (!queryVector) {
|
|
|
|
|
|
throw new Error('Query vector is undefined or null')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (this.dimension !== null && queryVector.length !== this.dimension) {
|
|
|
|
|
|
throw new Error(
|
|
|
|
|
|
`Query vector dimension mismatch: expected ${this.dimension}, got ${queryVector.length}`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Start from the entry point
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// If entry point is null but nouns exist, attempt O(1) recovery
|
2025-11-25 14:34:19 -08:00
|
|
|
|
if (!this.entryPointId && this.nouns.size > 0) {
|
2025-11-25 15:36:49 -08:00
|
|
|
|
const { id: recoveredId, level: recoveredLevel } = this.recoverEntryPointO1()
|
2025-11-25 14:34:19 -08:00
|
|
|
|
if (recoveredId) {
|
|
|
|
|
|
this.entryPointId = recoveredId
|
2025-11-25 15:36:49 -08:00
|
|
|
|
this.maxLevel = recoveredLevel
|
2025-11-25 14:34:19 -08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
if (!this.entryPointId) {
|
2025-11-25 14:34:19 -08:00
|
|
|
|
// Truly empty index - return empty results silently
|
2025-08-26 12:32:21 -07:00
|
|
|
|
return []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 14:34:19 -08:00
|
|
|
|
let entryPoint = this.nouns.get(this.entryPointId)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
if (!entryPoint) {
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Entry point ID exists but noun was deleted - O(1) recovery
|
2025-11-25 14:34:19 -08:00
|
|
|
|
if (this.nouns.size > 0) {
|
2025-11-25 15:36:49 -08:00
|
|
|
|
const { id: recoveredId, level: recoveredLevel } = this.recoverEntryPointO1()
|
2025-11-25 14:34:19 -08:00
|
|
|
|
if (recoveredId) {
|
|
|
|
|
|
this.entryPointId = recoveredId
|
2025-11-25 15:36:49 -08:00
|
|
|
|
this.maxLevel = recoveredLevel
|
2025-11-25 14:34:19 -08:00
|
|
|
|
entryPoint = this.nouns.get(recoveredId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If still no entry point, return empty
|
|
|
|
|
|
if (!entryPoint) {
|
|
|
|
|
|
return []
|
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let currObj = entryPoint
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
|
|
|
|
|
// OPTIMIZATION: Preload entry point vector
|
|
|
|
|
|
await this.preloadVectors([entryPoint.id])
|
|
|
|
|
|
|
|
|
|
|
|
let currDist = await Promise.resolve(this.distanceSafe(queryVector, currObj))
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// Traverse the graph from top to bottom to find the closest noun
|
|
|
|
|
|
for (let level = this.maxLevel; level > 0; level--) {
|
|
|
|
|
|
let changed = true
|
|
|
|
|
|
while (changed) {
|
|
|
|
|
|
changed = false
|
|
|
|
|
|
|
|
|
|
|
|
// Check all neighbors at current level
|
|
|
|
|
|
const connections = currObj.connections.get(level) || new Set<string>()
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// OPTIMIZATION: Preload all neighbor vectors in parallel before distance calculations
|
|
|
|
|
|
if (connections.size > 0) {
|
|
|
|
|
|
await this.preloadVectors(Array.from(connections))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// If we have enough connections, use parallel distance calculation
|
|
|
|
|
|
if (this.useParallelization && connections.size >= 10) {
|
|
|
|
|
|
// Prepare vectors for parallel calculation
|
|
|
|
|
|
const vectors: Array<{ id: string; vector: Vector }> = []
|
|
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) continue
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const neighborVector = await this.getVectorSafe(neighbor)
|
|
|
|
|
|
vectors.push({ id: neighborId, vector: neighborVector })
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate distances in parallel
|
|
|
|
|
|
const distances = await this.calculateDistancesInParallel(
|
|
|
|
|
|
queryVector,
|
|
|
|
|
|
vectors
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Find the closest neighbor
|
|
|
|
|
|
for (const { id, distance } of distances) {
|
|
|
|
|
|
if (distance < currDist) {
|
|
|
|
|
|
currDist = distance
|
|
|
|
|
|
const neighbor = this.nouns.get(id)
|
|
|
|
|
|
if (neighbor) {
|
|
|
|
|
|
currObj = neighbor
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Use sequential processing for small number of connections
|
|
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) {
|
|
|
|
|
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor))
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
if (distToNeighbor < currDist) {
|
|
|
|
|
|
currDist = distToNeighbor
|
|
|
|
|
|
currObj = neighbor
|
|
|
|
|
|
changed = true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 10:08:51 -07:00
|
|
|
|
// 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)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const nearestNouns = await this.searchLayer(
|
|
|
|
|
|
queryVector,
|
|
|
|
|
|
currObj,
|
|
|
|
|
|
ef,
|
|
|
|
|
|
0,
|
|
|
|
|
|
filter
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-15 10:08:51 -07:00
|
|
|
|
// Exact float32 distances throughout — return the top k directly.
|
2025-08-26 12:32:21 -07:00
|
|
|
|
return [...nearestNouns].slice(0, k)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 11:06:22 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Build (or return the cached) reverse-adjacency index. O(N + E) the first time
|
|
|
|
|
|
* after a bulk load/clear; O(1) thereafter while it stays live.
|
|
|
|
|
|
*/
|
|
|
|
|
|
private ensureIncoming(): Map<string, Map<number, Set<string>>> {
|
|
|
|
|
|
if (this.incoming !== null) return this.incoming
|
|
|
|
|
|
const inc = new Map<string, Map<number, Set<string>>>()
|
|
|
|
|
|
for (const [nodeId, node] of this.nouns) {
|
|
|
|
|
|
for (const [level, targets] of node.connections) {
|
|
|
|
|
|
for (const target of targets) {
|
|
|
|
|
|
let byLevel = inc.get(target)
|
|
|
|
|
|
if (!byLevel) { byLevel = new Map(); inc.set(target, byLevel) }
|
|
|
|
|
|
let set = byLevel.get(level)
|
|
|
|
|
|
if (!set) { set = new Set(); byLevel.set(level, set) }
|
|
|
|
|
|
set.add(nodeId)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
this.incoming = inc
|
|
|
|
|
|
return inc
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Record a new forward edge `source → target` at `level` (no-op while the index is stale). */
|
|
|
|
|
|
private addIncoming(target: string, level: number, source: string): void {
|
|
|
|
|
|
if (this.incoming === null) return
|
|
|
|
|
|
let byLevel = this.incoming.get(target)
|
|
|
|
|
|
if (!byLevel) { byLevel = new Map(); this.incoming.set(target, byLevel) }
|
|
|
|
|
|
let set = byLevel.get(level)
|
|
|
|
|
|
if (!set) { set = new Set(); byLevel.set(level, set) }
|
|
|
|
|
|
set.add(source)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** Record that the forward edge `source → target` at `level` is gone. */
|
|
|
|
|
|
private removeIncoming(target: string, level: number, source: string): void {
|
|
|
|
|
|
if (this.incoming === null) return
|
|
|
|
|
|
this.incoming.get(target)?.get(level)?.delete(source)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
* @description Unlink every graph edge touching `noun`, in BOTH directions,
|
|
|
|
|
|
* WITHOUT removing the node from `this.nouns` — the unlink walk shared by
|
|
|
|
|
|
* {@link removeItem} (which then drops the node) and {@link updateItem}
|
|
|
|
|
|
* (which relinks the node in place, so it must never leave the map and
|
|
|
|
|
|
* KEEPS its level).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Reverse-adjacency lets us touch ONLY the nodes that actually reference
|
|
|
|
|
|
* `noun.id` (its in-neighbors) rather than scanning the whole corpus —
|
|
|
|
|
|
* turning a delete from O(N) into O(in-degree) and a bulk delete from O(N²)
|
|
|
|
|
|
* into O(N·degree). Each referrer set is snapshotted because
|
|
|
|
|
|
* pruneConnections mutates the index. Outgoing edges are unhooked from each
|
|
|
|
|
|
* target's reverse set so no stale referrer survives.
|
|
|
|
|
|
*
|
|
|
|
|
|
* `incoming[noun.id]` itself is intentionally NOT maintained edge-by-edge
|
|
|
|
|
|
* inside the walk — both callers dispose of it wholesale afterwards
|
|
|
|
|
|
* (removeItem deletes it with the node; updateItem clears it and lets the
|
|
|
|
|
|
* relink rebuild it).
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns The ids of in-neighbors whose connection sets were modified
|
|
|
|
|
|
* (they dropped their edge to `noun` and may have been re-pruned), so a
|
|
|
|
|
|
* caller that persists per-node connections (updateItem) can mark them
|
|
|
|
|
|
* dirty / persist them. removeItem ignores the return — its persistence
|
|
|
|
|
|
* story lives in the caller's delete path, unchanged.
|
2025-08-26 12:32:21 -07:00
|
|
|
|
*/
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
private async unlinkNodeEdges(noun: HNSWNoun): Promise<Set<string>> {
|
|
|
|
|
|
const id = noun.id
|
|
|
|
|
|
const touchedReferrers = new Set<string>()
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
2026-06-29 11:06:22 -07:00
|
|
|
|
const incoming = this.ensureIncoming()
|
|
|
|
|
|
const referrers = incoming.get(id)
|
|
|
|
|
|
if (referrers) {
|
|
|
|
|
|
for (const [level, refSet] of referrers) {
|
|
|
|
|
|
for (const refId of Array.from(refSet)) {
|
|
|
|
|
|
const ref = this.nouns.get(refId)
|
|
|
|
|
|
if (ref && ref.connections.has(level)) {
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
// Drop the forward edge ref → id, then re-prune ref so the graph
|
|
|
|
|
|
// stays navigable.
|
2026-06-29 11:06:22 -07:00
|
|
|
|
ref.connections.get(level)!.delete(id)
|
|
|
|
|
|
await this.pruneConnections(ref, level)
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
touchedReferrers.add(refId)
|
2026-06-29 11:06:22 -07:00
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-29 11:06:22 -07:00
|
|
|
|
// id's OUTGOING edges disappear with it → drop id from each out-neighbor's
|
|
|
|
|
|
// reverse set so no stale referrer survives.
|
|
|
|
|
|
for (const [level, targets] of noun.connections) {
|
|
|
|
|
|
for (const target of targets) {
|
|
|
|
|
|
this.removeIncoming(target, level, id)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
return touchedReferrers
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Remove an item from the index
|
2026-08-10 09:29:06 -07:00
|
|
|
|
*
|
|
|
|
|
|
* @param generation - Brainy's commit generation for this removal (contract
|
|
|
|
|
|
* parity with `VectorIndexProvider.removeItem`). Accepted and ignored —
|
|
|
|
|
|
* this JS index removes immediately; a native provider records the
|
|
|
|
|
|
* tombstone at this generation.
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
*/
|
2026-08-10 09:29:06 -07:00
|
|
|
|
public async removeItem(id: string, generation?: bigint): Promise<boolean> {
|
|
|
|
|
|
void generation // Contract parity — the JS index keeps no per-write log.
|
fix(index): the flicker window dies — atomic in-place vector update; lazy open honors every provider's not-ready report; the Path Registry twin table
DP6/DP8 of the Path Registry (BRAINY-PROD-LATENCY-TRIAD, the proven flicker
mechanism): update paths staged RemoveFromVectorIndex then AddToVectorIndex
as two separately-awaited transaction ops — between them a live row was in
NEITHER index (dark to semantic recall, fine in metadata list). The native
pair widened that window to seconds in production before their side's
visibility-commit fix; the structural cure lands here:
- hnswIndex.updateItem: absent → add; SAME vector → pure no-op (the
production shape — a type-only update re-indexed an unchanged vector,
remove+add did pure damage); changed vector → the node NEVER leaves the
index: synchronous vector swap first (every query from that instant sees
correct distances), then unlink/relink at the node's existing level via
shared internals (linkNode/unlinkNodeEdges refactored out of add/remove;
entry point and maxLevel provably unchanged).
- ReplaceInVectorIndexOperation: ONE transaction leg; feature-detects
provider updateItem (native seam flagged — their side ships updateItem,
then the adjacent remove+add fallback is dead code). Both update staging
sites swapped; delete sites untouched.
- LAZY-OPEN GATE (fleet adoption find, SELF-ENGINE-PAIR-STANDARD): under
disableAutoRebuild, ensureIndexesLoaded assessed ONLY the vector index —
a not-ready native METADATA provider never blocked the completion latch
and every find() silently returned [] on a populated store. All three
providers now vote; any not-ready report falls through to the rebuild.
- docs/path-registry.md: brainy's twin table for the 32 shared path IDs —
service class, budgets, lifecycle, narration, and the cited pin per row;
owed rows named (LC4 doors-open migration, MT4 yielding heals, LC7
downgrade contract) per the lifecycle-sprint choreography.
Pins: update-item-atomic 9/9 (visibility-atomic swap, reverse-index parity
vs fresh rebuild, entry-point invariants) · lazy-notready-honor 2/2.
Gates: unit 1928/1928 (148 files) · integration 760 · conformance 27/27.
2026-08-05 16:11:23 -07:00
|
|
|
|
if (!this.nouns.has(id)) {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const noun = this.nouns.get(id)!
|
|
|
|
|
|
|
|
|
|
|
|
// Unlink every edge touching the node (shared with updateItem's in-place
|
|
|
|
|
|
// relink — see unlinkNodeEdges). The returned touched-referrer set is
|
|
|
|
|
|
// ignored here: removeItem's persistence story lives in the caller's
|
|
|
|
|
|
// delete path, unchanged.
|
|
|
|
|
|
await this.unlinkNodeEdges(noun)
|
|
|
|
|
|
|
2026-06-29 11:06:22 -07:00
|
|
|
|
// Remove the noun + its reverse-index entry.
|
2025-08-26 12:32:21 -07:00
|
|
|
|
this.nouns.delete(id)
|
2026-06-29 11:06:22 -07:00
|
|
|
|
this.incoming?.delete(id)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
|
|
|
|
|
|
// If we removed the entry point, find a new one
|
|
|
|
|
|
if (this.entryPointId === id) {
|
|
|
|
|
|
if (this.nouns.size === 0) {
|
|
|
|
|
|
this.entryPointId = null
|
|
|
|
|
|
this.maxLevel = 0
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Find the noun with the highest level
|
|
|
|
|
|
let maxLevel = 0
|
|
|
|
|
|
let newEntryPointId = null
|
|
|
|
|
|
|
|
|
|
|
|
for (const [nounId, noun] of this.nouns.entries()) {
|
|
|
|
|
|
if (noun.connections.size === 0) continue // Skip nouns with no connections
|
|
|
|
|
|
|
|
|
|
|
|
const nounLevel = Math.max(...noun.connections.keys())
|
|
|
|
|
|
if (nounLevel >= maxLevel) {
|
|
|
|
|
|
maxLevel = nounLevel
|
|
|
|
|
|
newEntryPointId = nounId
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.entryPointId = newEntryPointId
|
|
|
|
|
|
this.maxLevel = maxLevel
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get nouns with pagination
|
|
|
|
|
|
* @param options Pagination options
|
|
|
|
|
|
* @returns Object containing paginated nouns and pagination info
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getNounsPaginated(
|
|
|
|
|
|
options: {
|
|
|
|
|
|
offset?: number
|
|
|
|
|
|
limit?: number
|
|
|
|
|
|
filter?: (noun: HNSWNoun) => boolean
|
|
|
|
|
|
} = {}
|
|
|
|
|
|
): {
|
|
|
|
|
|
items: Map<string, HNSWNoun>
|
|
|
|
|
|
totalCount: number
|
|
|
|
|
|
hasMore: boolean
|
|
|
|
|
|
} {
|
|
|
|
|
|
const offset = options.offset || 0
|
|
|
|
|
|
const limit = options.limit || 100
|
|
|
|
|
|
const filter = options.filter || (() => true)
|
|
|
|
|
|
|
|
|
|
|
|
// Get all noun entries
|
|
|
|
|
|
const entries = [...this.nouns.entries()]
|
|
|
|
|
|
|
|
|
|
|
|
// Apply filter if provided
|
|
|
|
|
|
const filteredEntries = entries.filter(([_, noun]) => filter(noun))
|
|
|
|
|
|
|
|
|
|
|
|
// Get total count after filtering
|
|
|
|
|
|
const totalCount = filteredEntries.length
|
|
|
|
|
|
|
|
|
|
|
|
// Apply pagination
|
|
|
|
|
|
const paginatedEntries = filteredEntries.slice(offset, offset + limit)
|
|
|
|
|
|
|
|
|
|
|
|
// Check if there are more items
|
|
|
|
|
|
const hasMore = offset + limit < totalCount
|
|
|
|
|
|
|
|
|
|
|
|
// Create a new map with the paginated entries
|
|
|
|
|
|
const items = new Map(paginatedEntries)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
items,
|
|
|
|
|
|
totalCount,
|
|
|
|
|
|
hasMore
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Clear the index
|
|
|
|
|
|
*/
|
|
|
|
|
|
public clear(): void {
|
|
|
|
|
|
this.nouns.clear()
|
2026-06-29 11:06:22 -07:00
|
|
|
|
this.incoming = null // reverse index no longer reflects the (now empty) graph
|
2025-08-26 12:32:21 -07:00
|
|
|
|
this.entryPointId = null
|
|
|
|
|
|
this.maxLevel = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the size of the index
|
|
|
|
|
|
*/
|
|
|
|
|
|
public size(): number {
|
|
|
|
|
|
return this.nouns.size
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the distance function used by the index
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getDistanceFunction(): DistanceFunction {
|
|
|
|
|
|
return this.distanceFunction
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the entry point ID
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getEntryPointId(): string | null {
|
|
|
|
|
|
return this.entryPointId
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the maximum level
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getMaxLevel(): number {
|
|
|
|
|
|
return this.maxLevel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the dimension
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getDimension(): number | null {
|
|
|
|
|
|
return this.dimension
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get the configuration
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getConfig(): HNSWConfig {
|
|
|
|
|
|
return { ...this.config }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Get vector safely (always uses adaptive caching via UnifiedCache)
|
|
|
|
|
|
*
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Production-grade adaptive caching:
|
2025-10-10 14:09:30 -07:00
|
|
|
|
* - Vector already loaded: Returns immediately (O(1))
|
|
|
|
|
|
* - Vector in cache: Loads from UnifiedCache (O(1) hash lookup)
|
|
|
|
|
|
* - Vector on disk: Loads from storage → UnifiedCache (O(disk))
|
|
|
|
|
|
* - Cost-aware caching: UnifiedCache manages memory competition
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param noun The HNSW noun (may have empty vector if not yet loaded)
|
|
|
|
|
|
* @returns Promise<Vector> The vector (loaded on-demand if needed)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async getVectorSafe(noun: HNSWNoun): Promise<Vector> {
|
|
|
|
|
|
// Vector already in memory
|
|
|
|
|
|
if (noun.vector.length > 0) {
|
|
|
|
|
|
return noun.vector
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Load from UnifiedCache with storage fallback
|
|
|
|
|
|
const cacheKey = `hnsw:vector:${noun.id}`
|
|
|
|
|
|
|
|
|
|
|
|
const vector = await this.unifiedCache.get(cacheKey, async () => {
|
|
|
|
|
|
if (!this.storage) {
|
|
|
|
|
|
throw new Error('Storage not available for vector loading')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const loaded = await this.storage.getNounVector(noun.id)
|
|
|
|
|
|
if (!loaded) {
|
|
|
|
|
|
throw new Error(`Vector not found for noun ${noun.id}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add to UnifiedCache with cost-aware eviction
|
|
|
|
|
|
// This competes fairly with Graph and Metadata indexes
|
|
|
|
|
|
this.unifiedCache.set(
|
|
|
|
|
|
cacheKey,
|
|
|
|
|
|
loaded,
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
'vectors', // Type for fairness monitoring
|
2025-10-10 14:09:30 -07:00
|
|
|
|
loaded.length * 4, // Size in bytes (float32)
|
|
|
|
|
|
50 // Rebuild cost in ms (moderate priority)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return loaded
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return vector
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Get vector synchronously if available in memory
|
2025-10-10 14:09:30 -07:00
|
|
|
|
*
|
|
|
|
|
|
* Sync fast path optimization:
|
|
|
|
|
|
* - Vector in memory: Returns immediately (zero overhead)
|
|
|
|
|
|
* - Vector in cache: Returns from UnifiedCache synchronously
|
|
|
|
|
|
* - Returns null if vector not available (caller must handle async path)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Use for sync fast path in distance calculations - eliminates async overhead
|
|
|
|
|
|
* when vectors are already cached.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param noun The HNSW noun
|
|
|
|
|
|
* @returns Vector | null - vector if in memory/cache, null if needs async load
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getVectorSync(noun: HNSWNoun): Vector | null {
|
|
|
|
|
|
// Vector already in memory
|
|
|
|
|
|
if (noun.vector.length > 0) {
|
|
|
|
|
|
return noun.vector
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Try sync cache lookup
|
|
|
|
|
|
const cacheKey = `hnsw:vector:${noun.id}`
|
|
|
|
|
|
const vector = this.unifiedCache.getSync(cacheKey)
|
|
|
|
|
|
|
|
|
|
|
|
return vector || null
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Preload multiple vectors in parallel via UnifiedCache
|
|
|
|
|
|
*
|
|
|
|
|
|
* Optimization for search operations:
|
|
|
|
|
|
* - Loads all candidate vectors before distance calculations
|
|
|
|
|
|
* - Reduces serial disk I/O (parallel loads are faster)
|
|
|
|
|
|
* - Uses UnifiedCache's request coalescing to prevent stampede
|
|
|
|
|
|
* - Always active (no "mode" check) for optimal performance
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param nodeIds Array of node IDs to preload
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async preloadVectors(nodeIds: string[]): Promise<void> {
|
|
|
|
|
|
if (nodeIds.length === 0) return
|
|
|
|
|
|
|
2026-06-10 09:40:38 -07:00
|
|
|
|
// Per-entity storage load via UnifiedCache request coalescing.
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const promises = nodeIds.map(async (id) => {
|
|
|
|
|
|
const cacheKey = `hnsw:vector:${id}`
|
|
|
|
|
|
return this.unifiedCache.get(cacheKey, async () => {
|
|
|
|
|
|
if (!this.storage) return null
|
|
|
|
|
|
|
|
|
|
|
|
const vector = await this.storage.getNounVector(id)
|
|
|
|
|
|
if (vector) {
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
this.unifiedCache.set(cacheKey, vector, 'vectors', vector.length * 4, 50)
|
2025-10-10 14:09:30 -07:00
|
|
|
|
}
|
|
|
|
|
|
return vector
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
await Promise.all(promises)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Calculate distance with sync fast path
|
2025-10-10 14:09:30 -07:00
|
|
|
|
*
|
|
|
|
|
|
* Eliminates async overhead when vectors are in memory:
|
|
|
|
|
|
* - Sync path: Vector in memory → returns number (zero overhead)
|
|
|
|
|
|
* - Async path: Vector needs loading → returns Promise<number>
|
|
|
|
|
|
*
|
|
|
|
|
|
* Callers must handle union type: `const dist = await Promise.resolve(distance)`
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param queryVector The query vector
|
|
|
|
|
|
* @param noun The target noun (may have empty vector in lazy mode)
|
|
|
|
|
|
* @returns number | Promise<number> - sync when cached, async when needs load
|
|
|
|
|
|
*/
|
|
|
|
|
|
private distanceSafe(queryVector: Vector, noun: HNSWNoun): number | Promise<number> {
|
|
|
|
|
|
// Try sync fast path
|
|
|
|
|
|
const nounVector = this.getVectorSync(noun)
|
|
|
|
|
|
|
|
|
|
|
|
if (nounVector !== null) {
|
|
|
|
|
|
// SYNC PATH: Vector in memory - zero async overhead
|
|
|
|
|
|
return this.distanceFunction(queryVector, nounVector)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ASYNC PATH: Vector needs loading from storage
|
|
|
|
|
|
return this.getVectorSafe(noun).then(loadedVector =>
|
|
|
|
|
|
this.distanceFunction(queryVector, loadedVector)
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Get all nodes at a specific level for clustering
|
|
|
|
|
|
* This enables O(n) clustering using HNSW's natural hierarchy
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getNodesAtLevel(level: number): HNSWNoun[] {
|
|
|
|
|
|
const nodesAtLevel: HNSWNoun[] = []
|
|
|
|
|
|
|
|
|
|
|
|
for (const noun of this.nouns.values()) {
|
|
|
|
|
|
// A noun exists at level L if it has connections at that level or higher
|
|
|
|
|
|
if (noun.level >= level) {
|
|
|
|
|
|
nodesAtLevel.push(noun)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return nodesAtLevel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 11:15:17 -07:00
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Rebuild HNSW index from persisted graph data
|
2025-10-10 11:15:17 -07:00
|
|
|
|
*
|
|
|
|
|
|
* This is a production-grade O(N) rebuild that restores the pre-computed graph structure
|
|
|
|
|
|
* from storage. Much faster than re-building which is O(N log N).
|
|
|
|
|
|
*
|
|
|
|
|
|
* Designed for millions of entities with:
|
|
|
|
|
|
* - Cursor-based pagination (no memory overflow)
|
|
|
|
|
|
* - Batch processing (configurable batch size)
|
|
|
|
|
|
* - Progress reporting (optional callback)
|
|
|
|
|
|
* - Error recovery (continues on partial failures)
|
|
|
|
|
|
* - Lazy mode support (memory-efficient for constrained environments)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param options Rebuild options
|
|
|
|
|
|
* @returns Promise that resolves when rebuild is complete
|
|
|
|
|
|
*/
|
|
|
|
|
|
public async rebuild(options: {
|
2025-10-10 14:09:30 -07:00
|
|
|
|
lazy?: boolean // DEPRECATED: Auto-detected based on memory. Override only for testing.
|
2025-10-10 11:15:17 -07:00
|
|
|
|
batchSize?: number // Entities per batch (default 1000, tune for your environment)
|
|
|
|
|
|
onProgress?: (loaded: number, total: number) => void // Progress callback
|
|
|
|
|
|
} = {}): Promise<void> {
|
|
|
|
|
|
if (!this.storage) {
|
2025-10-10 14:09:30 -07:00
|
|
|
|
prodLog.warn('HNSW rebuild skipped: no storage adapter configured')
|
2025-10-10 11:15:17 -07:00
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const batchSize = options.batchSize || 1000
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Step 1: Clear existing in-memory index
|
|
|
|
|
|
this.clear()
|
|
|
|
|
|
|
|
|
|
|
|
// Step 2: Load system data (entry point, max level)
|
2026-06-11 14:51:00 -07:00
|
|
|
|
const systemData = await this.storage.getHNSWSystem()
|
2025-10-10 11:15:17 -07:00
|
|
|
|
if (systemData) {
|
|
|
|
|
|
this.entryPointId = systemData.entryPointId
|
|
|
|
|
|
this.maxLevel = systemData.maxLevel
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(index): watermark stamps on every TS projection — adopt/catchup/rescan verdicts at load, stamp-after-data
Every persisted projection artifact (metadata field indexes + column
segments, HNSW node records, graph adjacency LSM trees) now carries a
stamp asserting 'this state reflects every committed generation ≤ W,
atomically' — written LAST in each owner's flush (stamp-after-data: a
crash between data and stamp = unstamped = rescan, never trust). At load,
each owner computes the three-way verdict: stamped==committed → adopt
(zero work) · behind → catchup (gap reported) · above/unstamped → RESCAN,
loudly. Legacy artifacts re-derive once, then are stamped forever. Shared
law in projectionWatermark.ts (the aggregation verdict machinery,
generalized); vector artifacts carry model dimensions. Verdicts are
computed and exposed (watermark()/watermarkVerdict()/watermarkGap());
rebuild triggers unchanged — acting on 'catchup' is the fold train.
Pins: 22 unit (7 metadata · 8 hnsw · 7 graph, incl. spy-order
stamp-after-data) + the end-to-end reopen-adopts pin.
2026-08-10 10:55:11 -07:00
|
|
|
|
// Step 2b: Watermark verdict for the persisted artifact — computed and
|
|
|
|
|
|
// exposed only (today's rebuild flow is unchanged; this rebuild IS the
|
|
|
|
|
|
// re-derive a 'rescan' verdict asks for).
|
|
|
|
|
|
await this.loadWatermarkVerdict(systemData !== null)
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// Step 3: Determine preloading strategy (adaptive caching)
|
|
|
|
|
|
// Check if vectors should be preloaded at init or loaded on-demand
|
|
|
|
|
|
const stats = await this.storage.getStatistics()
|
|
|
|
|
|
const entityCount = stats?.totalNodes || 0
|
|
|
|
|
|
|
|
|
|
|
|
// Estimate memory needed for all vectors (384 dims × 4 bytes = 1536 bytes/vector)
|
|
|
|
|
|
const vectorMemory = entityCount * 1536
|
|
|
|
|
|
|
|
|
|
|
|
// Get available cache size (80% threshold - preload only if fits comfortably)
|
|
|
|
|
|
const cacheStats = this.unifiedCache.getStats()
|
|
|
|
|
|
const availableCache = cacheStats.maxSize * 0.80
|
|
|
|
|
|
|
|
|
|
|
|
const shouldPreload = vectorMemory < availableCache
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldPreload) {
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`HNSW: Preloading ${entityCount.toLocaleString()} vectors at init ` +
|
|
|
|
|
|
`(${(vectorMemory / 1024 / 1024).toFixed(1)}MB < ${(availableCache / 1024 / 1024).toFixed(1)}MB cache)`
|
|
|
|
|
|
)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`HNSW: Adaptive caching for ${entityCount.toLocaleString()} vectors ` +
|
|
|
|
|
|
`(${(vectorMemory / 1024 / 1024).toFixed(1)}MB > ${(availableCache / 1024 / 1024).toFixed(1)}MB cache) - loading on-demand`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 15:05:02 -07:00
|
|
|
|
// Step 4: Load all HNSW nodes at once. Brainy 8.0 ships filesystem +
|
2026-06-11 10:42:34 -07:00
|
|
|
|
// memory storage only; the cloud-pagination rebuild path was deleted
|
|
|
|
|
|
// alongside the cloud adapters.
|
2025-10-23 09:49:48 -07:00
|
|
|
|
const storageType = this.storage?.constructor.name || ''
|
2025-10-10 11:15:17 -07:00
|
|
|
|
let loadedCount = 0
|
|
|
|
|
|
let totalCount: number | undefined = undefined
|
|
|
|
|
|
|
2026-06-09 15:05:02 -07:00
|
|
|
|
{
|
|
|
|
|
|
prodLog.info(`HNSW: Load all nodes at once (${storageType})`)
|
2025-10-23 09:49:48 -07:00
|
|
|
|
|
2026-06-11 14:51:00 -07:00
|
|
|
|
const result = await this.storage.getNounsWithPagination({
|
|
|
|
|
|
limit: 10000000, // Effectively unlimited for local development
|
|
|
|
|
|
offset: 0
|
2025-10-10 11:15:17 -07:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-23 09:49:48 -07:00
|
|
|
|
totalCount = result.totalCount || result.items.length
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
2025-10-23 09:49:48 -07:00
|
|
|
|
// Process all nouns at once
|
2025-10-10 11:15:17 -07:00
|
|
|
|
for (const nounData of result.items) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Load HNSW graph data for this entity
|
2026-06-11 14:51:00 -07:00
|
|
|
|
const hnswData = await this.storage.getVectorIndexData(nounData.id)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
|
|
|
|
|
if (!hnswData) {
|
|
|
|
|
|
// No HNSW data - skip (might be entity added before persistence)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-31 12:41:53 -08:00
|
|
|
|
// Determine if vector should be kept in memory
|
|
|
|
|
|
const keepVector = shouldPreload && this.vectorStorageMode !== 'lazy'
|
|
|
|
|
|
|
2025-10-10 11:15:17 -07:00
|
|
|
|
// Create noun object with restored connections
|
|
|
|
|
|
const noun: HNSWNoun = {
|
|
|
|
|
|
id: nounData.id,
|
2026-01-31 12:41:53 -08:00
|
|
|
|
vector: keepVector ? nounData.vector : [], // Preload if dataset is small and not lazy
|
2025-10-10 11:15:17 -07:00
|
|
|
|
connections: new Map(),
|
|
|
|
|
|
level: hnswData.level
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat: graph link compression — delta-varint connections (2.4.0 #3)
Cortex registers a graph:compression provider exposing `encode`/`decode`
for HNSW connection lists (delta-varint, ~4× smaller edges than the
JSON-UUID-array shape brainy has persisted since the beginning). This
wires the consumer side without changing the saveHNSWData/getHNSWData
adapter signatures.
Architecture:
- NEW ConnectionsCodec (src/hnsw/connectionsCodec.ts) — translates between
UUID-keyed Map<level, Set<UUID>> and a single compact per-node buffer
via the provider + stable EntityIdMapper. Custom wire format:
[count: u8] [[level: u8] [len: u32 LE] [bytes...]]*. One blob per node
regardless of level count, so the storage I/O is identical to the
legacy path (one saveHNSWData call) plus a single saveBinaryBlob.
- HNSWIndex changes — a `connectionsCodec: ConnectionsCodec | null` field
with `setConnectionsCodec()` setter. THREE save sites (deferred flush,
immediate-mode entity persist, immediate-mode neighbor updates) now go
through a single `persistNodeConnections(nodeId, noun)` helper: when the
codec is wired AND the storage adapter exposes saveBinaryBlob, it
encodes the connections, stores them at `_hnsw_conn/<nodeId>`, and
records `connections: {}` in saveHNSWData as the marker. Otherwise the
legacy JSON-array path is taken. TWO load sites use a matching
`restoreNodeConnections` helper that tries loadBinaryBlob first and
falls back to the legacy hnswData.connections field on miss / decode
error. Format convergence is lazy: pre-2.4.0 nodes still load via the
legacy path, then write the compressed form on next dirty save.
- brainy.ts wireConnectionsCodec — runs alongside wireMmapVectorBackend
during init. Activates when (a) the graph:compression provider is
registered and (b) the metadata index exposes its idMapper. Unlike the
mmap-vector backend, this layer engages on EVERY brainy 7.25.0
adapter — the blob primitive itself is universal; only the codec
presence gates activation.
- Provider interface GraphCompressionProvider in plugin.ts — encode +
decode static signatures. Brainy depends on the interface; cortex's
registered { encode: encodeConnections, decode: decodeConnections }
satisfies it structurally.
Tests (1437 total, +4 vs prior tip):
- tests/unit/hnsw/connections-codec.test.ts — 4 unit tests with a JSON
mock provider: single-level round-trip, empty-Map round-trip (one-byte
buffer), multi-level fan-out round-trip, and silent-drop of UUIDs the
decoding idMapper no longer knows. The real cross-language byte
format is exercised when cortex 2.4.0 wires its delta-varint
encode/decode in.
This completes the brainy half of the 2.4.0 storage foundation: stable
ids (#23), mmap vectors (#24), graph link compression (#25), and
column-store interchange (#26). Coordinated release as brainy 7.26.0 +
cortex 2.4.0 follows.
2026-05-28 10:37:01 -07:00
|
|
|
|
// Restore connections from persisted data — compressed blob path
|
|
|
|
|
|
// first, legacy JSON-array fallback for indexes written before
|
|
|
|
|
|
// graph link compression landed.
|
|
|
|
|
|
await this.restoreNodeConnections(nounData.id, hnswData, noun)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
|
|
|
|
|
|
// Add to in-memory index
|
|
|
|
|
|
this.nouns.set(nounData.id, noun)
|
|
|
|
|
|
|
|
|
|
|
|
// Track high-level nodes for O(1) entry point selection
|
|
|
|
|
|
if (noun.level >= 2 && noun.level <= this.MAX_TRACKED_LEVELS) {
|
|
|
|
|
|
if (!this.highLevelNodes.has(noun.level)) {
|
|
|
|
|
|
this.highLevelNodes.set(noun.level, new Set())
|
|
|
|
|
|
}
|
|
|
|
|
|
this.highLevelNodes.get(noun.level)!.add(nounData.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
loadedCount++
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
// Log error but continue (robust error recovery)
|
|
|
|
|
|
console.error(`Failed to rebuild HNSW data for ${nounData.id}:`, error)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-23 09:49:48 -07:00
|
|
|
|
// Report final progress
|
2025-10-10 11:15:17 -07:00
|
|
|
|
if (options.onProgress && totalCount !== undefined) {
|
|
|
|
|
|
options.onProgress(loadedCount, totalCount)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 15:05:02 -07:00
|
|
|
|
prodLog.info(`HNSW: Loaded ${loadedCount.toLocaleString()} nodes (${storageType})`)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-27 15:38:21 -08:00
|
|
|
|
// Step 5: CRITICAL - Recover entry point if missing)
|
2025-11-25 14:34:19 -08:00
|
|
|
|
// This ensures consistency even if getHNSWSystem() returned null
|
|
|
|
|
|
if (this.nouns.size > 0 && this.entryPointId === null) {
|
2025-11-25 15:36:49 -08:00
|
|
|
|
prodLog.warn('HNSW rebuild: Entry point was null after loading nouns - recovering with O(1) lookup')
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
2025-11-25 15:36:49 -08:00
|
|
|
|
const { id: recoveredId, level: recoveredLevel } = this.recoverEntryPointO1()
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
2025-11-25 15:36:49 -08:00
|
|
|
|
this.entryPointId = recoveredId
|
|
|
|
|
|
this.maxLevel = recoveredLevel
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
2025-11-25 15:36:49 -08:00
|
|
|
|
prodLog.info(`HNSW entry point recovered: ${recoveredId} at level ${recoveredLevel}`)
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
|
|
|
|
|
// Persist recovered state to prevent future recovery
|
2025-11-25 15:36:49 -08:00
|
|
|
|
if (this.storage && recoveredId) {
|
2025-11-25 14:34:19 -08:00
|
|
|
|
await this.storage.saveHNSWSystem({
|
|
|
|
|
|
entryPointId: this.entryPointId,
|
|
|
|
|
|
maxLevel: this.maxLevel
|
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
prodLog.error('Failed to persist recovered HNSW system data:', error)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Step 6: Validate entry point exists if set (handles stale/deleted entry point)
|
|
|
|
|
|
if (this.entryPointId && !this.nouns.has(this.entryPointId)) {
|
2025-11-25 15:36:49 -08:00
|
|
|
|
prodLog.warn(`HNSW: Entry point ${this.entryPointId} not found in loaded nouns - recovering with O(1) lookup`)
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
2025-11-25 15:36:49 -08:00
|
|
|
|
const { id: recoveredId, level: recoveredLevel } = this.recoverEntryPointO1()
|
2025-11-25 14:34:19 -08:00
|
|
|
|
this.entryPointId = recoveredId
|
2025-11-25 15:36:49 -08:00
|
|
|
|
this.maxLevel = recoveredLevel
|
2025-11-25 14:34:19 -08:00
|
|
|
|
|
|
|
|
|
|
// Persist corrected state
|
|
|
|
|
|
if (this.storage && recoveredId) {
|
|
|
|
|
|
await this.storage.saveHNSWSystem({
|
|
|
|
|
|
entryPointId: this.entryPointId,
|
|
|
|
|
|
maxLevel: this.maxLevel
|
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
|
prodLog.error('Failed to persist corrected HNSW system data:', error)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const cacheInfo = shouldPreload
|
|
|
|
|
|
? ` (vectors preloaded)`
|
|
|
|
|
|
: ` (adaptive caching - vectors loaded on-demand)`
|
|
|
|
|
|
|
|
|
|
|
|
prodLog.info(
|
|
|
|
|
|
`✅ HNSW index rebuilt: ${loadedCount.toLocaleString()} entities, ` +
|
|
|
|
|
|
`${this.maxLevel + 1} levels, entry point: ${this.entryPointId || 'none'}${cacheInfo}`
|
2025-10-10 11:15:17 -07:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2025-10-10 14:09:30 -07:00
|
|
|
|
prodLog.error('HNSW rebuild failed:', error)
|
2025-10-10 11:15:17 -07:00
|
|
|
|
throw new Error(`Failed to rebuild HNSW index: ${error}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Get level statistics for understanding the hierarchy
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getLevelStats(): Array<{ level: number; nodeCount: number; avgConnections: number }> {
|
|
|
|
|
|
const levelStats = new Map<number, { count: number; totalConnections: number }>()
|
|
|
|
|
|
|
|
|
|
|
|
for (const noun of this.nouns.values()) {
|
|
|
|
|
|
for (let level = 0; level <= noun.level; level++) {
|
|
|
|
|
|
if (!levelStats.has(level)) {
|
|
|
|
|
|
levelStats.set(level, { count: 0, totalConnections: 0 })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const stats = levelStats.get(level)!
|
|
|
|
|
|
stats.count++
|
|
|
|
|
|
stats.totalConnections += noun.connections.get(level)?.size || 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Array.from(levelStats.entries()).map(([level, stats]) => ({
|
|
|
|
|
|
level,
|
|
|
|
|
|
nodeCount: stats.count,
|
|
|
|
|
|
avgConnections: stats.count > 0 ? stats.totalConnections / stats.count : 0
|
|
|
|
|
|
})).sort((a, b) => a.level - b.level)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Get index health metrics
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getIndexHealth(): {
|
|
|
|
|
|
averageConnections: number
|
|
|
|
|
|
layerDistribution: number[]
|
|
|
|
|
|
maxLayer: number
|
|
|
|
|
|
totalNodes: number
|
|
|
|
|
|
} {
|
|
|
|
|
|
let totalConnections = 0
|
|
|
|
|
|
const layerCounts = new Array(this.maxLevel + 1).fill(0)
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// Count connections and layer distribution
|
|
|
|
|
|
this.nouns.forEach(noun => {
|
|
|
|
|
|
// Count connections at each layer
|
|
|
|
|
|
for (let level = 0; level <= noun.level; level++) {
|
|
|
|
|
|
totalConnections += noun.connections.get(level)?.size || 0
|
|
|
|
|
|
layerCounts[level]++
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const totalNodes = this.nouns.size
|
|
|
|
|
|
const averageConnections = totalNodes > 0 ? totalConnections / totalNodes : 0
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
return {
|
|
|
|
|
|
averageConnections,
|
|
|
|
|
|
layerDistribution: layerCounts,
|
|
|
|
|
|
maxLayer: this.maxLevel,
|
|
|
|
|
|
totalNodes
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
/**
|
2026-01-27 15:38:21 -08:00
|
|
|
|
* Get cache performance statistics for monitoring and diagnostics
|
2025-10-10 14:09:30 -07:00
|
|
|
|
*
|
|
|
|
|
|
* Production-grade monitoring:
|
|
|
|
|
|
* - Adaptive caching strategy (preloading vs on-demand)
|
|
|
|
|
|
* - UnifiedCache performance (hits, misses, evictions)
|
|
|
|
|
|
* - HNSW-specific cache statistics
|
|
|
|
|
|
* - Fair competition metrics across all indexes
|
|
|
|
|
|
* - Actionable recommendations for tuning
|
|
|
|
|
|
*
|
|
|
|
|
|
* Use this to:
|
|
|
|
|
|
* - Diagnose performance issues (low hit rate = increase cache)
|
|
|
|
|
|
* - Monitor memory competition (fairness violations = adjust costs)
|
|
|
|
|
|
* - Verify adaptive caching decisions (memory estimates vs actual)
|
|
|
|
|
|
* - Track cache efficiency over time
|
|
|
|
|
|
*
|
|
|
|
|
|
* @returns Comprehensive caching and performance statistics
|
|
|
|
|
|
*/
|
|
|
|
|
|
public getCacheStats(): {
|
|
|
|
|
|
cachingStrategy: 'preloaded' | 'on-demand'
|
|
|
|
|
|
autoDetection: {
|
|
|
|
|
|
entityCount: number
|
|
|
|
|
|
estimatedVectorMemoryMB: number
|
|
|
|
|
|
availableCacheMB: number
|
|
|
|
|
|
threshold: number
|
|
|
|
|
|
rationale: string
|
|
|
|
|
|
}
|
|
|
|
|
|
unifiedCache: {
|
|
|
|
|
|
totalSize: number
|
|
|
|
|
|
maxSize: number
|
|
|
|
|
|
utilizationPercent: number
|
|
|
|
|
|
itemCount: number
|
|
|
|
|
|
hitRatePercent: number
|
|
|
|
|
|
totalAccessCount: number
|
|
|
|
|
|
}
|
|
|
|
|
|
hnswCache: {
|
|
|
|
|
|
vectorsInCache: number
|
|
|
|
|
|
cacheKeyPrefix: string
|
|
|
|
|
|
estimatedMemoryMB: number
|
|
|
|
|
|
}
|
|
|
|
|
|
fairness: {
|
|
|
|
|
|
hnswAccessCount: number
|
|
|
|
|
|
hnswAccessPercent: number
|
|
|
|
|
|
totalAccessCount: number
|
|
|
|
|
|
fairnessViolation: boolean
|
|
|
|
|
|
}
|
|
|
|
|
|
recommendations: string[]
|
|
|
|
|
|
} {
|
|
|
|
|
|
// Get UnifiedCache stats
|
|
|
|
|
|
const cacheStats = this.unifiedCache.getStats()
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate entity and memory estimates
|
|
|
|
|
|
const entityCount = this.nouns.size
|
|
|
|
|
|
const vectorDimension = this.dimension || 384
|
|
|
|
|
|
const bytesPerVector = vectorDimension * 4 // float32
|
|
|
|
|
|
const estimatedVectorMemoryMB = (entityCount * bytesPerVector) / (1024 * 1024)
|
|
|
|
|
|
const availableCacheMB = (cacheStats.maxSize * 0.8) / (1024 * 1024) // 80% threshold
|
|
|
|
|
|
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
// Calculate vector-index-specific cache stats
|
|
|
|
|
|
const vectorsInCache = cacheStats.typeCounts.vectors || 0
|
|
|
|
|
|
const hnswMemoryBytes = cacheStats.typeSizes.vectors || 0
|
2025-10-10 14:09:30 -07:00
|
|
|
|
|
|
|
|
|
|
// Calculate fairness metrics
|
refactor(8.0): final cleanup — drop HnswProvider alias + config.hnsw + 'hnsw' surface (scaffold step 6)
Step 6 of the brainy 8.0 rename scaffolding. Removes every legacy 'hnsw'-
flavoured public name introduced as a compat shim in steps 1-5. The
algorithm-neutral surface is now the only surface.
REMOVED — PHASE A (type aliases)
src/plugin.ts
- Removed `export type HnswProvider = VectorIndexProvider` alias.
- Removed `export type DiskAnnProvider = VectorIndexProvider` alias.
src/index.ts
- Removed `export const HNSWIndex = JsHnswVectorIndex` alias.
src/types/brainy.types.ts
- Removed `BrainyStats.indexHealth.hnsw` field (the new `vector` field
carries the same boolean).
src/brainy.ts
- `stats()` no longer emits the legacy `hnsw` field on `indexHealth`.
REMOVED — PHASE B (storage adapter rename, 8 adapters)
Repo-wide rename: `saveHNSWData` → `saveVectorIndexData` and
`getHNSWData` → `getVectorIndexData` across:
- src/storage/adapters/baseStorageAdapter.ts (abstract declarations)
- src/storage/adapters/fileSystemStorage.ts
- src/storage/adapters/gcsStorage.ts
- src/storage/adapters/r2Storage.ts
- src/storage/adapters/s3CompatibleStorage.ts
- src/storage/adapters/azureBlobStorage.ts
- src/storage/adapters/opfsStorage.ts
- src/storage/adapters/memoryStorage.ts
- src/storage/adapters/historicalStorageAdapter.ts
- src/brainy.ts (call sites)
- src/hnsw/hnswIndex.ts + src/hnsw/typeAwareHNSWIndex.ts (call sites)
The default-delegation wrappers added in scaffold step 4 are removed
(would have been duplicate declarations after the rename).
REMOVED — PHASE C (cache category 'hnsw')
src/utils/unifiedCache.ts
- Cache-category union narrowed from
'hnsw' | 'vectors' | 'metadata' | 'embedding' | 'other'
to
'vectors' | 'metadata' | 'embedding' | 'other'
- typeAccessCounts, typeSizes, typeCounts, accessRatios, sizeRatios,
and the fairness-check iterator all drop the 'hnsw' key.
- Per planning § 2.5: pre-8.0 'hnsw' cache entries are an in-memory
category. Cache is rebuildable; entries naturally don't exist after
a restart, so no migration path is needed.
src/hnsw/hnswIndex.ts
- 3 cache.set() call sites migrated from category 'hnsw' to 'vectors'.
- Cache key prefix `hnsw:vector:` → `vector:`.
- typeCounts/typeSizes/typeAccessCounts accessor renames
`.hnsw` → `.vectors`.
tests/unit/utils/unifiedCache-eviction.test.ts
- Test fixtures updated: cache.set(..., 'hnsw', ...) → cache.set(..., 'vectors', ...).
- Stats assertion `stats.typeSizes.hnsw` → `stats.typeSizes.vectors`.
REMOVED — PHASE D (config.hnsw)
src/types/brainy.types.ts
- Removed `BrainyConfig.hnsw` field entirely. The 8.0 surface is
`BrainyConfig.vector.{recall, quantization, vectorStorage, advanced}`.
src/brainy.ts
- normalizeConfig() no longer emits a `hnsw` field on Required<BrainyConfig>.
- setupIndex() rewired:
- Reads from `this.config.vector` (not `this.config.hnsw`).
- Calls `resolveJsHnswConfig(this.config.vector)` to translate the
`recall` preset into M / efConstruction / efSearch knobs (with
`advanced.hnsw` overrides winning when supplied).
- Imports `resolveJsHnswConfig` from './utils/recallPreset.js'.
NOT IN THIS COMMIT (deliberately)
- Persisted file path migration `_system/hnsw-*.json` →
`_system/vector-index-*.json`. Requires dual-read logic on boot
across 8 storage adapters. Per integration doc lines 531-539:
"Reader accepts either spelling on load; writer emits the new spelling
only; brains self-migrate on the next persist after upgrade." This is
a separate body of work and lands in a follow-up commit before 8.0 GA.
- `config.hnswPersistMode` top-level field is unchanged. It's not in
the rename inventory; a future commit can fold it into
`config.vector.persistMode` if desired.
- strictConfig enforcement wiring. The field is accepted by
normalizeConfig() with default 'warn'; the actual warning emission at
knob-mismatch sites lands when the config-resolution layer is touched
for the persisted-path migration.
VERIFICATION
- npx tsc --noEmit: clean
- npm test: 1468 / 1468 unit (one test fixture updated to use the new
category name; assertion still passes after fixture rename)
- npm run build: clean
The 8.0 PR's user-facing surface is now algorithm-neutral end-to-end:
VectorIndexProvider, config.vector.recall, JsHnswVectorIndex,
saveVectorIndexData, 'vectors' cache category, indexHealth.vector,
strictConfig — all standalone. No legacy 'hnsw' name remains in any
public type or method signature.
2026-06-09 13:28:53 -07:00
|
|
|
|
const hnswAccessCount = cacheStats.typeAccessCounts.vectors || 0
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const totalAccessCount = cacheStats.totalAccessCount
|
|
|
|
|
|
const hnswAccessPercent = totalAccessCount > 0 ? (hnswAccessCount / totalAccessCount) * 100 : 0
|
|
|
|
|
|
|
|
|
|
|
|
// Detect fairness violation (>90% cache with <10% access)
|
|
|
|
|
|
const hnswCachePercent = cacheStats.maxSize > 0 ? (hnswMemoryBytes / cacheStats.maxSize) * 100 : 0
|
|
|
|
|
|
const fairnessViolation = hnswCachePercent > 90 && hnswAccessPercent < 10
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate hit rate from cache
|
|
|
|
|
|
const hitRatePercent = (cacheStats.hitRate * 100) || 0
|
|
|
|
|
|
|
|
|
|
|
|
// Determine caching strategy (same logic as rebuild())
|
|
|
|
|
|
const cachingStrategy: 'preloaded' | 'on-demand' =
|
|
|
|
|
|
estimatedVectorMemoryMB < availableCacheMB ? 'preloaded' : 'on-demand'
|
|
|
|
|
|
|
|
|
|
|
|
// Generate actionable recommendations
|
|
|
|
|
|
const recommendations: string[] = []
|
|
|
|
|
|
|
|
|
|
|
|
if (cachingStrategy === 'on-demand' && hitRatePercent < 50) {
|
|
|
|
|
|
recommendations.push(
|
|
|
|
|
|
`Low cache hit rate (${hitRatePercent.toFixed(1)}%). Consider increasing UnifiedCache size for better performance`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cachingStrategy === 'preloaded' && estimatedVectorMemoryMB > availableCacheMB * 0.5) {
|
|
|
|
|
|
recommendations.push(
|
|
|
|
|
|
`Dataset growing (${estimatedVectorMemoryMB.toFixed(1)}MB). May switch to on-demand caching as entities increase`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (fairnessViolation) {
|
|
|
|
|
|
recommendations.push(
|
|
|
|
|
|
`Fairness violation: HNSW using ${hnswCachePercent.toFixed(1)}% cache with only ${hnswAccessPercent.toFixed(1)}% access`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (cacheStats.utilization > 0.95) {
|
|
|
|
|
|
recommendations.push(
|
|
|
|
|
|
`Cache utilization high (${(cacheStats.utilization * 100).toFixed(1)}%). Consider increasing cache size`
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (recommendations.length === 0) {
|
|
|
|
|
|
recommendations.push('All metrics healthy - no action needed')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
cachingStrategy,
|
|
|
|
|
|
autoDetection: {
|
|
|
|
|
|
entityCount,
|
|
|
|
|
|
estimatedVectorMemoryMB: parseFloat(estimatedVectorMemoryMB.toFixed(2)),
|
|
|
|
|
|
availableCacheMB: parseFloat(availableCacheMB.toFixed(2)),
|
|
|
|
|
|
threshold: 0.8, // 80% of UnifiedCache
|
|
|
|
|
|
rationale: cachingStrategy === 'preloaded'
|
|
|
|
|
|
? `Vectors preloaded at init (${estimatedVectorMemoryMB.toFixed(1)}MB < ${availableCacheMB.toFixed(1)}MB threshold)`
|
|
|
|
|
|
: `Adaptive on-demand loading (${estimatedVectorMemoryMB.toFixed(1)}MB > ${availableCacheMB.toFixed(1)}MB threshold)`
|
|
|
|
|
|
},
|
|
|
|
|
|
unifiedCache: {
|
|
|
|
|
|
totalSize: cacheStats.totalSize,
|
|
|
|
|
|
maxSize: cacheStats.maxSize,
|
|
|
|
|
|
utilizationPercent: parseFloat((cacheStats.utilization * 100).toFixed(2)),
|
|
|
|
|
|
itemCount: cacheStats.itemCount,
|
|
|
|
|
|
hitRatePercent: parseFloat(hitRatePercent.toFixed(2)),
|
|
|
|
|
|
totalAccessCount: cacheStats.totalAccessCount
|
|
|
|
|
|
},
|
|
|
|
|
|
hnswCache: {
|
|
|
|
|
|
vectorsInCache,
|
|
|
|
|
|
cacheKeyPrefix: 'hnsw:vector:',
|
|
|
|
|
|
estimatedMemoryMB: parseFloat((hnswMemoryBytes / (1024 * 1024)).toFixed(2))
|
|
|
|
|
|
},
|
|
|
|
|
|
fairness: {
|
|
|
|
|
|
hnswAccessCount,
|
|
|
|
|
|
hnswAccessPercent: parseFloat(hnswAccessPercent.toFixed(2)),
|
|
|
|
|
|
totalAccessCount,
|
|
|
|
|
|
fairnessViolation
|
|
|
|
|
|
},
|
|
|
|
|
|
recommendations
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Search within a specific layer
|
|
|
|
|
|
* Returns a map of noun IDs to distances, sorted by distance
|
|
|
|
|
|
*/
|
|
|
|
|
|
private async searchLayer(
|
|
|
|
|
|
queryVector: Vector,
|
|
|
|
|
|
entryPoint: HNSWNoun,
|
|
|
|
|
|
ef: number,
|
|
|
|
|
|
level: number,
|
|
|
|
|
|
filter?: (id: string) => Promise<boolean>
|
|
|
|
|
|
): Promise<Map<string, number>> {
|
|
|
|
|
|
// Set of visited nouns
|
|
|
|
|
|
const visited = new Set<string>([entryPoint.id])
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// OPTIMIZATION: Preload entry point vector
|
|
|
|
|
|
await this.preloadVectors([entryPoint.id])
|
|
|
|
|
|
|
|
|
|
|
|
// Check if entry point passes filter (with sync fast path)
|
|
|
|
|
|
const entryPointDistance = await Promise.resolve(this.distanceSafe(queryVector, entryPoint))
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const entryPointPasses = filter ? await filter(entryPoint.id) : true
|
|
|
|
|
|
|
|
|
|
|
|
// Priority queue of candidates (closest first)
|
|
|
|
|
|
const candidates = new Map<string, number>()
|
|
|
|
|
|
candidates.set(entryPoint.id, entryPointDistance)
|
|
|
|
|
|
|
|
|
|
|
|
// Priority queue of nearest neighbors found so far (closest first)
|
|
|
|
|
|
const nearest = new Map<string, number>()
|
|
|
|
|
|
if (entryPointPasses) {
|
|
|
|
|
|
nearest.set(entryPoint.id, entryPointDistance)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// While there are candidates to explore
|
|
|
|
|
|
while (candidates.size > 0) {
|
|
|
|
|
|
// Get closest candidate
|
|
|
|
|
|
const [closestId, closestDist] = [...candidates][0]
|
|
|
|
|
|
candidates.delete(closestId)
|
|
|
|
|
|
|
|
|
|
|
|
// If this candidate is farther than the farthest in our result set, we're done
|
|
|
|
|
|
const farthestInNearest = [...nearest][nearest.size - 1]
|
|
|
|
|
|
if (nearest.size >= ef && closestDist > farthestInNearest[1]) {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Explore neighbors of the closest candidate
|
|
|
|
|
|
const noun = this.nouns.get(closestId)
|
|
|
|
|
|
if (!noun) {
|
2025-10-10 14:09:30 -07:00
|
|
|
|
prodLog.error(`Noun with ID ${closestId} not found in searchLayer`)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
const connections = noun.connections.get(level) || new Set<string>()
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// OPTIMIZATION: Preload unvisited neighbor vectors in parallel
|
|
|
|
|
|
if (connections.size > 0) {
|
|
|
|
|
|
const unvisitedIds = Array.from(connections).filter(id => !visited.has(id))
|
|
|
|
|
|
if (unvisitedIds.length > 0) {
|
|
|
|
|
|
await this.preloadVectors(unvisitedIds)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// If we have enough connections and parallelization is enabled, use parallel distance calculation
|
|
|
|
|
|
if (this.useParallelization && connections.size >= 10) {
|
|
|
|
|
|
// Collect unvisited neighbors
|
|
|
|
|
|
const unvisitedNeighbors: Array<{ id: string; vector: Vector }> = []
|
|
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
if (!visited.has(neighborId)) {
|
|
|
|
|
|
visited.add(neighborId)
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) continue
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const neighborVector = await this.getVectorSafe(neighbor)
|
|
|
|
|
|
unvisitedNeighbors.push({ id: neighborId, vector: neighborVector })
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (unvisitedNeighbors.length > 0) {
|
|
|
|
|
|
// Calculate distances in parallel
|
|
|
|
|
|
const distances = await this.calculateDistancesInParallel(
|
|
|
|
|
|
queryVector,
|
|
|
|
|
|
unvisitedNeighbors
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
// Process the results
|
|
|
|
|
|
for (const { id, distance } of distances) {
|
|
|
|
|
|
// Apply filter if provided
|
|
|
|
|
|
const passes = filter ? await filter(id) : true
|
|
|
|
|
|
|
|
|
|
|
|
// Always add to candidates for graph traversal
|
|
|
|
|
|
candidates.set(id, distance)
|
|
|
|
|
|
|
|
|
|
|
|
// Only add to nearest if it passes the filter
|
|
|
|
|
|
if (passes) {
|
|
|
|
|
|
// If we haven't found ef nearest neighbors yet, or this neighbor is closer than the farthest one we've found
|
|
|
|
|
|
if (nearest.size < ef || distance < farthestInNearest[1]) {
|
|
|
|
|
|
nearest.set(id, distance)
|
|
|
|
|
|
|
|
|
|
|
|
// If we have more than ef neighbors, remove the farthest one
|
|
|
|
|
|
if (nearest.size > ef) {
|
|
|
|
|
|
const sortedNearest = [...nearest].sort((a, b) => a[1] - b[1])
|
|
|
|
|
|
nearest.clear()
|
|
|
|
|
|
for (let i = 0; i < ef; i++) {
|
|
|
|
|
|
nearest.set(sortedNearest[i][0], sortedNearest[i][1])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Use sequential processing for small number of connections
|
|
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
if (!visited.has(neighborId)) {
|
|
|
|
|
|
visited.add(neighborId)
|
|
|
|
|
|
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) {
|
|
|
|
|
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
2025-10-10 14:09:30 -07:00
|
|
|
|
const distToNeighbor = await Promise.resolve(this.distanceSafe(queryVector, neighbor))
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
// Apply filter if provided
|
|
|
|
|
|
const passes = filter ? await filter(neighborId) : true
|
|
|
|
|
|
|
|
|
|
|
|
// Always add to candidates for graph traversal
|
|
|
|
|
|
candidates.set(neighborId, distToNeighbor)
|
|
|
|
|
|
|
|
|
|
|
|
// Only add to nearest if it passes the filter
|
|
|
|
|
|
if (passes) {
|
|
|
|
|
|
// If we haven't found ef nearest neighbors yet, or this neighbor is closer than the farthest one we've found
|
|
|
|
|
|
if (nearest.size < ef || distToNeighbor < farthestInNearest[1]) {
|
|
|
|
|
|
nearest.set(neighborId, distToNeighbor)
|
|
|
|
|
|
|
|
|
|
|
|
// If we have more than ef neighbors, remove the farthest one
|
|
|
|
|
|
if (nearest.size > ef) {
|
|
|
|
|
|
const sortedNearest = [...nearest].sort((a, b) => a[1] - b[1])
|
|
|
|
|
|
nearest.clear()
|
|
|
|
|
|
for (let i = 0; i < ef; i++) {
|
|
|
|
|
|
nearest.set(sortedNearest[i][0], sortedNearest[i][1])
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sort nearest by distance
|
|
|
|
|
|
return new Map([...nearest].sort((a, b) => a[1] - b[1]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Select M nearest neighbors from the candidate set
|
|
|
|
|
|
*/
|
|
|
|
|
|
private selectNeighbors(
|
|
|
|
|
|
queryVector: Vector,
|
|
|
|
|
|
candidates: Map<string, number>,
|
|
|
|
|
|
M: number
|
|
|
|
|
|
): Map<string, number> {
|
|
|
|
|
|
if (candidates.size <= M) {
|
|
|
|
|
|
return candidates
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Simple heuristic: just take the M closest
|
|
|
|
|
|
const sortedCandidates = [...candidates].sort((a, b) => a[1] - b[1])
|
|
|
|
|
|
const result = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < Math.min(M, sortedCandidates.length); i++) {
|
|
|
|
|
|
result.set(sortedCandidates[i][0], sortedCandidates[i][1])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Ensure a noun doesn't have too many connections at a given level
|
|
|
|
|
|
*/
|
2025-10-10 14:09:30 -07:00
|
|
|
|
private async pruneConnections(noun: HNSWNoun, level: number): Promise<void> {
|
2025-11-01 11:56:11 -07:00
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
const connections = noun.connections.get(level)!
|
|
|
|
|
|
if (connections.size <= this.config.M) {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Calculate distances to all neighbors
|
|
|
|
|
|
const distances = new Map<string, number>()
|
|
|
|
|
|
const validNeighborIds = new Set<string>()
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// OPTIMIZATION: Preload all neighbor vectors
|
|
|
|
|
|
if (connections.size > 0) {
|
|
|
|
|
|
await this.preloadVectors(Array.from(connections))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 12:32:21 -07:00
|
|
|
|
for (const neighborId of connections) {
|
|
|
|
|
|
const neighbor = this.nouns.get(neighborId)
|
|
|
|
|
|
if (!neighbor) {
|
|
|
|
|
|
// Skip neighbors that don't exist (expected during rapid additions/deletions)
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-10 14:09:30 -07:00
|
|
|
|
// Only add valid neighbors to the distances map (handles lazy loading + sync fast path)
|
|
|
|
|
|
const nounVector = await this.getVectorSafe(noun)
|
|
|
|
|
|
const distance = await Promise.resolve(this.distanceSafe(nounVector, neighbor))
|
|
|
|
|
|
distances.set(neighborId, distance)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
validNeighborIds.add(neighborId)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Only proceed if we have valid neighbors
|
|
|
|
|
|
if (distances.size === 0) {
|
2026-06-29 11:06:22 -07:00
|
|
|
|
// If no valid neighbors, clear connections at this level. Every current
|
|
|
|
|
|
// connection is dropped — unlink each from the reverse index.
|
|
|
|
|
|
if (this.incoming !== null) {
|
|
|
|
|
|
for (const dropped of connections) this.removeIncoming(dropped, level, noun.id)
|
|
|
|
|
|
}
|
2025-08-26 12:32:21 -07:00
|
|
|
|
noun.connections.set(level, new Set())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Select M closest neighbors from valid ones
|
|
|
|
|
|
const selectedNeighbors = this.selectNeighbors(
|
|
|
|
|
|
noun.vector,
|
|
|
|
|
|
distances,
|
|
|
|
|
|
this.config.M
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-06-29 11:06:22 -07:00
|
|
|
|
// Update connections with only valid neighbors. Pruning only ever drops
|
|
|
|
|
|
// edges (the selected set is a subset of the current connections), so unlink
|
|
|
|
|
|
// each dropped neighbor from the reverse index.
|
|
|
|
|
|
const kept = new Set(selectedNeighbors.keys())
|
|
|
|
|
|
if (this.incoming !== null) {
|
|
|
|
|
|
for (const prev of connections) {
|
|
|
|
|
|
if (!kept.has(prev)) this.removeIncoming(prev, level, noun.id)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
noun.connections.set(level, kept)
|
2025-08-26 12:32:21 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Generate a random level for a new noun
|
|
|
|
|
|
* Uses the same distribution as in the original HNSW paper
|
|
|
|
|
|
*/
|
|
|
|
|
|
private getRandomLevel(): number {
|
|
|
|
|
|
const r = Math.random()
|
|
|
|
|
|
return Math.floor(-Math.log(r) * (1.0 / Math.log(this.config.M)))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|