runtime: publish the vector index instead of guarding it
El SDK CI - dev / build-and-test (pull_request) Failing after 10m59s

The crash (SIGTRAP in engram_activate -> eg_vindex_sync -> vindex_insert ->
_realloc) had three read paths mutating five process-global statics.
engram_activate, eg_knn_for_node (whose own comment says "No writes.") and
engram_geo_reify_run_json all called eg_vindex_sync, which frees the index,
reallocs the seen-map and inserts — on a read.

Three moves, in decreasing order of how much they dissolve:

1. Misfiled scratch is not shared state. visited/visit_epoch/visited_cap
   were never owned by the index; they are one traversal's local, hoisted
   into struct VIndex as an allocation optimisation. They want neither a
   lock nor a capability nor a pool — just to go back in the call frame.
   Two concurrent READS stomped each other purely because of this.

2. const IS the capability. Once the scratch leaves the struct, search
   reads and nothing else, so vindex_search takes a const VIndex*. That is
   exactly what a capability-pointer ABI would have bought — a read path
   physically cannot call vindex_insert, enforced by the compiler on every
   future caller — for one qualifier instead of an ABI swept across
   hundreds of builtins.

3. What survives is publication, not ownership. HNSW insert is NOT an
   append: it rewires the neighbour links of already-existing elements and
   reallocs elems[], so the store's append-only property does not transfer
   to the index derived from it. eg_vindex_sync therefore splits into
   eg_vindex_maintain (exclusive, sole mutator) and eg_vindex_view (shared,
   returns const VIndex*). A read path may demand that a current snapshot
   exist — a request to the owner, not a mutation by the reader.

Write-side owner: eg_vindex_note_embedded hooks the embedding-ASSIGNMENT
sites rather than the append sites, because a node with no embedding cannot
be in a vector index — embedding assignment is the event that owns index
membership. One O(log n) insert, no O(node_count) presence scan. This also
retires the "STALENESS (honest tradeoff)" note where a lazily-embedded
older node stayed invisible to route_nearest/autoconnect until a full
rebuild (the embed-gap #20 shape).

Evidence. The existing harness conflated two hazards, which is why fixing
half of it read as failure. Split into four:

  single (3000 vec, ASan+UBSan)          clean  ->  clean
  readers (4 readers, no writer, TSan)   RACE   ->  clean
  unsynchronized (writer+reader, bare)   race   ->  race, expected forever
  published (owner + 4 readers)          n/a    ->  clean, 3000/3000 landed

RESULT: PASS. recall@10 = 0.9365 at ef_search=128 (gate >= 0.90);
determinism byte-identical across two independent builds.

The unsynchronized half is now permanently expected to race, deliberately:
it is the executable proof that the boundary must live above the data
structure, not inside it.

fb32d15's guard is KEPT, correcting this design's own section 5. Measured,
it guards TWO structures and only one was converted here: g->nodes/g->edges
are realloc'd in place (el_runtime.c:7618,7629) and engram_activate_inner's
embed-backfill writes n->emb through exactly such a borrowed pointer.
Deleting the guard reintroduces a measured 11171->9579 edge loss. Its
comment is narrowed to the RAM graph and the deletion precondition named.

That corrects the ordering claim too: the residual is not one ABI that
dissolves everything at once, it is a PROPERTY applied per structure.
Residues evaporate in the order the property is applied, and a residue
whose structure has not been converted must be left standing.
This commit is contained in:
Neuron
2026-08-16 11:29:12 -05:00
committed by bigmerge
parent e99a4640e2
commit 8e9d88fc01
8 changed files with 609 additions and 112 deletions
+141 -16
View File
@@ -1627,7 +1627,21 @@ static pthread_mutex_t g_engram_req_lock = PTHREAD_MUTEX_INITIALIZER;
* inside an http_worker that already holds it (depth > 0) is a no-op, so there is
* no self-deadlock on this NON-recursive mutex. Depth is a plain counter, never a
* recursive-mutex count, which preserves engram_self_reify_beat_json's contract of
* really releasing the lock mid-beat (see engram_req_unlock at the reify beat). */
* really releasing the lock mid-beat (see engram_req_unlock at the reify beat).
*
* SCOPE NARROWED (2026-08-16, vindex publication boundary): this guard originally
* covered TWO hazards the RAM graph AND the process-global _eg_vindex. The vindex
* half is retired: the index now has its own publication boundary (_eg_vindex_rw),
* search takes a `const VIndex*`, and no read path can mutate the index at all.
*
* What REMAINS load-bearing here is the RAM graph alone, and it is a genuine,
* measured hazard independent of the index: g->nodes / g->edges are realloc'd in
* place (el_runtime.c:7618, 7629), so an awareness-thread reader holding
* `EngramNode* n = &g->nodes[i]` across a concurrent append from an http_worker
* holds a dangling pointer and engram_activate_inner's embed-backfill WRITES
* n->emb through exactly such a pointer. That is a separate residue with its own
* fix (the resident graph wants the same publication treatment the index just got);
* until it lands, this guard stays. Do NOT delete it as "the fb32d15 vindex lock". */
static __thread int _eg_req_depth = 0;
void engram_req_unlock(void){ if(_eg_req_depth > 0) _eg_req_depth--; pthread_mutex_unlock(&g_engram_req_lock); }
void engram_req_lock(void){ pthread_mutex_lock(&g_engram_req_lock); _eg_req_depth++; }
@@ -9606,6 +9620,35 @@ static double engram_goal_bias(const EngramNode* n, const char* query) {
* the exact O(n) argmax scan tops up any seed slot the ANN leaves unfilled.
* Single-threaded, matching the adjacent query-embedding cache (no lock).
* Returns NULL when no index is available caller falls back to the O(n) scan. */
/* ── VINDEX PUBLICATION BOUNDARY (2026-08-16) ────────────────────────────────
* The index is DERIVED GEOMETRY: a projection of the store's embeddings. The
* store is append-only and superseding, so a reader must be able to project
* against geometry that does not move under it.
*
* The HNSW index is NOT itself append-only: vindex_insert rewires the neighbour
* lists of ALREADY-EXISTING elements and reallocs elems[]. So "extend" is a
* mutation of the whole structure, and a reader holding element pointers across
* one is unsafe no matter how pure search itself is (measured: TSan reports the
* elems[] race even after the visited set moved to the call frame).
*
* Hence a publication boundary rather than an ownership discipline:
*
* - eg_vindex_maintain() is the ONLY mutator of the five statics below. It
* takes _eg_vindex_rw EXCLUSIVELY, so it never runs beside a reader.
* - eg_vindex_view() hands back a `const VIndex*` with the boundary held for
* READ. N readers project concurrently; none can mutate, because search
* takes a const index and the compiler enforces it.
*
* A read path may DEMAND that a current snapshot exist that is a request to
* the owner, not a mutation by the reader. What it may not do is mutate the
* geometry it is projecting against. eg_vindex_view/eg_vindex_maintain is
* exactly that split.
*
* Lock ordering: request-outer -> vindex -> store-inner. The vindex boundary is
* never held across a call that can re-enter eg_vindex_view/maintain (verified:
* the four read regions each acquire, search, release without nesting). */
static pthread_rwlock_t _eg_vindex_rw = PTHREAD_RWLOCK_INITIALIZER;
static VIndex* _eg_vindex = NULL;
static int32_t _eg_vindex_dim = 0;
static int64_t _eg_vindex_built_nc = 0; /* g->node_count at last (re)build */
@@ -9625,8 +9668,10 @@ static int eg_vindex_seen_ensure(int64_t need) {
return 0;
}
static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
if (!g || dim <= 0) return _eg_vindex;
/* THE OWNER. The only function that mutates _eg_vindex* — must be called with
* _eg_vindex_rw held EXCLUSIVELY (see eg_vindex_maintain, the sole caller). */
static void eg_vindex_publish_locked(EngramStore* g, int32_t dim) {
if (!g || dim <= 0) return;
/* Drop a stale index: embedder dim changed, or the resident array shrank
* (indices may have been reused/reordered cached node_ids unsafe). */
if (_eg_vindex && (_eg_vindex_dim != dim || g->node_count < _eg_vindex_built_nc)) {
@@ -9636,8 +9681,8 @@ static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
}
if (!_eg_vindex) {
VIndex* idx = vindex_create((int)dim, 0, 0);
if (!idx) return NULL;
if (eg_vindex_seen_ensure(g->node_count)) { vindex_free(idx); return NULL; }
if (!idx) return;
if (eg_vindex_seen_ensure(g->node_count)) { vindex_free(idx); return; }
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (n->emb && n->emb_dim == dim && vindex_insert(idx, (uint64_t)i, n->emb) == 0)
@@ -9661,8 +9706,62 @@ static VIndex* eg_vindex_sync(EngramStore* g, int32_t dim) {
}
_eg_vindex_built_nc = g->node_count;
}
}
/* Owner-mediated publish. Takes the boundary EXCLUSIVELY, so it can never run
* beside a reader. Cheap no-op when the published snapshot is already current. */
static void eg_vindex_maintain(EngramStore* g, int32_t dim) {
if (!g || dim <= 0) return;
pthread_rwlock_wrlock(&_eg_vindex_rw);
eg_vindex_publish_locked(g, dim);
pthread_rwlock_unlock(&_eg_vindex_rw);
}
/* READ SIDE. Returns the published snapshot as an IMMUTABLE view, with the
* boundary held for READ the caller MUST pair every call with exactly one
* eg_vindex_view_release(), on every path including error returns.
*
* The returned pointer is `const`: a read path physically cannot call
* vindex_insert on it. That is the compile-time constraint, and it is why this
* replaces eg_vindex_sync rather than wrapping it. May return NULL (no index
* available -> caller falls back to the exact O(n) scan); the boundary is still
* held and still must be released. */
static const VIndex* eg_vindex_view(EngramStore* g, int32_t dim) {
if (g && dim > 0) {
/* Fast path: snapshot already current, take it read-only and go. */
pthread_rwlock_rdlock(&_eg_vindex_rw);
if (_eg_vindex && _eg_vindex_dim == dim && _eg_vindex_built_nc == g->node_count)
return _eg_vindex;
/* Stale or absent. Drop to no lock, ask the owner to publish, re-acquire.
* NEVER upgrade rdlock->wrlock in place: that self-deadlocks. */
pthread_rwlock_unlock(&_eg_vindex_rw);
eg_vindex_maintain(g, dim);
}
pthread_rwlock_rdlock(&_eg_vindex_rw);
return _eg_vindex;
}
static void eg_vindex_view_release(void) {
pthread_rwlock_unlock(&_eg_vindex_rw);
}
/* WRITE-SIDE MAINTENANCE HOOK. Call after an embedding becomes present on a
* resident ordinal. A node without an embedding cannot be in a vector index at
* all, so embedding-assignment not node append is the event that owns index
* membership. Cheap: one O(log n) HNSW insert, no O(node_count) presence scan.
* A no-op before the first publish (the cold build picks the node up) and on a
* dim mismatch. */
static void eg_vindex_note_embedded(EngramStore* g, int64_t ordinal) {
if (!g || ordinal < 0 || ordinal >= g->node_count) return;
EngramNode* n = &g->nodes[ordinal];
if (!n->emb || n->emb_dim <= 0) return;
pthread_rwlock_wrlock(&_eg_vindex_rw);
if (_eg_vindex && _eg_vindex_dim == n->emb_dim &&
eg_vindex_seen_ensure(g->node_count) == 0 && !_eg_vindex_seen[ordinal]) {
if (vindex_insert(_eg_vindex, (uint64_t)ordinal, n->emb) == 0)
_eg_vindex_seen[ordinal] = 1;
}
pthread_rwlock_unlock(&_eg_vindex_rw);
}
/* ── M9 GEOMETRY PRIMING (ENGRAM_GEOMETRY_PRIMING, default OFF) ──────────────
* Opt-in wiring of the centered relational-neighborhood geometry (engram_geometry.c)
@@ -9812,6 +9911,12 @@ static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
float* v = eg_embed_fetch(n->content, &d);
if (!v) break; /* embedder down / breaker open — stop this call */
n->emb = v; n->emb_dim = d;
/* Write-side index maintenance: an embedding just became present on
* ordinal i, so the index's owner publishes it now. This is what
* retires the "STALENESS (honest tradeoff)" note above a lazily
* embedded OLDER node no longer waits for a full rebuild to become
* visible to route_nearest / autoconnect. */
eg_vindex_note_embedded(g, i);
backfilled++;
}
}
@@ -10010,7 +10115,9 @@ static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
* same budget as the exact scan's retry `guard` so dedup/threshold
* rejects still leave enough distinct seeds. */
{
VIndex* vx = eg_vindex_sync(g, q_dim);
/* Immutable view: the boundary is held for READ across the whole
* search + harvest, and released at the end of this block. */
const VIndex* vx = eg_vindex_view(g, q_dim);
if (vx && (int64_t)vindex_size(vx) >= ENGRAM_EMBED_SEED_K) {
const float* seed_qv = e_eff ? e_eff : q_emb;
int kreq = ENGRAM_EMBED_SEED_K * 8;
@@ -10054,6 +10161,7 @@ static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
}
free(aid); free(ad);
}
eg_vindex_view_release();
}
/* Exact O(n) argmax fallback / top-up (pre-M8 selection, verbatim).
@@ -10140,9 +10248,11 @@ static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
char** vids = malloc((size_t)g->node_count * sizeof(char*));
if (gmean && vids) {
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
const VIndex* gvx = eg_vindex_view(g, q_dim);
geo = engram_geometry_descriptor(
g_engram_store, _eg_vindex, vids, (int)g->node_count,
g_engram_store, gvx, vids, (int)g->node_count,
seed_ids, (size_t)nsel, NULL, gmean);
eg_vindex_view_release();
}
free(vids);
if (geo && geo->n_members > 0) {
@@ -13292,13 +13402,16 @@ static int eg_knn_for_node(EngramStore* g, int64_t self, int want, uint64_t* out
if(self < 0 || self >= g->node_count) return 0;
EngramNode* n = &g->nodes[self];
if(!n->emb || n->emb_dim <= 0) return 0;
VIndex* vx = eg_vindex_sync(g, n->emb_dim);
if(!vx) return 0;
/* Immutable view held for READ across the search; the harvest below reads
* only g->nodes, so the boundary is released as soon as the search returns. */
const VIndex* vx = eg_vindex_view(g, n->emb_dim);
if(!vx){ eg_vindex_view_release(); return 0; }
int K = want + 8;
uint64_t* ids = (uint64_t*)malloc(sizeof(uint64_t)*(size_t)K);
float* dist = (float*)malloc(sizeof(float)*(size_t)K);
if(!ids || !dist){ free(ids); free(dist); return 0; }
if(!ids || !dist){ eg_vindex_view_release(); free(ids); free(dist); return 0; }
int m = vindex_search(vx, n->emb, K, 0, ids, dist);
eg_vindex_view_release();
int c = 0;
for(int j=0; j<m && c<want; j++){
int64_t bi = (int64_t)ids[j];
@@ -13329,7 +13442,8 @@ el_val_t engram_autoconnect_node(el_val_t id_v, el_val_t k_v, el_val_t minsim_v)
EngramNode* n = &g->nodes[self];
if((!n->emb || n->emb_dim <= 0) && n->content && eg_embed_eligible(n)){
int32_t d = 0; float* v = eg_embed_fetch(n->content, &d);
if(v && d > 0){ n->emb = v; n->emb_dim = d; if(engram_store_enabled()) eg_store_put_node(n); }
if(v && d > 0){ n->emb = v; n->emb_dim = d; if(engram_store_enabled()) eg_store_put_node(n);
eg_vindex_note_embedded(g, self); }
else free(v);
}
if(!n->emb || n->emb_dim <= 0){ jb_puts(&b, "{\"connected\":0,\"reason\":\"unembedded\"}"); return el_wrap_str(b.buf); }
@@ -13464,8 +13578,10 @@ static GeoDescriptor* eg_geo_build_desc(const char* csv) {
char** vids = malloc((size_t)g->node_count * sizeof(char*));
if (gmean && vids) {
for (int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
geo = engram_geometry_descriptor(g_engram_store, _eg_vindex, vids, (int)g->node_count,
const VIndex* gvx = eg_vindex_view(g, dim);
geo = engram_geometry_descriptor(g_engram_store, gvx, vids, (int)g->node_count,
(const char* const*)ids, (size_t)ns, NULL, gmean);
eg_vindex_view_release();
}
free(vids);
for (int i = 0; i < ns; i++) free(ids[i]);
@@ -13502,12 +13618,16 @@ el_val_t engram_geo_reify_run_json(void){
int32_t dim = 0;
for(int64_t i = 0; i < g->node_count && dim == 0; i++)
if(g->nodes[i].emb && g->nodes[i].emb_dim > 0) dim = g->nodes[i].emb_dim;
VIndex* vx = (dim > 0) ? eg_vindex_sync(g, dim) : NULL;
char** vids = malloc((size_t)g->node_count * sizeof(char*));
if(!vids) return eg_geo_err("reify oom");
for(int64_t i = 0; i < g->node_count; i++) vids[i] = g->nodes[i].id;
/* Held for READ across the whole reify pass: it only searches the index.
* (The multi-second SELF-reify beat below builds a PRIVATE index instead and
* never touches this boundary at all.) */
const VIndex* vx = eg_vindex_view(g, dim);
int persisted = engram_geo_reify_store(g_engram_store, vx, vids,
(int)g->node_count, NULL);
eg_vindex_view_release();
free(vids);
int nested = 0;
if(persisted >= 0){
@@ -14231,9 +14351,13 @@ el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t di
}
/* Public activation entry point. Serializes against the http_worker threads that
* share g->nodes/g->edges and the global _eg_vindex this is the guard the
* awareness main thread (soul.el: awareness_run) was missing entirely. Nested
* calls from a worker that already holds the lock pass straight through. */
* share g->nodes/g->edges this is the guard the awareness main thread
* (soul.el: awareness_run) was missing entirely. Nested calls from a worker that
* already holds the lock pass straight through.
*
* It no longer guards _eg_vindex: the index has its own publication boundary
* (eg_vindex_view / eg_vindex_maintain) and search cannot mutate it. This guard is
* now about the RAM graph's realloc-in-place ONLY. See the note at eg_guard_enter. */
el_val_t engram_activate(el_val_t query, el_val_t depth) {
int owned = eg_guard_enter();
el_val_t r = engram_activate_inner(query, depth);
@@ -15017,6 +15141,7 @@ el_val_t engram_embed_backfill(el_val_t count) {
float* v = eg_embed_fetch(n->content, &d);
if (!v) break; /* embedder down / breaker open — stop this call */
n->emb = v; n->emb_dim = d;
eg_vindex_note_embedded(g, i); /* write-side index maintenance */
done++;
}
int64_t total = 0;