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.
This commit is contained in:
parent
71bc30b829
commit
617c156feb
5 changed files with 473 additions and 40 deletions
137
src/hnsw/connectionsCodec.ts
Normal file
137
src/hnsw/connectionsCodec.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* @module hnsw/connectionsCodec
|
||||
* @description Bridge between brainy's UUID-keyed HNSW connection sets and
|
||||
* cortex's int-keyed delta-varint encode/decode (the `graph:compression`
|
||||
* provider). Translates UUIDs to stable int slots via the post-2.4.0 #1
|
||||
* `EntityIdMapper`, batches all of a node's per-level connection lists into
|
||||
* a single compact buffer, and reverses the path on load.
|
||||
*
|
||||
* Wire format of the per-node buffer the codec produces:
|
||||
*
|
||||
* [num_levels: u8]
|
||||
* repeated num_levels times:
|
||||
* [level: u8]
|
||||
* [encoded_len: u32 LE]
|
||||
* [encoded_bytes: <encoded_len> bytes] <- provider.encode(ints)
|
||||
*
|
||||
* Why a single buffer per node and not one blob per level:
|
||||
* - One `saveBinaryBlob` call per persisted node, regardless of level count.
|
||||
* On hot insert paths an HNSW node may be at levels 0..3; the per-node
|
||||
* blob trades a one-byte count for 3 saved I/O round-trips.
|
||||
* - Read path is symmetric: one `loadBinaryBlob` reconstructs the full
|
||||
* connection set, so HNSW rebuild stays linear in the number of nodes.
|
||||
*
|
||||
* Loose UUIDs (ints assigned to entities that no longer exist) are silently
|
||||
* dropped on decode. They can't legitimately participate in graph traversal
|
||||
* because the entity is gone; treating them as `undefined` UUIDs matches the
|
||||
* pre-2.4.0 behaviour of an `idMapper.getUuid()` miss in the legacy load path.
|
||||
*/
|
||||
|
||||
import type {
|
||||
EntityIdMapperProvider,
|
||||
GraphCompressionProvider
|
||||
} from '../plugin.js'
|
||||
|
||||
export class ConnectionsCodec {
|
||||
constructor(
|
||||
private readonly provider: GraphCompressionProvider,
|
||||
private readonly idMapper: EntityIdMapperProvider
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @description Encode all levels of a node's connections into one compact
|
||||
* buffer. Empty input → an empty buffer (zero levels, no body). The
|
||||
* idMapper assigns stable ints on demand if a connection UUID has never
|
||||
* been seen — append-only, matches the rest of the 2.4.0 contract.
|
||||
*/
|
||||
encode(connectionsByLevel: Map<number, Set<string>>): Buffer {
|
||||
if (connectionsByLevel.size === 0) {
|
||||
// Single byte: zero levels. Round-trips cleanly through decode.
|
||||
return Buffer.from([0])
|
||||
}
|
||||
if (connectionsByLevel.size > 0xff) {
|
||||
throw new Error(
|
||||
`ConnectionsCodec.encode: too many HNSW levels (${connectionsByLevel.size}); ` +
|
||||
`the per-node header is one byte (max 255 levels).`
|
||||
)
|
||||
}
|
||||
|
||||
// First pass: encode each level into its own buffer.
|
||||
const levelBuffers: Array<{ level: number; buf: Buffer }> = []
|
||||
let bodyBytes = 0
|
||||
for (const [level, uuids] of connectionsByLevel) {
|
||||
if (level > 0xff) {
|
||||
throw new Error(
|
||||
`ConnectionsCodec.encode: level ${level} exceeds u8 header range (max 255).`
|
||||
)
|
||||
}
|
||||
const ints: number[] = []
|
||||
for (const uuid of uuids) {
|
||||
ints.push(this.idMapper.getOrAssign(uuid))
|
||||
}
|
||||
const buf = this.provider.encode(ints)
|
||||
levelBuffers.push({ level, buf })
|
||||
bodyBytes += 1 + 4 + buf.length // level + len + payload
|
||||
}
|
||||
|
||||
// Second pass: concatenate with header.
|
||||
const out = Buffer.alloc(1 + bodyBytes)
|
||||
out.writeUInt8(levelBuffers.length, 0)
|
||||
let offset = 1
|
||||
for (const { level, buf } of levelBuffers) {
|
||||
out.writeUInt8(level, offset)
|
||||
offset += 1
|
||||
out.writeUInt32LE(buf.length, offset)
|
||||
offset += 4
|
||||
buf.copy(out, offset)
|
||||
offset += buf.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Decode a per-node buffer back into a connections Map. Ints
|
||||
* for entities the idMapper no longer knows are silently dropped — see the
|
||||
* module-level note on why.
|
||||
*/
|
||||
decode(data: Buffer): Map<number, Set<string>> {
|
||||
const out = new Map<number, Set<string>>()
|
||||
if (data.length === 0) return out
|
||||
|
||||
const count = data.readUInt8(0)
|
||||
let offset = 1
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (offset + 5 > data.length) {
|
||||
throw new Error(`ConnectionsCodec.decode: truncated header at level ${i}`)
|
||||
}
|
||||
const level = data.readUInt8(offset)
|
||||
offset += 1
|
||||
const len = data.readUInt32LE(offset)
|
||||
offset += 4
|
||||
if (offset + len > data.length) {
|
||||
throw new Error(`ConnectionsCodec.decode: truncated body at level ${level}`)
|
||||
}
|
||||
const levelBuf = data.slice(offset, offset + len)
|
||||
offset += len
|
||||
|
||||
const ints = this.provider.decode(levelBuf)
|
||||
const uuids = new Set<string>()
|
||||
for (const intId of ints) {
|
||||
const uuid = this.idMapper.getUuid(intId)
|
||||
if (uuid !== undefined) uuids.add(uuid)
|
||||
}
|
||||
out.set(level, uuids)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Build the binary-blob storage key for a node's compressed
|
||||
* connections. Suffix-free; the adapter appends its own. Keys live under
|
||||
* `_hnsw_conn/` so they don't collide with the existing `_column_index/`
|
||||
* blobs and so a cortex-side reader knows exactly where to look.
|
||||
*/
|
||||
export function compressedConnectionsKey(nodeId: string): string {
|
||||
return `_hnsw_conn/${nodeId}`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue