diff --git a/engram/test/run_vindex_tests.sh b/engram/test/run_vindex_tests.sh new file mode 100755 index 0000000..0e9c380 --- /dev/null +++ b/engram/test/run_vindex_tests.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Build + RUN the M8 HNSW vector-index tests. Pure C11 (gcc/cc), stdlib + libm +# only. This is a standalone C module — NOT folded through elb/elc. +# +# Two passes: +# 1. PERF — optimised (-O2, no sanitizer): the real recall@10 gate + speedup +# numbers at full size (N=5000 recall, N=5000/20000 speedup). +# 2. SAFETY — ASan + UBSan on the same suite at reduced size (VINDEX_QUICK=1); +# memory-safety is size-independent, so this stays fast. +set -e +HERE=$(cd "$(dirname "$0")" && pwd) +RT="$HERE/../../lang/runtime" +CC=${CC:-cc} +SRC="$HERE/test_vindex.c $RT/engram_vindex.c $RT/engram_store.c" +WARN="-std=c11 -Wall -Wextra" +TMP=$(mktemp -d) + +echo "### PASS 1: PERF (optimised, un-sanitised) — recall gate + speedup" +$CC $WARN -O2 -I"$RT" $SRC -lm -o "$TMP/perf" +"$TMP/perf" + +echo +echo "### PASS 2: SAFETY (ASan/UBSan, reduced size)" +$CC $WARN -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -I"$RT" $SRC -lm -o "$TMP/safe" +VINDEX_QUICK=1 ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0} UBSAN_OPTIONS=halt_on_error=1 "$TMP/safe" diff --git a/engram/test/test_vindex.c b/engram/test/test_vindex.c new file mode 100644 index 0000000..5ed1034 --- /dev/null +++ b/engram/test/test_vindex.c @@ -0,0 +1,312 @@ +/* test_vindex.c — build + RUN gate for the M8 HNSW vector index. + * + * Covers: recall@10 vs brute-force oracle, brute-force-vs-index speedup, + * correctness edge cases (k>N, identical vectors, self-query, zero vector), + * determinism (seeded PRNG → identical graphs), and vindex_build_from_store + * over a real engram_store on-disk file. + * + * Pure C11; links engram_vindex.c + engram_store.c; -lm. ASan/UBSan clean. + */ +#include "engram_vindex.h" +#include "engram_store.h" + +#include +#include +#include +#include +#include +#include +#include + +#define DIM 768 + +static int g_fail = 0; +/* VINDEX_QUICK=1 shrinks the two large builds so the ASan/UBSan pass (which runs + * ~5-10x slower) stays fast — memory-safety is size-independent. The perf numbers + * (recall gate + speedup) come from the un-sanitized, full-size pass. */ +static int g_quick = 0; +static int envint(const char* k, int dflt){ const char* s=getenv(k); return s?atoi(s):dflt; } +#define CHECK(cond, msg) do{ if(!(cond)){ printf(" FAIL: %s\n", msg); g_fail=1; } else { printf(" ok: %s\n", msg); } }while(0) + +/* deterministic test PRNG (splitmix64) */ +static uint64_t rng_state = 0xABCDEF0123456789ULL; +static uint64_t xrng(uint64_t* s){ + uint64_t z=(*s+=0x9E3779B97F4A7C15ULL); + z=(z^(z>>30))*0xBF58476D1CE4E5B9ULL; z=(z^(z>>27))*0x94D049BB133111EBULL; + return z^(z>>31); +} +static float frand(uint64_t* s){ return (float)((xrng(s)>>11)*(1.0/9007199254740992.0)) - 0.5f; } + +static double now_s(void){ + struct timespec t; clock_gettime(CLOCK_MONOTONIC,&t); + return t.tv_sec + t.tv_nsec*1e-9; +} + +/* fill vec[N*DIM]: mostly random, some clustered groups (center + small noise). */ +static void gen_vectors(float* v, int N, uint64_t seed){ + uint64_t s = seed; + int clustered = N/5; /* last fifth is clustered */ + int ncenters = 20; + float* centers = (float*)malloc((size_t)ncenters*DIM*sizeof(float)); + for (int c=0;c0 && bd[p-1]>d){ bd[p]=bd[p-1]; ids[p]=ids[p-1]; p--; } + bd[p]=d; ids[p]=i; + } + } + free(bd); +} + +/* ── Test 1: recall@10 vs brute force + latency/recall tradeoff ────────────── */ +static void test_recall(void){ + int N=envint("VINDEX_N_RECALL", g_quick?1500:5000), Q=200, K=10; + printf("\n== Test 1: recall@10 vs brute force (N=%d, DIM=768) ==\n", N); + float* v = (float*)malloc((size_t)N*DIM*sizeof(float)); + gen_vectors(v, N, 111); + + double t0=now_s(); + VIndex* ix = vindex_create(DIM, VINDEX_DEFAULT_M, VINDEX_DEFAULT_EF_CONSTRUCTION); + for (int i=0;i= 0.90, "recall@10 >= 0.90 at default ef_search=128"); + } + free(oracle); free(qs); free(v); vindex_free(ix); +} + +/* ── Test 2: speedup vs brute force ───────────────────────────────────────── */ +static void speedup_at(int N){ + int Q=100, K=10; + float* v=(float*)malloc((size_t)N*DIM*sizeof(float)); + gen_vectors(v,N,222); + VIndex* ix=vindex_create(DIM,16,200); + double bt0=now_s(); + for(int i=0;i node count returns exactly node-count results"); + vindex_free(ix); + } + /* duplicate / identical vectors */ + { + VIndex* ix=vindex_create(DIM,16,200); + float a[DIM]; uint64_t s=2; for(int d=0;d=1 && ids[0]==(uint64_t)(1000+probe), "self-query returns itself as top-1"); + CHECK(dd[0] < 1e-4f, "self-query top-1 distance ~0"); + free(v); vindex_free(ix); + } + /* zero vector: no NaN, handled */ + { + VIndex* ix=vindex_create(DIM,16,200); + float z[DIM]; memset(z,0,sizeof z); + float a[DIM]; uint64_t s=3; for(int d=0;d=1 && !nan, "zero vector query produces no NaN/Inf"); + n=vindex_search(ix, a, 2, 64, ids, dd); /* zero indexed */ + nan=0; for(int i=0;i=1 && rids[0]<(uint64_t)nids && strcmp(ids[rids[0]], "node-42")==0); + printf(" query for node-42's vector → top-1 id=%s dist=%.5f\n", + (n>=1 && rids[0]<(uint64_t)nids)? ids[rids[0]] : "?", n?dd[0]:-1); + CHECK(correct, "build_from_store query resolves to the right node id"); + CHECK(n>=1 && dd[0]<1e-4f, "top-1 distance ~0 for exact stored vector"); + + for (int i=0;i +#include +#include +#include +#include +#include +#include +#include + +/* Deterministic PRNG seed base (fixed constant — never wall-clock/rand). */ +#define VINDEX_FIXED_SEED 0x9E3779B97F4A7C15ULL + +/* ── deterministic PRNG (splitmix64) ──────────────────────────────────────── */ +static inline uint64_t splitmix64(uint64_t* s){ + uint64_t z = (*s += 0x9E3779B97F4A7C15ULL); + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9ULL; + z = (z ^ (z >> 27)) * 0x94D049BB133111EBULL; + return z ^ (z >> 31); +} +/* Uniform double in (0,1]. */ +static inline double sm_uniform(uint64_t* s){ + /* 53-bit mantissa; +1 keeps it in (0,1] so log() never sees 0. */ + return ((double)((splitmix64(s) >> 11) + 1)) * (1.0 / 9007199254740993.0); +} + +/* ── element + index structures ───────────────────────────────────────────── */ +typedef struct { + int count; + int cap; + int* ids; /* neighbour element indices */ +} NeighList; + +typedef struct { + uint64_t node_id; + int level; /* top layer this element appears on (>=0) */ + float* vec; /* dim floats, L2-normalised */ + NeighList* links; /* level+1 lists; links[l] = neighbours at layer l */ +} Elem; + +struct VIndex { + int dim; + int M; /* max neighbours per node, upper layers */ + int M0; /* == 2*M, layer 0 */ + int ef_construction; + double mL; /* level normaliser = 1/ln(M) */ + + Elem* elems; + size_t n; + size_t cap; + + int entry; /* entry-point element index, -1 if empty */ + int max_level; /* current top layer */ + + /* scratch: version-stamped visited set (O(1) reset). */ + uint32_t* visited; + uint32_t visit_epoch; + size_t visited_cap; +}; + +/* ── small helpers ────────────────────────────────────────────────────────── */ +static float* vec_normalise_copy(const float* v, int dim){ + float* out = (float*)malloc((size_t)dim * sizeof(float)); + if (!out) return NULL; + double ss = 0.0; + for (int i=0;i 0.0){ + float inv = (float)(1.0 / sqrt(ss)); + for (int i=0;idim; + float s0=0,s1=0,s2=0,s3=0; + int i=0; + for (; i+4<=dim; i+=4){ + s0 += a[i]*b[i]; s1 += a[i+1]*b[i+1]; + s2 += a[i+2]*b[i+2]; s3 += a[i+3]*b[i+3]; + } + float dot = (s0+s1)+(s2+s3); + for (; icount == nl->cap){ + int nc = nl->cap ? nl->cap*2 : 4; + int* np = (int*)realloc(nl->ids, (size_t)nc*sizeof(int)); + if (!np) return -1; + nl->ids = np; nl->cap = nc; + } + nl->ids[nl->count++] = id; + return 0; +} + +/* ── binary heaps over (dist,elem) pairs ──────────────────────────────────── */ +typedef struct { float d; int e; } Pair; +typedef struct { Pair* a; int n, cap; } Heap; + +static int heap_reserve(Heap* h, int need){ + if (need <= h->cap) return 0; + int nc = h->cap ? h->cap*2 : 16; + while (nc < need) nc *= 2; + Pair* na = (Pair*)realloc(h->a, (size_t)nc*sizeof(Pair)); + if (!na) return -1; + h->a = na; h->cap = nc; return 0; +} +/* Order predicate: for a MAX-heap on distance, "higher priority" = larger dist; + * ties broken by larger element index (deterministic + stable). is_max selects. */ +static inline int pair_before(Pair x, Pair y, int is_max){ + if (x.d != y.d) return is_max ? (x.d > y.d) : (x.d < y.d); + return is_max ? (x.e > y.e) : (x.e < y.e); +} +static int heap_push(Heap* h, Pair v, int is_max){ + if (heap_reserve(h, h->n+1)) return -1; + int i = h->n++; + h->a[i] = v; + while (i > 0){ + int p = (i-1)/2; + if (pair_before(h->a[i], h->a[p], is_max)){ + Pair t=h->a[i]; h->a[i]=h->a[p]; h->a[p]=t; i=p; + } else break; + } + return 0; +} +static Pair heap_pop(Heap* h, int is_max){ + Pair top = h->a[0]; + h->a[0] = h->a[--h->n]; + int i = 0; + for (;;){ + int l=2*i+1, r=2*i+2, best=i; + if (ln && pair_before(h->a[l], h->a[best], is_max)) best=l; + if (rn && pair_before(h->a[r], h->a[best], is_max)) best=r; + if (best==i) break; + Pair t=h->a[i]; h->a[i]=h->a[best]; h->a[best]=t; i=best; + } + return top; +} + +/* ── visited set ──────────────────────────────────────────────────────────── */ +static int visited_ensure(VIndex* ix){ + if (ix->visited_cap >= ix->cap && ix->visited) return 0; + size_t nc = ix->cap ? ix->cap : 16; + uint32_t* nv = (uint32_t*)realloc(ix->visited, nc*sizeof(uint32_t)); + if (!nv) return -1; + if (nc > ix->visited_cap) memset(nv + ix->visited_cap, 0, (nc-ix->visited_cap)*sizeof(uint32_t)); + ix->visited = nv; ix->visited_cap = nc; + return 0; +} +static inline void visited_reset(VIndex* ix){ + if (++ix->visit_epoch == 0){ /* wrapped: clear all */ + memset(ix->visited, 0, ix->visited_cap*sizeof(uint32_t)); + ix->visit_epoch = 1; + } +} +static inline int is_visited(VIndex* ix, int e){ return ix->visited[e]==ix->visit_epoch; } +static inline void mark_visited(VIndex* ix, int e){ ix->visited[e]=ix->visit_epoch; } + +/* ── search one layer (Algorithm 2): best-first, ef-bounded ───────────────── */ +/* Returns results as an unsorted Heap (max-heap on distance, size<=ef). Caller + * owns res->a. `q` is a normalised query. */ +static int search_layer(VIndex* ix, const float* q, const int* eps, int neps, + int ef, int layer, Heap* res /*out, max-heap*/){ + Heap cand = {0,0,0}; /* min-heap: nearest to expand */ + res->a=NULL; res->n=0; res->cap=0; + visited_reset(ix); + for (int i=0;ielems[e].vec); + Pair p = { d, e }; + if (heap_push(&cand,p,0) || heap_push(res,p,1)){ free(cand.a); return -1; } + } + while (res->n > ef) heap_pop(res,1); /* trim to ef */ + + while (cand.n > 0){ + Pair c = heap_pop(&cand,0); + float worst = res->a[0].d; /* farthest kept result */ + if (res->n >= ef && c.d > worst) break; + Elem* ce = &ix->elems[c.e]; + if (layer <= ce->level){ + NeighList* nl = &ce->links[layer]; + for (int i=0;icount;i++){ + int e = nl->ids[i]; + if (is_visited(ix,e)) continue; + mark_visited(ix,e); + float d = vdist(ix, q, ix->elems[e].vec); + if (res->n < ef || d < res->a[0].d){ + Pair p = { d, e }; + if (heap_push(&cand,p,0) || heap_push(res,p,1)){ free(cand.a); return -1; } + if (res->n > ef) heap_pop(res,1); + } + } + } + } + free(cand.a); + return 0; +} + +/* ── neighbour selection heuristic (Algorithm 4) ──────────────────────────── */ +/* From candidate pairs W (any order), pick up to M diverse neighbours of q. + * Keep c only if it is nearer to q than to every already-chosen neighbour; + * backfill from the pruned set (nearest first) to reach M for connectivity. + * Writes chosen element indices into out[], returns the count. */ +static int select_neighbors(VIndex* ix, const float* q, Pair* W, int nW, int M, int* out){ + (void)q; /* q's distances are precomputed in W[].d; kept for call-site clarity */ + /* sort W ascending by (dist,elem) — deterministic. */ + for (int i=1;i=0 && !pair_before(W[j],key,0)){ W[j+1]=W[j]; j--; } + W[j+1]=key; + } + int nout = 0; + Pair* pruned = (Pair*)malloc((size_t)(nW?nW:1)*sizeof(Pair)); + int npr = 0; + if (!pruned) return -1; + for (int i=0;ielems[W[i].e].vec, ix->elems[out[j]].vec); + if (d < W[i].d){ good = 0; break; } /* nearer an existing pick → drop */ + } + if (good) out[nout++] = W[i].e; + else pruned[npr++] = W[i]; + } + for (int i=0;ielems[e].links[layer]; + if (nl->count <= Mmax) return; + const float* base = ix->elems[e].vec; + Pair* W = (Pair*)malloc((size_t)nl->count*sizeof(Pair)); + if (!W) return; + int nW = nl->count; + for (int i=0;ielems[nl->ids[i]].vec), nl->ids[i] }; + int* keep = (int*)malloc((size_t)nW*sizeof(int)); + if (!keep){ free(W); return; } + int nk = select_neighbors(ix, base, W, nW, Mmax, keep); + if (nk >= 0){ nl->count = nk; for (int i=0;iids[i]=keep[i]; } + free(keep); free(W); +} + +/* ── insert ───────────────────────────────────────────────────────────────── */ +static int elems_reserve(VIndex* ix){ + if (ix->n < ix->cap) return 0; + size_t nc = ix->cap ? ix->cap*2 : 64; + Elem* ne = (Elem*)realloc(ix->elems, nc*sizeof(Elem)); + if (!ne) return -1; + ix->elems = ne; ix->cap = nc; + return visited_ensure(ix); +} + +int vindex_insert(VIndex* ix, uint64_t node_id, const float* vec){ + if (!ix || !vec) return -1; + if (elems_reserve(ix)) return -1; + + int cur = (int)ix->n; + /* deterministic level assignment, seeded per-node. */ + uint64_t seed = VINDEX_FIXED_SEED ^ (node_id + 0x2545F4914F6CDD1DULL*(uint64_t)cur); + int level = (int)(-log(sm_uniform(&seed)) * ix->mL); + if (level < 0) level = 0; + + Elem* el = &ix->elems[cur]; + el->node_id = node_id; + el->level = level; + el->vec = vec_normalise_copy(vec, ix->dim); + el->links = (NeighList*)calloc((size_t)level+1, sizeof(NeighList)); + if (!el->vec || !el->links){ free(el->vec); free(el->links); return -1; } + ix->n++; + + if (ix->entry < 0){ /* first element */ + ix->entry = cur; ix->max_level = level; + return 0; + } + + int ep = ix->entry; + int L = ix->max_level; + /* greedy descent through layers above `level` to refine the entry point. */ + for (int lc = L; lc > level; lc--){ + Heap r = {0,0,0}; + int eps1[1] = { ep }; + if (search_layer(ix, el->vec, eps1, 1, 1, lc, &r)){ return -1; } + if (r.n){ ep = r.a[0].e; float bd=r.a[0].d; + for (int i=1;i= 0; lc--){ + int Mmax = (lc==0) ? ix->M0 : ix->M; + Heap W = {0,0,0}; + if (search_layer(ix, el->vec, eps, neps, ix->ef_construction, lc, &W)){ rc=-1; break; } + int* chosen = (int*)malloc((size_t)(W.n?W.n:1)*sizeof(int)); + if (!chosen){ free(W.a); rc=-1; break; } + int nc = select_neighbors(ix, el->vec, W.a, W.n, Mmax, chosen); + if (nc < 0){ free(chosen); free(W.a); rc=-1; break; } + /* link cur <-> chosen (bidirectional), prune neighbours if over-full. */ + for (int i=0;ilinks[lc], nb) || nl_push(&ix->elems[nb].links[lc], cur)){ + free(chosen); free(W.a); rc=-1; goto done; + } + prune_links(ix, nb, lc, Mmax); + } + free(chosen); + /* next layer's entry points = this layer's ef results. */ + if (lc > 0){ + int* neweps = (int*)malloc((size_t)(W.n?W.n:1)*sizeof(int)); + if (!neweps){ free(W.a); rc=-1; break; } + for (int i=0;i ix->max_level){ ix->max_level = level; ix->entry = cur; } + return 0; +} + +/* ── search ───────────────────────────────────────────────────────────────── */ +int vindex_search(VIndex* ix, const float* query, int k, int ef_search, + uint64_t* node_id_out, float* dist_out){ + if (!ix || !query || k <= 0) return -1; + if (ix->entry < 0) return 0; + if (ef_search <= 0) ef_search = VINDEX_DEFAULT_EF_SEARCH; + if (ef_search < k) ef_search = k; + + float* q = vec_normalise_copy(query, ix->dim); + if (!q) return -1; + + int ep = ix->entry; + for (int lc = ix->max_level; lc > 0; lc--){ + Heap r = {0,0,0}; + int eps[1] = { ep }; + if (search_layer(ix, q, eps, 1, 1, lc, &r)){ free(q); return -1; } + if (r.n){ int b=r.a[0].e; float bd=r.a[0].d; + for (int i=1;i=0;i--) sorted[i] = heap_pop(&res,1); /* farthest first out → fill from end */ + free(res.a); + + int out_n = (k < total) ? k : total; + for (int i=0;ielems[sorted[i].e].node_id; + if (dist_out) dist_out[i] = sorted[i].d; + } + free(sorted); + return out_n; +} + +size_t vindex_size(const VIndex* ix){ return ix ? ix->n : 0; } + +VIndex* vindex_create(int dim, int M, int ef_construction){ + if (dim <= 0) return NULL; + if (M <= 0) M = VINDEX_DEFAULT_M; + if (ef_construction <= 0) ef_construction = VINDEX_DEFAULT_EF_CONSTRUCTION; + VIndex* ix = (VIndex*)calloc(1, sizeof(VIndex)); + if (!ix) return NULL; + ix->dim = dim; + ix->M = M; + ix->M0 = 2*M; + ix->ef_construction = ef_construction; + ix->mL = 1.0 / log((double)M > 1.0 ? (double)M : 2.0); + ix->entry = -1; + ix->max_level = 0; + ix->visit_epoch = 0; + return ix; +} + +void vindex_free(VIndex* ix){ + if (!ix) return; + for (size_t i=0;in;i++){ + Elem* e = &ix->elems[i]; + if (e->links) for (int l=0;l<=e->level;l++) free(e->links[l].ids); + free(e->links); + free(e->vec); + } + free(ix->elems); + free(ix->visited); + free(ix); +} + +/* ── read-only decode of the paged store node format (design §2.4) ─────────── */ +/* Mirrors engram_store.c constants; the on-disk format is PERMANENT so these are + * safe to duplicate for a read-only harvest of emb vectors. */ +#define VS_PAGE_SIZE 16384u +#define VS_HDR 32u +#define VS_SLOT_SIZE 6u +#define VS_SLOT_LIVE 1u +#define VS_REC_HDR 4u +#define VS_REC_OVERFLOW 1u +#define VS_PT_NODE 1u +#define VS_OVF_NEXT 32u +#define VS_OVF_LEN 40u +#define VS_OVF_DATA 44u +#define VS_NT_ID 1u +#define VS_NT_EMB 24u +#define VS_NT_EMB_DIM 25u + +static uint16_t vg_u16(const uint8_t* p){ return (uint16_t)(p[0] | (p[1]<<8)); } +static uint32_t vg_u32(const uint8_t* p){ uint32_t v=0; for(int i=0;i<4;i++) v|=(uint32_t)p[i]<<(8*i); return v; } +static uint64_t vg_u64(const uint8_t* p){ uint64_t v=0; for(int i=0;i<8;i++) v|=(uint64_t)p[i]<<(8*i); return v; } + +static int vs_pread(int fd, uint64_t page, uint8_t* buf){ + off_t off = (off_t)page * VS_PAGE_SIZE; + ssize_t r = pread(fd, buf, VS_PAGE_SIZE, off); + return (r == (ssize_t)VS_PAGE_SIZE) ? 0 : -1; +} +/* Read a (possibly overflowed) record body; caller frees *out. */ +static int vs_read_body(int fd, const uint8_t* page, uint16_t off, uint16_t len, + uint8_t** out, size_t* outlen){ + if (len < VS_REC_HDR) return -1; + uint8_t flags = page[off+3]; + if (flags & VS_REC_OVERFLOW){ + uint64_t head = vg_u64(page + off + VS_REC_HDR); + uint64_t total = vg_u64(page + off + VS_REC_HDR + 8); + uint8_t* body = (uint8_t*)malloc(total ? total : 1); + if (!body) return -1; + size_t got=0; uint64_t id=head; + uint8_t ov[VS_PAGE_SIZE]; + while (id){ + if (vs_pread(fd, id, ov)){ free(body); return -1; } + uint32_t chunk = vg_u32(ov + VS_OVF_LEN); + if (got + chunk > total){ free(body); return -1; } + memcpy(body+got, ov+VS_OVF_DATA, chunk); got += chunk; + id = vg_u64(ov + VS_OVF_NEXT); + } + if (got != total){ free(body); return -1; } + *out = body; *outlen = total; + } else { + uint16_t reclen = vg_u16(page + off); + if (reclen < VS_REC_HDR) return -1; + size_t blen = reclen - VS_REC_HDR; + uint8_t* body = (uint8_t*)malloc(blen ? blen : 1); + if (!body) return -1; + memcpy(body, page + off + VS_REC_HDR, blen); + *out = body; *outlen = blen; + } + return 0; +} +/* Extract id (strdup) and emb (malloc'd float[dim]) from a TLV node body. */ +static void vs_parse_node(const uint8_t* body, size_t len, char** id_out, + float** emb_out, int* dim_out){ + *id_out=NULL; *emb_out=NULL; *dim_out=0; + size_t i=0; + while (i + 5 <= len){ + uint8_t tag = body[i]; + uint32_t flen = vg_u32(body + i + 1); + if (i + 5 + (size_t)flen > len) break; + const uint8_t* v = body + i + 5; + if (tag == VS_NT_ID){ + char* s = (char*)malloc(flen+1); + if (s){ memcpy(s,v,flen); s[flen]=0; free(*id_out); *id_out=s; } + } else if (tag == VS_NT_EMB){ + int dim = (int)(flen/4); + float* e = (float*)malloc((size_t)(dim?dim:1)*sizeof(float)); + if (e){ for (int k=0;kn*2 >= s->cap){ + size_t nc = s->cap ? s->cap*2 : 1024; + char** nk = (char**)calloc(nc, sizeof(char*)); + if (!nk) return -1; + for (size_t i=0;icap;i++) if (s->k[i]){ size_t j=vs_fnv(s->k[i])&(nc-1); while(nk[j]) j=(j+1)&(nc-1); nk[j]=s->k[i]; } + free(s->k); s->k=nk; s->cap=nc; + } + size_t j = vs_fnv(key)&(s->cap-1); + while (s->k[j]){ if (strcmp(s->k[j],key)==0) return 0; j=(j+1)&(s->cap-1); } + char* d = strdup(key); if(!d) return -1; + s->k[j]=d; s->n++; + return 1; +} +static void strset_free(StrSet* s){ for(size_t i=0;icap;i++) free(s->k[i]); free(s->k); } + +int vindex_build_from_store(VIndex* ix, const char* store_path, + char*** ids_out, int* n_out){ + if (!ix || !store_path) return -1; + int fd = open(store_path, O_RDONLY); + if (fd < 0) return -1; + struct stat st; + if (fstat(fd, &st) != 0){ close(fd); return -1; } + uint64_t npages = (uint64_t)st.st_size / VS_PAGE_SIZE; + + char** ids = NULL; size_t ids_n = 0, ids_cap = 0; + StrSet seen = {0,0,0}; + int inserted = 0; + uint8_t page[VS_PAGE_SIZE]; + + for (uint64_t pg = 2; pg < npages; pg++){ /* pages 0,1 = superblocks */ + if (vs_pread(fd, pg, page)) continue; + if (page[8] != VS_PT_NODE) continue; + int slots = vg_u16(page + 10); + for (int sidx=0; sidx VS_PAGE_SIZE) continue; + uint8_t* body=NULL; size_t blen=0; + if (vs_read_body(fd, page, off, len, &body, &blen)) continue; + char* id=NULL; float* emb=NULL; int dim=0; + vs_parse_node(body, blen, &id, &emb, &dim); + free(body); + if (!id || !emb || dim != ix->dim){ free(id); free(emb); continue; } + int add = strset_add(&seen, id); + if (add <= 0){ free(id); free(emb); continue; } /* dup or err */ + if (vindex_insert(ix, (uint64_t)inserted, emb) != 0){ free(id); free(emb); break; } + free(emb); + if (ids_n == ids_cap){ + size_t nc = ids_cap ? ids_cap*2 : 256; + char** ni = (char**)realloc(ids, nc*sizeof(char*)); + if (!ni){ free(id); break; } + ids = ni; ids_cap = nc; + } + ids[ids_n++] = id; /* transfers ownership */ + inserted++; + } + } + close(fd); + strset_free(&seen); + if (ids_out){ *ids_out = ids; if (n_out) *n_out = (int)ids_n; } + else { for (size_t i=0;idim, ix->M, ix->ef_construction, (int32_t)ix->n, ix->entry, ix->max_level }; + WR(hdr, sizeof(hdr)); + for (size_t i=0; ok && in; i++){ + Elem* e = &ix->elems[i]; + WR(&e->node_id, sizeof(uint64_t)); + int32_t lvl = e->level; WR(&lvl, sizeof(int32_t)); + WR(e->vec, (size_t)ix->dim*sizeof(float)); + for (int l=0; ok && l<=e->level; l++){ + int32_t c = e->links[l].count; WR(&c, sizeof(int32_t)); + WR(e->links[l].ids, (size_t)c*sizeof(int)); + } + } + #undef WR + fclose(f); + return ok ? 0 : -1; +} +VIndex* vindex_load(const char* path){ + FILE* f = fopen(path, "rb"); + if (!f) return NULL; + char magic[8]; + if (fread(magic,1,8,f)!=8 || memcmp(magic,VINDEX_SAVE_MAGIC,8)!=0){ fclose(f); return NULL; } + int32_t hdr[6]; + if (fread(hdr,sizeof(hdr),1,f)!=1){ fclose(f); return NULL; } + VIndex* ix = vindex_create(hdr[0], hdr[1], hdr[2]); + if (!ix){ fclose(f); return NULL; } + size_t N = (size_t)hdr[3]; + int ok = 1; + for (size_t i=0; ok && ielems[ix->n]; + int32_t lvl; + if (fread(&e->node_id,sizeof(uint64_t),1,f)!=1 || fread(&lvl,sizeof(int32_t),1,f)!=1){ ok=0; break; } + e->level = lvl; + e->vec = (float*)malloc((size_t)ix->dim*sizeof(float)); + e->links = (NeighList*)calloc((size_t)lvl+1, sizeof(NeighList)); + if (!e->vec || !e->links){ free(e->vec); free(e->links); ok=0; break; } + if (fread(e->vec,sizeof(float),(size_t)ix->dim,f)!=(size_t)ix->dim){ ok=0; } + for (int l=0; ok && l<=lvl; l++){ + int32_t c; if (fread(&c,sizeof(int32_t),1,f)!=1){ ok=0; break; } + e->links[l].ids = (int*)malloc((size_t)(c?c:1)*sizeof(int)); + e->links[l].cap = c; e->links[l].count = c; + if (c && fread(e->links[l].ids,sizeof(int),(size_t)c,f)!=(size_t)c){ ok=0; } + } + ix->n++; + } + ix->entry = hdr[4]; ix->max_level = hdr[5]; + fclose(f); + if (!ok){ vindex_free(ix); return NULL; } + return ix; +} diff --git a/lang/runtime/engram_vindex.h b/lang/runtime/engram_vindex.h new file mode 100644 index 0000000..6303c84 --- /dev/null +++ b/lang/runtime/engram_vindex.h @@ -0,0 +1,82 @@ +/* engram_vindex.h — M8 of the engram query engine: an approximate-nearest- + * neighbour (ANN) vector index over the node embedding vectors, for fast + * activation-seed selection. + * + * Replaces the O(n) cosine scan over emb vectors (design §9 M8; backlog #20) + * with an HNSW (Hierarchical Navigable Small World) graph that returns + * high-recall top-k seeds in ~O(log n). + * + * Standalone module: plain C11, stdlib + libm only. It does NOT modify the + * store format or engram_store.{c,h}; vindex_build_from_store() decodes the + * PERMANENT on-disk node format (design §2.4) read-only to harvest emb vectors. + * + * Similarity metric: cosine. Vectors are L2-normalised on insert/query, so + * cosine similarity == dot product. Reported distance = 1 - cosine_similarity + * (range [0,2]); smaller == closer. A query equal to an indexed vector scores + * distance ~0 against it. + * + * The index is fully rebuildable from the store, so persistence is optional for + * this milestone (see vindex_save/vindex_load below — provided as a convenience; + * boot may simply rebuild via vindex_build_from_store()). + */ +#ifndef ENGRAM_VINDEX_H +#define ENGRAM_VINDEX_H + +#include +#include + +/* Tuned defaults (rationale in engram_vindex.c). Pass 0 to vindex_create for + * M / ef_construction to take these; pass ef_search<=0 to vindex_search for + * VINDEX_DEFAULT_EF_SEARCH. */ +#define VINDEX_DEFAULT_M 24 +#define VINDEX_DEFAULT_EF_CONSTRUCTION 200 +#define VINDEX_DEFAULT_EF_SEARCH 128 + +typedef struct VIndex VIndex; + +/* Create an index over `dim`-dimensional f32 vectors. + * M — max neighbours per node on upper layers (2*M on layer 0). + * ef_construction — candidate-list width during insert (recall/build cost). + * Pass M<=0 or ef_construction<=0 to use the VINDEX_DEFAULT_* above. + * Returns NULL on bad args / OOM. */ +VIndex* vindex_create(int dim, int M, int ef_construction); + +/* Insert one vector under an opaque caller-defined node_id (need not be unique, + * but the caller is responsible for meaning). `vec` has `dim` floats; it is + * copied and L2-normalised internally. A zero vector is accepted (it simply has + * distance ~1 to everything; never produces NaN). Returns 0 on success, <0 on + * error (bad args / OOM). */ +int vindex_insert(VIndex* idx, uint64_t node_id, const float* vec); + +/* Top-k search by cosine similarity. Writes up to k results (fewer if the index + * holds fewer than k elements) into node_id_out[] / dist_out[], ordered nearest + * first (ascending distance). Either out array may be NULL to skip it. + * ef_search — search-time candidate width; larger == higher recall, slower. + * Pass <=0 for VINDEX_DEFAULT_EF_SEARCH. Internally clamped to >=k. + * Returns the number of results written, or <0 on error. */ +int vindex_search(VIndex* idx, const float* query, int k, int ef_search, + uint64_t* node_id_out, float* dist_out); + +/* Number of vectors currently indexed. */ +size_t vindex_size(const VIndex* idx); + +void vindex_free(VIndex* idx); + +/* Build an index by scanning every live node record in the paged store at + * `store_path` (the on-disk format is decoded read-only; the store need not be + * open). Nodes without an emb vector, or whose emb_dim != idx->dim, are skipped. + * Each inserted node is assigned node_id = its 0-based insertion ordinal; if + * `ids_out`/`n_out` are non-NULL, *ids_out is set to a malloc'd array of that + * many strdup'd string ids (ids_out[node_id] == the store id) and *n_out to the + * count — the caller frees each string and the array. Returns the number of + * vectors inserted, or <0 on error. */ +int vindex_build_from_store(VIndex* idx, const char* store_path, + char*** ids_out, int* n_out); + +/* Optional persistence (index is rebuildable from the store; provided for + * convenience). vindex_save writes a self-describing snapshot; vindex_load + * reconstructs an index from one. Return 0 / non-NULL on success. */ +int vindex_save(const VIndex* idx, const char* path); +VIndex* vindex_load(const char* path); + +#endif /* ENGRAM_VINDEX_H */