self-review 2026-07-18: fix soul SIGABRT double-free + engram route scoping sweep

1. engram_neighbors_json (release runtime): BFS frontier/visited strings were
   el_strdup'd (arena-tracked) but manually freed, so el_request_end()
   double-freed every one — SIGABRT in http_worker under load (2 prod crashes
   today via /api/neuron/session/begin and /api/neuron/graph; reproduced and
   verified fixed with ASAN). Introduced when porting from the dev runtime,
   which correctly uses plain strdup. Third instance of the
   arena-vs-manual-free class (after EngramNode 07-15 and idmap keys 07-16).

2. server.el: let-in-if scoping sweep — defaults assigned inside if-blocks
   never mutated the outer binding, so /api/search and /api/activate always
   ran with q="", created nodes got node_type=""/salience=0.0, edges got
   relation=""/weight=0.0, and save/load with no path hit engram_save("").
   Rewritten to the let-if-else expression form. /api/activate now also
   rejects empty queries instead of wiping carried WM weights.

3. engram_activate: retrieval reinforcement (ACT-R base-level learning) —
   nodes promoted to WM that survive both capacity caps now get
   last_activated/activation_count updated, so frequently retrieved memories
   decay slower than abandoned ones. Scoped to promoted-only to avoid
   flattening dampening across BFS fan-out.
This commit is contained in:
2026-07-18 08:48:04 -05:00
parent e3dabe3e08
commit ab6b52a0b4
3 changed files with 651 additions and 205 deletions
+431 -57
View File
@@ -102,6 +102,47 @@ void el_request_end(void) {
_tl_arena.count = 0;
}
/* ── Scoped arena for CLI use ─────────────────────────────────────────────── *
* CLI programs never call el_request_start/end, so all strdup allocations are
* permanent. el_arena_push/pop let the compiler free intermediate strings
* after each compilation unit. Ported verbatim from el-compiler/runtime on
* 2026-07-17: the soul daemon's awareness loop arena-scopes each tick with
* these, and they were present only in the dev runtime copy.
*
* el_arena_push() activates the arena if not already active, saves the
* current arena count as a mark, and returns it as an el_val_t Int.
* el_arena_pop(mark) frees all strings allocated since the push mark and
* resets the count. If count reaches 0, deactivates the arena.
*/
#define EL_ARENA_SCOPE_DEPTH 32
static _Thread_local size_t _tl_arena_scope[EL_ARENA_SCOPE_DEPTH];
static _Thread_local int _tl_arena_scope_depth = 0;
el_val_t el_arena_push(void) {
if (!_tl_arena_active) {
_tl_arena_active = 1;
}
if (_tl_arena_scope_depth < EL_ARENA_SCOPE_DEPTH) {
_tl_arena_scope[_tl_arena_scope_depth++] = _tl_arena.count;
}
return (el_val_t)(int64_t)_tl_arena.count;
}
el_val_t el_arena_pop(el_val_t mark) {
size_t save = (size_t)(int64_t)mark;
if (save > _tl_arena.count) save = 0;
for (size_t i = save; i < _tl_arena.count; i++) {
if (_tl_arena.ptrs[i]) {
free(_tl_arena.ptrs[i]);
_tl_arena.ptrs[i] = NULL;
}
}
_tl_arena.count = save;
if (_tl_arena_scope_depth > 0) _tl_arena_scope_depth--;
if (save == 0) _tl_arena_active = 0;
return 0;
}
/* Persistent allocation — bypasses the arena (state_set, engram internals). */
static char* el_strdup_persist(const char* s) {
if (!s) return strdup("");
@@ -1518,6 +1559,86 @@ void http_serve(el_val_t port, el_val_t handler) {
close(sock);
}
/* ── http_serve_async — non-blocking HTTP server ─────────────────────────── */
/* Runs the accept loop in a background pthread, returns immediately so the
* calling EL script can continue (e.g. to run an awareness loop).
* Ported verbatim from el-compiler/runtime on 2026-07-17: the soul daemon
* (soul.el) builds against this release runtime and calls http_serve_async,
* which was present only in the dev runtime copy.
*
* El signature: http_serve_async(port, handler) -> Void */
typedef struct { int sock; } HttpServeAsyncArg;
static void* _http_serve_async_loop(void* raw) {
HttpServeAsyncArg* a = (HttpServeAsyncArg*)raw;
int sock = a->sock;
free(a);
while (1) {
struct sockaddr_in6 cli;
socklen_t clen = sizeof(cli);
int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
if (cfd < 0) {
if (errno == EINTR) continue;
perror("accept"); break;
}
pthread_mutex_lock(&_http_conn_mu);
while (_http_conn_active >= HTTP_MAX_CONNS) {
pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
}
_http_conn_active++;
pthread_mutex_unlock(&_http_conn_mu);
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
if (!arg) { close(cfd); continue; }
arg->fd = cfd;
pthread_t tid;
if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
close(cfd); free(arg);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
pthread_mutex_unlock(&_http_conn_mu);
continue;
}
pthread_detach(tid);
}
close(sock);
return NULL;
}
void http_serve_async(el_val_t port, el_val_t handler) {
const char* hname = EL_CSTR(handler);
if (hname && looks_like_string(handler)) {
http_set_handler(handler);
}
int p = (int)port;
if (p <= 0 || p > 65535) { fprintf(stderr, "http_serve_async: invalid port %d\n", p); return; }
int sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return; }
int yes = 1; int no = 0;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons((uint16_t)p);
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(sock); return;
}
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
fprintf(stderr, "[http] async listening on [::]:%d (dual-stack)\n", p);
HttpServeAsyncArg* a = malloc(sizeof(HttpServeAsyncArg));
if (!a) { close(sock); return; }
a->sock = sock;
pthread_t tid;
if (pthread_create(&tid, NULL, _http_serve_async_loop, a) != 0) {
perror("pthread_create"); free(a); close(sock); return;
}
pthread_detach(tid);
/* Returns immediately — caller can now run awareness_run() or any loop. */
}
/* ── HTTP server v2 — request headers + structured response ──────────────── */
/*
* v2 widens the handler signature from
@@ -5926,7 +6047,16 @@ static void engram_idmap_put(EngramStore* g, const char* id, int64_t idx) {
}
/* Use tombstone slot if found (avoids growing used count unnecessarily) */
if (tomb_slot != SIZE_MAX) slot = tomb_slot;
g->id_map[slot].key = el_strdup(id);
/* MUST be el_strdup_persist: idmap keys outlive the request/tick arena.
* (2026-07-16 self-review) This was el_strdup (arena-tracked): every node
* created inside an HTTP request left its idmap key DANGLING as soon as
* el_request_end() freed the arena subsequent lookups strcmp'd freed
* memory, and any idmap_free/rebuild in a later request double-freed it
* (SIGABRT in http_worker; found via ASAN when engram_prune_telemetry
* triggered an in-request rebuild). Same allocation-discipline class as
* the 2026-07-15 EngramNode fix see the store-persistent comment above
* engram_new_id(). */
g->id_map[slot].key = el_strdup_persist(id);
g->id_map[slot].idx = idx;
g->id_map_used++;
}
@@ -6102,10 +6232,23 @@ static void engram_grow_edges(void) {
}
/* Build a fresh UUID string. Reuses uuid_new but takes the underlying char*. */
/* ── Store-persistent allocation discipline ─────────────────────────────────
* (2026-07-15 self-review) EngramNode/EngramEdge string fields OUTLIVE the
* request/tick arena they were created in. The 2026-07-13 leak-fix made
* el_strdup arena-tracked, which silently turned every node created inside
* an HTTP request (route_emit_ise, route_create_node, knowledge capture) or
* inside the soul's per-tick arena (engram_load_merge in the refresh cycle)
* into a bag of dangling pointers the moment the arena popped: readback by
* id returned {}, type-filtered scans skipped them, search returned request
* memory reused as node content, and snapshots persisted garbage ("numeric
* tier strings"). Everything written into the store must go through
* el_strdup_persist / el_strbuf_persist (plain malloc free() in
* engram_forget/evolve remains valid). Arena-tracked el_strdup remains
* correct for RETURN values handed back to EL code. */
static char* engram_new_id(void) {
el_val_t v = uuid_new();
const char* s = EL_CSTR(v);
return el_strdup(s ? s : "");
return el_strdup_persist(s ? s : "");
}
/* Convert a node into an ElMap of its fields. */
@@ -6166,12 +6309,12 @@ el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
n->id = engram_new_id();
const char* c = EL_CSTR(content);
const char* nt = EL_CSTR(node_type);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = engram_first_n_chars(c, 60);
n->tier = el_strdup("Working");
n->tags = el_strdup("");
n->metadata = el_strdup("{}");
n->content = el_strdup_persist(c ? c : "");
n->node_type = el_strdup_persist(nt && *nt ? nt : "Memory");
n->label = el_strdup_persist(engram_first_n_chars(c, 60));
n->tier = el_strdup_persist("Working");
n->tags = el_strdup_persist("");
n->metadata = el_strdup_persist("{}");
n->salience = engram_decode_score(salience);
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
n->importance = 0.5;
@@ -6203,12 +6346,12 @@ el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
const char* lb = EL_CSTR(label);
const char* ti = EL_CSTR(tier);
const char* tg = EL_CSTR(tags);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = el_strdup(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup(ti && *ti ? ti : "Working");
n->tags = el_strdup(tg ? tg : "");
n->metadata = el_strdup("{}");
n->content = el_strdup_persist(c ? c : "");
n->node_type = el_strdup_persist(nt && *nt ? nt : "Memory");
n->label = el_strdup_persist(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup_persist(ti && *ti ? ti : "Working");
n->tags = el_strdup_persist(tg ? tg : "");
n->metadata = el_strdup_persist("{}");
n->salience = engram_decode_score(salience);
n->importance = engram_decode_score(importance);
n->confidence = engram_decode_score(confidence);
@@ -6259,20 +6402,20 @@ el_val_t engram_node_layered(el_val_t content, el_val_t node_type, el_val_t labe
const char* lb = EL_CSTR(label);
const char* tg = EL_CSTR(tags);
const char* st = EL_CSTR(status);
n->content = el_strdup(c ? c : "");
n->node_type = el_strdup(nt && *nt ? nt : "Memory");
n->label = el_strdup(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup("Working");
n->tags = el_strdup(tg ? tg : "");
n->content = el_strdup_persist(c ? c : "");
n->node_type = el_strdup_persist(nt && *nt ? nt : "Memory");
n->label = el_strdup_persist(lb && *lb ? lb : (c ? engram_first_n_chars(c, 60) : ""));
n->tier = el_strdup_persist("Working");
n->tags = el_strdup_persist(tg ? tg : "");
if (st && *st) {
/* Minimal metadata payload: {"status":"..."}. Keep it cheap so
* callers using `status` don't pay JSON parse cost on every read. */
size_t sl = strlen(st) + 16;
char* meta = el_strbuf(sl);
char* meta = el_strbuf_persist(sl);
snprintf(meta, sl, "{\"status\":\"%s\"}", st);
n->metadata = meta;
} else {
n->metadata = el_strdup("{}");
n->metadata = el_strdup_persist("{}");
}
n->salience = engram_decode_score(salience);
n->importance = engram_decode_score(certainty);
@@ -6468,6 +6611,114 @@ el_val_t engram_node_count(void) {
return (el_val_t)engram_get()->node_count;
}
/* ── Telemetry retention ────────────────────────────────────────────────────
* (2026-07-16 self-review) InternalStateEvent nodes are append-only telemetry
* (heartbeat, curiosity_scan, engram_sync) written ~3/min by the awareness
* loop. Nothing ever removed them: by July 16 they were 10,175 of 13,522
* nodes 75% of the store was telemetry. They are already force-excluded
* from WM promotion (engram_activate), so their only effect was store bloat,
* snapshot bloat, and lexical-search noise.
*
* engram_prune_telemetry(older_than_ms) batch-removes ISE nodes whose
* created_at is older than now - older_than_ms, EXCEPT durable markers:
* - label "session-start" (boot history)
* - content containing "self_review" (daily review trail)
* Unlike repeated engram_forget (O(n) shift each), this is a single
* compaction pass over nodes plus one pass over edges, with one idmap
* rebuild O(nodes + edges) total, safe to call on every ISE insert.
* Returns the number of nodes removed. */
/* FNV-1a hash for the removed-id set used by the edge sweep. */
static uint64_t eg_fnv1a(const char* s) {
uint64_t h = 1469598103934665603ULL;
while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
return h;
}
el_val_t engram_prune_telemetry(el_val_t older_than_ms) {
int64_t horizon = (int64_t)older_than_ms;
if (horizon <= 0) horizon = 172800000; /* default 48h */
EngramStore* g = engram_get();
int64_t cutoff = engram_now_ms() - horizon;
/* Pass 1: mark. Collect ids of prunable nodes (ownership transferred —
* strings freed after the edge sweep). */
int64_t cap = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (n->node_type && strcmp(n->node_type, "InternalStateEvent") == 0 &&
n->created_at < cutoff) cap++;
}
if (cap == 0) return 0;
char** removed_ids = malloc((size_t)cap * sizeof(char*));
if (!removed_ids) return 0;
int64_t removed = 0, w = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
int prunable =
n->node_type && strcmp(n->node_type, "InternalStateEvent") == 0 &&
n->created_at < cutoff &&
!(n->label && strcmp(n->label, "session-start") == 0) &&
!(n->content && strstr(n->content, "self_review"));
if (prunable && removed < cap) {
removed_ids[removed++] = n->id; /* keep id for edge sweep */
free(n->content); free(n->node_type); free(n->label);
free(n->tier); free(n->tags); free(n->metadata);
} else {
if (w != i) g->nodes[w] = g->nodes[i];
w++;
}
}
g->node_count = w;
if (removed == 0) { free(removed_ids); return 0; }
/* Removed-id hash set (open addressing, power-of-two >= 2*removed). */
size_t set_cap = 16;
while (set_cap < (size_t)removed * 2) set_cap <<= 1;
const char** set = calloc(set_cap, sizeof(char*));
if (set) {
for (int64_t i = 0; i < removed; i++) {
size_t slot = eg_fnv1a(removed_ids[i]) & (set_cap - 1);
while (set[slot]) slot = (slot + 1) & (set_cap - 1);
set[slot] = removed_ids[i];
}
}
/* Pass 2: drop edges incident to any removed node (defensive — ISEs
* currently have no edges, but callers may connect them later). */
if (set) {
int64_t ew = 0;
for (int64_t r = 0; r < g->edge_count; r++) {
EngramEdge* e = &g->edges[r];
int incident = 0;
const char* ends[2] = { e->from_id, e->to_id };
for (int k = 0; k < 2 && !incident; k++) {
if (!ends[k]) continue;
size_t slot = eg_fnv1a(ends[k]) & (set_cap - 1);
while (set[slot]) {
if (strcmp(set[slot], ends[k]) == 0) { incident = 1; break; }
slot = (slot + 1) & (set_cap - 1);
}
}
if (incident) {
free(e->id); free(e->from_id); free(e->to_id);
free(e->relation); free(e->metadata);
} else {
if (ew != r) g->edges[ew] = g->edges[r];
ew++;
}
}
g->edge_count = ew;
free(set);
}
for (int64_t i = 0; i < removed; i++) free(removed_ids[i]);
free(removed_ids);
engram_idmap_rebuild(g);
engram_adj_free(g);
return (el_val_t)removed;
}
static int istr_contains(const char* hay, const char* needle) {
if (!hay || !needle || !*needle) return 0;
size_t nl = strlen(needle);
@@ -6552,10 +6803,10 @@ void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t
EngramEdge* e = &g->edges[g->edge_count];
memset(e, 0, sizeof(*e));
e->id = engram_new_id();
e->from_id = el_strdup(f);
e->to_id = el_strdup(t);
e->relation = el_strdup(r && *r ? r : "associate");
e->metadata = el_strdup("{}");
e->from_id = el_strdup_persist(f);
e->to_id = el_strdup_persist(t);
e->relation = el_strdup_persist(r && *r ? r : "associate");
e->metadata = el_strdup_persist("{}");
e->weight = engram_decode_score(weight);
if (e->weight <= 0.0 || e->weight > 1.0) e->weight = 0.5;
e->confidence = 1.0;
@@ -6853,6 +7104,14 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
}
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
/* InternalStateEvent nodes are observability-only telemetry — never
* seed activation from them. Their JSON payloads contain common words
* ("memory", "context", ...) that lexically match almost any query,
* turning telemetry into a spreading-activation ignition source. They
* are already excluded from WM promotion in pass 2; exclude them from
* seeding here too. */
if (n->node_type && strcmp(n->node_type, "InternalStateEvent") == 0)
continue;
if (istr_contains(n->content, q) ||
istr_contains(n->label, q) ||
istr_contains(n->tags, q)) {
@@ -6929,11 +7188,22 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
if (oi < 0 || oi >= g->node_count) continue;
EngramEdge* e = &g->edges[ei];
EngramNode* on = &g->nodes[oi];
/* Never propagate INTO InternalStateEvent nodes. They are already
* barred from WM promotion (pass 2) and from seeding (above), but
* as high-degree hubs they still relayed activation across the
* graph. Skipping here keeps them out of the frontier entirely. */
if (on->node_type && strcmp(on->node_type, "InternalStateEvent") == 0)
continue;
double tbonus = engram_temporal_proximity_bonus(on->created_at, seed_epoch);
double tdecay = engram_temporal_decay(on, now_ms);
double dampen = engram_activation_dampen(on);
double new_act = f.act * e->weight * SPREAD_DECAY * (1.0 + tbonus)
* tdecay * dampen;
/* Firing threshold per classic spreading-activation: sub-threshold
* activation neither updates the target nor enqueues it, so weak
* signals die out instead of flooding the whole graph with tiny
* nonzero background activation. */
if (new_act < 0.02) continue;
if (!reached[oi] || new_act > best_bg[oi]) {
best_bg[oi] = new_act;
best_hops[oi] = new_hops;
@@ -7174,6 +7444,31 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
}
}
/* ── Retrieval reinforcement (2026-07-18 self-review) ───────────────────
* ACT-R base-level learning: retrieval strengthens memory. Before this,
* NOTHING in engram_activate updated last_activated/activation_count
* only the rarely-called engram_strengthen did. Consequence: temporal
* decay aged every node from its last explicit strengthen (usually
* creation), so frequently-retrieved memories decayed identically to
* abandoned ones, and engram_activation_dampen() saw a frozen count.
*
* Scope deliberately narrow: reinforce ONLY nodes promoted to WM in THIS
* call that survived both capacity caps (wm_weights[i] > 0 = promoted this
* call; working_memory_weight > 0 = survived Pass 4/5 eviction). BFS
* fan-out touches thousands of nodes per curiosity scan reinforcing all
* of them would flatten dampening and freeze decay globally. Promotion to
* working memory is the analog of actual retrieval (executive access),
* matching ACT-R where only completed retrievals add a base-level
* presentation. Carry-over nodes (reached[i]==0) are NOT reinforced: they
* persist by decay, they were not re-retrieved. */
for (int64_t i = 0; i < g->node_count; i++) {
if (!reached[i] || wm_weights[i] <= 0.0) continue;
EngramNode* n = &g->nodes[i];
if (n->working_memory_weight <= 0.0) continue; /* evicted by cap */
n->last_activated = now_ms;
n->activation_count++;
}
/* ── Collect all background-activated nodes for the return value ────
* Callers see both layers. Context compilation uses only promoted nodes
* (working_memory_weight > 0). Sort: promoted first by wm_weight desc,
@@ -7331,12 +7626,21 @@ el_val_t engram_save(el_val_t path) {
/* Helper: extract a string field from a JSON object substring. */
static char* eg_get_str_field(const char* obj, const char* key) {
/* Returns a STORE-PERSISTENT string: callers assign the result directly
* into EngramNode/EngramEdge fields, and engram_load_merge runs inside
* the soul's per-tick arena. jp_parse_string_raw's success buffer is
* plain malloc (persist-safe, returned as-is); the empty/error returns
* were arena-tracked el_strdup("") those dangled after the tick arena
* popped and free()ing them in callers was a latent double-free. */
const char* p = json_find_key(obj, key);
if (!p) return el_strdup("");
if (*p != '"') return el_strdup("");
if (!p) return el_strdup_persist("");
if (*p != '"') return el_strdup_persist("");
JsonParser jp = { .p = p, .end = p + strlen(p), .err = 0 };
char* out = jp_parse_string_raw(&jp);
if (jp.err) { free(out); return el_strdup(""); }
/* *p == '"' is guaranteed above, so jp_parse_string_raw took its malloc
* path (its arena-tracked early-return only fires on a non-'"' start)
* free(out) on error is safe here. */
if (jp.err) { free(out); return el_strdup_persist(""); }
return out;
}
@@ -7358,6 +7662,48 @@ static const char* eg_skip_ws(const char* p) {
return p;
}
/* eg_enforce_wm_cap_on_load — clamp a snapshot-restored working-memory
* population to ENGRAM_WM_CAP.
*
* WHY (2026-07-15 self-review): engram_load restored working_memory_weight
* verbatim from the snapshot with no cap. Pass 4/5 cap enforcement only runs
* inside engram_activate so a snapshot frozen in the pre-2026-06-30 era
* (87-111 promoted nodes, all at the old 0.25 breakthrough floor) reloaded
* as-is on every boot, and heartbeats faithfully reported the stale
* population (wm_active 87-111, wm_avg pinned at exactly 0.25) forever.
* The cap must hold at every entry point that materializes WM, not just
* the activation path. Same top-K-by-weight logic as engram_activate
* Pass 5 (Cowan 2001: WM capacity is global). */
static void eg_enforce_wm_cap_on_load(EngramStore* g) {
int64_t wm_count = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].working_memory_weight > 0.0) wm_count++;
}
if (wm_count <= ENGRAM_WM_CAP) return;
double* vals = malloc((size_t)wm_count * sizeof(double));
if (!vals) return; /* skip on OOM — over cap this boot, no corruption */
int64_t vi = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].working_memory_weight > 0.0)
vals[vi++] = g->nodes[i].working_memory_weight;
}
qsort(vals, (size_t)wm_count, sizeof(double), engram_cmp_double_desc);
double cutoff = vals[ENGRAM_WM_CAP - 1];
free(vals);
int64_t above = 0;
for (int64_t i = 0; i < g->node_count; i++) {
if (g->nodes[i].working_memory_weight > cutoff) above++;
}
int64_t slots_at_cutoff = ENGRAM_WM_CAP - above;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (n->working_memory_weight <= 0.0) continue;
if (n->working_memory_weight > cutoff) continue;
if (slots_at_cutoff > 0) { slots_at_cutoff--; continue; }
n->working_memory_weight = 0.0; /* evict: over cap at load */
}
}
el_val_t engram_load(el_val_t path) {
const char* p = EL_CSTR(path);
if (!p || !*p) return 0;
@@ -7412,7 +7758,7 @@ el_val_t engram_load(el_val_t path) {
nn->tier = eg_get_str_field(obj, "tier");
nn->tags = eg_get_str_field(obj, "tags");
nn->metadata = eg_get_str_field(obj, "metadata");
if (!nn->metadata || !*nn->metadata) { free(nn->metadata); nn->metadata = el_strdup("{}"); }
if (!nn->metadata || !*nn->metadata) { free(nn->metadata); nn->metadata = el_strdup_persist("{}"); }
nn->salience = eg_get_num_field(obj, "salience");
nn->importance = eg_get_num_field(obj, "importance");
nn->confidence = eg_get_num_field(obj, "confidence");
@@ -7424,6 +7770,14 @@ el_val_t engram_load(el_val_t path) {
nn->updated_at = eg_get_int_field(obj, "updated_at");
nn->background_activation = eg_get_num_field(obj, "background_activation");
nn->working_memory_weight = eg_get_num_field(obj, "working_memory_weight");
/* Launder persisted WM weights across restarts: snapshots carry
* legacy breakthrough-floor 0.25 weights (pinned by the old
* suppression-breakthrough path) and nothing else ever launders
* them. Halve on every boot so genuine working-memory state has
* continuity across a restart while stale pinned weights decay
* out over successive boots; sub-0.05 residue drops to zero. */
nn->working_memory_weight *= 0.5;
if (nn->working_memory_weight < 0.05) nn->working_memory_weight = 0.0;
nn->suppression_count = (int32_t)eg_get_int_field(obj, "suppression_count");
/* layer_id defaults to ENGRAM_LAYER_DEFAULT (core-identity)
* for snapshots that predate the layered schema. We can't
@@ -7466,7 +7820,7 @@ el_val_t engram_load(el_val_t path) {
ee->to_id = eg_get_str_field(obj, "to_id");
ee->relation = eg_get_str_field(obj, "relation");
ee->metadata = eg_get_str_field(obj, "metadata");
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup("{}"); }
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup_persist("{}"); }
ee->weight = eg_get_num_field(obj, "weight");
ee->confidence = eg_get_num_field(obj, "confidence");
ee->created_at = eg_get_int_field(obj, "created_at");
@@ -7541,6 +7895,9 @@ el_val_t engram_load(el_val_t path) {
}
}
}
/* WM cap discipline applies to every entry point that materializes WM,
* including snapshot restore (see eg_enforce_wm_cap_on_load). */
eg_enforce_wm_cap_on_load(g);
free(data);
return 1;
}
@@ -7586,9 +7943,13 @@ el_val_t engram_load_merge(el_val_t path) {
char* obj = malloc(n + 1);
memcpy(obj, nodes_p, n); obj[n] = '\0';
char* nid = eg_get_str_field(obj, "id");
int already = (nid && *nid && engram_find_node(nid) != NULL);
/* Nodes with an empty/unparseable id can never dedup against
* the idmap, so without this guard they were re-added on EVERY
* merge cycle unbounded store growth. Skip them entirely. */
int has_id = (nid && *nid);
int already = (has_id && engram_find_node(nid) != NULL);
free(nid);
if (!already) {
if (has_id && !already) {
engram_grow_nodes();
EngramNode* nn = &g->nodes[g->node_count];
memset(nn, 0, sizeof(*nn));
@@ -7609,9 +7970,11 @@ el_val_t engram_load_merge(el_val_t path) {
nn->created_at = eg_get_int_field(obj, "created_at");
nn->updated_at = eg_get_int_field(obj, "updated_at");
nn->background_activation = eg_get_num_field(obj, "background_activation");
nn->working_memory_weight = eg_get_num_field(obj, "working_memory_weight");
if (!isfinite(nn->working_memory_weight) || nn->working_memory_weight < 0.0 || nn->working_memory_weight > 1.0)
nn->working_memory_weight = 0.0;
/* Nodes arriving via merge were never part of THIS store's
* working memory importing the source store's WM weight
* would inject foreign (often legacy 0.25 breakthrough-
* floor) weights into local WM every sync cycle. */
nn->working_memory_weight = 0.0;
nn->suppression_count = (int32_t)eg_get_int_field(obj, "suppression_count");
if (json_find_key(obj, "layer_id")) {
nn->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
@@ -7694,6 +8057,9 @@ el_val_t engram_load_merge(el_val_t path) {
}
}
/* Merged nodes can carry snapshot WM weights too — hold the cap here
* as well (see eg_enforce_wm_cap_on_load). */
eg_enforce_wm_cap_on_load(g);
free(data);
return (el_val_t)added_nodes;
}
@@ -7860,8 +8226,16 @@ el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t di
free(frontier); free(frontier_h); free(visited);
jb_putc(&b, ']'); return el_wrap_str(b.buf);
}
frontier[fc] = el_strdup(sid); frontier_h[fc] = 0; fc++;
visited[vc++] = el_strdup(sid);
/* MUST be el_strdup_persist: this function frees frontier/visited strings
* manually (lines below). el_strdup would ALSO register them in the
* per-request arena, so el_request_end() double-freed every one of them
* at the end of the HTTP request SIGABRT in http_worker under load.
* (2026-07-18 self-review; reproduced via ASAN on /api/neuron/session/begin
* and /api/neuron/graph. Same allocation-discipline class as the
* 2026-07-15 EngramNode and 2026-07-16 idmap-key fixes: never mix arena
* tracking with manual free.) */
frontier[fc] = el_strdup_persist(sid); frontier_h[fc] = 0; fc++;
visited[vc++] = el_strdup_persist(sid);
int first = 1;
while (fc > 0) {
@@ -7890,8 +8264,8 @@ el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t di
char tmp[64]; snprintf(tmp, sizeof(tmp), ",\"hops\":%lld}", (long long)(h + 1));
jb_puts(&b, tmp);
first = 0;
if (vc < 1024) visited[vc++] = el_strdup(peer);
if (fc < 1024 && h + 1 < depth) { frontier[fc] = el_strdup(peer); frontier_h[fc] = h + 1; fc++; }
if (vc < 1024) visited[vc++] = el_strdup_persist(peer);
if (fc < 1024 && h + 1 < depth) { frontier[fc] = el_strdup_persist(peer); frontier_h[fc] = h + 1; fc++; }
}
free(cur);
}
@@ -8612,13 +8986,13 @@ static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int cr
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = el_strdup(self_id);
n->content = el_strdup(_el_cgi_dharma_id ? _el_cgi_dharma_id : "(self)");
n->node_type = el_strdup("DharmaSelf");
n->label = el_strdup("dharma:self");
n->tier = el_strdup("Working");
n->tags = el_strdup("dharma");
n->metadata = el_strdup("{}");
n->id = el_strdup_persist(self_id);
n->content = el_strdup_persist(_el_cgi_dharma_id ? _el_cgi_dharma_id : "(self)");
n->node_type = el_strdup_persist("DharmaSelf");
n->label = el_strdup_persist("dharma:self");
n->tier = el_strdup_persist("Working");
n->tags = el_strdup_persist("dharma");
n->metadata = el_strdup_persist("{}");
n->salience = 1.0; n->importance = 1.0; n->confidence = 1.0;
int64_t now = engram_now_ms();
n->created_at = now; n->updated_at = now; n->last_activated = now;
@@ -8629,13 +9003,13 @@ static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int cr
engram_grow_nodes();
EngramNode* n = &g->nodes[g->node_count];
memset(n, 0, sizeof(*n));
n->id = el_strdup(peer_node);
n->content = el_strdup(peer_base);
n->node_type = el_strdup("DharmaPeer");
n->label = el_strdup(peer_node);
n->tier = el_strdup("Working");
n->tags = el_strdup("dharma");
n->metadata = el_strdup("{}");
n->id = el_strdup_persist(peer_node);
n->content = el_strdup_persist(peer_base);
n->node_type = el_strdup_persist("DharmaPeer");
n->label = el_strdup_persist(peer_node);
n->tier = el_strdup_persist("Working");
n->tags = el_strdup_persist("dharma");
n->metadata = el_strdup_persist("{}");
n->salience = 0.5; n->importance = 0.5; n->confidence = 1.0;
int64_t now = engram_now_ms();
n->created_at = now; n->updated_at = now; n->last_activated = now;
@@ -8647,10 +9021,10 @@ static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int cr
EngramEdge* e = &g->edges[g->edge_count];
memset(e, 0, sizeof(*e));
e->id = engram_new_id();
e->from_id = el_strdup(self_id);
e->to_id = el_strdup(peer_node);
e->relation = el_strdup("dharma-relation");
e->metadata = el_strdup("{}");
e->from_id = el_strdup_persist(self_id);
e->to_id = el_strdup_persist(peer_node);
e->relation = el_strdup_persist("dharma-relation");
e->metadata = el_strdup_persist("{}");
e->weight = 0.0;
e->confidence = 1.0;
int64_t now = engram_now_ms();