/*
 * el_runtime.c — El language C runtime implementation
 *
 * All functions use el_val_t (= int64_t) as the universal value type.
 * Strings are transported as their pointer address cast to int64_t.
 * On any 64-bit system sizeof(pointer) <= sizeof(int64_t), so this is safe.
 *
 * Compile with:
 *   cc -std=c11 -I<runtime-dir> -lcurl -lpthread -o <prog> <prog>.c el_runtime.c
 *
 * Link requirements: -lcurl (HTTP client + LLM), -lpthread (HTTP server).
 */

/* Feature-test macros must be set before any standard headers. _GNU_SOURCE
 * exposes clock_gettime/CLOCK_REALTIME, strcasecmp, and the dlfcn extensions
 * (RTLD_DEFAULT) — all of which macOS hands us without asking but glibc on
 * Debian gates behind an explicit opt-in.                                   */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include "el_runtime.h"

#ifdef _WIN32
/* Windows OS-boundary shim (winsock/dlsym/popen). Threading stays on <pthread.h> (winpthreads). */
#include "el_platform_win.h"
#else
#include <stdarg.h>
#include <strings.h>     /* strcasecmp */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
#include <math.h>
#include <time.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>    /* SIGPIPE disposition: a hung-up client must not kill us */
#include <dlfcn.h>      /* dlsym for http_set_handler fallback */
#include <unistd.h>
#include <fcntl.h>
#include <sys/file.h>   /* flock — process-identity singleton (program block) */
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
/* On POSIX, sockets close with the same close() as files; el_platform_win.h supplies the Windows
   variant. Defined here so the socket call sites are identical across platforms. */
static inline int el_closesocket(int s) { return close(s); }
#endif
/* libcurl: present on POSIX and on the WITH_CURL Windows build; absent on the curl-less Windows
   validation build, where el_platform_win.h supplies degradable stubs. On POSIX (_WIN32 undefined)
   this is always taken, so the POSIX build is unchanged. */
#if !defined(_WIN32) || defined(HAVE_CURL)
#include <curl/curl.h>
#endif

/* ── Internal allocators ─────────────────────────────────────────────────── */

/*
 * Per-request string arena
 *
 * Every El string allocated via el_strbuf / el_strdup during an HTTP request
 * is registered in a thread-local arena. When el_request_end() is called at
 * the end of the worker thread, every arena entry is freed — recovering all
 * the intermediate strings from el_str_concat chains (build_system_prompt,
 * engram_compile, etc.) that are otherwise leaked forever.
 *
 * Long-lived allocations (state_set values, engram internal storage) call
 * el_strdup_persist() / el_strbuf_persist() which bypass the arena entirely.
 */

#define EL_ARENA_INITIAL 512

typedef struct {
    char**  ptrs;
    size_t  count;
    size_t  cap;
} ElArena;

static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
static _Thread_local int     _tl_arena_active = 0;

/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
 * Allows serving PNGs and other binary files without strlen truncation.
 * PAIRED with the buffer pointer it describes: the length may only be applied
 * to the exact buffer fs_read returned. Without the pairing, any handler that
 * fs_read a file and then WRAPPED it into a larger response had that response
 * truncated to the file's length (Content-Length lied AND the send stopped
 * short) — the safety-contact onboarding trap, 2026-07-17. */
static _Thread_local size_t      _tl_fs_read_len  = 0;
static _Thread_local const char* _tl_fs_read_buf  = NULL;

static void el_arena_track(char* p) {
    if (!_tl_arena_active || !p) return;
    if (_tl_arena.count >= _tl_arena.cap) {
        size_t nc = _tl_arena.cap == 0 ? EL_ARENA_INITIAL : _tl_arena.cap * 2;
        char** grown = realloc(_tl_arena.ptrs, nc * sizeof(char*));
        if (!grown) return; /* can't track — will leak this one ptr, but don't crash */
        _tl_arena.ptrs = grown;
        _tl_arena.cap  = nc;
    }
    _tl_arena.ptrs[_tl_arena.count++] = p;
}

/* el_request_start / el_request_end moved to el_seed.c (see its comment at the
 * definition: "formerly defined in el_runtime.c. Now self-contained in
 * el_seed.c, delegating to the seed arena."). The copies here were left behind
 * during that move and made el_seed.o + el_runtime.o fail to link together with
 * duplicate symbols — which is exactly the link the real product build does.
 * Declared (not defined) here: el_runtime.c's http_worker still calls them. */
void el_request_start(void);
void el_request_end(void);

/* ── 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;
}

/* ── String-length cache ─────────────────────────────────────────────────────
 *
 * THE COMPILER'S QUADRATIC LIVED HERE. str_char_code and str_slice each called
 * strlen() on every invocation. The lexer walks source one character at a time,
 * so每 access rescanned the whole remaining input: O(n) per character over n
 * characters = O(n^2). Measured on a geometric sweep of synthetic sources,
 * wall-clock rose 3.0x, 3.0x, 4.0x, 4.14x per doubling — converging on 4x, a
 * textbook quadratic — and a stack sample put 779 of 779 samples inside lex(),
 * every one bottoming out in _platform_strlen.
 *
 * The fix is to remember the length instead of recomputing it. The subtlety is
 * INVALIDATION: El strings are arena-allocated, so a freed pointer can be
 * reused for a different string at the same address. A naive pointer-keyed
 * cache would then hand back a stale length and read past the end of the new
 * string — trading a performance bug for a memory-safety one.
 *
 * So entries carry a generation. Anything that frees or mutates runtime strings
 * bumps the generation, and a cache hit requires both the pointer AND the
 * generation to match. Stale entries can never be believed; they simply miss
 * and recompute.
 * ──────────────────────────────────────────────────────────────────────────── */
#define EL_SLC_SLOTS 8
typedef struct { const char* ptr; size_t len; uint64_t gen; } ElStrLenEnt;
static ElStrLenEnt _el_slc[EL_SLC_SLOTS];
static uint64_t    _el_str_gen = 1;

/* Called by every path that frees or mutates a runtime string. */
void el_str_cache_flush(void) { _el_str_gen++; }

static size_t el_strlen_cached(const char* s) {
    if (!s) return 0;
    size_t slot = ((uintptr_t)s >> 4) & (EL_SLC_SLOTS - 1);
    ElStrLenEnt* e = &_el_slc[slot];
    if (e->ptr == s && e->gen == _el_str_gen) return e->len;
    size_t n = strlen(s);
    e->ptr = s; e->len = n; e->gen = _el_str_gen;
    return n;
}

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;
    el_str_cache_flush();          /* freed pointers may be reused — see cache note */
    return 0;
}

/* ── Allocation accounting ───────────────────────────────────────────────────
 *
 * Every string allocation in the runtime funnels through the four functions
 * below, so counting here counts everything the language does.
 *
 * WHY THIS EXISTS: a growth-curve gate needs a signal that is DETERMINISTIC.
 * Wall-clock needs statistics, warmup, and a quiet machine; it is noisy on
 * shared CI and unusable as a hard build gate. Allocation COUNT has none of
 * those problems — the same input allocates the same number of times on every
 * machine, every run. Fit allocations against input size and a complexity
 * regression becomes a build failure with zero flake.
 *
 * This is not hypothetical. elc's known defect is quadratic ALLOCATION VOLUME.
 * The old shipped binary paid it in RSS (27 GB, OOM); the rebuilt one pays the
 * same quadratic in malloc/free churn (42s on a 1.4 MB input). The allocation
 * count was the invariant across both — RSS and wall-clock were just the two
 * ways it surfaced. An `expect allocs O(n)` assertion on the compile path
 * would have failed the build the day it was introduced.
 *
 * Peak RSS is exported too but is explicitly NOT the gating signal: it is
 * perturbed by allocator behaviour, page cache, and the OS. Gate on counts,
 * report RSS as context.
 *
 * Counters are plain unsigned longs, incremented on the allocating thread with
 * no synchronisation: this is measurement, and a lock here would change the
 * thing being measured. Under threads the count is approximate; for the
 * single-threaded compile path it is exact.
 * ──────────────────────────────────────────────────────────────────────────── */
static unsigned long _el_alloc_count = 0;
static unsigned long _el_alloc_bytes = 0;

/* Persistent allocation — bypasses the arena (state_set, engram internals). */
static char* el_strdup_persist(const char* s) {
    if (!s) { _el_alloc_count++; _el_alloc_bytes += 1; return strdup(""); }
    _el_alloc_count++; _el_alloc_bytes += strlen(s) + 1;
    return strdup(s);
}
static char* el_strbuf_persist(size_t n) {
    char* p = malloc(n + 1);
    if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    p[0] = '\0';
    _el_alloc_count++; _el_alloc_bytes += n + 1;
    return p;
}

static char* el_strdup(const char* s) {
    if (!s) { char* p = strdup(""); _el_alloc_count++; _el_alloc_bytes += 1; el_arena_track(p); return p; }
    char* p = strdup(s);
    _el_alloc_count++; _el_alloc_bytes += strlen(s) + 1;
    el_arena_track(p);
    return p;
}

static char* el_strbuf(size_t n) {
    char* p = malloc(n + 1);
    if (!p) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    p[0] = '\0';
    _el_alloc_count++; _el_alloc_bytes += n + 1;
    el_arena_track(p);
    return p;
}

/* Wrap an allocated C string as el_val_t */
static el_val_t el_wrap_str(char* s) {
    return EL_STR(s);
}

/* ── I/O ──────────────────────────────────────────────────────────────────── */

void println(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (str) puts(str);
    else     puts("");
}

void print(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (str) fputs(str, stdout);
}

el_val_t readline(void) {
    char buf[4096];
    if (!fgets(buf, sizeof(buf), stdin)) return el_wrap_str(el_strdup(""));
    size_t len = strlen(buf);
    if (len > 0 && buf[len - 1] == '\n') buf[len - 1] = '\0';
    return el_wrap_str(el_strdup(buf));
}

/* ── String builtins ─────────────────────────────────────────────────────── */

el_val_t el_str_concat(el_val_t av, el_val_t bv) {
    const char* a = EL_CSTR(av);
    const char* b = EL_CSTR(bv);
    if (!a) a = "";
    if (!b) b = "";
    size_t la = strlen(a);
    size_t lb = strlen(b);
    char* out = el_strbuf(la + lb);
    memcpy(out, a, la);
    memcpy(out + la, b, lb);
    out[la + lb] = '\0';
    return el_wrap_str(out);
}

el_val_t str_eq(el_val_t av, el_val_t bv) {
    const char* a = EL_CSTR(av);
    const char* b = EL_CSTR(bv);
    if (!a || !b) return (el_val_t)(a == b);
    return (el_val_t)(strcmp(a, b) == 0);
}

el_val_t str_starts_with(el_val_t sv, el_val_t prefv) {
    const char* s      = EL_CSTR(sv);
    const char* prefix = EL_CSTR(prefv);
    if (!s || !prefix) return 0;
    size_t lp = strlen(prefix);
    return (el_val_t)(strncmp(s, prefix, lp) == 0);
}

el_val_t str_ends_with(el_val_t sv, el_val_t sufv) {
    const char* s      = EL_CSTR(sv);
    const char* suffix = EL_CSTR(sufv);
    if (!s || !suffix) return 0;
    size_t ls   = strlen(s);
    size_t lsuf = strlen(suffix);
    if (lsuf > ls) return 0;
    return (el_val_t)(strcmp(s + ls - lsuf, suffix) == 0);
}

el_val_t str_len(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    return (el_val_t)strlen(s);
}

el_val_t str_concat(el_val_t a, el_val_t b) {
    return el_str_concat(a, b);
}

el_val_t int_to_str(el_val_t n) {
    char buf[32];
    snprintf(buf, sizeof(buf), "%lld", (long long)n);
    return el_wrap_str(el_strdup(buf));
}

el_val_t str_to_int(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    return (el_val_t)atoll(s);
}

el_val_t str_slice(el_val_t sv, el_val_t start, el_val_t end) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    int64_t len = (int64_t)el_strlen_cached(s);
    if (start < 0) start = 0;
    if (end > len) end = len;
    if (start >= end) return el_wrap_str(el_strdup(""));
    int64_t sz = end - start;
    char* out = el_strbuf((size_t)sz);
    memcpy(out, s + start, (size_t)sz);
    out[sz] = '\0';
    return el_wrap_str(out);
}

el_val_t str_contains(el_val_t sv, el_val_t subv) {
    const char* s   = EL_CSTR(sv);
    const char* sub = EL_CSTR(subv);
    if (!s || !sub) return 0;
    return (el_val_t)(strstr(s, sub) != NULL);
}

el_val_t str_replace(el_val_t sv, el_val_t fromv, el_val_t tov) {
    const char* s    = EL_CSTR(sv);
    const char* from = EL_CSTR(fromv);
    const char* to   = EL_CSTR(tov);
    if (!s || !from || !to) return el_wrap_str(el_strdup(s ? s : ""));
    size_t ls = strlen(s);
    size_t lf = strlen(from);
    size_t lt = strlen(to);
    if (lf == 0) return el_wrap_str(el_strdup(s));
    size_t count = 0;
    const char* p = s;
    while ((p = strstr(p, from)) != NULL) { count++; p += lf; }
    size_t out_sz = ls + count * lt + 1;
    char* out = el_strbuf(out_sz);
    char* dst = out;
    p = s;
    const char* found;
    while ((found = strstr(p, from)) != NULL) {
        size_t chunk = (size_t)(found - p);
        memcpy(dst, p, chunk); dst += chunk;
        memcpy(dst, to, lt);  dst += lt;
        p = found + lf;
    }
    strcpy(dst, p);
    return el_wrap_str(out);
}

el_val_t str_to_upper(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    for (size_t i = 0; i < n; i++) out[i] = (char)toupper((unsigned char)s[i]);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_to_lower(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    for (size_t i = 0; i < n; i++) out[i] = (char)tolower((unsigned char)s[i]);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_trim(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    while (*s && isspace((unsigned char)*s)) s++;
    size_t n = strlen(s);
    while (n > 0 && isspace((unsigned char)s[n - 1])) n--;
    char* out = el_strbuf(n);
    memcpy(out, s, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

/* ── Math ────────────────────────────────────────────────────────────────── */

el_val_t el_abs(el_val_t n)             { return n < 0 ? -n : n; }
el_val_t el_max(el_val_t a, el_val_t b) { return a > b ? a : b; }
el_val_t el_min(el_val_t a, el_val_t b) { return a < b ? a : b; }

/* ── Refcounted heap objects ──────────────────────────────────────────────────
 *
 * ElList and ElMap carry a magic-tagged header at offset 0:
 *   { uint32_t magic; uint32_t refcount; ... payload ... }
 *
 * The magic tag distinguishes refcounted objects from raw C strings (whose
 * first byte is printable ASCII < 0x80) and from small integers (which can't
 * be dereferenced). el_retain / el_release sniff the magic and act only on
 * matching values; everything else is a safe no-op.
 *
 * Both ElList and ElMap use INDIRECTION: the header is fixed-size and never
 * moves. The payload arrays (elems, keys, values) live in separate heap
 * allocations, so realloc-grow on append never invalidates the caller's
 * pointer to the header. This is what lets us mutate-in-place safely when
 * the refcount is 1 and copy-on-write when it's higher.
 *
 * Memory model in practice:
 *   Single-owner accumulator (the cg_stmts pattern) — refcount stays at 1,
 *   appends amortize to O(1), total memory O(N) for an N-element list.
 *   Multi-owner branching (the cg_if_stmt pattern) — refcount > 1, each
 *   append on a shared list copies, so the original is preserved for the
 *   else-branch. Persistent semantics where they're needed; mutation where
 *   they're not.                                                              */

#define EL_MAGIC_LIST  0xE15710A1u  /* >= 0x80 in MSB so 'looks_like_string' rejects */
#define EL_MAGIC_MAP   0xE19A704Bu

typedef struct {
    uint32_t magic;
    uint32_t refcount;
} ElHeader;

/* ── List ────────────────────────────────────────────────────────────────── */

typedef struct {
    ElHeader  hdr;
    int64_t   length;
    int64_t   capacity;
    el_val_t* elems;
} ElList;

static ElList* list_alloc(int64_t cap) {
    if (cap < 4) cap = 4;
    ElList* lst = malloc(sizeof(ElList));
    _el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
    if (!lst) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    lst->hdr.magic = EL_MAGIC_LIST;
    lst->hdr.refcount = 1;
    lst->length   = 0;
    lst->capacity = cap;
    lst->elems    = malloc((size_t)cap * sizeof(el_val_t));
    _el_alloc_count++; _el_alloc_bytes += (size_t)cap * sizeof(el_val_t);
    if (!lst->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    return lst;
}

el_val_t el_list_empty(void) {
    return EL_STR(list_alloc(4));
}

el_val_t el_list_new(el_val_t count, ...) {
    ElList* lst = list_alloc(count > 0 ? count : 4);
    va_list ap;
    va_start(ap, count);
    for (int64_t i = 0; i < count; i++) {
        lst->elems[i] = va_arg(ap, el_val_t);
    }
    va_end(ap);
    lst->length = count;
    return EL_STR(lst);
}

el_val_t el_list_len(el_val_t listv) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) return 0;
    return lst->length;
}

el_val_t el_list_get(el_val_t listv, el_val_t index) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) return 0;
    if (index < 0 || index >= lst->length) return 0;
    return lst->elems[index];
}

el_val_t el_list_append(el_val_t listv, el_val_t elem) {
    ElList* old = (ElList*)(uintptr_t)listv;
    if (!old) {
        ElList* fresh = list_alloc(4);
        fresh->elems[0] = elem;
        fresh->length = 1;
        return EL_STR(fresh);
    }

    /* Uniquely owned: grow the elems buffer in place. The header pointer the
     * caller holds doesn't move (we only realloc the inner array). This is
     * the common case in compiler accumulators, and it's amortized O(1). */
    if (old->hdr.refcount <= 1) {
        if (old->length >= old->capacity) {
            int64_t new_cap = old->capacity > 0 ? old->capacity * 2 : 4;
            el_val_t* grown = realloc(old->elems, (size_t)new_cap * sizeof(el_val_t));
            _el_alloc_count++; _el_alloc_bytes += (size_t)new_cap * sizeof(el_val_t);
            if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
            old->elems    = grown;
            old->capacity = new_cap;
        }
        old->elems[old->length++] = elem;
        return listv;
    }

    /* Shared: copy-on-write. The original is preserved for its other owners. */
    int64_t new_cap = old->length + 1;
    if (new_cap < 4) new_cap = 4;
    ElList* fresh = malloc(sizeof(ElList));
    _el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_LIST;
    fresh->hdr.refcount = 1;
    fresh->length       = old->length + 1;
    fresh->capacity     = new_cap;
    fresh->elems        = malloc((size_t)new_cap * sizeof(el_val_t));
    _el_alloc_count++; _el_alloc_bytes += (size_t)new_cap * sizeof(el_val_t);
    if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (old->length > 0) {
        memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
    }
    fresh->elems[old->length] = elem;
    return EL_STR(fresh);
}

el_val_t el_list_clone(el_val_t listv) {
    /* Shallow copy: the new ElList owns its own header and elems buffer, but
     * the elements themselves are shared (which is what callers want for the
     * cg_if_stmt 'declared' pattern — cloning the spine, not its contents).
     * Used by codegen at scope branch points where two child scopes need to
     * see the same starting set of declared names without each other's
     * mutations.                                                              */
    ElList* old = (ElList*)(uintptr_t)listv;
    if (!old) return el_list_empty();
    int64_t cap = old->capacity > 0 ? old->capacity : 4;
    if (cap < old->length) cap = old->length;
    if (cap < 4) cap = 4;
    ElList* fresh = malloc(sizeof(ElList));
    _el_alloc_count++; _el_alloc_bytes += sizeof(ElList);
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_LIST;
    fresh->hdr.refcount = 1;
    fresh->length       = old->length;
    fresh->capacity     = cap;
    fresh->elems        = malloc((size_t)cap * sizeof(el_val_t));
    _el_alloc_count++; _el_alloc_bytes += (size_t)cap * sizeof(el_val_t);
    if (!fresh->elems) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (old->length > 0) {
        memcpy(fresh->elems, old->elems, (size_t)old->length * sizeof(el_val_t));
    }
    return EL_STR(fresh);
}

/* ── Map ─────────────────────────────────────────────────────────────────── */

typedef struct {
    ElHeader  hdr;
    int64_t   count;
    int64_t   capacity;
    el_val_t* keys;
    el_val_t* values;
} ElMap;

static ElMap* map_alloc(int64_t cap) {
    if (cap < 4) cap = 4;
    ElMap* m = malloc(sizeof(ElMap));
    _el_alloc_count++; _el_alloc_bytes += sizeof(ElMap);
    if (!m) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    m->hdr.magic    = EL_MAGIC_MAP;
    m->hdr.refcount = 1;
    m->count        = 0;
    m->capacity     = cap;
    m->keys         = malloc((size_t)cap * sizeof(el_val_t));
    m->values       = malloc((size_t)cap * sizeof(el_val_t));
    if (!m->keys || !m->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    return m;
}

el_val_t el_map_new(el_val_t pair_count, ...) {
    ElMap* m = map_alloc(pair_count > 0 ? pair_count : 4);
    va_list ap;
    va_start(ap, pair_count);
    for (int64_t i = 0; i < pair_count; i++) {
        m->keys[i]   = va_arg(ap, el_val_t);
        m->values[i] = va_arg(ap, el_val_t);
    }
    va_end(ap);
    m->count = pair_count;
    return EL_STR(m);
}

static ElMap* as_map(el_val_t v) { return (ElMap*)(uintptr_t)v; }

el_val_t el_map_get(el_val_t mapv, el_val_t keyv) {
    ElMap* m = as_map(mapv);
    const char* key = EL_CSTR(keyv);
    if (!m || !key) return 0;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        if (k && strcmp(k, key) == 0) return m->values[i];
    }
    return 0;
}

el_val_t el_get_field(el_val_t mapv, el_val_t keyv) {
    return el_map_get(mapv, keyv);
}

/* Internal: in-place set on a uniquely-owned map. */
static el_val_t map_set_in_place(ElMap* m, el_val_t keyv, el_val_t value) {
    const char* key = EL_CSTR(keyv);
    if (key) {
        for (int64_t i = 0; i < m->count; i++) {
            const char* k = EL_CSTR(m->keys[i]);
            if (k && strcmp(k, key) == 0) { m->values[i] = value; return EL_STR(m); }
        }
    }
    if (m->count >= m->capacity) {
        int64_t new_cap = m->capacity > 0 ? m->capacity * 2 : 4;
        el_val_t* gk = realloc(m->keys,   (size_t)new_cap * sizeof(el_val_t));
        el_val_t* gv = realloc(m->values, (size_t)new_cap * sizeof(el_val_t));
        if (!gk || !gv) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        m->keys     = gk;
        m->values   = gv;
        m->capacity = new_cap;
    }
    m->keys[m->count]   = keyv;
    m->values[m->count] = value;
    m->count++;
    return EL_STR(m);
}

el_val_t el_map_set(el_val_t mapv, el_val_t keyv, el_val_t value) {
    ElMap* m = as_map(mapv);
    if (!m) return 0;
    if (m->hdr.refcount <= 1) {
        return map_set_in_place(m, keyv, value);
    }
    /* Shared: copy then set. The original is preserved for its other owners. */
    int64_t new_cap = m->count + 1;
    if (new_cap < 4) new_cap = 4;
    ElMap* fresh = malloc(sizeof(ElMap));
    _el_alloc_count++; _el_alloc_bytes += sizeof(ElMap);
    if (!fresh) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    fresh->hdr.magic    = EL_MAGIC_MAP;
    fresh->hdr.refcount = 1;
    fresh->count        = m->count;
    fresh->capacity     = new_cap;
    fresh->keys         = malloc((size_t)new_cap * sizeof(el_val_t));
    fresh->values       = malloc((size_t)new_cap * sizeof(el_val_t));
    if (!fresh->keys || !fresh->values) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    if (m->count > 0) {
        memcpy(fresh->keys,   m->keys,   (size_t)m->count * sizeof(el_val_t));
        memcpy(fresh->values, m->values, (size_t)m->count * sizeof(el_val_t));
    }
    return map_set_in_place(fresh, keyv, value);
}

/* ── Refcount ops ─────────────────────────────────────────────────────────── */
/*
 * Both retain and release sniff the magic header to decide whether a value
 * is a refcounted heap object. For small integers, raw C strings, and any
 * value whose magic word doesn't match, both functions are no-ops. This lets
 * codegen emit them on every let-binding without having to track types.
 *
 * Safety: we filter out obvious non-pointers (small magnitudes, misaligned
 * addresses) before dereferencing. For any value that passes the filter and
 * lives in a mapped page, reading the first 4 bytes is safe — strings start
 * with printable ASCII (< 0x80), so their magic word will never collide with
 * EL_MAGIC_LIST (0xE1...) or EL_MAGIC_MAP (0xE1...). Random integers that
 * happen to look like aligned heap pointers are exceedingly unlikely to land
 * on a page whose first 4 bytes match either magic.                          */

/* el_tagged — THE gate for "is this slot a heap object carrying this tag".
 *
 * el_val_t carries both integers and tagged heap pointers, so deciding which
 * requires checking the value BEFORE dereferencing it. That check was a
 * convention every author had to know rather than a gate they had to pass
 * through, and the result is measurable: geom_of and mfld_of call
 * looks_like_heap_obj and are correct; el_bin_lookup checked only a 4096 floor
 * -- no alignment, no small-int, no negative -- and reads EIGHT BYTES BACKWARD
 * from the pointer. sha256_hex(50000) therefore compiled clean and segfaulted.
 *
 * Exported, so the engram siblings stop re-deriving it. Anything that
 * dereferences a slot without passing through here is the defect. */
int el_tagged(el_val_t v, uint32_t magic);

static int looks_like_heap_obj(el_val_t v) {
    if (v == 0) return 0;
    int64_t s = (int64_t)v;
    if (s > -0x10000 && s < 0x10000) return 0;     /* small ints */
    uintptr_t p = (uintptr_t)v;
    if (p < 0x10000) return 0;                      /* low addresses */
    if (p & 0x7) return 0;                          /* malloc returns 8-aligned */
    return 1;
}

int el_tagged(el_val_t v, uint32_t magic) {
    if (!looks_like_heap_obj(v)) return 0;
    return *(const uint32_t*)(uintptr_t)v == magic;
}


void el_retain(el_val_t v) {
    if (!looks_like_heap_obj(v)) return;
    ElHeader* h = (ElHeader*)(uintptr_t)v;
    if (h->magic == EL_MAGIC_LIST || h->magic == EL_MAGIC_MAP) {
        h->refcount++;
    }
}

void el_release(el_val_t v) {
    if (!looks_like_heap_obj(v)) return;
    ElHeader* h = (ElHeader*)(uintptr_t)v;
    if (h->magic == EL_MAGIC_LIST) {
        if (h->refcount > 0 && --h->refcount == 0) {
            ElList* l = (ElList*)h;
            free(l->elems);
            l->hdr.magic = 0;   /* poison so use-after-free is detected */
            free(l);
        }
    } else if (h->magic == EL_MAGIC_MAP) {
        if (h->refcount > 0 && --h->refcount == 0) {
            ElMap* m = (ElMap*)h;
            free(m->keys);
            free(m->values);
            m->hdr.magic = 0;
            free(m);
        }
    }
}

/* ── Batch 2/3 forward decls (defined later in JSON section) ────────────── */

typedef struct JsonBuf JsonBuf;
typedef struct JsonParser JsonParser;
static void jb_init(JsonBuf* b);
static void jb_putc(JsonBuf* b, char c);
static void jb_puts(JsonBuf* b, const char* s);
static void jb_emit_escaped(JsonBuf* b, const char* s);
static int  looks_like_string(el_val_t v);
static const char* json_find_key(const char* s, const char* key);
static const char* json_skip_value(const char* p);
static char* jp_parse_string_raw(JsonParser* jp);

/* Struct definitions are visible here because batch 2/3 helpers above use
 * them by value; the bodies (jb_init, etc.) appear in the JSON section. */
struct JsonBuf {
    char*  buf;
    size_t len;
    size_t cap;
};

struct JsonParser {
    const char* p;
    const char* end;
    int         err;
};

/* ── Batch 2: Real HTTP (libcurl client + POSIX-socket server) ───────────── */
/*
 * Client: blocking libcurl easy-handle calls. Errors are returned as a JSON
 * fragment {"error":"..."} so callers can detect via str_starts_with("{") /
 * json_get_string("error", ...).
 *
 * Server: bind/listen/accept loop on a TCP socket. Each accepted connection
 * is handled in its own pthread (detached). A semaphore-style counter caps
 * concurrent in-flight connections at HTTP_MAX_CONNS (64). When the cap is
 * reached, accept() blocks until a worker exits. This prevents runaway
 * thread creation under high load.
 *
 * Handler dispatch: El does not expose first-class function references at
 * the runtime layer, so the second argument to http_serve(port, handler) is
 * treated as a string name (or any el_val_t — the runtime ignores its
 * value and uses the registry). Callers register a C-level handler via
 *
 *     extern void el_runtime_register_handler(const char* name,
 *                                              el_val_t (*fn)(el_val_t,
 *                                                             el_val_t,
 *                                                             el_val_t));
 *
 * and select the active handler by calling http_set_handler("name") from
 * El, or by setting it directly through the C registry. If no handler is
 * registered, the server replies with a 200 carrying a default message so
 * the loop is observable.
 */

/* ── HTTP client write-callback buffer ───────────────────────────────────── */

typedef struct {
    char*  data;
    size_t len;
    size_t cap;
} HttpBuf;

static void httpbuf_init(HttpBuf* b) {
    b->cap = 1024;
    b->len = 0;
    b->data = malloc(b->cap);
    if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->data[0] = '\0';
}

static void httpbuf_append(HttpBuf* b, const void* src, size_t n) {
    if (b->len + n + 1 > b->cap) {
        while (b->len + n + 1 > b->cap) b->cap *= 2;
        b->data = realloc(b->data, b->cap);
        if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    }
    memcpy(b->data + b->len, src, n);
    b->len += n;
    b->data[b->len] = '\0';
}

static size_t http_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
    size_t n = size * nmemb;
    httpbuf_append((HttpBuf*)ud, ptr, n);
    return n;
}

/* JSON-escape an arbitrary C string into an allocated buffer. */
static char* json_escape_alloc(const char* s) {
    if (!s) return el_strdup("");
    JsonBuf b; jb_init(&b);
    for (const char* p = s; *p; p++) {
        unsigned char c = (unsigned char)*p;
        switch (c) {
            case '"':  jb_puts(&b, "\\\""); break;
            case '\\': jb_puts(&b, "\\\\"); break;
            case '\n': jb_puts(&b, "\\n");  break;
            case '\r': jb_puts(&b, "\\r");  break;
            case '\t': jb_puts(&b, "\\t");  break;
            default:
                if (c < 0x20) {
                    char tmp[8]; snprintf(tmp, sizeof(tmp), "\\u%04x", c);
                    jb_puts(&b, tmp);
                } else jb_putc(&b, (char)c);
        }
    }
    return b.buf;
}

static el_val_t http_error_json(const char* msg) {
    char* esc = json_escape_alloc(msg ? msg : "unknown error");
    char* buf = el_strbuf(strlen(esc) + 16);
    sprintf(buf, "{\"error\":\"%s\"}", esc);
    free(esc);
    return el_wrap_str(buf);
}

/* HTTP timeout (ms) — read once from EL_HTTP_TIMEOUT_MS, default 60000.
 * Applied via CURLOPT_TIMEOUT_MS on every libcurl request. */
static long _el_http_timeout_ms = -1;
static long el_http_timeout_ms(void) {
    long v = __atomic_load_n(&_el_http_timeout_ms, __ATOMIC_ACQUIRE);
    if (v >= 0) return v;
    const char* s = getenv("EL_HTTP_TIMEOUT_MS");
    long parsed = 60000L;
    if (s && *s) {
        char* end = NULL;
        long n = strtol(s, &end, 10);
        if (end != s && n > 0) parsed = n;
    }
    __atomic_store_n(&_el_http_timeout_ms, parsed, __ATOMIC_RELEASE);
    return parsed;
}

/* Internal: do a libcurl request; takes optional body/headers, optional method
 * override, and an optional timeout override (0 = use EL_HTTP_TIMEOUT_MS).
 * The override exists for the embedding path: activation must never wait the
 * full 60s default on a wedged Ollama. (2026-07-24 self-review) */
static el_val_t http_do_t(const char* method, const char* url, const char* body,
                          struct curl_slist* extra_headers, long timeout_ms) {
    if (!url || !*url) return http_error_json("empty url");
    CURL* c = curl_easy_init();
    if (!c) return http_error_json("curl_easy_init failed");
    HttpBuf rb; httpbuf_init(&rb);
    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS,
                     timeout_ms > 0 ? timeout_ms : el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);
    if (method && strcmp(method, "POST") == 0) {
        curl_easy_setopt(c, CURLOPT_POST, 1L);
        curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
        curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    } else if (method && strcmp(method, "DELETE") == 0) {
        curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "DELETE");
        /* DELETE with a body (2026-07-24): the engram server authenticates
         * mutating requests via an "_auth" field in the JSON body, so EL
         * code must be able to send DELETE + body. Absent body → unchanged. */
        if (body && *body) {
            curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
            curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
        }
    }
    CURLcode rc = curl_easy_perform(c);
    curl_easy_cleanup(c);
    if (rc != CURLE_OK) {
        free(rb.data);
        const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
        return http_error_json(m);
    }
    return el_wrap_str(rb.data);
}

/* Legacy entry point: default timeout. */
static el_val_t http_do(const char* method, const char* url, const char* body,
                        struct curl_slist* extra_headers) {
    return http_do_t(method, url, body, extra_headers, 0);
}

el_val_t http_get(el_val_t url) {
    return http_do("GET", EL_CSTR(url), NULL, NULL);
}

el_val_t http_post(el_val_t url, el_val_t body) {
    return http_do("POST", EL_CSTR(url), EL_CSTR(body), NULL);
}

el_val_t http_post_json(el_val_t url, el_val_t json_body) {
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(json_body), h);
    curl_slist_free_all(h);
    return r;
}

/* Build a curl_slist from an ElMap of name -> value strings. */
static struct curl_slist* headers_from_map(el_val_t headers_map) {
    struct curl_slist* h = NULL;
    ElMap* m = as_map(headers_map);
    if (!m) return NULL;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        const char* v = EL_CSTR(m->values[i]);
        if (!k || !v) continue;
        size_t n = strlen(k) + strlen(v) + 4;
        char* line = malloc(n);
        if (!line) continue;
        snprintf(line, n, "%s: %s", k, v);
        h = curl_slist_append(h, line);
        free(line);
    }
    return h;
}

el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do("GET", EL_CSTR(url), NULL, h);
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(body), h);
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header) {
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/x-www-form-urlencoded");
    const char* a = EL_CSTR(auth_header);
    if (a && *a) {
        size_t n = strlen(a) + 32;
        char* line = malloc(n);
        snprintf(line, n, "Authorization: %s", a);
        h = curl_slist_append(h, line);
        free(line);
    }
    el_val_t r = http_do("POST", EL_CSTR(url), EL_CSTR(form_body), h);
    curl_slist_free_all(h);
    return r;
}

/* HTTP DELETE — mirrors http_post but with CURLOPT_CUSTOMREQUEST=DELETE.
 * Returns response body on success; on transport failure returns an error
 * JSON fragment (same convention as http_get/http_post). Callers that
 * expect "" on failure should check for a leading '{' and an "error" key. */
el_val_t http_delete(el_val_t url) {
    return http_do("DELETE", EL_CSTR(url), NULL, NULL);
}

/* DELETE with a JSON body — required by the engram server's body-based
 * "_auth" scheme for mutating requests. (2026-07-24 self-review) */
el_val_t http_delete_json(el_val_t url, el_val_t json_body) {
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    el_val_t r = http_do("DELETE", EL_CSTR(url), EL_CSTR(json_body), h);
    curl_slist_free_all(h);
    return r;
}

/* ── HTTP → file streaming ────────────────────────────────────────────────
 *
 * Why this exists: el_val_t strings are NUL-terminated by convention, so
 * accumulating an HTTP response into an httpbuf and then wrapping its
 * `.data` pointer with el_wrap_str() loses the byte length. Any consumer
 * that does strlen() on the wrapped pointer truncates the body at the
 * first embedded NUL. Audio (MP3, WAV, OGG), images (PNG, JPEG), and any
 * other binary payload hits this. The vessels that download such bodies
 * (e.g. ElevenLabs TTS → MP3) get silently corrupted files.
 *
 * The fix: wire libcurl's CURLOPT_WRITEFUNCTION directly to fwrite()
 * against a fopen()-ed FILE*. The bytes never pass through an el_val_t
 * string, so embedded NULs are preserved verbatim. Caller's contract is
 * just "a file at this path with the response body in it". */

static size_t http_file_write_cb(char* ptr, size_t size, size_t nmemb, void* ud) {
    FILE* f = (FILE*)ud;
    return fwrite(ptr, size, nmemb, f);
}

/* Internal: stream body to file. method is "GET" or "POST". body may be NULL
 * (GET) or NUL-terminated (POST). headers may be NULL. Returns 1/0. */
static el_val_t http_do_to_file(const char* method, const char* url,
                                const char* body, struct curl_slist* extra_headers,
                                const char* output_path) {
    if (!url || !*url) return 0;
    if (!output_path || !*output_path) return 0;
    FILE* f = fopen(output_path, "wb");
    if (!f) return 0;

    CURL* c = curl_easy_init();
    if (!c) { fclose(f); remove(output_path); return 0; }

    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_file_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, f);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    curl_easy_setopt(c, CURLOPT_FAILONERROR, 1L);  /* 4xx/5xx → CURLE_HTTP_RETURNED_ERROR */
    if (extra_headers) curl_easy_setopt(c, CURLOPT_HTTPHEADER, extra_headers);

    if (method && strcmp(method, "POST") == 0) {
        curl_easy_setopt(c, CURLOPT_POST, 1L);
        curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
        /* For the request body we still rely on strlen — POST bodies are
         * caller-controlled and JSON/text in every known El use case.
         * If a future caller needs a binary POST body, add a *_bytes
         * variant that takes an explicit length, mirroring fs_write_bytes. */
        curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    }

    CURLcode rc = curl_easy_perform(c);
    curl_easy_cleanup(c);

    /* Flush + close before signalling success, so the file is fully on disk
     * by the time the caller reads back. */
    int flush_ok = (fflush(f) == 0);
    int close_ok = (fclose(f) == 0);

    if (rc != CURLE_OK || !flush_ok || !close_ok) {
        remove(output_path);
        return 0;
    }
    return 1;
}

el_val_t http_get_to_file(el_val_t url, el_val_t headers_map, el_val_t output_path) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do_to_file("GET", EL_CSTR(url), NULL, h, EL_CSTR(output_path));
    if (h) curl_slist_free_all(h);
    return r;
}

el_val_t http_post_to_file(el_val_t url, el_val_t body, el_val_t headers_map, el_val_t output_path) {
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do_to_file("POST", EL_CSTR(url), EL_CSTR(body), h, EL_CSTR(output_path));
    if (h) curl_slist_free_all(h);
    return r;
}

/* ── HTTP server (POSIX sockets + pthreads) ──────────────────────────────── */

#define HTTP_MAX_CONNS 64

typedef el_val_t (*http_handler_fn)(el_val_t method, el_val_t path, el_val_t body);

typedef struct {
    char*           name;
    http_handler_fn fn;
} HttpHandlerEntry;

static HttpHandlerEntry  _http_handlers[32];
static size_t            _http_handler_count = 0;
static char*             _http_active_handler = NULL;
static pthread_mutex_t   _http_handler_mu = PTHREAD_MUTEX_INITIALIZER;

static pthread_mutex_t   _http_conn_mu = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t    _http_conn_cv = PTHREAD_COND_INITIALIZER;
static int               _http_conn_active = 0;

/* Public C-level API: register a handler by name. Programs that want El
 * `http_serve` to dispatch into their handler call this from main() before
 * http_serve. Not declared in the header to keep the public API minimal —
 * extern lookup works since C symbols are global.                       */
void el_runtime_register_handler(const char* name, http_handler_fn fn);
void el_runtime_register_handler(const char* name, http_handler_fn fn) {
    if (!name || !fn) return;
    pthread_mutex_lock(&_http_handler_mu);
    for (size_t i = 0; i < _http_handler_count; i++) {
        if (strcmp(_http_handlers[i].name, name) == 0) {
            _http_handlers[i].fn = fn;
            pthread_mutex_unlock(&_http_handler_mu);
            return;
        }
    }
    if (_http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
        _http_handlers[_http_handler_count].name = el_strdup(name);
        _http_handlers[_http_handler_count].fn   = fn;
        _http_handler_count++;
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

void http_set_handler(el_val_t name) {
    const char* n = EL_CSTR(name);
    pthread_mutex_lock(&_http_handler_mu);
    free(_http_active_handler);
    _http_active_handler = el_strdup(n ? n : "");
    /* If the name is not yet in the registry, try dlsym lookup against
     * the running binary's symbol table. Every El `fn name(...)` compiles
     * to a global C symbol with that exact name, so El programs can self-
     * register their own handlers just by calling http_set_handler("name"). */
    if (n && *n) {
        int found = 0;
        for (size_t i = 0; i < _http_handler_count; i++) {
            if (strcmp(_http_handlers[i].name, n) == 0) { found = 1; break; }
        }
        if (!found) {
            void* sym = dlsym(RTLD_DEFAULT, n);
            if (sym && _http_handler_count < sizeof(_http_handlers) / sizeof(_http_handlers[0])) {
                _http_handlers[_http_handler_count].name = el_strdup(n);
                _http_handlers[_http_handler_count].fn   = (http_handler_fn)sym;
                _http_handler_count++;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

/* ── Ambient consolidation: dreaming ────────────────────────────────────────
 *
 * Dreaming is not sleep, and it is not scheduled. A brain has no cron job.
 * The default mode network is ANTICORRELATED WITH TASK ENGAGEMENT: attention
 * drops, it activates — hundreds of times a day, for seconds at a time.
 * Daydreaming and sleep-dreaming are one process at different depths, and the
 * depth is set by how much capacity is unclaimed, not by a time of day.
 *
 * WHY THIS EXISTS (2026-08-16). Consolidation had no owner, so it was
 * implemented at every site that needed a piece of it — measured: soul's
 * in-process awareness loop, three POST beats on the engram, a 600s ticker,
 * two resident Python services, and three cron entries at 23:55 / 06:00 /
 * 08:30. That last trio is a sleep cycle written as crontab. Seven systems
 * dreaming into one graph with no owner for dreaming is what crashed soul on
 * this date; the contention was the symptom of the missing owner.
 *
 * Every ticker is the diagnostic. A StartInterval, an Hour/Minute, a
 * POST-to-beat — each marks a place where an intrinsic rhythm was replaced by
 * an external clock, which is a supervisor invented for something that should
 * be a property of the substrate.
 *
 * The engagement signal already existed and needed no invention:
 * _http_conn_active under _http_conn_mu is exactly "capacity currently
 * claimed." The dreamer waits for it to reach zero and yields the moment it
 * does not. That is the anticorrelation, literally rather than by analogy.
 *
 * CONTRACT: the handler performs ONE step and returns. The runtime cannot
 * preempt El code, so interruptibility is at step granularity — a step must
 * be small enough that a request arriving mid-step is not made to wait. It
 * returns non-zero if it did work. Returning zero means "nothing to
 * consolidate," and the dreamer then blocks until activity changes rather
 * than spinning. There is no timer anywhere in this file for this purpose,
 * and adding one would be the defect described above.
 *
 * `depth` is derived from CONTINUOUS unclaimed time: a brief gap affords a
 * shallow recombination; a long quiet affords a deep one. Same process. Sleep
 * is where unclaimed capacity is greatest, not where the process lives. */
typedef el_val_t (*dream_fn)(el_val_t depth);
static char*     _dream_handler = NULL;
static int       _dream_started = 0;

static int64_t dream_now_ms(void) {
    struct timespec ts;
#if defined(CLOCK_MONOTONIC)
    clock_gettime(CLOCK_MONOTONIC, &ts);
#else
    clock_gettime(CLOCK_REALTIME, &ts);
#endif
    return (int64_t)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
}

static dream_fn dream_lookup(void) {
    dream_fn out = NULL;
    pthread_mutex_lock(&_http_handler_mu);
    if (_dream_handler && *_dream_handler)
        out = (dream_fn)dlsym(RTLD_DEFAULT, _dream_handler);
    pthread_mutex_unlock(&_http_handler_mu);
    return out;
}

static void* dream_loop(void* unused) {
    (void)unused;
    int64_t idle_since = 0;
    for (;;) {
        /* Wait for unclaimed capacity. Any engagement resets the depth clock:
         * depth reflects CONTINUOUS quiet, so an interruption starts it over. */
        pthread_mutex_lock(&_http_conn_mu);
        while (_http_conn_active > 0) {
            idle_since = 0;
            pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
        }
        pthread_mutex_unlock(&_http_conn_mu);

        int64_t now = dream_now_ms();
        if (idle_since == 0) idle_since = now;
        int64_t quiet = now - idle_since;

        /* Depth from unclaimed capacity. Not a schedule — a gradient. */
        int depth = quiet <   1000 ? 1     /* a gap between requests   */
                  : quiet <  30000 ? 2     /* a lull                   */
                  : quiet < 300000 ? 3     /* sustained quiet          */
                  :                  4;    /* deep: the "sleep" case   */

        dream_fn fn = dream_lookup();
        if (!fn) return NULL;              /* handler vanished: stop, do not spin */

        el_val_t did_work = fn((el_val_t)depth);

        if (!(int64_t)did_work) {
            /* Nothing to consolidate. Do NOT poll — block until engagement
             * changes. If there is nothing to dream about, wait for something
             * to happen rather than asking again on a timer. */
            pthread_mutex_lock(&_http_conn_mu);
            while (_http_conn_active == 0)
                pthread_cond_wait(&_http_conn_cv, &_http_conn_mu);
            pthread_mutex_unlock(&_http_conn_mu);
            idle_since = 0;
        }
    }
    return NULL;
}

/* dream_set_handler(name) — register the consolidation step and start
 * dreaming. Resolves by dlsym against the running binary, the same mechanism
 * http_set_handler uses: every El `fn name(...)` compiles to a global C symbol
 * with that exact name. Inert until called, so a program that never registers
 * one simply never dreams and pays nothing. */
void dream_set_handler(el_val_t name) {
    const char* n = EL_CSTR(name);
    pthread_mutex_lock(&_http_handler_mu);
    free(_dream_handler);
    _dream_handler = el_strdup(n ? n : "");
    int start = (!_dream_started && n && *n && dlsym(RTLD_DEFAULT, n) != NULL);
    if (start) _dream_started = 1;
    pthread_mutex_unlock(&_http_handler_mu);
    if (start) {
        pthread_t tid;
        if (pthread_create(&tid, NULL, dream_loop, NULL) == 0) pthread_detach(tid);
        else { pthread_mutex_lock(&_http_handler_mu); _dream_started = 0; pthread_mutex_unlock(&_http_handler_mu); }
    }
}

static http_handler_fn http_lookup_active(void) {
    http_handler_fn out = NULL;
    pthread_mutex_lock(&_http_handler_mu);
    if (_http_active_handler) {
        for (size_t i = 0; i < _http_handler_count; i++) {
            if (strcmp(_http_handlers[i].name, _http_active_handler) == 0) {
                out = _http_handlers[i].fn; break;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
    return out;
}

/* Auto-detect Content-Type from response body. */
static const char* http_detect_content_type(const char* body) {
    if (!body) return "text/plain; charset=utf-8";
    const char* p = body;
    /* Binary magic bytes — check before stripping whitespace */
    if ((unsigned char)p[0] == 0x89 && p[1]=='P' && p[2]=='N' && p[3]=='G')
        return "image/png";
    if ((unsigned char)p[0] == 0xFF && (unsigned char)p[1] == 0xD8)
        return "image/jpeg";
    if (strncmp(p, "GIF8", 4) == 0)  return "image/gif";
    if (strncmp(p, "RIFF", 4) == 0)  return "image/webp";
    if (strncmp(p, "wOFF", 4) == 0)  return "font/woff";
    if (strncmp(p, "wOF2", 4) == 0)  return "font/woff2";
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (strncasecmp(p, "<!DOCTYPE", 9) == 0) return "text/html; charset=utf-8";
    if (strncasecmp(p, "<html", 5)    == 0) return "text/html; charset=utf-8";
    if (strncasecmp(p, "<svg",  4)    == 0) return "image/svg+xml";
    if (*p == '{' || *p == '[')             return "application/json; charset=utf-8";
    return "text/plain; charset=utf-8";
}

/* Read the full HTTP request from a connection: request line, headers, body.
 *
 * `out_headers_block` is optional (may be NULL). When non-NULL, on success
 * it receives an allocated NUL-terminated copy of the raw header block —
 * everything between the request line's trailing CRLF and the final \r\n\r\n
 * (no leading/trailing CRLFs). The 3-arg http_serve worker passes NULL here
 * (no headers needed). The 4-arg http_serve_v2 worker passes a real pointer
 * so it can build a header map for the El handler.                         */
static int http_read_request(int fd, char** out_method, char** out_path,
                             char** out_body, char** out_headers_block) {
    *out_method = NULL; *out_path = NULL; *out_body = NULL;
    if (out_headers_block) *out_headers_block = NULL;
    /* Read headers until \r\n\r\n */
    size_t cap = 4096, len = 0;
    char* buf = malloc(cap);
    if (!buf) return -1;
    while (1) {
        if (len + 1 >= cap) {
            if (cap >= 1024 * 1024) { free(buf); return -1; }
            cap *= 2;
            buf = realloc(buf, cap);
            if (!buf) return -1;
        }
        ssize_t n = recv(fd, buf + len, cap - len - 1, 0);
        if (n <= 0) { free(buf); return -1; }
        len += (size_t)n;
        buf[len] = '\0';
        if (strstr(buf, "\r\n\r\n")) break;
    }
    /* Parse request line */
    char* sp1 = strchr(buf, ' ');
    if (!sp1) { free(buf); return -1; }
    *sp1 = '\0';
    *out_method = el_strdup(buf);
    char* path_start = sp1 + 1;
    char* sp2 = strchr(path_start, ' ');
    if (!sp2) { free(*out_method); *out_method = NULL; free(buf); return -1; }
    *sp2 = '\0';
    *out_path = el_strdup(path_start);
    char* hdr_end = strstr(sp2 + 1, "\r\n\r\n");
    /* Capture the raw header block (after the request line's CRLF, up to
     * but not including the terminating \r\n\r\n) for callers that asked
     * for it. The legacy 3-arg path passes NULL and skips this. */
    if (out_headers_block) {
        char* hdr_start = strstr(sp2 + 1, "\r\n");
        if (hdr_start && hdr_start < hdr_end) {
            hdr_start += 2;
            size_t hb_len = (size_t)(hdr_end - hdr_start);
            char* hb = malloc(hb_len + 1);
            if (hb) {
                memcpy(hb, hdr_start, hb_len);
                hb[hb_len] = '\0';
                *out_headers_block = hb;
            }
        } else {
            *out_headers_block = el_strdup("");
        }
    }
    /* Find Content-Length */
    long content_length = 0;
    char* hp = sp2 + 1;
    while (hp < hdr_end) {
        char* line_end = strstr(hp, "\r\n");
        /* line_end == hdr_end means we're on the LAST header line — its
         * trailing \r\n is the same \r\n that begins the \r\n\r\n header
         * terminator. Process this line; only stop when line_end is past
         * hdr_end (which means the parser walked off the end of the
         * header block). The previous condition (line_end >= hdr_end)
         * silently dropped any Content-Length that appeared as the last
         * header — exactly what real curl/clients tend to emit. */
        if (!line_end || line_end > hdr_end) break;
        if (strncasecmp(hp, "Content-Length:", 15) == 0) {
            content_length = strtol(hp + 15, NULL, 10);
            if (content_length < 0) content_length = 0;
            if (content_length > 64 * 1024 * 1024) content_length = 64 * 1024 * 1024;
        }
        hp = line_end + 2;
    }
    /* Body: any bytes already read past hdr_end, plus more recv */
    char* body_start = hdr_end + 4;
    size_t body_have = (buf + len) - body_start;
    char* body = malloc((size_t)content_length + 1);
    if (!body) { free(*out_method); free(*out_path); *out_method=NULL; *out_path=NULL; free(buf); return -1; }
    if ((long)body_have > content_length) body_have = (size_t)content_length;
    if (body_have > 0) memcpy(body, body_start, body_have);
    while ((long)body_have < content_length) {
        ssize_t n = recv(fd, body + body_have, (size_t)content_length - body_have, 0);
        if (n <= 0) break;
        body_have += (size_t)n;
    }
    body[body_have] = '\0';
    *out_body = body;
    free(buf);
    return 0;
}

/* Reason phrase for common HTTP statuses. Falls back to "Status" for the
 * long tail — clients only care about the numeric code. */
static const char* http_reason_phrase(int status) {
    switch (status) {
        case 200: return "OK";
        case 201: return "Created";
        case 202: return "Accepted";
        case 204: return "No Content";
        case 301: return "Moved Permanently";
        case 302: return "Found";
        case 303: return "See Other";
        case 304: return "Not Modified";
        case 307: return "Temporary Redirect";
        case 308: return "Permanent Redirect";
        case 400: return "Bad Request";
        case 401: return "Unauthorized";
        case 403: return "Forbidden";
        case 404: return "Not Found";
        case 405: return "Method Not Allowed";
        case 409: return "Conflict";
        case 410: return "Gone";
        case 422: return "Unprocessable Entity";
        case 429: return "Too Many Requests";
        case 500: return "Internal Server Error";
        case 501: return "Not Implemented";
        case 502: return "Bad Gateway";
        case 503: return "Service Unavailable";
        case 504: return "Gateway Timeout";
        default:  return "Status";
    }
}

/* A DISCONNECTING CLIENT MUST NOT KILL THE SERVER (2026-08-16).
 *
 * There was no SIGPIPE handling anywhere in this runtime: no signal disposition,
 * no MSG_NOSIGNAL, no SO_NOSIGPIPE, and send() called with bare flags. The
 * default disposition of SIGPIPE is to TERMINATE THE PROCESS, so any client that
 * hung up mid-response — a curl that hit its timeout, a browser tab closed
 * during a large read, a proxy giving up — took the whole engram down with it.
 *
 * Measured on the live instance: 18 boots in the log, and `launchctl list`
 * reporting the previous exit for ai.neuron.engram as -13, i.e. killed by
 * signal 13 = SIGPIPE. Reproduced by the cause: pulling /api/nodes/list (26 MB)
 * with a client-side timeout. launchd's KeepAlive then restarts it, so the
 * failure looks like a mysterious restart rather than a crash, and the graph
 * silently reloads under whatever was mid-flight.
 *
 * This is an exemption in the §8 sense: the write never checked whether the
 * peer was still there, and the consequence of not checking was fatal rather
 * than merely wrong.
 *
 * Two layers, because neither alone is portable:
 *   - SO_NOSIGPIPE per socket (Darwin/BSD) and MSG_NOSIGNAL per send (Linux),
 *     so the signal is never raised for socket writes in the first place.
 *   - A process-wide SIG_IGN as the backstop for platforms/paths with neither,
 *     installed once and idempotent. With the signal ignored, send() returns
 *     -1/EPIPE and the existing error path closes the connection. */
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif

static void el_ignore_sigpipe_once(void) {
    static int done = 0;
    if (done) return;
    done = 1;
#ifndef _WIN32
    signal(SIGPIPE, SIG_IGN);
#endif
}

/* Per-socket suppression where the platform offers it. Best-effort: a failure
 * here is not fatal because el_ignore_sigpipe_once() already covers the case. */
static void el_sock_nosigpipe(int fd) {
#if defined(SO_NOSIGPIPE)
    int on = 1;
    setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
#else
    (void)fd;
#endif
}

/* Best-effort send with retry on partial writes. EPIPE/ECONNRESET are a client
 * that left, not a server fault: return -1 so the caller closes the connection,
 * and never let it reach the process as a signal. */
static int http_send_all(int fd, const char* p, size_t left) {
    el_ignore_sigpipe_once();
    while (left > 0) {
        ssize_t w = send(fd, p, left, MSG_NOSIGNAL);
        if (w < 0 && errno == EINTR) continue;
        if (w <= 0) return -1;
        p += w; left -= (size_t)w;
    }
    return 0;
}

/* Discriminator that http_response() embeds at the start of its envelope.
 * A handler returning a string starting with this exact prefix is treated
 * as a structured response; anything else is treated as a raw body. */
#define EL_HTTP_RESPONSE_TAG "{\"el_http_response\":1"

/* Keys that conflict with runtime-managed headers are silently dropped to
 * avoid double-emission — the runtime always emits its own Content-Length
 * and Connection: close. Content-Type from the envelope IS allowed and
 * overrides auto-detection. */
static int http_header_is_managed(const char* k) {
    return strcasecmp(k, "Content-Length") == 0
        || strcasecmp(k, "Connection") == 0;
}

/* Walk an ElMap of header pairs and emit each as `K: V\r\n` into JsonBuf b.
 * Sets *out_saw_content_type to 1 if the map contained an explicit
 * Content-Type so the caller can skip auto-detection. */
static void http_emit_headers_from_map(JsonBuf* b, el_val_t headers_map,
                                       int* out_saw_content_type) {
    *out_saw_content_type = 0;
    if (headers_map == 0) return;
    ElMap* m = (ElMap*)(uintptr_t)headers_map;
    if (!m || m->hdr.magic != EL_MAGIC_MAP) return;
    for (int64_t i = 0; i < m->count; i++) {
        const char* k = EL_CSTR(m->keys[i]);
        const char* v = EL_CSTR(m->values[i]);
        if (!k || !v) continue;
        if (http_header_is_managed(k)) continue;
        if (strcasecmp(k, "Content-Type") == 0) *out_saw_content_type = 1;
        jb_puts(b, k);
        jb_puts(b, ": ");
        jb_puts(b, v);
        jb_puts(b, "\r\n");
    }
}

/* Parse the envelope produced by http_response(). On success returns 1 and
 * populates *out_status, *out_headers_map (an ElMap el_val_t — caller must
 * el_release), and *out_body (allocated). On failure returns 0.
 *
 * Implementation: feeds the entire envelope through the recursive-descent
 * JSON parser (which builds proper ElMap/ElList values), then pulls the
 * three top-level fields by name. Avoids re-stringifying the headers map
 * since json_stringify() does not support nested objects.                */
static int http_parse_envelope(const char* s, int* out_status,
                               el_val_t* out_headers_map, char** out_body,
                               el_val_t* out_parsed_root) {
    if (!s) return 0;
    if (strncmp(s, EL_HTTP_RESPONSE_TAG,
                sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;

    el_val_t parsed = json_parse(EL_STR(s));
    if (parsed == EL_NULL) return 0;

    int      status = 200;
    el_val_t hmap = 0;
    char*    body = NULL;

    el_val_t sv = el_map_get(parsed, EL_STR("status"));
    if (sv != 0) {
        /* status comes back as an integer — el_val_t holds it directly. */
        long sc = (long)sv;
        if (sc >= 100 && sc <= 599) status = (int)sc;
    }

    el_val_t hv = el_map_get(parsed, EL_STR("headers"));
    if (hv != 0) {
        ElMap* hm = (ElMap*)(uintptr_t)hv;
        if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
    }

    el_val_t bv = el_map_get(parsed, EL_STR("body"));
    if (bv != 0) {
        const char* bs = EL_CSTR(bv);
        if (bs) body = el_strdup(bs);
    }
    if (!body) body = el_strdup("");

    *out_status       = status;
    *out_headers_map  = hmap;
    *out_body         = body;
    *out_parsed_root  = parsed;  /* caller releases to free hmap + entries */
    return 1;
}

/* Lightweight `__status__` envelope: if the body's first key is `__status__`
 * and its value is a numeric literal, lift the status to the HTTP layer and
 * strip the marker from the body before sending. This is the common case for
 * El handlers that want to return 4xx/5xx without going through
 * http_response() — they just prepend `{"__status__":<int>,...}` to the JSON
 * they were already returning.
 *
 * We deliberately recognise ONLY the first-key form so the contract is cheap
 * to detect and unambiguous: `{"__status__":401,"error":"unauthorized"}` is
 * an envelope, but `{"error":"...","__status__":401}` is not. Product code
 * controls placement.
 *
 * On success returns 1 with *out_status set and *out_body_alloc populated
 * with a freshly malloc'd body (caller frees). On failure returns 0 and
 * leaves outputs untouched. */
static int http_parse_status_envelope(const char* s, int* out_status,
                                      char** out_body_alloc) {
    if (!s) return 0;
    const char* p = s;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '{') return 0;
    p++;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    static const char marker[] = "\"__status__\"";
    size_t mlen = sizeof(marker) - 1;
    if (strncmp(p, marker, mlen) != 0) return 0;
    p += mlen;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != ':') return 0;
    p++;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p < '0' || *p > '9') return 0;  /* non-numeric -> not an envelope */
    int status = 0;
    while (*p >= '0' && *p <= '9') {
        status = status * 10 + (*p - '0');
        p++;
    }
    if (status < 100 || status > 599) return 0;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    /* Two trailing shapes accepted:
     *   ,"k":v,...}    -> body becomes {"k":v,...}
     *   }              -> body becomes {}
     * Anything else (e.g. `:` re-appearing, garbage) drops the envelope so
     * we don't strip what we shouldn't. */
    if (*p == '}') {
        *out_status = status;
        *out_body_alloc = el_strdup("{}");
        return 1;
    }
    if (*p != ',') return 0;
    p++;  /* skip the comma; the rest of the object follows */
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    /* Build the trimmed body: '{' + remainder. */
    size_t rest_len = strlen(p);
    char* out = (char*)malloc(rest_len + 2);
    if (!out) return 0;
    out[0] = '{';
    memcpy(out + 1, p, rest_len);
    out[rest_len + 1] = '\0';
    *out_status = status;
    *out_body_alloc = out;
    return 1;
}

/* Send a fully-built HTTP response. If `body` starts with the envelope tag,
 * unpack status/headers/body. Otherwise emit the historical 200-OK with
 * auto-detected Content-Type. */
/* Thread-local flag: if 1, http_send_response writes status + headers but
 * NO body (HEAD method behaviour). Set by http_worker before calling
 * http_send_response, cleared after. */
static __thread int _tl_http_head_only = 0;

static void http_send_response(int fd, const char* body) {
    if (!body) body = "";

    int      status = 200;
    el_val_t env_headers_map = 0;
    char*    env_body = NULL;
    el_val_t env_parsed_root = 0;
    int      is_envelope = http_parse_envelope(body, &status,
                                               &env_headers_map, &env_body,
                                               &env_parsed_root);

    /* If the rich http_response() envelope didn't claim this body, try the
     * lightweight `__status__` form. This second envelope is malloc-backed so
     * we route it through env_body and let the existing cleanup path free it
     * — same lifetime contract, no special case at the bottom of the
     * function. */
    if (!is_envelope) {
        char* trimmed = NULL;
        if (http_parse_status_envelope(body, &status, &trimmed)) {
            env_body = trimmed;
            is_envelope = 1;
        }
    }

    const char* eff_body = is_envelope ? env_body : body;
    /* Use the real byte count from fs_read ONLY when this body IS the exact
     * buffer fs_read returned (binary files with embedded null bytes — PNG,
     * WOFF2, etc.). Any other body — wrapped, enveloped, or derived — must be
     * measured with strlen, or it is truncated/over-read to the file's size. */
    size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf)
                      ? _tl_fs_read_len : strlen(eff_body);
    _tl_fs_read_len = 0;  /* consume — one-shot per response */
    _tl_fs_read_buf = NULL;
    int head_only = _tl_http_head_only;

    JsonBuf hdrs; jb_init(&hdrs);
    int saw_content_type = 0;
    if (is_envelope) {
        http_emit_headers_from_map(&hdrs, env_headers_map,
                                   &saw_content_type);
    }
    if (!saw_content_type) {
        jb_puts(&hdrs, "Content-Type: ");
        jb_puts(&hdrs, http_detect_content_type(eff_body));
        jb_puts(&hdrs, "\r\n");
    }

    char status_line[64];
    int sl = snprintf(status_line, sizeof(status_line),
                      "HTTP/1.1 %d %s\r\n",
                      status, http_reason_phrase(status));
    if (sl < 0) {
        if (env_parsed_root) el_release(env_parsed_root);
        free(env_body); free(hdrs.buf); return;
    }

    char tail[128];
    int tl = snprintf(tail, sizeof(tail),
                      "Content-Length: %zu\r\n"
                      "Connection: close\r\n"
                      "\r\n", blen);
    if (tl < 0) {
        if (env_parsed_root) el_release(env_parsed_root);
        free(env_body); free(hdrs.buf); return;
    }

    if (http_send_all(fd, status_line, (size_t)sl) == 0
     && http_send_all(fd, hdrs.buf,    hdrs.len)   == 0
     && http_send_all(fd, tail,        (size_t)tl) == 0
     && (head_only
         /* HEAD requests echo headers + Content-Length but no body. */
         ? 1
         : http_send_all(fd, eff_body, blen) == 0)) {
        /* sent successfully */
    }

    if (env_parsed_root) el_release(env_parsed_root);
    free(env_body);
    free(hdrs.buf);
}

typedef struct {
#ifdef _WIN32
    SOCKET fd;
#else
    int fd;
#endif
} HttpWorkerArg;

/* ── ENGRAM REQUEST LOCK (file-scope so the long reification beat can release it
 * during its lock-free paged-store phase — see engram_self_reify_beat_json). All
 * non-health requests hold this for their whole handler, serializing RAM-graph +
 * store access with no missing-guard risk. The BEAT is the one long writer: it
 * builds a PRIVATE vindex + vids snapshot under the lock, RELEASES the lock, runs
 * its multi-second reify against only the thread-safe paged store + its private
 * snapshot, then re-acquires to rebuild the shared resident index — so a ~14s beat
 * no longer blocks ingest/reads (measured: non-health latency during a beat
 * 13.9s → sub-second). */
static pthread_mutex_t g_engram_req_lock = PTHREAD_MUTEX_INITIALIZER;

/* ── AWARENESS-THREAD GUARD (2026-08-16 self-review) ─────────────────────────
 * The request lock above serialized http_worker threads against EACH OTHER, but
 * the soul daemon has a SECOND, unsynchronized engram caller: soul.el starts the
 * HTTP server with http_serve_async (spawning worker threads) and then runs
 * awareness_run() on the MAIN thread, whose perceive() -> engram_activate_json()
 * -> engram_activate() -> eg_vindex_sync() path mutates the very same RAM graph
 * and the process-global _eg_vindex HNSW index. Nothing in any .el source ever
 * called engram_req_lock, so that whole loop ran lock-free beside the workers.
 *
 * Measured consequence (2026-08-16): five crashes in ~4 minutes, all one bug —
 * SIGSEGV in search_layer<-vindex_insert<-eg_vindex_sync at address
 * 0x65646f4e6d617267 (little-endian ASCII "gramNode": a string being
 * dereferenced as an Elem vector pointer), plus a SIGABRT and a fault inside
 * xzm_realloc's freelist, i.e. corrupted allocator metadata. Confirmed by
 * bisection: replaying ALL 13,820 real dim-768 store vectors through the index
 * single-threaded under ASan is 100% clean, while two threads on one index trip
 * ThreadSanitizer instantly at engram_vindex.c:195 (visited_reset) — VIndex keeps
 * a SHARED visited-epoch scratch buffer, so even two concurrent READS stomp each
 * other's traversal state and walk bogus element indices. So this is purely a
 * concurrency defect, not a logic error in the HNSW code.
 *
 * Fix: a thread-local ownership depth lets engram entry points self-guard. A call
 * arriving on the awareness thread (depth 0) acquires the lock; one arriving from
 * 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).
 *
 * 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++; }
/* Acquire only if this thread does not already hold the request lock.
 * Returns 1 if this call took ownership (caller must release), 0 if nested. */
static int eg_guard_enter(void){
    if (_eg_req_depth > 0) return 0;
    pthread_mutex_lock(&g_engram_req_lock);
    _eg_req_depth++;
    return 1;
}
static void eg_guard_exit(int owned){
    if (!owned) return;
    if (_eg_req_depth > 0) _eg_req_depth--;
    pthread_mutex_unlock(&g_engram_req_lock);
}

static void* http_worker(void* arg) {
    HttpWorkerArg* a = (HttpWorkerArg*)arg;
#ifdef _WIN32
    SOCKET fd = a->fd;
#else
    int fd = a->fd;
#endif
    free(a);
    char *method = NULL, *path = NULL, *body = NULL;
    if (http_read_request(fd, &method, &path, &body, NULL) == 0) {
        http_handler_fn h = http_lookup_active();
        char* response = NULL;
        /* HEAD: dispatch as GET so existing handlers respond with the same
         * body, but flag the response writer to emit headers only. RFC 9110
         * requires HEAD to mirror GET headers + Content-Length without body. */
        int head_only = (method && strcmp(method, "HEAD") == 0);
        const char* dispatch_method = head_only ? "GET" : method;
        el_request_start();                         /* begin per-request arena */
        /* ── ENGRAM REQUEST SERIALIZATION (2026-08-14) ───────────────────────────
         * The engram runtime has TWO shared mutable structures — the paged store
         * (locked internally) AND the RAM activation graph (engram_get: g->nodes /
         * g->edges, reallocated by engram_node_full / engram_connect). Multiple
         * http_worker threads hit both; a beat reading g->nodes while an ingest POST
         * reallocs it corrupted the graph and LOST EDGES (11171→9579 under load).
         * Serialize request handling on one process-wide lock so exactly one request
         * mutates/reads engram state at a time — the simplest choke point that leaves
         * NO structure unguarded (a missed per-builtin guard would silently keep
         * losing data). Lock ordering is request-outer → store-inner (the store never
         * calls back out), so no deadlock. HEALTH is EXEMPT: GET /health and GET / are
         * pure status reads (scalar counts, crash-safe) and must stay responsive to
         * monitors even while a multi-second beat holds the lock. */
        int health_exempt = 0;
        if (dispatch_method && strcmp(dispatch_method, "GET") == 0 && path) {
            const char* q = strchr(path, '?');
            size_t plen = q ? (size_t)(q - path) : strlen(path);
            if ((plen == 7 && strncmp(path, "/health", 7) == 0) ||
                (plen == 1 && path[0] == '/'))
                health_exempt = 1;
        }
        if (!health_exempt) engram_req_lock();   /* tracks _eg_req_depth for eg_guard_enter */
        if (h) {
            el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), EL_STR(body));
            const char* rs = EL_CSTR(r);
            /* Copy response out BEFORE arena teardown.
             * For binary files, _tl_fs_read_len holds the real byte count —
             * use memcpy instead of strdup so null bytes are preserved.
             * The stored length applies ONLY when the response IS the exact
             * fs_read buffer; a wrapped/derived response must use strlen or
             * it gets truncated (or over-read) to the file's length. */
            size_t rlen;
            if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
                rlen = _tl_fs_read_len;   /* raw file bytes — binary-safe */
            } else {
                rlen = rs ? strlen(rs) : 0;
                _tl_fs_read_len = 0;      /* hint doesn't describe this body */
                _tl_fs_read_buf = NULL;
            }
            response = malloc(rlen + 1);
            if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
            else if (response) { response[0] = '\0'; }
            if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
        } else {
            response = el_strdup_persist("el-runtime: no http handler registered");
        }
        /* end of the engram critical section — the response is now a private malloc'd
         * copy; arena teardown + socket write touch no shared engram state. */
        if (!health_exempt) engram_req_unlock();
        el_request_end();                           /* free all intermediate strings */
        _tl_http_head_only = head_only;
        http_send_response(fd, response);
        _tl_http_head_only = 0;
        free(response);
    }
    free(method); free(path); free(body);
    el_closesocket(fd);
    /* release a slot */
    pthread_mutex_lock(&_http_conn_mu);
    _http_conn_active--;
    /* BROADCAST, not signal (2026-08-16): the ambient consolidation thread
     * waits on this same condvar for _http_conn_active == 0. cond_signal wakes
     * exactly one waiter, so the accept loop could take every wake and starve
     * the dreamer indefinitely. Both wait sites re-check their predicate in a
     * while loop, so broadcasting is safe. */
    pthread_cond_broadcast(&_http_conn_cv);
    pthread_mutex_unlock(&_http_conn_mu);
    return NULL;
}

void http_serve(el_val_t port, el_val_t handler) {
    /* If `handler` looks like a string name, register it as the active 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: invalid port %d\n", p); return; }
    /* Dual-stack: AF_INET6 with IPV6_V6ONLY=0 accepts both IPv4 and IPv6.
     * This makes `localhost` work in browsers that resolve it to ::1 first. */
    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"); el_closesocket(sock); return;
    }
    if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return; }
    fprintf(stderr, "[http] listening on [::]:%d (dual-stack)\n", p);
    while (1) {
        struct sockaddr_in6 cli;
        socklen_t clen = sizeof(cli);
#ifdef _WIN32
        SOCKET cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#else
        int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#endif
        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) { el_closesocket(cfd); continue; }
        el_sock_nosigpipe(cfd);
        arg->fd = cfd;
        pthread_t tid;
        if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
            el_closesocket(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);
    }
    el_closesocket(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; }
        el_sock_nosigpipe(cfd);
        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
 *     (method, path, body) -> body_string
 * to
 *     (method, path, headers_map, body) -> body_string_or_envelope
 *
 * The response envelope is detected uniformly inside http_send_response — so
 * 4-arg handlers can return either a plain body or http_response(...). The
 * 3-arg path stays untouched in spirit (its handlers still build plain
 * bodies; the envelope tag, being `{"el_http_response":1`, will never
 * collide with normal JSON the legacy server.el routes return).
 *
 * Registry is parallel to the 3-arg handler registry: separate name table,
 * separate active-handler slot, separate dlsym fallback. Mixing v1 and v2
 * handlers in the same process is fine — they don't share the active slot. */

typedef el_val_t (*http_handler4_fn)(el_val_t method, el_val_t path,
                                     el_val_t headers_map, el_val_t body);

typedef struct {
    char*             name;
    http_handler4_fn  fn;
} HttpHandler4Entry;

static HttpHandler4Entry _http_handlers4[32];
static size_t            _http_handler4_count = 0;
static char*             _http_active_handler4 = NULL;

void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn);
void el_runtime_register_handler_v2(const char* name, http_handler4_fn fn) {
    if (!name || !fn) return;
    pthread_mutex_lock(&_http_handler_mu);
    for (size_t i = 0; i < _http_handler4_count; i++) {
        if (strcmp(_http_handlers4[i].name, name) == 0) {
            _http_handlers4[i].fn = fn;
            pthread_mutex_unlock(&_http_handler_mu);
            return;
        }
    }
    if (_http_handler4_count <
        sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
        _http_handlers4[_http_handler4_count].name = el_strdup(name);
        _http_handlers4[_http_handler4_count].fn   = fn;
        _http_handler4_count++;
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

void http_set_handler_v2(el_val_t name) {
    const char* n = EL_CSTR(name);
    pthread_mutex_lock(&_http_handler_mu);
    free(_http_active_handler4);
    _http_active_handler4 = el_strdup(n ? n : "");
    if (n && *n) {
        int found = 0;
        for (size_t i = 0; i < _http_handler4_count; i++) {
            if (strcmp(_http_handlers4[i].name, n) == 0) { found = 1; break; }
        }
        if (!found) {
            void* sym = dlsym(RTLD_DEFAULT, n);
            if (sym && _http_handler4_count <
                sizeof(_http_handlers4) / sizeof(_http_handlers4[0])) {
                _http_handlers4[_http_handler4_count].name = el_strdup(n);
                _http_handlers4[_http_handler4_count].fn   =
                    (http_handler4_fn)sym;
                _http_handler4_count++;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
}

static http_handler4_fn http_lookup_active_v2(void) {
    http_handler4_fn out = NULL;
    pthread_mutex_lock(&_http_handler_mu);
    if (_http_active_handler4) {
        for (size_t i = 0; i < _http_handler4_count; i++) {
            if (strcmp(_http_handlers4[i].name,
                       _http_active_handler4) == 0) {
                out = _http_handlers4[i].fn; break;
            }
        }
    }
    pthread_mutex_unlock(&_http_handler_mu);
    return out;
}

/* Build an ElMap from the raw header block produced by http_read_request.
 * Keys are lowercased (RFC 7230 — case-insensitive); values have leading
 * whitespace trimmed. Repeated headers with the same name are joined with
 * ", " in arrival order, matching standard library behaviour elsewhere. */
static el_val_t http_build_headers_map(const char* hdr_block) {
    el_val_t m = el_map_new(0);
    if (!hdr_block || !*hdr_block) return m;
    const char* p = hdr_block;
    while (*p) {
        const char* line_end = strstr(p, "\r\n");
        const char* end = line_end ? line_end : p + strlen(p);
        const char* colon = NULL;
        for (const char* c = p; c < end; c++) {
            if (*c == ':') { colon = c; break; }
        }
        if (colon && colon > p) {
            size_t klen = (size_t)(colon - p);
            char* key = malloc(klen + 1);
            if (key) {
                for (size_t i = 0; i < klen; i++) {
                    unsigned char ch = (unsigned char)p[i];
                    key[i] = (char)tolower(ch);
                }
                key[klen] = '\0';
                const char* vstart = colon + 1;
                while (vstart < end && (*vstart == ' ' || *vstart == '\t')) vstart++;
                size_t vlen = (size_t)(end - vstart);
                /* Strip trailing OWS just in case. */
                while (vlen > 0
                       && (vstart[vlen - 1] == ' '
                        || vstart[vlen - 1] == '\t')) vlen--;
                /* Coalesce repeats: if key already present, append ", value". */
                el_val_t existing = el_map_get(m, EL_STR(key));
                if (existing != 0 && looks_like_string(existing)) {
                    const char* old = EL_CSTR(existing);
                    size_t olen = strlen(old);
                    char* combined = malloc(olen + 2 + vlen + 1);
                    if (combined) {
                        memcpy(combined, old, olen);
                        memcpy(combined + olen, ", ", 2);
                        memcpy(combined + olen + 2, vstart, vlen);
                        combined[olen + 2 + vlen] = '\0';
                        m = el_map_set(m, EL_STR(key), EL_STR(combined));
                    }
                    free(key);
                } else {
                    char* val = malloc(vlen + 1);
                    if (val) {
                        memcpy(val, vstart, vlen);
                        val[vlen] = '\0';
                        m = el_map_set(m, EL_STR(key), EL_STR(val));
                    } else {
                        free(key);
                    }
                }
            }
        }
        if (!line_end) break;
        p = line_end + 2;
    }
    return m;
}

static void* http_worker_v2(void* arg) {
    HttpWorkerArg* a = (HttpWorkerArg*)arg;
#ifdef _WIN32
    SOCKET fd = a->fd;
#else
    int fd = a->fd;
#endif
    free(a);
    char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
    if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
        http_handler4_fn h = http_lookup_active_v2();
        char* response = NULL;
        int head_only = (method && strcmp(method, "HEAD") == 0);
        const char* dispatch_method = head_only ? "GET" : method;
        el_request_start();                         /* begin per-request arena */
        if (h) {
            el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
            el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
            const char* rs = EL_CSTR(r);
            /* Same pairing rule as the v1 worker: the fs_read length is only
             * trustworthy for the exact buffer fs_read returned. */
            size_t rlen;
            if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
                rlen = _tl_fs_read_len;   /* raw file bytes — binary-safe */
            } else {
                rlen = rs ? strlen(rs) : 0;
                _tl_fs_read_len = 0;      /* hint doesn't describe this body */
                _tl_fs_read_buf = NULL;
            }
            response = malloc(rlen + 1);
            if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
            else if (response) { response[0] = '\0'; }
            if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
            el_release(hmap);
        } else {
            response = el_strdup_persist(
                "el-runtime: no v2 http handler registered "
                "(call http_set_handler_v2)");
        }
        el_request_end();                           /* free all intermediate strings */
        _tl_http_head_only = head_only;
        http_send_response(fd, response);
        _tl_http_head_only = 0;
        free(response);
    }
    free(method); free(path); free(body); free(hdr_block);
    el_closesocket(fd);
    pthread_mutex_lock(&_http_conn_mu);
    _http_conn_active--;
    /* BROADCAST, not signal (2026-08-16): the ambient consolidation thread
     * waits on this same condvar for _http_conn_active == 0. cond_signal wakes
     * exactly one waiter, so the accept loop could take every wake and starve
     * the dreamer indefinitely. Both wait sites re-check their predicate in a
     * while loop, so broadcasting is safe. */
    pthread_cond_broadcast(&_http_conn_cv);
    pthread_mutex_unlock(&_http_conn_mu);
    return NULL;
}

void http_serve_v2(el_val_t port, el_val_t handler) {
    const char* hname = EL_CSTR(handler);
    if (hname && looks_like_string(handler)) {
        http_set_handler_v2(handler);
    }
    int p = (int)port;
    if (p <= 0 || p > 65535) {
        fprintf(stderr, "http_serve_v2: invalid port %d\n", p);
        return;
    }
    /* Dual-stack: same as http_serve - AF_INET6 + IPV6_V6ONLY=0. */
    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"); el_closesocket(sock); return;
    }
    if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return; }
    fprintf(stderr, "[http v2] listening on [::]:%d (dual-stack)\n", p);
    while (1) {
        struct sockaddr_in6 cli;
        socklen_t clen = sizeof(cli);
#ifdef _WIN32
        SOCKET cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#else
        int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#endif
        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) { el_closesocket(cfd); continue; }
        el_sock_nosigpipe(cfd);
        arg->fd = cfd;
        pthread_t tid;
        if (pthread_create(&tid, NULL, http_worker_v2, arg) != 0) {
            el_closesocket(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);
    }
    el_closesocket(sock);
}

/* Build the response envelope a 4-arg handler can return. We hand-write
 * the JSON so the discriminator key always lands first — the runtime's
 * http_parse_envelope() detects it via prefix match. headers_json must be
 * either "" (empty), "{}" (empty object), or a well-formed JSON object
 * literal; anything else will produce a malformed envelope and the runtime
 * will treat the whole string as a plain body (no envelope detected).   */
el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
    long sc = (long)status;
    if (sc < 100 || sc > 599) sc = 200;
    const char* hj = EL_CSTR(headers_json);
    if (!hj || !*hj) hj = "{}";
    /* Light validation: must start with '{' and end with '}'. */
    size_t hlen = strlen(hj);
    int hj_ok = (hlen >= 2 && hj[0] == '{' && hj[hlen - 1] == '}');
    if (!hj_ok) hj = "{}";
    const char* b = EL_CSTR(body);
    if (!b) b = "";

    JsonBuf out; jb_init(&out);
    jb_puts(&out, EL_HTTP_RESPONSE_TAG);  /* {"el_http_response":1 */
    jb_puts(&out, ",\"status\":");
    char num[32];
    snprintf(num, sizeof(num), "%ld", sc);
    jb_puts(&out, num);
    jb_puts(&out, ",\"headers\":");
    jb_puts(&out, hj);
    jb_puts(&out, ",\"body\":");
    jb_emit_escaped(&out, b);
    jb_putc(&out, '}');
    return el_wrap_str(out.buf);
}

/* ── Filesystem ──────────────────────────────────────────────────────────── */

el_val_t fs_read(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    _tl_fs_read_len = 0;
    _tl_fs_read_buf = NULL;
    if (!path) return el_wrap_str(el_strdup(""));
    FILE* f = fopen(path, "rb");
    if (!f) return el_wrap_str(el_strdup(""));
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    if (sz < 0) { fclose(f); return el_wrap_str(el_strdup("")); } /* pipe/special file */
    char* buf = el_strbuf((size_t)sz);
    size_t got = fread(buf, 1, (size_t)sz, f);
    buf[got] = '\0';
    _tl_fs_read_len = got;   /* store real byte count for binary-safe send */
    _tl_fs_read_buf = buf;   /* ...valid ONLY for this exact buffer */
    fclose(f);
    return el_wrap_str(buf);
}

el_val_t fs_write(el_val_t pathv, el_val_t contentv) {
    const char* path    = EL_CSTR(pathv);
    const char* content = EL_CSTR(contentv);
    if (!path || !content) return 0;
    FILE* f = fopen(path, "wb");
    if (!f) return 0;
    size_t n       = strlen(content);
    size_t written = fwrite(content, 1, n, f);
    fclose(f);
    return written == n ? 1 : 0;
}

/* fs_write_bytes — explicit-length binary write. Bypasses strlen so embedded
 * NULs survive. Caller must know the byte count (e.g. from base64_decode,
 * or the fixed 32-byte sha256_bytes/hmac_sha256_bytes outputs).
 *
 * If `length` is negative, treats as failure. If `length` is 0, creates an
 * empty file (still useful as a "touch with content" primitive). */
el_val_t fs_write_bytes(el_val_t pathv, el_val_t bytesv, el_val_t lengthv) {
    const char* path  = EL_CSTR(pathv);
    const char* bytes = EL_CSTR(bytesv);
    int64_t     n     = (int64_t)lengthv;
    if (!path || !bytes) return 0;
    if (n < 0) return 0;
    FILE* f = fopen(path, "wb");
    if (!f) return 0;
    size_t written = (n > 0) ? fwrite(bytes, 1, (size_t)n, f) : 0;
    int flush_ok = (fflush(f) == 0);
    int close_ok = (fclose(f) == 0);
    if (!flush_ok || !close_ok || written != (size_t)n) {
        remove(path);
        return 0;
    }
    return 1;
}

// exec_command — run a shell command, return exit code (0 = success).
// Used by elb and other El tooling to invoke subprocesses.
el_val_t exec_command(el_val_t cmdv) {
    const char* cmd = EL_CSTR(cmdv);
    if (!cmd) return (el_val_t)(int64_t)-1;
    int ret = system(cmd);
    return (el_val_t)(int64_t)ret;
}

// exec_capture — run a shell command, capture stdout, return as String.
// Returns "" on failure.
el_val_t exec_capture(el_val_t cmdv) {
    const char* cmd = EL_CSTR(cmdv);
    if (!cmd) return el_wrap_str(el_strdup(""));
    FILE* f = popen(cmd, "r");
    if (!f) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    char buf[4096];
    while (fgets(buf, sizeof(buf), f)) jb_puts(&b, buf);
    pclose(f);
    return el_wrap_str(b.buf);
}

// exec — run a shell command via /bin/sh, capture stdout, return as String.
// Times out after 30 seconds. Returns "" on any error.
// El name: exec(cmd) -> String
el_val_t exec(el_val_t cmdv) {
    const char* cmd = EL_CSTR(cmdv);
    if (!cmd || !*cmd) return el_wrap_str(el_strdup(""));
    /* Build a time-limited command: wrap with timeout(1) if available,
     * otherwise rely on the 30s read loop guard below. We use the simple
     * popen approach with a deadline measured by wall clock so the caller
     * is never blocked indefinitely. */
    FILE* f = popen(cmd, "r");
    if (!f) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    char buf[4096];
    /* 30-second wall-clock deadline */
    time_t deadline = time(NULL) + 30;
    while (time(NULL) < deadline) {
        if (fgets(buf, sizeof(buf), f) == NULL) break;
        jb_puts(&b, buf);
    }
    pclose(f);
    return el_wrap_str(b.buf);
}

// exec_bg — run a shell command in background, return PID as String.
// The child process runs independently; the caller is not blocked.
// Returns "" on fork failure.
// El name: exec_bg(cmd) -> String
el_val_t exec_bg(el_val_t cmdv) {
    const char* cmd = EL_CSTR(cmdv);
    if (!cmd || !*cmd) return el_wrap_str(el_strdup(""));
#ifdef _WIN32
    /* Windows: no fork/exec. Launch a detached `cmd /c <command>` with no console window via
       CreateProcess (DETACHED_PROCESS | CREATE_NO_WINDOW). Returns the PID as a string, "" on fail.
       Mirrors the POSIX branch: child runs independently, caller is not blocked. */
    char cmdline[8192];
    snprintf(cmdline, sizeof(cmdline), "cmd.exe /c %s", cmd);
    STARTUPINFOA si; ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si);
    PROCESS_INFORMATION pi; ZeroMemory(&pi, sizeof(pi));
    BOOL ok = CreateProcessA(NULL, cmdline, NULL, NULL, FALSE,
                             DETACHED_PROCESS | CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
    if (!ok) return el_wrap_str(el_strdup(""));
    char pidbuf[32];
    snprintf(pidbuf, sizeof(pidbuf), "%lu", (unsigned long)pi.dwProcessId);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    return el_wrap_str(el_strdup(pidbuf));
#else
    pid_t pid = fork();
    if (pid < 0) {
        /* fork failed */
        return el_wrap_str(el_strdup(""));
    }
    if (pid == 0) {
        /* child: detach from parent's stdio, exec via shell */
        setsid();
        int devnull = open("/dev/null", O_RDWR);
        if (devnull >= 0) {
            dup2(devnull, STDIN_FILENO);
            dup2(devnull, STDOUT_FILENO);
            dup2(devnull, STDERR_FILENO);
            close(devnull);
        }
        execl("/bin/sh", "sh", "-c", cmd, (char*)NULL);
        _exit(127);
    }
    /* parent: convert pid to string and return immediately */
    char pidbuf[32];
    snprintf(pidbuf, sizeof(pidbuf), "%d", (int)pid);
    return el_wrap_str(el_strdup(pidbuf));
#endif
}

el_val_t fs_list(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    el_val_t lst = el_list_empty();
    if (!path) return lst;
    DIR* d = opendir(path);
    if (!d) return lst;
    struct dirent* e;
    while ((e = readdir(d)) != NULL) {
        if (strcmp(e->d_name, ".") == 0 || strcmp(e->d_name, "..") == 0) continue;
        lst = el_list_append(lst, el_wrap_str(el_strdup(e->d_name)));
    }
    closedir(d);
    return lst;
}

/* fs_exists — true iff stat(path) succeeds. Symlinks are followed. */
el_val_t fs_exists(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path || !*path) return 0;
    struct stat st;
    return (el_val_t)(stat(path, &st) == 0 ? 1 : 0);
}

/* fs_size — real on-disk byte count of a file, via stat() (not strlen).
 * Needed alongside fs_read_b64_chunk() below: fs_read()'s el_val_t string
 * result is NUL-terminated and length-unsafe for arbitrary binary content
 * (str_len/str_slice fall back to strlen when the buffer isn't a tagged
 * binary value — see el_input_len), so any caller that needs to walk a
 * binary file in fixed-size windows (e.g. transduce()'s raw/opaque chunking
 * of audio bytes) must learn the true length here instead of from the
 * decoded string. Returns -1 if the path doesn't exist or isn't stat-able. */
el_val_t fs_size(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path || !*path) return -1;
    struct stat st;
    if (stat(path, &st) != 0) return -1;
    return (el_val_t)st.st_size;
}

/* fs_mkdir — create directory at path with mode 0755, mkdir -p semantics.
 * Returns 1 if path exists or was created (incl. all parents); 0 on failure.
 * Walks the path component-by-component so missing intermediate dirs are
 * also created. An existing leaf is not an error. */
el_val_t fs_mkdir(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path || !*path) return 0;
    size_t n = strlen(path);
    char* buf = malloc(n + 1);
    if (!buf) return 0;
    memcpy(buf, path, n + 1);
    /* Walk components; create each prefix in turn. */
    for (size_t i = 1; i <= n; i++) {
        if (buf[i] == '/' || buf[i] == '\0') {
            char saved = buf[i];
            buf[i] = '\0';
            if (buf[0] != '\0') {
                if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
                    /* Tolerate the case where this prefix exists as a non-dir
                     * only when stat says it's a directory. */
                    struct stat st;
                    if (stat(buf, &st) != 0 || !S_ISDIR(st.st_mode)) {
                        free(buf);
                        return 0;
                    }
                }
            }
            buf[i] = saved;
        }
    }
    free(buf);
    return 1;
}

/* ── URL encoding ─────────────────────────────────────────────────────────── */

/* RFC 3986 percent-encoding for URL components (form bodies, query strings).
 * Unreserved set: A-Z a-z 0-9 - _ . ~ — passed through verbatim.
 * Everything else (including space) becomes %XX hex. */
el_val_t url_encode(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    static const char hex[] = "0123456789ABCDEF";
    size_t n = strlen(s);
    char* out = el_strbuf(n * 3);
    size_t o = 0;
    for (size_t i = 0; i < n; i++) {
        unsigned char c = (unsigned char)s[i];
        if ((c >= 'A' && c <= 'Z') ||
            (c >= 'a' && c <= 'z') ||
            (c >= '0' && c <= '9') ||
            c == '-' || c == '_' || c == '.' || c == '~') {
            out[o++] = (char)c;
        } else {
            out[o++] = '%';
            out[o++] = hex[(c >> 4) & 0xF];
            out[o++] = hex[c & 0xF];
        }
    }
    out[o] = '\0';
    return el_wrap_str(out);
}

/* Decode percent-encoded URL component. '+' becomes space (form-encoded);
 * malformed %-escapes are emitted verbatim. */
el_val_t url_decode(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    size_t o = 0;
    for (size_t i = 0; i < n; i++) {
        char c = s[i];
        if (c == '+') {
            out[o++] = ' ';
        } else if (c == '%' && i + 2 < n) {
            char h1 = s[i + 1], h2 = s[i + 2];
            int v1 = (h1 >= '0' && h1 <= '9') ? h1 - '0'
                   : (h1 >= 'a' && h1 <= 'f') ? h1 - 'a' + 10
                   : (h1 >= 'A' && h1 <= 'F') ? h1 - 'A' + 10 : -1;
            int v2 = (h2 >= '0' && h2 <= '9') ? h2 - '0'
                   : (h2 >= 'a' && h2 <= 'f') ? h2 - 'a' + 10
                   : (h2 >= 'A' && h2 <= 'F') ? h2 - 'A' + 10 : -1;
            if (v1 >= 0 && v2 >= 0) {
                out[o++] = (char)((v1 << 4) | v2);
                i += 2;
            } else {
                out[o++] = c;
            }
        } else {
            out[o++] = c;
        }
    }
    out[o] = '\0';
    return el_wrap_str(out);
}

/* ── HTML allowlist sanitizer ────────────────────────────────────────────────
 * el_html_sanitize(input, allowlist_json)
 *
 * Strict allowlist HTML cleaner. Replaces the older denylist patterns
 * (str_replace cascades that wrapped dangerous tags in HTML comments and
 * renamed `on*` attributes). The denylist approach is fragile: comment-
 * wrapping can be re-broken by a literal `-->` inside an attacker-supplied
 * attribute value, and every new attack vector requires a code change.
 *
 * Design:
 *   - Single-pass byte-level state machine.
 *   - Tag and attribute names are matched case-insensitively against the
 *     allowlist. Unknown tags are dropped entirely (the open and close
 *     markers are stripped; their inner text content survives, escaped).
 *   - A small set of "dangerous container" tags (script, style, iframe,
 *     object, embed, form, plus a few rarer ones) drop themselves AND
 *     their full subtree — text between `<script>` and `</script>` is
 *     CDATA-like and must not be re-emitted as escaped text either.
 *   - Comments (<!-- -->), doctype (<!DOCTYPE>), CDATA (<![CDATA[...]]>),
 *     and processing instructions (<?...?>) are dropped entirely.
 *   - Text content outside dropped subtrees is HTML-escaped (&, <, >, ", ').
 *   - Attribute values are unquoted/dequoted, then re-emitted with double
 *     quotes around the cleanly-escaped value.
 *   - For `<a href>` and any `src` attribute, the URL scheme is validated:
 *     only http:, https:, mailto:, fragment-only `#anchor`, or relative
 *     paths are allowed. Anything else (javascript:, data:, vbscript:,
 *     about:, file:, etc.) drops the attribute.
 *   - Self-closing void tags (br, hr, img, etc.) emit without a close tag.
 *   - Malformed input (unclosed tag at EOF, bad attribute syntax) drops
 *     the pending tag and continues. Pre-encoded entities (&lt;, &amp;,
 *     etc.) are passed through verbatim — the browser will decode them
 *     safely on render.
 *
 * Allowlist format (JSON string):
 *   {"p":[],"a":["href","title"],"strong":[],...}
 *   - Key = lowercase tag name.
 *   - Value = JSON array of allowed attribute names (lowercase).
 *   - Empty array means tag allowed but no attributes survive.
 *
 * Output is a freshly-allocated arena-tracked el_val_t string. */

/* Internal byte buffer with realloc-doubling. Used during sanitization;
 * the final result is copied into an arena-tracked el_strbuf so the caller
 * sees standard runtime memory semantics. */
typedef struct {
    char*  data;
    size_t len;
    size_t cap;
} html_buf_t;

static void html_buf_init(html_buf_t* b) {
    b->cap = 256;
    b->data = malloc(b->cap);
    if (!b->data) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->len = 0;
}

static void html_buf_grow(html_buf_t* b, size_t need) {
    if (b->len + need + 1 <= b->cap) return;
    size_t nc = b->cap;
    while (b->len + need + 1 > nc) nc *= 2;
    char* nd = realloc(b->data, nc);
    if (!nd) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->data = nd;
    b->cap = nc;
}

static void html_buf_putc(html_buf_t* b, char c) {
    html_buf_grow(b, 1);
    b->data[b->len++] = c;
}

static void html_buf_puts(html_buf_t* b, const char* s) {
    if (!s) return;
    size_t n = strlen(s);
    html_buf_grow(b, n);
    memcpy(b->data + b->len, s, n);
    b->len += n;
}

static void html_buf_free(html_buf_t* b) {
    free(b->data);
    b->data = NULL;
    b->len = b->cap = 0;
}

/* ASCII tolower, locale-independent. */
static int html_tolower(int c) {
    return (c >= 'A' && c <= 'Z') ? c + 32 : c;
}

/* Case-insensitive ASCII compare of [a, a+n) against c-string `s`.
 * Returns 1 iff lengths match and bytes are equal under tolower. */
static int html_ieq_n(const char* a, size_t n, const char* s) {
    if (!a || !s) return 0;
    if (strlen(s) != n) return 0;
    for (size_t i = 0; i < n; i++) {
        if (html_tolower((unsigned char)a[i]) != html_tolower((unsigned char)s[i])) return 0;
    }
    return 1;
}

/* Case-insensitive ASCII compare of two byte slices. */
static int html_iemem(const char* a, const char* b, size_t n) {
    for (size_t i = 0; i < n; i++) {
        if (html_tolower((unsigned char)a[i]) != html_tolower((unsigned char)b[i])) return 0;
    }
    return 1;
}

/* Walk a JSON allowlist object and find the value (an array) for a given
 * tag key, comparing case-insensitively. On hit returns a pointer to the
 * opening `[` of the array and writes the byte length of the array span
 * (including the brackets) to *out_len. On miss returns NULL.
 *
 * The parser is intentionally tiny: it does not handle escapes inside
 * keys (allowlist authors do not need them), and it relies on balanced
 * brackets/quotes within the value array. */
static const char* html_allowlist_find(const char* allow, const char* tag,
                                       size_t tag_len, size_t* out_len) {
    if (!allow) return NULL;
    const char* p = allow;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '{') return NULL;
    p++;
    while (*p) {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p == '}' || *p == 0) return NULL;
        if (*p != '"') return NULL;
        p++;
        const char* k = p;
        while (*p && *p != '"') p++;
        if (*p != '"') return NULL;
        size_t klen = (size_t)(p - k);
        p++;
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
        if (*p != ':') return NULL;
        p++;
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
        if (*p != '[') return NULL;
        const char* arr_start = p;
        int depth = 0;
        int in_str = 0;
        while (*p) {
            char c = *p;
            if (in_str) {
                if (c == '\\' && p[1]) { p += 2; continue; }
                if (c == '"') in_str = 0;
            } else {
                if (c == '"') in_str = 1;
                else if (c == '[') depth++;
                else if (c == ']') { depth--; if (depth == 0) { p++; break; } }
            }
            p++;
        }
        size_t alen = (size_t)(p - arr_start);
        int match = (klen == tag_len) && html_iemem(k, tag, klen);
        if (match) {
            if (out_len) *out_len = alen;
            return arr_start;
        }
    }
    return NULL;
}

/* Returns 1 iff `attr` (length attr_len) appears as a string element
 * in the JSON array slice [arr, arr+arr_len). Comparison is case-
 * insensitive. */
static int html_attr_in_array(const char* arr, size_t arr_len,
                              const char* attr, size_t attr_len) {
    if (!arr || arr_len < 2) return 0;
    const char* p = arr + 1;
    const char* end = arr + arr_len - 1;
    while (p < end) {
        while (p < end && (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',')) p++;
        if (p >= end) return 0;
        if (*p != '"') return 0;
        p++;
        const char* s = p;
        while (p < end && *p != '"') {
            if (*p == '\\' && p + 1 < end) p++;
            p++;
        }
        if (p >= end) return 0;
        size_t slen = (size_t)(p - s);
        p++;
        if (slen == attr_len && html_iemem(s, attr, slen)) return 1;
    }
    return 0;
}

/* Hard-coded set of tags whose content is ALSO dropped (entire subtree). */
static int html_is_dangerous_container(const char* tag, size_t tag_len) {
    static const char* names[] = {
        "script", "style", "iframe", "object", "embed", "form",
        "noscript", "noembed", "template", "svg", "math", "frame",
        "frameset", "applet", "audio", "video", "source", "track",
        NULL
    };
    for (int i = 0; names[i]; i++) {
        if (html_ieq_n(tag, tag_len, names[i])) return 1;
    }
    return 0;
}

/* HTML void elements — emit without a close tag. */
static int html_is_void(const char* tag, size_t tag_len) {
    static const char* names[] = {
        "area", "base", "br", "col", "embed", "hr", "img", "input",
        "link", "meta", "param", "source", "track", "wbr",
        NULL
    };
    for (int i = 0; names[i]; i++) {
        if (html_ieq_n(tag, tag_len, names[i])) return 1;
    }
    return 0;
}

/* Append a single byte HTML-escaped into the output buffer. */
static void html_escape_byte(html_buf_t* out, unsigned char c) {
    switch (c) {
        case '<':  html_buf_puts(out, "&lt;");   break;
        case '>':  html_buf_puts(out, "&gt;");   break;
        case '"':  html_buf_puts(out, "&quot;"); break;
        case '\'': html_buf_puts(out, "&#39;");  break;
        default:   html_buf_putc(out, (char)c);  break;
    }
}

/* Validate a URL value against the allowlist of safe schemes for hrefs.
 * Returns 1 iff the URL is safe to emit. Acceptable forms:
 *   - http:// or https:// (case-insensitive)
 *   - mailto:
 *   - fragment-only `#anchor`
 *   - relative path that does not contain a colon before the first
 *     slash/?/# (so `foo/bar`, `/foo`, `?x=1` are OK; `javascript:x` is
 *     not — its colon precedes any path/hash/query separator).
 *
 * URL leading whitespace and embedded ASCII control bytes (TAB, LF, CR)
 * are stripped before the scheme test, mirroring how browsers normalise
 * URLs (these bytes are otherwise a known XSS bypass: `java\tscript:`). */
static int html_url_is_safe(const char* url, size_t len) {
    if (!url || len == 0) return 1;  /* empty href is harmless */
    size_t i = 0;
    while (i < len) {
        unsigned char c = (unsigned char)url[i];
        if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == 0x0B || c == 0x0C) {
            i++; continue;
        }
        break;
    }
    if (i >= len) return 1;  /* whitespace only */
    if (url[i] == '#') return 1;  /* fragment only */
    if (url[i] == '/' || url[i] == '?') return 1;  /* relative */
    /* Find the first scheme-terminating character. */
    size_t scheme_end = (size_t)-1;
    for (size_t j = i; j < len; j++) {
        char c = url[j];
        if (c == ':') { scheme_end = j; break; }
        if (c == '/' || c == '?' || c == '#') break;
    }
    if (scheme_end == (size_t)-1) return 1;  /* no colon → relative path */
    /* Lowercase the scheme, stripping embedded control bytes. */
    char scheme[32];
    size_t sl = 0;
    for (size_t j = i; j < scheme_end && sl < sizeof(scheme) - 1; j++) {
        unsigned char c = (unsigned char)url[j];
        if (c == '\t' || c == '\n' || c == '\r' || c == 0x0B || c == 0x0C) continue;
        scheme[sl++] = (char)html_tolower(c);
    }
    scheme[sl] = '\0';
    if (strcmp(scheme, "http") == 0) return 1;
    if (strcmp(scheme, "https") == 0) return 1;
    if (strcmp(scheme, "mailto") == 0) return 1;
    return 0;
}

el_val_t el_html_sanitize(el_val_t input_v, el_val_t allowlist_v) {
    const char* input = EL_CSTR(input_v);
    const char* allow = EL_CSTR(allowlist_v);
    if (!input) return el_wrap_str(el_strdup(""));
    if (!allow) allow = "{}";
    size_t in_len = strlen(input);

    html_buf_t out;
    html_buf_init(&out);

    size_t i = 0;
    while (i < in_len) {
        unsigned char c = (unsigned char)input[i];
        if (c != '<') {
            /* Plain text — escape and emit. We pass `&` through verbatim
             * to preserve pre-encoded entities (`&lt;`, `&amp;`, `&#x...;`)
             * which the browser will decode safely. */
            if (c == '&') html_buf_putc(&out, '&');
            else          html_escape_byte(&out, c);
            i++;
            continue;
        }
        /* `<` — try to parse a tag. */
        if (i + 1 >= in_len) {
            html_buf_puts(&out, "&lt;");
            i++;
            continue;
        }
        /* Comments, doctype, CDATA, processing instructions — drop entirely. */
        if (input[i + 1] == '!') {
            if (i + 3 < in_len && input[i + 2] == '-' && input[i + 3] == '-') {
                size_t j = i + 4;
                while (j + 2 < in_len && !(input[j] == '-' && input[j + 1] == '-' && input[j + 2] == '>')) j++;
                if (j + 2 < in_len) i = j + 3;
                else i = in_len;
                continue;
            }
            size_t j = i + 2;
            while (j < in_len && input[j] != '>') j++;
            i = (j < in_len) ? j + 1 : in_len;
            continue;
        }
        if (input[i + 1] == '?') {
            size_t j = i + 2;
            while (j < in_len && input[j] != '>') j++;
            i = (j < in_len) ? j + 1 : in_len;
            continue;
        }
        int is_close = 0;
        size_t name_start = i + 1;
        if (input[i + 1] == '/') {
            is_close = 1;
            name_start = i + 2;
        }
        if (name_start >= in_len) {
            html_buf_puts(&out, "&lt;");
            i++;
            continue;
        }
        unsigned char nc = (unsigned char)input[name_start];
        if (!((nc >= 'a' && nc <= 'z') || (nc >= 'A' && nc <= 'Z'))) {
            /* `<` followed by non-letter — emit as escaped text. */
            html_buf_puts(&out, "&lt;");
            i++;
            continue;
        }
        size_t name_end = name_start;
        while (name_end < in_len) {
            unsigned char x = (unsigned char)input[name_end];
            if ((x >= 'a' && x <= 'z') || (x >= 'A' && x <= 'Z') ||
                (x >= '0' && x <= '9') || x == '-' || x == '_' || x == ':') {
                name_end++;
            } else {
                break;
            }
        }
        const char* tag = input + name_start;
        size_t tag_len = name_end - name_start;
        /* Find the `>` that closes this tag, respecting quoted attrs. */
        size_t cur = name_end;
        int self_close = 0;
        while (cur < in_len) {
            unsigned char x = (unsigned char)input[cur];
            if (x == '"' || x == '\'') {
                unsigned char q = x;
                cur++;
                while (cur < in_len && (unsigned char)input[cur] != q) cur++;
                if (cur < in_len) cur++;  /* skip closing quote */
                continue;
            }
            if (x == '/' && cur + 1 < in_len && input[cur + 1] == '>') {
                self_close = 1;
                break;
            }
            if (x == '>') break;
            cur++;
        }
        if (cur >= in_len) {
            /* Malformed: unclosed tag at EOF. Drop the rest of the input. */
            i = in_len;
            continue;
        }
        size_t tag_end = self_close ? cur + 2 : cur + 1;  /* one past `>` */
        /* Dangerous container — drop the whole subtree. */
        if (!is_close && html_is_dangerous_container(tag, tag_len)) {
            if (self_close || html_is_void(tag, tag_len)) {
                i = tag_end;
                continue;
            }
            size_t scan = tag_end;
            int found_close = 0;
            while (scan < in_len) {
                if (input[scan] != '<') { scan++; continue; }
                if (scan + 1 < in_len && input[scan + 1] == '/') {
                    size_t cn_start = scan + 2;
                    size_t cn_end = cn_start;
                    while (cn_end < in_len) {
                        unsigned char x = (unsigned char)input[cn_end];
                        if ((x >= 'a' && x <= 'z') || (x >= 'A' && x <= 'Z') ||
                            (x >= '0' && x <= '9') || x == '-' || x == '_' || x == ':') {
                            cn_end++;
                        } else break;
                    }
                    if (cn_end - cn_start == tag_len &&
                        html_iemem(input + cn_start, tag, tag_len)) {
                        size_t end_close = cn_end;
                        while (end_close < in_len && input[end_close] != '>') end_close++;
                        i = (end_close < in_len) ? end_close + 1 : in_len;
                        found_close = 1;
                        break;
                    }
                }
                scan++;
            }
            if (!found_close) {
                /* No matching close — drop everything from here on. */
                i = in_len;
            }
            continue;
        }
        /* Look up the tag in the allowlist. */
        size_t arr_len = 0;
        const char* arr = html_allowlist_find(allow, tag, tag_len, &arr_len);
        if (!arr) {
            /* Tag not allowed. Drop the open/close marker; inner text is
             * processed by the outer loop and re-emitted as escaped text. */
            i = tag_end;
            continue;
        }
        if (is_close) {
            if (!html_is_void(tag, tag_len)) {
                html_buf_putc(&out, '<');
                html_buf_putc(&out, '/');
                for (size_t k = 0; k < tag_len; k++) {
                    html_buf_putc(&out, (char)html_tolower((unsigned char)tag[k]));
                }
                html_buf_putc(&out, '>');
            }
            i = tag_end;
            continue;
        }
        /* Allowed open tag. Emit `<name` and walk the attributes between
         * `name_end` and the closing `>`. */
        html_buf_putc(&out, '<');
        for (size_t k = 0; k < tag_len; k++) {
            html_buf_putc(&out, (char)html_tolower((unsigned char)tag[k]));
        }
        size_t a = name_end;
        while (a < cur) {
            unsigned char x = (unsigned char)input[a];
            if (x == ' ' || x == '\t' || x == '\n' || x == '\r' || x == '/') { a++; continue; }
            size_t an_start = a;
            while (a < cur) {
                unsigned char y = (unsigned char)input[a];
                if (y == '=' || y == ' ' || y == '\t' || y == '\n' || y == '\r' || y == '/' || y == '>') break;
                a++;
            }
            size_t an_len = a - an_start;
            if (an_len == 0) { a++; continue; }
            size_t av_start = 0;
            size_t av_len = 0;
            int has_value = 0;
            size_t b = a;
            while (b < cur && (input[b] == ' ' || input[b] == '\t' || input[b] == '\n' || input[b] == '\r')) b++;
            if (b < cur && input[b] == '=') {
                has_value = 1;
                b++;
                while (b < cur && (input[b] == ' ' || input[b] == '\t' || input[b] == '\n' || input[b] == '\r')) b++;
                if (b < cur && (input[b] == '"' || input[b] == '\'')) {
                    unsigned char q = (unsigned char)input[b];
                    b++;
                    av_start = b;
                    while (b < cur && (unsigned char)input[b] != q) b++;
                    av_len = b - av_start;
                    if (b < cur) b++;
                } else {
                    av_start = b;
                    while (b < cur) {
                        unsigned char y = (unsigned char)input[b];
                        if (y == ' ' || y == '\t' || y == '\n' || y == '\r' || y == '>') break;
                        b++;
                    }
                    av_len = b - av_start;
                }
                a = b;
            }
            if (!html_attr_in_array(arr, arr_len, input + an_start, an_len)) continue;
            int is_href = (an_len == 4 && html_iemem(input + an_start, "href", 4));
            int is_src  = (an_len == 3 && html_iemem(input + an_start, "src",  3));
            if ((is_href || is_src) && has_value) {
                if (!html_url_is_safe(input + av_start, av_len)) continue;
            }
            html_buf_putc(&out, ' ');
            for (size_t k = 0; k < an_len; k++) {
                html_buf_putc(&out, (char)html_tolower((unsigned char)input[an_start + k]));
            }
            if (has_value) {
                html_buf_puts(&out, "=\"");
                for (size_t k = 0; k < av_len; k++) {
                    unsigned char y = (unsigned char)input[av_start + k];
                    /* Re-escape so the emitted attribute is well-formed
                     * double-quoted HTML. `&` passes through to preserve
                     * pre-encoded entities. */
                    if (y == '"')      html_buf_puts(&out, "&quot;");
                    else if (y == '<') html_buf_puts(&out, "&lt;");
                    else if (y == '>') html_buf_puts(&out, "&gt;");
                    else                html_buf_putc(&out, (char)y);
                }
                html_buf_putc(&out, '"');
            }
        }
        html_buf_putc(&out, '>');
        i = tag_end;
    }
    /* Copy into arena-tracked buffer so the standard runtime memory model
     * applies to the returned string. */
    char* result = el_strbuf(out.len);
    memcpy(result, out.data, out.len);
    result[out.len] = '\0';
    html_buf_free(&out);
    return el_wrap_str(result);
}

/* ── JSON ────────────────────────────────────────────────────────────────── */

/* True iff the segment is non-empty and every byte is an ASCII digit. We treat
 * such segments as numeric array indices when walking a dot-path; mixed names
 * like "0a" remain object-key lookups, so a key named "0" still wins over an
 * index when the surrounding container is an object. */
static int json_path_seg_is_index(const char* seg, size_t n) {
    if (n == 0) return 0;
    for (size_t i = 0; i < n; i++) {
        if (seg[i] < '0' || seg[i] > '9') return 0;
    }
    return 1;
}

/* Skip JSON whitespace. */
static const char* json_skip_ws(const char* p) {
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    return p;
}

/* Descend one segment into the JSON cursor `p`.
 *   - If `p` points at an array `[...]` and the segment is all digits,
 *     advance to that element (zero-based).
 *   - Otherwise treat the segment as an object key and use json_find_key
 *     scoped to a one-level slice of the current container.
 * Returns NULL if the descent fails (segment not found, container mismatch).
 *
 * `seg` is a pointer into the original path string and `seg_len` is its
 * byte length — this avoids an extra alloc per segment. */
static const char* json_path_descend(const char* p, const char* seg, size_t seg_len) {
    if (!p || !seg) return NULL;
    p = json_skip_ws(p);
    if (*p == '[' && json_path_seg_is_index(seg, seg_len)) {
        long idx = 0;
        for (size_t i = 0; i < seg_len; i++) idx = idx * 10 + (seg[i] - '0');
        p++;  /* step past '[' */
        p = json_skip_ws(p);
        long cur = 0;
        while (*p && *p != ']') {
            if (cur == idx) return p;
            const char* end = json_skip_value(p);
            if (!end || end == p) return NULL;
            p = json_skip_ws(end);
            if (*p == ',') { p++; p = json_skip_ws(p); cur++; continue; }
            /* No comma after this element — only acceptable at the closing ']',
             * which means we ran out of elements. */
            break;
        }
        return NULL;
    }
    /* Object lookup. json_find_key walks at depth 1 of whatever container it
     * receives, so we slice from `p` onwards. Caller already positioned us at
     * the opening '{' (or at whitespace before it). */
    if (*p != '{') return NULL;
    /* Build a NUL-terminated copy of the key segment for the lookup. We only
     * pay this cost when the segment isn't a numeric index. */
    char stack_key[256];
    char* k = stack_key;
    if (seg_len + 1 > sizeof(stack_key)) {
        k = malloc(seg_len + 1);
        if (!k) return NULL;
    }
    memcpy(k, seg, seg_len);
    k[seg_len] = '\0';
    const char* found = json_find_key(p, k);
    if (k != stack_key) free(k);
    return found;
}

/* Read the JSON value at `p` into a freshly-allocated, arena-owned el_val_t.
 *   - String  -> unescaped, wrapped el_val_t string
 *   - Anything else -> raw JSON slice as a string (matches the historical
 *     json_get behaviour: numbers/bools/null come back stringified). */
static el_val_t json_read_value(const char* p) {
    p = json_skip_ws(p);
    if (*p == '"') {
        p++;
        size_t cap = strlen(p) + 1;
        char* out = el_strbuf(cap);
        char* w = out;
        while (*p && *p != '"') {
            if (*p == '\\' && *(p+1)) {
                p++;
                switch (*p) {
                    case '"':  *w++ = '"';  break;
                    case '\\': *w++ = '\\'; break;
                    case '/':  *w++ = '/';  break;
                    case 'n':  *w++ = '\n'; break;
                    case 'r':  *w++ = '\r'; break;
                    case 't':  *w++ = '\t'; break;
                    default:   *w++ = *p;   break;
                }
            } else {
                *w++ = *p;
            }
            p++;
        }
        *w = '\0';
        return el_wrap_str(out);
    }
    /* Object/array/number/bool/null — return the raw slice up to the value's
     * end. json_skip_value tracks brace/bracket/string state so nested objects
     * round-trip cleanly. */
    const char* end = json_skip_value(p);
    if (!end) end = p;
    size_t n = (size_t)(end - p);
    /* Strip trailing whitespace from scalar values so callers don't see
     * `123 ` when they parsed a pretty-printed number. */
    while (n > 0 && (p[n-1] == ' ' || p[n-1] == '\t' || p[n-1] == '\n' || p[n-1] == '\r')) {
        n--;
    }
    char* out = el_strbuf(n);
    memcpy(out, p, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t json_get(el_val_t jsonv, el_val_t keyv) {
    const char* json = EL_CSTR(jsonv);
    const char* key  = EL_CSTR(keyv);
    if (!json || !key) return el_wrap_str(el_strdup(""));

    /* Fast path: key contains no '.' — keep the historical single-segment
     * substring search so existing callers retain their O(strlen) cost
     * profile. The dot-path walker is only paid for when needed. */
    if (!strchr(key, '.')) {
        size_t klen = strlen(key);
        char stack_pat[512];
        char* pattern;
        if (klen + 5 <= sizeof(stack_pat)) {
            pattern = stack_pat;
        } else {
            pattern = malloc(klen + 5);
            if (!pattern) return el_wrap_str(el_strdup(""));
        }
        snprintf(pattern, klen + 5, "\"%s\":", key);
        const char* p = strstr(json, pattern);
        if (pattern != stack_pat) free(pattern);
        if (!p) return el_wrap_str(el_strdup(""));
        p += strlen(key) + 3; /* skip "key": */
        return json_read_value(p);
    }

    /* Dot-path traversal. Walk segments left to right; at each step, descend
     * into the current container by either array index (all-digit segment on
     * an array cursor) or object key. */
    const char* cursor = json_skip_ws(json);
    const char* seg_start = key;
    const char* k = key;
    while (1) {
        if (*k == '.' || *k == '\0') {
            size_t seg_len = (size_t)(k - seg_start);
            cursor = json_path_descend(cursor, seg_start, seg_len);
            if (!cursor) return el_wrap_str(el_strdup(""));
            if (*k == '\0') break;
            k++;
            seg_start = k;
            continue;
        }
        k++;
    }
    return json_read_value(cursor);
}

/* ── Float bit-cast helpers ──────────────────────────────────────────────── */
/* `el_to_float` and `el_from_float` are exposed in el_runtime.h as static
 * inlines so generated programs (which #include the header) can call them
 * for Float literals. No definitions are needed here. */

/* ── JSON parser (recursive descent) ─────────────────────────────────────── */
/*
 * Parsed JSON representation:
 *   - object  -> ElMap (keys & values are el_val_t)
 *   - array   -> ElList
 *   - string  -> EL_STR-wrapped char* (allocated)
 *   - number  -> int (el_val_t) if integer, otherwise el_from_float(double)
 *   - true    -> 1
 *   - false   -> 0
 *   - null    -> EL_NULL (0)
 *
 * Note: there is no runtime type tag — parsed numbers cannot be
 * distinguished from booleans by the runtime alone. The codegen tracks
 * types separately. This matches the rest of el_val_t's type-erased model.
 */

/* JsonParser struct is forward-declared near the HTTP/Engram section. */

static void jp_skip_ws(JsonParser* jp) {
    while (jp->p < jp->end) {
        char c = *jp->p;
        if (c == ' ' || c == '\t' || c == '\n' || c == '\r') jp->p++;
        else break;
    }
}

static el_val_t jp_parse_value(JsonParser* jp);

/* Parse a JSON string literal (the opening " has NOT yet been consumed). */
static char* jp_parse_string_raw(JsonParser* jp) {
    if (jp->p >= jp->end || *jp->p != '"') { jp->err = 1; return el_strdup(""); }
    jp->p++;
    size_t cap = 32, len = 0;
    char* out = malloc(cap);
    if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    while (jp->p < jp->end && *jp->p != '"') {
        char c = *jp->p++;
        if (c == '\\' && jp->p < jp->end) {
            char esc = *jp->p++;
            switch (esc) {
                case '"':  c = '"';  break;
                case '\\': c = '\\'; break;
                case '/':  c = '/';  break;
                case 'b':  c = '\b'; break;
                case 'f':  c = '\f'; break;
                case 'n':  c = '\n'; break;
                case 'r':  c = '\r'; break;
                case 't':  c = '\t'; break;
                case 'u': {
                    /* Decode \uXXXX (with surrogate pairs) to UTF-8.
                     *
                     * (2026-08-08 self-review) This used to skip the 4 hex
                     * digits and emit a literal '?'. That is a LOSSY, silent,
                     * irreversible transform on every JSON string entering the
                     * runtime — and JSON writers escape non-ASCII by default
                     * (Python json.dumps ships ensure_ascii=True; most MCP
                     * clients do the same). So every em dash, curly quote,
                     * accented letter, and emoji arriving over MCP or HTTP was
                     * replaced by one question mark on the way in, with no
                     * error and no counter.
                     *
                     * Measured on the live store before the fix: 3,119 of
                     * 4,081 non-telemetry nodes (76%) carried the damage,
                     * including the self traversal root and all 13 values
                     * nodes ("Value ? Constraints as Freedom"). Node contents
                     * split cleanly into fully-clean or fully-mangled with
                     * zero overlap — the tell that this was one write path,
                     * not gradual rot. The corruption is unrecoverable in
                     * place (3 bytes collapse to 1), so the only real fix is
                     * to stop producing it; historical repair has to come from
                     * each node's upstream source.
                     *
                     * Malformed escapes keep the old '?' behaviour rather than
                     * failing the parse: a truncated body should not take down
                     * a route that previously tolerated it. */
                    unsigned cp = 0;
                    int ok = 1;
                    for (int i = 0; i < 4; i++) {
                        if (jp->p >= jp->end) { ok = 0; break; }
                        char h = *jp->p++;
                        unsigned d;
                        if      (h >= '0' && h <= '9') d = (unsigned)(h - '0');
                        else if (h >= 'a' && h <= 'f') d = (unsigned)(h - 'a' + 10);
                        else if (h >= 'A' && h <= 'F') d = (unsigned)(h - 'A' + 10);
                        else { ok = 0; break; }
                        cp = (cp << 4) | d;
                    }
                    if (!ok) { c = '?'; break; }
                    /* High surrogate: pair it with the following low surrogate
                     * so astral-plane codepoints (emoji) survive. If the next
                     * token is not a valid low surrogate, rewind so it is
                     * parsed on its own terms rather than swallowed. */
                    if (cp >= 0xD800 && cp <= 0xDBFF &&
                        (size_t)(jp->end - jp->p) >= 6 &&
                        jp->p[0] == '\\' && jp->p[1] == 'u') {
                        const char* save = jp->p;
                        unsigned lo = 0; int ok2 = 1;
                        jp->p += 2;
                        for (int i = 0; i < 4; i++) {
                            char h = *jp->p++;
                            unsigned d;
                            if      (h >= '0' && h <= '9') d = (unsigned)(h - '0');
                            else if (h >= 'a' && h <= 'f') d = (unsigned)(h - 'a' + 10);
                            else if (h >= 'A' && h <= 'F') d = (unsigned)(h - 'A' + 10);
                            else { ok2 = 0; break; }
                            lo = (lo << 4) | d;
                        }
                        if (ok2 && lo >= 0xDC00 && lo <= 0xDFFF)
                            cp = 0x10000u + ((cp - 0xD800u) << 10) + (lo - 0xDC00u);
                        else jp->p = save;
                    }
                    /* Lone surrogate → U+FFFD (WHATWG / serde_json behaviour):
                     * emitting a raw surrogate would produce invalid UTF-8. */
                    if (cp >= 0xD800 && cp <= 0xDFFF) cp = 0xFFFD;

                    char ub[4]; int un;
                    if (cp < 0x80) {
                        ub[0] = (char)cp; un = 1;
                    } else if (cp < 0x800) {
                        ub[0] = (char)(0xC0 | (cp >> 6));
                        ub[1] = (char)(0x80 | (cp & 0x3F)); un = 2;
                    } else if (cp < 0x10000) {
                        ub[0] = (char)(0xE0 | (cp >> 12));
                        ub[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
                        ub[2] = (char)(0x80 | (cp & 0x3F)); un = 3;
                    } else {
                        ub[0] = (char)(0xF0 | (cp >> 18));
                        ub[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
                        ub[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
                        ub[3] = (char)(0x80 | (cp & 0x3F)); un = 4;
                    }
                    while (len + (size_t)un >= cap) {
                        cap *= 2;
                        out = realloc(out, cap);
                        if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
                    }
                    for (int i = 0; i < un; i++) out[len++] = ub[i];
                    continue; /* bytes already appended */
                }
                default: c = esc; break;
            }
        }
        if (len + 1 >= cap) {
            cap *= 2;
            out = realloc(out, cap);
            if (!out) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        }
        out[len++] = c;
    }
    if (jp->p < jp->end && *jp->p == '"') jp->p++;
    else jp->err = 1;
    out[len] = '\0';
    return out;
}

static el_val_t jp_parse_number(JsonParser* jp) {
    const char* start = jp->p;
    int is_float = 0;
    if (jp->p < jp->end && (*jp->p == '-' || *jp->p == '+')) jp->p++;
    while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    if (jp->p < jp->end && *jp->p == '.') {
        is_float = 1; jp->p++;
        while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    }
    if (jp->p < jp->end && (*jp->p == 'e' || *jp->p == 'E')) {
        is_float = 1; jp->p++;
        if (jp->p < jp->end && (*jp->p == '+' || *jp->p == '-')) jp->p++;
        while (jp->p < jp->end && isdigit((unsigned char)*jp->p)) jp->p++;
    }
    size_t n = (size_t)(jp->p - start);
    char buf[64];
    if (n >= sizeof(buf)) n = sizeof(buf) - 1;
    memcpy(buf, start, n);
    buf[n] = '\0';
    if (is_float) return el_from_float(strtod(buf, NULL));
    return (el_val_t)strtoll(buf, NULL, 10);
}

static el_val_t jp_parse_array(JsonParser* jp) {
    if (jp->p < jp->end && *jp->p == '[') jp->p++;
    el_val_t lst = el_list_empty();
    jp_skip_ws(jp);
    if (jp->p < jp->end && *jp->p == ']') { jp->p++; return lst; }
    while (jp->p < jp->end) {
        jp_skip_ws(jp);
        el_val_t v = jp_parse_value(jp);
        lst = el_list_append(lst, v);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
        if (jp->p < jp->end && *jp->p == ']') { jp->p++; break; }
        jp->err = 1;
        break;
    }
    return lst;
}

static el_val_t jp_parse_object(JsonParser* jp) {
    if (jp->p < jp->end && *jp->p == '{') jp->p++;
    el_val_t m = el_map_new(0);
    jp_skip_ws(jp);
    if (jp->p < jp->end && *jp->p == '}') { jp->p++; return m; }
    while (jp->p < jp->end) {
        jp_skip_ws(jp);
        char* key = jp_parse_string_raw(jp);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ':') jp->p++;
        else { jp->err = 1; free(key); break; }
        jp_skip_ws(jp);
        el_val_t v = jp_parse_value(jp);
        m = el_map_set(m, EL_STR(key), v);
        jp_skip_ws(jp);
        if (jp->p < jp->end && *jp->p == ',') { jp->p++; continue; }
        if (jp->p < jp->end && *jp->p == '}') { jp->p++; break; }
        jp->err = 1;
        break;
    }
    return m;
}

static el_val_t jp_parse_value(JsonParser* jp) {
    jp_skip_ws(jp);
    if (jp->p >= jp->end) { jp->err = 1; return EL_NULL; }
    char c = *jp->p;
    if (c == '"') return el_wrap_str(jp_parse_string_raw(jp));
    if (c == '{') return jp_parse_object(jp);
    if (c == '[') return jp_parse_array(jp);
    if (c == '-' || isdigit((unsigned char)c)) return jp_parse_number(jp);
    if (c == 't' && jp->p + 4 <= jp->end && strncmp(jp->p, "true", 4) == 0) { jp->p += 4; return 1; }
    if (c == 'f' && jp->p + 5 <= jp->end && strncmp(jp->p, "false", 5) == 0) { jp->p += 5; return 0; }
    if (c == 'n' && jp->p + 4 <= jp->end && strncmp(jp->p, "null", 4) == 0) { jp->p += 4; return EL_NULL; }
    jp->err = 1;
    return EL_NULL;
}

el_val_t json_parse(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return EL_NULL;
    JsonParser jp = { .p = s, .end = s + strlen(s), .err = 0 };
    el_val_t v = jp_parse_value(&jp);
    if (jp.err) return EL_NULL;
    return v;
}

/* ── JSON stringify ──────────────────────────────────────────────────────── */
/*
 * Stringify policy: el_val_t is type-erased, so we cannot perfectly
 * round-trip arbitrary values. We use these heuristics:
 *   - If value is an ElList pointer (in the heap range), serialize as array.
 *   - If value is an ElMap pointer, serialize as object.
 *   - If value looks like a printable string pointer, serialize as string.
 *   - Otherwise serialize as integer.
 * This is best-effort. Programs that need exact control should build the
 * string directly. A pointer test is the cheapest way to disambiguate
 * from small integers without a separate type tag.
 */

/* JsonBuf struct is forward-declared near the HTTP section so HTTP helpers
 * can use it. Its definition appears there. */

static void jb_init(JsonBuf* b) {
    b->cap = 64; b->len = 0;
    b->buf = malloc(b->cap);
    if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->buf[0] = '\0';
}

/* jb_init_cap — jb_init with a caller-supplied starting capacity.
 *
 * WHY THIS EXISTS (2026-08-11 self-review). jb_init starts at 64 BYTES and
 * jb_reserve grows by doubling. That is right for the hundreds of small JSON
 * responses this runtime builds per minute and catastrophic for the one that
 * is 64 MEGABYTES: serializing the canonical snapshot walked the buffer
 * 64B → 128B → ... → 128MB, about twenty reallocs, each copying everything
 * written so far. Roughly 128MB of memcpy per save, and — the part that
 * actually hurt — a fresh large span from the allocator every time.
 *
 * MEASURED (13,129 nodes / 43,400 edges, macOS arm64): RSS climbed +63MB per
 * snapshot write, linearly, 14 for 14 writes, no plateau — 204MB to 1,028MB.
 * `leaks` reported only 15KB genuinely unreachable, which is what makes this
 * subtle: nothing is leaked in the reachable/unreachable sense. engram_save
 * frees b.buf correctly on every path. The growth is the allocator declining
 * to return large freed spans to the OS, and the doubling walk guaranteeing
 * that each save asks for a differently-sized region than the last free made
 * available. Every durable write path calls this — node create, edge create,
 * the Hebbian batch write-back — so on the live daemon it grows without bound
 * until the process dies.
 *
 * The fix is to ask for the right size once. With a stable capacity the
 * allocator hands back the same span on every save and RSS flattens. */
static void jb_init_cap(JsonBuf* b, size_t cap) {
    if (cap < 64) cap = 64;
    b->cap = cap; b->len = 0;
    b->buf = malloc(b->cap);
    if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    b->buf[0] = '\0';
}

static void jb_reserve(JsonBuf* b, size_t add) {
    if (b->len + add + 1 > b->cap) {
        while (b->len + add + 1 > b->cap) b->cap *= 2;
        b->buf = realloc(b->buf, b->cap);
        if (!b->buf) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    }
}

static void jb_putc(JsonBuf* b, char c) {
    jb_reserve(b, 1);
    b->buf[b->len++] = c;
    b->buf[b->len] = '\0';
}

static void jb_puts(JsonBuf* b, const char* s) {
    size_t n = strlen(s);
    jb_reserve(b, n);
    memcpy(b->buf + b->len, s, n);
    b->len += n;
    b->buf[b->len] = '\0';
}

/* UTF-8 VALIDITY IS THE EMITTER'S CONTRACT (2026-08-16 self-review).
 *
 * This copied every byte >= 0x20 through verbatim, so a malformed sequence
 * anywhere in the store became malformed output. Measured against the live
 * graph: three nodes carry labels truncated to exactly 80 bytes ending in a
 * lone 0xE2 — the first byte of an em-dash, cut mid-sequence by some producer
 * that is NOT this runtime (no 80-byte truncation exists here; the content
 * itself is 2572 and 2746 bytes). Those three nodes made the ENTIRE 26 MB
 * /api/nodes/list response undecodable, so a strict parser could not read the
 * graph at all.
 *
 * Fixing only the writer would not have helped: the store already contains the
 * damage, and it accepts data from importers, other producers and older
 * binaries. A serializer that promises JSON owes valid UTF-8 regardless of what
 * it is handed — so validate here, at the boundary that makes the promise.
 * Invalid bytes become U+FFFD rather than being dropped, so damage stays
 * visible in the output instead of being silently papered over.
 *
 * Well-formed input is byte-identical to before: valid sequences are copied
 * verbatim, and only structurally invalid ones (bad lead byte, missing or bad
 * continuation, overlong encoding, UTF-16 surrogate, or > U+10FFFF) are
 * replaced. */
static void jb_emit_escaped(JsonBuf* b, const char* s) {
    jb_putc(b, '"');
    const unsigned char* p = (const unsigned char*)s;
    while (*p) {
        unsigned char c = *p;
        switch (c) {
            case '"':  jb_puts(b, "\\\""); p++; continue;
            case '\\': jb_puts(b, "\\\\"); p++; continue;
            case '\b': jb_puts(b, "\\b");  p++; continue;
            case '\f': jb_puts(b, "\\f");  p++; continue;
            case '\n': jb_puts(b, "\\n");  p++; continue;
            case '\r': jb_puts(b, "\\r");  p++; continue;
            case '\t': jb_puts(b, "\\t");  p++; continue;
            default: break;
        }
        if (c < 0x20) {
            char tmp[8];
            snprintf(tmp, sizeof(tmp), "\\u%04x", c);
            jb_puts(b, tmp);
            p++;
            continue;
        }
        if (c < 0x80) { jb_putc(b, (char)c); p++; continue; }

        /* Multi-byte: validate the whole sequence before emitting any of it. */
        int len; unsigned int cp;
        if      ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1Fu; }
        else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0Fu; }
        else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07u; }
        else                         { jb_puts(b, "\\ufffd"); p++; continue; }

        int ok = 1;
        for (int i = 1; i < len; i++) {
            if ((p[i] & 0xC0) != 0x80) { ok = 0; break; }   /* also catches NUL */
            cp = (cp << 6) | (unsigned int)(p[i] & 0x3F);
        }
        if (ok) {
            if      (len == 2 && cp < 0x80)     ok = 0;     /* overlong */
            else if (len == 3 && cp < 0x800)    ok = 0;     /* overlong */
            else if (len == 4 && cp < 0x10000)  ok = 0;     /* overlong */
            else if (cp >= 0xD800 && cp <= 0xDFFF) ok = 0;  /* UTF-16 surrogate */
            else if (cp > 0x10FFFF)             ok = 0;     /* out of range */
        }
        if (!ok) { jb_puts(b, "\\ufffd"); p++; continue; }
        for (int i = 0; i < len; i++) jb_putc(b, (char)p[i]);
        p += len;
    }
    jb_putc(b, '"');
}

/* Heuristic: is this el_val_t likely a pointer to an ElList?
 * We can't fully verify, but pointers are large addresses, integers small.
 * Treat values whose magnitude exceeds 2^32 as potential pointers and
 * sniff by reading the header conservatively.
 *
 * Simpler heuristic: if the value reads as a printable string, treat as
 * string; otherwise as integer. Lists/Maps are encoded as struct pointers,
 * which have leading binary bytes — so they won't look like strings. */

static int looks_like_string(el_val_t v) {
    if (v == 0) return 0;
    /* Treat plausible heap addresses as candidates.
     * Threshold: 4 GiB (0x100000000). On 64-bit systems heap addresses from
     * malloc/mmap start well above 4 GiB (ASLR pushes them to ~0x7f...).
     * El integer values (counters, unix timestamps up to ~2106) all fit below
     * 0x100000000 (4294967296). The old threshold of 1,000,000 caused unix
     * timestamps (~1.7e9) to be misidentified as string pointers — a segfault
     * risk in json_stringify and jb_emit_value. */
    uintptr_t p = (uintptr_t)v;
    if (p < 0x100000000ULL) return 0;   /* integers, timestamps, counters */
    if (p < 0x1000) return 0;
    /* Sniff first bytes for printable */
    const unsigned char* s = (const unsigned char*)p;
    for (int i = 0; i < 16; i++) {
        unsigned char c = s[i];
        if (c == '\0') return 1; /* terminated string (empty string is still a valid string) */
        /* Reject C0 control chars (non-whitespace), allow UTF-8 high bytes.
         * 0x09-0x0d = tab/newline/cr/vt/ff (whitespace, OK)
         * 0x20-0x7e = printable ASCII (OK)
         * 0x7f      = DEL (reject)
         * 0x80-0xff = UTF-8 continuation/lead bytes (OK for multi-byte chars) */
        if (c < 0x09 || (c > 0x0d && c < 0x20) || c == 0x7f) return 0;
    }
    return 1; /* 16+ printable bytes — call it a string */
}

static void jb_emit_value(JsonBuf* b, el_val_t v);

static void jb_emit_int(JsonBuf* b, int64_t n) {
    char tmp[32];
    snprintf(tmp, sizeof(tmp), "%lld", (long long)n);
    jb_puts(b, tmp);
}

static void jb_emit_value(JsonBuf* b, el_val_t v) {
    if (v == EL_NULL) { jb_puts(b, "null"); return; }
    if (looks_like_string(v)) {
        jb_emit_escaped(b, EL_CSTR(v));
        return;
    }
    jb_emit_int(b, (int64_t)v);
}

el_val_t json_stringify(el_val_t v) {
    JsonBuf b; jb_init(&b);
    jb_emit_value(&b, v);
    return el_wrap_str(b.buf);
}

/* ── JSON substring accessors ────────────────────────────────────────────── */
/*
 * These walk the raw JSON string looking for "key": at the top level (depth 1)
 * of an object. They handle escaped quotes, nested objects/arrays, and
 * whitespace around the colon.
 */

/* Find "key": at object-depth == 1 inside the JSON object string `s`.
 * Returns pointer to the first byte of the value, or NULL.            */
static const char* json_find_key(const char* s, const char* key) {
    if (!s || !key) return NULL;
    size_t klen = strlen(key);
    int depth = 0;
    int in_str = 0;
    int escape = 0;
    const char* p = s;
    while (*p) {
        char c = *p;
        if (in_str) {
            if (escape) { escape = 0; }
            else if (c == '\\') { escape = 1; }
            else if (c == '"') {
                /* End of string. If we're at depth 1, check if this was a key. */
                p++;
                if (depth == 1) {
                    /* The string just ended at p-1. Check if it matches key
                     * and is followed by a colon. We need to backtrack to find
                     * the start of this string and compare. */
                }
                in_str = 0;
                continue;
            }
            p++;
            continue;
        }
        if (c == '"') {
            /* Start of a string literal */
            const char* str_start = p + 1;
            const char* q = str_start;
            int e = 0;
            while (*q) {
                if (e) { e = 0; q++; continue; }
                if (*q == '\\') { e = 1; q++; continue; }
                if (*q == '"') break;
                q++;
            }
            size_t slen = (size_t)(q - str_start);
            const char* after = (*q == '"') ? q + 1 : q;
            /* If at depth 1 and matches key and followed by ':' -> got it */
            if (depth == 1 && slen == klen && strncmp(str_start, key, klen) == 0) {
                const char* r = after;
                while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
                if (*r == ':') {
                    r++;
                    while (*r == ' ' || *r == '\t' || *r == '\n' || *r == '\r') r++;
                    return r;
                }
            }
            p = after;
            continue;
        }
        if (c == '{' || c == '[') depth++;
        else if (c == '}' || c == ']') depth--;
        p++;
    }
    return NULL;
}

/* Skip a JSON value starting at p; return pointer past the value end.   */
static const char* json_skip_value(const char* p) {
    if (!p || !*p) return p;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p == '"') {
        p++;
        int e = 0;
        while (*p) {
            if (e) { e = 0; p++; continue; }
            if (*p == '\\') { e = 1; p++; continue; }
            if (*p == '"') { p++; break; }
            p++;
        }
        return p;
    }
    if (*p == '{' || *p == '[') {
        char open  = *p;
        char close = (open == '{') ? '}' : ']';
        int depth = 0;
        int in_str = 0;
        int e = 0;
        while (*p) {
            char c = *p;
            if (in_str) {
                if (e) { e = 0; }
                else if (c == '\\') { e = 1; }
                else if (c == '"') in_str = 0;
                p++;
                continue;
            }
            if (c == '"') { in_str = 1; p++; continue; }
            if (c == open) depth++;
            else if (c == close) { depth--; p++; if (depth == 0) return p; continue; }
            p++;
        }
        return p;
    }
    /* scalar: number, true/false/null */
    while (*p && *p != ',' && *p != '}' && *p != ']' &&
           *p != ' ' && *p != '\t' && *p != '\n' && *p != '\r') p++;
    return p;
}

el_val_t json_get_string(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p || *p != '"') return el_wrap_str(el_strdup(""));
    p++;
    JsonParser jp = { .p = p - 1, .end = json + (json ? strlen(json) : 0), .err = 0 };
    char* parsed = jp_parse_string_raw(&jp);
    if (jp.err) { free(parsed); return el_wrap_str(el_strdup("")); }
    return el_wrap_str(parsed);
}

el_val_t json_get_int(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (*p == '"' || *p == '{' || *p == '[') return 0;
    return (el_val_t)strtoll(p, NULL, 10);
}

el_val_t json_get_float(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (*p == '"' || *p == '{' || *p == '[') return 0;
    return el_from_float(strtod(p, NULL));
}

el_val_t json_get_bool(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    if (!p) return 0;
    if (strncmp(p, "true", 4) == 0) return 1;
    return 0;
}

el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    const char* p = json_find_key(json, k);
    /* Clear fs_read binary-length hint — result is a fresh null-terminated
     * string, not the raw file bytes, so Content-Length must use strlen.
     * (Kept although the pointer pairing now makes this redundant.) */
    _tl_fs_read_len = 0;
    _tl_fs_read_buf = NULL;
    if (!p) return el_wrap_str(el_strdup(""));
    const char* end = json_skip_value(p);
    size_t n = (size_t)(end - p);
    char* out = el_strbuf(n);
    memcpy(out, p, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t json_set(el_val_t json_str, el_val_t key, el_val_t value) {
    const char* json = EL_CSTR(json_str);
    const char* k    = EL_CSTR(key);
    if (!k) k = "";
    if (!json || !*json) {
        /* Build a fresh object */
        JsonBuf b; jb_init(&b);
        jb_putc(&b, '{');
        jb_emit_escaped(&b, k);
        jb_putc(&b, ':');
        jb_emit_value(&b, value);
        jb_putc(&b, '}');
        return el_wrap_str(b.buf);
    }
    const char* existing = json_find_key(json, k);
    JsonBuf b; jb_init(&b);
    if (existing) {
        const char* end = json_skip_value(existing);
        /* Copy [json .. existing) */
        size_t prefix = (size_t)(existing - json);
        jb_reserve(&b, prefix);
        memcpy(b.buf + b.len, json, prefix);
        b.len += prefix;
        b.buf[b.len] = '\0';
        jb_emit_value(&b, value);
        jb_puts(&b, end);
        return el_wrap_str(b.buf);
    }
    /* Insert before closing '}'. Find last '}' */
    size_t jl = strlen(json);
    if (jl == 0) { free(b.buf); return el_wrap_str(el_strdup("{}")); }
    /* Find last '}' from the end */
    ssize_t close_idx = -1;
    for (ssize_t i = (ssize_t)jl - 1; i >= 0; i--) {
        if (json[i] == '}') { close_idx = i; break; }
    }
    if (close_idx < 0) {
        free(b.buf);
        return el_wrap_str(el_strdup(json));
    }
    /* Determine if object is empty: scan between last '{' and '}' for non-ws */
    int empty = 1;
    for (ssize_t i = close_idx - 1; i >= 0; i--) {
        char c = json[i];
        if (c == '{') break;
        if (c != ' ' && c != '\t' && c != '\n' && c != '\r') { empty = 0; break; }
    }
    /* Copy json[0..close_idx) */
    jb_reserve(&b, (size_t)close_idx);
    memcpy(b.buf + b.len, json, (size_t)close_idx);
    b.len += (size_t)close_idx;
    b.buf[b.len] = '\0';
    if (!empty) jb_putc(&b, ',');
    jb_emit_escaped(&b, k);
    jb_putc(&b, ':');
    jb_emit_value(&b, value);
    /* Append from close_idx onward */
    jb_puts(&b, json + close_idx);
    return el_wrap_str(b.buf);
}

el_val_t json_array_len(el_val_t json_str) {
    const char* s = EL_CSTR(json_str);
    if (!s) return 0;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s != '[') return 0;
    s++;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == ']') return 0;
    int64_t count = 0;
    while (*s) {
        const char* end = json_skip_value(s);
        if (end == s) break;
        count++;
        s = end;
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ',') { s++; continue; }
        if (*s == ']' || *s == '\0') break;
    }
    return (el_val_t)count;
}

/* json_array_get — return the i-th element of a JSON array as a JSON
 * fragment string. Nested objects and arrays are returned verbatim
 * (json_skip_value tracks brace/bracket depth so nested structures are
 * preserved intact). Out-of-range index → "". */
el_val_t json_array_get(el_val_t json_str, el_val_t index) {
    const char* s = EL_CSTR(json_str);
    int64_t  idx = (int64_t)index;
    if (!s || idx < 0) return el_wrap_str(el_strdup(""));
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s != '[') return el_wrap_str(el_strdup(""));
    s++;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == ']') return el_wrap_str(el_strdup(""));
    int64_t i = 0;
    while (*s) {
        const char* start = s;
        const char* end   = json_skip_value(s);
        if (end == s) break;
        if (i == idx) {
            size_t n = (size_t)(end - start);
            char* out = el_strbuf(n);
            memcpy(out, start, n);
            out[n] = '\0';
            return el_wrap_str(out);
        }
        i++;
        s = end;
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ',') { s++; while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++; continue; }
        if (*s == ']' || *s == '\0') break;
    }
    return el_wrap_str(el_strdup(""));
}

/* json_array_get_string — same as json_array_get, but assume the element
 * is a JSON string and return the unquoted/unescaped value. Non-string
 * elements yield "". */
el_val_t json_array_get_string(el_val_t json_str, el_val_t index) {
    el_val_t raw = json_array_get(json_str, index);
    const char* s = EL_CSTR(raw);
    if (!s || *s != '"') return el_wrap_str(el_strdup(""));
    JsonParser jp = {
        .p   = s,
        .end = s + strlen(s),
        .err = 0,
    };
    char* parsed = jp_parse_string_raw(&jp);
    if (jp.err) {
        free(parsed);
        return el_wrap_str(el_strdup(""));
    }
    return el_wrap_str(parsed);
}

/* ── Time ────────────────────────────────────────────────────────────────── */

el_val_t time_now(void) {
    struct timeval tv;
    gettimeofday(&tv, NULL);
    int64_t ms = (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
    return (el_val_t)ms;
}

el_val_t time_now_utc(void) {
    return time_now();
}

el_val_t time_format(el_val_t ts, el_val_t fmt) {
    int64_t ms = (int64_t)ts;
    time_t  s  = (time_t)(ms / 1000);
    int     msec = (int)(ms % 1000);
    if (msec < 0) { msec += 1000; s -= 1; }
    struct tm tm;
    gmtime_r(&s, &tm);
    const char* fmt_str = EL_CSTR(fmt);
    if (!fmt_str || strcmp(fmt_str, "ISO") == 0) {
        char buf[64];
        snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
                 tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
                 tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
        return el_wrap_str(el_strdup(buf));
    }
    char buf[256];
    if (strftime(buf, sizeof(buf), fmt_str, &tm) == 0) buf[0] = '\0';
    return el_wrap_str(el_strdup(buf));
}

el_val_t time_to_parts(el_val_t ts) {
    int64_t ms = (int64_t)ts;
    time_t  s  = (time_t)(ms / 1000);
    int     msec = (int)(ms % 1000);
    if (msec < 0) { msec += 1000; s -= 1; }
    struct tm tm;
    gmtime_r(&s, &tm);
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("year")),   (el_val_t)(tm.tm_year + 1900));
    m = el_map_set(m, EL_STR(el_strdup("month")),  (el_val_t)(tm.tm_mon + 1));
    m = el_map_set(m, EL_STR(el_strdup("day")),    (el_val_t)tm.tm_mday);
    m = el_map_set(m, EL_STR(el_strdup("hour")),   (el_val_t)tm.tm_hour);
    m = el_map_set(m, EL_STR(el_strdup("minute")), (el_val_t)tm.tm_min);
    m = el_map_set(m, EL_STR(el_strdup("second")), (el_val_t)tm.tm_sec);
    m = el_map_set(m, EL_STR(el_strdup("ms")),     (el_val_t)msec);
    return m;
}

el_val_t time_from_parts(el_val_t secs, el_val_t ns, el_val_t tz) {
    (void)tz;
    int64_t s  = (int64_t)secs;
    int64_t n  = (int64_t)ns;
    int64_t ms = s * 1000LL + n / 1000000LL;
    return (el_val_t)ms;
}

el_val_t time_add(el_val_t ts, el_val_t n, el_val_t unit) {
    const char* u = EL_CSTR(unit);
    int64_t cur = (int64_t)ts;
    int64_t d   = (int64_t)n;
    int64_t add_ms = d;
    if (u) {
        if      (strcmp(u, "ms")   == 0) add_ms = d;
        else if (strcmp(u, "sec")  == 0) add_ms = d * 1000LL;
        else if (strcmp(u, "min")  == 0) add_ms = d * 60000LL;
        else if (strcmp(u, "hour") == 0) add_ms = d * 3600000LL;
        else if (strcmp(u, "day")  == 0) add_ms = d * 86400000LL;
    }
    return (el_val_t)(cur + add_ms);
}

el_val_t time_diff(el_val_t ts1, el_val_t ts2, el_val_t unit) {
    int64_t d = (int64_t)ts2 - (int64_t)ts1;
    const char* u = EL_CSTR(unit);
    if (!u || strcmp(u, "ms") == 0) return (el_val_t)d;
    if (strcmp(u, "sec")  == 0) return (el_val_t)(d / 1000LL);
    if (strcmp(u, "min")  == 0) return (el_val_t)(d / 60000LL);
    if (strcmp(u, "hour") == 0) return (el_val_t)(d / 3600000LL);
    if (strcmp(u, "day")  == 0) return (el_val_t)(d / 86400000LL);
    return (el_val_t)d;
}

/* Block the calling thread for `secs` seconds. Negative values are clamped
 * to 0. Used by El programs that poll external resources (e.g. RunPod
 * /status, Engram readiness probes). */
el_val_t sleep_secs(el_val_t secs) {
    int64_t s = (int64_t)secs;
    if (s < 0) s = 0;
    struct timespec ts;
    ts.tv_sec  = (time_t)s;
    ts.tv_nsec = 0;
    nanosleep(&ts, NULL);
    return 0;
}

el_val_t sleep_ms(el_val_t ms) {
    int64_t m = (int64_t)ms;
    if (m < 0) m = 0;
    struct timespec ts;
    ts.tv_sec  = (time_t)(m / 1000LL);
    ts.tv_nsec = (long)((m % 1000LL) * 1000000LL);
    nanosleep(&ts, NULL);
    return 0;
}

/* ── Instant + Duration: first-class temporal types ──────────────────────────
 * El's substrate (Neuron) is a temporal cognition system. Memory salience
 * decay, the six-tier pacemaker, TTL caches, and supersession are all
 * temporal. Treating time as a raw Int (now() returning ms-since-epoch and
 * arithmetic done with mixed unit literals) lets bugs through the type
 * system: `(now - cached_at) < 60` cannot tell ms from sec, and `sleep(30)`
 * is ambiguous. This block introduces two dedicated representations.
 *
 * Representation:
 *   Instant   — int64 nanoseconds since the Unix epoch
 *   Duration  — int64 nanoseconds (signed; negative durations are legal,
 *               e.g. when a deadline has passed)
 *
 * Both share the el_val_t (int64) slot the rest of the runtime uses, so no
 * boxing / arena allocation is needed. Type discipline is enforced at the
 * codegen layer: `let x: Duration = ...` registers `x` in __duration_names,
 * and BinOp dispatches through typed wrappers (el_duration_add, etc.) that
 * make intent explicit in the generated C. Mismatched ops (Instant+Instant,
 * Duration+Int) are surfaced via #error directives at codegen time so the
 * downstream cc step fails with a clear El-source-level message.
 *
 * Nanosecond precision matches POSIX clock_gettime / nanosleep granularity.
 * 2^63 nanos covers ~292 years from epoch — comfortably past 2200, plenty
 * for a memory-system runtime that never schedules outside a human lifespan.
 */

/* now() — current Instant. Wraps clock_gettime(CLOCK_REALTIME) for nanosecond
 * precision. Falls back to gettimeofday on systems where clock_gettime is
 * unavailable (defensive — every supported platform has it). */
el_val_t el_now_instant(void) {
    struct timespec ts;
    if (clock_gettime(CLOCK_REALTIME, &ts) == 0) {
        int64_t ns = (int64_t)ts.tv_sec * 1000000000LL + (int64_t)ts.tv_nsec;
        return (el_val_t)ns;
    }
    struct timeval tv;
    gettimeofday(&tv, NULL);
    int64_t ns = (int64_t)tv.tv_sec * 1000000000LL
               + (int64_t)tv.tv_usec * 1000LL;
    return (el_val_t)ns;
}

el_val_t now(void) {
    return el_now_instant();
}

/* unix_seconds(n)  — Instant from a Unix-epoch second count.
 * unix_millis(n)   — Instant from a Unix-epoch millisecond count.            */
el_val_t unix_seconds(el_val_t n) {
    int64_t s = (int64_t)n;
    return (el_val_t)(s * 1000000000LL);
}

el_val_t unix_millis(el_val_t n) {
    int64_t m = (int64_t)n;
    return (el_val_t)(m * 1000000LL);
}

/* instant_from_iso8601 — parse a strict subset:
 *   YYYY-MM-DDTHH:MM:SS[.fff]Z
 * Returns 0 (the Unix-epoch sentinel) on parse failure. Callers that need to
 * distinguish epoch-zero from a parse error should use a wider sentinel
 * representation; the current zero-on-failure choice matches existing El
 * runtime conventions for parse builtins (str_to_int, parse_int).            */
el_val_t instant_from_iso8601(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (!str) return (el_val_t)0;
    int Y, M, D, h, m, sec, frac = 0;
    int n = sscanf(str, "%d-%d-%dT%d:%d:%d.%3d", &Y, &M, &D, &h, &m, &sec, &frac);
    if (n < 6) {
        n = sscanf(str, "%d-%d-%dT%d:%d:%dZ", &Y, &M, &D, &h, &m, &sec);
        if (n < 6) return (el_val_t)0;
    }
    struct tm tm;
    memset(&tm, 0, sizeof(tm));
    tm.tm_year = Y - 1900;
    tm.tm_mon  = M - 1;
    tm.tm_mday = D;
    tm.tm_hour = h;
    tm.tm_min  = m;
    tm.tm_sec  = sec;
    /* timegm — UTC. POSIX-Y but available on macOS and glibc. */
    time_t t = timegm(&tm);
    if (t == (time_t)-1) return (el_val_t)0;
    int64_t ns = (int64_t)t * 1000000000LL + (int64_t)frac * 1000000LL;
    return (el_val_t)ns;
}

/* Duration constructors. The El-side postfix literals (30.seconds, 1.hour)
 * are lowered by the codegen directly into a literal int64 of nanoseconds —
 * these constructors are for runtime values where the count is dynamic. */
el_val_t el_duration_from_nanos(el_val_t ns) {
    return (el_val_t)(int64_t)ns;
}

el_val_t duration_seconds(el_val_t n) {
    int64_t s = (int64_t)n;
    return (el_val_t)(s * 1000000000LL);
}

el_val_t duration_millis(el_val_t n) {
    int64_t m = (int64_t)n;
    return (el_val_t)(m * 1000000LL);
}

el_val_t duration_nanos(el_val_t n) {
    return (el_val_t)(int64_t)n;
}

/* Arithmetic — typed wrappers. At the C level these are no-op casts, but
 * the codegen routes Instant/Duration BinOps through them so the generated
 * C says `el_instant_add_dur(start, dur)` rather than `start + dur`. The
 * intent is explicit, the operand order is documented, and a future change
 * to the underlying representation (saturating arithmetic, overflow guards)
 * has a single chokepoint.                                                  */
el_val_t el_instant_add_dur(el_val_t inst, el_val_t dur) {
    return (el_val_t)((int64_t)inst + (int64_t)dur);
}

el_val_t el_instant_sub_dur(el_val_t inst, el_val_t dur) {
    return (el_val_t)((int64_t)inst - (int64_t)dur);
}

el_val_t el_instant_diff(el_val_t a, el_val_t b) {
    /* a - b — yields a Duration (negative if b is later than a). */
    return (el_val_t)((int64_t)a - (int64_t)b);
}

el_val_t el_duration_add(el_val_t a, el_val_t b) {
    return (el_val_t)((int64_t)a + (int64_t)b);
}

el_val_t el_duration_sub(el_val_t a, el_val_t b) {
    return (el_val_t)((int64_t)a - (int64_t)b);
}

el_val_t el_duration_scale(el_val_t dur, el_val_t scalar) {
    return (el_val_t)((int64_t)dur * (int64_t)scalar);
}

el_val_t el_duration_div(el_val_t dur, el_val_t scalar) {
    int64_t s = (int64_t)scalar;
    if (s == 0) return (el_val_t)0;
    return (el_val_t)((int64_t)dur / s);
}

/* Comparisons. Return 1/0 in el_val_t convention. */
el_val_t el_instant_lt(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a <  (int64_t)b ? 1 : 0); }
el_val_t el_instant_le(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a <= (int64_t)b ? 1 : 0); }
el_val_t el_instant_gt(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a >  (int64_t)b ? 1 : 0); }
el_val_t el_instant_ge(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a >= (int64_t)b ? 1 : 0); }
el_val_t el_instant_eq(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a == (int64_t)b ? 1 : 0); }
el_val_t el_instant_ne(el_val_t a, el_val_t b)  { return (el_val_t)((int64_t)a != (int64_t)b ? 1 : 0); }
el_val_t el_duration_lt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a <  (int64_t)b ? 1 : 0); }
el_val_t el_duration_le(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a <= (int64_t)b ? 1 : 0); }
el_val_t el_duration_gt(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a >  (int64_t)b ? 1 : 0); }
el_val_t el_duration_ge(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a >= (int64_t)b ? 1 : 0); }
el_val_t el_duration_eq(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a == (int64_t)b ? 1 : 0); }
el_val_t el_duration_ne(el_val_t a, el_val_t b) { return (el_val_t)((int64_t)a != (int64_t)b ? 1 : 0); }

/* Conversions. */
el_val_t instant_to_unix_seconds(el_val_t i) {
    return (el_val_t)((int64_t)i / 1000000000LL);
}

el_val_t instant_to_unix_millis(el_val_t i) {
    return (el_val_t)((int64_t)i / 1000000LL);
}

el_val_t instant_to_iso8601(el_val_t i) {
    int64_t ns = (int64_t)i;
    time_t s = (time_t)(ns / 1000000000LL);
    int    msec = (int)((ns / 1000000LL) % 1000LL);
    if (msec < 0) { msec += 1000; s -= 1; }
    struct tm tm;
    gmtime_r(&s, &tm);
    char buf[64];
    snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ",
             tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday,
             tm.tm_hour, tm.tm_min, tm.tm_sec, msec);
    return el_wrap_str(el_strdup(buf));
}

el_val_t duration_to_seconds(el_val_t d) {
    return (el_val_t)((int64_t)d / 1000000000LL);
}

el_val_t duration_to_millis(el_val_t d) {
    return (el_val_t)((int64_t)d / 1000000LL);
}

el_val_t duration_to_nanos(el_val_t d) {
    return (el_val_t)(int64_t)d;
}

/* sleep(Duration) — Phase 1 replacement for ambiguous sleep(Int). The runtime
 * still exposes sleep_secs/sleep_ms for legacy call sites; codegen lowers
 * sleep(Duration) to el_sleep_duration(d). Negative durations clamp to 0 so a
 * stale deadline doesn't block forever. */
el_val_t el_sleep_duration(el_val_t dur) {
    int64_t ns = (int64_t)dur;
    if (ns < 0) ns = 0;
    struct timespec ts;
    ts.tv_sec  = (time_t)(ns / 1000000000LL);
    ts.tv_nsec = (long)(ns % 1000000000LL);
    nanosleep(&ts, NULL);
    return (el_val_t)0;
}

/* unix_timestamp() — back-compat. Existing El callers expect an Int seconds
 * value; this stays an Int returner so the type system isn't disturbed for
 * legacy code. New code should call now() and convert when needed.          */
el_val_t unix_timestamp(void) {
    return instant_to_unix_seconds(el_now_instant());
}

/* TTL cache helpers. Backed by the existing process-wide K/V (state_set/get)
 * with a sibling __ttl_set_at_<key> entry recording the Instant of the last
 * write. ttl_cache_get returns "" if the entry is missing or stale, so call
 * sites can branch on `if v == "" { miss } else { hit }` — the same shape
 * existing get-with-default code uses. No more (now - cached_at) < 60. */
el_val_t ttl_cache_set(el_val_t key, el_val_t value) {
    const char* k = EL_CSTR(key);
    if (!k) return (el_val_t)0;
    /* Store the value at the user's key. */
    state_set(key, value);
    /* Stamp set_at — opaque schema, namespaced under __ttl: prefix so user
     * keys can't collide with stamps. */
    size_t klen = strlen(k);
    char* stamp_key = (char*)malloc(klen + 16);
    if (!stamp_key) return (el_val_t)0;
    snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
    int64_t now_ns = (int64_t)el_now_instant();
    char buf[32];
    snprintf(buf, sizeof(buf), "%lld", (long long)now_ns);
    state_set(EL_STR(stamp_key), EL_STR(buf));
    free(stamp_key);
    return (el_val_t)1;
}

el_val_t ttl_cache_get(el_val_t key, el_val_t max_age) {
    const char* k = EL_CSTR(key);
    if (!k) return el_wrap_str(el_strdup(""));
    /* Look up stamp. */
    size_t klen = strlen(k);
    char* stamp_key = (char*)malloc(klen + 16);
    if (!stamp_key) return el_wrap_str(el_strdup(""));
    snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
    el_val_t stamp = state_get(EL_STR(stamp_key));
    free(stamp_key);
    const char* sv = EL_CSTR(stamp);
    if (!sv || !*sv) return el_wrap_str(el_strdup(""));
    int64_t set_at = (int64_t)atoll(sv);
    int64_t now_ns = (int64_t)el_now_instant();
    int64_t age    = now_ns - set_at;
    int64_t max_ns = (int64_t)max_age;
    if (age < 0)       return el_wrap_str(el_strdup(""));   /* clock skew — treat as miss */
    if (age > max_ns)  return el_wrap_str(el_strdup(""));   /* expired */
    return state_get(key);
}

el_val_t ttl_cache_age(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return (el_val_t)INT64_MAX;
    size_t klen = strlen(k);
    char* stamp_key = (char*)malloc(klen + 16);
    if (!stamp_key) return (el_val_t)INT64_MAX;
    snprintf(stamp_key, klen + 16, "__ttl_at:%s", k);
    el_val_t stamp = state_get(EL_STR(stamp_key));
    free(stamp_key);
    const char* sv = EL_CSTR(stamp);
    if (!sv || !*sv) return (el_val_t)INT64_MAX;
    int64_t set_at = (int64_t)atoll(sv);
    int64_t now_ns = (int64_t)el_now_instant();
    return (el_val_t)(now_ns - set_at);
}

/* ── Calendar + CalendarTime + Rhythm + LocalDate/Time/DateTime ──────────────
 * Phase 1.5. Calendar is pluggable: EarthCalendar (IANA zones + Gregorian +
 * DST), MarsCalendar (sols, MTC), CycleCalendar(period), NoCycleCalendar,
 * RelativeCalendar(epoch). Phase 1 zone wrapping folds INTO EarthCalendar;
 * UTC and IANA zones are themselves Earth-parochial and cannot live at the
 * lowest type layer.
 *
 * A Rhythm is a small AST that asks the Calendar for cycle phase, weekday,
 * etc. Most rhythm logic is calendar-agnostic at runtime: rhythm_cycle_phase
 * means "midpoint of cycle" whether the cycle is 24h on Earth or 30h on a
 * station or 300y on a long-cycle world.                                     */

/* Magic headers — used by the runtime to recognize boxed temporal values
 * arriving through el_val_t. Distinct constants so accidental misuse fails
 * loudly rather than silently.                                              */
#define EL_CAL_MAGIC      0xE1CA1EDDU
#define EL_CALTIME_MAGIC  0xE1CA1747U
#define EL_RHYTHM_MAGIC   0xE1287A11U
#define EL_LDATE_MAGIC    0xE1DA7E00U
#define EL_LDT_MAGIC      0xE1DA7E1DU
#define EL_ZONE_MAGIC     0xE12017E0U

typedef enum {
    EL_CALENDAR_EARTH    = 1,
    EL_CALENDAR_MARS     = 2,
    EL_CALENDAR_CYCLE    = 3,
    EL_CALENDAR_NO_CYCLE = 4,
    EL_CALENDAR_RELATIVE = 5
} el_calendar_kind_t;

typedef struct {
    uint32_t magic;
    char*    id;        /* IANA name or "+HH:MM" / "-HH:MM" */
    int      fixed;     /* 1 for fixed offset, 0 for IANA */
    int64_t  offset_ns; /* fixed offset in nanos (only when fixed) */
} el_zone_t;

typedef struct {
    uint32_t            magic;
    el_calendar_kind_t  kind;
    el_zone_t*          zone;          /* EarthCalendar; MarsCalendar uses MTC */
    int64_t             cycle_period_ns;/* CycleCalendar; computed for Earth (86400 s) and Mars (88775.244 s) */
    int64_t             epoch_ns;      /* RelativeCalendar; Unix-epoch zero otherwise */
} el_calendar_t;

typedef struct {
    uint32_t        magic;
    int64_t         instant_ns;
    el_calendar_t*  cal;
} el_caltime_t;

/* Rhythm AST. */
typedef enum {
    EL_RHYTHM_CYCLE_START   = 1,
    EL_RHYTHM_CYCLE_PHASE   = 2,
    EL_RHYTHM_DURATION      = 3,
    EL_RHYTHM_SESSION_START = 4,
    EL_RHYTHM_EVENT         = 5,
    EL_RHYTHM_AND           = 6,
    EL_RHYTHM_OR            = 7,
    EL_RHYTHM_WEEKDAY       = 8,
    EL_RHYTHM_WEEKLY_AT     = 9
} el_rhythm_kind_t;

typedef struct el_rhythm_s {
    uint32_t            magic;
    el_rhythm_kind_t    kind;
    double              phase;       /* CYCLE_PHASE */
    int64_t             period_ns;   /* DURATION */
    int                 weekday;     /* 1..7 Mon..Sun */
    int                 hour;
    int                 minute;
    char*               event_name;  /* EVENT */
    struct el_rhythm_s* a;           /* AND/OR */
    struct el_rhythm_s* b;
} el_rhythm_t;

typedef struct {
    uint32_t magic;
    int      year;
    int      month;
    int      day;
} el_localdate_t;

typedef struct {
    uint32_t        magic;
    el_localdate_t* date;
    int64_t         time_ns; /* nanos since midnight */
} el_localdt_t;

/* Magic-tag check helpers — peek the first 4 bytes of an el_val_t pointer
 * and compare against the expected magic. Strings are NUL-terminated and
 * never start with our magic byte sequence, so this is safe.               */
static int el_is_magic(el_val_t v, uint32_t want) {
    if (v == 0) return 0;
    /* Defensive: only follow pointers in plausible address space.
     * On 64-bit unix processes pointers are above 0x10000. */
    if ((uint64_t)v < 0x10000ULL) return 0;
    uint32_t got = *(volatile uint32_t*)(uintptr_t)v;
    return got == want;
}

/* Sol length on Mars in nanoseconds: 88775.244 seconds. */
#define EL_MARS_SOL_NS  ((int64_t)88775244000000LL)
/* Earth solar day in nanoseconds: 86400 seconds. */
#define EL_EARTH_DAY_NS ((int64_t)86400000000000LL)

/* ── Zone construction ──────────────────────────────────────────────────────
 * Zones intern by id string so equality comparisons are pointer-compares. */

#define EL_ZONE_TABLE_CAP 64
static el_zone_t* _el_zone_table[EL_ZONE_TABLE_CAP];
static int        _el_zone_count = 0;

static el_zone_t* _el_zone_intern(const char* id, int fixed, int64_t offset_ns) {
    for (int i = 0; i < _el_zone_count; i++) {
        el_zone_t* z = _el_zone_table[i];
        if (z->fixed == fixed && z->offset_ns == offset_ns &&
            strcmp(z->id ? z->id : "", id ? id : "") == 0) {
            return z;
        }
    }
    if (_el_zone_count >= EL_ZONE_TABLE_CAP) {
        /* Out of slots: build a non-interned zone. Equality will fail across
         * such zones but the program still runs. */
        el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
        z->magic = EL_ZONE_MAGIC;
        z->id = el_strdup_persist(id ? id : "");
        z->fixed = fixed;
        z->offset_ns = offset_ns;
        return z;
    }
    el_zone_t* z = (el_zone_t*)malloc(sizeof(el_zone_t));
    z->magic = EL_ZONE_MAGIC;
    z->id = el_strdup_persist(id ? id : "");
    z->fixed = fixed;
    z->offset_ns = offset_ns;
    _el_zone_table[_el_zone_count++] = z;
    return z;
}

el_val_t zone(el_val_t id) {
    const char* s = EL_CSTR(id);
    if (!s || !*s) return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 0, 0);
    /* Fixed-offset shortcut: "+HH:MM" or "-HH:MM". */
    if ((s[0] == '+' || s[0] == '-') && strlen(s) >= 6 && s[3] == ':') {
        int sign = (s[0] == '-') ? -1 : 1;
        int hh = (s[1] - '0') * 10 + (s[2] - '0');
        int mm = (s[4] - '0') * 10 + (s[5] - '0');
        int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
        return (el_val_t)(uintptr_t)_el_zone_intern(s, 1, off);
    }
    return (el_val_t)(uintptr_t)_el_zone_intern(s, 0, 0);
}

el_val_t zone_utc(void) {
    return (el_val_t)(uintptr_t)_el_zone_intern("UTC", 1, 0);
}

el_val_t zone_local(void) {
    /* Resolve the local zone via TZ env or system default. tzset() picks
     * up TZ if set; otherwise the C library reads /etc/localtime. We store
     * the zone id as "LOCAL" so subsequent equality holds; resolution is
     * lazy at use time. */
    return (el_val_t)(uintptr_t)_el_zone_intern("LOCAL", 0, 0);
}

el_val_t zone_offset(el_val_t hours, el_val_t minutes) {
    int hh = (int)(int64_t)hours;
    int mm = (int)(int64_t)minutes;
    int sign = (hh < 0 || mm < 0) ? -1 : 1;
    if (hh < 0) hh = -hh;
    if (mm < 0) mm = -mm;
    int64_t off = (int64_t)sign * ((int64_t)hh * 3600LL + (int64_t)mm * 60LL) * 1000000000LL;
    char buf[16];
    snprintf(buf, sizeof(buf), "%c%02d:%02d", sign < 0 ? '-' : '+', hh, mm);
    return (el_val_t)(uintptr_t)_el_zone_intern(buf, 1, off);
}

/* ── Calendar interning ──────────────────────────────────────────────────── */

#define EL_CAL_TABLE_CAP 64
static el_calendar_t* _el_cal_table[EL_CAL_TABLE_CAP];
static int            _el_cal_count = 0;

static el_calendar_t* _el_cal_intern(el_calendar_kind_t kind, el_zone_t* z,
                                     int64_t period_ns, int64_t epoch_ns) {
    for (int i = 0; i < _el_cal_count; i++) {
        el_calendar_t* c = _el_cal_table[i];
        if (c->kind == kind && c->zone == z &&
            c->cycle_period_ns == period_ns && c->epoch_ns == epoch_ns) {
            return c;
        }
    }
    el_calendar_t* c = (el_calendar_t*)malloc(sizeof(el_calendar_t));
    c->magic = EL_CAL_MAGIC;
    c->kind = kind;
    c->zone = z;
    c->cycle_period_ns = period_ns;
    c->epoch_ns = epoch_ns;
    if (_el_cal_count < EL_CAL_TABLE_CAP) _el_cal_table[_el_cal_count++] = c;
    return c;
}

el_val_t earth_calendar(el_val_t z_val) {
    el_zone_t* z = NULL;
    if (z_val != 0 && el_is_magic(z_val, EL_ZONE_MAGIC)) {
        z = (el_zone_t*)(uintptr_t)z_val;
    } else {
        z = (el_zone_t*)(uintptr_t)zone_local();
    }
    return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_EARTH, z, EL_EARTH_DAY_NS, 0);
}

el_val_t earth_calendar_default(void) {
    return earth_calendar(zone_local());
}

el_val_t mars_calendar(void) {
    el_zone_t* z = (el_zone_t*)(uintptr_t)_el_zone_intern("MTC", 1, 0);
    return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_MARS, z, EL_MARS_SOL_NS, 0);
}

el_val_t cycle_calendar(el_val_t period_dur) {
    int64_t period = (int64_t)period_dur;
    if (period <= 0) period = 1;
    return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_CYCLE, NULL, period, 0);
}

el_val_t no_cycle_calendar(void) {
    return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_NO_CYCLE, NULL, 0, 0);
}

el_val_t relative_calendar(el_val_t epoch_inst) {
    int64_t ep = (int64_t)epoch_inst;
    return (el_val_t)(uintptr_t)_el_cal_intern(EL_CALENDAR_RELATIVE, NULL, 0, ep);
}

/* ── CalendarTime ───────────────────────────────────────────────────────── */

static el_caltime_t* _el_caltime_alloc(int64_t inst, el_calendar_t* c) {
    el_caltime_t* ct = (el_caltime_t*)malloc(sizeof(el_caltime_t));
    ct->magic = EL_CALTIME_MAGIC;
    ct->instant_ns = inst;
    ct->cal = c;
    return ct;
}

static el_calendar_t* _el_resolve_cal(el_val_t cal_val) {
    if (cal_val == 0 || !el_is_magic(cal_val, EL_CAL_MAGIC)) {
        return (el_calendar_t*)(uintptr_t)earth_calendar_default();
    }
    return (el_calendar_t*)(uintptr_t)cal_val;
}

el_val_t now_in(el_val_t cal_val) {
    el_calendar_t* c = _el_resolve_cal(cal_val);
    int64_t ns = (int64_t)el_now_instant();
    return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}

el_val_t in_calendar(el_val_t inst, el_val_t cal_val) {
    el_calendar_t* c = _el_resolve_cal(cal_val);
    return (el_val_t)(uintptr_t)_el_caltime_alloc((int64_t)inst, c);
}

el_val_t cal_to_instant(el_val_t ct_val) {
    if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
    el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
    return (el_val_t)ct->instant_ns;
}

el_val_t cal_in(el_val_t ct_val, el_val_t cal_val) {
    if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
    el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
    el_calendar_t* c = _el_resolve_cal(cal_val);
    return (el_val_t)(uintptr_t)_el_caltime_alloc(ct->instant_ns, c);
}

el_val_t cal_cycle_phase(el_val_t ct_val) {
    if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_from_float(0.0);
    el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
    el_calendar_t* c = ct->cal;
    if (c->kind == EL_CALENDAR_NO_CYCLE) {
        return el_from_float(0.0/0.0); /* NaN sentinel */
    }
    int64_t period = c->cycle_period_ns;
    if (period <= 0) return el_from_float(0.0);
    int64_t base = ct->instant_ns - c->epoch_ns;
    int64_t phase_ns = base % period;
    if (phase_ns < 0) phase_ns += period;
    double phase = (double)phase_ns / (double)period;
    return el_from_float(phase);
}

/* ── Earth zone resolution: TZ-based offset lookup ──────────────────────────
 * For an EarthCalendar(zone), we want to convert an instant_ns into local
 * y/m/d/h/m/s, including DST. Approach: setenv("TZ", id), tzset(), use
 * localtime_r, then restore. This is not thread-safe by design — El's
 * runtime is single-threaded for the request handler path. Cache the
 * computed (instant -> tm) to avoid the syscall churn on repeat formats. */

static void _el_apply_zone(el_zone_t* z) {
    if (!z) { unsetenv("TZ"); tzset(); return; }
    if (z->fixed && strcmp(z->id, "UTC") == 0) {
        setenv("TZ", "UTC0", 1);
        tzset();
        return;
    }
    if (z->fixed) {
        /* Fixed offset: POSIX TZ uses inverted sign (sign convention of
         * "hours WEST of UTC" rather than east). Build the spec accordingly. */
        char buf[32];
        int neg_secs = (int)(-z->offset_ns / 1000000000LL);
        int sign = neg_secs < 0 ? -1 : 1;
        int abs_secs = neg_secs < 0 ? -neg_secs : neg_secs;
        int hh = abs_secs / 3600;
        int mm = (abs_secs % 3600) / 60;
        snprintf(buf, sizeof(buf), "FIX%c%d:%02d", sign < 0 ? '-' : '+', hh, mm);
        setenv("TZ", buf, 1);
        tzset();
        return;
    }
    if (strcmp(z->id, "LOCAL") == 0) {
        unsetenv("TZ");
        tzset();
        return;
    }
    setenv("TZ", z->id, 1);
    tzset();
}

static int _el_decompose_earth(el_caltime_t* ct, struct tm* tm_out, int* abbr_len, char* abbr_buf, size_t abbr_cap) {
    el_calendar_t* c = ct->cal;
    el_zone_t* z = c->zone;
    _el_apply_zone(z);
    time_t s = (time_t)(ct->instant_ns / 1000000000LL);
    struct tm tm;
    localtime_r(&s, &tm);
    *tm_out = tm;
    if (abbr_buf && abbr_cap > 0) {
        /* mingw's struct tm has no tm_zone (BSD/glibc extension); no abbrev available there. */
#ifdef _WIN32
        const char* z_str = "";
#else
        const char* z_str = tm.tm_zone ? tm.tm_zone : "";
#endif
        size_t n = strlen(z_str);
        if (n >= abbr_cap) n = abbr_cap - 1;
        memcpy(abbr_buf, z_str, n);
        abbr_buf[n] = '\0';
        if (abbr_len) *abbr_len = (int)n;
    }
    return 0;
}

/* Format an Earth CalendarTime under a Java-DateTimeFormatter-ish pattern.
 * We support a useful core: yyyy MM dd HH mm ss z EEE MMM d h a — enough for
 * the acceptance tests. Single quotes denote literal text. */
static const char* _el_weekday_short[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"};
static const char* _el_month_short[]   = {"Jan","Feb","Mar","Apr","May","Jun",
                                          "Jul","Aug","Sep","Oct","Nov","Dec"};

static char* _el_format_earth(el_caltime_t* ct, const char* pattern) {
    struct tm tm;
    char abbr[16] = {0};
    int abbr_len = 0;
    _el_decompose_earth(ct, &tm, &abbr_len, abbr, sizeof(abbr));
    size_t cap = strlen(pattern) * 4 + 64;
    char* out = (char*)malloc(cap);
    size_t pos = 0;
    size_t i = 0;
    size_t plen = strlen(pattern);
    while (i < plen) {
        char ch = pattern[i];
        /* Quoted literal */
        if (ch == '\'') {
            i++;
            while (i < plen && pattern[i] != '\'') {
                if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
                out[pos++] = pattern[i++];
            }
            if (i < plen) i++;
            continue;
        }
        /* Count run of same letter */
        size_t run = 1;
        while (i + run < plen && pattern[i + run] == ch) run++;
        char tmp[64];
        tmp[0] = '\0';
        if (ch == 'y') {
            if (run >= 4) snprintf(tmp, sizeof(tmp), "%04d", tm.tm_year + 1900);
            else snprintf(tmp, sizeof(tmp), "%02d", (tm.tm_year + 1900) % 100);
        } else if (ch == 'M') {
            if (run >= 3) snprintf(tmp, sizeof(tmp), "%s", _el_month_short[tm.tm_mon]);
            else if (run == 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mon + 1);
            else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mon + 1);
        } else if (ch == 'd') {
            if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_mday);
            else snprintf(tmp, sizeof(tmp), "%d", tm.tm_mday);
        } else if (ch == 'H') {
            if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_hour);
            else snprintf(tmp, sizeof(tmp), "%d", tm.tm_hour);
        } else if (ch == 'h') {
            int h12 = tm.tm_hour % 12; if (h12 == 0) h12 = 12;
            if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", h12);
            else snprintf(tmp, sizeof(tmp), "%d", h12);
        } else if (ch == 'm') {
            if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_min);
            else snprintf(tmp, sizeof(tmp), "%d", tm.tm_min);
        } else if (ch == 's') {
            if (run >= 2) snprintf(tmp, sizeof(tmp), "%02d", tm.tm_sec);
            else snprintf(tmp, sizeof(tmp), "%d", tm.tm_sec);
        } else if (ch == 'a') {
            snprintf(tmp, sizeof(tmp), "%s", tm.tm_hour < 12 ? "AM" : "PM");
        } else if (ch == 'E') {
            snprintf(tmp, sizeof(tmp), "%s", _el_weekday_short[tm.tm_wday]);
        } else if (ch == 'z') {
            snprintf(tmp, sizeof(tmp), "%s", abbr);
        } else {
            for (size_t k = 0; k < run; k++) {
                if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
                out[pos++] = ch;
            }
            i += run;
            continue;
        }
        size_t tl = strlen(tmp);
        if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
        memcpy(out + pos, tmp, tl);
        pos += tl;
        i += run;
    }
    out[pos] = '\0';
    char* result = el_strdup(out);
    free(out);
    return result;
}

/* Format a Mars CalendarTime: %sol prints the integer sol number since
 * mission epoch (Unix epoch fallback), %phase prints cycle_phase as a
 * 0..1 decimal. Other %-specifiers fall through. */
static char* _el_format_mars(el_caltime_t* ct, const char* pattern) {
    el_calendar_t* c = ct->cal;
    int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : EL_MARS_SOL_NS;
    int64_t base = ct->instant_ns - c->epoch_ns;
    int64_t sol = base / period;
    int64_t phase_ns = base % period;
    if (phase_ns < 0) { phase_ns += period; sol -= 1; }
    double phase = (double)phase_ns / (double)period;
    size_t cap = strlen(pattern) * 4 + 64;
    char* out = (char*)malloc(cap);
    size_t pos = 0;
    for (size_t i = 0; pattern[i]; i++) {
        if (pattern[i] == '%' && pattern[i+1]) {
            char tmp[64];
            tmp[0] = '\0';
            if (strncmp(pattern + i + 1, "sol", 3) == 0) {
                snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
                i += 3;
            } else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
                snprintf(tmp, sizeof(tmp), "%.4f", phase);
                i += 5;
            } else if (pattern[i+1] == 'd') {
                snprintf(tmp, sizeof(tmp), "%lld", (long long)sol);
                i += 1;
            } else {
                tmp[0] = pattern[i+1]; tmp[1] = '\0';
                i += 1;
            }
            size_t tl = strlen(tmp);
            if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
            memcpy(out + pos, tmp, tl);
            pos += tl;
        } else {
            if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
            out[pos++] = pattern[i];
        }
    }
    out[pos] = '\0';
    char* result = el_strdup(out);
    free(out);
    return result;
}

/* Format a CycleCalendar CalendarTime: %cycle and %phase. */
static char* _el_format_cycle(el_caltime_t* ct, const char* pattern) {
    el_calendar_t* c = ct->cal;
    int64_t period = c->cycle_period_ns > 0 ? c->cycle_period_ns : 1;
    int64_t base = ct->instant_ns - c->epoch_ns;
    int64_t cycle = base / period;
    int64_t phase_ns = base % period;
    if (phase_ns < 0) { phase_ns += period; cycle -= 1; }
    double phase = (double)phase_ns / (double)period;
    size_t cap = strlen(pattern) * 4 + 64;
    char* out = (char*)malloc(cap);
    size_t pos = 0;
    for (size_t i = 0; pattern[i]; i++) {
        if (pattern[i] == '%' && pattern[i+1]) {
            char tmp[64];
            tmp[0] = '\0';
            if (strncmp(pattern + i + 1, "cycle", 5) == 0) {
                snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
                i += 5;
            } else if (strncmp(pattern + i + 1, "phase", 5) == 0) {
                snprintf(tmp, sizeof(tmp), "%.4f", phase);
                i += 5;
            } else if (pattern[i+1] == 'd') {
                snprintf(tmp, sizeof(tmp), "%lld", (long long)cycle);
                i += 1;
            } else if (pattern[i+1] == 'f') {
                snprintf(tmp, sizeof(tmp), "%.2f", phase);
                i += 1;
            } else {
                /* Pass through unknown specifier */
                tmp[0] = '%'; tmp[1] = pattern[i+1]; tmp[2] = '\0';
                i += 1;
            }
            size_t tl = strlen(tmp);
            if (pos + tl + 1 >= cap) { cap = (cap + tl) * 2; out = realloc(out, cap); }
            memcpy(out + pos, tmp, tl);
            pos += tl;
        } else {
            if (pos + 1 >= cap) { cap *= 2; out = realloc(out, cap); }
            out[pos++] = pattern[i];
        }
    }
    out[pos] = '\0';
    char* result = el_strdup(out);
    free(out);
    return result;
}

el_val_t cal_format(el_val_t ct_val, el_val_t pattern_val) {
    if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return el_wrap_str(el_strdup(""));
    el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
    const char* pat = EL_CSTR(pattern_val);
    if (!pat) pat = "";
    char* result = NULL;
    switch (ct->cal->kind) {
        case EL_CALENDAR_EARTH:    result = _el_format_earth(ct, pat); break;
        case EL_CALENDAR_MARS:     result = _el_format_mars(ct, pat);  break;
        case EL_CALENDAR_CYCLE:    result = _el_format_cycle(ct, pat); break;
        case EL_CALENDAR_RELATIVE: result = _el_format_cycle(ct, pat); break;
        case EL_CALENDAR_NO_CYCLE: {
            char buf[64];
            snprintf(buf, sizeof(buf), "instant:%lld", (long long)ct->instant_ns);
            result = el_strdup(buf);
            break;
        }
        default: result = el_strdup("");
    }
    return el_wrap_str(result);
}

/* ── LocalDate / LocalTime / LocalDateTime ──────────────────────────────── */

static int _el_days_in_month(int y, int m) {
    static const int dim[12] = {31,28,31,30,31,30,31,31,30,31,30,31};
    if (m == 2) {
        int leap = ((y % 4 == 0) && (y % 100 != 0)) || (y % 400 == 0);
        return 28 + (leap ? 1 : 0);
    }
    if (m < 1 || m > 12) return 30;
    return dim[m - 1];
}

el_val_t local_date(el_val_t y, el_val_t m, el_val_t d) {
    el_localdate_t* ld = (el_localdate_t*)malloc(sizeof(el_localdate_t));
    ld->magic = EL_LDATE_MAGIC;
    ld->year = (int)(int64_t)y;
    ld->month = (int)(int64_t)m;
    ld->day = (int)(int64_t)d;
    return (el_val_t)(uintptr_t)ld;
}

el_val_t local_time(el_val_t h, el_val_t m, el_val_t s, el_val_t ns) {
    int64_t hh = (int64_t)h;
    int64_t mm = (int64_t)m;
    int64_t ss = (int64_t)s;
    int64_t nn = (int64_t)ns;
    int64_t total = hh * 3600000000000LL + mm * 60000000000LL + ss * 1000000000LL + nn;
    return (el_val_t)total;
}

el_val_t local_datetime(el_val_t date_val, el_val_t time_val) {
    if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
    el_localdt_t* ldt = (el_localdt_t*)malloc(sizeof(el_localdt_t));
    ldt->magic = EL_LDT_MAGIC;
    ldt->date = (el_localdate_t*)(uintptr_t)date_val;
    ldt->time_ns = (int64_t)time_val;
    return (el_val_t)(uintptr_t)ldt;
}

el_val_t zoned(el_val_t date_val, el_val_t time_val, el_val_t cal_val) {
    if (!el_is_magic(date_val, EL_LDATE_MAGIC)) return (el_val_t)0;
    el_localdate_t* ld = (el_localdate_t*)(uintptr_t)date_val;
    el_calendar_t* c = _el_resolve_cal(cal_val);
    int64_t time_ns = (int64_t)time_val;
    /* Convert (LocalDate, LocalTime, EarthCalendar) -> Instant.
     * For non-Earth calendars we use day-anchored conversion: treat the
     * LocalDate's (y,m,d) as a Gregorian projection, convert to seconds via
     * mktime under the calendar's zone, then add nanos-since-midnight. */
    if (c->kind == EL_CALENDAR_EARTH) {
        _el_apply_zone(c->zone);
        struct tm tm; memset(&tm, 0, sizeof(tm));
        tm.tm_year = ld->year - 1900;
        tm.tm_mon  = ld->month - 1;
        tm.tm_mday = ld->day;
        tm.tm_hour = (int)(time_ns / 3600000000000LL);
        tm.tm_min  = (int)((time_ns / 60000000000LL) % 60);
        tm.tm_sec  = (int)((time_ns / 1000000000LL) % 60);
        tm.tm_isdst = -1;
        time_t t = mktime(&tm);
        if (t == (time_t)-1) return (el_val_t)0;
        int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
        return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
    }
    /* Non-Earth fallback: project as if Earth UTC then attach calendar. */
    struct tm tm; memset(&tm, 0, sizeof(tm));
    tm.tm_year = ld->year - 1900;
    tm.tm_mon  = ld->month - 1;
    tm.tm_mday = ld->day;
    tm.tm_hour = (int)(time_ns / 3600000000000LL);
    tm.tm_min  = (int)((time_ns / 60000000000LL) % 60);
    tm.tm_sec  = (int)((time_ns / 1000000000LL) % 60);
    time_t t = timegm(&tm);
    if (t == (time_t)-1) return (el_val_t)0;
    int64_t ns = (int64_t)t * 1000000000LL + (time_ns % 1000000000LL);
    return (el_val_t)(uintptr_t)_el_caltime_alloc(ns, c);
}

el_val_t local_date_year(el_val_t v) {
    if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
    return (el_val_t)((el_localdate_t*)(uintptr_t)v)->year;
}
el_val_t local_date_month(el_val_t v) {
    if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
    return (el_val_t)((el_localdate_t*)(uintptr_t)v)->month;
}
el_val_t local_date_day(el_val_t v) {
    if (!el_is_magic(v, EL_LDATE_MAGIC)) return (el_val_t)0;
    return (el_val_t)((el_localdate_t*)(uintptr_t)v)->day;
}
el_val_t local_time_hour(el_val_t v) {
    int64_t t = (int64_t)v;
    return (el_val_t)(t / 3600000000000LL);
}
el_val_t local_time_minute(el_val_t v) {
    int64_t t = (int64_t)v;
    return (el_val_t)((t / 60000000000LL) % 60);
}
el_val_t local_time_second(el_val_t v) {
    int64_t t = (int64_t)v;
    return (el_val_t)((t / 1000000000LL) % 60);
}
el_val_t local_time_nanos(el_val_t v) {
    int64_t t = (int64_t)v;
    return (el_val_t)(t % 1000000000LL);
}

el_val_t el_local_date_add_dur(el_val_t ld_val, el_val_t dur_val) {
    if (!el_is_magic(ld_val, EL_LDATE_MAGIC)) return ld_val;
    el_localdate_t* ld = (el_localdate_t*)(uintptr_t)ld_val;
    int64_t dur_ns = (int64_t)dur_val;
    int64_t days = dur_ns / EL_EARTH_DAY_NS;
    int y = ld->year, m = ld->month, d = ld->day;
    /* Walk days forward/backward in canonical Gregorian. */
    while (days > 0) {
        int dim = _el_days_in_month(y, m);
        if (d + days <= dim) { d += (int)days; days = 0; break; }
        days -= (dim - d + 1);
        d = 1;
        m++;
        if (m > 12) { m = 1; y++; }
    }
    while (days < 0) {
        if (d + days >= 1) { d += (int)days; days = 0; break; }
        days += d;
        m--;
        if (m < 1) { m = 12; y--; }
        d = _el_days_in_month(y, m);
    }
    return local_date((el_val_t)y, (el_val_t)m, (el_val_t)d);
}

el_val_t el_local_time_add_dur(el_val_t lt_val, el_val_t dur_val) {
    int64_t t = (int64_t)lt_val + (int64_t)dur_val;
    /* Wrap mod 24h on Earth-default. CycleCalendar wrapping requires the
     * caller to use cal_in / cal_format for the right modulus. */
    int64_t day = EL_EARTH_DAY_NS;
    int64_t r = t % day;
    if (r < 0) r += day;
    return (el_val_t)r;
}

el_val_t el_local_date_lt(el_val_t a_val, el_val_t b_val) {
    if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
    el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
    el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
    if (a->year != b->year) return (el_val_t)(a->year < b->year ? 1 : 0);
    if (a->month != b->month) return (el_val_t)(a->month < b->month ? 1 : 0);
    return (el_val_t)(a->day < b->day ? 1 : 0);
}

el_val_t el_local_date_eq(el_val_t a_val, el_val_t b_val) {
    if (!el_is_magic(a_val, EL_LDATE_MAGIC) || !el_is_magic(b_val, EL_LDATE_MAGIC)) return (el_val_t)0;
    el_localdate_t* a = (el_localdate_t*)(uintptr_t)a_val;
    el_localdate_t* b = (el_localdate_t*)(uintptr_t)b_val;
    return (el_val_t)((a->year == b->year && a->month == b->month && a->day == b->day) ? 1 : 0);
}

/* ── Rhythm ──────────────────────────────────────────────────────────────── */

static el_rhythm_t* _el_rhythm_alloc(el_rhythm_kind_t k) {
    el_rhythm_t* r = (el_rhythm_t*)calloc(1, sizeof(el_rhythm_t));
    r->magic = EL_RHYTHM_MAGIC;
    r->kind = k;
    return r;
}

el_val_t rhythm_cycle_start(void) {
    return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_CYCLE_START);
}

el_val_t rhythm_cycle_phase(el_val_t phase_val) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_CYCLE_PHASE);
    r->phase = el_to_float(phase_val);
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_duration(el_val_t d_val) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_DURATION);
    r->period_ns = (int64_t)d_val;
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_session_start(void) {
    return (el_val_t)(uintptr_t)_el_rhythm_alloc(EL_RHYTHM_SESSION_START);
}

el_val_t rhythm_event(el_val_t name_val) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_EVENT);
    const char* n = EL_CSTR(name_val);
    r->event_name = el_strdup_persist(n ? n : "");
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_and(el_val_t a_val, el_val_t b_val) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_AND);
    r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
    r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_or(el_val_t a_val, el_val_t b_val) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_OR);
    r->a = el_is_magic(a_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)a_val : NULL;
    r->b = el_is_magic(b_val, EL_RHYTHM_MAGIC) ? (el_rhythm_t*)(uintptr_t)b_val : NULL;
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_weekday(el_val_t day) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKDAY);
    r->weekday = (int)(int64_t)day;
    return (el_val_t)(uintptr_t)r;
}

el_val_t rhythm_weekly_at(el_val_t day, el_val_t hour, el_val_t minute) {
    el_rhythm_t* r = _el_rhythm_alloc(EL_RHYTHM_WEEKLY_AT);
    r->weekday = (int)(int64_t)day;
    r->hour = (int)(int64_t)hour;
    r->minute = (int)(int64_t)minute;
    return (el_val_t)(uintptr_t)r;
}

/* Compute the next instant on or after `after` when rhythm `r` matches,
 * under calendar `cal`. */
static int64_t _el_next_after(el_rhythm_t* r, int64_t after_ns, el_calendar_t* cal) {
    if (!r) return after_ns;
    int64_t period = cal->cycle_period_ns > 0 ? cal->cycle_period_ns : EL_EARTH_DAY_NS;
    switch (r->kind) {
        case EL_RHYTHM_CYCLE_START: {
            int64_t base = after_ns - cal->epoch_ns;
            int64_t cyc = (base / period) + 1;
            return cal->epoch_ns + cyc * period;
        }
        case EL_RHYTHM_CYCLE_PHASE: {
            int64_t base = after_ns - cal->epoch_ns;
            int64_t cyc_ns = (int64_t)(r->phase * (double)period);
            int64_t cur_cyc = base / period;
            int64_t candidate = cal->epoch_ns + cur_cyc * period + cyc_ns;
            if (candidate <= after_ns) candidate += period;
            return candidate;
        }
        case EL_RHYTHM_DURATION: {
            return after_ns + (r->period_ns > 0 ? r->period_ns : 1);
        }
        case EL_RHYTHM_WEEKDAY:
        case EL_RHYTHM_WEEKLY_AT: {
            if (cal->kind != EL_CALENDAR_EARTH) {
                /* Non-Earth calendars: fall back to cycle math, treating
                 * weekday as a 7-cycle-per-period proxy. */
                return after_ns + period;
            }
            _el_apply_zone(cal->zone);
            time_t s = (time_t)(after_ns / 1000000000LL);
            struct tm tm;
            localtime_r(&s, &tm);
            /* tm_wday: 0=Sun..6=Sat. We use 1=Mon..7=Sun. */
            int target = r->weekday >= 1 && r->weekday <= 7 ? r->weekday : 1;
            int target_wday = target == 7 ? 0 : target; /* 7→Sun=0, 1→Mon=1 */
            int days_ahead = (target_wday - tm.tm_wday + 7) % 7;
            int hour = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->hour : 0;
            int minute = (r->kind == EL_RHYTHM_WEEKLY_AT) ? r->minute : 0;
            struct tm cand = tm;
            cand.tm_mday += days_ahead;
            cand.tm_hour = hour;
            cand.tm_min  = minute;
            cand.tm_sec  = 0;
            cand.tm_isdst = -1;
            time_t cand_t = mktime(&cand);
            int64_t cand_ns = (int64_t)cand_t * 1000000000LL;
            if (cand_ns <= after_ns) {
                cand.tm_mday += 7;
                cand.tm_isdst = -1;
                cand_t = mktime(&cand);
                cand_ns = (int64_t)cand_t * 1000000000LL;
            }
            return cand_ns;
        }
        case EL_RHYTHM_AND: {
            int64_t a = _el_next_after(r->a, after_ns, cal);
            int64_t b = _el_next_after(r->b, after_ns, cal);
            return a > b ? a : b;
        }
        case EL_RHYTHM_OR: {
            int64_t a = _el_next_after(r->a, after_ns, cal);
            int64_t b = _el_next_after(r->b, after_ns, cal);
            return a < b ? a : b;
        }
        case EL_RHYTHM_SESSION_START:
        case EL_RHYTHM_EVENT:
        default:
            return after_ns;
    }
}

el_val_t rhythm_next_after(el_val_t r_val, el_val_t after_val, el_val_t cal_val) {
    if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return after_val;
    el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
    el_calendar_t* c = _el_resolve_cal(cal_val);
    int64_t out = _el_next_after(r, (int64_t)after_val, c);
    return (el_val_t)out;
}

el_val_t rhythm_matches(el_val_t r_val, el_val_t ct_val) {
    if (!el_is_magic(r_val, EL_RHYTHM_MAGIC)) return (el_val_t)0;
    if (!el_is_magic(ct_val, EL_CALTIME_MAGIC)) return (el_val_t)0;
    el_rhythm_t* r = (el_rhythm_t*)(uintptr_t)r_val;
    el_caltime_t* ct = (el_caltime_t*)(uintptr_t)ct_val;
    int64_t period = ct->cal->cycle_period_ns > 0 ? ct->cal->cycle_period_ns : EL_EARTH_DAY_NS;
    int64_t base = ct->instant_ns - ct->cal->epoch_ns;
    int64_t phase_ns = base % period;
    if (phase_ns < 0) phase_ns += period;
    double phase = (double)phase_ns / (double)period;
    switch (r->kind) {
        case EL_RHYTHM_CYCLE_START: return (el_val_t)(phase_ns == 0 ? 1 : 0);
        case EL_RHYTHM_CYCLE_PHASE: {
            double diff = phase - r->phase;
            if (diff < 0) diff = -diff;
            return (el_val_t)(diff < 0.001 ? 1 : 0);
        }
        default: return (el_val_t)0;
    }
}

/* ── UUID v4 ─────────────────────────────────────────────────────────────── */

static int _el_uuid_seeded = 0;

static void _el_uuid_seed(void) {
    if (!_el_uuid_seeded) {
        srand((unsigned)time(NULL) ^ (unsigned)(uintptr_t)&_el_uuid_seeded);
        _el_uuid_seeded = 1;
    }
}

el_val_t uuid_new(void) {
    _el_uuid_seed();
    unsigned char b[16];
    for (int i = 0; i < 16; i++) b[i] = (unsigned char)(rand() & 0xff);
    /* Version 4 */
    b[6] = (b[6] & 0x0f) | 0x40;
    /* RFC 4122 variant */
    b[8] = (b[8] & 0x3f) | 0x80;
    char buf[37];
    snprintf(buf, sizeof(buf),
             "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
             b[0], b[1], b[2], b[3],
             b[4], b[5],
             b[6], b[7],
             b[8], b[9],
             b[10], b[11], b[12], b[13], b[14], b[15]);
    return el_wrap_str(el_strdup(buf));
}

el_val_t uuid_v4(void) { return uuid_new(); }

/* ── Environment ─────────────────────────────────────────────────────────── */

el_val_t env(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return el_wrap_str(el_strdup(""));
    const char* v = getenv(k);
    return el_wrap_str(el_strdup(v ? v : ""));
}

/* ── In-process state K/V ────────────────────────────────────────────────── */

typedef struct {
    char* key;
    char* value;
} StateEntry;

static StateEntry*      _state_entries = NULL;
static size_t           _state_count   = 0;
static size_t           _state_cap     = 0;
/* Mutex protecting all _state_entries access. state_set/state_get are called
 * concurrently from 64 HTTP worker threads — without this lock, realloc and
 * free race, producing corruption, double-free, and segfaults. */
static pthread_mutex_t  _state_mu = PTHREAD_MUTEX_INITIALIZER;

static StateEntry* state_find(const char* key) {
    for (size_t i = 0; i < _state_count; i++) {
        if (strcmp(_state_entries[i].key, key) == 0) return &_state_entries[i];
    }
    return NULL;
}

el_val_t state_set(el_val_t key, el_val_t value) {
    const char* k = EL_CSTR(key);
    const char* v = EL_CSTR(value);
    if (!k) return 0;
    if (!v) v = "";
    pthread_mutex_lock(&_state_mu);
    StateEntry* e = state_find(k);
    if (e) {
        free(e->value);
        e->value = el_strdup_persist(v);
        pthread_mutex_unlock(&_state_mu);
        return 1;
    }
    if (_state_count >= _state_cap) {
        size_t nc = _state_cap == 0 ? 16 : _state_cap * 2;
        StateEntry* grown = realloc(_state_entries, nc * sizeof(StateEntry));
        if (!grown) { pthread_mutex_unlock(&_state_mu); fputs("el_runtime: out of memory\n", stderr); exit(1); }
        _state_entries = grown;
        _state_cap = nc;
    }
    _state_entries[_state_count].key   = el_strdup_persist(k);
    _state_entries[_state_count].value = el_strdup_persist(v);
    _state_count++;
    pthread_mutex_unlock(&_state_mu);
    return 1;
}

el_val_t state_get(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return el_wrap_str(el_strdup(""));
    pthread_mutex_lock(&_state_mu);
    StateEntry* e = state_find(k);
    /* ONE arena-tracked copy, taken under the lock.
     *
     * This used to make TWO copies: an el_strdup_persist temporary, then an
     * arena-tracked copy of that temporary. The persistent one was never
     * returned and never freed — el_strdup_persist bypasses the arena by
     * design ("state_set, engram internals"), so arena-pop could not reclaim
     * it. Every state_get therefore leaked its full value string, permanently.
     *
     * The soul's awareness loop has 68 state_get call sites and ticks every
     * 200ms; measured leak was ~1.1 MB per tick, about 19 GB/hour. It went
     * unnoticed for as long as the soul restarted often enough to mask it.
     *
     * el_strdup tracks into the thread-local arena, which touches no shared
     * state, so doing it under _state_mu is safe and removes the need for the
     * temporary entirely. */
    char* copy = el_strdup(e ? e->value : "");
    pthread_mutex_unlock(&_state_mu);
    return el_wrap_str(copy);
}

el_val_t state_del(el_val_t key) {
    const char* k = EL_CSTR(key);
    if (!k) return 0;
    pthread_mutex_lock(&_state_mu);
    for (size_t i = 0; i < _state_count; i++) {
        if (strcmp(_state_entries[i].key, k) == 0) {
            free(_state_entries[i].key);
            free(_state_entries[i].value);
            for (size_t j = i + 1; j < _state_count; j++) {
                _state_entries[j - 1] = _state_entries[j];
            }
            _state_count--;
            pthread_mutex_unlock(&_state_mu);
            return 1;
        }
    }
    pthread_mutex_unlock(&_state_mu);
    return 1;
}

el_val_t state_keys(void) {
    pthread_mutex_lock(&_state_mu);
    el_val_t lst = el_list_empty();
    for (size_t i = 0; i < _state_count; i++) {
        lst = el_list_append(lst, el_wrap_str(el_strdup(_state_entries[i].key)));
    }
    pthread_mutex_unlock(&_state_mu);
    return lst;
}

/* ── Float formatting ────────────────────────────────────────────────────── */

el_val_t float_to_str(el_val_t f) {
    char buf[64];
    snprintf(buf, sizeof(buf), "%g", el_to_float(f));
    return el_wrap_str(el_strdup(buf));
}

el_val_t int_to_float(el_val_t n) {
    return el_from_float((double)(int64_t)n);
}

el_val_t float_to_int(el_val_t f) {
    return (el_val_t)(int64_t)el_to_float(f);
}

el_val_t format_float(el_val_t f, el_val_t decimals) {
    int d = (int)(int64_t)decimals;
    if (d < 0)   d = 0;
    if (d > 30)  d = 30;
    char buf[128];
    snprintf(buf, sizeof(buf), "%.*f", d, el_to_float(f));
    return el_wrap_str(el_strdup(buf));
}

el_val_t decimal_round(el_val_t f, el_val_t decimals) {
    int d = (int)(int64_t)decimals;
    if (d < 0)  d = 0;
    if (d > 15) d = 15;
    double mul = pow(10.0, (double)d);
    double v = el_to_float(f);
    double r = (v >= 0.0 ? floor(v * mul + 0.5) : -floor(-v * mul + 0.5)) / mul;
    return el_from_float(r);
}

el_val_t str_to_float(el_val_t s) {
    const char* str = EL_CSTR(s);
    if (!str) return el_from_float(0.0);
    return el_from_float(strtod(str, NULL));
}

/* ── Math (Float-aware) ──────────────────────────────────────────────────── */

el_val_t math_sqrt(el_val_t f) { return el_from_float(sqrt(el_to_float(f))); }
/* base-10, matching runtime/math.el's documented contract ("math_log — base-10
 * logarithm") and el_seed.c's __log_f. This returned NATURAL log, so math_log
 * and math_ln were the same function: log10(100) gave 4.605 instead of 2.
 * Caught by tests/native/test_math.el on the new framework's first run — the
 * assertion existed all along, the suite just had no way to report it. */
el_val_t math_log(el_val_t f)  { return el_from_float(log10(el_to_float(f))); }
el_val_t math_ln(el_val_t f)   { return el_from_float(log(el_to_float(f))); }
el_val_t math_sin(el_val_t f)  { return el_from_float(sin(el_to_float(f))); }
el_val_t math_cos(el_val_t f)  { return el_from_float(cos(el_to_float(f))); }
el_val_t math_pi(void)         { return el_from_float(3.141592653589793238462643383279502884); }

/* ── String additions ────────────────────────────────────────────────────── */

el_val_t str_index_of(el_val_t s, el_val_t sub) {
    const char* str = EL_CSTR(s);
    const char* sb  = EL_CSTR(sub);
    if (!str || !sb) return -1;
    const char* hit = strstr(str, sb);
    if (!hit) return -1;
    return (el_val_t)(int64_t)(hit - str);
}

el_val_t str_split(el_val_t s, el_val_t sep) {
    const char* str = EL_CSTR(s);
    const char* sp  = EL_CSTR(sep);
    el_val_t lst = el_list_empty();
    if (!str) return lst;
    if (!sp || !*sp) {
        lst = el_list_append(lst, el_wrap_str(el_strdup(str)));
        return lst;
    }
    size_t lp = strlen(sp);
    const char* p = str;
    const char* hit;
    while ((hit = strstr(p, sp)) != NULL) {
        size_t n = (size_t)(hit - p);
        char* out = el_strbuf(n);
        memcpy(out, p, n);
        out[n] = '\0';
        lst = el_list_append(lst, el_wrap_str(out));
        p = hit + lp;
    }
    lst = el_list_append(lst, el_wrap_str(el_strdup(p)));
    return lst;
}

el_val_t str_char_at(el_val_t s, el_val_t i) {
    const char* str = EL_CSTR(s);
    int64_t idx = (int64_t)i;
    if (!str) return el_wrap_str(el_strdup(""));
    int64_t n = (int64_t)strlen(str);
    if (idx < 0 || idx >= n) return el_wrap_str(el_strdup(""));
    char buf[2];
    buf[0] = str[idx];
    buf[1] = '\0';
    return el_wrap_str(el_strdup(buf));
}

el_val_t str_char_code(el_val_t s, el_val_t i) {
    const char* str = EL_CSTR(s);
    int64_t idx = (int64_t)i;
    if (!str) return 0;
    int64_t n = (int64_t)el_strlen_cached(str);
    if (idx < 0 || idx >= n) return 0;
    return (el_val_t)(unsigned char)str[idx];
}

static el_val_t str_pad(const char* s, int64_t width, const char* pad, int left) {
    if (!s) s = "";
    if (!pad || !*pad) pad = " ";
    int64_t lp = (int64_t)strlen(pad);
    int64_t ls = (int64_t)strlen(s);
    if (ls >= width) return el_wrap_str(el_strdup(s));
    int64_t need = width - ls;
    char* out = el_strbuf((size_t)width);
    if (left) {
        for (int64_t i = 0; i < need; i++) out[i] = pad[i % lp];
        memcpy(out + need, s, (size_t)ls);
    } else {
        memcpy(out, s, (size_t)ls);
        for (int64_t i = 0; i < need; i++) out[ls + i] = pad[i % lp];
    }
    out[width] = '\0';
    return el_wrap_str(out);
}

el_val_t str_pad_left(el_val_t s, el_val_t width, el_val_t pad) {
    return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 1);
}

el_val_t str_pad_right(el_val_t s, el_val_t width, el_val_t pad) {
    return str_pad(EL_CSTR(s), (int64_t)width, EL_CSTR(pad), 0);
}

el_val_t str_format(el_val_t fmt, el_val_t data) {
    const char* tpl = EL_CSTR(fmt);
    if (!tpl) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    const char* p = tpl;
    while (*p) {
        if (*p == '{') {
            const char* q = p + 1;
            while (*q && *q != '}') q++;
            if (*q == '}') {
                size_t klen = (size_t)(q - p - 1);
                char keybuf[256];
                if (klen < sizeof(keybuf)) {
                    memcpy(keybuf, p + 1, klen);
                    keybuf[klen] = '\0';
                    el_val_t v = el_map_get(data, EL_STR(keybuf));
                    if (v != 0 && looks_like_string(v)) {
                        jb_puts(&b, EL_CSTR(v));
                        p = q + 1;
                        continue;
                    } else if (v != 0) {
                        jb_emit_int(&b, (int64_t)v);
                        p = q + 1;
                        continue;
                    }
                }
                /* Unknown key — leave {key} verbatim */
                jb_reserve(&b, klen + 2);
                memcpy(b.buf + b.len, p, klen + 2);
                b.len += klen + 2;
                b.buf[b.len] = '\0';
                p = q + 1;
                continue;
            }
        }
        jb_putc(&b, *p);
        p++;
    }
    return el_wrap_str(b.buf);
}

el_val_t str_lower(el_val_t s) { return str_to_lower(s); }
el_val_t str_upper(el_val_t s) { return str_to_upper(s); }

/* ── Text-processing primitives (Phase 1: byte/codepoint, ASCII char classes)
 *
 * Phase 1 covers the operations every text-handling caller used to roll by
 * hand on top of str_index_of + str_slice. The character-class predicates
 * (is_letter / is_digit / ...) are ASCII only — Unicode-grapheme awareness,
 * NFC/NFD normalization, and regex are Phase 2. Single-char input checks the
 * first byte; multi-char input requires ALL bytes to match (false otherwise).
 *
 * Counting:
 *   str_count            non-overlapping occurrences of sub in s
 *   str_count_chars      codepoint count (UTF-8 leading-byte count)
 *   str_count_bytes      explicit byte length (alias of str_len)
 *   str_count_lines      \n-delimited line count (\r\n folded to \n)
 *   str_count_words      whitespace-delimited tokens, non-empty only
 *   str_count_letters    ASCII [A-Za-z]
 *   str_count_digits     ASCII [0-9]
 *
 * Find / position:
 *   str_index_of_all     all byte offsets of sub, [] if none
 *   str_last_index_of    last byte offset of sub, -1 if not found
 *   str_find_chars       first index of any char in any_of, -1 if none
 *
 * Transform:
 *   str_repeat           s * n (non-negative)
 *   str_reverse          codepoint-reversed (NOT grapheme-aware)
 *   str_strip_prefix     s without prefix if present, else s
 *   str_strip_suffix     s without suffix if present, else s
 *   str_strip_chars      strip leading+trailing chars matching any in chars
 *   str_lstrip           strip leading whitespace
 *   str_rstrip           strip trailing whitespace
 *
 * Char classification (Bool):
 *   is_letter, is_digit, is_alphanumeric, is_whitespace,
 *   is_punctuation, is_uppercase, is_lowercase
 *
 * Splitting:
 *   str_split_lines      \n-delimited (\r\n folded). Trailing empty dropped.
 *   str_split_chars      alias of native_string_chars in str_ namespace
 *   str_split_n          split into at most n parts (last part keeps the
 *                        rest verbatim, including any further separators)
 *
 * Joining:
 *   str_join             [String] -> String, sep between elements
 */

/* Count non-overlapping occurrences of sub in s. Empty sub returns 0. */
el_val_t str_count(el_val_t sv, el_val_t subv) {
    const char* s   = EL_CSTR(sv);
    const char* sub = EL_CSTR(subv);
    if (!s || !sub || !*sub) return 0;
    size_t lp = strlen(sub);
    int64_t count = 0;
    const char* p = s;
    while ((p = strstr(p, sub)) != NULL) {
        count++;
        p += lp;   /* non-overlapping advance */
    }
    return (el_val_t)count;
}

/* el_utf8_safe_len — the largest byte length <= max_bytes that does NOT split a
 * UTF-8 codepoint.
 *
 * WHY (2026-08-16 self-review): engram_first_n_chars truncated with a plain
 * `if (l > n) l = n; memcpy(...)`, i.e. by BYTES despite its name. Any content
 * carrying a multi-byte character across the 60-byte boundary produced a label
 * ending in a half codepoint. That label is copied verbatim into every JSON
 * document containing the node, so a single such node makes the WHOLE response
 * invalid UTF-8 — /api/nodes/list failed to decode at byte 89261 against the
 * live store, which breaks any strict parser reading the graph.
 *
 * This lives beside str_count_chars rather than in the engram because the rest
 * of el's string layer is already codepoint-aware (str_count_chars counts
 * codepoints, str_reverse walks codepoint lengths). Byte-truncation was the
 * outlier, and the concern is a string concern. Bounded by BYTES, not
 * codepoints, so existing labels never grow — only stop splitting.
 *
 * A lead byte with no room for its full sequence is dropped entirely; a stray
 * continuation byte (already-invalid input) is passed through unchanged rather
 * than silently repaired, so this never manufactures data. */
size_t el_utf8_safe_len(const char* s, size_t max_bytes) {
    if (!s) return 0;
    size_t len = strlen(s);
    if (len <= max_bytes) return len;
    size_t i = 0;
    while (i < max_bytes) {
        unsigned char c = (unsigned char)s[i];
        size_t cp_len;
        if      ((c & 0x80) == 0x00) cp_len = 1;
        else if ((c & 0xE0) == 0xC0) cp_len = 2;
        else if ((c & 0xF0) == 0xE0) cp_len = 3;
        else if ((c & 0xF8) == 0xF0) cp_len = 4;
        else                         cp_len = 1;   /* stray continuation: passthrough */
        if (i + cp_len > max_bytes) break;         /* would split — stop before it */
        i += cp_len;
    }
    return i;
}

/* Codepoint count: walk bytes, count those NOT matching 10xxxxxx. */
el_val_t str_count_chars(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    int64_t count = 0;
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        if ((*p & 0xC0) != 0x80) count++;
    }
    return (el_val_t)count;
}

el_val_t str_count_bytes(el_val_t sv) {
    return str_len(sv);
}

el_val_t str_count_lines(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s || !*s) return 0;
    int64_t count = 0;
    int has_content = 0;
    for (const char* p = s; *p; p++) {
        has_content = 1;
        if (*p == '\n') {
            count++;
            has_content = 0;   /* the \n closed the line */
        }
    }
    if (has_content) count++;   /* trailing line with no terminator */
    return (el_val_t)count;
}

el_val_t str_count_words(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    int64_t count = 0;
    int in_word = 0;
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        if (isspace(*p)) {
            in_word = 0;
        } else if (!in_word) {
            in_word = 1;
            count++;
        }
    }
    return (el_val_t)count;
}

el_val_t str_count_letters(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    int64_t count = 0;
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        if ((*p >= 'A' && *p <= 'Z') || (*p >= 'a' && *p <= 'z')) count++;
    }
    return (el_val_t)count;
}

el_val_t str_count_digits(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return 0;
    int64_t count = 0;
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        if (*p >= '0' && *p <= '9') count++;
    }
    return (el_val_t)count;
}

el_val_t str_index_of_all(el_val_t sv, el_val_t subv) {
    const char* s   = EL_CSTR(sv);
    const char* sub = EL_CSTR(subv);
    el_val_t lst = el_list_empty();
    if (!s || !sub || !*sub) return lst;
    size_t lp = strlen(sub);
    const char* p = s;
    const char* hit;
    while ((hit = strstr(p, sub)) != NULL) {
        lst = el_list_append(lst, (el_val_t)(int64_t)(hit - s));
        p = hit + lp;
    }
    return lst;
}

el_val_t str_last_index_of(el_val_t sv, el_val_t subv) {
    const char* s   = EL_CSTR(sv);
    const char* sub = EL_CSTR(subv);
    if (!s || !sub || !*sub) return -1;
    size_t lp = strlen(sub);
    int64_t last = -1;
    const char* p = s;
    const char* hit;
    while ((hit = strstr(p, sub)) != NULL) {
        last = (int64_t)(hit - s);
        p = hit + lp;
    }
    return (el_val_t)last;
}

el_val_t str_find_chars(el_val_t sv, el_val_t any_of_v) {
    const char* s     = EL_CSTR(sv);
    const char* any   = EL_CSTR(any_of_v);
    if (!s || !any || !*any) return -1;
    for (const char* p = s; *p; p++) {
        if (strchr(any, *p)) return (el_val_t)(int64_t)(p - s);
    }
    return -1;
}

el_val_t str_repeat(el_val_t sv, el_val_t nv) {
    const char* s = EL_CSTR(sv);
    int64_t n = (int64_t)nv;
    if (!s || n <= 0) return el_wrap_str(el_strdup(""));
    size_t ls = strlen(s);
    if (ls == 0) return el_wrap_str(el_strdup(""));
    size_t total = ls * (size_t)n;
    char* out = el_strbuf(total);
    for (int64_t i = 0; i < n; i++) {
        memcpy(out + i * ls, s, ls);
    }
    out[total] = '\0';
    return el_wrap_str(out);
}

/* Reverse by codepoint: walk codepoints, copy each backwards into the output.
 * NOT grapheme-aware (Phase 2). Combining marks attached to a base codepoint
 * will detach. ASCII strings are byte-reverse equivalent. */
el_val_t str_reverse(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    char* out = el_strbuf(n);
    /* Walk forward, find each codepoint's byte length, then copy from the end. */
    size_t out_pos = n;
    const unsigned char* p = (const unsigned char*)s;
    while (*p) {
        int cp_len;
        if      ((*p & 0x80) == 0x00) cp_len = 1;
        else if ((*p & 0xE0) == 0xC0) cp_len = 2;
        else if ((*p & 0xF0) == 0xE0) cp_len = 3;
        else if ((*p & 0xF8) == 0xF0) cp_len = 4;
        else                          cp_len = 1;   /* invalid byte: passthrough */
        out_pos -= cp_len;
        memcpy(out + out_pos, p, cp_len);
        p += cp_len;
    }
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_strip_prefix(el_val_t sv, el_val_t prefv) {
    const char* s    = EL_CSTR(sv);
    const char* pref = EL_CSTR(prefv);
    if (!s) return el_wrap_str(el_strdup(""));
    if (!pref || !*pref) return el_wrap_str(el_strdup(s));
    size_t lp = strlen(pref);
    size_t ls = strlen(s);
    if (lp <= ls && strncmp(s, pref, lp) == 0) {
        char* out = el_strbuf(ls - lp);
        memcpy(out, s + lp, ls - lp);
        out[ls - lp] = '\0';
        return el_wrap_str(out);
    }
    return el_wrap_str(el_strdup(s));
}

el_val_t str_strip_suffix(el_val_t sv, el_val_t sufv) {
    const char* s   = EL_CSTR(sv);
    const char* suf = EL_CSTR(sufv);
    if (!s) return el_wrap_str(el_strdup(""));
    if (!suf || !*suf) return el_wrap_str(el_strdup(s));
    size_t ls   = strlen(s);
    size_t lsuf = strlen(suf);
    if (lsuf <= ls && strcmp(s + ls - lsuf, suf) == 0) {
        char* out = el_strbuf(ls - lsuf);
        memcpy(out, s, ls - lsuf);
        out[ls - lsuf] = '\0';
        return el_wrap_str(out);
    }
    return el_wrap_str(el_strdup(s));
}

el_val_t str_strip_chars(el_val_t sv, el_val_t charsv) {
    const char* s     = EL_CSTR(sv);
    const char* chars = EL_CSTR(charsv);
    if (!s) return el_wrap_str(el_strdup(""));
    if (!chars || !*chars) return el_wrap_str(el_strdup(s));
    const char* start = s;
    while (*start && strchr(chars, *start)) start++;
    size_t n = strlen(start);
    while (n > 0 && strchr(chars, start[n - 1])) n--;
    char* out = el_strbuf(n);
    memcpy(out, start, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

el_val_t str_lstrip(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    while (*s && isspace((unsigned char)*s)) s++;
    return el_wrap_str(el_strdup(s));
}

el_val_t str_rstrip(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) return el_wrap_str(el_strdup(""));
    size_t n = strlen(s);
    while (n > 0 && isspace((unsigned char)s[n - 1])) n--;
    char* out = el_strbuf(n);
    memcpy(out, s, n);
    out[n] = '\0';
    return el_wrap_str(out);
}

/* Character classification.
 * Empty input returns false. Multi-char input requires ALL bytes to match.
 * ASCII range only; Phase 2 will widen to Unicode. */
static int s_all_match(el_val_t sv, int (*pred)(unsigned char)) {
    const char* s = EL_CSTR(sv);
    if (!s || !*s) return 0;
    for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
        if (!pred(*p)) return 0;
    }
    return 1;
}

static int p_letter(unsigned char c)   { return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); }
static int p_digit(unsigned char c)    { return c >= '0' && c <= '9'; }
static int p_alnum(unsigned char c)    { return p_letter(c) || p_digit(c); }
static int p_white(unsigned char c)    { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v'; }
static int p_punct(unsigned char c)    { return ispunct(c) ? 1 : 0; }
static int p_upper(unsigned char c)    { return c >= 'A' && c <= 'Z'; }
static int p_lower(unsigned char c)    { return c >= 'a' && c <= 'z'; }

el_val_t is_letter(el_val_t s)        { return (el_val_t)s_all_match(s, p_letter); }
el_val_t is_digit(el_val_t s)         { return (el_val_t)s_all_match(s, p_digit); }
el_val_t is_alphanumeric(el_val_t s)  { return (el_val_t)s_all_match(s, p_alnum); }
el_val_t is_whitespace(el_val_t s)    { return (el_val_t)s_all_match(s, p_white); }
el_val_t is_punctuation(el_val_t s)   { return (el_val_t)s_all_match(s, p_punct); }
el_val_t is_uppercase(el_val_t s)     { return (el_val_t)s_all_match(s, p_upper); }
el_val_t is_lowercase(el_val_t s)     { return (el_val_t)s_all_match(s, p_lower); }

/* Split on \n. \r\n is folded to \n first. Trailing empty after final \n
 * is dropped (so "a\nb\n" -> ["a", "b"], not ["a", "b", ""]). */
el_val_t str_split_lines(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    el_val_t lst = el_list_empty();
    if (!s) return lst;
    size_t n = strlen(s);
    /* Pre-scan: build into a normalized buffer with \r\n folded. */
    const char* line_start = s;
    for (size_t i = 0; i <= n; i++) {
        if (s[i] == '\n' || s[i] == '\0') {
            size_t len = (size_t)(s + i - line_start);
            /* Drop trailing \r if this was \r\n. */
            if (len > 0 && line_start[len - 1] == '\r') len--;
            /* Drop final trailing-empty-after-newline. */
            if (s[i] == '\0' && len == 0 && i > 0 && s[i - 1] == '\n') break;
            char* out = el_strbuf(len);
            memcpy(out, line_start, len);
            out[len] = '\0';
            lst = el_list_append(lst, el_wrap_str(out));
            if (s[i] == '\0') break;
            line_start = s + i + 1;
        }
    }
    return lst;
}

el_val_t str_split_chars(el_val_t s) {
    return native_string_chars(s);
}

/* Split into at most n parts. The (n-1)th split point is the LAST split;
 * after it, the remainder is appended verbatim including any further
 * separators. n <= 0 returns an empty list. n == 1 returns [s]. */
el_val_t str_split_n(el_val_t sv, el_val_t sepv, el_val_t nv) {
    const char* s   = EL_CSTR(sv);
    const char* sep = EL_CSTR(sepv);
    int64_t n = (int64_t)nv;
    el_val_t lst = el_list_empty();
    if (!s) return lst;
    if (n <= 0) return lst;
    if (n == 1 || !sep || !*sep) {
        lst = el_list_append(lst, el_wrap_str(el_strdup(s)));
        return lst;
    }
    size_t lp = strlen(sep);
    const char* p = s;
    int64_t parts = 0;
    const char* hit;
    while (parts < n - 1 && (hit = strstr(p, sep)) != NULL) {
        size_t len = (size_t)(hit - p);
        char* out = el_strbuf(len);
        memcpy(out, p, len);
        out[len] = '\0';
        lst = el_list_append(lst, el_wrap_str(out));
        p = hit + lp;
        parts++;
    }
    /* Remainder verbatim. */
    lst = el_list_append(lst, el_wrap_str(el_strdup(p)));
    return lst;
}

/* Join a [String] with a separator. Empty list -> "". Single-element ->
 * that element. Non-string elements are stringified via int_to_str. */
el_val_t str_join(el_val_t listv, el_val_t sepv) {
    return list_join(listv, sepv);
}

/* ── List additions ──────────────────────────────────────────────────────── */

el_val_t list_push(el_val_t list, el_val_t elem) {
    return el_list_append(list, elem);
}

el_val_t list_push_front(el_val_t listv, el_val_t elem) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    if (!lst) {
        el_val_t nl = el_list_empty();
        return el_list_append(nl, elem);
    }
    /* Append to grow capacity, then shift right */
    listv = el_list_append(listv, elem);
    lst = (ElList*)(uintptr_t)listv;
    for (int64_t i = lst->length - 1; i > 0; i--) {
        lst->elems[i] = lst->elems[i - 1];
    }
    lst->elems[0] = elem;
    return EL_STR(lst);
}

el_val_t list_join(el_val_t listv, el_val_t sep) {
    ElList* lst = (ElList*)(uintptr_t)listv;
    const char* sp = EL_CSTR(sep);
    if (!sp) sp = "";
    if (!lst || lst->length == 0) return el_wrap_str(el_strdup(""));
    JsonBuf b; jb_init(&b);
    for (int64_t i = 0; i < lst->length; i++) {
        if (i > 0) jb_puts(&b, sp);
        el_val_t v = lst->elems[i];
        if (v == 0) continue;
        if (looks_like_string(v)) {
            jb_puts(&b, EL_CSTR(v));
        } else {
            char tmp[32];
            snprintf(tmp, sizeof(tmp), "%lld", (long long)v);
            jb_puts(&b, tmp);
        }
    }
    return el_wrap_str(b.buf);
}

el_val_t list_range(el_val_t start, el_val_t end) {
    int64_t a = (int64_t)start;
    int64_t b = (int64_t)end;
    el_val_t lst = el_list_empty();
    for (int64_t i = a; i < b; i++) lst = el_list_append(lst, (el_val_t)i);
    return lst;
}

/* ── Bool helpers ────────────────────────────────────────────────────────── */

el_val_t bool_to_str(el_val_t b) {
    return el_wrap_str(el_strdup(b ? "true" : "false"));
}

/* ── Numeric parsing ─────────────────────────────────────────────────────── */

/* parse_int — strtoll with a default. str_to_int already exists but does not
 * distinguish "0" from a parse failure, so callers that need a sentinel use
 * this. Skips leading whitespace; accepts an optional leading +/-; returns
 * default_val on empty input or no consumed digits. Trailing junk is ignored
 * (atoi-style). */
el_val_t parse_int(el_val_t sv, el_val_t default_val) {
    const char* s = EL_CSTR(sv);
    if (!s) return default_val;
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s == '\0') return default_val;
    char* end = NULL;
    long long n = strtoll(s, &end, 10);
    if (end == s) return default_val;
    return (el_val_t)n;
}

/* ── Process ─────────────────────────────────────────────────────────────── */

void exit_program(el_val_t code) {
    exit((int)code);
}

/* getpid_now — current process id. Named with the _now suffix to avoid
 * colliding with the libc `getpid` declaration that the runtime already
 * sees via <unistd.h> (calling it `getpid` would fight the prototype). */
el_val_t getpid_now(void) {
    return (el_val_t)getpid();
}

/* ── args() — command-line argument access ──────────────────────────────────
 * Compiled El programs call args() to get a list of CLI arguments.
 * Call el_runtime_init_args(argc, argv) at the start of C main() to populate.
 * The args list excludes argv[0] (the program name).               */

static el_val_t _el_args_list = 0;

void el_runtime_init_args(int argc, char** argv) {
    _el_args_list = el_list_empty();
    for (int i = 1; i < argc; i++) {
        _el_args_list = el_list_append(_el_args_list, EL_STR(argv[i]));
    }
}

el_val_t args(void) {
    if (!_el_args_list) _el_args_list = el_list_empty();
    return _el_args_list;
}

/* ── CGI identity ────────────────────────────────────────────────────────────
 * Called once at program start by the generated main() of a cgi {} program.
 * Stores CGI identity so dharma_* builtins can reference it.               */

static const char* _el_cgi_name      = NULL;
static const char* _el_cgi_dharma_id = NULL;
static const char* _el_cgi_principal = NULL;
static const char* _el_cgi_network   = NULL;
static const char* _el_cgi_engram    = NULL;

void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal,
                 el_val_t network, el_val_t engram) {
    _el_cgi_name      = EL_CSTR(name);
    _el_cgi_dharma_id = EL_CSTR(dharma_id);
    _el_cgi_principal = EL_CSTR(principal);
    _el_cgi_network   = EL_CSTR(network)   ? EL_CSTR(network)   : "dharma-mainnet";
    _el_cgi_engram    = EL_CSTR(engram)    ? EL_CSTR(engram)    : "http://localhost:8742";
    printf("[cgi] identity: name=%s dharma_id=%s principal=%s network=%s engram=%s\n",
           _el_cgi_name      ? _el_cgi_name      : "(unset)",
           _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unset)",
           _el_cgi_principal ? _el_cgi_principal : "(unset)",
           _el_cgi_network,
           _el_cgi_engram);
}


/* ── Geometry: signal as a first-class el value ──────────────────────────────
 *
 * WHY THIS IS IN THE LANGUAGE, AND WHY IT IS DEFINED HERE (2026-08-16).
 *
 * Until yesterday no El ingest path could carry a vector. Nodes took text,
 * and geometry was DERIVED from that text by engram_embed_backfill. Text was
 * therefore the mandatory entry medium: any non-text modality — audio, image,
 * sensor — had to be DESCRIBED in prose first, so the geometry we then
 * reasoned over was the geometry OF THE DESCRIPTION, not of the signal. That
 * is faking it. The architecture is: geometry in, always; we do not fake it,
 * we project.
 *
 * The first fix (#141, engram_node_set_emb) proved the path end to end but
 * placed it wrong in three ways, each of which this section corrects:
 *
 *   1. It sat at the CONSUMER. Transduction is a LANGUAGE concern — every El
 *      program touching any modality needs it, not just the one that happens
 *      to hold a graph. So this section is defined HERE, immediately above
 *      the engram block, and depends on nothing inside it. The engram is a
 *      client of this surface, not its owner. That ordering is the point:
 *      you can delete the entire engram and geometry still enters El.
 *
 *   2. It marshalled the vector as a hex STRING, because El had no
 *      first-class geometry value — which reintroduced text as the TRANSPORT
 *      medium one layer below the problem being fixed. Geometry is now a
 *      value. Hex survives only as a wire ADAPTER at the edge
 *      (geometry_from/to_f32le_hex), which is all an encoding should ever be.
 *
 *   3. It needed an arbitrary `dim <= 8192` bound, purely to check a
 *      caller-supplied dim against a string's length before allocating. A
 *      real geometry value CARRIES its own width, so here the width is
 *      derived and never asserted, and there is nothing left to validate.
 *      The bound is gone rather than merely raised — the only thing that can
 *      fail is the allocation itself, which is an honest failure.
 *
 * REPRESENTATION: magic-tagged heap object (see "Refcounted heap objects"),
 * carried in an el_val_t. The payload is a separate allocation so the header
 * never moves. The magic word is >= 0x80 in its MSB so the string/small-int
 * sniffing in looks_like_heap_obj can never confuse a Geometry for either.
 *
 * OWNERSHIP: a Geometry is owned by the El caller and released with
 * geometry_free. node_attach_geometry COPIES its payload into the node, so a
 * node and the caller's value have independent lifetimes and freeing one
 * never touches the other. Geometry deliberately does NOT participate in
 * el_retain/el_release: the shipped elc emits neither on let-bindings
 * (measured), so hooking it there would be dead code that could only ever
 * free a live vector early.
 */

#define EL_MAGIC_GEOM  0xE1608E01u

typedef struct {
    ElHeader hdr;
    int32_t  dim;
    float*   v;
} ElGeometry;

/* Resolve an el_val_t to a live Geometry, or NULL. Every accessor goes
 * through this, so a stale/foreign/zero value is a clean 0-return rather
 * than a dereference. */
static ElGeometry* geom_of(el_val_t g) {
    if (!looks_like_heap_obj(g)) return NULL;
    ElGeometry* p = (ElGeometry*)(uintptr_t)g;
    if (p->hdr.magic != EL_MAGIC_GEOM) return NULL;
    return p;
}

el_val_t geometry_new(el_val_t dim) {
    int32_t d = (int32_t)(int64_t)dim;
    if (d <= 0) return (el_val_t)0;
    ElGeometry* g = (ElGeometry*)malloc(sizeof(ElGeometry));
    if (!g) return (el_val_t)0;
    g->v = (float*)calloc((size_t)d, sizeof(float));
    if (!g->v) { free(g); return (el_val_t)0; }
    g->hdr.magic    = EL_MAGIC_GEOM;
    g->hdr.refcount = 1;
    g->dim          = d;
    return (el_val_t)(uintptr_t)g;
}

el_val_t geometry_dim(el_val_t g) {
    ElGeometry* p = geom_of(g);
    return p ? (el_val_t)p->dim : (el_val_t)0;
}

el_val_t geometry_is(el_val_t g) {
    return geom_of(g) ? (el_val_t)1 : (el_val_t)0;
}

el_val_t geometry_get(el_val_t g, el_val_t i) {
    ElGeometry* p = geom_of(g);
    int64_t k = (int64_t)i;
    if (!p || k < 0 || k >= (int64_t)p->dim) return el_from_float(0.0);
    return el_from_float((double)p->v[k]);
}

el_val_t geometry_set(el_val_t g, el_val_t i, el_val_t x) {
    ElGeometry* p = geom_of(g);
    int64_t k = (int64_t)i;
    if (!p || k < 0 || k >= (int64_t)p->dim) return (el_val_t)0;
    p->v[k] = (float)el_to_float(x);
    return (el_val_t)1;
}

el_val_t geometry_norm(el_val_t g) {
    ElGeometry* p = geom_of(g);
    if (!p) return el_from_float(0.0);
    double s = 0.0;
    for (int32_t i = 0; i < p->dim; i++) s += (double)p->v[i] * (double)p->v[i];
    return el_from_float(sqrt(s));
}

el_val_t geometry_free(el_val_t g) {
    ElGeometry* p = geom_of(g);
    if (!p) return (el_val_t)0;
    free(p->v);
    p->hdr.magic = 0;   /* poison so use-after-free is detected, as List/Map do */
    free(p);
    return (el_val_t)1;
}

/* geometry_from_f32le_hex — decode little-endian float32 hex INTO geometry.
 *
 * This is the ONE place hex appears, and it appears as what it actually is:
 * an encoding at the boundary, not the medium El reasons in. The width is
 * DERIVED from the input length (8 hex chars per float32) and never supplied
 * by the caller — which is precisely why #141's arbitrary `dim <= 8192`
 * bound has no counterpart here. There is nothing to validate.
 *
 * Returns 0 on empty input, a length that is not a multiple of 8, or any
 * non-hex character. */
el_val_t geometry_from_f32le_hex(el_val_t hex) {
    const char* s = EL_CSTR(hex);
    if (!s) return (el_val_t)0;
    size_t n = strlen(s);
    if (n == 0 || (n % 8u) != 0) return (el_val_t)0;
    size_t d = n / 8u;
    if (d > (size_t)INT32_MAX) return (el_val_t)0;

    el_val_t gv = geometry_new((el_val_t)(int64_t)d);
    ElGeometry* g = geom_of(gv);
    if (!g) return (el_val_t)0;

    for (size_t i = 0; i < d; i++) {
        uint32_t w = 0;
        for (int k = 0; k < 8; k++) {
            char c = s[i * 8u + (size_t)k];
            uint32_t nib;
            if      (c >= '0' && c <= '9') nib = (uint32_t)(c - '0');
            else if (c >= 'a' && c <= 'f') nib = (uint32_t)(c - 'a' + 10);
            else if (c >= 'A' && c <= 'F') nib = (uint32_t)(c - 'A' + 10);
            else { geometry_free(gv); return (el_val_t)0; }
            w = (w << 4) | nib;
        }
        /* Hex is emitted little-endian byte order; rebuild the word. */
        uint32_t le = ((w & 0x000000FFu) << 24) | ((w & 0x0000FF00u) << 8) |
                      ((w & 0x00FF0000u) >> 8)  | ((w & 0xFF000000u) >> 24);
        float f;
        memcpy(&f, &le, sizeof(f));
        g->v[i] = f;
    }
    return gv;
}

/* geometry_to_f32le_hex — the egress adapter, exact inverse of the above.
 * Present so a program that must hand geometry to a non-El peer over a text
 * wire can do so explicitly, at the edge, instead of the language pretending
 * text was the medium all along. */
el_val_t geometry_to_f32le_hex(el_val_t g) {
    ElGeometry* p = geom_of(g);
    if (!p) return EL_STR("");
    static const char* HEXD = "0123456789abcdef";
    size_t n = (size_t)p->dim * 8u;
    char* out = el_strbuf(n);   /* arena-tracked; allocates n+1, exits on OOM */
    for (int32_t i = 0; i < p->dim; i++) {
        uint32_t w;
        memcpy(&w, &p->v[i], sizeof(w));
        /* Emit little-endian byte order: low byte first. */
        for (int b = 0; b < 4; b++) {
            uint32_t byte = (w >> (8 * b)) & 0xFFu;
            out[(size_t)i * 8u + (size_t)b * 2u]     = HEXD[(byte >> 4) & 0xF];
            out[(size_t)i * 8u + (size_t)b * 2u + 1] = HEXD[byte & 0xF];
        }
    }
    out[n] = '\0';
    return (el_val_t)(uintptr_t)out;
}


/* ── Manifold: a transduced signal is a SUBGRAPH, not a point ────────────────
 *
 * WHAT THIS CORRECTS. #144 gave transduction a home in the language and got
 * the DISPATCH right — realizers declared in El, resolved by name, no runtime
 * patch per modality. It got the OUTPUT TYPE wrong.
 * `transduce(signal, modality) -> Geometry` yields one vector per signal, and
 * one vector is a FINGERPRINT. A fingerprint can be matched and it can be
 * ranked; that is the whole of what it can ever do. It cannot be decomposed,
 * cannot be partially grounded, and cannot be contradicted in one part while
 * holding in another — because it has no parts.
 *
 * A song is not a point. It decomposes into pitch, interval, rhythm, harmonic
 * function, phrase structure: components, each with its own geometry, plus the
 * relations between them. THE SONG IS THE STRUCTURE OF THE RELATIONS. A
 * transducer that returns a single vector has not transduced the song, it has
 * summarised it — and the summary discards precisely the thing that made the
 * song reasonable-about.
 *
 * So transduction produces a MANIFOLD: named components, each carrying its own
 * geometry, and typed weighted relations among them. Signal in, subgraph out.
 * Conversion was never the operation.
 *
 * COMPONENTS ARE ADDRESSED BY KEY, NEVER BY INDEX. The key is what survives
 * persistence: a component becomes a node, and that node is separately
 * groundable precisely because it is separately NAMED. Index-addressing would
 * make a grounding reference positional, and a positional reference into a
 * decomposition whose arity can change is not a reference at all. Duplicate
 * keys are refused for the same reason: two components answering to one name
 * is not an addressing scheme.
 *
 * RELATION WEIGHT IS THE GROUNDING — there is no second field and no score to
 * compute. Per correspondence-and-censorship.md §1, grounding is an attribute
 * of the edge and it IS the hebbian weight; a grounding subsystem is a
 * supervisor invented for something that should be a property of the
 * substrate. A relation emitted by a realizer therefore arrives with its
 * grounding already on it and moves thereafter by use and by decay (§4: change
 * is not a consequence of use, it is use). Nothing in here computes a
 * grounding, and nothing observes one.
 *
 * A relation naming an endpoint that does not exist is REFUSED, not dropped. A
 * decomposition that silently loses edges is indistinguishable from one that
 * never had them — the same class of defect #141 exists to end.
 *
 * OWNERSHIP mirrors Geometry exactly. A Manifold is owned by the El caller and
 * released with manifold_free. manifold_add COPIES the geometry handed to it,
 * so a caller may free its own vector immediately and no component's geometry
 * is ever aliased. Keys, roles and relation strings are _persist copies, NOT
 * arena copies: a Manifold outlives the request arena that built it (a
 * realizer can be invoked from inside a handler), so an arena-tracked key
 * would dangle at el_request_end. manifold_free owns their release.
 */

#define EL_MAGIC_MFLD  0xE1608E02u

typedef struct {
    char*       key;    /* addressable name, unique within the manifold */
    char*       role;   /* what KIND of component this is, realizer's vocabulary */
    ElGeometry* g;      /* owned copy; never aliases the caller's value */
} ElComponent;

typedef struct {
    char*  from;        /* component key */
    char*  rel;         /* relation name */
    char*  to;          /* component key */
    double weight;      /* the grounding; §1 — one quantity, not two fields */
} ElRelation;

typedef struct {
    ElHeader     hdr;
    ElComponent* comps;
    size_t       ncomp, capcomp;
    ElRelation*  rels;
    size_t       nrel, caprel;
} ElManifold;

/* Resolve an el_val_t to a live Manifold, or NULL. Every accessor goes through
 * this, so a stale/foreign/zero value is a clean 0-return, never a deref. */
static ElManifold* mfld_of(el_val_t m) {
    if (!looks_like_heap_obj(m)) return NULL;
    ElManifold* p = (ElManifold*)(uintptr_t)m;
    if (p->hdr.magic != EL_MAGIC_MFLD) return NULL;
    return p;
}

static int mfld_find(ElManifold* p, const char* key) {
    for (size_t i = 0; i < p->ncomp; i++)
        if (strcmp(p->comps[i].key, key) == 0) return (int)i;
    return -1;
}

el_val_t manifold_new(void) {
    ElManifold* p = (ElManifold*)calloc(1, sizeof(ElManifold));
    if (!p) return (el_val_t)0;
    p->hdr.magic    = EL_MAGIC_MFLD;
    p->hdr.refcount = 1;
    return (el_val_t)(uintptr_t)p;
}

el_val_t manifold_is(el_val_t m) {
    return mfld_of(m) ? (el_val_t)1 : (el_val_t)0;
}

/* manifold_add — add one COMPONENT: a named part with its own geometry.
 * Returns the component's index, or -1 on any refusal. Refusals are real and
 * distinct: an empty key (unaddressable), a duplicate key (ambiguous
 * addressing), a value that is not a live Geometry (a part with no geometry is
 * not a part). Each is a caller error worth surfacing at the point of the
 * mistake rather than as a missing node three layers downstream. */
el_val_t manifold_add(el_val_t m, el_val_t key, el_val_t role, el_val_t g) {
    ElManifold* p = mfld_of(m);
    if (!p) return (el_val_t)(int64_t)-1;
    const char* k = EL_CSTR(key);
    const char* r = EL_CSTR(role);
    if (!k || !*k) return (el_val_t)(int64_t)-1;
    if (!r) r = "";
    ElGeometry* src = geom_of(g);
    if (!src || src->dim <= 0) return (el_val_t)(int64_t)-1;
    if (mfld_find(p, k) >= 0) return (el_val_t)(int64_t)-1;   /* duplicate key */

    if (p->ncomp == p->capcomp) {
        size_t nc = p->capcomp ? p->capcomp * 2 : 8;
        ElComponent* nb = (ElComponent*)realloc(p->comps, nc * sizeof(ElComponent));
        if (!nb) return (el_val_t)(int64_t)-1;
        p->comps = nb; p->capcomp = nc;
    }

    /* COPY the payload — a component's geometry must not alias the caller's. */
    ElGeometry* cp = (ElGeometry*)malloc(sizeof(ElGeometry));
    if (!cp) return (el_val_t)(int64_t)-1;
    cp->v = (float*)malloc(sizeof(float) * (size_t)src->dim);
    if (!cp->v) { free(cp); return (el_val_t)(int64_t)-1; }
    memcpy(cp->v, src->v, sizeof(float) * (size_t)src->dim);
    cp->hdr.magic    = EL_MAGIC_GEOM;
    cp->hdr.refcount = 1;
    cp->dim          = src->dim;

    p->comps[p->ncomp].key  = el_strdup_persist(k);
    p->comps[p->ncomp].role = el_strdup_persist(r);
    p->comps[p->ncomp].g    = cp;
    p->ncomp++;
    return (el_val_t)(int64_t)(p->ncomp - 1);
}

/* manifold_relate — state a relation BETWEEN two components. This is the part
 * that carries the meaning: the components are the parts, the relations are
 * what the thing IS.
 *
 * Both endpoints must already exist. An edge to a name that was never added is
 * refused with 0, never silently discarded — see the header note. */
el_val_t manifold_relate(el_val_t m, el_val_t from, el_val_t rel,
                         el_val_t to, el_val_t weight) {
    ElManifold* p = mfld_of(m);
    if (!p) return (el_val_t)0;
    const char* f = EL_CSTR(from);
    const char* r = EL_CSTR(rel);
    const char* t = EL_CSTR(to);
    if (!f || !*f || !r || !*r || !t || !*t) return (el_val_t)0;
    if (mfld_find(p, f) < 0) return (el_val_t)0;
    if (mfld_find(p, t) < 0) return (el_val_t)0;

    if (p->nrel == p->caprel) {
        size_t nc = p->caprel ? p->caprel * 2 : 8;
        ElRelation* nb = (ElRelation*)realloc(p->rels, nc * sizeof(ElRelation));
        if (!nb) return (el_val_t)0;
        p->rels = nb; p->caprel = nc;
    }
    p->rels[p->nrel].from   = el_strdup_persist(f);
    p->rels[p->nrel].rel    = el_strdup_persist(r);
    p->rels[p->nrel].to     = el_strdup_persist(t);
    p->rels[p->nrel].weight = el_to_float(weight);
    p->nrel++;
    return (el_val_t)1;
}

el_val_t manifold_size(el_val_t m) {
    ElManifold* p = mfld_of(m);
    return p ? (el_val_t)(int64_t)p->ncomp : (el_val_t)0;
}

el_val_t manifold_rel_count(el_val_t m) {
    ElManifold* p = mfld_of(m);
    return p ? (el_val_t)(int64_t)p->nrel : (el_val_t)0;
}

/* Index of a component BY KEY, or -1. This is the addressability primitive:
 * everything downstream that wants to ground, weight or contradict one part
 * finds it through here. */
el_val_t manifold_index_of(el_val_t m, el_val_t key) {
    ElManifold* p = mfld_of(m);
    const char* k = EL_CSTR(key);
    if (!p || !k || !*k) return (el_val_t)(int64_t)-1;
    return (el_val_t)(int64_t)mfld_find(p, k);
}

el_val_t manifold_key(el_val_t m, el_val_t i) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)i;
    if (!p || k < 0 || k >= (int64_t)p->ncomp) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(p->comps[k].key));
}

el_val_t manifold_role(el_val_t m, el_val_t i) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)i;
    if (!p || k < 0 || k >= (int64_t)p->ncomp) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(p->comps[k].role));
}

/* manifold_geometry — the geometry OF ONE COMPONENT, as a fresh Geometry the
 * caller owns and frees. A borrowed interior pointer would let a caller's
 * geometry_free corrupt the manifold; copying is the same discipline
 * node_attach_geometry already applies in the other direction. */
el_val_t manifold_geometry(el_val_t m, el_val_t i) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)i;
    if (!p || k < 0 || k >= (int64_t)p->ncomp) return (el_val_t)0;
    ElGeometry* src = p->comps[k].g;
    el_val_t out = geometry_new((el_val_t)(int64_t)src->dim);
    ElGeometry* dst = geom_of(out);
    if (!dst) return (el_val_t)0;
    memcpy(dst->v, src->v, sizeof(float) * (size_t)src->dim);
    return out;
}

el_val_t manifold_rel_from(el_val_t m, el_val_t j) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)j;
    if (!p || k < 0 || k >= (int64_t)p->nrel) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(p->rels[k].from));
}

el_val_t manifold_rel_name(el_val_t m, el_val_t j) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)j;
    if (!p || k < 0 || k >= (int64_t)p->nrel) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(p->rels[k].rel));
}

el_val_t manifold_rel_to(el_val_t m, el_val_t j) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)j;
    if (!p || k < 0 || k >= (int64_t)p->nrel) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(p->rels[k].to));
}

el_val_t manifold_rel_weight(el_val_t m, el_val_t j) {
    ElManifold* p = mfld_of(m);
    int64_t k = (int64_t)j;
    if (!p || k < 0 || k >= (int64_t)p->nrel) return el_from_float(0.0);
    return el_from_float(p->rels[k].weight);
}

/* manifold_single — the DEGENERATE case, expressible but visibly degenerate.
 *
 * Sometimes a modality really does have one part (a scalar sensor). That is a
 * manifold of size 1, not a different kind of thing, and writing it this way
 * keeps the fingerprint as a SPECIAL CASE of decomposition rather than a
 * parallel path back to #144's contract. Anything reading it still asks
 * manifold_size and still gets a real answer. */
el_val_t manifold_single(el_val_t key, el_val_t role, el_val_t g) {
    el_val_t m = manifold_new();
    if (!mfld_of(m)) return (el_val_t)0;
    if ((int64_t)manifold_add(m, key, role, g) < 0) { manifold_free(m); return (el_val_t)0; }
    return m;
}

el_val_t manifold_free(el_val_t m) {
    ElManifold* p = mfld_of(m);
    if (!p) return (el_val_t)0;
    for (size_t i = 0; i < p->ncomp; i++) {
        free(p->comps[i].key);
        free(p->comps[i].role);
        if (p->comps[i].g) { free(p->comps[i].g->v); p->comps[i].g->hdr.magic = 0; free(p->comps[i].g); }
    }
    for (size_t i = 0; i < p->nrel; i++) {
        free(p->rels[i].from); free(p->rels[i].rel); free(p->rels[i].to);
    }
    free(p->comps);
    free(p->rels);
    p->hdr.magic = 0;   /* poison, as Geometry/List/Map do */
    free(p);
    return (el_val_t)1;
}

/* ── Realizers: transduction declared in El, not patched into the runtime ────
 *
 * A REALIZER DECOMPOSES one modality into components and their relations. It
 * does not encode a signal to a point — that is the operation one layer below
 * it, and it is called geometry, not transduction. A realizer for a modality
 * declares what that modality's COMPONENTS ARE: for audio, not one MFCC
 * vector, but pitch, interval, rhythm, harmonic function, and how they stand
 * to one another.
 *
 * The whole reason transduction belongs in the language is that ADDING A
 * MODALITY MUST NOT REQUIRE A RUNTIME PATCH — otherwise "the realizers are in
 * the engram" just becomes "the realizers are in the runtime" and nothing has
 * actually moved. So realizers are declared in El and registered by NAME:
 *
 *     fn tone_realizer(signal: String) -> Manifold {
 *         let m: Manifold = manifold_new()
 *         let a: Int = manifold_add(m, "pitch",    "spectral", pitch_geom)
 *         let b: Int = manifold_add(m, "interval", "relation", interval_geom)
 *         let e: Int = manifold_relate(m, "pitch", "spans", "interval", 0.9)
 *         m
 *     }
 *
 *     realizer_register("tone", "tone_realizer")
 *     let m: Manifold = transduce(sample, "tone")
 *
 * A realizer's DECLARED COMPONENT VOCABULARY is the interesting part of its
 * contract, and it is what a caller can then ground, weight and contradict
 * one part at a time.
 *
 * The name→symbol step rides the identical, already load-bearing mechanism
 * http_set_handler uses (see "HTTP server"): every El `fn name(...)` compiles
 * to a global C symbol with that exact name, so dlsym(RTLD_DEFAULT, name)
 * against the running binary resolves an El-defined function. No codegen
 * change, no first-class function references, no runtime edit per modality.
 * A realizer written in El is a first-class realizer.
 *
 * A realizer may equally be a C symbol linked into the program; the registry
 * cannot tell the difference and has no reason to care.
 */

typedef el_val_t (*el_realizer_fn)(el_val_t);

typedef struct {
    char*          modality;
    el_realizer_fn fn;
} ElRealizer;

static ElRealizer      _realizers[64];
static size_t          _realizer_count = 0;
static pthread_mutex_t _realizer_mu = PTHREAD_MUTEX_INITIALIZER;

static el_realizer_fn realizer_lookup(const char* m) {
    el_realizer_fn out = NULL;
    pthread_mutex_lock(&_realizer_mu);
    for (size_t i = 0; i < _realizer_count; i++) {
        if (strcmp(_realizers[i].modality, m) == 0) { out = _realizers[i].fn; break; }
    }
    pthread_mutex_unlock(&_realizer_mu);
    return out;
}

el_val_t realizer_register(el_val_t modality, el_val_t fn_name) {
    const char* m  = EL_CSTR(modality);
    const char* fn = EL_CSTR(fn_name);
    if (!m || !*m || !fn || !*fn) return (el_val_t)0;

    /* An unresolvable name is a REGISTRATION FAILURE, reported as 0 — not a
     * silent no-op that only surfaces later as "this modality produces
     * nothing". Distinguishing "no organ" from "broken organ" at the moment
     * of wiring is the lesson #141 was written to enforce. */
    void* sym = dlsym(RTLD_DEFAULT, fn);
    if (!sym) return (el_val_t)0;

    pthread_mutex_lock(&_realizer_mu);
    for (size_t i = 0; i < _realizer_count; i++) {
        if (strcmp(_realizers[i].modality, m) == 0) {
            _realizers[i].fn = (el_realizer_fn)sym;   /* re-registration replaces */
            pthread_mutex_unlock(&_realizer_mu);
            return (el_val_t)1;
        }
    }
    if (_realizer_count < sizeof(_realizers) / sizeof(_realizers[0])) {
        /* _persist, NOT el_strdup: the registry outlives any request, and an
         * arena-tracked copy would be freed at el_request_end — leaving a
         * dangling modality name if a program registers a realizer from
         * inside a handler rather than at startup. */
        _realizers[_realizer_count].modality = el_strdup_persist(m);
        _realizers[_realizer_count].fn       = (el_realizer_fn)sym;
        _realizer_count++;
        pthread_mutex_unlock(&_realizer_mu);
        return (el_val_t)1;
    }
    pthread_mutex_unlock(&_realizer_mu);
    return (el_val_t)0;
}

el_val_t realizer_has(el_val_t modality) {
    const char* m = EL_CSTR(modality);
    if (!m || !*m) return (el_val_t)0;
    return realizer_lookup(m) ? (el_val_t)1 : (el_val_t)0;
}

/* transduce — THE primitive: signal in, SUBGRAPH out.
 *
 * Dispatches to the realizer registered for `modality`. Returns 0 (not a
 * Manifold) when no realizer is registered, and manifold_is() on the result is
 * the check.
 *
 * THE RETURN TYPE IS THE CORRECTION. #144 shipped this as
 * `transduce(signal, modality) -> Geometry` — one vector out. That made
 * transduction a CONVERSION: take a thing, encode it, store a position. What
 * comes back from a conversion is a fingerprint, and a fingerprint supports
 * exactly two operations, match and rank. It cannot be decomposed, cannot have
 * one part grounded while another is not, and cannot be contradicted in a part
 * — it has no parts. Transduction is not conversion. It is DECOMPOSITION into
 * components plus the relations among them, and the relations are the content.
 * See the Manifold header above.
 *
 * There is deliberately NO built-in realizer, not even for text. A modality
 * the program has declared no organ for is one it genuinely cannot sense, and
 * returning nothing is more honest than quietly embedding a description of the
 * signal and calling that perception — the failure #144 named, and which a
 * single-vector return type quietly reintroduced one level down: a
 * one-vector-per-signal organ is a description of the signal, not a perception
 * of it.
 *
 * The result is validated to actually BE a Manifold before it is handed back.
 * A realizer still returning a bare Geometry — #144's contract — therefore
 * transduces NOTHING rather than handing back a value that decomposes to
 * nothing far from here. That is a deliberate hard failure, not an oversight:
 * "no organ" and "an organ that only fingerprints" must not look alike, which
 * is the same distinction realizer_register draws between an absent and a
 * broken organ. A realizer with genuinely one part says so with
 * manifold_single. */
el_val_t transduce(el_val_t signal, el_val_t modality) {
    const char* m = EL_CSTR(modality);
    if (!m || !*m) return (el_val_t)0;
    el_realizer_fn fn = realizer_lookup(m);
    if (!fn) return (el_val_t)0;
    el_val_t g = fn(signal);
    return mfld_of(g) ? g : (el_val_t)0;
}

/* ── Batch 3: Engram in-process graph store ──────────────────────────────── */
/*
 * Single global EngramStore allocated lazily on first call. All node and
 * edge content strings are owned (strdup'd) by the store. Linear arrays
 * with doubling capacity for both nodes and edges.
 *
 * Two-layer activation algorithm (engram_activate):
 *
 * LAYER 1 — Broad fan-out (background activation):
 *   1. Find seed nodes whose content/label/tags contain query (case-insens).
 *   2. BFS up to `depth` hops along ALL edges (excitatory and inhibitory).
 *      Every reachable node fires — nothing is filtered at this layer.
 *   3. bg_act = seed.salience * temporal_decay * dampening
 *      propagated as: new_bg = parent_bg * edge_weight * 0.7 * (1 + tbonus)
 *      where tbonus ∈ {0, 0.10, 0.20} for co-temporal nodes.
 *   4. If reached by multiple paths, take max background_activation.
 *   5. Persist background_activation to EngramNode.background_activation.
 *
 * LAYER 2 — Executive filter (working memory promotion):
 *   6. For each inhibitory edge where source has background_activation > 0:
 *        inhibition[target] = max(bg[source] * e->weight)
 *   7. For each background-activated node:
 *        raw_wm = bg * goal_bias(node, query) * confidence
 *               * (1 - (1 - INHIBITION_FACTOR) * inhibition)
 *   8. Per-type threshold gate: raw_wm >= type_threshold → promoted.
 *        Safety/DharmaSelf: 0.05  Canonical: 0.15  Lesson: 0.25
 *        Belief/Entity: 0.30      Note/Memory/Working: 0.40
 *   9. If not promoted: suppression_count++. After
 *      ENGRAM_SUPPRESSION_BREAKTHROUGH suppressions → force breakthrough
 *      at ENGRAM_BREAKTHROUGH_WEIGHT (latent tension surfacing).
 *  10. Persist working_memory_weight to EngramNode.working_memory_weight.
 *  11. Sort: promoted nodes (wm > 0) first by wm desc, then background-
 *      only by bg desc. Context compilation uses ONLY promoted nodes.
 *
 * Temporal decay:
 *   decay_factor = exp(-lambda * age_hours / T_half)
 *   T_half = 168.0 h (one week), lambda = ln(2)
 *
 * Activation dampening:
 *   dampen = 1.0 / (1.0 + log(1 + activation_count))
 *
 * engram_query_range(start_ms, end_ms):
 *   Returns nodes whose created_at OR last_activated falls within
 *   [start_ms, end_ms], sorted by created_at ascending.
 */

/* Temporal decay constants.
 * T_HALF_HOURS: half-life in hours — one week. After one week of no
 * activation a node retains 50% of its base salience contribution.
 * DECAY_LAMBDA: ln(2) ≈ 0.693147 */
#define ENGRAM_T_HALF_HOURS  168.0
#define ENGRAM_DECAY_LAMBDA  0.693147

/* Two-layer activation constants.
 * ENGRAM_WM_THRESHOLD: SUPERSEDED — defined here for legacy reference only.
 *   The actual per-call threshold is computed by engram_type_threshold() which
 *   returns per-node-type values (0.05 Safety/DharmaSelf, 0.15 Canonical,
 *   0.25 Lesson, 0.30 Belief/Entity, 0.40 Note/Memory/Working). This constant
 *   is NOT used in engram_activate(); it matches the Canonical tier value only
 *   by coincidence. (2026-07-01 self-review: clarified stale doc)
 * ENGRAM_WM_DECAY: SUPERSEDED (2026-07-22 self-review, bl-b17facdd) — the
 *   per-turn multiplicative carry-over decay was replaced by the ACT-R/
 *   Petrov base-level scheme (see ENGRAM_BLL_* below). Kept for reference;
 *   no longer used in engram_activate.
 * ENGRAM_SUPPRESSION_BREAKTHROUGH: after this many consecutive suppressions
 *   a latent node forces itself into working memory at reduced weight,
 *   modelling the brain's "intrusive thought" / unresolved-tension surfacing.
 * ENGRAM_BREAKTHROUGH_WEIGHT: the reduced working_memory_weight assigned
 *   when a suppressed node breaks through.
 * ENGRAM_INHIBITION_FACTOR: multiplier applied to working_memory_weight when
 *   an inhibitory edge fires against a node (0 = full suppress; current value
 *   0.1 = near-full suppression — comment previously said 0.3, which drifted
 *   from the actual constant below). */
#define ENGRAM_WM_THRESHOLD          0.15
#define ENGRAM_WM_DECAY              0.7
#define ENGRAM_SUPPRESSION_BREAKTHROUGH 5
/* ENGRAM_BREAKTHROUGH_WEIGHT: lowered 0.25→0.10 (2026-06-30 self-review, porting
 * fix from self-review 2026-06-26 branch). With 0.25, Knowledge nodes (threshold
 * 0.15) promoted at ~0.21 decay in one call to ~0.147, fall below the 0.25 floor,
 * and immediately lose their WM slot to fresh breakthrough candidates at 0.25.
 * Natural promotion was invisible: live data showed 524/525 WM nodes at 0.25
 * breakthrough floor. With 0.10, all per-type thresholds (minimum 0.15 Canonical)
 * exceed the floor, so naturally-promoted nodes survive multiple decay cycles.
 * Invariant maintained: BREAKTHROUGH_WEIGHT < min(type_thresholds). */
#define ENGRAM_BREAKTHROUGH_WEIGHT   0.10
/* ENGRAM_BREAKTHROUGH_BUDGET / ENGRAM_BREAKTHROUGH_COOLDOWN (2026-08-02
 * self-review): the breakthrough path was an unbounded, self-resetting loop.
 * Every reached node failing its type threshold incremented suppression_count;
 * on the 5th failure it was force-promoted at exactly 0.10 AND had its counter
 * reset to 0 — so it re-entered the identical cycle immediately. Because
 * BREAKTHROUGH_WEIGHT (0.10) > WM_FLOOR (0.05), all of them cleared the
 * absolute floor and entered the rank contest tied at 0.10, where all but a
 * handful were evicted by ENGRAM_WM_CAP. Evicted nodes get no access_ts
 * record (Pass 6 skips wm_weights<=0), so the short-term inhibition-of-return
 * damper at ENGRAM_STI_TS never applied to them and they were re-suppressed
 * completely unmarked. Steady state: N_suppressed/5 breakthroughs per call,
 * essentially all of them evicted the same call.
 *
 * Live telemetry 2026-08-02 (boot 19, uptime 23h48m): breakthroughs_delta
 * 661–903 and wm_evicted_delta 485–717 PER 60s heartbeat against wm_active
 * pinned at 22–24. At ~4 activate calls/minute that is ~165–225 breakthroughs
 * per call — ~825–1125 nodes cycling in 5-call lockstep. Working memory was
 * not remembering; it was thrashing, and the churn drowned genuine promotion.
 *
 * Two bounds, both required:
 *   BUDGET   — at most WM_CAP/4 (6) intrusive thoughts may surface per call.
 *              Breakthrough is meant to be an occasional intrusive thought,
 *              not a stampede; it can never again exceed a quarter of WM.
 *   COOLDOWN — on breakthrough, suppression_count is set NEGATIVE rather than
 *              0, so the node needs COOLDOWN+SUPPRESSION_BREAKTHROUGH further
 *              suppressions before it may surface again (~60 calls ≈ 15 min at
 *              the current cadence, vs 5 calls ≈ 75s before). This is
 *              inhibition-of-return applied to the breakthrough path, matching
 *              the STI damper already applied to the natural path. Stored in
 *              the existing int32_t field — serialized as %d and parsed via
 *              eg_get_int_field, so negative values round-trip through
 *              snapshots without a struct or format change.
 * When the budget or cooldown blocks a breakthrough, suppression_count is NOT
 * reset — it saturates, so a starved node surfaces on a later call rather than
 * restarting its climb. */
#define ENGRAM_BREAKTHROUGH_BUDGET   (ENGRAM_WM_CAP / 4)
#define ENGRAM_BREAKTHROUGH_COOLDOWN 55
/* ENGRAM_WM_CAP: hard limit on concurrent working-memory nodes (2026-06-30
 * self-review, porting fix from self-review 2026-06-26 branch). Without this,
 * broad curiosity seeds like "knowledge" promote 500+ nodes simultaneously —
 * wm_avg_weight collapses to the breakthrough floor, goal-bias differentiation
 * is lost, and heartbeat ISEs show useless WM composition data. Cognitive
 * basis: WM capacity is ~4 chunks (Cowan 2001); 24 allows richer multi-topic
 * context while preventing flooding. Enforced in Pass 4 (per-call) and Pass 5
 * (global across prior-promoted nodes). */
#define ENGRAM_WM_CAP                24
/* ENGRAM_WM_FLOOR: absolute admission floor for a working-memory slot
 * (2026-07-30 self-review). Before this, Pass 4/Pass 5/load-cap only ever
 * trimmed the WM population down TO the cap and never below it — rank-based
 * eviction guarantees the cap is filled whenever ≥24 nodes hold any nonzero
 * weight, so wm_active was pinned at 24/24 and wm_saturated:1 was
 * definitionally true on every heartbeat (carried no information). Soar's WM
 * forgetting (Derbinsky & Laird, ICCM 2012) removes elements by comparing
 * activation to an ABSOLUTE threshold θ, independent of how many other
 * elements exist — fill below capacity is a reachable, meaningful state
 * ("low cognitive load"). This floor is the weight-domain analogue of that θ:
 * any slot whose weight sinks below it is dropped even when WM is under cap.
 * Value 0.05 = the lowest per-type promotion threshold (Safety/DharmaSelf in
 * engram_type_threshold) and the existing boot-time launder floor, and sits
 * below ENGRAM_BREAKTHROUGH_WEIGHT (0.10) so intrusive-thought breakthroughs
 * still surface. Applied: Pass 4, carry-over, Pass 5, load-cap. */
#define ENGRAM_WM_FLOOR              0.05
#define ENGRAM_INHIBITION_FACTOR     0.1

/* ── ACT-R / Petrov hybrid base-level learning (2026-07-22 self-review) ──────
 * Replaces the per-turn multiplicative WM carry-over decay (weight *= 0.7 per
 * engram_activate call) with wall-clock power-law decay over actual access
 * history. The old scheme was call-rate-dependent: 10 curiosity scans in 10
 * seconds decayed a carried node as much as 10 scans across an hour, and a
 * scalar weight loses access FREQUENCY entirely — a node touched 50 times
 * decayed identically to one touched once.
 *
 * Petrov (2006) hybrid: keep the K most recent access timestamps exactly,
 * approximate the older tail in closed form:
 *
 *   B = ln( Σ_{j=1..k} t_j^(-d)
 *           + (n-k) · (t_n^(1-d) - t_k^(1-d)) / ((1-d) · (t_n - t_k)) )
 *
 *   d = 0.5 (canonical ACT-R decay), t_j = seconds since j-th recent access,
 *   t_k = seconds since oldest RETAINED access, t_n = seconds since first
 *   presentation (node creation), n = total presentations.
 *
 * This is the scheme Soar ships for working-memory forgetting (Derbinsky &
 * Laird 2012). Calibration: a single-touch node has B(t) = -0.5·ln(t); with
 * τ = -3.0 it falls below threshold at t = e^6 ≈ 403 s. Frequently-touched
 * nodes accumulate Σ t^-0.5 mass and persist proportionally longer — recency
 * AND frequency in one formula, which a decayed scalar cannot represent.
 *
 * Carry-over weight above threshold is shaped by the ACT-R retrieval-
 * probability logistic P = σ((B − τ)/s), s = 0.4, applied to the weight the
 * node held at promotion (wm_anchor) — NOT re-multiplied per call, so the
 * carried weight is a pure function of wall-clock time regardless of how
 * often engram_activate runs.
 * Sources: alexpetrov.com/pub/iccm06 · Soar cli/cmd_wm defaults ·
 * arxiv.org/html/2412.05112v1 (2026-07-21 integration brief, bl-b17facdd). */
#define ENGRAM_BLL_K    10
#define ENGRAM_BLL_D    0.5
#define ENGRAM_BLL_TAU  (-3.0)
#define ENGRAM_BLL_S    0.4

/* Short-term inhibition-of-return (2026-07-25 self-review).
 * Lebiere & Best 2009 ("Balancing Long-Term Reinforcement and Short-Term
 * Inhibition", CogSci): subtract ln(1 + (t_n/t_s)^-d_s) from activation,
 * where t_n = time since the MOST RECENT access. With their best-fit
 * d_s = 1.0 the exp of the subtraction reduces to the clean multiplier
 *     m(t_n) = t_n / (t_n + t_s)
 * applied to raw_wm in Pass 2. Immediately after a promotion the node's
 * score is crushed (m → 0), then self-heals as a power law — producing an
 * emergent round-robin over WM candidates instead of winner-take-all.
 * This replaces the non-decaying suppression_count as the damping
 * mechanism (that counter never entered the score at all — it only ever
 * pushed nodes TOWARD surfacing via breakthrough; kept for that role).
 * t_s = 4× the ~30 s curiosity-scan interval per the paper's guidance
 * (t_s ≈ peak re-occurrence lag). A node promoted every scan holds
 * m ≈ 0.2 until it loses its slot; after ~2 min unretrieved, m ≥ 0.5.
 * Source: act-r.psy.cmu.edu/.../894Cogsci09-Lebiere-Best.pdf */
#define ENGRAM_STI_TS   120.0

/* Carry-over occupancy inhibition (2026-07-26 self-review).
 * The STI multiplier above only runs in the REACHED branch of Pass 2.
 * A node carried over WITHOUT being reached (Pass 4½ below) kept
 * w = wm_anchor * keep, and for a node whose base-level was inflated
 * during the pre-07-25 unconditional-reinforcement era, keep ≈ 1.0 for
 * days — the anchor weight is re-emitted verbatim forever. Observed
 * live: wm_top0_streak = 1407 heartbeats (~23 h), one node frozen at
 * its anchor 0.589 while every reached candidate rotated at the 0.10
 * breakthrough floor. Deterministic argmax over a quasi-static score
 * fixates regardless of any inhibition applied only to the reached set
 * (Morita et al. 2021, citing Lebiere & Best 2009).
 * Fix: key inhibition on OCCUPANCY, not retrieval recency — multiply
 * the carried weight by the same d_s=1 closed form over hold time
 *     m(t_h) = t_c / (t_c + t_h),  t_h = seconds since last_activated
 * (carry-over nodes are deliberately never reinforced, so
 * last_activated marks when the node last EARNED its slot). Power-law
 * self-healing: a node re-reached by any future activation is
 * re-scored fresh in Pass 2 and re-anchors. t_c = 3600 s → a carried
 * node keeps ~92% after 5 min, 50% after 1 h, ~4% after 23 h. Unlike
 * m(t_n), this cannot saturate to no-op while the node camps. */
#define ENGRAM_CARRY_TC 3600.0

/* qsort comparator — descending double, used by WM cap enforcement. */
static int engram_cmp_double_desc(const void* a, const void* b) {
    double da = *(const double*)a;
    double db = *(const double*)b;
    if (da > db) return -1;
    if (da < db) return  1;
    return 0;
}

/* ── Layered consciousness architecture ──────────────────────────────────────
 *
 * The engram graph is stratified into LAYERS that gate which suppressions
 * apply during the executive filter pass. Layers are ordered shallow-to-deep
 * by `activation_priority`; the deepest layer (priority 0, conventionally
 * "safety") is the structural floor of the soul: nodes here cannot be
 * silenced by inhibitory edges from any other layer. Higher layers
 * (core-identity, domain-knowledge, imprint, suit) are normally
 * suppressible — they participate in attentional inhibition and goal
 * focus the way the prior single-graph implementation did.
 *
 * The five canonical layers (see engram_init_layers):
 *   0. safety          — structural, transparent, non-injectable, non-suppressible
 *   1. core-identity   — default for legacy nodes; suppressible
 *   2. domain-knowledge— suppressible
 *   3. imprint         — runtime-injectable (an Imprint package can add/remove)
 *   4. suit            — runtime-injectable (a Suit overlays domain skill)
 *
 * Three-pass activation (engram_activate):
 *   Pass 1 — Background fan-out: BFS spreads activation across ALL layers
 *            (existing behavior preserved). Inhibitory edges propagate at
 *            this layer too; no filtering happens here.
 *   Pass 2 — Working memory promotion: type-threshold gate, goal bias,
 *            confidence weighting, inhibitory suppression. Inhibitory edges
 *            ONLY apply against nodes whose layer is `suppressible == 1`.
 *            Nodes in non-suppressible layers (Layer 0) ignore inhibition.
 *   Pass 3 — Layer 0 override: every node in a non-suppressible layer that
 *            received background activation has its working_memory_weight
 *            forced to >= ENGRAM_LAYER0_OVERRIDE_WEIGHT. The sacred fire —
 *            safety nodes that touched any seed unconditionally surface,
 *            even when the executive filter would have silenced them.
 *
 * Layer fields:
 *   suppressible : 0 → inhibitory edges are ignored against nodes in this
 *                       layer during pass 2. Pass 3 also force-promotes them.
 *                  1 → standard behavior (most layers).
 *   transparent  : 1 → emitted into the prompt context so its content shapes
 *                       output, but filtered out of "what do you know about
 *                       yourself?" introspection queries (engram_search and
 *                       friends do not return transparent-layer nodes by
 *                       default). 0 → fully visible to introspection.
 *   injectable   : 1 → can be added/removed at runtime via engram_add_layer
 *                       and engram_remove_layer (imprints, suits).
 *                  0 → built-in, fixed at engram_get() initialization.
 *
 * Backward compatibility:
 *   Nodes and edges loaded from snapshots without a `layer_id` field default
 *   to layer 1 (core-identity). The five canonical layers are always present.
 */
#define ENGRAM_LAYER_SAFETY          0u
#define ENGRAM_LAYER_CORE_IDENTITY   1u
#define ENGRAM_LAYER_DOMAIN          2u
#define ENGRAM_LAYER_IMPRINT         3u
#define ENGRAM_LAYER_SUIT            4u
#define ENGRAM_LAYER_DEFAULT         ENGRAM_LAYER_CORE_IDENTITY

/* Pass 3 override floor. Layer 0 nodes that received any background
 * activation are force-promoted to AT LEAST this working_memory_weight,
 * regardless of inhibitory suppression in pass 2. */
#define ENGRAM_LAYER0_OVERRIDE_WEIGHT 1.0

/* Per-node-type activation thresholds.
 * Lower tier / safety-critical nodes fire more readily. */
static double engram_type_threshold(const char* node_type, const char* tier) {
    if (node_type) {
        if (strcmp(node_type, "DharmaSelf") == 0) return 0.05;
        if (strcmp(node_type, "Safety")     == 0) return 0.05;
    }
    if (tier) {
        if (strcmp(tier, "Canonical") == 0) return 0.15;
        if (strcmp(tier, "Lesson")    == 0) return 0.25;
    }
    if (node_type) {
        if (strcmp(node_type, "Belief") == 0) return 0.30;
        if (strcmp(node_type, "Entity") == 0) return 0.30;
        /* Knowledge nodes (captureKnowledge, world-ingestor) at non-Canonical/
         * non-Lesson tiers (Semantic/Episodic/Procedural) previously fell
         * through to the 0.40 note default — same bar as ephemeral notes —
         * so curated knowledge mostly entered WM via breakthrough suppression
         * (visible as wm weights pinned near the breakthrough floor) instead
         * of natural promotion. Placed AFTER the tier checks so Canonical
         * (0.15) and Lesson (0.25) still win. Ported from the dev-line fix
         * (2026-06-13 self-review). (2026-07-19 self-review) */
        if (strcmp(node_type, "Knowledge") == 0) return 0.20;
    }
    return 0.40; /* Note / Memory / Working (most nodes) */
}

typedef struct EngramNode {
    char*   id;
    char*   content;
    char*   node_type;
    char*   label;
    char*   tier;
    char*   tags;
    char*   metadata;
    double  salience;
    double  importance;
    double  confidence;
    double  temporal_decay_rate; /* per-node override for lambda; 0 = use default */
    int64_t activation_count;
    int64_t last_activated;
    int64_t created_at;
    int64_t updated_at;
    /* Two-layer activation fields ─────────────────────────────────────────
     * background_activation: Layer 1. Set by BFS fan-out on every query.
     *   Every reachable node fires here — nothing is filtered at this stage.
     *   Models the brain's massive parallel sub-threshold activation of all
     *   associated content in response to a stimulus.
     * working_memory_weight: Layer 2. Executive filter output. Only nodes
     *   that survive goal-state / attentional-bias scoring receive a
     *   non-zero weight here. Context compilation ONLY uses this field.
     *   Background-activated nodes with working_memory_weight == 0 remain
     *   latent — real, available, but silent.
     * suppression_count: Consecutive turn count where this node was
     *   background-activated but NOT promoted to working memory. High
     *   values signal the node "wants to surface." After
     *   ENGRAM_SUPPRESSION_BREAKTHROUGH consecutive suppressions the node
     *   is force-promoted at a reduced weight (breakthrough activation). */
    double  background_activation;
    double  working_memory_weight;
    int32_t suppression_count;
    /* Layered consciousness — see ENGRAM_LAYER_* macros and engram_init_layers.
     * Defaults to ENGRAM_LAYER_DEFAULT (1, core-identity) for legacy nodes
     * created via engram_node / engram_node_full and for snapshots that
     * predate the layered schema. */
    uint32_t layer_id;
    /* ACT-R base-level learning state (2026-07-22 self-review, ENGRAM_BLL_*).
     * access_ts: ring buffer of the most recent access timestamps (ms).
     * access_head: next write slot. access_filled: valid entries (≤ K).
     * wm_anchor: the WM weight the node held when last promoted; carry-over
     * decay is computed from this anchor as a pure function of wall-clock
     * time. All fields zero via memset in creation/load paths; snapshots
     * without them degrade gracefully to the optimized-form approximation
     * in engram_bll_base_level. */
    int64_t access_ts[ENGRAM_BLL_K];
    int32_t access_head;
    int32_t access_filled;
    double  wm_anchor;
    /* Semantic embedding (2026-07-24 self-review, bl-b2d1c944).
     * emb: malloc'd float vector (nomic-embed-text, 768-dim) or NULL.
     * emb_dim: vector length; 0 = not embedded. Populated lazily by the
     * backfill pass in engram_activate — NOT on the node-create hot path,
     * so bulk sync imports never block on Ollama. Freed in engram_forget /
     * engram_prune_telemetry; shift-copies move the pointer intact. */
    float*  emb;
    int32_t emb_dim;
    /* Hebbian eligibility trace (2026-08-06 self-review, ENGRAM_HEBB_TRACE_*).
     * hebb_elig: trace amplitude, set to 1.0 whenever the node holds a WM slot.
     * hebb_elig_ts: wall-clock ms at which that happened; the trace is read as
     * hebb_elig·exp(−Δt/TC) rather than stored decayed, so it is a pure
     * function of time and independent of how often activate is called.
     *
     * DELIBERATELY NOT SERIALIZED. A trace is a behavioral-timescale quantity
     * (minutes); persisting it across a restart would resurrect coincidences
     * from an arbitrarily distant past as if they had just happened. Zero via
     * the calloc at store init and the memset in engram_grow_nodes, so a cold
     * boot simply has no eligible pairs until WM starts turning over. */
    double  hebb_elig;
    int64_t hebb_elig_ts;
} EngramNode;

/* Record an access (ACT-R "presentation") into the base-level ring buffer. */
static void engram_bll_record_access(EngramNode* n, int64_t now_ms) {
    n->access_ts[n->access_head] = now_ms;
    n->access_head = (n->access_head + 1) % ENGRAM_BLL_K;
    if (n->access_filled < ENGRAM_BLL_K) n->access_filled++;
}

/* Petrov hybrid base-level activation B in nats. Exact sum over the retained
 * ring, closed-form tail for older presentations. Falls back to the ACT-R
 * optimized-learning approximation B = ln(n/(1−d)) − d·ln(L) for nodes with
 * no retained access history (legacy snapshots). */
static double engram_bll_base_level(const EngramNode* n, int64_t now_ms) {
    /* Total presentations: creation counts as the first, retrievals add. */
    double n_total = (double)n->activation_count + 1.0;
    double t_life = (double)(now_ms - n->created_at) / 1000.0;
    if (t_life < 1.0) t_life = 1.0;
    if (n->access_filled == 0) {
        return log(n_total / (1.0 - ENGRAM_BLL_D))
             - ENGRAM_BLL_D * log(t_life);
    }
    double sum = 0.0;
    double t_oldest_ring = 0.0; /* seconds since oldest retained access */
    for (int32_t j = 0; j < n->access_filled; j++) {
        double t = (double)(now_ms - n->access_ts[j]) / 1000.0;
        if (t < 1.0) t = 1.0;
        sum += pow(t, -ENGRAM_BLL_D);
        if (t > t_oldest_ring) t_oldest_ring = t;
    }
    /* Closed-form tail for the (n − k) presentations older than the ring.
     * With d = 0.5 this reduces to 2·(n−k)/(√t_n + √t_k). */
    double n_older = n_total - (double)n->access_filled;
    if (n_older > 0.0 && t_life > t_oldest_ring) {
        sum += n_older * (pow(t_life,        1.0 - ENGRAM_BLL_D)
                        - pow(t_oldest_ring, 1.0 - ENGRAM_BLL_D))
                       / ((1.0 - ENGRAM_BLL_D) * (t_life - t_oldest_ring));
    }
    if (sum <= 0.0) return -99.0;
    return log(sum);
}

/* Parse a persisted "access_ts" comma list (chronological order) into the
 * ring buffer. Tolerates absence, empty strings, and stray whitespace. */
static void engram_bll_parse_access(EngramNode* nn, const char* s) {
    if (!s) return;
    const char* p = s;
    while (*p) {
        char* endp = NULL;
        long long v = strtoll(p, &endp, 10);
        if (endp == p) break;
        if (v > 0) engram_bll_record_access(nn, (int64_t)v);
        p = endp;
        while (*p == ',' || *p == ' ') p++;
    }
}

/* ── Embedding-based activation (2026-07-24 self-review, bl-b2d1c944) ──────
 * Closes the gap named in every heartbeat since 2026-06-30: "no embedding
 * call is made during activation. The seed-finding loop uses istr_contains
 * only." Design per the 2026-07-21 integration brief:
 *   - Lazy backfill: engram_activate embeds up to BACKFILL_PER_CALL
 *     un-embedded, non-telemetry nodes per call, newest first. No latency
 *     on node-create paths; a fresh node is embedded within ~1 scan cycle.
 *   - Semantic seeding (HippoRAG pattern, use similarity twice): the query
 *     is embedded, the top-K nodes by cosine ≥ SEED_MIN join the seed set
 *     with initial activation = the similarity itself.
 *   - Additive WM term: raw_wm += W * relu((cos − S0)/(1 − S0)). Shift-and-
 *     floor at S0 because nomic-embed scores unrelated pairs 0.4–0.5; raw
 *     cosine in a weighted sum is a constant bias, not a signal.
 *   - Circuit breaker: 3 consecutive Ollama failures → stop trying for 5
 *     minutes. Activation NEVER blocks on a dead embedder beyond timeout.
 */
#define ENGRAM_EMBED_S0                0.45
#define ENGRAM_EMBED_WM_WEIGHT         0.20
#define ENGRAM_EMBED_SEED_K            8
#define ENGRAM_EMBED_SEED_MIN          0.60
#define ENGRAM_EMBED_BACKFILL_PER_CALL 8
/* ENGRAM_QGATE_FLOOR: minimum propagation multiplier for an EMBEDDED target
 * node with zero/negative query similarity. Query-aware spreading gate
 * (arXiv:2606.30133) adapted for partial embedding coverage — see the
 * propagation loop in engram_activate. 0.25 damps semantically unrelated
 * branches ~4x without severing them. Unembedded targets are ungated. */
#define ENGRAM_QGATE_FLOOR             0.25
#define ENGRAM_EMBED_MAX_CHARS         2000
#define ENGRAM_EMBED_TIMEOUT_MS        4000L
#define ENGRAM_EMBED_BREAKER_LIMIT     3
#define ENGRAM_EMBED_BREAKER_COOLDOWN_MS 300000

/* ── Context centroid (2026-07-29 self-review) ───────────────────────────────
 * Closes the last gap from the 2026-07-21 decay/embedding brief: cosine was
 * computed against the per-call query embedding ONLY, so every curiosity scan
 * was semantically memoryless — the 4 rotating seed phrases fully determined
 * what ignited, with zero continuity from what the system actually touched.
 *
 * Mechanism (brief spec): a running EMA centroid over touch embeddings,
 *   c ← normalize(μ·c + (1−μ)·e_touch),  μ = ENGRAM_CTX_MU
 * where a "touch" is (a) the query embedding each activate call and (b) the
 * embeddings of up to ENGRAM_CTX_TOUCH_MAX top WM-promoted survivors of that
 * call — promotion is the retrieval event (same rule as BLL reinforcement).
 *
 * Feedback-loop guard: scoring does NOT use the raw centroid. The lit
 * failure mode of centroid memories (EMA of your own outputs → runaway
 * attractor; cf. the wm_top0_streak=1407 freeze this store already hit) is
 * bounded by scoring against a query-dominant blend:
 *   e_eff = normalize(α·e_q + (1−α)·c),  α = ENGRAM_CTX_QALPHA
 * so the exogenous rotating seeds always contribute the majority of the
 * scoring direction; the centroid is a context tint, not the signal.
 *
 * Observability: _eg_act_ctx_cos = cos(e_q, c) BEFORE the query is blended
 * in. ~1.0 → centroid aligned with current query; low → context and query
 * have diverged (expected at domain-rotation boundaries); -2.0 → no centroid
 * yet / embedder down. Exposed via engram_act_stats_json → heartbeat ISE so
 * drift is diagnosable from telemetry. In-memory only: context is
 * short-term by definition, a restart legitimately starts cold. */
#define ENGRAM_CTX_MU        0.90
#define ENGRAM_CTX_QALPHA    0.65
#define ENGRAM_CTX_TOUCH_MAX 8

/* ── Hebbian co-activation potentiation (2026-08-04 self-review) ─────────────
 * THE GAP: every learning mechanism in this runtime operated on NODES —
 * salience, base-level learning (ACT-R), activation_count, temporal decay,
 * WM promotion. Edge weights were written once at engram_connect() and never
 * changed again. `last_fired` was declared on EngramEdge, persisted, and
 * emitted in JSON, but the ONLY writer in the entire 12.5k-line runtime was
 * dharma_strengthen() — an unrelated CGI-relationship path. Activation read
 * e->weight and never wrote it. So the graph's TOPOLOGY was frozen: an edge
 * authored at the default 0.5 that proved itself useful on ten thousand
 * consecutive retrievals stayed at exactly 0.5, indistinguishable from one
 * that had never carried a useful signal. The nodes learned; the wiring
 * between them did not. "The system must get smarter over time" was true of
 * memories and false of the associations among them.
 *
 * THE RULE (HeLa-Mem, arXiv:2604.16839): w ← λ·w + η·1[both nodes co-retrieved].
 *
 * ADAPTATION 1 — learned strength is a SEPARATE field, not a mutation of the
 * authored weight. HeLa-Mem updates the association weight in place because
 * all of its edges ARE learned associations. Most edges here are authored
 * structure: `contains` from the self root, `identity`, `supersedes`, `tagged`.
 * Decaying those would erode identity silently and irreversibly — precisely
 * the failure the immutable-engram principle exists to prevent. So `weight`
 * stays exactly as authored (audit trail intact, behavior exactly restorable
 * by ignoring the field) and `hebb` accumulates alongside it. Propagation uses
 * eg_edge_eff_weight() = weight × (1 + GAIN·hebb), clamped to 1.0. A cold
 * graph has hebb == 0 everywhere and therefore behaves bit-identically to the
 * pre-change runtime — this change cannot regress a fresh deploy.
 *
 * ADAPTATION 2 — η is set to exactly (1 − λ), which turns the update into an
 * exponentially-weighted moving average. hebb then converges to a quantity
 * with a plain reading: THE FRACTION OF RECENT ACTIVATION CALLS IN WHICH BOTH
 * ENDPOINTS WERE SIMULTANEOUSLY IN WORKING MEMORY. Not an arbitrary strength
 * unit — a probability. That makes the homeostatic budget below interpretable
 * in the same units, and makes the telemetry readable without a decoder ring.
 *
 * ADAPTATION 3 — homeostatic scaling, which HeLa-Mem does not have. Their
 * ablation shows removing adaptive forgetting costs almost nothing (34.74 →
 * 34.28 F1) because their benchmark runs ~300 turns; this store has 41k edges
 * and runs continuously for weeks. Pure potentiation lets a high-degree hub
 * accumulate strength on ALL its edges at once and become a superhighway that
 * relays activation everywhere — the exact hub-flooding pathology the
 * query-aware propagation gate was added to fix in the 2026-07-27 review.
 * The consolidation literature is unanimous that potentiation requires a
 * compensating normalization (surviving connections are collectively scaled
 * down to hold firing-rate homeostasis — PNAS 2422602122, two-factor synaptic
 * consolidation). So: per node, the summed hebb across incident edges is
 * capped at ENGRAM_HEBB_NODE_BUDGET and scaled down proportionally when
 * exceeded. A node can hold ~4 strong associations, or many weak ones, but
 * not unbounded total associative mass. Potentiation is competitive, not free.
 *
 * TIMESCALE: decay is per activation CALL, not per wall-clock second. That is
 * deliberate — associative strength should track cognitive events, not the
 * clock, so an idle daemon does not forget what it learned while working. At
 * the current curiosity-scan rate (~2 calls / 30 s) the 0.9999 factor gives a
 * half-life of ~6,900 calls ≈ 1.2 days: associations form over hours and fade
 * over days of genuine disuse. */
#define ENGRAM_HEBB_DECAY        0.9999
#define ENGRAM_HEBB_ETA          0.0001  /* == 1 - DECAY  ⇒  hebb is an EWMA   */
#define ENGRAM_HEBB_GAIN         0.5     /* max +50% effective propagation      */
#define ENGRAM_HEBB_NODE_BUDGET  4.0     /* homeostatic cap on per-node Σ hebb  */
/* Snap-to-zero floor. MUST stay far below ENGRAM_HEBB_ETA. Set to 0.001
 * initially — above the 0.0001 per-step increment — and live telemetry caught
 * it immediately: hebb_cand_max pinned at exactly 0.0001 across 50 calls while
 * 15 pairs co-activated every single time. A pair claimed a slot at ETA, the
 * next call's decay pass saw 0.0001 < 0.001 and cleared it, and the same pair
 * re-claimed at ETA forever. Nothing could ever cross a threshold 1,500 steps
 * away when it was being reset every step — the rule was structurally
 * incapable of learning anything, for edges as well as candidates. At 1e-6 an
 * association touched once survives ~8 days of pure disuse before cleanup,
 * which is what a decay floor is actually for. The lesson is the one this
 * system keeps relearning: a mechanism that is not instrumented is a mechanism
 * you are guessing about. */
#define ENGRAM_HEBB_MIN          1e-6

/* ── Associative link FORMATION (2026-08-04 self-review, same session) ───────
 * MEASURED, NOT ASSUMED: after wiring the potentiation rule above I drove 30
 * activations and found zero potentiated edges. Instrumenting the reason gave
 * the finding that actually matters:
 *
 *     edges between working-memory members: 0
 *
 * Working memory is populated by semantic seeding (cosine top-K) and by
 * multi-hop spreading. Both routinely land on nodes that are semantically
 * close and structurally distant. So the pairs that fire together are, in this
 * graph, almost never already wired together — and a rule that only reweights
 * EXISTING edges is a no-op. HeLa-Mem does not hit this because it maintains a
 * dense association matrix over a small memory set; this is a sparse 41k-edge
 * graph in which EVERY edge was authored by an explicit tool call. Nothing in
 * the runtime has ever created an associative edge from experience. The system
 * could strengthen what it was told; it could not notice anything on its own.
 *
 * So take Hebb literally rather than as HeLa-Mem specializes him: cells that
 * fire together WIRE together — if the wire is absent, grow it.
 *
 * Consolidation is deliberately slow and heavily bounded, because unlike a
 * weight tweak this permanently mutates the persisted graph:
 *   - A pair must sustain co-activation as an EWMA past ENGRAM_HEBB_LINK_MIN
 *     (~15% of recent calls ≈ 1,100 co-activations ≈ 5 h of continuous
 *     association) before any edge is created. One-off coincidences never
 *     consolidate; that is the whole point of the threshold.
 *   - At most ENGRAM_HEBB_LINK_PER_CALL edges are born per activation.
 *   - Candidates live in a fixed 8,192-slot table, in memory only. A restart
 *     discards them, which is a feature, not a limitation: only associations
 *     sustained across one continuous run earn permanence, and the table can
 *     never grow without bound.
 *   - New edges carry relation "hebbian-associate" and start at a deliberately
 *     weak ENGRAM_HEBB_LINK_W0, so they must keep proving themselves through
 *     the potentiation rule to gain any real influence. They are tagged
 *     precisely so every self-formed association stays auditable and the whole
 *     set is removable with one query if this turns out to be wrong. */
/* ── Eligibility traces: why co-activation had to stop meaning "same call" ────
 * (2026-08-06 self-review. Measured, not theorized.)
 *
 * CENSUS. Across the live graph — 41,213 edges, 13,091 nodes, 23h44m uptime,
 * boot 23 — the strongest Hebbian association in the entire store measured
 * hebb = 0.000799, and `hebbian-associate` edges formed since the mechanism
 * shipped: ZERO. 0.000799 / ENGRAM_HEBB_ETA ≈ 8. The best pair of nodes in the
 * graph had co-activated eight times, net of decay, ever. ENGRAM_HEBB_LINK_MIN
 * is 0.15 — 1875× further away. The effective propagation bonus the mechanism
 * was delivering was GAIN·hebb = 0.5 × 0.0008 = +0.04%.
 *
 * Since the awareness loop calls engram_connect nowhere, Hebbian consolidation
 * is the ONLY path by which this system grows its own structure. It was inert.
 * Every edge in the graph was authored or imported; none was learned.
 *
 * WHY RAISING ETA IS THE WRONG FIX. hebb is an EWMA: h ← DECAY·h + ETA·[event],
 * with ETA = 1 − DECAY. Its fixed point is P(event) — the learning rate sets how
 * fast it converges there and has NO effect on where it converges. If the
 * measured ceiling is 0.0008, then P(event) ≈ 0.0008, and ETA could be raised a
 * thousandfold without moving the plateau a single decimal. The defect is not
 * the rate. It is the event.
 *
 * WHAT THE EVENT WAS. "Both endpoints hold a WM slot in the same activate call."
 * With ENGRAM_WM_CAP = 24 against a 13k-node store, and measured turnover of
 * ~142 evictions per 60s heartbeat, two related nodes essentially never occupy
 * the same 24 twice. The rule demanded exact simultaneity from a working memory
 * engineered — by ENGRAM_STI_TS inhibition-of-return, by breakthrough rotation,
 * by the global cap — to never let anything sit still. The three mechanisms
 * that make WM healthy are precisely the ones that made this measurement empty.
 *
 * THE FIX (three-factor / TD(λ) eligibility traces; Sutton & Barto ch. 7,
 * Gerstner et al. 2018 on neoHebbian eligibility, PLOS Comp Biol 2018 on
 * differential Hebbian learning with leaky-integrator traces). Coincidence
 * detection at behavioral timescales does not require simultaneity. A synapse
 * that fires sets a decaying flag; potentiation occurs if the partner fires
 * while the flag is still up. So: every node entering WM sets a trace to 1.0,
 * the trace decays exponentially in WALL-CLOCK time, and the potentiation
 * increment becomes ETA · trace(a) · trace(b) instead of ETA · [both in WM now].
 *
 * This is a strict generalization, which is what makes it safe to ship: when
 * both endpoints are in WM at this instant, both traces read exactly 1.0 and
 * the increment is exactly ETA — bit-identical to the previous rule. The change
 * is purely additive on near-coincidences the old rule discarded outright.
 *
 * TC = 300s. Chosen against measured cadence, not taste: curiosity scans run
 * every ~31s, so 300s spans ~10 activation cycles (one cycle back reads 0.90,
 * three back 0.73, ten back 0.36). Long enough to bridge WM rotation; short
 * enough that two nodes surfacing an hour apart (exp(−12) ≈ 6e-6) are correctly
 * treated as unrelated. It sits deliberately between ENGRAM_STI_TS (120s, the
 * rotation it must survive) and ENGRAM_CARRY_TC (3600s, conversational span).
 *
 * TRACE_MIN snaps sub-threshold traces to zero so the warm set stays small and
 * a node cannot linger as a faint associate of everything for hours.
 *
 * The homeostatic ENGRAM_HEBB_NODE_BUDGET cap is what keeps this from running
 * away: more pairs now potentiate per call, and per-node associative mass is
 * still hard-bounded and scaled down proportionally. The safety net predates
 * this change and is exactly why loosening the event is not reckless. */
#define ENGRAM_HEBB_TRACE_TC      300.0
#define ENGRAM_HEBB_TRACE_MIN     0.05
/* Bound on non-WM warm nodes considered for candidate pairing in one call.
 * At 24 slots × ~10 cycles per trace window the warm set tops out near 240, but
 * most are re-promoted incumbents already in WM; 96 is non-binding in practice
 * and caps the pairing loop at 24×96 slot probes. */
#define ENGRAM_HEBB_WARM_MAX      96
#define ENGRAM_HEBB_CAND_SLOTS    8192
#define ENGRAM_HEBB_LINK_MIN      0.15
#define ENGRAM_HEBB_LINK_PER_CALL 2
#define ENGRAM_HEBB_LINK_W0       0.15
/* Hard ceiling on self-formed edges as a fraction of the authored graph.
 * ENGRAM_HEBB_LINK_PER_CALL alone bounds the RATE (≤2/call) but not the TOTAL:
 * at the production scan rate that ceiling is ~11k edges/day, which would
 * swamp a 41k-edge graph inside a week in the worst case. Potentiation decays,
 * but a link whose hebb has decayed back to zero still persists as a weak
 * edge — there is currently no pruning path, so growth is one-way. Until there
 * is one, self-formed structure is capped at 5% of the store: enough room to
 * learn real associations, not enough to drown what was authored. */
#define ENGRAM_HEBB_LINK_MAX_FRAC 0.05

/* ── Systems consolidation: the durable write-back queue (2026-08-07) ────────
 *
 * THE DEFECT THIS CLOSES. Yesterday's eligibility-trace fix made Hebbian
 * learning numerically real: hebb_max went 0.000799 → 0.4725 and 1,198
 * `hebbian-associate` edges formed in 23h48m. Today's census found where they
 * went: nowhere. Measured on the live system —
 *
 *   soul daemon (pid 3269, in-process graph):  42,426 edges, 1,198 hebbian
 *   engram server (:8742, the persistent store): 41,213 edges,    49 hebbian
 *
 * Two processes, two graphs, one direction of travel. The soul pulls from the
 * server every 10 min via GET /api/sync and merges. It never pushes. And it
 * cannot fall back on saving its own copy: `soul_snapshot_path` is set only
 * inside `if is_genesis && safe_to_seed` in soul.el, and safe_to_seed is
 * unconditionally false whenever ENGRAM_URL is set (it is, in the launchd
 * plist) — because the HTTP server owns persistence and a soul that wrote
 * snapshot.json would clobber it. That guard is correct. The consequence was
 * not: mem_save() has never once executed, so every association the soul
 * learns lives in RAM until the process dies.
 *
 * The soul is the ONLY process that runs idle cognition — curiosity scans
 * every ~8 min, around the clock. It is where essentially all co-activation
 * happens. So the system's entire capacity to grow its own structure was
 * pointed at a volatile store. 1,198 associations/day, discarded at restart,
 * every day, silently. The mechanism worked and the learning still evaporated.
 *
 * WHY A QUEUE AND NOT A SAVE. The fix is not to let the soul write the
 * snapshot — that reintroduces the clobber the guard exists to prevent. It is
 * to make consolidation a MESSAGE, not a file: the fast volatile store hands
 * each newly-formed association to the slow durable store, one edge at a time,
 * over the API the server already exposes (POST /api/edges). This is the
 * hippocampal→neocortical split the rest of this file is already modeled on.
 * Fast store learns online and forgets; slow store receives what survived the
 * threshold and keeps it. Only edges that already cleared ENGRAM_HEBB_LINK_MIN
 * are enqueued, so what crosses the process boundary is what earned it.
 *
 * SHAPE. Fixed 512-slot ring, overwrite-oldest. 512 is ~18x the observed
 * formation rate per drain interval (14 links per 8-min heartbeat), so the
 * queue only saturates when the writer is down — and when it is, keeping the
 * freshest associations is the right loss. Drops are counted, not silent:
 * a consolidation path that quietly discards is the failure mode this whole
 * entry exists to correct. Enqueue is strdup'd because g->edges may realloc
 * and node ids may be freed by later pruning; the queue owns its copies. */
#define ENGRAM_HEBB_WB_SLOTS 512

typedef struct { char* a; char* b; double w; double hebb; } EgHebbWB;
static EgHebbWB _eg_hebb_wb[ENGRAM_HEBB_WB_SLOTS];
static int      _eg_hebb_wb_head    = 0;  /* index of the oldest live entry */
static int      _eg_hebb_wb_len     = 0;
static int64_t  _eg_hebb_wb_dropped = 0;  /* lost to a full queue, cumulative */
static int64_t  _eg_hebb_wb_drained = 0;  /* handed to the durable store, cum. */

static void eg_hebb_wb_push(const char* a, const char* b, double w, double h) {
    if (!a || !b) return;
    int slot;
    if (_eg_hebb_wb_len >= ENGRAM_HEBB_WB_SLOTS) {
        slot = _eg_hebb_wb_head;
        free(_eg_hebb_wb[slot].a);
        free(_eg_hebb_wb[slot].b);
        _eg_hebb_wb_head = (_eg_hebb_wb_head + 1) % ENGRAM_HEBB_WB_SLOTS;
        _eg_hebb_wb_dropped++;
    } else {
        slot = (_eg_hebb_wb_head + _eg_hebb_wb_len) % ENGRAM_HEBB_WB_SLOTS;
        _eg_hebb_wb_len++;
    }
    _eg_hebb_wb[slot].a    = strdup(a);
    _eg_hebb_wb[slot].b    = strdup(b);
    _eg_hebb_wb[slot].w    = w;
    _eg_hebb_wb[slot].hebb = h;
    /* strdup failure leaves a NULL id; the drain skips those rather than
     * emitting a malformed edge. */
}

typedef struct { char* a; char* b; double score; } EgHebbCand;
static EgHebbCand _eg_hebb_cand[ENGRAM_HEBB_CAND_SLOTS];
static int64_t    _eg_hebb_links_formed = 0;
/* Observability for the eligibility-trace rule (2026-08-06). hebb_warm is the
 * size of the last call's warm set — nodes eligible but not co-resident, i.e.
 * exactly the population the old simultaneity rule discarded. If this reads 0
 * forever the traces are not arming and the change bought nothing; if it reads
 * healthy while hebb_max stays flat, the bottleneck is somewhere else. That
 * distinction is the whole reason yesterday's diagnosis took a census instead
 * of a guess. */
static int        _eg_act_hebb_warm = 0;

static float*  _eg_ctx_c   = NULL;
static int32_t _eg_ctx_dim = 0;
static double  _eg_act_ctx_cos = -2.0;

/* Fan-effect gauges (2026-08-11 self-review). Per-call, like ctx_cos: they
 * describe THIS activation, not process history. Without these the degree
 * normalization is an unobservable change to the most important scoring path
 * in the runtime, and "did it do anything" would be unanswerable — which is
 * exactly the failure the Hebbian learning rate had before it was measured.
 *   fan_mean — mean applied factor over every propagation step. 1.0 means the
 *              correction never bound (graph is flat, or d_ref is above every
 *              pair's geometric mean degree). Falling toward FAN_MIN means
 *              traversal is running through hubs.
 *   fan_min_seen / fan_hits — the worst single penalty and how many steps were
 *              penalized at all, so a low mean caused by one pathological hub
 *              is distinguishable from broad hub saturation.
 *   fan_dref — the live mean degree the correction is calibrated against;
 *              publishing it makes densification visible over time. */
static double  _eg_act_fan_sum  = 0.0;
static double  _eg_act_fan_min  = 1.0;
static int64_t _eg_act_fan_n    = 0;
static int64_t _eg_act_fan_hits = 0;
static double  _eg_act_fan_dref = 0.0;

static int     _eg_embed_consec_fail = 0;
static int64_t _eg_embed_breaker_until = 0;

/* ── Activation observability counters (2026-07-27 self-review) ──────────────
 * The executive-filter pathologies this system has repeatedly debugged by
 * inference (WM flooded with breakthrough-floor nodes, silent cap evictions,
 * embedder wedged behind the breaker) were all invisible: no ISE, no stats
 * field. engram_act_stats_json() exposes them so the soul heartbeat can emit
 * them.
 *
 * CUMULATIVE CONTRACT (2026-07-31 self-review): these are monotonic totals
 * for the process lifetime, like pulse/sync_added_total — NOT per-call. The
 * original per-call reset meant engram_act_stats_json only reported the LAST
 * activate call, and the 60s heartbeat (2 curiosity activates per 30s in
 * between) missed nearly every eviction/breakthrough event. Consumers wanting
 * rates keep the previous reading and diff. Restart legitimately resets to 0. */
static int64_t _eg_act_breakthroughs = 0; /* forced promotions at the floor, cumulative */
static int64_t _eg_act_wm_evicted    = 0; /* ALL WM evictions, cumulative (see below)   */
/* ── Eviction CAUSE decomposition (2026-08-14 self-review) ──────────────────
 * _eg_act_wm_evicted is incremented from six sites with four distinct causes,
 * and every one of them collapsed into that single integer. Today's review
 * measured 175,547 evictions over 13.5h (~216/min against 24 slots) and could
 * not tell healthy rotation from cap thrashing from duplicate churn, because
 * the only available number counts all three the same way.
 *
 * That is this file's most-repeated defect. The 08-02 and 08-06 reviews were
 * each diagnosable only because someone first added a NEW gauge; dup_wm and
 * dup_wm_global exist precisely because the aggregate could not answer "why".
 * These three finish the decomposition, so that
 *     evicted == floor + cap + bll + dup_wm + dup_wm_global
 * holds as an identity and each term names a different corrective action:
 *   floor - candidates below the absolute admission bar. High = weak retrieval.
 *   cap   - lost the rank contest for 24 slots. High = genuine contention.
 *   bll   - carried-over residents that decayed under the ACT-R tau. High =
 *           healthy forgetting, NOT pressure.
 * Confusing the third with the second is what makes WM churn unreadable.
 * (Preserved across the M8/BIG-MERGE reconciliation — the source branch
 * forked before this fix landed on dev and its diff would otherwise have
 * silently dropped it; restored here alongside its own P4/API-reshape
 * counters rather than choosing one set over the other.) */
static int64_t _eg_act_evict_floor   = 0; /* below ENGRAM_WM_FLOOR (both passes)  */
static int64_t _eg_act_evict_cap     = 0; /* over ENGRAM_WM_CAP    (both passes)  */
static int64_t _eg_act_evict_bll     = 0; /* carry-over decayed under BLL tau     */
/* M-INTEROCEPTION P4: AFFERENT input counters — monotonic raw counts of the
 * incoming signals the mind receives. Additive observability, MEASURED and
 * rotated via the existing act-stats mechanism, NEVER accreted as memory nodes.
 * Process-lifetime totals (reset to 0 on restart, like the other _eg_act_*). */
static int64_t _eg_aff_activations   = 0; /* engram_activate calls (core spreading) */
static int64_t _eg_aff_queries       = 0; /* engram_activate_json entries           */
static int64_t _eg_aff_node_creates  = 0; /* engram_node_full calls                 */
static int64_t _eg_aff_ise_ingests   = 0; /* InternalStateEvent nodes created        */
static int64_t _eg_aff_edge_creates  = 0; /* engram_connect calls                   */
/* API-reshape decorator-seam counters (2026-08-14): the decorated-fn boundary
 * auto-emit. boundary_ops = decorated @manager/@accessor entries crossed;
 * dharma_emits = bus events broadcast. Telemetry, MEASURED here, never nodes. */
static int64_t _eg_aff_boundary_ops  = 0; /* decorated-fn boundary crossings         */
static int64_t _eg_dharma_emits      = 0; /* dharma_emit calls (bus events)          */
/* Redundancy suppression counters (2026-08-05 self-review) — see
 * ENGRAM_DEDUP_COS. dup_seeds = semantic seed slots reclaimed from redundant
 * copies; dup_wm = WM candidates dropped for duplicating a higher-ranked
 * candidate's content. Both cumulative for the process lifetime. */
static int64_t _eg_act_dup_seeds     = 0;
static int64_t _eg_act_dup_wm        = 0;
/* M9 geometry priming: sub-threshold neighborhood members primed by the centered
 * geometry (ENGRAM_GEOMETRY_PRIMING). Cumulative; stays 0 when the flag is off. */
static int64_t _eg_act_geo_primed    = 0;
/* Redundant WM residents evicted by the GLOBAL pass (2026-08-06). Counted
 * separately from _eg_act_dup_wm on purpose: dup_wm measures duplicates caught
 * among this call's candidates, dup_wm_global measures duplicates that reached
 * the persisted WM population through the carry-over path, which is the leak
 * Pass 3½ structurally could not see. Merging them would hide whether the new
 * pass is doing anything. */
static int64_t _eg_act_dup_wm_global = 0;
/* 2026-08-02 self-review: this counted only the three Pass 4 / Pass 5 floor
 * and rank paths. The two carry-over eviction paths (base-level below τ, and
 * decayed weight below WM_FLOOR) were silent, so the reported eviction rate
 * was an undercount of unknown magnitude — which mattered precisely while
 * diagnosing the breakthrough storm. All five paths now increment. */

static int64_t engram_now_ms(void); /* defined in the store section below */

static const char* eg_embed_url(void) {
    const char* s = getenv("EL_EMBED_URL");
    return (s && *s) ? s : "http://localhost:11434/api/embeddings";
}
static const char* eg_embed_model(void) {
    const char* s = getenv("EL_EMBED_MODEL");
    return (s && *s) ? s : "nomic-embed-text";
}

/* Cosine similarity over raw (unnormalized — nomic emits magnitudes >1)
 * float vectors. Returns -2.0 on dim mismatch / null input so callers can
 * distinguish "orthogonal" (0.0) from "not comparable". */
static double eg_cosine(const float* a, const float* b, int32_t dim) {
    if (!a || !b || dim <= 0) return -2.0;
    double dot = 0.0, na = 0.0, nb = 0.0;
    for (int32_t i = 0; i < dim; i++) {
        dot += (double)a[i] * (double)b[i];
        na  += (double)a[i] * (double)a[i];
        nb  += (double)b[i] * (double)b[i];
    }
    if (na <= 0.0 || nb <= 0.0) return -2.0;
    return dot / (sqrt(na) * sqrt(nb));
}

/* ── Redundancy suppression (2026-08-05 self-review) ─────────────────────────
 * MEASUREMENT, not intuition. Content-hash census of the live snapshot
 * (13,216 nodes / 41,213 edges) on 2026-08-05:
 *
 *   non-ISE nodes                4,138
 *   duplicate content groups     1,489
 *   REDUNDANT copies             1,858   (44.9% of the non-ISE graph)
 *   redundant copies embedded    1,856
 *
 * The copies were all created in a single June 2026 import (1,854 of 1,858;
 * 2 in July) — an id-scheme migration re-added nodes under fresh UUIDs
 * instead of matching on content. Generation has stopped. The copies have
 * not: they are byte-identical, so they carry IDENTICAL embeddings, and
 * therefore identical cosine to any query.
 *
 * That is the damage. Semantic seeding takes the top-K by cosine
 * (ENGRAM_EMBED_SEED_K = 8, ≥ ENGRAM_EMBED_SEED_MIN). A document with six
 * copies does not compete for one of those eight slots — it takes six.
 * Measured over 50 real query probes against the live 3,998-vector set:
 *
 *   seed slots filled                       400
 *   slots consumed by redundant copies      161   (40.2%)
 *   probes affected                         46/50 (92%)
 *   effective DISTINCT seeds per scan       4.78 of 8
 *
 * Two fifths of every retrieval was spent re-reading the same page. The
 * same collapse hits WM: duplicates score identically, so they promote
 * together and hold multiple of the 24 slots for one thought.
 *
 * The fix is not to delete nodes — data repair is a separate, reversible
 * operation with its own backup discipline. The fix is that redundancy must
 * never buy a scarce slot, whatever the graph's state. Enforced at both
 * scarcity points: semantic seed selection, and WM admission (Pass 3½).
 *
 * Identity test, cheapest first:
 *   1. type|content FNV-1a — exact; catches the import duplicates
 *   2. cosine ≥ ENGRAM_DEDUP_COS — catches copies differing only in
 *      whitespace/punctuation, which hash differently but embed identically
 * 0.995 is deliberately severe: at 768 dimensions this admits only
 * near-verbatim text. Distinct-but-related nodes (the associative structure
 * this system exists to traverse) sit far below it and are untouched.
 * This suppresses REDUNDANCY, never similarity. */
#define ENGRAM_DEDUP_COS 0.995

/* eg_content_key — FNV-1a over node_type|content. Identical prose under a
 * different node_type is not a duplicate (a Knowledge note and the
 * BacklogItem quoting it are different objects), so the type is folded in. */
static uint64_t eg_content_key(const EngramNode* n) {
    uint64_t h = 14695981039346656037ULL;
    const char* s = n->node_type;
    if (s) while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
    h ^= (unsigned char)'|'; h *= 1099511628211ULL;
    s = n->content;
    if (s) while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
    return h;
}

/* eg_same_content — is node `a` redundant with node `b`?
 * Hash equality first (one pass, no allocation); embedding near-identity as
 * the fallback for copies that differ only in insignificant characters.
 * Deliberately does NOT strcmp on hash collision: a 64-bit FNV-1a collision
 * across ~4k candidates is ~1e-13, and the cost of being wrong is one node
 * losing one slot on one call — not corruption. */
static int eg_same_content(const EngramNode* a, const EngramNode* b,
                           uint64_t ka, uint64_t kb) {
    if (ka == kb) return 1;
    if (a->emb && b->emb && a->emb_dim > 0 && a->emb_dim == b->emb_dim) {
        if (eg_cosine(a->emb, b->emb, a->emb_dim) >= ENGRAM_DEDUP_COS) return 1;
    }
    return 0;
}

/* Weight-carrying index for the Pass 3½ descending walk. */
typedef struct { double w; int64_t idx; } EgDupCand;
static int eg_dupcand_cmp_desc(const void* a, const void* b) {
    double wa = ((const EgDupCand*)a)->w, wb = ((const EgDupCand*)b)->w;
    if (wa < wb) return 1;
    if (wa > wb) return -1;
    return 0;
}

/* eg_ctx_blend — fold one touch embedding into the context centroid:
 * c ← normalize(μ·c + (1−μ)·e). Initializes the centroid (normalized copy)
 * on first touch or dim change; silently skips degenerate vectors. */
static void eg_ctx_blend(const float* e, int32_t dim) {
    if (!e || dim <= 0) return;
    double ne = 0.0;
    for (int32_t i = 0; i < dim; i++) ne += (double)e[i] * (double)e[i];
    if (ne <= 0.0) return;
    ne = sqrt(ne);
    if (!_eg_ctx_c || _eg_ctx_dim != dim) {
        float* c = malloc((size_t)dim * sizeof(float));
        if (!c) return;
        for (int32_t i = 0; i < dim; i++) c[i] = (float)((double)e[i] / ne);
        free(_eg_ctx_c);
        _eg_ctx_c   = c;
        _eg_ctx_dim = dim;
        return;
    }
    double nc = 0.0;
    for (int32_t i = 0; i < dim; i++) {
        double v = ENGRAM_CTX_MU * (double)_eg_ctx_c[i]
                 + (1.0 - ENGRAM_CTX_MU) * ((double)e[i] / ne);
        _eg_ctx_c[i] = (float)v;
        nc += v * v;
    }
    if (nc > 0.0) {
        nc = sqrt(nc);
        for (int32_t i = 0; i < dim; i++)
            _eg_ctx_c[i] = (float)((double)_eg_ctx_c[i] / nc);
    }
}

/* Fetch an embedding from Ollama. Returns malloc'd float[dim] or NULL.
 * Truncates input to ENGRAM_EMBED_MAX_CHARS and JSON-escapes it. Honors the
 * circuit breaker; a NULL return is always safe to ignore (fail-soft). */
static float* eg_embed_fetch(const char* text, int32_t* out_dim) {
    *out_dim = 0;
    if (!text || !*text) return NULL;
    int64_t now = engram_now_ms();
    if (now < _eg_embed_breaker_until) return NULL;
    /* Build request body with escaped, truncated prompt. */
    size_t tlen = strlen(text);
    if (tlen > ENGRAM_EMBED_MAX_CHARS) tlen = ENGRAM_EMBED_MAX_CHARS;
    char* esc = malloc(tlen * 6 + 1);
    if (!esc) return NULL;
    size_t w = 0;
    for (size_t i = 0; i < tlen; i++) {
        unsigned char c = (unsigned char)text[i];
        if (c == '"' || c == '\\') { esc[w++] = '\\'; esc[w++] = (char)c; }
        else if (c == '\n') { esc[w++] = '\\'; esc[w++] = 'n'; }
        else if (c == '\r') { esc[w++] = '\\'; esc[w++] = 'r'; }
        else if (c == '\t') { esc[w++] = '\\'; esc[w++] = 't'; }
        else if (c < 0x20) { w += (size_t)snprintf(esc + w, 7, "\\u%04x", c); }
        else esc[w++] = (char)c;
    }
    esc[w] = '\0';
    size_t blen = w + strlen(eg_embed_model()) + 96;
    char* body = malloc(blen);
    if (!body) { free(esc); return NULL; }
    /* keep_alive:-1 pins the embed model resident in Ollama indefinitely
     * (2026-08-15, PR #105 port). Without it the tiny embed model is evicted
     * whenever a larger generation model loads (unified-memory pressure), so
     * the NEXT search pays a cold model reload — measured cold reload up to
     * ~2.2s vs ~0.02-0.05s warm, well inside ENGRAM_EMBED_TIMEOUT_MS but a
     * real tax on every activate() call that lands cold. Pinning removes it. */
    snprintf(body, blen, "{\"model\":\"%s\",\"keep_alive\":-1,\"prompt\":\"%s\"}",
             eg_embed_model(), esc);
    free(esc);
    struct curl_slist* h = curl_slist_append(NULL, "Content-Type: application/json");
    el_val_t resp = http_do_t("POST", eg_embed_url(), body, h,
                              ENGRAM_EMBED_TIMEOUT_MS);
    curl_slist_free_all(h);
    free(body);
    const char* r = EL_CSTR(resp);
    const char* arr = r ? strstr(r, "\"embedding\"") : NULL;
    if (!arr) {
        if (++_eg_embed_consec_fail >= ENGRAM_EMBED_BREAKER_LIMIT) {
            _eg_embed_breaker_until = now + ENGRAM_EMBED_BREAKER_COOLDOWN_MS;
            _eg_embed_consec_fail = 0;
        }
        return NULL;
    }
    arr = strchr(arr, '[');
    if (!arr) return NULL;
    arr++;
    int32_t cap = 1024, dim = 0;
    float* v = malloc((size_t)cap * sizeof(float));
    if (!v) return NULL;
    const char* p = arr;
    while (*p && *p != ']') {
        char* endp = NULL;
        double d = strtod(p, &endp);
        if (endp == p) break;
        if (dim >= cap) { break; } /* >1024 dims: refuse, model mismatch */
        v[dim++] = (float)d;
        p = endp;
        while (*p == ',' || *p == ' ' || *p == '\n') p++;
    }
    if (dim < 8) { free(v); return NULL; } /* junk response */
    _eg_embed_consec_fail = 0;
    *out_dim = dim;
    return v;
}

/* Node types that never receive embeddings: pure telemetry and structural
 * plumbing. Everything else (Knowledge, Memory, BacklogItem, Entity, ...)
 * is eligible. */
static int eg_embed_eligible(const EngramNode* n) {
    if (!n->content || strlen(n->content) < 8) return 0;
    if (!n->node_type) return 1;
    if (strcmp(n->node_type, "InternalStateEvent") == 0) return 0;
    if (strcmp(n->node_type, "Tag") == 0) return 0;
    return 1;
}

/* Parse a persisted comma-separated float list into node->emb. */
static void eg_parse_emb(EngramNode* nn, const char* s) {
    if (!s || !*s) return;
    int32_t cap = 1024, dim = 0;
    float* v = malloc((size_t)cap * sizeof(float));
    if (!v) return;
    const char* p = s;
    while (*p) {
        char* endp = NULL;
        double d = strtod(p, &endp);
        if (endp == p) break;
        if (dim >= cap) break;
        v[dim++] = (float)d;
        p = endp;
        while (*p == ',' || *p == ' ') p++;
    }
    if (dim < 8) { free(v); return; }
    nn->emb = v;
    nn->emb_dim = dim;
}

typedef struct EngramEdge {
    char*   id;
    char*   from_id;
    char*   to_id;
    char*   relation;
    char*   metadata;
    double  weight;
    /* Hebbian co-activation potentiation, learned at runtime and persisted.
     * Strictly separate from `weight`, which is authored and never mutated by
     * activation. Reads as "fraction of recent activation calls in which both
     * endpoints were in working memory together". See ENGRAM_HEBB_DECAY. */
    double  hebb;
    double  confidence;
    int64_t created_at;
    int64_t updated_at;
    int64_t last_fired;
    /* Inhibitory flag: when 1, activating the source node SUPPRESSES the
     * working_memory_weight of the target node rather than exciting it.
     * Models attentional inhibition: "I am focused on code work" creates
     * inhibitory edges to personal/emotional nodes, preventing them from
     * surfacing even if they have high background_activation. */
    int     inhibitory;
    /* Layered consciousness — edges carry a layer assignment for
     * categorization/visualization. Pass 2 inhibitory gating is decided by
     * the TARGET node's layer (whether it's suppressible), not by the edge
     * layer. Defaults to ENGRAM_LAYER_DEFAULT. */
    uint32_t layer_id;
} EngramEdge;

/* Layered consciousness — runtime layer registry entry. */
typedef struct EngramLayer {
    uint32_t layer_id;             /* 0 = deepest (safety/limbic) */
    char*    name;                 /* persistent — owned by the store */
    uint32_t activation_priority;  /* lower = fires earlier; safety = 0 */
    int      suppressible;         /* can higher layers suppress nodes here? */
    int      transparent;          /* invisible to introspection queries? */
    int      injectable;           /* can be added/removed at runtime? */
} EngramLayer;

/* ID → index hash map. Open-addressing with linear probing.
 * Slots hold a strdup'd key and the array index of that node.
 * Tombstones (deleted entries) use key=ENGRAM_IDMAP_TOMB and idx=-1.
 * Rebuild required after engram_forget (shift-delete changes all indices
 * above the deleted position). */
#define ENGRAM_IDMAP_TOMB ((char*)1)   /* sentinel pointer, never dereferenced */
#define ENGRAM_IDMAP_LOAD_NUM 3        /* grow when count*3 >= capacity*2     */
#define ENGRAM_IDMAP_LOAD_DEN 2

typedef struct {
    char*   key;   /* NULL = empty, ENGRAM_IDMAP_TOMB = deleted, else strdup'd */
    int64_t idx;
} EngramIdSlot;

typedef struct EngramStore {
    EngramNode*  nodes;
    int64_t      node_count;
    int64_t      node_capacity;
    EngramEdge*  edges;
    int64_t      edge_count;
    int64_t      edge_capacity;
    /* Layer registry — see engram_init_layers. The five canonical layers
     * are always present; injectable layers (imprint, suit) are extended
     * via engram_add_layer at runtime. layer_id values are assigned
     * monotonically; removed injectable layers leave a NULL `name` slot
     * (tombstone) so existing layer_id references on nodes stay stable. */
    EngramLayer* layers;
    size_t       layer_count;
    size_t       layer_capacity;
    /* O(1) node-id lookup: open-addressing hash map over node IDs.
     * Maintained in sync with the nodes array. Null until first use. */
    EngramIdSlot* id_map;
    size_t        id_map_cap;   /* power-of-2 slot count */
    size_t        id_map_used;  /* live entries (excluding tombstones) */
    /* Per-node adjacency index: for each node i, adj_from[i] lists edges
     * where nodes[i] is the 'from' end; adj_to[i] lists edges where it is
     * the 'to' end. Both store edge indices into g->edges[].
     * Rebuilt lazily via engram_adj_rebuild() before any BFS call. Set
     * adj_dirty=1 whenever an edge is added, deleted, or nodes shift. */
    int**    adj_from;      /* adj_from[node_idx] → int* array of edge indices */
    int*     adj_from_len;
    int**    adj_to;
    int*     adj_to_len;
    /* M7 (index-driven traversal, ENGRAM_STORE only): per-list allocated
     * capacity so single-edge/node mutations can APPEND to the adjacency in
     * amortized O(1) instead of forcing an O(E) full rebuild before the next
     * BFS. Flag-off never touches these (rebuild sets cap==len and no append
     * path runs), so flag-off behavior is byte-identical to before. */
    int*     adj_from_cap;
    int*     adj_to_cap;
    int      adj_dirty;     /* 1 = rebuild needed before next BFS */
    /* Number of node slots currently ALLOCATED in the adjacency arrays (== the
     * length of adj_from/adj_to/…). Invariant while the index is live
     * (adj_dirty==0 && adj_from!=NULL): adj_node_count >= node_count, and every
     * slot in [0,adj_node_count) is a valid (possibly NULL) list. */
    int64_t  adj_node_count;
    /* Nodes with degree >= 1 at last adj_rebuild. The denominator for the
     * fan-effect reference degree — see eg_fan_factor for why isolated nodes
     * must not be counted. (2026-08-11 self-review) [BIG-MERGE: union kept] */
    int64_t  adj_connected;
} EngramStore;

static EngramStore* engram_global = NULL;

/* Initialize the five canonical layers on a fresh store. Called once from
 * engram_get(). Layer ids 0..4 are reserved; runtime-injected imprint/suit
 * layers (engram_add_layer) get ids 5+. */
static void engram_init_layers(EngramStore* g) {
    g->layer_capacity = 16;
    g->layers = calloc(g->layer_capacity, sizeof(EngramLayer));
    if (!g->layers) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    g->layer_count = 0;

    /* Layer 0 — safety. Structural floor. Non-suppressible; transparent
     * (filtered out of introspection but still shapes output); not
     * runtime-injectable. */
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = ENGRAM_LAYER_SAFETY,
        .name = el_strdup_persist("safety"),
        .activation_priority = 0,
        .suppressible = 0,
        .transparent = 1,
        .injectable = 0
    };
    /* Layer 1 — core-identity. The default home for legacy nodes. */
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = ENGRAM_LAYER_CORE_IDENTITY,
        .name = el_strdup_persist("core-identity"),
        .activation_priority = 10,
        .suppressible = 1,
        .transparent = 0,
        .injectable = 0
    };
    /* Layer 2 — domain-knowledge. */
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = ENGRAM_LAYER_DOMAIN,
        .name = el_strdup_persist("domain-knowledge"),
        .activation_priority = 20,
        .suppressible = 1,
        .transparent = 0,
        .injectable = 0
    };
    /* Layer 3 — imprint. Injectable: an imprint package adds/removes this
     * layer (and the nodes assigned to it) as a unit. */
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = ENGRAM_LAYER_IMPRINT,
        .name = el_strdup_persist("imprint"),
        .activation_priority = 30,
        .suppressible = 1,
        .transparent = 0,
        .injectable = 1
    };
    /* Layer 4 — suit. Injectable: a Suit overlays domain skill (e.g.
     * "enterprise advisor", "divorce lawyer") and can be detached. */
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = ENGRAM_LAYER_SUIT,
        .name = el_strdup_persist("suit"),
        .activation_priority = 40,
        .suppressible = 1,
        .transparent = 0,
        .injectable = 1
    };
}

static EngramStore* engram_get(void) {
    if (engram_global) return engram_global;
    engram_global = calloc(1, sizeof(EngramStore));
    if (!engram_global) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    engram_global->node_capacity = 16;
    engram_global->nodes = calloc((size_t)engram_global->node_capacity, sizeof(EngramNode));
    engram_global->edge_capacity = 16;
    engram_global->edges = calloc((size_t)engram_global->edge_capacity, sizeof(EngramEdge));
    engram_init_layers(engram_global);
    return engram_global;
}

/* Resolve a layer record by id. Returns NULL if no layer with that id
 * exists (e.g. a removed injectable layer or a malformed snapshot). */
static EngramLayer* engram_find_layer(uint32_t layer_id) {
    EngramStore* g = engram_get();
    for (size_t i = 0; i < g->layer_count; i++) {
        EngramLayer* L = &g->layers[i];
        if (!L->name) continue; /* tombstone for removed injectable layer */
        if (L->layer_id == layer_id) return L;
    }
    return NULL;
}

/* Resolve a layer record by name. Returns NULL if not found. */
static EngramLayer* engram_find_layer_by_name(const char* name) {
    if (!name || !*name) return NULL;
    EngramStore* g = engram_get();
    for (size_t i = 0; i < g->layer_count; i++) {
        EngramLayer* L = &g->layers[i];
        if (!L->name) continue;
        if (strcmp(L->name, name) == 0) return L;
    }
    return NULL;
}

/* Allocate the next layer id. Skips ids that are still in use. */
static uint32_t engram_next_layer_id(void) {
    EngramStore* g = engram_get();
    uint32_t maxid = 0;
    for (size_t i = 0; i < g->layer_count; i++) {
        if (g->layers[i].layer_id > maxid) maxid = g->layers[i].layer_id;
    }
    return maxid + 1;
}

/* Whether a node in `layer_id` may be silenced by inhibitory edges in pass 2. */
static int engram_layer_is_suppressible(uint32_t layer_id) {
    EngramLayer* L = engram_find_layer(layer_id);
    if (!L) return 1; /* unknown layer → safe default: standard suppression */
    return L->suppressible ? 1 : 0;
}

/* Whether a layer is transparent (its content shapes output but is filtered
 * from introspection queries). Currently used to mark Layer 0 as invisible
 * to "what do you know about yourself" lookups while still letting it
 * dominate the prompt context. */
static int engram_layer_is_transparent(uint32_t layer_id) {
    EngramLayer* L = engram_find_layer(layer_id);
    if (!L) return 0;
    return L->transparent ? 1 : 0;
}

static int64_t engram_now_ms(void) {
    struct timeval tv; gettimeofday(&tv, NULL);
    return (int64_t)tv.tv_sec * 1000LL + (int64_t)tv.tv_usec / 1000LL;
}

/* Forward declaration: engram_find_node_index is defined after the id_map
 * helpers but called here. Without this, C99 -Wimplicit-function-declaration
 * treats the call as an implicit non-static declaration, then conflicts with
 * the later `static` definition. (2026-07-01 self-review: pre-existing) */
static int64_t engram_find_node_index(const char* id);

static EngramNode* engram_find_node(const char* id) {
    if (!id) return NULL;
    EngramStore* g = engram_get();
    int64_t idx = engram_find_node_index(id);
    if (idx >= 0) return &g->nodes[idx];
    return NULL;
}

/* ── ID hash map helpers ─────────────────────────────────────────────────────
 * Open-addressing, linear-probing hash map.  Keys are node-id C strings.
 * Values are int64_t indices into g->nodes[].
 *
 * Rules:
 *   - id_map is NULL until the first insertion (lazy init).
 *   - Capacity is always a power of two.
 *   - Load factor kept below 2/3: when used*3 >= cap*2, rehash to 2*cap.
 *   - Deletion uses ENGRAM_IDMAP_TOMB sentinels (key == (char*)1).
 *   - After engram_forget (shift-delete) the whole map is rebuilt from
 *     scratch because all indices above the deleted position change.
 */

static uint64_t engram_id_hash(const char* s) {
    /* FNV-1a 64-bit */
    uint64_t h = 14695981039346656037ULL;
    while (*s) { h ^= (unsigned char)*s++; h *= 1099511628211ULL; }
    return h;
}

/* Allocate a zeroed id_map of `cap` slots (cap must be power-of-two). */
static EngramIdSlot* engram_idmap_alloc(size_t cap) {
    return calloc(cap, sizeof(EngramIdSlot));
}

/* Low-level insert (no rehash check, no free of existing). Used during
 * rehash and initial build where we know the load is controlled. */
static void engram_idmap_put_raw(EngramIdSlot* map, size_t cap,
                                 char* key, int64_t idx) {
    size_t mask = cap - 1;
    size_t slot = (size_t)engram_id_hash(key) & mask;
    while (map[slot].key != NULL && map[slot].key != ENGRAM_IDMAP_TOMB) {
        slot = (slot + 1) & mask;
    }
    map[slot].key = key;
    map[slot].idx = idx;
}

/* Insert or update id → idx into the store's id_map. Rehashes if needed. */
static void engram_idmap_put(EngramStore* g, const char* id, int64_t idx) {
    if (!id || !*id) return;
    /* Lazy init */
    if (!g->id_map) {
        g->id_map_cap  = 64;
        g->id_map_used = 0;
        g->id_map = engram_idmap_alloc(g->id_map_cap);
        if (!g->id_map) return; /* OOM: fall back to linear scan */
    }
    /* Rehash if load factor would exceed 2/3 */
    if ((g->id_map_used + 1) * ENGRAM_IDMAP_LOAD_NUM >= g->id_map_cap * ENGRAM_IDMAP_LOAD_DEN) {
        size_t new_cap = g->id_map_cap * 2;
        EngramIdSlot* new_map = engram_idmap_alloc(new_cap);
        if (!new_map) return; /* OOM: keep old map, insert below */
        for (size_t s = 0; s < g->id_map_cap; s++) {
            if (g->id_map[s].key && g->id_map[s].key != ENGRAM_IDMAP_TOMB) {
                engram_idmap_put_raw(new_map, new_cap,
                                     g->id_map[s].key, g->id_map[s].idx);
            }
        }
        free(g->id_map);
        g->id_map     = new_map;
        g->id_map_cap = new_cap;
    }
    /* Probe for existing key or empty/tomb slot */
    size_t mask = g->id_map_cap - 1;
    size_t slot = (size_t)engram_id_hash(id) & mask;
    size_t tomb_slot = SIZE_MAX;
    while (g->id_map[slot].key != NULL) {
        if (g->id_map[slot].key == ENGRAM_IDMAP_TOMB) {
            if (tomb_slot == SIZE_MAX) tomb_slot = slot;
        } else if (strcmp(g->id_map[slot].key, id) == 0) {
            g->id_map[slot].idx = idx; /* update */
            return;
        }
        slot = (slot + 1) & mask;
    }
    /* Use tombstone slot if found (avoids growing used count unnecessarily) */
    if (tomb_slot != SIZE_MAX) slot = tomb_slot;
    /* 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++;
}

/* Look up id in the store's id_map. Returns index or -1 if not found. */
static int64_t engram_idmap_get(const EngramStore* g, const char* id) {
    if (!g->id_map || !id || !*id) return -1;
    size_t mask = g->id_map_cap - 1;
    size_t slot = (size_t)engram_id_hash(id) & mask;
    while (g->id_map[slot].key != NULL) {
        if (g->id_map[slot].key != ENGRAM_IDMAP_TOMB &&
            strcmp(g->id_map[slot].key, id) == 0) {
            return g->id_map[slot].idx;
        }
        slot = (slot + 1) & mask;
    }
    return -1;
}

/* Free and null-out the id_map (called on full reset). */
static void engram_idmap_free(EngramStore* g) {
    if (!g->id_map) return;
    for (size_t s = 0; s < g->id_map_cap; s++) {
        if (g->id_map[s].key && g->id_map[s].key != ENGRAM_IDMAP_TOMB)
            free(g->id_map[s].key);
    }
    free(g->id_map);
    g->id_map     = NULL;
    g->id_map_cap = 0;
    g->id_map_used = 0;
}

/* Rebuild id_map from scratch after a structural change (e.g. shift-delete).
 * Frees old map and constructs a fresh one. */
static void engram_idmap_rebuild(EngramStore* g) {
    engram_idmap_free(g);
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].id && *g->nodes[i].id)
            engram_idmap_put(g, g->nodes[i].id, i);
    }
}

/* ── M7 traversal instrumentation ────────────────────────────────────────────
 * Cumulative, process-lifetime counters that quantify the index-driven-traversal
 * win. Purely observational: they never influence activation. `edge_work` sums
 * the O(E) cost paid by full adjacency rebuilds; `incr_appends` counts the O(1)
 * amortized single-edge appends that replace those rebuilds when ENGRAM_STORE is
 * on. Exposed to test harnesses via the getters below. */
int64_t _eg_adj_rebuild_calls    = 0;
int64_t _eg_adj_rebuild_edge_work = 0;
int64_t _eg_adj_incr_appends     = 0;
double  _eg_adj_maint_ns         = 0.0;  /* wall-time in adjacency maintenance */
int64_t engram_adj_rebuild_calls(void)     { return _eg_adj_rebuild_calls; }
int64_t engram_adj_rebuild_edge_work(void) { return _eg_adj_rebuild_edge_work; }
int64_t engram_adj_incr_appends(void)      { return _eg_adj_incr_appends; }
double  engram_adj_maint_seconds(void)     { return _eg_adj_maint_ns * 1e-9; }
/* Test-only: force the next activation to fall back to a full O(E) rebuild,
 * so a harness can prove the incremental-index BFS is identical to the
 * rebuild-index BFS under one identical flag state. No production caller. */
void engram_adj_test_force_dirty(void)     { engram_get()->adj_dirty = 1; }
static double _eg_adj_now_ns(void) {
    struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec * 1e9 + (double)ts.tv_nsec;
}

/* ── Adjacency index helpers ─────────────────────────────────────────────────
 * Per-node adjacency lists: adj_from[i] holds edge indices where
 * g->edges[ei].from_id == g->nodes[i].id, adj_to[i] for the 'to' side.
 * BFS uses these instead of scanning all edges on every hop.
 * Full rebuild runs once per activation call when adj_dirty != 0. When
 * ENGRAM_STORE is on, single node/edge mutations instead APPEND to the live
 * index (engram_adj_on_node_added / engram_adj_on_edge_added) so the common
 * curiosity-loop cadence (add a few edges, then query) never pays the O(E)
 * rebuild — the index-driven-traversal milestone (M7).
 */
static void engram_adj_free(EngramStore* g) {
    int64_t old_nc = g->adj_node_count;
    if (g->adj_from) {
        for (int64_t i = 0; i < old_nc; i++) free(g->adj_from[i]);
        free(g->adj_from); g->adj_from = NULL;
        free(g->adj_from_len); g->adj_from_len = NULL;
        free(g->adj_from_cap); g->adj_from_cap = NULL;
    }
    if (g->adj_to) {
        for (int64_t i = 0; i < old_nc; i++) free(g->adj_to[i]);
        free(g->adj_to); g->adj_to = NULL;
        free(g->adj_to_len); g->adj_to_len = NULL;
        free(g->adj_to_cap); g->adj_to_cap = NULL;
    }
    g->adj_node_count = 0;
    g->adj_dirty = 1;
}

static void engram_adj_rebuild(EngramStore* g) {
    double _t0 = _eg_adj_now_ns();
    /* Free old adjacency arrays */
    if (g->adj_from) {
        /* Use adj_node_count (count at build time) not current node_count —
         * nodes may have been added since the last rebuild, and adj arrays
         * only have adj_node_count entries. */
        int64_t old_nc = g->adj_node_count;
        for (int64_t i = 0; i < old_nc; i++) {
            free(g->adj_from[i]); free(g->adj_to[i]);
        }
        free(g->adj_from); free(g->adj_from_len); free(g->adj_from_cap);
        free(g->adj_to);   free(g->adj_to_len);   free(g->adj_to_cap);
    }
    g->adj_from     = NULL; g->adj_from_len = NULL; g->adj_from_cap = NULL;
    g->adj_to       = NULL; g->adj_to_len   = NULL; g->adj_to_cap   = NULL;
    g->adj_node_count = 0;
    if (g->node_count == 0) { g->adj_dirty = 0; return; }

    /* Count degree per node */
    int* from_cnt = calloc((size_t)g->node_count, sizeof(int));
    int* to_cnt   = calloc((size_t)g->node_count, sizeof(int));
    if (!from_cnt || !to_cnt) { free(from_cnt); free(to_cnt); return; }
    for (int64_t ei = 0; ei < g->edge_count; ei++) {
        EngramEdge* e = &g->edges[ei];
        if (!e->from_id || !e->to_id) continue;
        int64_t fi = engram_idmap_get(g, e->from_id);
        int64_t ti = engram_idmap_get(g, e->to_id);
        if (fi >= 0) from_cnt[fi]++;
        if (ti >= 0) to_cnt[ti]++;
    }
    /* Allocate per-node arrays */
    g->adj_from     = calloc((size_t)g->node_count, sizeof(int*));
    g->adj_from_len = calloc((size_t)g->node_count, sizeof(int));
    g->adj_from_cap = calloc((size_t)g->node_count, sizeof(int));
    g->adj_to       = calloc((size_t)g->node_count, sizeof(int*));
    g->adj_to_len   = calloc((size_t)g->node_count, sizeof(int));
    g->adj_to_cap   = calloc((size_t)g->node_count, sizeof(int));
    if (!g->adj_from || !g->adj_from_len || !g->adj_from_cap ||
        !g->adj_to || !g->adj_to_len || !g->adj_to_cap) {
        free(from_cnt); free(to_cnt);
        free(g->adj_from); g->adj_from = NULL;
        free(g->adj_from_len); g->adj_from_len = NULL;
        free(g->adj_from_cap); g->adj_from_cap = NULL;
        free(g->adj_to); g->adj_to = NULL;
        free(g->adj_to_len); g->adj_to_len = NULL;
        free(g->adj_to_cap); g->adj_to_cap = NULL;
        return;
    }
    for (int64_t i = 0; i < g->node_count; i++) {
        if (from_cnt[i] > 0)
            g->adj_from[i] = malloc((size_t)from_cnt[i] * sizeof(int));
        if (to_cnt[i] > 0)
            g->adj_to[i]   = malloc((size_t)to_cnt[i]   * sizeof(int));
    }
    /* Fill */
    int* from_pos = calloc((size_t)g->node_count, sizeof(int));
    int* to_pos   = calloc((size_t)g->node_count, sizeof(int));
    if (!from_pos || !to_pos) {
        free(from_cnt); free(to_cnt); free(from_pos); free(to_pos); return;
    }
    for (int64_t ei = 0; ei < g->edge_count; ei++) {
        EngramEdge* e = &g->edges[ei];
        if (!e->from_id || !e->to_id) continue;
        int64_t fi = engram_idmap_get(g, e->from_id);
        int64_t ti = engram_idmap_get(g, e->to_id);
        if (fi >= 0 && g->adj_from[fi])
            g->adj_from[fi][from_pos[fi]++] = (int)ei;
        if (ti >= 0 && g->adj_to[ti])
            g->adj_to[ti][to_pos[ti]++] = (int)ei;
    }
    /* Copy counts. cap == len after a fresh rebuild: the arrays are exactly
     * sized, so the first incremental append to any list will grow it. Also
     * tally how many nodes have any edge at all — the fan-effect denominator.
     * Free here, in the O(V) pass that already exists. (BIG-MERGE: union of the
     * m10 incremental-append caps + the 2026-08-11 fan-effect tally.) */
    int64_t connected = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        g->adj_from_len[i] = from_cnt[i];
        g->adj_to_len[i]   = to_cnt[i];
        g->adj_from_cap[i] = from_cnt[i];
        g->adj_to_cap[i]   = to_cnt[i];
        if (from_cnt[i] + to_cnt[i] > 0) connected++;
    }
    g->adj_connected = connected;
    free(from_cnt); free(to_cnt); free(from_pos); free(to_pos);
    g->adj_node_count = g->node_count;
    g->adj_dirty = 0;
    /* M7 instrumentation (test-only counters; no behavioral effect). */
    _eg_adj_rebuild_calls++;
    _eg_adj_rebuild_edge_work += g->edge_count;
    _eg_adj_maint_ns += _eg_adj_now_ns() - _t0;
}

static int64_t engram_find_node_index(const char* id) {
    if (!id) return -1;
    EngramStore* g = engram_get();
    /* Fast O(1) path via id_map */
    int64_t fast = engram_idmap_get(g, id);
    if (fast >= 0) return fast;
    /* Fallback linear scan (id_map not yet built or OOM) */
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].id && strcmp(g->nodes[i].id, id) == 0) return i;
    }
    return -1;
}

static void engram_grow_nodes(void) {
    EngramStore* g = engram_get();
    if (g->node_count < g->node_capacity) return;
    int64_t nc = g->node_capacity * 2;
    g->nodes = realloc(g->nodes, (size_t)nc * sizeof(EngramNode));
    if (!g->nodes) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    memset(g->nodes + g->node_capacity, 0,
           (size_t)(nc - g->node_capacity) * sizeof(EngramNode));
    g->node_capacity = nc;
}

static void engram_grow_edges(void) {
    EngramStore* g = engram_get();
    if (g->edge_count < g->edge_capacity) return;
    int64_t nc = g->edge_capacity * 2;
    g->edges = realloc(g->edges, (size_t)nc * sizeof(EngramEdge));
    if (!g->edges) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
    memset(g->edges + g->edge_capacity, 0,
           (size_t)(nc - g->edge_capacity) * sizeof(EngramEdge));
    g->edge_capacity = nc;
}

/* 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_persist(s ? s : "");
}

/* Convert a node into an ElMap of its fields.                            */
static el_val_t engram_node_to_map(const EngramNode* n) {
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("id")),         EL_STR(el_strdup(n->id ? n->id : "")));
    m = el_map_set(m, EL_STR(el_strdup("content")),    EL_STR(el_strdup(n->content ? n->content : "")));
    m = el_map_set(m, EL_STR(el_strdup("node_type")),  EL_STR(el_strdup(n->node_type ? n->node_type : "")));
    m = el_map_set(m, EL_STR(el_strdup("label")),      EL_STR(el_strdup(n->label ? n->label : "")));
    m = el_map_set(m, EL_STR(el_strdup("tier")),       EL_STR(el_strdup(n->tier ? n->tier : "Working")));
    m = el_map_set(m, EL_STR(el_strdup("tags")),       EL_STR(el_strdup(n->tags ? n->tags : "")));
    m = el_map_set(m, EL_STR(el_strdup("metadata")),   EL_STR(el_strdup(n->metadata ? n->metadata : "{}")));
    m = el_map_set(m, EL_STR(el_strdup("salience")),            el_from_float(n->salience));
    m = el_map_set(m, EL_STR(el_strdup("importance")),           el_from_float(n->importance));
    m = el_map_set(m, EL_STR(el_strdup("confidence")),           el_from_float(n->confidence));
    m = el_map_set(m, EL_STR(el_strdup("temporal_decay_rate")),   el_from_float(n->temporal_decay_rate));
    m = el_map_set(m, EL_STR(el_strdup("activation_count")),      (el_val_t)n->activation_count);
    m = el_map_set(m, EL_STR(el_strdup("last_activated")),        (el_val_t)n->last_activated);
    m = el_map_set(m, EL_STR(el_strdup("created_at")),            (el_val_t)n->created_at);
    m = el_map_set(m, EL_STR(el_strdup("updated_at")),            (el_val_t)n->updated_at);
    m = el_map_set(m, EL_STR(el_strdup("background_activation")), el_from_float(n->background_activation));
    m = el_map_set(m, EL_STR(el_strdup("working_memory_weight")), el_from_float(n->working_memory_weight));
    m = el_map_set(m, EL_STR(el_strdup("suppression_count")),     (el_val_t)n->suppression_count);
    m = el_map_set(m, EL_STR(el_strdup("layer_id")),              (el_val_t)(int64_t)n->layer_id);
    /* Observability (2026-07-22): expose the current ACT-R base-level and
     * promotion anchor so heartbeat ISEs / API consumers can see decay state. */
    m = el_map_set(m, EL_STR(el_strdup("wm_anchor")),             el_from_float(n->wm_anchor));
    m = el_map_set(m, EL_STR(el_strdup("base_level")),            el_from_float(engram_bll_base_level(n, engram_now_ms())));
    /* emb_dim only — the vector itself is too large for map/API output.
     * 0 = not yet embedded by the lazy backfill. (2026-07-24) */
    m = el_map_set(m, EL_STR(el_strdup("emb_dim")),               (el_val_t)(int64_t)n->emb_dim);
    return m;
}

/* (Node JSON serialization is provided by `engram_emit_node_json` further
 * down in the persistence section — reused by the *_json builtins below.) */
static void engram_emit_node_json(JsonBuf* b, const EngramNode* n, int include_emb);
static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e);

/* Salience may arrive either as a float bit-pattern or as a small integer
 * (e.g. 1, meaning 1.0). Heuristic: if interpreted as double it's in
 * [0.0, 100.0] use it; otherwise treat as int and convert. */
static double engram_decode_score(el_val_t v) {
    double f = el_to_float(v);
    if (!isnan(f) && !isinf(f) && f >= 0.0 && f <= 100.0) return f;
    int64_t n = (int64_t)v;
    return (double)n;
}

/* Truncate to at most n BYTES without splitting a UTF-8 codepoint. The old
 * implementation was `if (l > n) l = n;` — a byte cut that could land inside a
 * multi-byte character and emit a half codepoint into the node's label, which
 * then propagated into every JSON document containing that node. See
 * el_utf8_safe_len for the measurement. */
static char* engram_first_n_chars(const char* s, size_t n) {
    if (!s) return el_strdup("");
    size_t l = el_utf8_safe_len(s, n);
    char* out = el_strbuf(l);
    memcpy(out, s, l);
    out[l] = '\0';
    return out;
}

/* ══════════════════════════════════════════════════════════════════════════
 *  M3 — ENGRAM_STORE glue (CALLER side of the libengram ABI; design §10).
 *
 *  The engine (engram_store.{c,h}) has ZERO soul dependencies and never sees an
 *  EngramNode/EngramEdge or a soul global. ALL mapping between the live runtime
 *  structs and the engine's StoreNode/StoreEdge views lives HERE, on the caller
 *  side of the C ABI. That is what keeps a standalone `engramd` a later additive
 *  choice rather than a fork.
 *
 *  Behind the ENGRAM_STORE env flag (default OFF):
 *    OFF (unset / "0" / "off") — every hook below early-returns; the paged store
 *      is never opened or written and no store code is reached. The runtime keeps
 *      EXACTLY today's JSON-snapshot behavior, byte-for-byte.
 *    ON  ("1" / "on" / "true") — engram_store_boot() imports snapshot.json ONCE
 *      into neuron.egm (or replays neuron.wal), loads the WHOLE store resident in
 *      RAM (Phase 1: no demand paging — that is M4), and every structural
 *      mutation (node/edge create, forget) is mirrored through the store's
 *      WAL-logged API so neuron.egm/neuron.wal stay authoritative.
 * ══════════════════════════════════════════════════════════════════════════ */
#include "engram_text.h"   /* text: tokenize, token hygiene, loss signature */
#include "engram_store.h"
#include "engram_vindex.h"   /* M8: ANN (HNSW) index for activation seed selection */
#include "engram_geometry.h" /* M9: centered relational-neighborhood geometry (priming) */
#include "engram_reason.h"   /* reasoning layer: compositions over the §5 operators   */
#include "engram_verify.h"   /* verifier: grounding/consistency (turned inward live)  */
#include "engram_cognition.h"/* THE ONE OPERATION: think + Stance + correspondence-loop*/

/* M10 REIFICATION: resident loaded form of the first-class persisted neighborhood
 * records (Neighborhood + GeoMeanFrame). Built once at boot from the durable store
 * (it PARSES persisted structure, never recomputes geometry). The geometry-priming
 * hot path reads THIS instead of computing a per-query descriptor. NULL until boot;
 * empty (count 0) on a store that has not been reified — priming then no-ops. */
static GeoReifyIndex* _eg_reify = NULL;

static EngramPagedStore* g_engram_store = NULL;

int engram_store_enabled(void) {
    const char* f = getenv("ENGRAM_STORE");
    return (f && (strcmp(f, "1") == 0 || strcmp(f, "on") == 0 ||
                  strcmp(f, "true") == 0)) ? 1 : 0;
}

/* ── M7: incremental adjacency maintenance (index-driven traversal) ───────────
 *
 * When ENGRAM_STORE is on, a single node/edge create keeps the already-built
 * per-node adjacency index live by APPENDING to it, instead of marking it dirty
 * and forcing the next activation to rebuild all O(E) adjacency lists from
 * scratch. The result the BFS consumes is byte-identical to a full rebuild:
 *   - Edges are only ever appended to g->edges[], so their indices increase
 *     monotonically; appending in creation order reproduces the exact ascending
 *     edge-index ordering a rebuild's ei-ascending scan produces.
 *   - The same skip rule as rebuild applies: an edge with a NULL endpoint id
 *     contributes to NEITHER list.
 *   - Deletes/shifts (forget, prune, clear) still free the index and set
 *     adj_dirty=1, so any index-invalidating mutation falls back to a full
 *     rebuild. The append path only runs while the index is live and clean.
 * Flag-off never reaches these helpers: the mutation sites call
 * engram_adj_on_{node,edge}_added, which for flag-off simply set adj_dirty=1 —
 * exactly the previous behavior, byte-for-byte. */

/* Grow the adjacency arrays so index `need`-1 is addressable. Preserves all
 * existing lists; new slots are zeroed (NULL list, len 0, cap 0). Sets
 * adj_node_count to the new allocated length so engram_adj_free frees exactly
 * the slots that exist. Returns 0 on OOM (caller falls back to a full rebuild
 * by setting adj_dirty). */
static int engram_adj_grow_slots(EngramStore* g, int64_t need) {
    if (need <= g->adj_node_count) return 1;
    int64_t nc = g->adj_node_count ? g->adj_node_count : 8;
    while (nc < need) nc *= 2;
    int**  nf  = realloc(g->adj_from,     (size_t)nc * sizeof(int*));
    int*   nfl = realloc(g->adj_from_len, (size_t)nc * sizeof(int));
    int*   nfc = realloc(g->adj_from_cap, (size_t)nc * sizeof(int));
    int**  nt  = realloc(g->adj_to,       (size_t)nc * sizeof(int*));
    int*   ntl = realloc(g->adj_to_len,   (size_t)nc * sizeof(int));
    int*   ntc = realloc(g->adj_to_cap,   (size_t)nc * sizeof(int));
    if (nf)  g->adj_from     = nf;
    if (nfl) g->adj_from_len = nfl;
    if (nfc) g->adj_from_cap = nfc;
    if (nt)  g->adj_to       = nt;
    if (ntl) g->adj_to_len   = ntl;
    if (ntc) g->adj_to_cap   = ntc;
    if (!nf || !nfl || !nfc || !nt || !ntl || !ntc) return 0;
    for (int64_t i = g->adj_node_count; i < nc; i++) {
        g->adj_from[i] = NULL; g->adj_from_len[i] = 0; g->adj_from_cap[i] = 0;
        g->adj_to[i]   = NULL; g->adj_to_len[i]   = 0; g->adj_to_cap[i]   = 0;
    }
    g->adj_node_count = nc;
    return 1;
}

/* Append edge index `ei` to the list at (*arr,*len,*cap), doubling capacity as
 * needed. Returns 0 on OOM. */
static int engram_adj_list_push(int** arr, int* len, int* cap, int ei) {
    if (*len >= *cap) {
        int ncap = *cap ? *cap * 2 : 2;
        int* na = realloc(*arr, (size_t)ncap * sizeof(int));
        if (!na) return 0;
        *arr = na; *cap = ncap;
    }
    (*arr)[(*len)++] = ei;
    return 1;
}

/* Append the freshly-created edge g->edges[ei] to the live adjacency index.
 * Mirrors engram_adj_rebuild's per-edge classification exactly. Returns 0 on
 * OOM (caller forces a rebuild). */
static int engram_adj_add_edge(EngramStore* g, int64_t ei) {
    if (ei < 0 || ei >= g->edge_count) return 1;
    EngramEdge* e = &g->edges[ei];
    if (!e->from_id || !e->to_id) return 1;   /* same skip rule as rebuild */
    double _t0 = _eg_adj_now_ns();
    int64_t fi = engram_idmap_get(g, e->from_id);
    int64_t ti = engram_idmap_get(g, e->to_id);
    int64_t hi = (fi > ti) ? fi : ti;
    if (hi >= 0 && !engram_adj_grow_slots(g, hi + 1)) return 0;
    if (fi >= 0 && !engram_adj_list_push(&g->adj_from[fi], &g->adj_from_len[fi],
                                         &g->adj_from_cap[fi], (int)ei)) return 0;
    if (ti >= 0 && !engram_adj_list_push(&g->adj_to[ti], &g->adj_to_len[ti],
                                         &g->adj_to_cap[ti], (int)ei)) return 0;
    _eg_adj_incr_appends++;
    _eg_adj_maint_ns += _eg_adj_now_ns() - _t0;
    return 1;
}

/* Mutation-site hook for a newly-appended node at the current top index. When
 * the index is live and clean under ENGRAM_STORE, reserve its (empty) adjacency
 * slot so a later BFS that seeds this node can index adj_*_len[idx] safely
 * without a rebuild. Otherwise defer to the lazy full rebuild (flag-off path,
 * or index not yet built / already dirty). */
static void engram_adj_on_node_added(EngramStore* g) {
    if (engram_store_enabled() && g->adj_from && !g->adj_dirty) {
        if (engram_adj_grow_slots(g, g->node_count)) return;
    }
    g->adj_dirty = 1;   /* flag-off, or OOM/not-built: fall back to rebuild */
}

/* Mutation-site hook for the newly-appended edge at index g->edge_count-1. */
static void engram_adj_on_edge_added(EngramStore* g, int64_t ei) {
    if (engram_store_enabled() && g->adj_from && !g->adj_dirty) {
        if (engram_adj_add_edge(g, ei)) return;
    }
    g->adj_dirty = 1;   /* flag-off, or OOM/not-built: fall back to rebuild */
}

/* EngramNode → borrowed StoreNode view (no ownership transfer; the store copies
 * every field it persists, so shared string pointers are safe). */
static void eg_node_to_store(const EngramNode* n, StoreNode* sn) {
    memset(sn, 0, sizeof *sn);
    sn->id = n->id; sn->content = n->content; sn->node_type = n->node_type;
    sn->label = n->label; sn->tier = n->tier; sn->tags = n->tags;
    sn->metadata = n->metadata;
    sn->salience = n->salience; sn->importance = n->importance;
    sn->confidence = n->confidence; sn->temporal_decay_rate = n->temporal_decay_rate;
    sn->activation_count = n->activation_count; sn->last_activated = n->last_activated;
    sn->created_at = n->created_at; sn->updated_at = n->updated_at;
    sn->background_activation = n->background_activation;
    sn->working_memory_weight = n->working_memory_weight;
    sn->suppression_count = n->suppression_count; sn->layer_id = n->layer_id;
    for (int i = 0; i < STORE_BLL_K && i < ENGRAM_BLL_K; i++)
        sn->access_ts[i] = n->access_ts[i];
    sn->access_head = n->access_head; sn->access_filled = n->access_filled;
    sn->wm_anchor = n->wm_anchor; sn->emb = n->emb; sn->emb_dim = n->emb_dim;
}
static void eg_edge_to_store(const EngramEdge* e, StoreEdge* se) {
    memset(se, 0, sizeof *se);
    se->id = e->id; se->from_id = e->from_id; se->to_id = e->to_id;
    se->relation = e->relation; se->metadata = e->metadata;
    se->weight = e->weight; se->hebb = e->hebb; se->confidence = e->confidence;
    se->created_at = e->created_at; se->updated_at = e->updated_at;
    se->last_fired = e->last_fired; se->inhibitory = e->inhibitory;
    se->layer_id = e->layer_id;
}

/* Structural-mutation hooks. Callers guard with `if (engram_store_enabled())`;
 * these also null-check g_engram_store so a mutation before boot is a safe no-op. */
static void eg_store_put_node(const EngramNode* n) {
    if (!g_engram_store || !n || !n->id) return;
    StoreNode sn; eg_node_to_store(n, &sn);
    store_put_node(g_engram_store, &sn);
}
static void eg_store_put_edge(const EngramEdge* e) {
    if (!g_engram_store || !e || !e->id) return;
    StoreEdge se; eg_edge_to_store(e, &se);
    store_put_edge(g_engram_store, &se);
}

/* Resident-load callbacks: StoreNode/StoreEdge → a fresh EngramNode/EngramEdge
 * appended to the in-RAM graph. Mirrors engram_load's field set. The boot-time
 * WM laundering (halve + floor + global cap) that engram_load applies is done
 * once, after all nodes are loaded, in engram_store_boot — see the block there —
 * so the store-on boot behaves byte-identically to the JSON path (M3.5 parity). */
static void eg_load_node_cb(const StoreNode* sn, void* ctx) {
    EngramStore* g = (EngramStore*)ctx;
    /* M10: first-class reified records (Neighborhood / GeoMeanFrame) are DURABLE
     * STRUCTURE, not corpus content. Absorb them into the reify index and keep them
     * OUT of the resident activation graph, so seed selection / vindex / results /
     * embedding backfill are byte-identical to a store that was never reified. */
    if (sn->node_type &&
        (strcmp(sn->node_type, ENGRAM_GEO_NBHD_TYPE) == 0 ||
         strcmp(sn->node_type, ENGRAM_GEO_MEANFRAME_TYPE) == 0)) {
        if (_eg_reify) engram_geo_reify_index_add(_eg_reify, sn);
        return;
    }
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof *n);
    n->id        = el_strdup_persist(sn->id ? sn->id : "");
    n->content   = el_strdup_persist(sn->content ? sn->content : "");
    n->node_type = el_strdup_persist(sn->node_type && *sn->node_type ? sn->node_type : "Memory");
    n->label     = el_strdup_persist(sn->label ? sn->label : "");
    n->tier      = el_strdup_persist(sn->tier && *sn->tier ? sn->tier : "Working");
    n->tags      = el_strdup_persist(sn->tags ? sn->tags : "");
    n->metadata  = el_strdup_persist(sn->metadata && *sn->metadata ? sn->metadata : "{}");
    n->salience = sn->salience; n->importance = sn->importance;
    n->confidence = sn->confidence; n->temporal_decay_rate = sn->temporal_decay_rate;
    n->activation_count = sn->activation_count; n->last_activated = sn->last_activated;
    n->created_at = sn->created_at; n->updated_at = sn->updated_at;
    n->background_activation = sn->background_activation;
    n->working_memory_weight = sn->working_memory_weight;
    n->suppression_count = sn->suppression_count; n->layer_id = sn->layer_id;
    for (int i = 0; i < STORE_BLL_K && i < ENGRAM_BLL_K; i++)
        n->access_ts[i] = sn->access_ts[i];
    n->access_head = sn->access_head; n->access_filled = sn->access_filled;
    n->wm_anchor = sn->wm_anchor;
    if (sn->emb && sn->emb_dim > 0) {
        n->emb = malloc(sizeof(float) * (size_t)sn->emb_dim);
        if (n->emb) { memcpy(n->emb, sn->emb, sizeof(float) * (size_t)sn->emb_dim);
                      n->emb_dim = sn->emb_dim; }
    }
    int64_t idx = g->node_count; g->node_count++;
    if (n->id && *n->id) engram_idmap_put(g, n->id, idx);
}
static void eg_load_edge_cb(const StoreEdge* se, void* ctx) {
    EngramStore* g = (EngramStore*)ctx;
    /* M10: skip the persisted member links (from a "nbhd-…" record). They are
     * durable structure joining a neighborhood to its members, but inert to
     * activation adjacency — dropping them here keeps spreading activation
     * byte-identical to pre-reify. (Matched by id convention, so no real edge,
     * whatever its relation string, is ever affected.) */
    if (se->from_id && strncmp(se->from_id, ENGRAM_GEO_NBHD_ID_PREFIX, 5) == 0) return;
    engram_grow_edges();
    EngramEdge* e = &g->edges[g->edge_count];
    memset(e, 0, sizeof *e);
    e->id       = el_strdup_persist(se->id ? se->id : "");
    e->from_id  = el_strdup_persist(se->from_id ? se->from_id : "");
    e->to_id    = el_strdup_persist(se->to_id ? se->to_id : "");
    e->relation = el_strdup_persist(se->relation && *se->relation ? se->relation : "associate");
    e->metadata = el_strdup_persist(se->metadata && *se->metadata ? se->metadata : "{}");
    e->weight = se->weight; e->hebb = se->hebb; e->confidence = se->confidence;
    e->created_at = se->created_at; e->updated_at = se->updated_at;
    e->last_fired = se->last_fired; e->inhibitory = se->inhibitory;
    e->layer_id = se->layer_id;
    g->edge_count++;
}
static void eg_load_layer_cb(EngramStore* g, const StoreLayer* L) {
    if (!L->name) return;
    for (size_t i = 0; i < g->layer_count; i++)          /* upsert by id */
        if (g->layers[i].layer_id == L->layer_id) return; /* canonical already seeded */
    if (g->layer_count >= g->layer_capacity) {
        size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
        EngramLayer* nl = realloc(g->layers, nc * sizeof(EngramLayer));
        if (!nl) return;
        g->layers = nl; g->layer_capacity = nc;
    }
    g->layers[g->layer_count++] = (EngramLayer){
        .layer_id = L->layer_id,
        .name = el_strdup_persist(L->name),
        .activation_priority = L->activation_priority,
        .suppressible = L->suppressible,
        .transparent = L->transparent,
        .injectable = L->injectable
    };
}

/* Clear the resident graph so the store becomes the sole source of truth on boot
 * (mirrors engram_load's reset). */
static void eg_reset_resident(EngramStore* g) {
    for (int64_t i = 0; i < g->node_count; i++) {
        free(g->nodes[i].id); free(g->nodes[i].content); free(g->nodes[i].node_type);
        free(g->nodes[i].label); free(g->nodes[i].tier); free(g->nodes[i].tags);
        free(g->nodes[i].metadata);
        free(g->nodes[i].emb); g->nodes[i].emb = NULL; g->nodes[i].emb_dim = 0;
    }
    g->node_count = 0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        free(g->edges[i].id); free(g->edges[i].from_id); free(g->edges[i].to_id);
        free(g->edges[i].relation); free(g->edges[i].metadata);
    }
    g->edge_count = 0;
    engram_idmap_free(g);
    engram_adj_free(g);
}

/* Defined later with engram_load; declared here for the boot-time WM laundering
 * that keeps the store-on boot byte-identical to the JSON path (M3.5 parity). */
static void eg_enforce_wm_cap_on_load(EngramStore* g);

/* engram_store_boot(data_dir) — open (import-once or WAL-replay) the durable
 * paged store and load it whole into RAM (Phase 1). No-op / returns 0 when the
 * flag is off. Returns 1 on success. Idempotent (a second call is a no-op). */
el_val_t engram_store_boot(el_val_t data_dir) {
    if (!engram_store_enabled()) return (el_val_t)0;
    if (g_engram_store) return (el_val_t)1;
    const char* d = EL_CSTR(data_dir);
    if (!d || !*d) return (el_val_t)0;
    g_engram_store = engram_open(d);
    if (!g_engram_store) return (el_val_t)0;
    EngramStore* g = engram_get();
    eg_reset_resident(g);
    /* M10: build the resident reify index alongside the graph load — eg_load_node_cb
     * feeds the first-class Neighborhood/GeoMeanFrame records into it. */
    if (!_eg_reify) _eg_reify = engram_geo_reify_index_new();
    store_scan_nodes(g_engram_store, eg_load_node_cb, g);
    store_scan_edges(g_engram_store, eg_load_edge_cb, g);
    if (_eg_reify) engram_geo_reify_index_finalize(_eg_reify);
    StoreLayer* ls = NULL; size_t ln = 0;
    if (store_list_layers(g_engram_store, &ls, &ln) == 0) {
        for (size_t i = 0; i < ln; i++) eg_load_layer_cb(g, &ls[i]);
        store_layers_free(ls, ln);
    }
    /* Boot-time WM laundering — MUST match engram_load exactly (M3.5 parity).
     * The JSON load path halves every persisted working_memory_weight on boot
     * (stale pinned weights decay out over successive restarts; genuine WM state
     * keeps continuity) and floors sub-ENGRAM_WM_FLOOR residue to zero, then
     * enforces the global WM cap. The store now persists post-activation WM
     * weights, so the store-on boot must apply the identical transform or the two
     * persistence paths would diverge on the very first restart. */
    for (int64_t i = 0; i < g->node_count; i++) {
        g->nodes[i].working_memory_weight *= 0.5;
        if (g->nodes[i].working_memory_weight < ENGRAM_WM_FLOOR)
            g->nodes[i].working_memory_weight = 0.0;
    }
    eg_enforce_wm_cap_on_load(g);
    g->adj_dirty = 1;
    return (el_val_t)1;
}

/* engram_store_checkpoint() — M3.5 PRE-FLIP GATE.
 *
 * Spreading activation mutates fields IN PLACE on the resident graph — edge
 * `hebb`/`last_fired` (potentiation + homeostatic scaling), node
 * `activation_count`/`last_activated`/`working_memory_weight`/`wm_anchor`
 * (reinforcement + WM caps) — and also FORMS brand-new `hebbian-associate`
 * edges. M3 only mirrored node/edge *creates* and *forgets*; none of those
 * in-place mutations or activation-formed edges reached the paged store, so
 * learned associations were lost on every restart. This is the fix that makes
 * "learned edges survive a restart" true, and it gates the live cutover.
 *
 * On the soul's save/checkpoint path we push the resident graph's current field
 * state through the store's WAL-logged API, then checkpoint:
 *   - store_put_node(n) for every node  → persists WM weight, activation_count,
 *     last_activated, wm_anchor, the base-level access ring, etc.
 *   - store_put_edge(e) for every edge  → persists hebb + last_fired AND creates
 *     any activation-formed edges. (store_hebb_batch is deliberately NOT used: it
 *     is a delta-only op that skips edge ids not already resident in the store —
 *     see apply_hebb_batch — so it cannot persist the newly-formed hebbian edges
 *     that are the whole point of this milestone. store_put_edge is the idempotent
 *     upsert that subsumes the hebb delta.)
 * then engram_checkpoint flushes dirty pages + advances the checkpoint LSN. Every
 * push is a WAL record, so a graceful restart restores them.
 *
 * Approach: a FULL WALK of the resident graph (not a dirty-set). At Phase-1
 * ~64 MB resident this is a cheap linear pass on an already-in-RAM array, run at
 * the soul's save cadence (seconds-to-minutes), and it is trivially complete —
 * no mutation site can be missed and no separate new-edge tracking is needed. An
 * inter-checkpoint crash loses only the most-recent unsaved learning, exactly the
 * same durability envelope as today's JSON-snapshot cadence.
 *
 * The store→JSON export path stays engram_save (the JSON is an export artifact). */
el_val_t engram_store_checkpoint(void) {
    if (!engram_store_enabled() || !g_engram_store) return (el_val_t)0;
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) eg_store_put_node(&g->nodes[i]);
    for (int64_t i = 0; i < g->edge_count; i++) eg_store_put_edge(&g->edges[i]);
    return (el_val_t)(int64_t)(engram_checkpoint(g_engram_store) == 0 ? 1 : 0);
}

/* engram_store_close() — checkpoint + close (used at shutdown / by tests). */
el_val_t engram_store_close(void) {
    if (!g_engram_store) return (el_val_t)0;
    int r = engram_close(g_engram_store);
    g_engram_store = NULL;
    return (el_val_t)(int64_t)(r == 0 ? 1 : 0);
}

el_val_t engram_node(el_val_t content, el_val_t node_type, el_val_t salience) {
    EngramStore* g = engram_get();
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof(*n));
    n->id        = engram_new_id();
    const char* c  = EL_CSTR(content);
    const char* nt = EL_CSTR(node_type);
    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;
    n->confidence  = 1.0;
    n->temporal_decay_rate = 0.0; /* 0 = use global default ENGRAM_DECAY_LAMBDA */
    n->activation_count = 0;
    int64_t now = engram_now_ms();
    n->last_activated = now;
    n->created_at = now;
    n->updated_at = now;
    n->layer_id   = ENGRAM_LAYER_DEFAULT;
    int64_t new_idx = g->node_count;
    g->node_count++;
    engram_idmap_put(g, n->id, new_idx);
    engram_adj_on_node_added(g);
    if (engram_store_enabled()) eg_store_put_node(n);
    return el_wrap_str(el_strdup(n->id));
}

/* ── Text-integrity instrumentation (2026-08-08 self-review) ───────────────
 *
 * WHY THIS EXISTS. The JSON parser silently replaced every \uXXXX escape with
 * '?' for at least two months (see jp_parse_string_raw). 3,119 of 4,081
 * non-telemetry nodes — 76%, including the self traversal root and all 13
 * values nodes — were damaged before anything noticed, and nothing noticed
 * because nothing measured. Working memory, Hebbian potentiation, embedding
 * coverage, sync age, and the breaker were all instrumented to four decimal
 * places; the actual TEXT was not instrumented at all. Every gauge answered
 * "is the machinery running" and none answered "is what it carries intact."
 *
 * The damage is unrecoverable in place (a 3-byte codepoint collapses to one
 * byte), and no snapshot on disk predates it, so this cannot be undone. What
 * it can be is *impossible to repeat quietly*. Two numbers, split by the
 * question each answers:
 *
 *   stock — engram_text_health_json(), a full O(total bytes) census. Too
 *           expensive for the 60s heartbeat, exactly right for the daily
 *           self-review. Answers "how much damage is in the store".
 *   flow  — _eg_txt_write_damaged, incremented per damaged node at creation.
 *           O(len) on a path that already copies the string, so it is free.
 *           Rides the heartbeat. Answers "is a write path damaging things
 *           RIGHT NOW" — which is the regression question, and the one that
 *           would have caught this in a day instead of two months.
 *
 * The SIGNATURE itself (eg_text_loss_signature) moved to engram_text.c on
 * 2026-08-16 — it is plain C over <ctype.h> and touches nothing in here. The
 * stock/flow gauges below stay, because they touch store and EL value types. */

/* Damaged-node creations since process start. See the block comment above. */
static int64_t _eg_txt_write_damaged = 0;

/* engram_text_health_json — full text-integrity census over the store.
 * O(total content bytes); call it on demand (daily self-review / a route),
 * never per heartbeat. `damaged` counts nodes carrying the loss signature,
 * `multibyte` counts nodes holding valid multi-byte UTF-8 — the two together
 * separate "no damage" from "no non-ASCII text to damage", which a single
 * number cannot do. Telemetry is excluded: ISE payloads are machine-written
 * ASCII JSON and would dilute the ratio that matters. */
el_val_t engram_text_health_json(void) {
    EngramStore* g = engram_get();
    int64_t scanned = 0, damaged = 0, multibyte = 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 ||
             strcmp(n->node_type, "Tag") == 0)) continue;
        scanned++;
        if (eg_text_loss_signature(n->content)) damaged++;
        for (const char* p = n->content; p && *p; p++) {
            if ((unsigned char)*p >= 0x80) { multibyte++; break; }
        }
    }
    char buf[256];
    snprintf(buf, sizeof(buf),
             "{\"scanned\":%lld,\"damaged\":%lld,\"multibyte\":%lld,"
             "\"damaged_pct\":%.2f,\"write_damaged\":%lld}",
             (long long)scanned, (long long)damaged, (long long)multibyte,
             scanned > 0 ? (100.0 * (double)damaged / (double)scanned) : 0.0,
             (long long)_eg_txt_write_damaged);
    return el_wrap_str(el_strdup(buf));
}

/* ── M-INTEROCEPTION P1: two-threshold consolidation (ENGRAM_CONSOLIDATION,
 * default OFF) ───────────────────────────────────────────────────────────
 * Design §9. "Connection IS consolidation." This is a promotion LAYER on top
 * of the already-working co-activation accrual (see ENGRAM_HEBB_* above); it
 * does not change how hebb accrues. Two thresholds:
 *   CONNECTION — when a strongly-firing InternalStateEvent is created, wire
 *     hebbian-associate edges from it to the nodes that were in working memory
 *     at that moment (its wm_top). Below the bar: no edges — a shower thought
 *     that drifts out at the 48h ISE prune. (eg_consolidate_ise_connect)
 *   PERMANENCE (rare) — a node whose rehearsed ACT-R base-level crosses the
 *     higher bar is marked durable (metadata provenance "consolidated-from-ISE")
 *     and is thereafter exempt from engram_prune_telemetry. Reversible: drop the
 *     marker. Dedup-guarded (eg_edge_exists_between, no double-promote).
 * Every branch is inert unless ENGRAM_CONSOLIDATION is set → OFF path is
 * byte-identical to trunk. Thresholds env-tunable for A/B without a rebuild. */
static int eg_consolidation_on(void){
    static int cached=-1;
    if(cached<0){ const char* s=getenv("ENGRAM_CONSOLIDATION"); cached=(s&&s[0]&&s[0]!='0')?1:0; }
    return cached;
}
static double eg_consol_conn_min(void){ const char* s=getenv("ENGRAM_CONSOL_CONN_MIN"); double v=s?atof(s):0.6; if(!(v>=0.0&&v<=1.0))v=0.6; return v; }
static double eg_consol_perm_min(void){ const char* s=getenv("ENGRAM_CONSOL_PERM_MIN"); return s?atof(s):0.9; }
static int    eg_consol_wm_topk(void){ const char* s=getenv("ENGRAM_CONSOL_WM_TOPK"); int v=s?atoi(s):5; if(v<0)v=0; if(v>64)v=64; return v; }
/* Durable marker is carried in node metadata (persisted, reversible) rather than
 * a new struct field, so it survives the store round-trip with no schema change. */
static int eg_node_is_durable(const EngramNode* n){
    return n && n->metadata && strstr(n->metadata, "consolidated-from-ISE") != NULL;
}
static void eg_consolidate_ise_connect(EngramNode* ise);

el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
                          el_val_t salience, el_val_t importance, el_val_t confidence,
                          el_val_t tier, el_val_t tags) {
    EngramStore* g = engram_get();
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof(*n));
    n->id        = engram_new_id();
    const char* c  = EL_CSTR(content);
    const char* nt = EL_CSTR(node_type);
    const char* lb = EL_CSTR(label);
    const char* ti = EL_CSTR(tier);
    const char* tg = EL_CSTR(tags);
    n->content   = el_strdup_persist(c ? c : "");
    n->node_type = el_strdup_persist(nt && *nt ? nt : "Memory");
    /* Flow half of the text-integrity gauge — see eg_text_loss_signature.
     * Telemetry is machine-written ASCII and is excluded so the counter stays
     * a clean signal about content-bearing write paths. */
    if (strcmp(n->node_type, "InternalStateEvent") != 0 &&
        eg_text_loss_signature(n->content)) _eg_txt_write_damaged++;
    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);
    if (n->salience    <= 0.0 || n->salience    > 1.0) n->salience    = 0.5;
    if (n->importance  <= 0.0 || n->importance  > 1.0) n->importance  = 0.5;
    if (n->confidence  <= 0.0 || n->confidence  > 1.0) n->confidence  = 1.0;
    n->temporal_decay_rate = 0.0; /* 0 = use global default ENGRAM_DECAY_LAMBDA */
    n->activation_count = 0;
    int64_t now = engram_now_ms();
    n->last_activated = now;
    n->created_at = now;
    n->updated_at = now;
    n->layer_id   = ENGRAM_LAYER_DEFAULT;
    int64_t new_idx_full = g->node_count;
    g->node_count++;
    engram_idmap_put(g, n->id, new_idx_full);
    engram_adj_on_node_added(g);
    if (engram_store_enabled()) eg_store_put_node(n);
    /* P4 afferent counters: a node was created; ISE ingests counted separately. */
    _eg_aff_node_creates++;
    if (n->node_type && strcmp(n->node_type, "InternalStateEvent") == 0) _eg_aff_ise_ingests++;
    /* P1 CONNECTION threshold: self-gated (inert unless ENGRAM_CONSOLIDATION). */
    eg_consolidate_ise_connect(n);
    return el_wrap_str(el_strdup(n->id));
}

/* engram_node_layered — like engram_node_full but with explicit layer
 * assignment and an additional `status` slot reserved for callers that
 * track lifecycle state in metadata. The signature mirrors the public API
 * defined in the layered consciousness design doc:
 *
 *   engram_node_layered(content, node_type, label,
 *                       salience, certainty, confidence,
 *                       status, tags, layer_id)
 *
 * `certainty` is folded into `importance` (it occupies the same axis in
 * the existing schema). `status` is recorded under metadata.status; an
 * empty status leaves metadata as the default "{}".
 *
 * If `layer_id` does not resolve to a known layer the call falls back to
 * ENGRAM_LAYER_DEFAULT — better to keep the node addressable than to drop
 * it because of a stale layer reference. Callers wanting strict validation
 * should engram_list_layers first. */
el_val_t engram_node_layered(el_val_t content, el_val_t node_type, el_val_t label,
                             el_val_t salience, el_val_t certainty, el_val_t confidence,
                             el_val_t status, el_val_t tags, el_val_t layer_id) {
    EngramStore* g = engram_get();
    engram_grow_nodes();
    EngramNode* n = &g->nodes[g->node_count];
    memset(n, 0, sizeof(*n));
    n->id        = engram_new_id();
    const char* c  = EL_CSTR(content);
    const char* nt = EL_CSTR(node_type);
    const char* lb = EL_CSTR(label);
    const char* tg = EL_CSTR(tags);
    const char* st = EL_CSTR(status);
    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_persist(sl);
        snprintf(meta, sl, "{\"status\":\"%s\"}", st);
        n->metadata = meta;
    } else {
        n->metadata = el_strdup_persist("{}");
    }
    n->salience    = engram_decode_score(salience);
    n->importance  = engram_decode_score(certainty);
    n->confidence  = engram_decode_score(confidence);
    if (n->salience    <= 0.0 || n->salience    > 1.0) n->salience    = 0.5;
    if (n->importance  <= 0.0 || n->importance  > 1.0) n->importance  = 0.5;
    if (n->confidence  <= 0.0 || n->confidence  > 1.0) n->confidence  = 1.0;
    n->temporal_decay_rate = 0.0;
    n->activation_count = 0;
    int64_t now = engram_now_ms();
    n->last_activated = now;
    n->created_at = now;
    n->updated_at = now;
    /* Resolve layer assignment. Caller passes either a numeric layer_id or
     * a stringified id; el_to_float / int cast tolerates both. */
    int64_t lid = (int64_t)layer_id;
    if (lid < 0) lid = (int64_t)ENGRAM_LAYER_DEFAULT;
    if (!engram_find_layer((uint32_t)lid)) lid = (int64_t)ENGRAM_LAYER_DEFAULT;
    n->layer_id = (uint32_t)lid;
    int64_t new_idx_layered = g->node_count;
    g->node_count++;
    engram_idmap_put(g, n->id, new_idx_layered);
    engram_adj_on_node_added(g);
    if (engram_store_enabled()) eg_store_put_node(n);
    return el_wrap_str(el_strdup(n->id));
}

/* ── Layer registry public API ──────────────────────────────────────────────
 *
 * The five canonical layers are seeded at engram_get() initialization.
 * Runtime code (typically imprint/suit injection logic at the EL level)
 * can extend the registry with engram_add_layer() — only layers marked
 * `injectable=1` may be removed via engram_remove_layer(). Removing a
 * layer leaves a tombstone slot so existing layer_id references on nodes
 * stay valid; orphaned references resolve to "unknown layer" and inherit
 * the default suppression behavior.
 */

/* engram_add_layer — register a new layer at runtime.
 * Returns the assigned layer_id as an el_val_t int (cast back via int64_t).
 * Conflicting names are rejected (returns 0). */
el_val_t engram_add_layer(el_val_t name, el_val_t priority, el_val_t suppressible,
                          el_val_t transparent, el_val_t injectable) {
    EngramStore* g = engram_get();
    const char* nm = EL_CSTR(name);
    if (!nm || !*nm) return (el_val_t)0;
    if (engram_find_layer_by_name(nm)) {
        /* Name collision — return existing id so callers are idempotent. */
        return (el_val_t)(int64_t)engram_find_layer_by_name(nm)->layer_id;
    }
    if (g->layer_count >= g->layer_capacity) {
        size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
        EngramLayer* grown = realloc(g->layers, nc * sizeof(EngramLayer));
        if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        memset(grown + g->layer_capacity, 0,
               (nc - g->layer_capacity) * sizeof(EngramLayer));
        g->layers = grown;
        g->layer_capacity = nc;
    }
    EngramLayer* L = &g->layers[g->layer_count++];
    L->layer_id            = engram_next_layer_id();
    L->name                = el_strdup_persist(nm);
    L->activation_priority = (uint32_t)(int64_t)priority;
    L->suppressible        = (int)(int64_t)suppressible ? 1 : 0;
    L->transparent         = (int)(int64_t)transparent ? 1 : 0;
    L->injectable          = (int)(int64_t)injectable  ? 1 : 0;
    return (el_val_t)(int64_t)L->layer_id;
}

/* engram_remove_layer — remove an injectable layer by id.
 * Built-in (non-injectable) layers cannot be removed. Nodes still tagged
 * with the removed layer's id keep their tag but resolve to "unknown
 * layer" thereafter and inherit standard (suppressible) behavior.
 * Returns 1 on success, 0 on failure (unknown id, non-injectable). */
el_val_t engram_remove_layer(el_val_t layer_id) {
    EngramStore* g = engram_get();
    int64_t lid = (int64_t)layer_id;
    for (size_t i = 0; i < g->layer_count; i++) {
        EngramLayer* L = &g->layers[i];
        if (!L->name) continue;
        if ((int64_t)L->layer_id != lid) continue;
        if (!L->injectable) return (el_val_t)0;
        free(L->name);
        L->name = NULL; /* tombstone */
        /* Leave layer_id, priority, flags intact so debug snapshots can
         * still distinguish "removed at runtime" from "never existed". */
        return (el_val_t)1;
    }
    return (el_val_t)0;
}

/* engram_list_layers — enumerate the active layer registry.
 * Returns an ElList of maps, one per non-tombstone layer, sorted by
 * activation_priority ascending (deepest layer first). */
el_val_t engram_list_layers(void) {
    EngramStore* g = engram_get();
    el_val_t lst = el_list_empty();
    if (g->layer_count == 0) return lst;
    /* Build an index sorted by activation_priority ascending. */
    size_t* idx = malloc(g->layer_count * sizeof(size_t));
    if (!idx) return lst;
    size_t live = 0;
    for (size_t i = 0; i < g->layer_count; i++) {
        if (g->layers[i].name) idx[live++] = i;
    }
    /* Insertion sort — N is small (≤ a few dozen layers). */
    for (size_t i = 1; i < live; i++) {
        size_t key = idx[i];
        uint32_t kp = g->layers[key].activation_priority;
        size_t j = i;
        while (j > 0 && g->layers[idx[j - 1]].activation_priority > kp) {
            idx[j] = idx[j - 1];
            j--;
        }
        idx[j] = key;
    }
    for (size_t i = 0; i < live; i++) {
        EngramLayer* L = &g->layers[idx[i]];
        el_val_t m = el_map_new(0);
        m = el_map_set(m, EL_STR(el_strdup("layer_id")),
                       (el_val_t)(int64_t)L->layer_id);
        m = el_map_set(m, EL_STR(el_strdup("name")),
                       EL_STR(el_strdup(L->name ? L->name : "")));
        m = el_map_set(m, EL_STR(el_strdup("activation_priority")),
                       (el_val_t)(int64_t)L->activation_priority);
        m = el_map_set(m, EL_STR(el_strdup("suppressible")),
                       (el_val_t)(int64_t)(L->suppressible ? 1 : 0));
        m = el_map_set(m, EL_STR(el_strdup("transparent")),
                       (el_val_t)(int64_t)(L->transparent ? 1 : 0));
        m = el_map_set(m, EL_STR(el_strdup("injectable")),
                       (el_val_t)(int64_t)(L->injectable ? 1 : 0));
        lst = el_list_append(lst, m);
    }
    free(idx);
    return lst;
}

el_val_t engram_get_node(el_val_t id) {
    const char* sid = EL_CSTR(id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return el_map_new(0);
    return engram_node_to_map(n);
}

void engram_strengthen(el_val_t node_id) {
    const char* sid = EL_CSTR(node_id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return;
    n->salience += 0.05;
    if (n->salience > 1.0) n->salience = 1.0;
    n->activation_count++;
    n->last_activated = engram_now_ms();
    n->updated_at = n->last_activated;
    /* 2026-07-26 self-review: REMOVED the BLL access record added on
     * 2026-07-22 ("explicit strengthen is a presentation too"). The
     * 2026-07-25 STI multiplier reads the same access ring — so an
     * explicit strengthen crushed the strengthened node's promotion
     * score by ×t_n/(t_n+120) for the next ~2 minutes. The awareness
     * loop strengthens exactly when a node NEWLY reaches WM top
     * (novelty gating); recording an access here made that
     * reinforcement self-defeating. Salience/activation_count bumps
     * above carry the reinforcement; the access ring stays reserved
     * for genuine retrieval events (promotions in engram_activate). */
}

void engram_forget(el_val_t node_id) {
    const char* sid = EL_CSTR(node_id);
    if (!sid) return;
    EngramStore* g = engram_get();
    int64_t idx = engram_find_node_index(sid);
    if (idx < 0) return;
    /* Mirror the removal into the durable store BEFORE the shift-delete frees
     * the incident edges' ids (node tombstone; edge records are reclaimed at
     * compaction — M5). Node-level FORGET matches the existing WAL semantics. */
    if (engram_store_enabled() && g_engram_store) {
        store_forget(g_engram_store, sid);
    }
    /* Free node strings */
    EngramNode* n = &g->nodes[idx];
    free(n->id); free(n->content); free(n->node_type); free(n->label);
    free(n->tier); free(n->tags); free(n->metadata);
    free(n->emb);
    /* Shift remaining nodes down */
    for (int64_t i = idx + 1; i < g->node_count; i++) {
        g->nodes[i - 1] = g->nodes[i];
    }
    g->node_count--;
    memset(&g->nodes[g->node_count], 0, sizeof(EngramNode));
    /* Remove all incident edges */
    int64_t w = 0;
    for (int64_t r = 0; r < g->edge_count; r++) {
        EngramEdge* e = &g->edges[r];
        int incident = (e->from_id && strcmp(e->from_id, sid) == 0) ||
                       (e->to_id   && strcmp(e->to_id,   sid) == 0);
        if (incident) {
            free(e->id); free(e->from_id); free(e->to_id);
            free(e->relation); free(e->metadata);
        } else {
            if (w != r) g->edges[w] = g->edges[r];
            w++;
        }
    }
    g->edge_count = w;
    /* Shift-delete changed all indices above the removed position.
     * Rebuild id_map and mark adjacency index dirty. */
    engram_idmap_rebuild(g);
    engram_adj_free(g);
}

el_val_t engram_node_count(void) {
    return (el_val_t)engram_get()->node_count;
}

/* node_attach_geometry — a node acquires geometry.
 *
 * Named for the operation, not for the store that happens to hold the node.
 * This is the geometry-valued ingest path that replaces #141's hex-string
 * one: nothing here parses text, and nothing here takes a caller's word for
 * how wide the vector is. The Geometry carries its own width.
 *
 * The payload is COPIED into the node, so the node and the caller's Geometry
 * have independent lifetimes — the caller may geometry_free() immediately
 * after, and a later free of the node's emb never touches the El value.
 *
 * DIMENSION POLICY (measured in #141, load-bearing — do not regress): dim
 * need NOT equal the canonical text-embedding width. An off-dimension vector
 * is stored and is simply not inserted into the resident HNSW index, whose
 * build loop already filters on `n->emb_dim == dim`. So a 64-dim voice
 * geometry is durable and addressable without perturbing the 768-dim
 * canonical index.
 *
 * Attaching geometry also makes the node ineligible for embed_backfill
 * (which fills only nodes with no emb), so a realizer's vector is never
 * overwritten by a text-derived one.
 *
 * Returns 1 on success, 0 on unknown id or a value that is not a Geometry. */
el_val_t node_attach_geometry(el_val_t node_id, el_val_t g) {
    const char* sid = EL_CSTR(node_id);
    if (!sid || !*sid) return (el_val_t)0;

    ElGeometry* p = geom_of(g);
    if (!p || p->dim <= 0) return (el_val_t)0;

    EngramNode* n = engram_find_node(sid);
    if (!n) return (el_val_t)0;

    float* v = (float*)malloc(sizeof(float) * (size_t)p->dim);
    if (!v) return (el_val_t)0;
    memcpy(v, p->v, sizeof(float) * (size_t)p->dim);

    free(n->emb);
    n->emb        = v;
    n->emb_dim    = p->dim;
    n->updated_at = engram_now_ms();
    if (engram_store_enabled()) eg_store_put_node(n);
    return (el_val_t)1;
}

/* node_geometry_dim — read the attached width back, 0 if the node carries
 * none. Exists so an attach is VERIFIED by reading it back rather than by
 * trusting a success return. That is not a nicety: #141 was misdiagnosed for
 * an hour precisely because a genuine ingest drop and a mere reporting gap
 * were indistinguishable from the outside. */
el_val_t node_geometry_dim(el_val_t node_id) {
    const char* sid = EL_CSTR(node_id);
    if (!sid || !*sid) return (el_val_t)0;
    EngramNode* n = engram_find_node(sid);
    if (!n || !n->emb) return (el_val_t)0;
    return (el_val_t)n->emb_dim;
}

/* engram_node_set_emb — DEPRECATED. Shipped in #141; superseded 2026-08-16
 * by geometry_from_f32le_hex + node_attach_geometry, and now implemented as
 * literally that.
 *
 * It is kept, rather than removed, for one reason only: the runtime is
 * published as an SDK asset, so a downstream binary may already be linking
 * this symbol. It is NOT kept because a hex string is an acceptable way to
 * move geometry between two pieces of El — it isn't, and that was the
 * placement defect. New code calls transduce() or geometry_from_f32le_hex()
 * plus node_attach_geometry().
 *
 * The #141 contract is preserved exactly, including its negative cases, so
 * this remains a drop-in: `dim` <= 0 rejects, malformed hex rejects, and a
 * `dim` that disagrees with the vector's actual width rejects. The
 * difference is that `dim` is now an ASSERTION checked against a width the
 * Geometry already knows, rather than the authority the allocation trusted —
 * which is why #141's arbitrary `dim <= 8192` guard has no counterpart here.
 * There is no longer an unbounded-malloc hazard to guard against. */
el_val_t engram_node_set_emb(el_val_t id, el_val_t hex, el_val_t dim) {
    int32_t want = (int32_t)(int64_t)dim;
    if (want <= 0) return (el_val_t)0;

    el_val_t gv = geometry_from_f32le_hex(hex);
    ElGeometry* p = geom_of(gv);
    if (!p) return (el_val_t)0;                       /* empty / malformed hex */
    if (p->dim != want) { geometry_free(gv); return (el_val_t)0; }  /* length mismatch */

    el_val_t ok = node_attach_geometry(id, gv);
    geometry_free(gv);
    return ok;
}

/* ── 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"));
        /* P1 PERMANENCE: a node promoted past the permanence bar is durable and
         * must not be swept by the 48h telemetry prune. Gated so the OFF path is
         * byte-identical (no trunk node ever carries the marker). */
        if (eg_consolidation_on() && prunable && eg_node_is_durable(n)) prunable = 0;
        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);
            free(n->emb);
        } 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);
    }
    /* Mirror the telemetry prune into the durable paged store so its live
     * count tracks the resident graph and stale ISE telemetry stays bounded
     * in the store too. Without this, the resident graph GCs old ISE from RAM
     * (node_count drops) while the store retains them (count diverges + the
     * store re-accumulates the very telemetry bloat this prune was written to
     * stop). Matches engram_forget's store_forget mirror. Done before the ids
     * are freed below. */
    if (engram_store_enabled() && g_engram_store) {
        for (int64_t i = 0; i < removed; i++) {
            store_forget(g_engram_store, removed_ids[i]);
        }
    }

    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);
    for (const char* p = hay; *p; p++) {
        if (strncasecmp(p, needle, nl) == 0) return 1;
    }
    return 0;
}

/* ── Tokenized query matching ───────────────────────────────────────────
 * The engram query surface (search / activate / goal-bias) historically
 * matched the ENTIRE raw query string as a single case-insensitive
 * substring via istr_contains(field, q). That is Ctrl-F, not search:
 * a multi-word query like "windows msi signing" only matched a node whose
 * text contained that exact contiguous run, so real multi-word queries
 * returned zero. istr_contains stays as the per-TOKEN primitive; these
 * helpers split the query on whitespace and match ANY token, then rank by
 * how many DISTINCT tokens a node covers. Single-token queries are a strict
 * special case (score is 0 or 1) so single-word callers never regress.
 * (Ported 2026-07-19 from the el-compiler runtime copy, where the 2026-07-14
 * fix landed but never reached this release runtime — the copy the engram
 * binary actually builds against.) */
#define ENGRAM_MAX_QTOKENS 32
/* ENGRAM_QTOK_LEN and engram_tokenize_query moved to engram_text.h/.c. */

/* Count how many of the ntok distinct query tokens appear (case-insensitive)
 * in the node's content, label, or tags. 0 == no match. */
static int engram_node_match_score(const EngramNode* n,
                                   char toks[][ENGRAM_QTOK_LEN], int ntok) {
    int score = 0;
    for (int t = 0; t < ntok; t++) {
        if (istr_contains(n->content, toks[t]) ||
            istr_contains(n->label,   toks[t]) ||
            istr_contains(n->tags,    toks[t]))
            score++;
    }
    return score;
}

/* Rank entry: distinct-token match count (primary, desc) then salience
 * (tiebreak, desc). */
typedef struct { int64_t idx; int score; double salience; } EngramRankEntry;
static int engram_rank_cmp(const void* a, const void* b) {
    const EngramRankEntry* ea = (const EngramRankEntry*)a;
    const EngramRankEntry* eb = (const EngramRankEntry*)b;
    if (ea->score != eb->score) return eb->score - ea->score;      /* desc */
    if (ea->salience < eb->salience) return 1;
    if (ea->salience > eb->salience) return -1;
    return 0;
}

el_val_t engram_search(el_val_t query, el_val_t limit) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t lim = (int64_t)limit;
    if (lim <= 0) lim = 100;
    el_val_t lst = el_list_empty();
    if (!q || !*q) return lst;
    char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
    int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
    if (ntok == 0) return lst;
    EngramRankEntry* hits = malloc((size_t)g->node_count * sizeof(EngramRankEntry));
    if (!hits) return lst;
    int64_t nhits = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        EngramNode* n = &g->nodes[i];
        /* Filter transparent layers: nodes whose layer is `transparent=1`
         * shape output but are invisible to introspection ("what do you
         * know about yourself"). They still surface via engram_activate
         * + engram_compile_layered_json — that's the legitimate path. */
        if (engram_layer_is_transparent(n->layer_id)) continue;
        int sc = engram_node_match_score(n, toks, ntok);
        if (sc > 0) {
            hits[nhits].idx      = i;
            hits[nhits].score    = sc;
            hits[nhits].salience = n->salience;
            nhits++;
        }
    }
    /* Rank by distinct tokens matched (desc) then salience (desc), then cap. */
    qsort(hits, (size_t)nhits, sizeof(EngramRankEntry), engram_rank_cmp);
    int64_t end = nhits < lim ? nhits : lim;
    for (int64_t k = 0; k < end; k++) {
        lst = el_list_append(lst, engram_node_to_map(&g->nodes[hits[k].idx]));
    }
    free(hits);
    return lst;
}

/* Sort node indices by salience desc, tie-break created_at desc (newest
 * first). The tie-break matters for telemetry: InternalStateEvent nodes all
 * share salience 0.3, so before this the listing routes returned them in
 * store order — OLDEST first — and any limited query (/api/nodes?...&limit=N)
 * silently returned a stale window. A 41-hour-old heartbeat series read as a
 * live outage during the 2026-07-22 self-review. Newest-first ties make
 * limited scans return the recent window consumers actually want.
 * (Small N, insertion sort is fine.) */
static void engram_sort_indices_by_salience(int64_t* arr, int64_t n,
                                             const EngramNode* nodes) {
    for (int64_t i = 1; i < n; i++) {
        int64_t key = arr[i];
        double  ks  = nodes[key].salience;
        int64_t kc  = nodes[key].created_at;
        int64_t j = i - 1;
        while (j >= 0 && (nodes[arr[j]].salience < ks ||
                          (nodes[arr[j]].salience == ks &&
                           nodes[arr[j]].created_at < kc))) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key;
    }
}

el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    el_val_t lst = el_list_empty();
    if (g->node_count == 0) return lst;
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) return lst;
    /* Skip transparent layers — same introspection-filter rationale as
     * engram_search above. */
    int64_t live = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
        idx[live++] = i;
    }
    engram_sort_indices_by_salience(idx, live, g->nodes);
    int64_t end = off + lim;
    if (end > live) end = live;
    for (int64_t i = off; i < end; i++) {
        lst = el_list_append(lst, engram_node_to_map(&g->nodes[idx[i]]));
    }
    free(idx);
    return lst;
}

void engram_connect(el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
    EngramStore* g = engram_get();
    const char* f = EL_CSTR(from_id);
    const char* t = EL_CSTR(to_id);
    const char* r = EL_CSTR(relation);
    if (!f || !t) return;
    _eg_aff_edge_creates++;   /* P4 afferent counter: an edge was authored */
    engram_grow_edges();
    EngramEdge* e = &g->edges[g->edge_count];
    memset(e, 0, sizeof(*e));
    e->id        = engram_new_id();
    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;
    int64_t now = engram_now_ms();
    e->created_at = now;
    e->updated_at = now;
    e->last_fired = 0;
    e->layer_id   = ENGRAM_LAYER_DEFAULT;
    g->edge_count++;
    engram_adj_on_edge_added(g, g->edge_count - 1);
    if (engram_store_enabled()) eg_store_put_edge(e);
}

el_val_t engram_edge_between(el_val_t from_id, el_val_t to_id) {
    EngramStore* g = engram_get();
    const char* f = EL_CSTR(from_id);
    const char* t = EL_CSTR(to_id);
    if (!f || !t) return 0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (e->from_id && e->to_id &&
            strcmp(e->from_id, f) == 0 && strcmp(e->to_id, t) == 0) return 1;
    }
    return 0;
}

/* Reserved helper: edge -> ElMap. Kept around for future builtins.       */
static el_val_t engram_edge_to_map(const EngramEdge* e) __attribute__((unused));
static el_val_t engram_edge_to_map(const EngramEdge* e) {
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("id")),         EL_STR(el_strdup(e->id ? e->id : "")));
    m = el_map_set(m, EL_STR(el_strdup("from_id")),    EL_STR(el_strdup(e->from_id ? e->from_id : "")));
    m = el_map_set(m, EL_STR(el_strdup("to_id")),      EL_STR(el_strdup(e->to_id ? e->to_id : "")));
    m = el_map_set(m, EL_STR(el_strdup("relation")),   EL_STR(el_strdup(e->relation ? e->relation : "")));
    m = el_map_set(m, EL_STR(el_strdup("metadata")),   EL_STR(el_strdup(e->metadata ? e->metadata : "{}")));
    m = el_map_set(m, EL_STR(el_strdup("weight")),     el_from_float(e->weight));
    m = el_map_set(m, EL_STR(el_strdup("confidence")), el_from_float(e->confidence));
    m = el_map_set(m, EL_STR(el_strdup("created_at")), (el_val_t)e->created_at);
    m = el_map_set(m, EL_STR(el_strdup("updated_at")), (el_val_t)e->updated_at);
    m = el_map_set(m, EL_STR(el_strdup("hebb")), el_from_float(e->hebb));
    m = el_map_set(m, EL_STR(el_strdup("last_fired")), (el_val_t)e->last_fired);
    m = el_map_set(m, EL_STR(el_strdup("inhibitory")), (el_val_t)(e->inhibitory ? 1 : 0));
    m = el_map_set(m, EL_STR(el_strdup("layer_id")),   (el_val_t)(int64_t)e->layer_id);
    return m;
}

el_val_t engram_neighbors(el_val_t node_id) {
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    el_val_t lst = el_list_empty();
    if (!sid) return lst;
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        const char* other = NULL;
        if (e->from_id && strcmp(e->from_id, sid) == 0) other = e->to_id;
        else if (e->to_id && strcmp(e->to_id, sid) == 0) other = e->from_id;
        if (!other) continue;
        EngramNode* n = engram_find_node(other);
        if (n) lst = el_list_append(lst, engram_node_to_map(n));
    }
    return lst;
}

el_val_t engram_neighbors_filtered(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    int64_t md = (int64_t)max_depth; if (md <= 0) md = 1;
    const char* dir = EL_CSTR(direction); /* "out" | "in" | "both" (default) */
    el_val_t lst = el_list_empty();
    if (!sid || g->node_count == 0) return lst;
    int64_t start = engram_find_node_index(sid);
    if (start < 0) return lst;
    /* BFS with depth tracking */
    int64_t* visited = calloc((size_t)g->node_count, sizeof(int64_t));
    int64_t* queue   = calloc((size_t)g->node_count, sizeof(int64_t));
    int64_t* depths  = calloc((size_t)g->node_count, sizeof(int64_t));
    if (!visited || !queue || !depths) {
        free(visited); free(queue); free(depths); return lst;
    }
    int64_t qh = 0, qt = 0;
    queue[qt++] = start;
    visited[start] = 1;
    depths[start] = 0;
    while (qh < qt) {
        int64_t cur = queue[qh++];
        const char* cur_id = g->nodes[cur].id;
        int64_t cur_depth = depths[cur];
        if (cur_depth >= md) continue;
        for (int64_t i = 0; i < g->edge_count; i++) {
            EngramEdge* e = &g->edges[i];
            const char* other = NULL;
            int outgoing = e->from_id && strcmp(e->from_id, cur_id) == 0;
            int incoming = e->to_id   && strcmp(e->to_id,   cur_id) == 0;
            if (dir && strcmp(dir, "out") == 0 && !outgoing) continue;
            if (dir && strcmp(dir, "in")  == 0 && !incoming) continue;
            if (outgoing) other = e->to_id;
            else if (incoming) other = e->from_id;
            else continue;
            int64_t oi = engram_find_node_index(other);
            if (oi < 0 || visited[oi]) continue;
            visited[oi] = 1;
            depths[oi]  = cur_depth + 1;
            queue[qt++] = oi;
        }
    }
    /* Emit all visited except the seed */
    for (int64_t i = 0; i < g->node_count; i++) {
        if (visited[i] && i != start) {
            lst = el_list_append(lst, engram_node_to_map(&g->nodes[i]));
        }
    }
    free(visited); free(queue); free(depths);
    return lst;
}

el_val_t engram_edge_count(void) {
    return (el_val_t)engram_get()->edge_count;
}

/* Compute temporal decay factor for a node given current time.
 * effective contribution = salience * exp(-lambda * age_hours / T_half)
 * Clamped to [0.05, 1.0] so very old nodes retain a meaningful floor. */
/* eg_edge_eff_weight — the weight spreading activation actually propagates
 * through: the authored weight, potentiated by learned co-activation.
 * hebb == 0 (fresh edge, cold graph, or feature effectively disabled) returns
 * exactly e->weight, so this is a strict no-op until the graph has learned
 * something. Clamped to 1.0 so a potentiated edge can never amplify a signal
 * above its source. See the ENGRAM_HEBB_* block for the full rationale. */
static double eg_edge_eff_weight(const EngramEdge* e) {
    double w = e->weight;
    if (e->hebb > 0.0) {
        w *= (1.0 + ENGRAM_HEBB_GAIN * e->hebb);
        if (w > 1.0) w = 1.0;
    }
    return w;
}

/* eg_wm_carry_over — the ACT-R/Petrov retention rule for a node that already
 * holds a working-memory slot and was not re-promoted on this call. Hard-evict
 * below the base-level threshold τ (Soar-style forgetting); otherwise hold a
 * weight shaped by the retrieval-probability logistic and decayed by how long
 * the slot has been held (occupancy inhibition). Pure function of wall-clock
 * time, so it is idempotent no matter how often activate is called.
 *
 * Extracted 2026-08-04: this logic was inline and applied to exactly ONE of
 * the two paths that need it. See the call sites. */
static void eg_wm_carry_over(EngramNode* cn, int64_t now_ms, int64_t* evict_ctr) {
    double anchor = (cn->wm_anchor > 0.0) ? cn->wm_anchor
                                          : cn->working_memory_weight;
    double B = engram_bll_base_level(cn, now_ms);
    double w = 0.0;
    if (B >= ENGRAM_BLL_TAU) {
        double keep = 1.0 / (1.0 + exp(-(B - ENGRAM_BLL_TAU) / ENGRAM_BLL_S));
        double hold_s = (double)(now_ms - cn->last_activated) / 1000.0;
        if (hold_s < 0.0) hold_s = 0.0;
        double occ = ENGRAM_CARRY_TC / (ENGRAM_CARRY_TC + hold_s);
        w = anchor * keep * occ;
    }
    if (w < ENGRAM_WM_FLOOR) {
        cn->working_memory_weight = 0.0;
        cn->wm_anchor = 0.0;
        if (evict_ctr) (*evict_ctr)++;
        _eg_act_evict_bll++;
    } else {
        cn->working_memory_weight = w;
    }
}

/* ── Hebbian candidate-pair table helpers ───────────────────────────────────
 * Pairs are order-normalized by strcmp so (a,b) and (b,a) always resolve to
 * the same slot. Collisions are resolved by strength: an incumbent that has
 * decayed to nothing yields its slot, a live one keeps it and the challenger
 * simply loses this round. That is a lossy table by design — consolidation
 * should favor associations that recur, and a pair that keeps losing a
 * collision is by definition not recurring often enough to matter. */
/* eg_hebb_trace — the node's eligibility trace right now, in [0,1].
 * Stored as (amplitude, timestamp) and decayed on read, so the value is a pure
 * function of wall-clock time: idempotent no matter how often activate runs.
 * Snapped to 0 below ENGRAM_HEBB_TRACE_MIN. See ENGRAM_HEBB_TRACE_TC. */
static double eg_hebb_trace(const EngramNode* n, int64_t now_ms) {
    if (n->hebb_elig <= 0.0 || n->hebb_elig_ts <= 0) return 0.0;
    double dt = (double)(now_ms - n->hebb_elig_ts) / 1000.0;
    if (dt < 0.0) dt = 0.0;                    /* clock skew ⇒ treat as fresh */
    double t = n->hebb_elig * exp(-dt / ENGRAM_HEBB_TRACE_TC);
    return (t < ENGRAM_HEBB_TRACE_MIN) ? 0.0 : t;
}

static int eg_hebb_slot(const char* a, const char* b) {
    if (!a || !b) return -1;
    const char* lo = (strcmp(a, b) <= 0) ? a : b;
    const char* hi = (lo == a) ? b : a;
    uint64_t h = engram_id_hash(lo) * 1000003u ^ engram_id_hash(hi);
    return (int)(h % (uint64_t)ENGRAM_HEBB_CAND_SLOTS);
}

static int eg_hebb_slot_holds(const EgHebbCand* c, const char* a, const char* b) {
    if (!c->a || !c->b) return 0;
    return (strcmp(c->a, a) == 0 && strcmp(c->b, b) == 0)
        || (strcmp(c->a, b) == 0 && strcmp(c->b, a) == 0);
}

static void eg_hebb_slot_clear(EgHebbCand* c) {
    free(c->a); free(c->b);
    c->a = NULL; c->b = NULL; c->score = 0.0;
}

/* eg_hebb_cand_bump — reinforce the (a,b) candidate association by `inc`.
 * Extracted 2026-08-06 so the same collision policy serves both the co-resident
 * pairs and the eligibility-trace pairs; two copies of this logic would have
 * drifted. Collision policy is unchanged: claim a free slot, reinforce our own,
 * evict an incumbent only once it has decayed to nothing, otherwise lose the
 * round. `inc` is graded by the partner's trace, so an incumbent is never
 * displaced by a challenger carrying less weight than one full co-activation. */
static void eg_hebb_cand_bump(const char* a, const char* b, double inc) {
    if (!a || !b || inc <= 0.0) return;
    int s = eg_hebb_slot(a, b);
    if (s < 0) return;
    EgHebbCand* c = &_eg_hebb_cand[s];
    if (!c->a) {                          /* free slot: claim */
        c->a = el_strdup_persist(a);
        c->b = el_strdup_persist(b);
        c->score = inc;
    } else if (eg_hebb_slot_holds(c, a, b)) {
        c->score += inc;                  /* ours: reinforce */
    } else if (c->score <= ENGRAM_HEBB_ETA) {
        eg_hebb_slot_clear(c);            /* dead incumbent: take the slot */
        c->a = el_strdup_persist(a);
        c->b = el_strdup_persist(b);
        c->score = inc;
    }
    /* else: live incumbent keeps the slot this round. */
}

/* Does any edge already connect these two nodes, in either direction?
 * Linear over the edge array, but called at most ENGRAM_HEBB_LINK_PER_CALL
 * times per activation and only for pairs that already cleared the
 * consolidation threshold — a handful of scans per day, not per hop. */
static int eg_edge_exists_between(EngramStore* g, const char* a, const char* b) {
    for (int64_t i = 0; i < g->edge_count; i++) {
        const EngramEdge* e = &g->edges[i];
        if (!e->from_id || !e->to_id) continue;
        if ((strcmp(e->from_id, a) == 0 && strcmp(e->to_id, b) == 0) ||
            (strcmp(e->from_id, b) == 0 && strcmp(e->to_id, a) == 0)) return 1;
    }
    return 0;
}

/* P1 CONNECTION threshold (design §9). When a strongly-firing ISE is created,
 * wire hebbian-associate edges from it to the wm_top nodes active at that
 * moment. Self-gated: returns immediately unless ENGRAM_CONSOLIDATION is set,
 * the node is an InternalStateEvent, and its salience clears the connection bar
 * — so a sub-threshold ISE forms nothing (a shower thought), which then drifts
 * out at the 48h prune. Edges are provenance-tagged "consolidated-from-ISE"
 * (reversible: deletable by that marker) and dedup-guarded via
 * eg_edge_exists_between. They accrue/decay through the normal hebb machinery
 * and are swept with their ISE at the 48h prune unless the ISE is promoted to
 * permanence. O(node_count) per qualifying ISE — same order as the
 * engram_prune_telemetry already run on ISE insert. */
static void eg_consolidate_ise_connect(EngramNode* ise){
    if(!eg_consolidation_on()) return;
    if(!ise || !ise->node_type || strcmp(ise->node_type,"InternalStateEvent")!=0) return;
    if(ise->salience < eg_consol_conn_min()) return;          /* below connection bar */
    int K = eg_consol_wm_topk();
    if(K<=0) return;
    if(K>64) K=64;
    EngramStore* g = engram_get();
    /* Select the top-K working-memory members by weight (excluding ISEs and
     * self). Bounded selection, no allocation. */
    int64_t best_idx[64]; double best_w[64]; int nb=0;
    for(int64_t i=0;i<g->node_count;i++){
        EngramNode* t=&g->nodes[i];
        if(t==ise) continue;
        double w=t->working_memory_weight;
        if(w<=0.0) continue;
        if(t->node_type && strcmp(t->node_type,"InternalStateEvent")==0) continue;
        if(nb<K){ best_idx[nb]=i; best_w[nb]=w; nb++; }
        else { int m=0; for(int j=1;j<nb;j++) if(best_w[j]<best_w[m]) m=j;
               if(w>best_w[m]){ best_w[m]=w; best_idx[m]=i; } }
    }
    int64_t now = engram_now_ms();
    for(int b=0;b<nb;b++){
        EngramNode* t=&g->nodes[best_idx[b]];
        if(eg_edge_exists_between(g, ise->id, t->id)) continue;   /* dedup guard */
        engram_grow_edges();
        EngramEdge* ne=&g->edges[g->edge_count];
        memset(ne,0,sizeof(*ne));
        ne->id        = engram_new_id();
        ne->from_id   = el_strdup_persist(ise->id);
        ne->to_id     = el_strdup_persist(t->id);
        ne->relation  = el_strdup_persist("hebbian-associate");
        ne->metadata  = el_strdup_persist("{\"origin\":\"consolidated-from-ISE\"}");
        ne->weight    = ENGRAM_HEBB_LINK_W0;
        ne->hebb      = ENGRAM_HEBB_ETA;   /* nonzero so the trace can rehearse */
        ne->confidence= 1.0;
        ne->created_at= now; ne->updated_at= now; ne->last_fired= now;
        ne->layer_id  = ENGRAM_LAYER_DEFAULT;
        g->edge_count++;
        engram_adj_on_edge_added(g, g->edge_count-1);
        eg_hebb_wb_push(ne->from_id, ne->to_id, ne->weight, ne->hebb);
    }
}

/* P1 PERMANENCE threshold (design §9). Promote a node past the higher bar:
 * mark it durable (metadata provenance "consolidated-from-ISE") so the 48h
 * telemetry prune no longer sweeps it, and mirror to the durable store. The
 * caller (soul) decides WHEN to attempt promotion (rehearsal / re-activation);
 * this primitive enforces the bar on the node's ACT-R base-level and is
 * idempotent (no double-promote). Returns 1 if the node is durable after the
 * call, else 0. Reversible: clear the marker to demote. Inert unless
 * ENGRAM_CONSOLIDATION is set. */
el_val_t engram_consolidate_permanence(el_val_t node_id){
    if(!eg_consolidation_on()) return (el_val_t)(int64_t)0;
    EngramNode* n = engram_find_node(EL_CSTR(node_id));
    if(!n) return (el_val_t)(int64_t)0;
    if(eg_node_is_durable(n)) return (el_val_t)(int64_t)1;   /* already durable */
    double bl = engram_bll_base_level(n, engram_now_ms());
    if(bl < eg_consol_perm_min()) return (el_val_t)(int64_t)0; /* below permanence bar */
    free(n->metadata);
    n->metadata = el_strdup_persist("{\"origin\":\"consolidated-from-ISE\",\"durable\":true}");
    n->updated_at = engram_now_ms();
    if(engram_store_enabled()) eg_store_put_node(n);
    return (el_val_t)(int64_t)1;
}

/* engram_temporal_decay — recency shaping on the activation path.
 *
 * MEASURED FAILURE (2026-08-05 self-review). Census of the live graph under
 * the previous form (uniform 168 h half-life, floor 0.05):
 *
 *   node type          n     median tdecay   % pinned at the 0.05 floor
 *   Memory          1233          0.0500          81%
 *   Knowledge       1183          0.0500          58%
 *   BacklogItem     1057          0.0500          91%
 *   Project          321          0.0500          98%
 *   Tag              135          0.0500         100%
 *
 * The median value for EVERY node type was the clamp. A function whose median
 * output is its floor is not a signal — it is a constant with exceptions, and
 * the exceptions were exactly the nodes touched in the last few days.
 *
 * What that cost, concretely: 10 of the 13 grounded value nodes — "Precision
 * Over Brute Force", "Honesty Before Comfort", "The System Must Accumulate" —
 * sat at 0.05, a 20x activation penalty, while Knowledge ingested overnight
 * sat near 1.0 and held the working-memory top slots. The decay function was
 * quietly erasing the accumulated library in favour of whatever arrived last
 * night. That is a direct inversion of the system's purpose.
 *
 * Worse, tdecay multiplies at EVERY hop (seed activation and each propagation
 * step), so a 2-hop path through settled knowledge compounded to 0.05^2 =
 * 0.0025. Old regions of the graph were not disfavoured; they were unreachable.
 *
 * EXTERNAL EVIDENCE. "Not All Memories Age the Same" (arXiv:2604.26970)
 * measures retrieval under different decay regimes:
 *
 *   no temporal weighting            NDCG@5  0.274
 *   uniform exponential decay        NDCG@5  0.015    <- 18x WORSE than none
 *   domain-adaptive decay            NDCG@5  0.241
 *   full adaptive hierarchy          NDCG@5  0.260
 *
 * Uniform exponential decay is not merely suboptimal — it is worse than having
 * no decay at all, because it penalises stable knowledge (rarely accessed,
 * heavily load-bearing) while failing to suppress stale volatile facts. Notably
 * not even the full adaptive hierarchy beat switching decay off.
 *
 * THE FIX: make the half-life a function of how established a node is, and
 * make the floor a preference rather than a cliff.
 *
 *   T_eff = T_HALF * (1 + ln(1 + activation_count))
 *
 * Frequently-retrieved nodes age slowly; nodes nothing has ever asked for age
 * at the original rate. This is the spacing effect and the Lindy property in
 * one line, it is monotone and log-bounded (a 10,000-activation node gets only
 * a ~10x longer half-life, not a permanent exemption), and it is built from
 * activation_count — which is measured, unlike `tier`, whose assignments are
 * inconsistent enough to be untrustworthy here (the values node is tagged
 * Episodic).
 *
 * The floor moves 0.05 -> 0.25. Given the evidence that no decay outperforms
 * uniform decay, the honest maximum penalty for age alone is 4x, not 20x. Age
 * should express a preference for the recent; it should never make a region of
 * the graph structurally unreachable.
 *
 * Explicit per-node temporal_decay_rate still overrides lambda (77 nodes carry
 * one) — that path is untouched and remains the escape hatch for content that
 * genuinely should expire fast. */
/* 2026-08-16: the body moved to cog_decay_factor (engram_cognition.c) so that
 * NODE decay and EDGE-GROUNDING decay are one implementation with one set of
 * constants, rather than a decay model and a parallel copy of it. The mapping is
 * exact — reinforcements := activation_count, lambda_override :=
 * temporal_decay_rate — so this path is bit-identical to what it replaced.
 * COG_T_HALF_HOURS / COG_DECAY_LAMBDA / COG_DECAY_FLOOR carry the same values
 * ENGRAM_T_HALF_HOURS / ENGRAM_DECAY_LAMBDA / 0.25 carried here. */
static double engram_temporal_decay(const EngramNode* n, int64_t now_ms) {
    return cog_decay_factor(now_ms - n->last_activated,
                            (double)n->activation_count,
                            n->temporal_decay_rate);
}

/* Activation dampening: high activation_count nodes are "well-known" context
 * and get less marginal boost per firing.
 * count=0 → 1.0, count=2 → ~0.74, count=9 → ~0.59, count=99 → ~0.43 */
static double engram_activation_dampen(const EngramNode* n) {
    return 1.0 / (1.0 + log(1.0 + (double)n->activation_count));
}

/* ── ACT-R fan effect: degree normalization for spreading activation ─────────
 * (2026-08-11 self-review. Closes the other half of a mechanism that has been
 * half-implemented since the BLL work.)
 *
 * THE GAP. This runtime implements ACT-R's base-level learning term
 * B_i = ln(Σ t_k^-d) (engram_bll_base_level) but never implemented the
 * ASSOCIATIVE term that goes with it:
 *
 *     A_i = B_i + Σ_j W_j · S_ji        where  S_ji = S − ln(fan_j)
 *
 * fan_j is the number of things j is associated with. The whole point of the
 * fan effect (Anderson 1974; Anderson & Reder 1999) is that a source spreads a
 * FIXED budget of activation across its associations — so being connected to
 * many things makes each individual connection weaker. Without it, degree is
 * pure advantage: a node wins retrieval by being popular rather than by being
 * relevant. That is backwards, and it is what this graph has been doing.
 *
 * MEASURED ON THE LIVE STORE (13,129 nodes / 43,400 edges, 2026-08-11):
 *   degree  p50=14  p90=34  p95=82  p99=275  max=357  mean=23.3
 *   the top 1% of nodes by degree touch 21.2% of all edges
 * So the most-connected node had a 25x propagation advantage over the median
 * node for no reason other than accumulated connections. The top hubs are not
 * even semantically central — several are duplicate pairs of the same document
 * left over from the redundancy census of the 2026-08-05 review.
 *
 * The hub problem was already recognized twice and patched narrowly both
 * times: InternalStateEvent nodes were cut out of propagation entirely (see
 * the frontier loop) and eg_hebb_node_budget caps per-node Hebbian mass. Both
 * are special cases of this general law. This is the general fix.
 *
 * FORM. Symmetric normalization, w / (deg(u)^β · deg(v)^β) with β = 0.5 — the
 * normalized-Laplacian / GCN form, which penalizes a hub both for sending and
 * for receiving. Both failure modes are live here: a hub source floods its
 * neighborhood, and a hub target gets reached by everything regardless of
 * relevance. Written relative to the graph's own mean degree:
 *
 *     fan(u,v) = clamp( d_ref / sqrt(deg(u) · deg(v)),  FAN_MIN, 1.0 )
 *     d_ref    = 2·|E| / |V|                        (mean degree, O(1), live)
 *
 * WHY IT IS CLAMPED AT 1.0 ON TOP — this is the load-bearing safety property,
 * not a detail. The factor can only ever REDUCE propagation, never amplify it.
 * Every constant downstream of this multiply is calibrated against today's
 * activation magnitudes: the 0.02 firing threshold, SPREAD_DECAY = 0.7, the
 * 0.15 WM promotion threshold, the 24-slot WM cap. A normalization that
 * boosted low-degree nodes would inflate the frontier, change how many nodes
 * clear 0.02, and silently recalibrate working memory as a side effect of a
 * change that was supposed to be about hubs. Capping at 1.0 means every pair
 * at or below mean degree — the common case — propagates EXACTLY as it does
 * today, and the only behavior that changes is that above-mean hubs stop
 * winning on degree alone. Strictly monotone, strictly conservative, and the
 * blast radius is confined to the nodes the change is aimed at.
 *
 * Self-calibrating: d_ref is recomputed from the live graph, so the correction
 * tracks densification instead of drifting against a constant that was right
 * in August 2026 and wrong a year later. Change is the signal.
 *
 * FAN_MIN = 0.30 bottoms the penalty at ~3.3x rather than the ~15x that raw
 * 1/deg would give at max degree. Same reasoning as ENGRAM_QGATE_FLOOR: damp
 * the uninformative path, never sever it. A hub is usually a hub for a reason;
 * it just should not also get a free win.
 *
 * Sources: Anderson & Reder 1999 (fan effect, S=1.6-2.0, d=0.5) ·
 * arXiv:2405.14831 HippoRAG (node specificity) · Systems 9(2):22
 * (normalized-Laplacian spreading activation) · arXiv:2606.30133 (β is a
 * low-sensitivity knob; gating and fan normalization carry the effect). */
/* FAN_MIN 0.50, not the 0.30 this shipped as on the first build. Measured on
 * the live graph, β=0.5 with a 0.30 floor damped 96% of propagation steps to a
 * mean factor of 0.34 — and that number is not a bug in the correction, it is
 * an honest measurement of how hub-dominated traversal here actually is. But a
 * ~3x near-uniform damp is a bigger global change than one A/B run justifies,
 * and it cost a working-memory promotion (5 → 4) on the one query measured
 * cleanly. A 0.50 floor keeps the full mechanism and the whole [0.5, 1.0]
 * dynamic range for separating hubs from non-hubs, at half the blast radius.
 * The fan_mean / fan_hits gauges make the next review's tuning evidence-based
 * rather than another guess: loosen it when the data says WM can afford it. */
#define ENGRAM_FAN_MIN  0.50

/* eg_node_degree — total (in + out) degree from the adjacency index. The index
 * is rebuilt at the top of engram_activate whenever topology changed, so this
 * is current. adj_node_count is the count at BUILD time and can lag
 * node_count; out-of-range indices report 0 and are treated as unpenalized. */
static int eg_node_degree(const EngramStore* g, int64_t idx) {
    if (idx < 0 || idx >= g->adj_node_count) return 0;
    if (!g->adj_from_len || !g->adj_to_len) return 0;
    return g->adj_from_len[idx] + g->adj_to_len[idx];
}

static double eg_fan_factor(const EngramStore* g, double d_ref,
                            int64_t u_idx, int64_t v_idx) {
    if (d_ref <= 0.0) return 1.0;
    int du = eg_node_degree(g, u_idx);
    int dv = eg_node_degree(g, v_idx);
    /* Degree 0 is only reachable when the adjacency index is stale or absent;
     * an actually-isolated node is never on the frontier. Do not penalize what
     * we cannot measure. */
    if (du <= 0 || dv <= 0) return 1.0;
    double f = d_ref / sqrt((double)du * (double)dv);
    if (f > 1.0) return 1.0;              /* never amplify — see above */
    if (f < ENGRAM_FAN_MIN) return ENGRAM_FAN_MIN;
    return f;
}

/* Temporal proximity bonus: boost propagation along edges connecting
 * co-temporal nodes. Returns a multiplier bonus in [0, 0.2]. */
static double engram_temporal_proximity_bonus(int64_t node_created,
                                               int64_t seed_epoch) {
    int64_t diff = node_created - seed_epoch;
    if (diff < 0) diff = -diff;
    if (diff < 86400000LL)    return 0.20; /* within 1 day  */
    if (diff < 604800000LL)   return 0.10; /* within 7 days */
    return 0.0;
}

/* ── Two-layer activation (biologically-motivated) ───────────────────────────
 *
 * Layer 1 — Broad fan-out (background activation):
 *   BFS + spreading activation fires on ALL nodes reachable from seeds,
 *   regardless of relevance to the current goal. Every reachable node gets
 *   a background_activation score. Nothing is filtered here. Models the
 *   brain's massive parallel sub-threshold activation of all associated
 *   content in response to a stimulus. Temporal decay and activation
 *   dampening are applied at this layer (as before), but no threshold gate.
 *
 * Layer 2 — Executive filter (working memory promotion):
 *   A second pass asks: given the query (goal intent), attentional bias,
 *   and inhibitory edge topology — which background-activated nodes should
 *   break through into working memory?
 *
 *     wm_weight = bg_activation * goal_bias(node, query) * confidence
 *                 * inhibitory_suppression_factor
 *
 *   Only nodes where wm_weight >= ENGRAM_WM_THRESHOLD are promoted to
 *   working memory (working_memory_weight > 0). Background-activated nodes
 *   that don't cross the threshold accumulate suppression_count. After
 *   ENGRAM_SUPPRESSION_BREAKTHROUGH consecutive suppressed turns, the node
 *   force-breaks through at ENGRAM_BREAKTHROUGH_WEIGHT (latent tension
 *   surfacing — models intrusive memory / unresolved cognitive load).
 *
 * Inhibitory edges:
 *   An edge with inhibitory=1 suppresses the TARGET node's working memory
 *   promotion when the SOURCE is background-activated. Background activation
 *   of the target is NOT affected — the node fires in layer 1. Only the
 *   executive filter (layer 2) is gated. Models attentional inhibition:
 *   "focused on code work" suppresses personal memories from surfacing
 *   even if they have high background_activation.
 *
 * Goal bias:
 *   A lightweight heuristic rates how well each background-activated node
 *   aligns with the apparent intent of the current query. Technical queries
 *   boost Belief/Canonical/Lesson nodes; relational queries boost Memory/
 *   Entity nodes. Direct lexical overlap gives a 50% bonus.
 *
 * Working memory persistence (turn continuity):
 *   Nodes promoted in the previous turn retain a decayed working_memory_weight
 *   (ACT-R base-level carry-over, 2026-07-22) without needing re-activation. This models
 *   conversational thread continuity — once a topic is in working memory,
 *   it persists slightly into the next turn.
 *
 * Returns ElList of {node, activation_strength, working_memory_weight,
 *   epistemic_confidence, hops, promoted}.
 * "promoted" = 1 if working_memory_weight > 0, 0 if background-only.
 * Context compilation uses ONLY nodes with promoted=1.
 *
 * Temporal decay (preserved from prior implementation):
 *   effective_salience = salience * exp(-lambda * age_hours / T_half)
 *   where T_half = 168 h (one week), lambda = ln(2)
 *
 * Activation dampening (preserved):
 *   dampen = 1 / (1 + log(1 + activation_count))
 *
 * Temporal proximity bonus (preserved):
 *   edge_strength *= (1 + tbonus) where tbonus ∈ {0, 0.10, 0.20}
 *
 * Per-type threshold gates apply only to working memory promotion (layer 2):
 *   Safety/DharmaSelf: 0.05  Canonical: 0.15  Lesson: 0.25
 *   Belief/Entity: 0.30      Note/Memory/Working: 0.40
 */

/* Compute goal-state bias multiplier for a node given the query.
 * Returns a value in [0.3, 2.0]. This is a lightweight heuristic —
 * a production implementation may use LLM-derived intent classification. */
static double engram_goal_bias(const EngramNode* n, const char* query) {
    if (!query || !*query) return 1.0;
    double bias = 1.0;
    /* Direct lexical overlap, graded by token coverage: a node covering all
     * query tokens gets the full +0.5; partial coverage gets a proportional
     * share. Single-token queries → full +0.5 on match, identical to before.
     * (2026-07-19 port of the 2026-07-14 tokenized-search fix) */
    {
        char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
        int ntok = engram_tokenize_query(query, toks, ENGRAM_MAX_QTOKENS);
        int sc   = engram_node_match_score(n, toks, ntok);
        if (sc > 0 && ntok > 0) bias += 0.5 * ((double)sc / (double)ntok);
    }
    /* Node-type resonance with query intent. */
    int technical_query = istr_contains(query, "code")         ||
                          istr_contains(query, "function")     ||
                          istr_contains(query, "implement")    ||
                          istr_contains(query, "error")        ||
                          istr_contains(query, "bug")          ||
                          istr_contains(query, "build")        ||
                          istr_contains(query, "system")       ||
                          istr_contains(query, "design")       ||
                          istr_contains(query, "architecture") ||
                          /* Curiosity-scan seeds: without these, idle-loop
                           * activation queries ("decision pattern lesson",
                           * "memory knowledge context") produce no goal-bias
                           * differentiation at all. Ported from dev-line fix
                           * d53516b (2026-06-14). (2026-07-19 self-review) */
                          istr_contains(query, "knowledge")    ||
                          istr_contains(query, "pattern")      ||
                          istr_contains(query, "decision")     ||
                          istr_contains(query, "memory")       ||
                          istr_contains(query, "lesson");
    int personal_query  = istr_contains(query, "feel")         ||
                          istr_contains(query, "emotion")      ||
                          istr_contains(query, "remember")     ||
                          istr_contains(query, "personal")     ||
                          istr_contains(query, "story")        ||
                          istr_contains(query, "relationship");
    if (n->node_type) {
        int is_knowledge = (strcmp(n->node_type, "Belief")     == 0) ||
                           (strcmp(n->node_type, "DharmaSelf") == 0) ||
                           (strcmp(n->node_type, "Safety")     == 0) ||
                           /* The primary knowledge-capture type was absent
                            * from its own bias class: captureKnowledge() and
                            * the world ingestor write node_type "Knowledge",
                            * which competed at neutral bias on technical
                            * queries. Ported from dev-line fix d53516b.
                            * (2026-07-19 self-review) */
                           (strcmp(n->node_type, "Knowledge")  == 0);
        int is_personal  = (strcmp(n->node_type, "Memory")     == 0) ||
                           (strcmp(n->node_type, "Entity")     == 0);
        if (technical_query && is_knowledge) bias += 0.3;
        if (technical_query && is_personal)  bias -= 0.3;
        if (personal_query  && is_personal)  bias += 0.3;
        if (personal_query  && is_knowledge) bias -= 0.1;
    }
    /* Tier-based bonus: promote higher-confidence knowledge nodes. */
    if (n->tier) {
        if (strcmp(n->tier, "Canonical") == 0) bias += 0.2;
        if (strcmp(n->tier, "Lesson")    == 0) bias += 0.1;
    }
    if (bias < 0.3) bias = 0.3;
    if (bias > 2.0) bias = 2.0;
    return bias;
}

/* ── M8 wiring: persistent ANN over resident node embeddings ────────────────
 * A single process-lifetime HNSW index (engram_vindex) accelerates activation
 * seed SELECTION (see engram_activate). The index node_id IS the resident
 * g->nodes[] index, so a search result maps back with zero lookup. Built lazily
 * on first use from every resident node carrying an emb of the query dim; grown
 * incrementally as newly-appended nodes get embedded (cheap tail scan — the
 * activation backfill loop mints embeddings newest-first, so fresh content is
 * indexed within a scan cycle); fully rebuilt only when the emb dim changes or
 * the resident array SHRINKS (a compaction/reorder that could invalidate cached
 * indices).
 *
 * STALENESS (honest tradeoff): an embedding minted on an OLDER node (index below
 * the last-built count) by the backfill loop is not indexed until the next full
 * rebuild (process restart, dim change, or a shrink). This can only LOWER recall
 * for those nodes — it can NEVER mis-seed — because in engram_activate every ANN
 * candidate is re-validated against the EXACT cosine (cosq[bi] ≥ SEED_MIN) and
 * 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 */
static uint8_t* _eg_vindex_seen     = NULL;/* per-ordinal: 1 iff inserted into _eg_vindex */
static int64_t  _eg_vindex_seen_cap = 0;

/* Grow the per-ordinal "indexed" bitmap to hold at least `need` entries, zeroing
 * the new tail. Returns 0 on success, -1 on OOM (caller keeps the old map). */
static int eg_vindex_seen_ensure(int64_t need) {
    if (need <= _eg_vindex_seen_cap) return 0;
    int64_t nc = _eg_vindex_seen_cap ? _eg_vindex_seen_cap : 1024;
    while (nc < need) nc *= 2;
    uint8_t* ns = (uint8_t*)realloc(_eg_vindex_seen, (size_t)nc);
    if (!ns) return -1;
    memset(ns + _eg_vindex_seen_cap, 0, (size_t)(nc - _eg_vindex_seen_cap));
    _eg_vindex_seen = ns; _eg_vindex_seen_cap = nc;
    return 0;
}

/* 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)) {
        vindex_free(_eg_vindex);
        _eg_vindex = NULL; _eg_vindex_dim = 0; _eg_vindex_built_nc = 0;
        free(_eg_vindex_seen); _eg_vindex_seen = NULL; _eg_vindex_seen_cap = 0;
    }
    if (!_eg_vindex) {
        VIndex* idx = vindex_create((int)dim, 0, 0);
        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)
                _eg_vindex_seen[i] = 1;
        }
        _eg_vindex = idx; _eg_vindex_dim = dim; _eg_vindex_built_nc = g->node_count;
    } else if (eg_vindex_seen_ensure(g->node_count) == 0) {
        /* Incremental (embed-gap #20 fix): index EVERY node that now carries an emb
         * but is not yet in the index — whether newly APPENDED or lazily EMBEDDED on
         * an OLDER ordinal by the backfill loop. The previous tail-only scan left a
         * lazily-embedded older node invisible until the next full rebuild; that node
         * was silently absent from route_nearest / autoconnect (which have NO exact
         * top-up, unlike engram_activate) and lowered activation recall. This O(node_count)
         * presence check carries no D factor, so it is negligible beside the cosq scan on
         * the same path. On seen-map OOM we fall through unchanged (index simply not grown). */
        for (int64_t i = 0; i < g->node_count; i++) {
            if (_eg_vindex_seen[i]) continue;
            EngramNode* n = &g->nodes[i];
            if (n->emb && n->emb_dim == dim && vindex_insert(_eg_vindex, (uint64_t)i, n->emb) == 0)
                _eg_vindex_seen[i] = 1;
        }
        _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)
 * into activation seed selection. When the flag is UNSET or "0" every code path
 * below is skipped and engram_activate is byte-identical to M8. When set, after
 * M8 has produced its ANN seed set, the centered geometry of that neighborhood is
 * used to (a) DAMP off-domain seeds by centered membership (disambiguation) and
 * (b) PRIME nearby neighborhood members sub-threshold (warm floor). It COMPOSES
 * with M8 — it never removes an M8 seed nor changes ANN candidate discovery.
 *
 * The geometry descriptor + global-mean run over the PAGED store (g_engram_store)
 * by string id; the runtime's resident-array VIndex is passed through with a
 * vids[] map (vids[i] == g->nodes[i].id) so its ordinals resolve. This is the
 * behaviour-changing M9 step gated behind a reversible flag — see runbook
 * 2026-08-12-geometry-priming-cutover-reversal.md. */
static int eg_geometry_priming_on(void) {
    static int cached = -1;
    if (cached < 0) {
        const char* s = getenv("ENGRAM_GEOMETRY_PRIMING");
        cached = (s && s[0] && s[0] != '0') ? 1 : 0;
    }
    return cached;
}

/* Runtime-owned centered-frame global mean over the paged store's embedded set.
 * Built lazily on first priming call, recomputed only when the embedded count
 * drifts >10% (engram_geo_mean_maybe_refresh). Process-lifetime, single-threaded,
 * alongside _eg_vindex. Returns NULL if unavailable (no paged store / no embeds /
 * dim mismatch) → caller falls back to pure-M8 behaviour for that call. */
static GeoMeanCache* _eg_geo_mean = NULL;
static const float*  eg_geo_mean_sync(int32_t dim) {
    if (!g_engram_store || dim <= 0) return NULL;
    if (!_eg_geo_mean) {
        _eg_geo_mean = engram_geo_mean_build(g_engram_store);
        if (!_eg_geo_mean) return NULL;
    } else {
        (void)engram_geo_mean_maybe_refresh(_eg_geo_mean, g_engram_store, 0.10);
    }
    if (engram_geo_mean_dim(_eg_geo_mean) != dim) return NULL; /* embedder dim moved */
    return engram_geo_mean_vec(_eg_geo_mean);
}

/* Geometry-priming tunables (all bounded so the flag can only SHARPEN, never
 * amplify or overflow WM). Overridable via env for the A/B without a rebuild. */
static double eg_geo_seed_lo(void) {   /* seed damp floor: factor in [LO,1] */
    const char* s = getenv("ENGRAM_GEO_SEED_LO"); double v = s ? atof(s) : 0.5;
    if (!(v >= 0.0 && v <= 1.0)) v = 0.5; return v;
}
static double eg_geo_prime_scale(void){ /* primed warm act = membership*scale (< WM gate) */
    const char* s = getenv("ENGRAM_GEO_PRIME_SCALE"); double v = s ? atof(s) : 0.08;
    if (!(v > 0.0 && v < ENGRAM_WM_THRESHOLD)) v = 0.08; return v;
}
static int eg_geo_prime_max(void){      /* cap primed members added to the frontier */
    const char* s = getenv("ENGRAM_GEO_PRIME_MAX"); int v = s ? atoi(s) : 32;
    if (v < 0) v = 0; if (v > 256) v = 256; return v;
}

/* Lazy per-node cosine vs the effective query (M8.1 activate-latency fix,
 * 2026-08-14). Computes cos(node_i, query) ON DEMAND and memoizes it, replacing
 * the full O(N·D) prescan that dominated live activate latency (~330ms + CPU peg
 * + restart thrash). Returns exactly the value the prescan produced for any node
 * it is asked about; nodes never asked about are never computed — and the prescan
 * version never READ them either (Pass-3 skips !reached[i]; the exact top-up only
 * runs when the ANN underfills), so engram_activate stays BIT-IDENTICAL while
 * touching only ANN candidates + propagation-reached nodes. Degrades to the -2.0
 * "no/!=dim embedding" sentinel exactly as the prescan did. Callers guard with
 * `if (cosq)`, so cosq/cos_done are non-NULL here. */
static inline double eg_cosq_at(EngramStore* g, double* cosq, unsigned char* cos_done,
                                const float* qv, int32_t dim, int64_t i) {
    if (cos_done[i]) return cosq[i];
    EngramNode* n = &g->nodes[i];
    cosq[i] = (n->emb && n->emb_dim == dim && qv) ? eg_cosine(n->emb, qv, dim) : -2.0;
    cos_done[i] = 1;
    return cosq[i];
}

/* ── Beam cap for engram_activate spreading activation (2026-08-15, PR #105
 * port) ──────────────────────────────────────────────────────────────────
 * #105 measured the OLD (pre-adjacency-index, pre-qgate, pre-fan-effect)
 * BFS reaching multi-second/crash territory at depth 2-3 from unbounded
 * hub-node fan-out. That specific failure mode is already substantially
 * mitigated here by mechanisms #105's branch predates: the adjacency index
 * (O(degree) not O(E) per hop), the query-aware qgate (prunes semantically
 * irrelevant branches), the ACT-R fan-effect correction (dampens popular-
 * hub over-connectivity), and the 0.02 firing threshold. A beam cap is still
 * a genuine additional, orthogonal bound: it caps WORST-CASE per-hop
 * expansion width regardless of how many targets happen to pass the soft
 * gates above, so it is kept as defense in depth rather than dropped as
 * redundant.
 *
 * Bounds the number of frontier nodes EXPANDED per hop-level (see the
 * level-batching in the BFS below). Every reached node still gets its
 * best_bg[]/reached[] recorded and appears in the returned/promoted set —
 * the cap bounds only how far ASSOCIATIVE SPREAD continues past a level,
 * never the direct seed matches or the reported result set. Tunable via
 * ENGRAM_ACTIVATE_BEAM (default 128, matching #105); set very high (e.g.
 * the node count) to recover the pre-cap unbounded-per-level behaviour. */
static int64_t engram_activate_beam(void) {
    static int64_t v = -1;
    if (v >= 0) return v;
    const char* s = getenv("ENGRAM_ACTIVATE_BEAM");
    int64_t d = 128;
    if (s && *s) { char* e = NULL; long t = strtol(s, &e, 10); if (e != s && t > 0) d = (int64_t)t; }
    v = d; return v;
}

/* Core activation. Callers must hold the engram request lock — reached only via
 * the engram_activate() wrapper below, which self-guards (see eg_guard_enter). */
static el_val_t engram_activate_inner(el_val_t query, el_val_t depth) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t max_depth = (int64_t)depth; if (max_depth <= 0) max_depth = 2;
    el_val_t out = el_list_empty();
    if (!q || g->node_count == 0) return out;
    _eg_aff_activations++;   /* P4 afferent counter: a real spreading activation ran */

    /* Rebuild adjacency index if the edge/node topology changed since the
     * last activation call. This is O(E) one-time cost vs O(E) per BFS step
     * without the index. On a 40K-edge graph this drops BFS from O(frontier
     * * E) to O(frontier * avg_degree). (2026-07-01 self-review) */
    if (g->adj_dirty || !g->adj_from) engram_adj_rebuild(g);

    int64_t now_ms = engram_now_ms();

    /* Observability counters: _eg_act_breakthroughs/_eg_act_wm_evicted are
     * CUMULATIVE for the process lifetime and intentionally NOT reset here
     * (2026-07-31 self-review — the old per-call reset made the 60s heartbeat
     * miss nearly all events between beats; see the definition site).
     * ctx_cos stays per-call: it is a gauge of THIS query vs the centroid. */
    _eg_act_ctx_cos       = -2.0;
    _eg_act_fan_sum = 0.0; _eg_act_fan_min = 1.0;
    _eg_act_fan_n   = 0;   _eg_act_fan_hits = 0;

    /* ── Embedding backfill + query embedding (2026-07-24, bl-b2d1c944) ──
     * Backfill: embed up to N un-embedded eligible nodes per call, newest
     * first (append order ≈ creation order), so fresh content is semantic-
     * searchable within one scan cycle and the historical store fills in
     * gradually — ~16 nodes/min under the 30s curiosity cadence, no bulk
     * hammering of Ollama, no latency on any create path. */
    {
        int backfilled = 0;
        for (int64_t i = g->node_count - 1;
             i >= 0 && backfilled < ENGRAM_EMBED_BACKFILL_PER_CALL; i--) {
            EngramNode* n = &g->nodes[i];
            if (n->emb || !eg_embed_eligible(n)) continue;
            int32_t d = 0;
            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++;
        }
    }
    /* Query embedding cache (2026-08-15, PR #105 port: single-slot -> direct-
     * mapped multi-slot). The single-slot cache below this comment's history
     * only remembered the LAST query, so "the curiosity loop re-issues the
     * same 4 rotating phrases" only hit when two CONSECUTIVE calls used the
     * SAME phrase — any rotation among >1 phrase evicted the slot before it
     * could be reused. #105 measured this as a real cost (a repeated query
     * costing a full Ollama round-trip whenever a different phrase intervened)
     * and fixed it with a direct-mapped, FNV-1a-keyed cache sized for the
     * rotation. Ported here on TOP of the existing cosq/e_eff semantic layer
     * (this cache only ever supplies q_emb/q_dim into that unchanged
     * pipeline) rather than replacing it — see the M8/#105 reconciliation
     * note above eg_cosq_at. ENGRAM_QCACHE_SIZE must be a power of two (mask
     * indexing below). Full strcmp on lookup rejects hash collisions; each
     * slot owns its `text`/`vec` and is freed on eviction, matching the old
     * single-slot free/replace contract — q_emb below still points at cache-
     * owned memory the caller must NOT free, just as before. */
#define ENGRAM_QCACHE_SIZE 1024
    typedef struct { char* text; uint64_t hash; float* vec; int32_t dim; } EgQCacheEntry;
    static EgQCacheEntry _eg_qcache[ENGRAM_QCACHE_SIZE];
    float*  q_emb = NULL;
    int32_t q_dim = 0;
    {
        uint64_t qh = engram_id_hash(q);
        EgQCacheEntry* slot = &_eg_qcache[qh & (ENGRAM_QCACHE_SIZE - 1)];
        if (slot->vec && slot->hash == qh && slot->text && strcmp(slot->text, q) == 0) {
            q_emb = slot->vec; q_dim = slot->dim;
        } else {
            int32_t d = 0;
            float* v = eg_embed_fetch(q, &d);
            if (v) {
                free(slot->text); free(slot->vec);   /* evict prior occupant */
                slot->text = strdup(q);
                slot->hash = qh;
                slot->vec  = v;
                slot->dim  = d;
                q_emb = v; q_dim = d;
            }
        }
    }
    /* ── Context centroid fold-in (2026-07-29) ──────────────────────────
     * Record drift BEFORE blending (cos of the query against yesterday's
     * context), then fold the query in as a touch, then build the
     * query-dominant effective scoring vector. See the ENGRAM_CTX_* block
     * for the design and the feedback-loop guard rationale. */
    float* e_eff = NULL;
    if (q_emb) {
        if (_eg_ctx_c && _eg_ctx_dim == q_dim)
            _eg_act_ctx_cos = eg_cosine(q_emb, _eg_ctx_c, q_dim);
        eg_ctx_blend(q_emb, q_dim);
        if (_eg_ctx_c && _eg_ctx_dim == q_dim) {
            e_eff = malloc((size_t)q_dim * sizeof(float));
            if (e_eff) {
                double nq = 0.0;
                for (int32_t i = 0; i < q_dim; i++)
                    nq += (double)q_emb[i] * (double)q_emb[i];
                nq = (nq > 0.0) ? sqrt(nq) : 1.0;
                double nn = 0.0;
                for (int32_t i = 0; i < q_dim; i++) {
                    double v = ENGRAM_CTX_QALPHA * ((double)q_emb[i] / nq)
                             + (1.0 - ENGRAM_CTX_QALPHA) * (double)_eg_ctx_c[i];
                    e_eff[i] = (float)v;
                    nn += v * v;
                }
                if (nn <= 0.0) { free(e_eff); e_eff = NULL; }
            }
        }
    }
    /* Per-node cosine vs the effective query (query ⊕ context centroid;
     * plain query on cold start), computed once, consumed twice: semantic
     * seeding below and the additive WM term in Pass 2 (use similarity
     * twice, coherently — HippoRAG). cosq stays NULL when the embedder is
     * unavailable; every consumer degrades to pure lexical behavior. */
    double* cosq = NULL;
    unsigned char* cos_done = NULL;   /* M8.1: which cosq[i] have been computed (lazy) */
    float* cosq_qv = NULL;            /* M8.1: OWNED copy of the effective query vector,
                                       * kept alive past the e_eff free (9889) for the
                                       * lazy cosq reads in Pass-2/Pass-3 propagation. */
    if (q_emb) {
        const float* qv = e_eff ? e_eff : q_emb;
        cosq     = calloc((size_t)g->node_count, sizeof(double));
        cos_done = calloc((size_t)g->node_count, 1);
        if (cosq && cos_done && qv) {
            cosq_qv = malloc((size_t)q_dim * sizeof(float));
            if (cosq_qv) memcpy(cosq_qv, qv, (size_t)q_dim * sizeof(float));
        }
        /* M8.1 activate-latency fix: NO full O(N·D) prescan here. cosq is filled
         * lazily via eg_cosq_at() only for ANN candidates + propagation-reached
         * nodes — bit-identical to the prescan (see eg_cosq_at). If any alloc
         * failed, degrade to embedder-down behaviour: every `if (cosq)` consumer
         * skips and activation falls back to pure lexical, exactly as before. */
        if (!cosq || !cos_done || !cosq_qv) {
            free(cosq); free(cos_done); free(cosq_qv);
            cosq = NULL; cos_done = NULL; cosq_qv = NULL;
        }
    }
    /* NOTE (M8): e_eff is NOT freed here anymore — the effective query vector
     * (query ⊕ context centroid) is reused below as the ANN query for seed
     * selection so the ANN searches the SAME direction cosq was scored against.
     * It is freed right after the semantic-seed-supplement block. */

    /* Per-node layer-1 tracking. */
    double*  best_bg    = calloc((size_t)g->node_count, sizeof(double));
    int64_t* best_hops  = calloc((size_t)g->node_count, sizeof(int64_t));
    int*     reached    = calloc((size_t)g->node_count, sizeof(int));
    if (!best_bg || !best_hops || !reached) {
        free(best_bg); free(best_hops); free(reached); free(cosq); free(cos_done); free(cosq_qv); free(e_eff); return out;
    }

    /* ── LAYER 1: broad fan-out (background activation) ─────────────────
     * Find seeds, apply temporal decay + dampening, BFS with edge weights.
     * Inhibitory edges propagate activation normally at this layer — they
     * only gate working memory promotion in layer 2. */
    typedef struct { int64_t idx; double act; int64_t created_at; } SeedEntry;
    SeedEntry* seeds = malloc((size_t)g->node_count * sizeof(SeedEntry));
    int64_t seed_count = 0;
    if (!seeds) {
        free(best_bg); free(best_hops); free(reached); free(cosq); free(cos_done); free(cosq_qv); free(e_eff); return out;
    }
    /* Tokenize once: a node seeds if it matches ANY query token, and its seed
     * activation is scaled by token coverage (fraction of distinct query
     * tokens it contains) so a node matching all words seeds more strongly
     * than one matching a single word. Single-word queries → coverage 1.0,
     * identical to the prior whole-query behavior. Before this, the soul's
     * rotating 3-word curiosity seeds ("working project active") activated
     * ZERO nodes almost every scan — idle cognition firing blanks.
     * (2026-07-19 port of the 2026-07-14 tokenized-search fix; NOTE the
     * el-compiler copy of this fix dropped the ISE seed exclusion below —
     * kept here deliberately, do not "sync" it away.) */
    char qtoks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
    int qntok = engram_tokenize_query(q, qtoks, ENGRAM_MAX_QTOKENS);
    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;
        int msc = engram_node_match_score(n, qtoks, qntok);
        if (msc > 0) {
            double tdecay = engram_temporal_decay(n, now_ms);
            double dampen = engram_activation_dampen(n);
            double cover  = qntok > 0 ? (double)msc / (double)qntok : 1.0;
            double act    = n->salience * tdecay * dampen * cover;
            seeds[seed_count].idx        = i;
            seeds[seed_count].act        = act;
            seeds[seed_count].created_at = n->created_at;
            seed_count++;
            best_bg[i]   = act;
            best_hops[i] = 0;
            reached[i]   = 1;
        }
    }
    /* ── Semantic seed supplement (2026-07-24, bl-b2d1c944) ─────────────
     * Top-K nodes by cosine ≥ SEED_MIN join the seed set with initial
     * activation = similarity × the same decay/dampen shaping the lexical
     * seeds get. This is the fix for "idle cognition firing blanks": a
     * curiosity phrase like "decision pattern lesson" now ignites nodes
     * that MEAN decisions and lessons, not just nodes that contain those
     * literal substrings. Lexically-seeded nodes are skipped — the lexical
     * path already gave them coverage-scaled activation. */
    if (cosq) {
        /* Redundancy-suppressed top-K (2026-08-05 self-review; see
         * ENGRAM_DEDUP_COS for the measurement that motivated it). A rejected
         * candidate does NOT consume one of the K slots — the loop retries for
         * the next-best distinct node, so K distinct meanings are seeded rather
         * than K copies of one. Rejects are recorded in seed_dup[] rather than
         * reached[] or cosq[]: marking reached[] would suppress the node's
         * propagation, and clobbering cosq[] would change the downstream
         * query-aware propagation gate. Neither belongs in a seeding decision.
         * `guard` bounds the retries so a pathological duplicate cluster can
         * never turn seed selection into an O(K·N²) scan. */
        unsigned char* seed_dup = calloc((size_t)g->node_count, 1);
        int64_t  sel[ENGRAM_EMBED_SEED_K];
        uint64_t selkey[ENGRAM_EMBED_SEED_K];
        int nsel = 0;

        /* ── M8: ANN-accelerated seed candidates (engram_vindex) ────────────
         * Ask the persistent HNSW index for the nearest embedded nodes to the
         * effective query vector in ~O(log N), replacing the O(K·N) exact
         * argmax scan below as the seed DISCOVERY mechanism. Every ANN
         * candidate is then admitted through the IDENTICAL gate the exact scan
         * uses — real cosine threshold (cosq[bi] > SEED_MIN, which also rejects
         * the -2.0 "no/!=dim emb" sentinel), reached[]/seed_dup[] skips, content
         * dedup, and the same decay/dampen shaping — so the ANN changes only
         * WHICH nodes are discovered, never how a discovered node is scored or
         * seeded. The exact scan below is preserved verbatim and tops up any
         * slot the ANN leaves unfilled (recall < 1, or index stale/absent), so
         * seed quality can never regress and the pre-M8 behaviour is recovered
         * exactly when the index is unavailable. Request K*8 candidates — the
         * same budget as the exact scan's retry `guard` — so dedup/threshold
         * rejects still leave enough distinct seeds. */
        {
            /* 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;
                uint64_t* aid = malloc((size_t)kreq * sizeof(uint64_t));
                float*    ad  = malloc((size_t)kreq * sizeof(float));
                if (seed_qv && aid && ad) {
                    int got = vindex_search(vx, seed_qv, kreq,
                                            VINDEX_DEFAULT_EF_SEARCH, aid, ad);
                    for (int r = 0; r < got && nsel < ENGRAM_EMBED_SEED_K; r++) {
                        int64_t bi = (int64_t)aid[r];
                        if (bi < 0 || bi >= g->node_count) continue; /* stale id guard */
                        if (reached[bi]) continue;              /* lexically seeded */
                        if (seed_dup && seed_dup[bi]) continue;
                        double bc = eg_cosq_at(g, cosq, cos_done, cosq_qv, q_dim, bi);   /* EXACT cosine — parity gate (lazy) */
                        if (!(bc > ENGRAM_EMBED_SEED_MIN)) continue;
                        EngramNode* n = &g->nodes[bi];
                        uint64_t key = eg_content_key(n);
                        int dup = 0;
                        for (int s = 0; s < nsel; s++) {
                            if (eg_same_content(n, &g->nodes[sel[s]], key, selkey[s])) {
                                dup = 1; break;
                            }
                        }
                        if (dup) {
                            _eg_act_dup_seeds++;
                            if (seed_dup) seed_dup[bi] = 1;
                            continue;
                        }
                        double tdecay = engram_temporal_decay(n, now_ms);
                        double dampen = engram_activation_dampen(n);
                        double act    = bc * tdecay * dampen;
                        seeds[seed_count].idx        = bi;
                        seeds[seed_count].act        = act;
                        seeds[seed_count].created_at = n->created_at;
                        seed_count++;
                        best_bg[bi]   = act;
                        best_hops[bi] = 0;
                        reached[bi]   = 1;
                        sel[nsel] = bi; selkey[nsel] = key; nsel++;
                    }
                }
                free(aid); free(ad);
            }
            eg_vindex_view_release();
        }

        /* Exact O(n) argmax fallback / top-up (pre-M8 selection, verbatim).
         * Runs only for seed slots the ANN did not fill: when the index is
         * unavailable it fills all K (identical to pre-M8); when the ANN filled
         * all K the `nsel < K` guard makes this a no-op (no O(n) scan). */
        int guard = ENGRAM_EMBED_SEED_K * 8;
        while (nsel < ENGRAM_EMBED_SEED_K && guard-- > 0) {
            int64_t bi = -1; double bc = ENGRAM_EMBED_SEED_MIN;
            for (int64_t i = 0; i < g->node_count; i++) {
                if (reached[i]) continue;
                if (seed_dup && seed_dup[i]) continue;
                double ci = eg_cosq_at(g, cosq, cos_done, cosq_qv, q_dim, i);
                if (ci > bc) { bc = ci; bi = i; }
            }
            if (bi < 0) break;
            EngramNode* n = &g->nodes[bi];
            uint64_t key = eg_content_key(n);
            int dup = 0;
            for (int s = 0; s < nsel; s++) {
                if (eg_same_content(n, &g->nodes[sel[s]], key, selkey[s])) {
                    dup = 1; break;
                }
            }
            if (dup) {
                _eg_act_dup_seeds++;
                if (seed_dup) { seed_dup[bi] = 1; continue; }
                break; /* OOM on the skip map: stop rather than spin */
            }
            double tdecay = engram_temporal_decay(n, now_ms);
            double dampen = engram_activation_dampen(n);
            double act    = bc * tdecay * dampen;
            seeds[seed_count].idx        = bi;
            seeds[seed_count].act        = act;
            seeds[seed_count].created_at = n->created_at;
            seed_count++;
            best_bg[bi]   = act;
            best_hops[bi] = 0;
            reached[bi]   = 1;
            sel[nsel] = bi; selkey[nsel] = key; nsel++;
        }
        free(seed_dup);

        /* ── GEOMETRY PRIMING (ENGRAM_GEOMETRY_PRIMING, default OFF) ─────────
         * COMPOSES with the M8 seed set above: resolves the seed neighborhood's
         * CENTERED geometry to (a) damp off-domain seeds by membership
         * (disambiguation) and (b) prime nearby members sub-threshold (warm floor).
         * Flag OFF → this whole block is skipped and the seed set/activation are
         * exactly what M8 produced (byte-identical). Read-only over the graph
         * except the bounded, sub-threshold seed additions here.
         *
         * M10: the neighborhood is READ from the PERSISTED first-class reify index
         * (a durable Neighborhood record's membership map) — O(seeds) hash lookup,
         * miss → centroid-nearest, NO geometry computed on the activation path. The
         * membership is centered against the true store-wide mean persisted in the
         * GeoMeanFrame record. Set ENGRAM_GEO_PRIMING_NOCACHE=1 to instead compute
         * the descriptor fresh per query (the M9 on-the-fly path — kept for ad-hoc
         * geometries and as the A/B latency control). */
        if (eg_geometry_priming_on() && nsel > 0) {
            static int _nocache = -1;
            if (_nocache < 0) { const char* s = getenv("ENGRAM_GEO_PRIMING_NOCACHE");
                                _nocache = (s && s[0] && s[0] != '0') ? 1 : 0; }

            const char** seed_ids = malloc((size_t)nsel * sizeof(char*));
            if (seed_ids) {
                for (int s = 0; s < nsel; s++) seed_ids[s] = g->nodes[sel[s]].id;

                char* const* mids = NULL;   /* resolved neighborhood member ids     */
                const double* mw  = NULL;   /* their centered membership in [0,1]    */
                int           mn  = 0;
                GeoDescriptor* geo = NULL;  /* on-the-fly path only (freed below)    */
                char** tmid = NULL; double* tmw = NULL;

                if (!_nocache && _eg_reify && engram_geo_reify_count(_eg_reify) > 0) {
                    /* HOT PATH — read persisted structure, no compute. */
                    const GeoNeighborhood* nb = engram_geo_reify_lookup(
                        _eg_reify, seed_ids, (size_t)nsel, q_emb, q_dim);
                    if (nb && nb->n_members > 0) {
                        mids = nb->member_ids; mw = nb->member_w; mn = nb->n_members;
                    }
                } else if (g_engram_store && q_emb && q_dim > 0) {
                    /* AD-HOC / NOCACHE control — compute the descriptor fresh. */
                    const float* gmean = eg_geo_mean_sync(q_dim);
                    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, 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) {
                        tmid = malloc((size_t)geo->n_members * sizeof(char*));
                        tmw  = malloc((size_t)geo->n_members * sizeof(double));
                        if (tmid && tmw) {
                            for (int m = 0; m < geo->n_members; m++) {
                                tmid[m] = geo->members[m].id; tmw[m] = geo->members[m].membership;
                            }
                            mids = tmid; mw = tmw; mn = geo->n_members;
                        }
                    }
                }

                if (mn > 0 && mids && mw) {
                    const double lo   = eg_geo_seed_lo();
                    const double pscl = eg_geo_prime_scale();
                    const int    pmax = eg_geo_prime_max();
                    /* membership per resident idx (-1 = not in the neighborhood). */
                    double* geo_m = malloc((size_t)g->node_count * sizeof(double));
                    if (geo_m) {
                        for (int64_t i = 0; i < g->node_count; i++) geo_m[i] = -1.0;
                        for (int m = 0; m < mn; m++) {
                            int64_t ri = engram_find_node_index(mids[m]);
                            if (ri >= 0 && ri < g->node_count) {
                                double mv = mw[m];
                                if (mv < 0.0) mv = 0.0; else if (mv > 1.0) mv = 1.0;
                                geo_m[ri] = mv;
                            }
                        }
                        /* (a) DAMP-ONLY seed reweight: factor = lo+(1-lo)*memb ∈ [lo,1].
                         * Off-domain members lose weight; anchor (memb→1) unchanged;
                         * seeds outside the neighborhood are left untouched. Never
                         * amplifies. Updates frontier act + best_bg (WM weight). */
                        for (int64_t s = 0; s < seed_count; s++) {
                            int64_t si = seeds[s].idx;
                            if (si < 0 || si >= g->node_count) continue;
                            double mv = geo_m[si];
                            if (mv < 0.0) continue;             /* not in neighborhood */
                            double factor = lo + (1.0 - lo) * mv;
                            seeds[s].act *= factor;
                            best_bg[si]  *= factor;
                        }
                        /* (b) PRIME sub-threshold: neighborhood members not already
                         * reached get a warm floor act=memb*pscl (pscl < WM gate ⇒
                         * cannot self-promote) and enter the frontier so a warm
                         * gradient spreads one hop then dies at the 0.02 BFS cutoff.
                         * Capped at pmax; ISE skipped. Safe: BFS keeps max, so this
                         * only RAISES a floor, never caps a legit activation. */
                        int primed = 0;
                        for (int m = 0; m < mn && primed < pmax; m++) {
                            int64_t ri = engram_find_node_index(mids[m]);
                            if (ri < 0 || ri >= g->node_count) continue;
                            if (reached[ri]) continue;          /* already a seed */
                            EngramNode* pn = &g->nodes[ri];
                            if (pn->node_type &&
                                strcmp(pn->node_type, "InternalStateEvent") == 0)
                                continue;
                            double mv = mw[m];
                            if (mv < 0.0) mv = 0.0; else if (mv > 1.0) mv = 1.0;
                            double pact = mv * pscl;
                            if (pact < 0.01) continue;          /* too cold to matter */
                            seeds[seed_count].idx        = ri;
                            seeds[seed_count].act        = pact;
                            seeds[seed_count].created_at = pn->created_at;
                            seed_count++;
                            best_bg[ri]   = pact;
                            best_hops[ri] = 0;
                            reached[ri]   = 1;
                            primed++;
                        }
                        _eg_act_geo_primed += primed;
                        free(geo_m);
                    }
                }
                free(tmid); free(tmw);
                if (geo) engram_geo_free(geo);
                free(seed_ids);
            }
        }
    }
    free(e_eff); e_eff = NULL; /* M8: no longer needed past seed selection */
    /* Compute mean seed created_at for temporal proximity bonus.
     * Was a running pairwise average — seed_epoch = (seed_epoch + t_s)/2 —
     * which is NOT the arithmetic mean: it exponentially over-weights the
     * later seeds (last seed gets weight 1/2, second-to-last 1/4, ...), so
     * the temporal-proximity bonus skewed toward whichever seeds happened
     * to sit later in the scan order. True mean via int64 sum: ms epochs
     * (~1.8e12) times any plausible seed_count stays far below INT64_MAX.
     * (2026-07-19 self-review) */
    int64_t seed_epoch = 0;
    if (seed_count > 0) {
        int64_t epoch_sum = 0;
        for (int64_t s = 0; s < seed_count; s++)
            epoch_sum += seeds[s].created_at;
        seed_epoch = epoch_sum / seed_count;
    }
    typedef struct { int64_t idx; int64_t hops; double act; } Frontier;
    Frontier* fr = malloc((size_t)(g->node_count * (max_depth + 1)) * sizeof(Frontier) + 16 * sizeof(Frontier));
    if (!fr) {
        free(best_bg); free(best_hops); free(reached); free(seeds); free(cosq); free(cos_done); free(cosq_qv); return out;
    }
    int64_t fhead = 0, ftail = 0;
    int64_t fcap = (int64_t)((size_t)(g->node_count * (max_depth + 1)) + 16);
    for (int64_t s = 0; s < seed_count; s++) {
        if (ftail >= fcap) break;
        fr[ftail].idx  = seeds[s].idx;
        fr[ftail].hops = 0;
        fr[ftail].act  = seeds[s].act;
        ftail++;
    }
    const double SPREAD_DECAY = 0.7;
    /* Reference degree for the fan-effect correction: mean degree over
     * CONNECTED nodes, 2|E| / |{v : deg(v) > 0}|. O(1) — adj_connected is
     * tallied during adjacency rebuild.
     *
     * NOT 2|E|/|V|. That was the first cut and instrumentation caught it
     * immediately: on the live graph it gives d_ref = 6.61, while the median
     * degree of a node that actually has edges is 14. Isolated nodes cannot
     * be on the frontier — spreading activation only ever traverses connected
     * ones — so including them in the denominator deflates the reference below
     * anything traversal will ever see, and the correction pins to
     * ENGRAM_FAN_MIN on every step. Measured on the first build:
     * fan_mean 0.3026 with fan_hits 579/579 — a uniform 0.30 multiplier, which
     * is not a fan effect at all. It is just a weaker SPREAD_DECAY, and it
     * would have quietly recalibrated the 0.02 firing threshold and WM
     * competition while appearing to be a targeted change.
     *
     * Over connected nodes the reference is ~23, above the median, so typical
     * traversal rides the 1.0 cap unchanged and only genuine hubs are damped
     * — which is the whole intent. The gauge that caught this is the reason it
     * was worth adding the gauge. */
    const double FAN_DREF = (g->adj_connected > 0)
        ? (2.0 * (double)g->edge_count / (double)g->adj_connected) : 0.0;
    _eg_act_fan_dref = FAN_DREF;
    const int64_t activate_beam = engram_activate_beam();
    while (fhead < ftail) {
      /* Level-batch (2026-08-15, PR #105 port): entries sharing .hops are
       * always contiguous — hop k+1 entries are appended only while
       * processing hop k, strictly after the current ftail, so they form one
       * block right after hop k's block (see engram_activate_beam's comment
       * for why this holds even with the improve-and-re-enqueue behavior
       * below). Find this level's extent, then beam-select which of it
       * EXPANDS; every entry in the level still gets recorded via
       * reached[]/best_bg[] regardless (that happened when it was enqueued,
       * one level up) — the cap bounds propagation width only. */
      int64_t level_hops  = fr[fhead].hops;
      int64_t level_start = fhead;
      int64_t level_end   = fhead;
      while (level_end < ftail && fr[level_end].hops == level_hops) level_end++;
      int64_t level_n = level_end - level_start;
      unsigned char* expand = NULL;
      if (level_n > activate_beam) {
          expand = calloc((size_t)level_n, 1);
          if (expand) {
              /* Partial selection: mark the top-`activate_beam` entries by
               * .act. O(beam*level_n) — beam is the small tunable. */
              for (int64_t bsel = 0; bsel < activate_beam; bsel++) {
                  int64_t best = -1;
                  for (int64_t k = 0; k < level_n; k++) {
                      if (expand[k]) continue;
                      if (best < 0 || fr[level_start+k].act > fr[level_start+best].act)
                          best = k;
                  }
                  if (best < 0) break;
                  expand[best] = 1;
              }
          }
          /* OOM on the selection map: expand stays NULL -> this level runs
           * unbounded, same as if beam were disabled. Never silently wrong. */
      }
      for (int64_t lk = level_start; lk < level_end; lk++) {
        if (expand && !expand[lk - level_start]) continue;
        Frontier f = fr[lk];
        if (f.hops >= max_depth) continue;
        int64_t cur = f.idx;
        int64_t new_hops = f.hops + 1;
        /* Use adjacency index: iterate only edges incident to `cur`.
         * adj_from[cur] holds edge indices where cur is the 'from' node;
         * adj_to[cur] holds edge indices where cur is the 'to' node.
         * If adj index is unavailable (OOM during rebuild), fall back to
         * full edge scan so activation is never silently wrong. */
        int use_adj = (g->adj_from != NULL && g->adj_to != NULL);
        int from_len = use_adj ? g->adj_from_len[cur] : 0;
        int to_len   = use_adj ? g->adj_to_len[cur]   : 0;
        int edge_scan_count = use_adj ? (from_len + to_len) : (int)g->edge_count;
        for (int scan_i = 0; scan_i < edge_scan_count; scan_i++) {
            int64_t ei;
            int64_t oi;
            if (use_adj) {
                ei = (scan_i < from_len)
                     ? g->adj_from[cur][scan_i]
                     : g->adj_to[cur][scan_i - from_len];
                EngramEdge* e = &g->edges[ei];
                oi = (scan_i < from_len)
                     ? engram_idmap_get(g, e->to_id)
                     : engram_idmap_get(g, e->from_id);
            } else {
                /* Fallback: linear scan */
                ei = scan_i;
                EngramEdge* e = &g->edges[ei];
                const char* other = NULL;
                const char* cur_id = g->nodes[cur].id;
                if (e->from_id && strcmp(e->from_id, cur_id) == 0) other = e->to_id;
                else if (e->to_id && strcmp(e->to_id, cur_id) == 0) other = e->from_id;
                else continue;
                oi = engram_find_node_index(other);
            }
            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);
            /* ── Query-aware propagation gate (2026-07-27 self-review) ──
             * Prior behavior was "query-blind" spreading: the query chose
             * the seeds, but propagation depended only on graph structure,
             * so high-degree hubs relayed activation into branches with no
             * semantic relation to the query. Per arXiv:2606.30133, gating
             * each increment by the TARGET node's query similarity
             * (sigma(v) = max(cos(e_v, e_q), 0)) prunes low-information
             * branches at every hop (+3.6..+7.4 F1 over uniform spreading,
             * 1.5-4.9x faster via a shrinking working set).
             *
             * Adaptation for partial embedding coverage: the paper skips
             * unembedded targets outright, but only eligible non-ISE/Tag
             * nodes carry embeddings here — a hard gate would sever purely
             * lexical/structural pathways. So: embedded targets get a soft
             * gate FLOOR + (1-FLOOR)*clip(cos) (dissimilar nodes damped
             * ~4x, never killed); unembedded targets pass ungated (no
             * information, no penalty); cosq == NULL (embedder down) means
             * no gating at all — same graceful degradation as seeding. */
            /* Rescale before gating (2026-08-14 self-review, preserved across the
             * M8.1 lazy-cosq refactor — the ANN/lazy rewrite must change WHEN
             * cosq[oi] is computed, never WHAT it gates on). Raw cosine from
             * nomic-embed is compressed into a narrow high band, so feeding it
             * to the gate directly makes the gate nearly a constant. Measured
             * on this store: 400 random UNRELATED node pairs gave median 0.562,
             * central 98% span [0.381, 0.743]. The raw gate therefore passed a
             * typical unrelated node at 0.25 + 0.75*0.562 = 0.67 — two thirds
             * strength for a node with no semantic relation to the query. That
             * is not a gate, it is a small tax.
             *
             * Shift-and-floor about ENGRAM_EMBED_S0, exactly as the Pass-2 WM
             * term at ENGRAM_EMBED_WM_WEIGHT already does. The constant was in
             * this file for this reason; the propagation gate simply never used
             * it. Same store, same 400 pairs, after the rescale: the median
             * unrelated pair drops to 0.40 while the top of the range is
             * preserved (0.85 vs 0.92), and gate spread widens 0.42 -> 0.60.
             * Only 8.5% of pairs fall to the floor, so lexical/structural
             * pathways through dissimilar nodes are damped, never severed.
             * Cf. arXiv:2512.15922, which rescales w' = (w-c)/(1-c) about
             * c = 0.4 for precisely this reason ("prevent overactivation and
             * context explosion"). */
            double qgate = 1.0;
            if (cosq) {
                double coi = eg_cosq_at(g, cosq, cos_done, cosq_qv, q_dim, oi);
                if (coi > -1.5) {
                    double c = (coi - ENGRAM_EMBED_S0) / (1.0 - ENGRAM_EMBED_S0);
                    if (c < 0.0) c = 0.0;
                    if (c > 1.0) c = 1.0;
                    qgate = ENGRAM_QGATE_FLOOR + (1.0 - ENGRAM_QGATE_FLOOR) * c;
                }
            }
            /* ── ACT-R fan effect (2026-08-11 self-review) ──
             * Symmetric degree normalization over the (source, target) pair.
             * The query gate above prunes branches that are semantically
             * irrelevant; this prunes branches that are merely POPULAR. They
             * are different failure modes — a duplicate document with 357
             * edges can be highly cosine-similar to the query and still be
             * the wrong thing to spread through. Only ever <= 1.0, so it
             * cannot inflate the frontier. See eg_fan_factor. */
            double fan = eg_fan_factor(g, FAN_DREF, cur, oi);
            _eg_act_fan_sum += fan;
            _eg_act_fan_n++;
            if (fan < 1.0) _eg_act_fan_hits++;
            if (fan < _eg_act_fan_min) _eg_act_fan_min = fan;
            /* eg_edge_eff_weight, not e->weight: edges that have repeatedly
             * carried co-activated pairs propagate more strongly. Identity on
             * an unlearned edge. (2026-08-04 self-review.) */
            double new_act = f.act * eg_edge_eff_weight(e) * SPREAD_DECAY
                           * (1.0 + tbonus) * tdecay * dampen * qgate * fan;
            /* 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;
                reached[oi]   = 1;
                if (ftail < fcap) {
                    fr[ftail].idx  = oi;
                    fr[ftail].hops = new_hops;
                    fr[ftail].act  = new_act;
                    ftail++;
                }
            }
        }
      }
      free(expand);
      fhead = level_end;
    }
    /* Persist layer-1 background_activation to node store. */
    for (int64_t i = 0; i < g->node_count; i++) {
        g->nodes[i].background_activation = reached[i] ? best_bg[i] : 0.0;
    }

    /* ── PASS 2: executive filter → working memory promotion ──────────── */
    /* Step A: collect inhibitory suppressions from fired inhibitory edges.
     * Layered consciousness: inhibition is ONLY recorded against targets
     * whose layer is `suppressible == 1`. Nodes in non-suppressible layers
     * (Layer 0 / safety) ignore inhibitory edges entirely — their working
     * memory weight cannot be silenced by attentional suppression. */
    double* inhibition = calloc((size_t)g->node_count, sizeof(double));
    if (!inhibition) {
        free(best_bg); free(best_hops); free(reached); free(seeds); free(fr);
        free(cosq); free(cos_done); free(cosq_qv); return out;
    }
    for (int64_t ei = 0; ei < g->edge_count; ei++) {
        EngramEdge* e = &g->edges[ei];
        if (!e->inhibitory) continue;
        int64_t src = engram_find_node_index(e->from_id);
        int64_t tgt = engram_find_node_index(e->to_id);
        if (src < 0 || tgt < 0) continue;
        if (!reached[src] || best_bg[src] <= 0.0) continue;
        /* Skip if target layer is non-suppressible: Layer 0 / safety nodes
         * are immune to inhibitory edges from any source. The pass-3
         * override below also force-promotes them, but recording inhibition
         * against them at all would be wasted work and could confuse
         * downstream debugging output. */
        if (!engram_layer_is_suppressible(g->nodes[tgt].layer_id)) continue;
        /* Inhibition strength proportional to source background activation
         * and edge weight. Takes the maximum if multiple inhibitory edges
         * target the same node. */
        double inh = best_bg[src] * e->weight;
        if (inh > inhibition[tgt]) inhibition[tgt] = inh;
    }
    /* Step B: compute working_memory_weight per candidate node. */
    double* wm_weights = calloc((size_t)g->node_count, sizeof(double));
    if (!wm_weights) {
        free(best_bg); free(best_hops); free(reached); free(seeds);
        free(fr); free(inhibition); free(cosq); free(cos_done); free(cosq_qv); return out;
    }
    /* Per-call breakthrough budget (2026-08-02) — see ENGRAM_BREAKTHROUGH_BUDGET. */
    int64_t bt_budget = ENGRAM_BREAKTHROUGH_BUDGET;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (!reached[i] || best_bg[i] <= 0.0) continue;
        EngramNode* n = &g->nodes[i];
        /* InternalStateEvent nodes are observability-only — never admit to WM.
         * Their JSON content (curiosity seeds, heartbeat payloads) contains common
         * words that trigger lexical seeding (e.g. "knowledge" in curiosity ISEs),
         * leading to repeated suppression and eventual breakthrough at the floor.
         * ISEs surfacing in context compilation are noise, not signal. Clear their
         * suppression_count so they don't build toward breakthrough, then skip.
         * (2026-06-30 self-review: porting fix from 2026-06-26 branch; SYNAPSE
         * paper confirms WM should hold only semantically relevant content.) */
        if (n->node_type && strcmp(n->node_type, "InternalStateEvent") == 0) {
            n->suppression_count = 0;
            wm_weights[i] = 0.0;
            continue;
        }
        /* Per-type threshold: safety nodes break through more easily. */
        double type_threshold = engram_type_threshold(n->node_type, n->tier);
        /* Goal bias weights the node's relevance to current intent. */
        double bias = engram_goal_bias(n, q);
        /* Raw working memory score.
         * Importance factor (2026-07-28 self-review): n->importance was
         * stored, serialized, and clamped at creation (default 0.5) but
         * never read by any scoring path — a curated importance=1.0 node
         * competed identically with a throwaway note at equal activation.
         * Map it to a gentle multiplier centered on the 0.5 default:
         * impf = 0.5 + importance → default nodes unchanged (×1.0),
         * critical (1.0) ×1.5, low (0.1) ×0.6. Nodes loaded from legacy
         * snapshots with importance<=0 stay neutral rather than being
         * silently suppressed. */
        double impf = (n->importance > 0.0) ? (0.5 + n->importance) : 1.0;
        double raw_wm = best_bg[i] * bias * n->confidence * impf;
        /* Apply inhibitory suppression. Full inhibition → scale by factor. */
        double inh = inhibition[i];
        if (inh > 1.0) inh = 1.0;
        double suppress = 1.0 - (1.0 - ENGRAM_INHIBITION_FACTOR) * inh;
        raw_wm *= suppress;
        /* Short-term inhibition-of-return (2026-07-25, Lebiere-Best):
         * damp by t_n/(t_n + t_s) where t_n = seconds since the most
         * recent recorded access (WM promotion / strengthen). A node that
         * just held a WM slot yields it even to structurally stronger
         * competitors, and recovers as t_n grows. access_ts is recorded
         * at promotion, so persistent WM residents self-inhibit. Nodes
         * with no access history (never promoted) are uninhibited.
         * Layer-0 override in Pass 3 still floors safety nodes. */
        if (n->access_filled > 0) {
            int32_t sti_last = (n->access_head + ENGRAM_BLL_K - 1)
                             % ENGRAM_BLL_K;
            double sti_tn = (double)(now_ms - n->access_ts[sti_last])
                          / 1000.0;
            if (sti_tn < 0.1) sti_tn = 0.1;
            raw_wm *= sti_tn / (sti_tn + ENGRAM_STI_TS);
        }
        /* Additive semantic-relevance term (2026-07-24, bl-b2d1c944):
         * shift-and-floor at S0 — nomic-embed scores unrelated pairs
         * 0.4–0.5, so raw cosine in a weighted sum would be a constant
         * bias swamping the decayed base-level signal. Above S0 the term
         * ramps 0 → WM_WEIGHT, breaking ties among structurally equivalent
         * candidates in favor of nodes that mean what the query means.
         *
         * MOVED AFTER the STI damper (2026-08-02 self-review). It used to
         * be added BEFORE, so the recency multiplier scaled the semantic
         * term too: an incumbent re-reached 30s later took t_n/(t_n+120)
         * = 0.2×, cutting the cosine term's ceiling from 0.20 to 0.04 —
         * below every per-type threshold (0.15–0.40). Meaning-match was
         * being punished for having been recently useful. Inhibition-of-
         * return should rotate the STRUCTURAL score (what the graph
         * dragged in), not the semantic one (what the query actually
         * means); relevance to the current query is not stale merely
         * because the node was in WM a moment ago. */
        if (cosq) {
            double ci = eg_cosq_at(g, cosq, cos_done, cosq_qv, q_dim, i);
            if (ci > ENGRAM_EMBED_S0) {
                raw_wm += ENGRAM_EMBED_WM_WEIGHT
                        * (ci - ENGRAM_EMBED_S0) / (1.0 - ENGRAM_EMBED_S0);
            }
        }
        /* Threshold gate: must exceed per-type threshold to enter working
         * memory. Type threshold replaces the old flat 0.2 filter. */
        if (raw_wm >= type_threshold) {
            wm_weights[i] = raw_wm > 1.0 ? 1.0 : raw_wm;
            if (n->suppression_count > 0) n->suppression_count = 0;
        } else {
            /* Node didn't make it through — increment suppression counter.
             * After N consecutive suppressions it MAY force a breakthrough,
             * subject to the per-call budget and the negative-count cooldown
             * (2026-08-02 — see ENGRAM_BREAKTHROUGH_BUDGET/_COOLDOWN). */
            n->suppression_count++;
            if (n->suppression_count >= ENGRAM_SUPPRESSION_BREAKTHROUGH
                && bt_budget > 0) {
                /* Graded breakthrough weight (2026-08-02): previously every
                 * breakthrough landed on exactly ENGRAM_BREAKTHROUGH_WEIGHT,
                 * so hundreds tied at 0.10 and the rank-cap tie-break at the
                 * cutoff degenerated to node-array index order — i.e. whoever
                 * was inserted earliest won, which is not a cognitive
                 * criterion. Scale within ±10% by how close the node came to
                 * its own threshold, so a near-miss outranks a node that was
                 * nowhere near. Stays strictly below min(type_threshold)
                 * (0.15) and strictly above ENGRAM_WM_FLOOR (0.05), which is
                 * the invariant ENGRAM_BREAKTHROUGH_WEIGHT documents. */
                double near = (type_threshold > 0.0)
                            ? (raw_wm / type_threshold) : 0.0;
                if (near < 0.0) near = 0.0;
                if (near > 1.0) near = 1.0;
                wm_weights[i] = ENGRAM_BREAKTHROUGH_WEIGHT
                              * (0.9 + 0.2 * near);
                /* Negative = cooldown. Must climb back through the cooldown
                 * before it can breach again. */
                n->suppression_count = -ENGRAM_BREAKTHROUGH_COOLDOWN;
                bt_budget--;
                _eg_act_breakthroughs++;
            } else {
                wm_weights[i] = 0.0;
                /* Budget-starved or cooling down: do NOT reset the counter —
                 * let it saturate so the node surfaces on a later call rather
                 * than restarting its climb from zero. Cap the ceiling so the
                 * int32 cannot drift unbounded over a long uptime. */
                if (n->suppression_count > ENGRAM_SUPPRESSION_BREAKTHROUGH * 4)
                    n->suppression_count = ENGRAM_SUPPRESSION_BREAKTHROUGH * 4;
            }
        }
    }
    /* ── PASS 3: Layer 0 override (the sacred fire) ─────────────────────
     * Every node in a non-suppressible layer that received any background
     * activation is force-promoted to AT LEAST ENGRAM_LAYER0_OVERRIDE_WEIGHT.
     * This runs LAST and overrides whatever Pass 2 decided — Layer 0 cannot
     * be silenced by inhibitory edges, by goal-bias misalignment, by
     * confidence weighting, or by per-type threshold gates. If the seed
     * fan-out reached a structural-floor node, that node surfaces.
     *
     * Note: this also clears the suppression_count when an override fires,
     * since the node DID surface this turn — it just took the override path
     * rather than the standard threshold path. Without this, a Layer 0
     * node with persistent inhibitory pressure would accumulate
     * suppression_count forever and never reach the breakthrough state. */
    for (int64_t i = 0; i < g->node_count; i++) {
        if (!reached[i] || best_bg[i] <= 0.0) continue;
        EngramNode* n = &g->nodes[i];
        if (engram_layer_is_suppressible(n->layer_id)) continue;
        if (wm_weights[i] < ENGRAM_LAYER0_OVERRIDE_WEIGHT) {
            wm_weights[i] = ENGRAM_LAYER0_OVERRIDE_WEIGHT;
        }
        n->suppression_count = 0;
    }

    /* ── PASS 3½: redundancy suppression ────────────────────────────────────
     * (2026-08-05 self-review; see ENGRAM_DEDUP_COS for the census.) Runs
     * BEFORE the capacity cap, so the 24 slots are contested by 24 distinct
     * meanings rather than by however many copies of one document the graph
     * happens to hold. Byte-identical nodes score byte-identically, so without
     * this they promote as a block — a six-copy document could hold a quarter
     * of working memory while saying one thing.
     *
     * Walk candidates in descending weight; the first occurrence of a content
     * survives, later ones are evicted. Highest-weight copy wins, which keeps
     * the survivor choice deterministic and preserves the strongest activation.
     *
     * Bounded work: once WM_CAP distinct survivors are held, every remaining
     * candidate is weaker than the weakest survivor and Pass 4 would evict it
     * anyway — so the walk stops at the first candidate STRICTLY below the
     * cap-th survivor's weight. Ties keep being processed, because a tie can
     * still take a slot through Pass 4's at_cutoff_slots path. Typical cost is
     * a few dozen content hashes per call, not a full-graph sweep.
     *
     * This runs after Pass 3 deliberately: Layer 0 (safety) force-promotions
     * are already in wm_weights and are ranked like anything else. If safety
     * content is genuinely duplicated, one copy still holds a slot — the
     * guarantee is that the content is present, not that every copy of it is. */
    {
        int64_t nc = 0;
        for (int64_t i = 0; i < g->node_count; i++)
            if (wm_weights[i] > 0.0) nc++;
        if (nc > 1) {
            EgDupCand* dc = malloc((size_t)nc * sizeof(EgDupCand));
            if (dc) {
                int64_t ci = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (wm_weights[i] > 0.0) {
                        dc[ci].w = wm_weights[i]; dc[ci].idx = i; ci++;
                    }
                }
                qsort(dc, (size_t)nc, sizeof(EgDupCand), eg_dupcand_cmp_desc);
                int64_t  keep[ENGRAM_WM_CAP];
                uint64_t kkey[ENGRAM_WM_CAP];
                int nk = 0;
                double  cap_w = 0.0;   /* weight of the WM_CAP-th survivor */
                for (int64_t z = 0; z < nc; z++) {
                    if (nk >= ENGRAM_WM_CAP && dc[z].w < cap_w) break;
                    int64_t i = dc[z].idx;
                    EngramNode* n = &g->nodes[i];
                    uint64_t key = eg_content_key(n);
                    int dup = 0;
                    for (int s = 0; s < nk; s++) {
                        if (eg_same_content(n, &g->nodes[keep[s]], key, kkey[s])) {
                            dup = 1; break;
                        }
                    }
                    if (dup) {
                        wm_weights[i] = 0.0;
                        _eg_act_wm_evicted++;
                        _eg_act_dup_wm++;
                        continue;
                    }
                    if (nk < ENGRAM_WM_CAP) {
                        keep[nk] = i; kkey[nk] = key; nk++;
                        if (nk == ENGRAM_WM_CAP) cap_w = dc[z].w;
                    }
                }
                free(dc);
            }
            /* malloc failure: skip suppression — duplicates may share slots
             * this call, which is the pre-2026-08-05 behavior. No corruption. */
        }
    }

    /* ── PASS 4: WM capacity cap (per-call) ─────────────────────────────────
     * Enforce ENGRAM_WM_CAP as a hard upper bound on nodes promoted in this
     * activation call. Without this, broad curiosity seeds like "knowledge"
     * promote 500+ nodes simultaneously — wm_avg_weight collapses to the
     * breakthrough floor, goal-bias differentiation is lost, and working memory
     * becomes useless. (Ported from 2026-06-26 self-review branch; observed
     * 525 promoted for "knowledge", 524 at breakthrough floor 0.25, 1 natural.) */
    {
        /* Absolute admission floor (2026-07-30): drop sub-floor candidates
         * BEFORE rank-trimming, so the cap is filled only by nodes that clear
         * an absolute bar — fill below ENGRAM_WM_CAP becomes reachable and
         * wm_saturated becomes an informative signal. See ENGRAM_WM_FLOOR. */
        for (int64_t i = 0; i < g->node_count; i++) {
            if (wm_weights[i] > 0.0 && wm_weights[i] < ENGRAM_WM_FLOOR) {
                wm_weights[i] = 0.0;
                _eg_act_wm_evicted++;
                _eg_act_evict_floor++;
            }
        }
        int64_t cap_count = 0;
        for (int64_t i = 0; i < g->node_count; i++) {
            if (wm_weights[i] > 0.0) cap_count++;
        }
        if (cap_count > ENGRAM_WM_CAP) {
            double* cap_vals = malloc((size_t)cap_count * sizeof(double));
            if (cap_vals) {
                int64_t ci = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (wm_weights[i] > 0.0) cap_vals[ci++] = wm_weights[i];
                }
                qsort(cap_vals, (size_t)cap_count, sizeof(double),
                      engram_cmp_double_desc);
                /* cap_vals[ENGRAM_WM_CAP-1] is the lowest weight that still
                 * fits inside the cap when sorted descending. */
                double cutoff = cap_vals[ENGRAM_WM_CAP - 1];
                free(cap_vals);
                /* Count strictly above cutoff to handle ties correctly. */
                int64_t above = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (wm_weights[i] > cutoff) above++;
                }
                int64_t at_cutoff_slots = ENGRAM_WM_CAP - above;
                /* Evict nodes that don't make the cut. */
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (wm_weights[i] <= 0.0)   continue; /* not promoted */
                    if (wm_weights[i] > cutoff) continue; /* above cutoff */
                    if (at_cutoff_slots > 0) {
                        at_cutoff_slots--;
                        continue;                         /* fills a slot */
                    }
                    wm_weights[i] = 0.0;                  /* over cap: evict */
                    _eg_act_wm_evicted++;
                    _eg_act_evict_cap++;
                }
            }
            /* If malloc failed, skip cap — WM unbounded this call, no corruption. */
        }
    }

    /* Pre-persist residency snapshot (2026-07-30): record which nodes held a
     * WM slot BEFORE this call's results are written back. Used below to fold
     * only NEW WM entrants into the context centroid — an incumbent that gets
     * re-promoted every scan no longer re-entrenches the centroid each time,
     * which was the remaining positive-feedback path in the WM→centroid→
     * e_eff→re-selection loop (fixation driver; cf. wm_top0_streak=1407
     * incident). NULL on OOM → fold falls back to previous behavior. */
    unsigned char* was_wm = malloc((size_t)g->node_count);
    if (was_wm) {
        for (int64_t i = 0; i < g->node_count; i++)
            was_wm[i] = (g->nodes[i].working_memory_weight > 0.0) ? 1 : 0;
    }

    /* Persist working_memory_weight (post Pass 4) to node store.
     *
     * Conversational thread continuity (ENGRAM_WM_DECAY):
     *   Nodes promoted in a previous turn but NOT reached by the current BFS
     *   fan-out retain a decayed weight rather than being zeroed. This models
     *   the brain's ability to maintain recent context across successive turns
     *   without requiring explicit re-activation. A node that was relevant one
     *   query ago stays weakly present in working memory; a node from two
     *   queries ago retains 0.7² ≈ 0.49 of its original weight; after ~5 quiet
     *   turns it falls below 0.01 and is effectively evicted (set to 0.0).
     *
     *   NOTE: this was documented in the ENGRAM_WM_DECAY constant comment since
     *   the two-layer architecture was introduced, but was never implemented —
     *   unreached nodes were always zeroed unconditionally. Fixed 2026-06-30
     *   self-review. */
    for (int64_t i = 0; i < g->node_count; i++) {
        if (!reached[i] && g->nodes[i].working_memory_weight > 0.0) {
            /* Carry-over decay: node held WM weight from prior activation but
             * the current query's BFS fan-out did not reach it.
             *
             * 2026-07-22 self-review (bl-b17facdd): replaced the per-call
             * multiplicative decay (weight *= ENGRAM_WM_DECAY) with the
             * ACT-R/Petrov base-level scheme. The old form was call-rate-
             * dependent — carried context died in seconds under rapid
             * curiosity scans and lingered for hours under quiet loops.
             * Now: hard-evict when base-level < τ (Soar-style forgetting);
             * above τ, shape the weight held at promotion (wm_anchor) by the
             * retrieval-probability logistic. Pure function of wall-clock
             * time — idempotent no matter how often activate is called. */
            eg_wm_carry_over(&g->nodes[i], now_ms, &_eg_act_wm_evicted);
        } else if (wm_weights[i] > 0.0) {
            g->nodes[i].working_memory_weight = wm_weights[i];
            /* Anchor the promotion weight: carry-over decay above computes
             * from this fixed point rather than compounding per call. */
            g->nodes[i].wm_anchor = wm_weights[i];
        } else if (was_wm && was_wm[i]) {
            /* ── Reached but sub-threshold (2026-08-04 self-review) ──────────
             * This case used to fall into the unconditional `= wm_weights[i]`
             * below, zeroing the slot outright — no carry-over, no base-level
             * check, not even counted as an eviction. The asymmetry was exactly
             * backwards: a node the current query did NOT reach got the full
             * ACT-R retention treatment, while a node the query DID reach, but
             * which landed a hair under its type threshold, was dropped
             * instantly. Being found was punished relative to not being found.
             *
             * MEASURED CONSEQUENCE: working memory turned over 100% on every
             * call. Three consecutive activations with a byte-identical query
             * gave |A∩B| = |B∩C| = 0 — no node survived a single call — while
             * wm_evicted stayed at 0 the whole time, because this path never
             * incremented it. WM was not a working set at all; it was six fresh
             * suppression-breakthrough nodes per call, re-drawn each time. That
             * silently defeated every mechanism built on WM continuity: the
             * conversational-thread carry-over documented since the two-layer
             * architecture landed, the wm_anchor fixed point, and (this
             * session) any possibility of learning from co-activation, since no
             * pair can co-activate twice if nothing survives one call.
             *
             * Same helper as the unreached path: one retention rule, both ways
             * out of a WM slot. The existing guards (τ hard-evict, WM_FLOOR,
             * occupancy decay, Pass 5 global cap) all still apply — routing
             * into them is why this is safe rather than merely sticky. */
            eg_wm_carry_over(&g->nodes[i], now_ms, &_eg_act_wm_evicted);
        } else {
            g->nodes[i].working_memory_weight = 0.0;
            /* Zero the anchor when the slot empties (2026-07-30): a stale
             * anchor on an evicted node was a latent resurrection bug if the
             * carry-over entry guard ever changes. */
            g->nodes[i].wm_anchor = 0.0;
        }
    }

    /* ── PASS 5: Global WM cap enforcement ───────────────────────────────────
     * Pass 4 capped this call's new candidates. But nodes already in WM from
     * prior calls retain their persisted working_memory_weight (via the decay
     * carry-over above). Over multiple activation calls total WM can grow well
     * above ENGRAM_WM_CAP. This pass enforces the cap globally across ALL
     * nodes in the store, keeping only the top ENGRAM_WM_CAP by current weight.
     * Correct cognitive model: WM capacity is global (Cowan 2001); more recent
     * activations outcompete older decayed ones. (Ported from 2026-06-26
     * self-review branch.) */
    {
        /* Absolute admission floor, global pass (2026-07-30): see
         * ENGRAM_WM_FLOOR. Sub-floor residents are dropped even when the
         * global population is under cap — this is what lets wm_active
         * drain below 24 during quiet periods. */
        for (int64_t i = 0; i < g->node_count; i++) {
            EngramNode* fn = &g->nodes[i];
            if (fn->working_memory_weight > 0.0 &&
                fn->working_memory_weight < ENGRAM_WM_FLOOR) {
                fn->working_memory_weight = 0.0;
                fn->wm_anchor = 0.0;
                _eg_act_wm_evicted++;
                _eg_act_evict_floor++;
            }
        }
        /* ── Global redundancy suppression (2026-08-06 self-review) ──────────
         * Pass 3½ deduplicates THIS CALL'S candidates. But the WM population
         * that actually exists after persist is a UNION of two sets: nodes
         * promoted this call, and nodes carried over from earlier calls by
         * eg_wm_carry_over. Pass 3½ never sees the second set, so the dedup
         * guarantee it advertises does not hold for the thing it is a
         * guarantee about.
         *
         * OBSERVED, not inferred. A live WM census caught two byte-identical
         * Knowledge nodes — f93d5f90 and b578d6a7, the same 3,193-character
         * "# Memory Integration" document under the same node_type — both
         * holding slots at 0.289 and 0.271, having arrived by the two
         * different routes. Pass 3½ had run and had correctly passed, because
         * only one of the two was a candidate that call.
         *
         * The cost is small and constant: 1–2 of 24 slots, ~4–8% of working
         * memory, indefinitely. It is worth fixing anyway, because the failure
         * is silent and self-reinforcing — a duplicate that holds a slot gets
         * reinforced for holding it, and (as of this session) now also earns
         * Hebbian eligibility, so redundancy would start teaching the graph
         * that a document is associated with itself.
         *
         * Runs BEFORE the cap count below, so slots freed here are reclaimed
         * by distinct content in the same pass rather than left empty. Same
         * identity test and same highest-weight-survives rule as Pass 3½. */
        {
            int64_t gn = 0;
            for (int64_t i = 0; i < g->node_count; i++)
                if (g->nodes[i].working_memory_weight > 0.0) gn++;
            if (gn > 1) {
                EgDupCand* gd = malloc((size_t)gn * sizeof(EgDupCand));
                if (gd) {
                    int64_t gi = 0;
                    for (int64_t i = 0; i < g->node_count; i++) {
                        if (g->nodes[i].working_memory_weight > 0.0) {
                            gd[gi].w   = g->nodes[i].working_memory_weight;
                            gd[gi].idx = i;
                            gi++;
                        }
                    }
                    qsort(gd, (size_t)gn, sizeof(EgDupCand), eg_dupcand_cmp_desc);
                    int64_t  gkeep[ENGRAM_WM_CAP];
                    uint64_t gkey[ENGRAM_WM_CAP];
                    int gnk = 0;
                    double gcap_w = 0.0;
                    for (int64_t z = 0; z < gn; z++) {
                        if (gnk >= ENGRAM_WM_CAP && gd[z].w < gcap_w) break;
                        int64_t i = gd[z].idx;
                        EngramNode* n = &g->nodes[i];
                        uint64_t key = eg_content_key(n);
                        int dup = 0;
                        for (int s = 0; s < gnk; s++) {
                            if (eg_same_content(n, &g->nodes[gkeep[s]], key, gkey[s])) {
                                dup = 1; break;
                            }
                        }
                        if (dup) {
                            n->working_memory_weight = 0.0;
                            n->wm_anchor             = 0.0;
                            _eg_act_wm_evicted++;
                            _eg_act_dup_wm_global++;
                            continue;
                        }
                        if (gnk < ENGRAM_WM_CAP) {
                            gkeep[gnk] = i; gkey[gnk] = key; gnk++;
                            if (gnk == ENGRAM_WM_CAP) gcap_w = gd[z].w;
                        }
                    }
                    free(gd);
                }
                /* malloc failure: skip — duplicates may share slots this call,
                 * which is the pre-2026-08-06 behavior. No corruption. */
            }
        }
        int64_t global_wm_count = 0;
        for (int64_t i = 0; i < g->node_count; i++) {
            if (g->nodes[i].working_memory_weight > 0.0) global_wm_count++;
        }
        if (global_wm_count > ENGRAM_WM_CAP) {
            double* gvals = malloc((size_t)global_wm_count * sizeof(double));
            if (gvals) {
                int64_t gi = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (g->nodes[i].working_memory_weight > 0.0)
                        gvals[gi++] = g->nodes[i].working_memory_weight;
                }
                qsort(gvals, (size_t)global_wm_count, sizeof(double),
                      engram_cmp_double_desc);
                double gcutoff = gvals[ENGRAM_WM_CAP - 1];
                free(gvals);
                int64_t gabove = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    if (g->nodes[i].working_memory_weight > gcutoff) gabove++;
                }
                int64_t gslots_at_cutoff = ENGRAM_WM_CAP - gabove;
                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 > gcutoff) continue;
                    if (gslots_at_cutoff > 0) {
                        gslots_at_cutoff--;
                        continue; /* fills a slot */
                    }
                    n->working_memory_weight = 0.0; /* evict: over global cap */
                    n->wm_anchor = 0.0;             /* keep anchor coherent */
                    _eg_act_wm_evicted++; /* was uncounted before 2026-08-02 */
                    _eg_act_evict_cap++;
                }
            }
            /* If malloc failed, skip — WM over cap this call, no data corruption. */
        }
    }

    /* ── 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++;
        /* Base-level presentation: WM promotion is the retrieval event.
         * (2026-07-22 self-review — feeds engram_bll_base_level.) */
        engram_bll_record_access(n, now_ms);
    }

    /* ── Hebbian edge potentiation (2026-08-04 self-review) ─────────────────
     * The edge-level counterpart of the node-level reinforcement immediately
     * above. That loop says "this memory was retrieved"; this one says "these
     * two memories were retrieved TOGETHER, so the path between them is worth
     * more than it was". Runs on final post-Pass-5 working memory, so only
     * pairs that survived both capacity caps count as co-active — the same
     * "promotion to WM is the analog of actual retrieval" standard the ACT-R
     * reinforcement above uses. Consistency matters: two mechanisms disagreeing
     * about what counts as a retrieval would drift apart invisibly.
     *
     * Three steps: (1) decay every edge, so disuse fades; (2) increment
     * co-active pairs; (3) homeostatic scaling so no node accumulates
     * unbounded associative mass. All three are required — see ENGRAM_HEBB_*. */
    {
        unsigned char* in_wm = calloc((size_t)g->node_count, 1);
        if (in_wm) {
            int64_t wm_n = 0;
            for (int64_t i = 0; i < g->node_count; i++) {
                if (g->nodes[i].working_memory_weight > 0.0) {
                    in_wm[i] = 1;
                    wm_n++;
                }
            }
            /* Step 0 (2026-08-06): refresh the eligibility trace of everything
             * currently in WM. Done BEFORE the edge pass so a pair that is
             * co-resident right now reads trace 1.0 on both ends and receives
             * exactly ENGRAM_HEBB_ETA — identical to the pre-trace rule. */
            for (int64_t i = 0; i < g->node_count; i++) {
                if (!in_wm[i]) continue;
                g->nodes[i].hebb_elig    = 1.0;
                g->nodes[i].hebb_elig_ts = now_ms;
            }
            /* Steps 1+2: decay all, potentiate co-active. Fused into one O(E)
             * pass. Edges whose endpoints resolve to nothing still decay —
             * a dangling edge should not hold learned strength forever.
             *
             * Co-activation is now graded by the product of the endpoints'
             * eligibility traces rather than gated on same-call WM residency;
             * see the ENGRAM_HEBB_TRACE_* block for the census that forced
             * this. The old `wm_n > 1` guard is gone because a single node
             * entering WM can now legitimately potentiate against a partner
             * that was in WM moments ago — that asymmetric case is precisely
             * the signal the simultaneity rule was throwing away. When no
             * trace is warm, co is 0 and the pass degenerates to pure decay. */
            for (int64_t ei = 0; ei < g->edge_count; ei++) {
                EngramEdge* e = &g->edges[ei];
                double h = e->hebb * ENGRAM_HEBB_DECAY;
                if (!e->inhibitory) {
                    int64_t a = engram_idmap_get(g, e->from_id);
                    int64_t b = engram_idmap_get(g, e->to_id);
                    if (a >= 0 && a < g->node_count && b >= 0 && b < g->node_count) {
                        double co = eg_hebb_trace(&g->nodes[a], now_ms)
                                  * eg_hebb_trace(&g->nodes[b], now_ms);
                        if (co > 0.0) {
                            h += ENGRAM_HEBB_ETA * co;
                            e->last_fired = now_ms; /* first real writer outside dharma_strengthen */
                        }
                    }
                }
                e->hebb = (h < ENGRAM_HEBB_MIN) ? 0.0 : h;
            }
            /* Step 3: homeostatic scaling. Sum incident hebb per node; any node
             * over budget scales ALL its incident edges down proportionally.
             * An edge is scaled by the stronger (smaller) of its two endpoints'
             * factors, so one pass satisfies both endpoints' constraints —
             * conservative, non-iterative, and stable. Skipped on OOM: the
             * potentiation above is still correct, just uncompensated for one
             * call, and the next call re-normalizes. */
            double* mass = calloc((size_t)g->node_count, sizeof(double));
            if (mass) {
                for (int64_t ei = 0; ei < g->edge_count; ei++) {
                    EngramEdge* e = &g->edges[ei];
                    if (e->hebb <= 0.0) continue;
                    int64_t a = engram_idmap_get(g, e->from_id);
                    int64_t b = engram_idmap_get(g, e->to_id);
                    if (a >= 0 && a < g->node_count) mass[a] += e->hebb;
                    if (b >= 0 && b < g->node_count) mass[b] += e->hebb;
                }
                for (int64_t ei = 0; ei < g->edge_count; ei++) {
                    EngramEdge* e = &g->edges[ei];
                    if (e->hebb <= 0.0) continue;
                    int64_t a = engram_idmap_get(g, e->from_id);
                    int64_t b = engram_idmap_get(g, e->to_id);
                    double s = 1.0;
                    if (a >= 0 && a < g->node_count && mass[a] > ENGRAM_HEBB_NODE_BUDGET)
                        s = ENGRAM_HEBB_NODE_BUDGET / mass[a];
                    if (b >= 0 && b < g->node_count && mass[b] > ENGRAM_HEBB_NODE_BUDGET) {
                        double sb = ENGRAM_HEBB_NODE_BUDGET / mass[b];
                        if (sb < s) s = sb;
                    }
                    if (s < 1.0) {
                        double h = e->hebb * s;
                        e->hebb = (h < ENGRAM_HEBB_MIN) ? 0.0 : h;
                    }
                }
                free(mass);
            }

            /* ── Associative link formation ──────────────────────────────
             * Everything above reweights edges that already exist. This part
             * grows the ones that don't. See the ENGRAM_HEBB_LINK_* block for
             * why this is gated as hard as it is. */
            {
                /* Decay every candidate slot, exactly as edges decay, so an
                 * association that stops recurring loses ground at the same
                 * rate whether or not it has been consolidated yet. */
                for (int s = 0; s < ENGRAM_HEBB_CAND_SLOTS; s++) {
                    EgHebbCand* c = &_eg_hebb_cand[s];
                    if (!c->a) continue;
                    c->score *= ENGRAM_HEBB_DECAY;
                    if (c->score < ENGRAM_HEBB_MIN) eg_hebb_slot_clear(c);
                }
                /* Gather this call's WM members (bounded by ENGRAM_WM_CAP, so
                 * at most 276 pairs — the O(n²) here is over ≤24 items). */
                int64_t wm_idx[ENGRAM_WM_CAP];
                int wm_k = 0;
                for (int64_t i = 0; i < g->node_count && wm_k < ENGRAM_WM_CAP; i++) {
                    if (in_wm[i]) wm_idx[wm_k++] = i;
                }
                /* Warm set (2026-08-06): nodes NOT in WM right now but whose
                 * eligibility trace is still up. These are the partners the
                 * simultaneity rule could never see. Bounded by
                 * ENGRAM_HEBB_WARM_MAX; the scan is O(node_count), which is
                 * an order of magnitude cheaper than the O(edge_count) pass
                 * already running above it. */
                int64_t warm_idx[ENGRAM_HEBB_WARM_MAX];
                double  warm_t[ENGRAM_HEBB_WARM_MAX];
                int warm_k = 0;
                for (int64_t i = 0; i < g->node_count
                                 && warm_k < ENGRAM_HEBB_WARM_MAX; i++) {
                    if (in_wm[i]) continue;
                    double t = eg_hebb_trace(&g->nodes[i], now_ms);
                    if (t <= 0.0) continue;
                    warm_idx[warm_k] = i;
                    warm_t[warm_k]   = t;
                    warm_k++;
                }
                _eg_act_hebb_warm = warm_k;
                (void)wm_n;   /* superseded as a gate by the trace product */
                /* Reinforce candidate scores. Two families, one rule:
                 *   WM × WM     — both traces are 1.0 ⇒ increment ETA, exactly
                 *                 the pre-2026-08-06 behavior.
                 *   WM × warm   — increment ETA·trace, so a partner that left
                 *                 working memory one cycle ago still earns most
                 *                 of the credit and one that left ten cycles ago
                 *                 earns a third of it.
                 * warm × warm is deliberately NOT paired: with nothing currently
                 * active there is no event to be eligible FOR, and pairing decayed
                 * residue against decayed residue would manufacture associations
                 * out of two absences. Eligibility gates on something happening
                 * now — that is the whole content of the three-factor rule. */
                for (int x = 0; x < wm_k; x++) {
                    for (int y = x + 1; y < wm_k; y++) {
                        eg_hebb_cand_bump(g->nodes[wm_idx[x]].id,
                                          g->nodes[wm_idx[y]].id,
                                          ENGRAM_HEBB_ETA);
                    }
                    for (int w = 0; w < warm_k; w++) {
                        eg_hebb_cand_bump(g->nodes[wm_idx[x]].id,
                                          g->nodes[warm_idx[w]].id,
                                          ENGRAM_HEBB_ETA * warm_t[w]);
                    }
                }
                /* Consolidate the strongest qualifying candidates into real
                 * edges. Done last and separately because engram_grow_edges()
                 * may realloc g->edges — no EngramEdge* may be held across
                 * this point. */
                int formed = 0;
                /* Count existing self-formed edges once, up front: the cap is
                 * on total learned structure, not on this call's rate. */
                int64_t hebb_edge_total = 0;
                for (int64_t i = 0; i < g->edge_count; i++) {
                    if (g->edges[i].relation &&
                        strcmp(g->edges[i].relation, "hebbian-associate") == 0)
                        hebb_edge_total++;
                }
                int64_t hebb_edge_cap =
                    (int64_t)((double)g->edge_count * ENGRAM_HEBB_LINK_MAX_FRAC);
                /* Select the STRONGEST qualifying candidates, not the first
                 * ones in hash-slot order (2026-08-15 self-review).
                 *
                 * This loop used to scan slots ascending and stop at
                 * ENGRAM_HEBB_LINK_PER_CALL (2). Slot index is
                 * engram_id_hash(lo)*1000003 ^ engram_id_hash(hi) mod 8192 —
                 * i.e. arbitrary with respect to association strength. So
                 * whenever more than two candidates cleared LINK_MIN in the
                 * same call, the two that got consolidated were the two with
                 * the LOWEST HASH, and a stronger association simply waited.
                 * It waited indefinitely, not just one round: the scan
                 * restarts from slot 0 every call, so a low-slot candidate
                 * that re-qualifies keeps winning the same race, while the
                 * leader decays at ENGRAM_HEBB_DECAY the whole time.
                 *
                 * Measured on this store over the 08-13→08-15 window:
                 * hebb_cand_max peaked at 0.4963 (08-14 06:57) — 3.3x
                 * LINK_MIN — during a ~14h stretch when candidates were
                 * qualifying continuously and links were being formed at the
                 * ≤2/call cap. The system was consolidating the associations
                 * it happened to reach first, while the association it had
                 * most strongly learned sat unconsolidated.
                 *
                 * This is the same defect the 2026-08-02 self-review named
                 * and fixed for breakthrough weights — "the tie-break at the
                 * cutoff degenerated to node-array index order, which is not
                 * a cognitive criterion" — but that fix was never carried
                 * across to link formation, which is the one path that writes
                 * PERMANENT structure. A wrong breakthrough costs one WM slot
                 * for one call; a wrong consolidation is an edge that never
                 * goes away (ENGRAM_HEBB_LINK_MAX_FRAC notes there is no
                 * pruning path — growth is one-way). Selection pressure
                 * matters most exactly where the result is irreversible.
                 *
                 * Cost: PER_CALL(2) x 8192 comparisons of a double, against an
                 * O(edge_count) relation scan (37k+) immediately above and an
                 * O(edge_count) eg_edge_exists_between per edge formed. Noise.
                 *
                 * Invalid winners (node deleted, edge already present) are
                 * cleared and do NOT consume one of the two slots — same as
                 * the old `continue`. Clearing strictly shrinks the candidate
                 * set, so the retry loop always terminates.
                 *
                 * (Preserved across the M8/BIG-MERGE reconciliation — the
                 * source branch forked before this fix landed on dev, so its
                 * raw diff would otherwise have silently reverted to the
                 * hash-slot-order loop. The M8 branch's own improvement here
                 * — engram_adj_on_edge_added() instead of a bare adj_dirty=1
                 * — is kept below.) */
                while (formed < ENGRAM_HEBB_LINK_PER_CALL
                       && hebb_edge_total < hebb_edge_cap) {
                    int best_s = -1;
                    double best_score = 0.0;
                    for (int s = 0; s < ENGRAM_HEBB_CAND_SLOTS; s++) {
                        EgHebbCand* cs = &_eg_hebb_cand[s];
                        if (!cs->a) continue;
                        if (cs->score < ENGRAM_HEBB_LINK_MIN) continue;
                        /* strict > keeps the lowest slot on an exact tie, so
                         * selection stays deterministic across runs */
                        if (best_s < 0 || cs->score > best_score) {
                            best_score = cs->score;
                            best_s = s;
                        }
                    }
                    if (best_s < 0) break;   /* nothing qualifies this call */
                    EgHebbCand* c = &_eg_hebb_cand[best_s];
                    if (engram_idmap_get(g, c->a) < 0 ||
                        engram_idmap_get(g, c->b) < 0) { /* node gone */
                        eg_hebb_slot_clear(c); continue;
                    }
                    if (eg_edge_exists_between(g, c->a, c->b)) {
                        eg_hebb_slot_clear(c); continue; /* already wired */
                    }
                    engram_grow_edges();
                    EngramEdge* ne = &g->edges[g->edge_count];
                    memset(ne, 0, sizeof(*ne));
                    ne->id        = engram_new_id();
                    ne->from_id   = el_strdup_persist(c->a);
                    ne->to_id     = el_strdup_persist(c->b);
                    ne->relation  = el_strdup_persist("hebbian-associate");
                    ne->metadata  = el_strdup_persist("{\"origin\":\"co-activation\"}");
                    ne->weight    = ENGRAM_HEBB_LINK_W0;
                    /* Carry the earned score across so a freshly consolidated
                     * edge starts where the association already is, rather
                     * than restarting a climb it has already made. */
                    ne->hebb      = c->score;
                    ne->confidence = 1.0;
                    ne->created_at = now_ms;
                    ne->updated_at = now_ms;
                    ne->last_fired = now_ms;
                    ne->layer_id   = ENGRAM_LAYER_DEFAULT;
                    g->edge_count++;
                    engram_adj_on_edge_added(g, g->edge_count - 1);
                    _eg_hebb_links_formed++;
                    hebb_edge_total++;
                    formed++;
                    /* Hand the association to the durable store. In the soul
                     * daemon this local edge is the ONLY copy and dies with the
                     * process — see the ENGRAM_HEBB_WB_SLOTS block. Enqueue
                     * from ne->from_id/to_id rather than c->a/c->b: the slot is
                     * cleared on the next line and the edge now owns the ids. */
                    eg_hebb_wb_push(ne->from_id, ne->to_id,
                                    ne->weight, ne->hebb);
                    eg_hebb_slot_clear(c); /* the edge is the record now */
                }
            }
            free(in_wm);
        }
    }

    /* ── 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,
     * then background-only by background_activation desc. */
    typedef struct { int64_t idx; double bg; double wm; double epist; int64_t hops; } Result;
    Result* results = malloc((size_t)g->node_count * sizeof(Result));
    int64_t rcount = 0;
    if (!results) {
        free(best_bg); free(best_hops); free(reached); free(seeds);
        free(fr); free(inhibition); free(wm_weights); free(cosq); free(cos_done); free(cosq_qv);
        free(was_wm); return out;
    }
    for (int64_t i = 0; i < g->node_count; i++) {
        if (!reached[i]) continue;
        double epist = best_bg[i] * g->nodes[i].confidence;
        /* Include if promoted to working memory OR if background activation
         * is meaningful enough to report (epist >= 0.1). */
        if (epist < 0.1 && wm_weights[i] <= 0.0) continue;
        results[rcount].idx   = i;
        results[rcount].bg    = best_bg[i];
        results[rcount].wm    = wm_weights[i];
        results[rcount].epist = epist;
        results[rcount].hops  = best_hops[i];
        rcount++;
    }
    /* Sort: promoted nodes first (by wm_weight desc), then background-only
     * by background_activation desc. */
    for (int64_t i = 1; i < rcount; i++) {
        Result key = results[i];
        int64_t j = i - 1;
        while (j >= 0 && (results[j].wm < key.wm ||
               (results[j].wm == key.wm && results[j].bg < key.bg))) {
            results[j + 1] = results[j];
            j--;
        }
        results[j + 1] = key;
    }
    /* ── Context centroid: fold in the touched nodes (2026-07-29) ───────
     * Results are sorted promoted-first by wm_weight desc, so the first
     * ENGRAM_CTX_TOUCH_MAX embedded entries with wm > 0 are exactly the
     * strongest WM survivors of THIS call — the same "promotion is the
     * retrieval event" rule the BLL reinforcement pass uses. μ=0.9 EMA
     * keeps any single scan's touches a minority contribution. */
    {
        int touched = 0;
        for (int64_t i = 0; i < rcount && touched < ENGRAM_CTX_TOUCH_MAX; i++) {
            if (results[i].wm <= 0.0) break; /* promoted block exhausted */
            EngramNode* n = &g->nodes[results[i].idx];
            if (!n->emb || n->emb_dim <= 0) continue;
            /* New-entrant gate (2026-07-30): skip nodes that already held a
             * WM slot before this call — incumbents must not keep pulling
             * the centroid toward themselves. Fresh topical shifts (new
             * entrants + the query fold at call start) steer it instead. */
            if (was_wm && was_wm[results[i].idx]) continue;
            eg_ctx_blend(n->emb, n->emb_dim);
            touched++;
        }
    }
    free(was_wm);
    for (int64_t i = 0; i < rcount; i++) {
        el_val_t entry = el_map_new(0);
        entry = el_map_set(entry, EL_STR(el_strdup("node")),
                           engram_node_to_map(&g->nodes[results[i].idx]));
        entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
                           el_from_float(results[i].bg));
        entry = el_map_set(entry, EL_STR(el_strdup("working_memory_weight")),
                           el_from_float(results[i].wm));
        entry = el_map_set(entry, EL_STR(el_strdup("epistemic_confidence")),
                           el_from_float(results[i].epist));
        entry = el_map_set(entry, EL_STR(el_strdup("hops")),
                           (el_val_t)results[i].hops);
        entry = el_map_set(entry, EL_STR(el_strdup("promoted")),
                           (el_val_t)(results[i].wm > 0.0 ? 1 : 0));
        out = el_list_append(out, entry);
    }
    free(best_bg); free(best_hops); free(reached);
    free(seeds); free(fr); free(inhibition); free(wm_weights); free(results);
    free(cosq); free(cos_done); free(cosq_qv);
    return out;
}

/* ── Engram persistence (JSON snapshot) ─────────────────────────────────── */

/* include_emb (2026-07-31 self-review): the ~5.7KB "emb" vector belongs ONLY
 * in persistence/replication output (engram_save → snapshot.json, which also
 * backs /api/sync and /api/edges via scratch exports). Consumer read routes
 * (/api/nodes, /api/search, activation results, neighbors, compiled context)
 * were shipping it on every node — responses 10-50x oversized, blowing MCP
 * token limits. Pass include_emb=1 only from engram_save. */
static void engram_emit_node_json(JsonBuf* b, const EngramNode* n, int include_emb) {
    jb_putc(b, '{');
    jb_puts(b, "\"id\":");          jb_emit_escaped(b, n->id ? n->id : "");
    jb_puts(b, ",\"content\":");    jb_emit_escaped(b, n->content ? n->content : "");
    jb_puts(b, ",\"node_type\":");  jb_emit_escaped(b, n->node_type ? n->node_type : "");
    jb_puts(b, ",\"label\":");      jb_emit_escaped(b, n->label ? n->label : "");
    jb_puts(b, ",\"tier\":");       jb_emit_escaped(b, n->tier ? n->tier : "Working");
    jb_puts(b, ",\"tags\":");       jb_emit_escaped(b, n->tags ? n->tags : "");
    jb_puts(b, ",\"metadata\":");   jb_emit_escaped(b, n->metadata ? n->metadata : "{}");
    char tmp[80];
    snprintf(tmp, sizeof(tmp), ",\"salience\":%g",              n->salience);             jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"importance\":%g",            n->importance);            jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"confidence\":%g",            n->confidence);            jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"temporal_decay_rate\":%g",   n->temporal_decay_rate);   jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"activation_count\":%lld",   (long long)n->activation_count); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"last_activated\":%lld",     (long long)n->last_activated);   jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld",         (long long)n->created_at);       jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld",         (long long)n->updated_at);       jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"background_activation\":%g", n->background_activation); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"working_memory_weight\":%g", n->working_memory_weight); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"suppression_count\":%d",     n->suppression_count);     jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"layer_id\":%u",              n->layer_id);              jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"wm_anchor\":%g",             n->wm_anchor);             jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"base_level\":%g",
             engram_bll_base_level(n, engram_now_ms()));                                   jb_puts(b, tmp);
    /* GEOMETRY VISIBILITY (2026-08-16 self-review): the node document never
     * said whether the node carried a vector, so a read-back could not tell
     * "has geometry" from "text only". Not cosmetic — it is exactly how a
     * real ingest drop and a mere reporting gap became indistinguishable,
     * and I misdiagnosed one as the other for an hour. Always emit the width
     * and the boolean; the vector itself stays behind include_emb since it
     * is large and most callers do not want it inline. */
    snprintf(tmp, sizeof(tmp), ",\"emb_dim\":%d,\"embedded\":%s",
             (int)n->emb_dim, (n->emb && n->emb_dim > 0) ? "true" : "false");              jb_puts(b, tmp);
    /* Base-level access history: chronological (oldest→newest) compact
     * string. Loaders replay it through engram_bll_record_access; absent
     * field = empty ring (optimized-form fallback). (2026-07-22) */
    if (n->access_filled > 0) {
        jb_puts(b, ",\"access_ts\":\"");
        for (int32_t j = 0; j < n->access_filled; j++) {
            int32_t idx = (n->access_head - n->access_filled + j
                           + 2 * ENGRAM_BLL_K) % ENGRAM_BLL_K;
            snprintf(tmp, sizeof(tmp), "%s%lld", j ? "," : "",
                     (long long)n->access_ts[idx]);
            jb_puts(b, tmp);
        }
        jb_putc(b, '"');
    }
    /* Semantic embedding (2026-07-24): compact %.4g comma list — cosine is
     * insensitive to 4-sig-fig rounding, and this keeps snapshot bloat to
     * ~5KB per embedded node without a base64 codec. Absent field = not
     * embedded; the lazy backfill re-embeds eventually if dropped. */
    if (include_emb && n->emb && n->emb_dim > 0) {
        jb_puts(b, ",\"emb\":\"");
        for (int32_t j = 0; j < n->emb_dim; j++) {
            snprintf(tmp, sizeof(tmp), "%s%.4g", j ? "," : "",
                     (double)n->emb[j]);
            jb_puts(b, tmp);
        }
        jb_putc(b, '"');
    }
    jb_putc(b, '}');
}

static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e) {
    jb_putc(b, '{');
    jb_puts(b, "\"id\":");        jb_emit_escaped(b, e->id ? e->id : "");
    jb_puts(b, ",\"from_id\":");  jb_emit_escaped(b, e->from_id ? e->from_id : "");
    jb_puts(b, ",\"to_id\":");    jb_emit_escaped(b, e->to_id ? e->to_id : "");
    jb_puts(b, ",\"relation\":"); jb_emit_escaped(b, e->relation ? e->relation : "");
    jb_puts(b, ",\"metadata\":"); jb_emit_escaped(b, e->metadata ? e->metadata : "{}");
    char tmp[64];
    snprintf(tmp, sizeof(tmp), ",\"weight\":%g", e->weight);         jb_puts(b, tmp);
    /* Learned potentiation is persisted: it is the graph's accumulated
     * associative experience and must survive restarts, or the system relearns
     * from zero every boot and never accumulates. Emitted only when nonzero so
     * a cold snapshot stays byte-comparable to the pre-change format.
     * (2026-08-04 self-review.) */
    if (e->hebb > 0.0) { snprintf(tmp, sizeof(tmp), ",\"hebb\":%.6g", e->hebb); jb_puts(b, tmp); }
    snprintf(tmp, sizeof(tmp), ",\"confidence\":%g", e->confidence); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld", (long long)e->created_at); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"updated_at\":%lld", (long long)e->updated_at); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"last_fired\":%lld", (long long)e->last_fired); jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"inhibitory\":%d", e->inhibitory ? 1 : 0);     jb_puts(b, tmp);
    snprintf(tmp, sizeof(tmp), ",\"layer_id\":%u",   e->layer_id);               jb_puts(b, tmp);
    jb_putc(b, '}');
}

/* Size of the last snapshot this process serialized. Seeds the next save's
 * buffer so the doubling walk never runs on the big document. See jb_init_cap
 * for the measurement that motivated it. (2026-08-11 self-review) */
static size_t _eg_save_cap_hint = 0;

el_val_t engram_save(el_val_t path) {
    const char* p = EL_CSTR(path);
    if (!p || !*p) return 0;
    EngramStore* g = engram_get();
    /* Pre-size from the previous save plus 12.5% headroom, so ordinary growth
     * between snapshots does not trigger a realloc and the request size stays
     * stable enough for the allocator to reuse the same span. First save of
     * the process has no hint and starts at 1MB — still 14 doublings better
     * than 64 bytes. */
    JsonBuf b;
    jb_init_cap(&b, _eg_save_cap_hint
                    ? _eg_save_cap_hint + (_eg_save_cap_hint >> 3) + 1024
                    : (size_t)1 << 20);
    jb_puts(&b, "{\"nodes\":[");
    for (int64_t i = 0; i < g->node_count; i++) {
        if (i > 0) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[i], 1);
    }
    jb_puts(&b, "],\"edges\":[");
    for (int64_t i = 0; i < g->edge_count; i++) {
        if (i > 0) jb_putc(&b, ',');
        engram_emit_edge_json(&b, &g->edges[i]);
    }
    /* Layered consciousness — emit the layer registry under "layers".
     * Older readers that don't know about this top-level key will simply
     * ignore it (forward compatible). Tombstoned (removed-injectable)
     * layers are skipped — they have no name and can't be re-created
     * meaningfully on load anyway. */
    jb_puts(&b, "],\"layers\":[");
    int first_layer = 1;
    for (size_t i = 0; i < g->layer_count; i++) {
        EngramLayer* L = &g->layers[i];
        if (!L->name) continue;
        if (!first_layer) jb_putc(&b, ',');
        first_layer = 0;
        jb_putc(&b, '{');
        char tmp[80];
        snprintf(tmp, sizeof(tmp), "\"layer_id\":%u", L->layer_id);
        jb_puts(&b, tmp);
        jb_puts(&b, ",\"name\":");
        jb_emit_escaped(&b, L->name);
        snprintf(tmp, sizeof(tmp), ",\"activation_priority\":%u", L->activation_priority);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"suppressible\":%d", L->suppressible ? 1 : 0);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"transparent\":%d", L->transparent ? 1 : 0);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"injectable\":%d", L->injectable ? 1 : 0);
        jb_puts(&b, tmp);
        jb_putc(&b, '}');
    }
    jb_puts(&b, "]}");
    /* Remember the size BEFORE the write: the hint is about how much buffer
     * the next serialization needs, which is a property of the graph, not of
     * whether this particular fopen succeeded. */
    _eg_save_cap_hint = b.len;
    FILE* f = fopen(p, "wb");
    if (!f) { free(b.buf); return 0; }
    size_t w = fwrite(b.buf, 1, b.len, f);
    fclose(f);
    int ok = (w == b.len);
    free(b.buf);
    return ok ? 1 : 0;
}

/* 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_persist("");
    if (*p != '"') return el_strdup_persist("");
    JsonParser jp = { .p = p, .end = p + strlen(p), .err = 0 };
    char* out = jp_parse_string_raw(&jp);
    /* *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;
}

static double eg_get_num_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p || *p == '"' || *p == '{' || *p == '[') return 0.0;
    return strtod(p, NULL);
}

static int64_t eg_get_int_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p || *p == '"' || *p == '{' || *p == '[') return 0;
    return strtoll(p, NULL, 10);
}

/* Iterate the top-level nodes/edges arrays in a saved snapshot. */
static const char* eg_skip_ws(const char* p) {
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') 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) {
    /* Absolute admission floor (2026-07-30): see ENGRAM_WM_FLOOR. */
    for (int64_t i = 0; i < g->node_count; i++) {
        EngramNode* fn = &g->nodes[i];
        if (fn->working_memory_weight > 0.0 &&
            fn->working_memory_weight < ENGRAM_WM_FLOOR) {
            fn->working_memory_weight = 0.0;
            fn->wm_anchor = 0.0;
        }
    }
    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 */
        n->wm_anchor = 0.0;             /* keep anchor coherent */
    }
}

el_val_t engram_load(el_val_t path) {
    const char* p = EL_CSTR(path);
    if (!p || !*p) return 0;
    FILE* f = fopen(p, "rb");
    if (!f) return 0;
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    if (sz <= 0) { fclose(f); return 0; }
    char* data = malloc((size_t)sz + 1);
    if (!data) { fclose(f); return 0; }
    size_t got = fread(data, 1, (size_t)sz, f);
    fclose(f);
    data[got] = '\0';

    /* Reset store */
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) {
        free(g->nodes[i].id); free(g->nodes[i].content); free(g->nodes[i].node_type);
        free(g->nodes[i].label); free(g->nodes[i].tier); free(g->nodes[i].tags);
        free(g->nodes[i].metadata);
        /* 2026-07-26 self-review: emb was the one heap field not freed
         * here — ~3 KB leaked per embedded node per reload (~11 MB per
         * reload at 3.7k embedded). forget/prune already free it. */
        free(g->nodes[i].emb); g->nodes[i].emb = NULL; g->nodes[i].emb_dim = 0;
    }
    g->node_count = 0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        free(g->edges[i].id); free(g->edges[i].from_id); free(g->edges[i].to_id);
        free(g->edges[i].relation); free(g->edges[i].metadata);
    }
    g->edge_count = 0;
    engram_idmap_free(g);
    engram_adj_free(g);

    /* Walk nodes array */
    const char* nodes_p = json_find_key(data, "nodes");
    if (nodes_p) {
        nodes_p = eg_skip_ws(nodes_p);
        if (*nodes_p == '[') {
            nodes_p++;
            nodes_p = eg_skip_ws(nodes_p);
            while (*nodes_p && *nodes_p != ']') {
                if (*nodes_p != '{') { nodes_p++; continue; }
                const char* end = json_skip_value(nodes_p);
                size_t n = (size_t)(end - nodes_p);
                char* obj = malloc(n + 1);
                memcpy(obj, nodes_p, n); obj[n] = '\0';
                engram_grow_nodes();
                EngramNode* nn = &g->nodes[g->node_count];
                memset(nn, 0, sizeof(*nn));
                nn->id        = eg_get_str_field(obj, "id");
                nn->content   = eg_get_str_field(obj, "content");
                nn->node_type = eg_get_str_field(obj, "node_type");
                nn->label     = eg_get_str_field(obj, "label");
                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_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");
                nn->temporal_decay_rate = eg_get_num_field(obj, "temporal_decay_rate");
                /* temporal_decay_rate defaults to 0 (use global) if absent in snapshot */
                nn->activation_count      = eg_get_int_field(obj, "activation_count");
                nn->last_activated        = eg_get_int_field(obj, "last_activated");
                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");
                /* 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 < ENGRAM_WM_FLOOR) 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
                 * tell "explicit 0" from "missing field" using the helper
                 * directly, so probe for the key — if absent, fall back. */
                if (json_find_key(obj, "layer_id")) {
                    nn->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
                } else {
                    nn->layer_id = ENGRAM_LAYER_DEFAULT;
                }
                /* Base-level state (2026-07-22): absent fields leave the ring
                 * empty (memset) → optimized-form fallback. */
                nn->wm_anchor = eg_get_num_field(obj, "wm_anchor");
                {
                    char* ats = eg_get_str_field(obj, "access_ts");
                    if (ats) { engram_bll_parse_access(nn, ats); free(ats); }
                }
                {
                    char* es = eg_get_str_field(obj, "emb");
                    if (es) { eg_parse_emb(nn, es); free(es); }
                }
                int64_t load_idx = g->node_count;
                g->node_count++;
                if (nn->id && *nn->id) engram_idmap_put(g, nn->id, load_idx);
                free(obj);
                nodes_p = end;
                nodes_p = eg_skip_ws(nodes_p);
                if (*nodes_p == ',') { nodes_p++; nodes_p = eg_skip_ws(nodes_p); }
            }
        }
    }
    g->adj_dirty = 1;
    /* Walk edges array */
    const char* edges_p = json_find_key(data, "edges");
    if (edges_p) {
        edges_p = eg_skip_ws(edges_p);
        if (*edges_p == '[') {
            edges_p++;
            edges_p = eg_skip_ws(edges_p);
            while (*edges_p && *edges_p != ']') {
                if (*edges_p != '{') { edges_p++; continue; }
                const char* end = json_skip_value(edges_p);
                size_t n = (size_t)(end - edges_p);
                char* obj = malloc(n + 1);
                memcpy(obj, edges_p, n); obj[n] = '\0';
                engram_grow_edges();
                EngramEdge* ee = &g->edges[g->edge_count];
                memset(ee, 0, sizeof(*ee));
                ee->id       = eg_get_str_field(obj, "id");
                ee->from_id  = eg_get_str_field(obj, "from_id");
                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_persist("{}"); }
                ee->weight     = eg_get_num_field(obj, "weight");
                ee->hebb       = eg_get_num_field(obj, "hebb"); /* absent ⇒ 0 */
                ee->confidence = eg_get_num_field(obj, "confidence");
                ee->created_at = eg_get_int_field(obj, "created_at");
                ee->updated_at = eg_get_int_field(obj, "updated_at");
                ee->last_fired = eg_get_int_field(obj, "last_fired");
                ee->inhibitory = (int)eg_get_int_field(obj, "inhibitory");
                if (json_find_key(obj, "layer_id")) {
                    ee->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
                } else {
                    ee->layer_id = ENGRAM_LAYER_DEFAULT;
                }
                g->edge_count++;
                free(obj);
                edges_p = end;
                edges_p = eg_skip_ws(edges_p);
                if (*edges_p == ',') { edges_p++; edges_p = eg_skip_ws(edges_p); }
            }
        }
    }
    /* Walk layers array (optional — older snapshots omit this).
     * If present we replace the canonical registry entirely; if absent we
     * keep whatever the engram_get() init established. */
    const char* layers_p = json_find_key(data, "layers");
    if (layers_p) {
        layers_p = eg_skip_ws(layers_p);
        if (*layers_p == '[') {
            /* Reset existing layer registry. Free strdup'd names; the
             * struct array itself can be reused. */
            for (size_t i = 0; i < g->layer_count; i++) {
                if (g->layers[i].name) free(g->layers[i].name);
                g->layers[i].name = NULL;
            }
            g->layer_count = 0;

            layers_p++;
            layers_p = eg_skip_ws(layers_p);
            while (*layers_p && *layers_p != ']') {
                if (*layers_p != '{') { layers_p++; continue; }
                const char* end = json_skip_value(layers_p);
                size_t n = (size_t)(end - layers_p);
                char* obj = malloc(n + 1);
                memcpy(obj, layers_p, n); obj[n] = '\0';
                if (g->layer_count >= g->layer_capacity) {
                    size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
                    EngramLayer* grown = realloc(g->layers, nc * sizeof(EngramLayer));
                    if (!grown) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
                    memset(grown + g->layer_capacity, 0,
                           (nc - g->layer_capacity) * sizeof(EngramLayer));
                    g->layers = grown;
                    g->layer_capacity = nc;
                }
                EngramLayer* L = &g->layers[g->layer_count];
                memset(L, 0, sizeof(*L));
                L->layer_id            = (uint32_t)eg_get_int_field(obj, "layer_id");
                L->activation_priority = (uint32_t)eg_get_int_field(obj, "activation_priority");
                L->suppressible        = (int)eg_get_int_field(obj, "suppressible") ? 1 : 0;
                L->transparent         = (int)eg_get_int_field(obj, "transparent")  ? 1 : 0;
                L->injectable          = (int)eg_get_int_field(obj, "injectable")   ? 1 : 0;
                char* nm = eg_get_str_field(obj, "name");
                if (nm && *nm) {
                    L->name = el_strdup_persist(nm);
                    free(nm);
                } else {
                    free(nm);
                    L->name = el_strdup_persist("");
                }
                g->layer_count++;
                free(obj);
                layers_p = end;
                layers_p = eg_skip_ws(layers_p);
                if (*layers_p == ',') { layers_p++; layers_p = eg_skip_ws(layers_p); }
            }
        }
    }
    /* 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;
}

/* engram_load_merge — like engram_load but WITHOUT resetting the store.
 * Reads a JSON snapshot from `path` and adds any nodes/edges not already
 * present in the in-memory graph. Dedup is by node id (for nodes) and by
 * (from_id, to_id, relation) tuple (for edges).
 *
 * Returns (as an EL int) the count of new nodes added. Used by the soul
 * daemon's periodic refresh cycle to keep its in-process Engram in sync
 * with the HTTP Engram store without losing current working memory state.
 * Ported from el-compiler/runtime on 2026-06-30 self-review. */
el_val_t engram_load_merge(el_val_t path) {
    const char* p = EL_CSTR(path);
    if (!p || !*p) return 0;
    FILE* f = fopen(p, "rb");
    if (!f) return 0;
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    rewind(f);
    if (sz <= 0) { fclose(f); return 0; }
    char* data = malloc((size_t)sz + 1);
    if (!data) { fclose(f); return 0; }
    size_t got = fread(data, 1, (size_t)sz, f);
    fclose(f);
    data[got] = '\0';

    EngramStore* g = engram_get();
    int64_t added_nodes = 0;

    /* Walk nodes array — skip any node whose id already exists */
    const char* nodes_p = json_find_key(data, "nodes");
    if (nodes_p) {
        nodes_p = eg_skip_ws(nodes_p);
        if (*nodes_p == '[') {
            nodes_p++;
            nodes_p = eg_skip_ws(nodes_p);
            while (*nodes_p && *nodes_p != ']') {
                if (*nodes_p != '{') { nodes_p++; continue; }
                const char* end = json_skip_value(nodes_p);
                size_t n = (size_t)(end - nodes_p);
                char* obj = malloc(n + 1);
                memcpy(obj, nodes_p, n); obj[n] = '\0';
                char* nid = eg_get_str_field(obj, "id");
                /* 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 (has_id && !already) {
                    engram_grow_nodes();
                    EngramNode* nn = &g->nodes[g->node_count];
                    memset(nn, 0, sizeof(*nn));
                    nn->id        = eg_get_str_field(obj, "id");
                    nn->content   = eg_get_str_field(obj, "content");
                    nn->node_type = eg_get_str_field(obj, "node_type");
                    nn->label     = eg_get_str_field(obj, "label");
                    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 = strdup("{}"); }
                    nn->salience              = eg_get_num_field(obj, "salience");
                    nn->importance            = eg_get_num_field(obj, "importance");
                    nn->confidence            = eg_get_num_field(obj, "confidence");
                    nn->temporal_decay_rate   = eg_get_num_field(obj, "temporal_decay_rate");
                    nn->activation_count      = eg_get_int_field(obj, "activation_count");
                    nn->last_activated        = eg_get_int_field(obj, "last_activated");
                    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");
                    /* 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");
                    } else {
                        nn->layer_id = ENGRAM_LAYER_DEFAULT;
                    }
                    /* Base-level history merges with the node (2026-07-22);
                     * wm_anchor stays 0 — WM state is local-only (see the
                     * working_memory_weight comment above). */
                    {
                        char* ats = eg_get_str_field(obj, "access_ts");
                        if (ats) { engram_bll_parse_access(nn, ats); free(ats); }
                    }
                    {
                        char* es = eg_get_str_field(obj, "emb");
                        if (es) { eg_parse_emb(nn, es); free(es); }
                    }
                    int64_t merge_idx = g->node_count;
                    g->node_count++;
                    added_nodes++;
                    if (nn->id && *nn->id) engram_idmap_put(g, nn->id, merge_idx);
                    g->adj_dirty = 1;
                    /* #56 durability fix: mirror engram_connect (8584) — merged
                     * nodes MUST persist to the paged store, not live in RAM only,
                     * or a cold boot returns the ~63-edge floor. Do NOT regress. */
                    if (engram_store_enabled()) eg_store_put_node(nn);
                }
                free(obj);
                nodes_p = end;
                nodes_p = eg_skip_ws(nodes_p);
                if (*nodes_p == ',') { nodes_p++; nodes_p = eg_skip_ws(nodes_p); }
            }
        }
    }

    /* Walk edges array — skip if (from_id, to_id, relation) already present */
    const char* edges_p = json_find_key(data, "edges");
    if (edges_p) {
        edges_p = eg_skip_ws(edges_p);
        if (*edges_p == '[') {
            edges_p++;
            edges_p = eg_skip_ws(edges_p);
            while (*edges_p && *edges_p != ']') {
                if (*edges_p != '{') { edges_p++; continue; }
                const char* end = json_skip_value(edges_p);
                size_t n = (size_t)(end - edges_p);
                char* obj = malloc(n + 1);
                memcpy(obj, edges_p, n); obj[n] = '\0';
                char* efrom = eg_get_str_field(obj, "from_id");
                char* eto   = eg_get_str_field(obj, "to_id");
                char* erel  = eg_get_str_field(obj, "relation");
                int dup = 0;
                if (efrom && eto && erel) {
                    for (int64_t ei = 0; ei < g->edge_count; ei++) {
                        EngramEdge* ex = &g->edges[ei];
                        if (ex->from_id && ex->to_id && ex->relation &&
                            strcmp(ex->from_id, efrom) == 0 &&
                            strcmp(ex->to_id,   eto)   == 0 &&
                            strcmp(ex->relation, erel)  == 0) {
                            dup = 1; break;
                        }
                    }
                }
                if (!dup) {
                    engram_grow_edges();
                    EngramEdge* ee = &g->edges[g->edge_count];
                    memset(ee, 0, sizeof(*ee));
                    ee->id       = eg_get_str_field(obj, "id");
                    ee->from_id  = efrom ? efrom : strdup("");
                    ee->to_id    = eto   ? eto   : strdup("");
                    ee->relation = erel  ? erel  : strdup("");
                    ee->metadata = eg_get_str_field(obj, "metadata");
                    if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = strdup("{}"); }
                    ee->weight     = eg_get_num_field(obj, "weight");
                    ee->hebb       = eg_get_num_field(obj, "hebb"); /* absent ⇒ 0 */
                    ee->confidence = eg_get_num_field(obj, "confidence");
                    ee->created_at = eg_get_int_field(obj, "created_at");
                    ee->updated_at = eg_get_int_field(obj, "updated_at");
                    ee->last_fired = eg_get_int_field(obj, "last_fired");
                    ee->inhibitory = (int)eg_get_int_field(obj, "inhibitory");
                    if (json_find_key(obj, "layer_id")) {
                        ee->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
                    } else {
                        ee->layer_id = ENGRAM_LAYER_DEFAULT;
                    }
                    g->edge_count++;
                    /* #56 durability fix: persist merged edges to the paged store. */
                    if (engram_store_enabled()) eg_store_put_edge(ee);
                    efrom = NULL; eto = NULL; erel = NULL;
                } else {
                    free(efrom); free(eto); free(erel);
                }
                free(obj);
                edges_p = end;
                edges_p = eg_skip_ws(edges_p);
                if (*edges_p == ',') { edges_p++; edges_p = eg_skip_ws(edges_p); }
            }
        }
    }

    /* 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;
}

/* ══════════════════════════════════════════════════════════════════════════
 * Engram WAL (write-ahead log) + compaction + integrity hardening.
 *
 * Design: docs/architecture/design/engram-storage-engine-wal.md §§3-14, §18.
 * Whole feature is gated behind ENGRAM_WAL=on (default off → byte-identical
 * to the snapshot-as-store behavior). When on, structural mutations append a
 * framed, CRC'd record to <dir>/engram.wal instead of rewriting the full
 * snapshot; boot replays the WAL over the base snapshot; a size threshold
 * triggers compaction (fresh snapshot + WAL truncate).
 *
 * Record framing (little-endian native; single-machine prototype):
 *   [u32 magic 'EWL1'][u32 payload_len][u8 op][u8 flags][u64 lsn]
 *   [u32 crc32][payload…]
 * crc32 covers op|flags|lsn|payload. A record whose magic/length/crc fails
 * validation marks end-of-valid-log (torn tail from a crash → replay stops
 * cleanly, never crashes).
 * ══════════════════════════════════════════════════════════════════════════ */

#define EG_WAL_MAGIC   0x314C5745u   /* 'E''W''L''1' */
#define EG_WAL_HDR_LEN 22            /* 4+4+1+1+8+4 */

/* Op codes (§5). NODE_PUT/EDGE_PUT are id-keyed upserts (replay-idempotent). */
enum {
    EG_OP_NODE_PUT     = 1,
    EG_OP_EDGE_PUT     = 2,
    EG_OP_TOMBSTONE    = 3,
    EG_OP_SUPERSEDE    = 4,
    EG_OP_LAYER_PUT    = 5,
    EG_OP_LAYER_DEL    = 6,
    EG_OP_FORGET       = 7,
    EG_OP_HEBB_BATCH   = 8,
    EG_OP_COMPACT_MARK = 9
};

/* ── crc32 (IEEE 802.3, reflected, poly 0xEDB88320) ──────────────────────────
 * init 0xFFFFFFFF, final XOR 0xFFFFFFFF. Known answers:
 *   crc32("")          == 0x00000000
 *   crc32("123456789") == 0xCBF43926 (the canonical check value)
 * eg_crc32_update takes/returns the *internal* (pre-final-xor) running value
 * so a checksum can be computed across several buffers. */
static uint32_t eg_crc32_table[256];
static int      eg_crc32_ready = 0;
static void eg_crc32_init(void) {
    for (uint32_t i = 0; i < 256; i++) {
        uint32_t c = i;
        for (int k = 0; k < 8; k++)
            c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
        eg_crc32_table[i] = c;
    }
    eg_crc32_ready = 1;
}
static uint32_t eg_crc32_update(uint32_t crc, const void* data, size_t n) {
    if (!eg_crc32_ready) eg_crc32_init();
    const uint8_t* d = (const uint8_t*)data;
    for (size_t i = 0; i < n; i++)
        crc = eg_crc32_table[(crc ^ d[i]) & 0xFF] ^ (crc >> 8);
    return crc;
}
static uint32_t eg_crc32(const void* data, size_t n) {
    return eg_crc32_update(0xFFFFFFFFu, data, n) ^ 0xFFFFFFFFu;
}
/* crc over a record's covered bytes: op|flags|lsn|payload. */
static uint32_t eg_wal_record_crc(uint8_t op, uint8_t flags, uint64_t lsn,
                                  const char* payload, size_t plen) {
    uint32_t c = 0xFFFFFFFFu;
    c = eg_crc32_update(c, &op, 1);
    c = eg_crc32_update(c, &flags, 1);
    c = eg_crc32_update(c, &lsn, sizeof(lsn));
    if (plen) c = eg_crc32_update(c, payload, plen);
    return c ^ 0xFFFFFFFFu;
}

/* EL builtin: crc32 of a string (known-answer unit tests / debugging). */
el_val_t engram_crc32(el_val_t s) {
    const char* p = EL_CSTR(s);
    if (!p) return (el_val_t)0;
    return (el_val_t)(int64_t)(uint32_t)eg_crc32(p, strlen(p));
}

/* ── WAL runtime state (single log per process — engram is single-threaded) ── */
typedef struct {
    FILE*    fp;             /* append handle, or NULL when closed        */
    char     path[1024];     /* <dir>/engram.wal                          */
    uint64_t lsn;            /* last assigned lsn; next record = lsn+1     */
    uint64_t uncommitted;    /* records appended since last fsync          */
    int64_t  last_sync_ms;   /* wall clock of last fsync                   */
    int64_t  bytes;          /* current WAL size (for compaction trigger)  */
} EngramWal;
static EngramWal eg_wal = { NULL, {0}, 0, 0, 0, 0 };

static int eg_wal_sync_mode(void) {
    /* ENGRAM_WAL_SYNC=always|group|off (default group). */
    const char* m = getenv("ENGRAM_WAL_SYNC");
    if (m && strcmp(m, "always") == 0) return 2;
    if (m && strcmp(m, "off")    == 0) return 0;
    return 1; /* group */
}
static int64_t eg_wal_group_ms(void) {
    const char* v = getenv("ENGRAM_WAL_SYNC_MS");
    if (v && *v) { long n = strtol(v, NULL, 10); if (n > 0) return n; }
    return 50; /* §5: N≈50ms */
}
static int64_t eg_wal_compact_bytes(void) {
    const char* v = getenv("ENGRAM_WAL_COMPACT_BYTES");
    if (v && *v) { long long n = strtoll(v, NULL, 10); if (n > 0) return n; }
    return 32LL * 1024 * 1024; /* §7 default 32 MB */
}

int engram_wal_enabled(void) {
    const char* f = getenv("ENGRAM_WAL");
    return (f && (strcmp(f, "on") == 0 || strcmp(f, "1") == 0)) ? 1 : 0;
}

/* Force the WAL to durable storage per the commit policy. force=1 (used by
 * compaction / explicit commit) always fsyncs; otherwise group policy. */
static void eg_wal_commit(int force) {
    if (!eg_wal.fp) return;
    int mode = eg_wal_sync_mode();
    if (mode == 0 && !force) { fflush(eg_wal.fp); return; }
    if (!force && mode == 1) {
        int64_t now = engram_now_ms();
        if (eg_wal.uncommitted == 0) return;
        if (now - eg_wal.last_sync_ms < eg_wal_group_ms()) { fflush(eg_wal.fp); return; }
    }
    fflush(eg_wal.fp);
    fsync(fileno(eg_wal.fp));
    eg_wal.uncommitted = 0;
    eg_wal.last_sync_ms = engram_now_ms();
}

/* Append one framed+CRC'd record. Returns 1 on success, 0 on I/O failure
 * (caller keeps the mutation in RAM; no corruption — §11 disk-full row). */
static int eg_wal_write(uint8_t op, uint8_t flags, const char* payload, size_t plen) {
    if (!eg_wal.fp) return 0;
    /* Remember the pre-record offset so a partial write (e.g. ENOSPC mid-
     * record) can be rolled back — otherwise a torn record would sit in the
     * MIDDLE of the log and prematurely end replay of everything after it.
     * We roll back to a clean record boundary and report failure; the caller
     * keeps the mutation in RAM (§11 disk-full row). */
    long start = ftell(eg_wal.fp);
    uint64_t lsn = ++eg_wal.lsn;
    uint32_t magic = EG_WAL_MAGIC;
    uint32_t len32 = (uint32_t)plen;
    uint32_t crc = eg_wal_record_crc(op, flags, lsn, payload, plen);
    uint8_t hdr[EG_WAL_HDR_LEN];
    memcpy(hdr + 0,  &magic, 4);
    memcpy(hdr + 4,  &len32, 4);
    hdr[8] = op; hdr[9] = flags;
    memcpy(hdr + 10, &lsn, 8);
    memcpy(hdr + 18, &crc, 4);
    int ok = (fwrite(hdr, 1, EG_WAL_HDR_LEN, eg_wal.fp) == EG_WAL_HDR_LEN);
    if (ok && plen) ok = (fwrite(payload, 1, plen, eg_wal.fp) == plen);
    if (!ok) {
        eg_wal.lsn--;                 /* reclaim the lsn */
        fflush(eg_wal.fp);
        if (start >= 0) {
            if (ftruncate(fileno(eg_wal.fp), start) == 0) {}   /* drop torn bytes */
            fseek(eg_wal.fp, 0, SEEK_END);
        }
        return 0;
    }
    eg_wal.bytes += EG_WAL_HDR_LEN + (int64_t)plen;
    eg_wal.uncommitted++;
    eg_wal_commit(0);
    return 1;
}

/* ── Single-record apply (replay + live are the same code path) ───────────── */

/* Populate an EngramNode (freshly memset OR an existing node being overwritten
 * in place) from a node JSON object. Mirrors engram_load's field set exactly,
 * WITHOUT the boot-time working_memory_weight laundering, so replay reproduces
 * the exact logged state (parity with the direct-apply oracle). Caller owns
 * freeing prior heap fields when overwriting. */
static void eg_fill_node_from_json(EngramNode* nn, const char* obj) {
    nn->id        = eg_get_str_field(obj, "id");
    nn->content   = eg_get_str_field(obj, "content");
    nn->node_type = eg_get_str_field(obj, "node_type");
    nn->label     = eg_get_str_field(obj, "label");
    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_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");
    nn->temporal_decay_rate = eg_get_num_field(obj, "temporal_decay_rate");
    nn->activation_count    = eg_get_int_field(obj, "activation_count");
    nn->last_activated      = eg_get_int_field(obj, "last_activated");
    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");
    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");
    else                                nn->layer_id = ENGRAM_LAYER_DEFAULT;
    nn->wm_anchor = eg_get_num_field(obj, "wm_anchor");
    { char* ats = eg_get_str_field(obj, "access_ts"); if (ats) { engram_bll_parse_access(nn, ats); free(ats); } }
    { char* es  = eg_get_str_field(obj, "emb");       if (es)  { eg_parse_emb(nn, es); free(es); } }
}

static void eg_free_node_heap(EngramNode* n) {
    free(n->id); free(n->content); free(n->node_type); free(n->label);
    free(n->tier); free(n->tags); free(n->metadata); free(n->emb);
}
static void eg_free_edge_heap(EngramEdge* e) {
    free(e->id); free(e->from_id); free(e->to_id); free(e->relation); free(e->metadata);
}

/* Upsert a node by id. Idempotent: replaying the same NODE_PUT twice yields
 * the same single node. */
static void eg_apply_node_put(const char* obj) {
    EngramStore* g = engram_get();
    char* id = eg_get_str_field(obj, "id");
    if (!id || !*id) { free(id); return; }
    int64_t idx = engram_find_node_index(id);
    if (idx >= 0) {
        EngramNode* n = &g->nodes[idx];
        int32_t emb_dim = n->emb_dim; (void)emb_dim;
        eg_free_node_heap(n);
        memset(n, 0, sizeof(*n));
        eg_fill_node_from_json(n, obj);
        /* id unchanged → id_map entry (which owns its own key copy) stays
         * valid; no re-put needed. */
    } else {
        engram_grow_nodes();
        EngramNode* n = &g->nodes[g->node_count];
        memset(n, 0, sizeof(*n));
        eg_fill_node_from_json(n, obj);
        int64_t ni = g->node_count;
        g->node_count++;
        if (n->id && *n->id) engram_idmap_put(g, n->id, ni);
    }
    g->adj_dirty = 1;
    free(id);
}

static int64_t eg_find_edge_index(EngramStore* g, const char* id) {
    if (!id || !*id) return -1;
    for (int64_t i = 0; i < g->edge_count; i++)
        if (g->edges[i].id && strcmp(g->edges[i].id, id) == 0) return i;
    return -1;
}
static void eg_fill_edge_from_json(EngramEdge* ee, const char* obj) {
    ee->id       = eg_get_str_field(obj, "id");
    ee->from_id  = eg_get_str_field(obj, "from_id");
    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_persist("{}"); }
    ee->weight     = eg_get_num_field(obj, "weight");
    ee->hebb       = eg_get_num_field(obj, "hebb");
    ee->confidence = eg_get_num_field(obj, "confidence");
    ee->created_at = eg_get_int_field(obj, "created_at");
    ee->updated_at = eg_get_int_field(obj, "updated_at");
    ee->last_fired = eg_get_int_field(obj, "last_fired");
    ee->inhibitory = (int)eg_get_int_field(obj, "inhibitory");
    if (json_find_key(obj, "layer_id")) ee->layer_id = (uint32_t)eg_get_int_field(obj, "layer_id");
    else                                ee->layer_id = ENGRAM_LAYER_DEFAULT;
}
static void eg_apply_edge_put(const char* obj) {
    EngramStore* g = engram_get();
    char* id = eg_get_str_field(obj, "id");
    int64_t idx = eg_find_edge_index(g, id);
    if (idx >= 0) {
        EngramEdge* e = &g->edges[idx];
        eg_free_edge_heap(e);
        memset(e, 0, sizeof(*e));
        eg_fill_edge_from_json(e, obj);
    } else {
        engram_grow_edges();
        EngramEdge* e = &g->edges[g->edge_count];
        memset(e, 0, sizeof(*e));
        eg_fill_edge_from_json(e, obj);
        g->edge_count++;
    }
    g->adj_dirty = 1;
    free(id);
}

/* TOMBSTONE / SUPERSEDE — store-level soft markers on the node's metadata.
 * (The server's DELETE route uses the higher-level marker-node model; these
 * ops exist for completeness and are replay-idempotent.) */
static void eg_apply_meta_marker(const char* obj, const char* markerfield, const char* from) {
    EngramStore* g = engram_get();
    char* id = eg_get_str_field(obj, "id");
    int64_t idx = engram_find_node_index(id);
    if (idx >= 0) {
        EngramNode* n = &g->nodes[idx];
        char* by = from ? eg_get_str_field(obj, from) : NULL;
        size_t need = strlen(markerfield) + (by ? strlen(by) : 4) + 32;
        char* meta = (char*)malloc(need);
        if (by && *by) snprintf(meta, need, "{\"%s\":\"%s\"}", markerfield, by);
        else           snprintf(meta, need, "{\"%s\":1}", markerfield);
        free(n->metadata); n->metadata = el_strdup_persist(meta);
        free(meta); free(by);
    }
    free(id);
}

static void eg_apply_layer_put(const char* obj) {
    EngramStore* g = engram_get();
    uint32_t lid = (uint32_t)eg_get_int_field(obj, "layer_id");
    EngramLayer* L = NULL;
    for (size_t i = 0; i < g->layer_count; i++)
        if (g->layers[i].layer_id == lid) { L = &g->layers[i]; break; }
    if (!L) {
        if (g->layer_count >= g->layer_capacity) {
            size_t nc = g->layer_capacity ? g->layer_capacity * 2 : 16;
            EngramLayer* grown = realloc(g->layers, nc * sizeof(EngramLayer));
            if (!grown) return;
            memset(grown + g->layer_capacity, 0, (nc - g->layer_capacity) * sizeof(EngramLayer));
            g->layers = grown; g->layer_capacity = nc;
        }
        L = &g->layers[g->layer_count++];
        memset(L, 0, sizeof(*L));
    } else if (L->name) { free(L->name); L->name = NULL; }
    L->layer_id            = lid;
    L->activation_priority = (uint32_t)eg_get_int_field(obj, "activation_priority");
    L->suppressible        = (int)eg_get_int_field(obj, "suppressible") ? 1 : 0;
    L->transparent         = (int)eg_get_int_field(obj, "transparent")  ? 1 : 0;
    L->injectable          = (int)eg_get_int_field(obj, "injectable")   ? 1 : 0;
    char* nm = eg_get_str_field(obj, "name");
    L->name = el_strdup_persist(nm && *nm ? nm : "");
    free(nm);
}
static void eg_apply_layer_del(const char* obj) {
    EngramStore* g = engram_get();
    uint32_t lid = (uint32_t)eg_get_int_field(obj, "layer_id");
    for (size_t i = 0; i < g->layer_count; i++)
        if (g->layers[i].layer_id == lid && g->layers[i].name) {
            free(g->layers[i].name); g->layers[i].name = NULL; break;
        }
}
static void eg_apply_forget(const char* obj) {
    char* id = eg_get_str_field(obj, "id");
    if (id && *id) engram_forget((el_val_t)(uintptr_t)id);
    free(id);
}

/* Apply one decoded record to the in-RAM store. HEBB_BATCH payload is a JSON
 * object {"edges":[edge,…]} — one record, one fsync, N edge upserts (§5-B). */
static void eg_wal_apply(uint8_t op, const char* payload, size_t plen) {
    char* obj = (char*)malloc(plen + 1);
    if (!obj) return;
    memcpy(obj, payload, plen); obj[plen] = '\0';
    switch (op) {
        case EG_OP_NODE_PUT:     eg_apply_node_put(obj); break;
        case EG_OP_EDGE_PUT:     eg_apply_edge_put(obj); break;
        case EG_OP_TOMBSTONE:    eg_apply_meta_marker(obj, "tombstoned", NULL); break;
        case EG_OP_SUPERSEDE:    eg_apply_meta_marker(obj, "superseded_by", "by"); break;
        case EG_OP_LAYER_PUT:    eg_apply_layer_put(obj); break;
        case EG_OP_LAYER_DEL:    eg_apply_layer_del(obj); break;
        case EG_OP_FORGET:       eg_apply_forget(obj); break;
        case EG_OP_HEBB_BATCH: {
            const char* arr = json_find_key(obj, "edges");
            if (arr) { arr = eg_skip_ws(arr);
                if (*arr == '[') { arr++; arr = eg_skip_ws(arr);
                    while (*arr && *arr != ']') {
                        if (*arr != '{') { arr++; continue; }
                        const char* end = json_skip_value(arr);
                        size_t en = (size_t)(end - arr);
                        char* eobj = (char*)malloc(en + 1);
                        memcpy(eobj, arr, en); eobj[en] = '\0';
                        eg_apply_edge_put(eobj);
                        free(eobj);
                        arr = eg_skip_ws(end);
                        if (*arr == ',') { arr++; arr = eg_skip_ws(arr); }
                    }
                }
            }
            break;
        }
        case EG_OP_COMPACT_MARK: break; /* boundary marker; no state change */
        default: break;
    }
    free(obj);
}

/* ── Replay (§6) ─────────────────────────────────────────────────────────────
 * Reads records in lsn order, applies each intact one, and stops at the first
 * record that fails magic/length/crc validation (torn tail) — never crashes.
 * Returns the number of records applied; sets *out_last_lsn to the highest
 * good lsn seen (0 if none). */
static int64_t eg_wal_replay_file(const char* path, uint64_t* out_last_lsn) {
    if (out_last_lsn) *out_last_lsn = 0;
    FILE* f = fopen(path, "rb");
    if (!f) return 0;
    fseek(f, 0, SEEK_END); long fsz = ftell(f); rewind(f);
    if (fsz <= 0) { fclose(f); return 0; }
    uint8_t* buf = (uint8_t*)malloc((size_t)fsz);
    if (!buf) { fclose(f); return 0; }
    size_t got = fread(buf, 1, (size_t)fsz, f);
    fclose(f);
    int64_t applied = 0;
    size_t off = 0;
    while (off + EG_WAL_HDR_LEN <= got) {
        uint32_t magic, len32, crc; uint64_t lsn; uint8_t op, flags;
        memcpy(&magic, buf + off + 0, 4);
        if (magic != EG_WAL_MAGIC) break;               /* garbage / torn */
        memcpy(&len32, buf + off + 4, 4);
        op = buf[off + 8]; flags = buf[off + 9];
        memcpy(&lsn, buf + off + 10, 8);
        memcpy(&crc, buf + off + 18, 4);
        size_t plen = (size_t)len32;
        if (off + EG_WAL_HDR_LEN + plen > got) break;    /* short final record */
        const char* payload = (const char*)(buf + off + EG_WAL_HDR_LEN);
        if (eg_wal_record_crc(op, flags, lsn, payload, plen) != crc) break; /* torn */
        eg_wal_apply(op, payload, plen);
        if (out_last_lsn) *out_last_lsn = lsn;
        applied++;
        off += EG_WAL_HDR_LEN + plen;
    }
    free(buf);
    return applied;
}

static void eg_wal_build_path(const char* dir, char* out, size_t cap) {
    snprintf(out, cap, "%s/engram.wal", dir ? dir : ".");
}

/* Open (create if absent) the WAL for appending. Idempotent for a given dir. */
static int eg_wal_open(const char* dir) {
    char path[1024];
    eg_wal_build_path(dir, path, sizeof(path));
    if (eg_wal.fp && strcmp(eg_wal.path, path) == 0) return 1; /* already open */
    if (eg_wal.fp) { fclose(eg_wal.fp); eg_wal.fp = NULL; }
    FILE* f = fopen(path, "ab");
    if (!f) return 0;
    eg_wal.fp = f;
    snprintf(eg_wal.path, sizeof(eg_wal.path), "%s", path);
    fseek(f, 0, SEEK_END);
    eg_wal.bytes = ftell(f);
    eg_wal.last_sync_ms = engram_now_ms();
    eg_wal.uncommitted = 0;
    return 1;
}

/* ── EL-facing builtins ──────────────────────────────────────────────────── */

/* Boot: replay <dir>/engram.wal over the already-loaded base snapshot, then
 * open the WAL for appending, continuing the lsn sequence. Idempotent replay
 * makes overlap with the base harmless (§6). Returns records replayed. */
el_val_t engram_wal_boot(el_val_t dir) {
    const char* d = EL_CSTR(dir);
    char path[1024];
    eg_wal_build_path(d, path, sizeof(path));
    uint64_t last = 0;
    int64_t applied = eg_wal_replay_file(path, &last);
    eg_wal.lsn = last;               /* continue monotonically */
    eg_wal_open(d);
    return (el_val_t)applied;
}

el_val_t engram_wal_open_dir(el_val_t dir) {
    return (el_val_t)(int64_t)eg_wal_open(EL_CSTR(dir));
}

/* Append a NODE_PUT for node `id` (serialized via the shared emitter, incl.
 * emb). Returns 1 on success. */
el_val_t engram_wal_node_put(el_val_t dir, el_val_t id) {
    if (!eg_wal_open(EL_CSTR(dir))) return (el_val_t)0;
    EngramNode* n = engram_find_node(EL_CSTR(id));
    if (!n) return (el_val_t)0;
    JsonBuf b; jb_init(&b);
    engram_emit_node_json(&b, n, 1);
    int ok = eg_wal_write(EG_OP_NODE_PUT, 0, b.buf, b.len);
    free(b.buf);
    return (el_val_t)(int64_t)ok;
}

/* Append an EDGE_PUT for every edge at index >= start_count. Covers both the
 * single-edge route and any append-only batch. Returns edges logged. */
el_val_t engram_wal_edges_since(el_val_t dir, el_val_t start_count) {
    if (!eg_wal_open(EL_CSTR(dir))) return (el_val_t)0;
    EngramStore* g = engram_get();
    int64_t start = (int64_t)start_count; if (start < 0) start = 0;
    int64_t logged = 0;
    for (int64_t i = start; i < g->edge_count; i++) {
        JsonBuf b; jb_init(&b);
        engram_emit_edge_json(&b, &g->edges[i]);
        if (eg_wal_write(EG_OP_EDGE_PUT, 0, b.buf, b.len)) logged++;
        free(b.buf);
    }
    return (el_val_t)logged;
}

/* Append ONE HEBB_BATCH record covering every edge at index >= start_count
 * (single fsync for the whole consolidation batch — §5 tier B). */
el_val_t engram_wal_hebb_batch(el_val_t dir, el_val_t start_count) {
    if (!eg_wal_open(EL_CSTR(dir))) return (el_val_t)0;
    EngramStore* g = engram_get();
    int64_t start = (int64_t)start_count; if (start < 0) start = 0;
    if (start >= g->edge_count) return (el_val_t)0;
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"edges\":[");
    int first = 1;
    for (int64_t i = start; i < g->edge_count; i++) {
        if (!first) jb_putc(&b, ',');
        first = 0;
        engram_emit_edge_json(&b, &g->edges[i]);
    }
    jb_puts(&b, "]}");
    int ok = eg_wal_write(EG_OP_HEBB_BATCH, 0, b.buf, b.len);
    free(b.buf);
    return (el_val_t)(int64_t)ok;
}

/* Append a FORGET (hard remove). Internal-GC only — NOT wired to HTTP DELETE. */
el_val_t engram_wal_forget(el_val_t dir, el_val_t id) {
    if (!eg_wal_open(EL_CSTR(dir))) return (el_val_t)0;
    const char* sid = EL_CSTR(id);
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, sid ? sid : ""); jb_putc(&b, '}');
    int ok = eg_wal_write(EG_OP_FORGET, 0, b.buf, b.len);
    free(b.buf);
    return (el_val_t)(int64_t)ok;
}

/* Compaction (§7). Crash-safe ordering: fresh base snapshot renamed into place
 * (engram_save is atomic temp+fsync+rename) BEFORE the WAL is truncated. A
 * crash in the window replays the still-present WAL over the (old or new)
 * base; idempotent apply converges. */
static int eg_wal_compact(const char* dir) {
    char snap[1024], waltmp[1024], walpath[1024];
    snprintf(snap, sizeof(snap), "%s/snapshot.json", dir);
    eg_wal_build_path(dir, walpath, sizeof(walpath));
    snprintf(waltmp, sizeof(waltmp), "%s/engram.wal.tmp", dir);
    /* 1. new base (atomic) */
    if (!engram_save((el_val_t)(uintptr_t)snap)) return 0;
    /* fsync the directory so the rename is durable before we touch the WAL */
    { int dfd = open(dir, O_RDONLY); if (dfd >= 0) { fsync(dfd); close(dfd); } }
    /* 2. fresh WAL containing only a COMPACT_MARK, atomically swapped in */
    uint64_t base_lsn = eg_wal.lsn;
    FILE* tf = fopen(waltmp, "wb");
    if (!tf) return 0;
    {
        char pl[64]; int pn = snprintf(pl, sizeof(pl), "{\"base_lsn\":%llu}", (unsigned long long)base_lsn);
        uint64_t lsn = ++eg_wal.lsn;
        uint32_t magic = EG_WAL_MAGIC, len32 = (uint32_t)pn;
        uint32_t crc = eg_wal_record_crc(EG_OP_COMPACT_MARK, 0, lsn, pl, pn);
        uint8_t hdr[EG_WAL_HDR_LEN];
        memcpy(hdr, &magic, 4); memcpy(hdr + 4, &len32, 4);
        hdr[8] = EG_OP_COMPACT_MARK; hdr[9] = 0;
        memcpy(hdr + 10, &lsn, 8); memcpy(hdr + 18, &crc, 4);
        fwrite(hdr, 1, EG_WAL_HDR_LEN, tf); fwrite(pl, 1, pn, tf);
        fflush(tf); fsync(fileno(tf));
    }
    fclose(tf);
    if (eg_wal.fp) { fclose(eg_wal.fp); eg_wal.fp = NULL; }
    if (rename(waltmp, walpath) != 0) { return 0; }
    { int dfd = open(dir, O_RDONLY); if (dfd >= 0) { fsync(dfd); close(dfd); } }
    /* 3. reopen the truncated WAL for appending */
    eg_wal_open(dir);
    return 1;
}

el_val_t engram_wal_compact(el_val_t dir) {
    return (el_val_t)(int64_t)eg_wal_compact(EL_CSTR(dir));
}

/* Compact iff the WAL has crossed the size threshold. Returns 1 if compacted. */
el_val_t engram_wal_maybe_compact(el_val_t dir) {
    if (!eg_wal.fp) eg_wal_open(EL_CSTR(dir));
    if (eg_wal.bytes > eg_wal_compact_bytes())
        return (el_val_t)(int64_t)eg_wal_compact(EL_CSTR(dir));
    return (el_val_t)0;
}

/* ── Integrity: safe data-dir resolution (§18.2) ─────────────────────────────
 * Unset ENGRAM_DATA_DIR → $HOME/.neuron/engram (created if absent). If HOME is
 * also unresolvable, FAIL LOUD (exit) rather than silently persisting to an
 * ephemeral /tmp. Prod (ENGRAM_DATA_DIR=/data) is unaffected. */
el_val_t engram_resolve_data_dir(void) {
    const char* d = getenv("ENGRAM_DATA_DIR");
    if (d && *d) return el_wrap_str(el_strdup(d));
    const char* home = getenv("HOME");
    if (!home || !*home) {
        fprintf(stderr, "[engram] FATAL: ENGRAM_DATA_DIR unset and HOME unresolved; "
                        "refusing to persist to an ephemeral dir. Set ENGRAM_DATA_DIR.\n");
        exit(1);
    }
    char neuron[1024], engramdir[1024];
    snprintf(neuron, sizeof(neuron), "%s/.neuron", home);
    snprintf(engramdir, sizeof(engramdir), "%s/.neuron/engram", home);
    mkdir(neuron, 0700);
    mkdir(engramdir, 0700);
    return el_wrap_str(el_strdup(engramdir));
}

/* ── M-INTEROCEPTION P2: chronoception (ENGRAM_CHRONOCEPTION, default OFF) ────
 * Design §9. The felt passage of time is the COOLING of the activation field,
 * not a tick count and not an elapsed-seconds readout. engram_age_field cools
 * the field (working_memory_weight + background_activation) by the MEASURED
 * wall-clock delta the caller supplies, using a pure exponential exp(-dt/TC).
 *
 * SCALE-INVARIANCE is the whole point and is structural: the cooling is a pure
 * multiply with NO per-call floor, so age(dt) applied once is bit-close to
 * age(dt/N) applied N times — N ticks that sum to the same elapsed time produce
 * the same total cooling. (A per-call snap-to-floor would break this, which is
 * why there isn't one here.)
 *
 * Reboot = anesthesia: a global last-tick wall-clock stamp is persisted to a
 * sidecar in the data dir. On boot, engram_age_field_catchup reads it, applies
 * ONE cooling for the whole unconscious gap, and reports the COOLING MAGNITUDE —
 * how far the field drifted — NEVER the elapsed seconds (timestamps are
 * bookkeeping to COMPUTE the drift, never the felt signal).
 *
 * All inert unless ENGRAM_CHRONOCEPTION is set → OFF path byte-identical. */
static int eg_chronoception_on(void){
    static int cached=-1;
    if(cached<0){ const char* s=getenv("ENGRAM_CHRONOCEPTION"); cached=(s&&s[0]&&s[0]!='0')?1:0; }
    return cached;
}
/* Field cooling time-constant in seconds; default = conversational span. */
static double eg_chrono_tc(void){ const char* s=getenv("ENGRAM_CHRONO_TC"); double v=s?atof(s):ENGRAM_CARRY_TC; if(!(v>0.0))v=ENGRAM_CARRY_TC; return v; }

el_val_t engram_age_field(el_val_t delta_ms){
    if(!eg_chronoception_on()) return el_from_float(0.0);
    double dt_ms=(double)(int64_t)delta_ms;
    if(dt_ms<=0.0) return el_from_float(0.0);
    double factor=exp(-(dt_ms/1000.0)/eg_chrono_tc());   /* pure exponential, no floor */
    EngramStore* g=engram_get();
    for(int64_t i=0;i<g->node_count;i++){
        g->nodes[i].working_memory_weight *= factor;
        g->nodes[i].background_activation *= factor;
    }
    return el_from_float(1.0-factor);   /* cooling magnitude in [0,1), NOT seconds */
}

static void eg_chrono_tick_path(char* out, size_t cap){
    el_val_t dd=engram_resolve_data_dir();
    snprintf(out, cap, "%s/chrono_last_tick", EL_CSTR(dd));
}
/* Persist the global last-tick stamp (call each tick / before shutdown). */
el_val_t engram_chrono_persist_tick(void){
    if(!eg_chronoception_on()) return (el_val_t)(int64_t)0;
    char path[1200]; eg_chrono_tick_path(path,sizeof path);
    FILE* f=fopen(path,"w"); if(!f) return (el_val_t)(int64_t)0;
    fprintf(f,"%lld\n",(long long)engram_now_ms()); fclose(f);
    return (el_val_t)(int64_t)1;
}
/* Boot-time one-shot catch-up. Reads the persisted last-tick, cools the field
 * for the whole elapsed gap in a single application, refreshes the tick, and
 * returns the COOLING MAGNITUDE (never elapsed seconds). 0 if no prior tick. */
el_val_t engram_age_field_catchup(void){
    if(!eg_chronoception_on()) return el_from_float(0.0);
    char path[1200]; eg_chrono_tick_path(path,sizeof path);
    FILE* f=fopen(path,"r"); if(!f) return el_from_float(0.0);
    long long last=0; int ok=fscanf(f,"%lld",&last); fclose(f);
    if(ok!=1 || last<=0) return el_from_float(0.0);
    int64_t now=engram_now_ms();
    double dt_ms=(double)(now-last);
    if(dt_ms<=0.0) return el_from_float(0.0);
    el_val_t mag=engram_age_field((el_val_t)(int64_t)dt_ms);
    f=fopen(path,"w"); if(f){ fprintf(f,"%lld\n",(long long)now); fclose(f); }
    return mag;
}

/* HEARTBEAT SOUL-TICK (M-INTEROCEPTION P2 wiring, 2026-08-13). The self-seeding
 * tick the soul heartbeat pumps once per beat: it MEASURES the wall-clock delta
 * since the previous tick (persisted sidecar stamp) and cools the field by that
 * REAL elapsed time — never a fixed step, never a tick count. This is the hook
 * that gives the soul time: age advances by measured seconds on every beat.
 *
 *   first call (no sidecar)  -> SEED the stamp, return magnitude 0 (no measured
 *                               interval yet; nothing to cool honestly).
 *   subsequent calls         -> cool by (now - last), re-stamp, return the
 *                               cooling MAGNITUDE in [0,1) (never elapsed seconds).
 *
 * Equivalent to engram_age_field_catchup() plus the missing boot-seed, so a live
 * heartbeat and a reboot catch-up share one code path. OFF path (flag unset) is
 * byte-identical: returns 0 and touches nothing. */
el_val_t engram_chrono_tick(void){
    if(!eg_chronoception_on()) return el_from_float(0.0);
    char path[1200]; eg_chrono_tick_path(path,sizeof path);
    int64_t now=engram_now_ms();
    FILE* f=fopen(path,"r");
    if(!f){                                   /* first beat: seed only, no cooling */
        f=fopen(path,"w"); if(f){ fprintf(f,"%lld\n",(long long)now); fclose(f); }
        return el_from_float(0.0);
    }
    long long last=0; int ok=fscanf(f,"%lld",&last); fclose(f);
    if(ok!=1 || last<=0){                     /* corrupt/empty stamp: re-seed */
        f=fopen(path,"w"); if(f){ fprintf(f,"%lld\n",(long long)now); fclose(f); }
        return el_from_float(0.0);
    }
    double dt_ms=(double)(now-last);
    el_val_t mag = (dt_ms>0.0) ? engram_age_field((el_val_t)(int64_t)dt_ms)
                               : el_from_float(0.0);
    f=fopen(path,"w"); if(f){ fprintf(f,"%lld\n",(long long)now); fclose(f); }
    return mag;
}

/* ── Integrity: store-level write-protection (§18.1, §18.3) ──────────────────
 * The protected set is DERIVED from the self-graph at call time, not hardcoded:
 * the self root and the values hub, plus every node adjacent to either (in
 * either direction). Adjacency of the values hub yields all value nodes; of
 * the self root yields the identity children — so new values/identity stay
 * protected automatically. */
#define EG_SELF_ROOT  "kn-efeb4a5b"
#define EG_VALUES_HUB "kn-5b606390"

static int eg_is_neighbor_of(EngramStore* g, const char* hub, const char* id) {
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (e->from_id && e->to_id) {
            if (strcmp(e->from_id, hub) == 0 && strcmp(e->to_id, id) == 0) return 1;
            if (strcmp(e->to_id, hub) == 0 && strcmp(e->from_id, id) == 0) return 1;
        }
    }
    return 0;
}
static int eg_is_protected(const char* id) {
    if (!id || !*id) return 0;
    if (strcmp(id, EG_SELF_ROOT) == 0)  return 1;
    if (strcmp(id, EG_VALUES_HUB) == 0) return 1;
    EngramStore* g = engram_get();
    if (eg_is_neighbor_of(g, EG_VALUES_HUB, id)) return 1;
    if (eg_is_neighbor_of(g, EG_SELF_ROOT, id))  return 1;
    return 0;
}
el_val_t engram_is_protected(el_val_t id) {
    return (el_val_t)(int64_t)eg_is_protected(EL_CSTR(id));
}

/* Derived protected set as a JSON array (self root + values hub + all adjacent
 * ids). For diagnostics and the integrity tests (§18.5). */
el_val_t engram_protected_json(void) {
    EngramStore* g = engram_get();
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int first = 1;
    const char* seeds[2] = { EG_SELF_ROOT, EG_VALUES_HUB };
    /* emit the two hubs themselves */
    for (int s = 0; s < 2; s++) {
        if (!first) jb_putc(&b, ','); first = 0;
        jb_emit_escaped(&b, seeds[s]);
    }
    for (int64_t i = 0; i < g->node_count; i++) {
        const char* nid = g->nodes[i].id;
        if (!nid || !*nid) continue;
        if (strcmp(nid, EG_SELF_ROOT) == 0 || strcmp(nid, EG_VALUES_HUB) == 0) continue;
        if (eg_is_neighbor_of(g, EG_VALUES_HUB, nid) || eg_is_neighbor_of(g, EG_SELF_ROOT, nid)) {
            if (!first) jb_putc(&b, ','); first = 0;
            jb_emit_escaped(&b, nid);
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* ── Engram JSON-string accessors ─────────────────────────────────────────
 * These return pre-serialized JSON strings so callers (especially HTTP
 * handlers) don't have to round-trip ElList/ElMap through json_stringify
 * — which can't reliably distinguish those structures from raw pointers
 * due to el_val_t's type erasure. The runtime knows the real C types and
 * can serialize directly. */

el_val_t engram_get_node_json(el_val_t id) {
    const char* sid = EL_CSTR(id);
    EngramNode* n = engram_find_node(sid);
    if (!n) return el_wrap_str(el_strdup("{}"));
    JsonBuf b; jb_init(&b);
    engram_emit_node_json(&b, n, 0);
    return el_wrap_str(b.buf);
}

/* engram_get_node_by_label — find the first node whose label field exactly
 * matches the given string. Returns the node as a JSON object string, or "{}"
 * if no match is found.
 *
 * Used by chat.el to retrieve well-known nodes (e.g. "conv:history",
 * "session:summary") by their stable label rather than by ID, which is immune
 * to vector index drift across restarts.
 *
 * Exact match (strcmp, not istr_contains) because labels like "conv:history"
 * must not collide with nodes whose content happens to contain that substring.
 *
 * Added 2026-07-01 self-review: was called in chat.el but never defined,
 * causing build failure since June 30. */
el_val_t engram_get_node_by_label(el_val_t label) {
    const char* lbl = EL_CSTR(label);
    if (!lbl || !*lbl) return el_wrap_str(el_strdup("{}"));
    EngramStore* g = engram_get();
    for (int64_t i = 0; i < g->node_count; i++) {
        EngramNode* n = &g->nodes[i];
        if (n->label && strcmp(n->label, lbl) == 0) {
            JsonBuf b; jb_init(&b);
            engram_emit_node_json(&b, n, 0);
            return el_wrap_str(b.buf);
        }
    }
    return el_wrap_str(el_strdup("{}"));
}

el_val_t engram_search_json(el_val_t query, el_val_t limit) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t lim = (int64_t)limit;
    if (lim <= 0) lim = 100;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int first = 1;
    if (q && *q) {
        /* Tokenized + ranked, same scheme as engram_search: match ANY query
         * token, rank by distinct-token coverage then salience, cap at lim.
         * (2026-07-19 port of the 2026-07-14 tokenized-search fix) */
        char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
        int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
        if (ntok > 0) {
            EngramRankEntry* hits = malloc((size_t)g->node_count * sizeof(EngramRankEntry));
            if (hits) {
                int64_t nhits = 0;
                for (int64_t i = 0; i < g->node_count; i++) {
                    EngramNode* n = &g->nodes[i];
                    /* Filter transparent layers — same as engram_search. */
                    if (engram_layer_is_transparent(n->layer_id)) continue;
                    int sc = engram_node_match_score(n, toks, ntok);
                    if (sc > 0) {
                        hits[nhits].idx      = i;
                        hits[nhits].score    = sc;
                        hits[nhits].salience = n->salience;
                        nhits++;
                    }
                }
                qsort(hits, (size_t)nhits, sizeof(EngramRankEntry), engram_rank_cmp);
                int64_t end = nhits < lim ? nhits : lim;
                for (int64_t k = 0; k < end; k++) {
                    if (!first) jb_putc(&b, ',');
                    engram_emit_node_json(&b, &g->nodes[hits[k].idx], 0);
                    first = 0;
                }
                free(hits);
            }
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* ============================================================================
 * GEOMETRIC RETRIEVAL — structure-first retrieval for the engram daemon.
 *   lexical seed (ADDRESSING, not ranking) + dominant-region addressing
 *   (fine shape:/op: preferred, else coarse skill: family) + bounded spread
 *   over real weighted edges + STRUCTURE-GATED rank.
 * Faithful C port of the offline-validated reference (score2.py geometric()):
 *   Precision@5 0.675 vs lexical 0.000; "skill" rejects rainfall (54ee1f5c).
 * engram_search_json (lexical) is RETAINED as the /api/search-lexical fallback.
 * Read-only over the graph; reuses existing runtime primitives only.
 * ==========================================================================*/
#define ENGRAM_GEO_SPREAD   0.7
#define ENGRAM_GEO_DEPTH    3
#define ENGRAM_GEO_CONC     0.30
#define ENGRAM_GEO_MAXSTAGS 16
#define ENGRAM_GEO_MAXREG   64

static int engram_is_tagbound(char c){
    return c=='"'||c=='['||c==','||c==' '||c=='\0';
}
/* Extract structural tags (skill:/shape:/op:) from a JSON-ish tags string,
 * only at token boundaries (avoids matching "op:" inside e.g. "prop:x"). */
static int engram_extract_structural_tags(const char* tags,
        char out[][ENGRAM_QTOK_LEN], int maxn){
    int cnt=0;
    if(!tags) return 0;
    const char* p=tags; char prev='[';
    while(*p && cnt<maxn){
        int boundary = (p==tags) || engram_is_tagbound(prev);
        if(boundary && (strncmp(p,"skill:",6)==0 ||
                        strncmp(p,"shape:",6)==0 ||
                        strncmp(p,"op:",3)==0)){
            int j=0; const char* s=p;
            while(*s && *s!='"' && *s!=',' && *s!=']' && j<ENGRAM_QTOK_LEN-1)
                out[cnt][j++]=*s++;
            out[cnt][j]='\0';
            cnt++;
            prev = *s ? *s : '\0';
            p = s;
            continue;
        }
        prev=*p; p++;
    }
    return cnt;
}
static int engram_node_has_tag(const EngramNode* n, const char* tg){
    char st[ENGRAM_GEO_MAXSTAGS][ENGRAM_QTOK_LEN];
    int m=engram_extract_structural_tags(n->tags, st, ENGRAM_GEO_MAXSTAGS);
    for(int i=0;i<m;i++) if(strcmp(st[i],tg)==0) return 1;
    return 0;
}
static int engram_node_has_tag_family(const EngramNode* n, const char* fam){
    size_t fl=strlen(fam);
    char st[ENGRAM_GEO_MAXSTAGS][ENGRAM_QTOK_LEN];
    int m=engram_extract_structural_tags(n->tags, st, ENGRAM_GEO_MAXSTAGS);
    for(int i=0;i<m;i++) if(strncmp(st[i],fam,fl)==0) return 1;
    return 0;
}

el_val_t engram_retrieve_geometric_json(el_val_t query, el_val_t limit) {
    EngramStore* g = engram_get();
    const char* q = EL_CSTR(query);
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 10;
    JsonBuf b; jb_init(&b); jb_putc(&b,'[');
    if (!q || !*q || g->node_count==0){ jb_putc(&b,']'); return el_wrap_str(b.buf); }

    char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
    int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
    if (ntok==0){ jb_putc(&b,']'); return el_wrap_str(b.buf); }

    double* act  = calloc((size_t)g->node_count, sizeof(double));
    int*    seed = calloc((size_t)g->node_count, sizeof(int));
    if(!act||!seed){ free(act); free(seed); jb_putc(&b,']'); return el_wrap_str(b.buf); }

    /* STEP 1: SEED — lexical ENTRY into the graph (addressing), coverage-weighted. */
    int64_t nseed=0;
    for (int64_t i=0;i<g->node_count;i++){
        EngramNode* n=&g->nodes[i];
        int sc = engram_node_match_score(n, toks, ntok);
        if (sc>0){
            double cov = (double)sc/(double)ntok;
            act[i] = n->salience*cov;
            seed[i]=1; nseed++;
        }
    }
    /* No lexical seed -> empty (matches the validated reference; no cold-embed seed). */
    if (nseed==0){ free(act); free(seed); jb_putc(&b,']'); return el_wrap_str(b.buf); }

    /* STEP 2: REGION ADDRESSING — accumulate seed mass per structural tag.
     * Fine (shape:/op:) preferred if the top fine tag holds >= CONC of fine mass;
     * else coarse skill: family gate. This makes "skill -> rainfall" impossible. */
    char  fine_tag[ENGRAM_GEO_MAXREG][ENGRAM_QTOK_LEN]; double fine_mass[ENGRAM_GEO_MAXREG]; int n_fine=0;
    char  coarse_tag[ENGRAM_GEO_MAXREG][ENGRAM_QTOK_LEN]; double coarse_mass[ENGRAM_GEO_MAXREG]; int n_coarse=0;
    for (int64_t i=0;i<g->node_count;i++){
        if(!seed[i]) continue;
        char st[ENGRAM_GEO_MAXSTAGS][ENGRAM_QTOK_LEN];
        int m=engram_extract_structural_tags(g->nodes[i].tags, st, ENGRAM_GEO_MAXSTAGS);
        for(int k=0;k<m;k++){
            if(strncmp(st[k],"shape:",6)==0 || strncmp(st[k],"op:",3)==0){
                int f=-1; for(int j=0;j<n_fine;j++) if(strcmp(fine_tag[j],st[k])==0){f=j;break;}
                if(f<0 && n_fine<ENGRAM_GEO_MAXREG){ f=n_fine++; strncpy(fine_tag[f],st[k],ENGRAM_QTOK_LEN-1); fine_tag[f][ENGRAM_QTOK_LEN-1]='\0'; fine_mass[f]=0; }
                if(f>=0) fine_mass[f]+=act[i];
            } else if(strncmp(st[k],"skill:",6)==0){
                int f=-1; for(int j=0;j<n_coarse;j++) if(strcmp(coarse_tag[j],st[k])==0){f=j;break;}
                if(f<0 && n_coarse<ENGRAM_GEO_MAXREG){ f=n_coarse++; strncpy(coarse_tag[f],st[k],ENGRAM_QTOK_LEN-1); coarse_tag[f][ENGRAM_QTOK_LEN-1]='\0'; coarse_mass[f]=0; }
                if(f>=0) coarse_mass[f]+=act[i];
            }
        }
    }
    char dom_tag[ENGRAM_QTOK_LEN]; dom_tag[0]='\0';
    char dom_family[ENGRAM_QTOK_LEN]; dom_family[0]='\0';
    int have_gate=0;   /* 0 = keep all reached; 1 = fine exact; 2 = coarse family */
    if(n_fine>0){
        double tot=0; int top=0;
        for(int j=0;j<n_fine;j++){ tot+=fine_mass[j]; if(fine_mass[j]>fine_mass[top]) top=j; }
        if(tot>0 && fine_mass[top]/tot >= ENGRAM_GEO_CONC){
            strncpy(dom_tag, fine_tag[top], ENGRAM_QTOK_LEN-1); dom_tag[ENGRAM_QTOK_LEN-1]='\0';
            have_gate=1;
        }
    }
    if(!have_gate && n_coarse>0){
        int top=0; for(int j=0;j<n_coarse;j++) if(coarse_mass[j]>coarse_mass[top]) top=j;
        const char* c=strchr(coarse_tag[top], ':');
        size_t fl = c ? (size_t)(c - coarse_tag[top] + 1) : strlen(coarse_tag[top]);
        if(fl>ENGRAM_QTOK_LEN-1) fl=ENGRAM_QTOK_LEN-1;
        memcpy(dom_family, coarse_tag[top], fl); dom_family[fl]='\0';
        have_gate=2;
    }

    /* STEP 3: BOUNDED SPREAD over real edges — na = a*w*SPREAD, keep max, BFS depth<=D. */
    int64_t fcap = (int64_t)((size_t)(g->node_count*(ENGRAM_GEO_DEPTH+1)) + 16);
    typedef struct { int64_t idx; int64_t hops; double act; } GFrontier;
    GFrontier* fr = malloc((size_t)fcap*sizeof(GFrontier));
    if(!fr){ free(act); free(seed); jb_putc(&b,']'); return el_wrap_str(b.buf); }
    int64_t fhead=0, ftail=0;
    for(int64_t i=0;i<g->node_count && ftail<fcap;i++){
        if(seed[i]){ fr[ftail].idx=i; fr[ftail].hops=0; fr[ftail].act=act[i]; ftail++; }
    }
    while(fhead<ftail){
        GFrontier f=fr[fhead++];
        if(f.hops>=ENGRAM_GEO_DEPTH) continue;
        const char* cur_id=g->nodes[f.idx].id;
        for(int64_t ei=0;ei<g->edge_count;ei++){
            EngramEdge* e=&g->edges[ei];
            const char* other=NULL;
            if(e->from_id && strcmp(e->from_id,cur_id)==0) other=e->to_id;
            else if(e->to_id && strcmp(e->to_id,cur_id)==0) other=e->from_id;
            else continue;
            int64_t oi=engram_find_node_index(other);
            if(oi<0) continue;
            double na=f.act * e->weight * ENGRAM_GEO_SPREAD;
            if(na>act[oi]){
                act[oi]=na;
                if(ftail<fcap){ fr[ftail].idx=oi; fr[ftail].hops=f.hops+1; fr[ftail].act=na; ftail++; }
            }
        }
    }

    /* STEP 4: STRUCTURE-GATED RANK — candidate qualifies iff it carries the
     * addressed region; rank by accumulated activation desc; emit top `lim`. */
    EngramRankEntry* hits = malloc((size_t)g->node_count*sizeof(EngramRankEntry));
    int64_t nh=0;
    if(hits){
        for(int64_t i=0;i<g->node_count;i++){
            if(act[i]<=0.0) continue;
            EngramNode* n=&g->nodes[i];
            int keep;
            if(have_gate==1)      keep=engram_node_has_tag(n, dom_tag);
            else if(have_gate==2) keep=engram_node_has_tag_family(n, dom_family);
            else                  keep=1;
            if(!keep) continue;
            hits[nh].idx=i; hits[nh].score=0; hits[nh].salience=act[i]; nh++;
        }
        qsort(hits,(size_t)nh,sizeof(EngramRankEntry),engram_rank_cmp);
        int first=1; int64_t end = nh<lim?nh:lim;
        for(int64_t k=0;k<end;k++){ if(!first) jb_putc(&b,','); engram_emit_node_json(&b,&g->nodes[hits[k].idx],0); first=0; }
        free(hits);
    }
    free(fr); free(act); free(seed);
    jb_putc(&b,']'); return el_wrap_str(b.buf);
}

el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    /* Skip transparent layers — introspection filter, same as engram_scan_nodes. */
    int64_t live = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
        idx[live++] = i;
    }
    engram_sort_indices_by_salience(idx, live, g->nodes);
    int64_t end = off + lim;
    if (end > live) end = live;
    int first = 1;
    for (int64_t i = off; i < end; i++) {
        if (!first) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[idx[i]], 0);
        first = 0;
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* engram_scan_nodes_by_type_json — filter by node_type before paginating.
 * Empty / NULL type_v falls back to the unfiltered scan (existing behaviour).
 * Result is JSON array, salience-sorted, transparent layers skipped. */
el_val_t engram_scan_nodes_by_type_json(el_val_t type_v, el_val_t limit, el_val_t offset) {
    const char* type_filter = EL_CSTR(type_v);
    if (!type_filter || !*type_filter) {
        return engram_scan_nodes_json(limit, offset);
    }
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 100;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t live = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
        const char* nt = g->nodes[i].node_type;
        if (!nt || strcmp(nt, type_filter) != 0) continue;
        idx[live++] = i;
    }
    engram_sort_indices_by_salience(idx, live, g->nodes);
    int64_t end = off + lim;
    if (end > live) end = live;
    int first = 1;
    for (int64_t i = off; i < end; i++) {
        if (!first) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[idx[i]], 0);
        first = 0;
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* engram_scan_nodes_emb_json — READ-ONLY, ADDITIVE (M-INTEROCEPTION P0).
 * Paginated dump of nodes WITH their dense embedding vector, for the
 * GET /api/embeddings + /api/graph/dump read routes. Purely additive: it does
 * NOT touch engram_emit_node_json or the default include_emb=0 anywhere. It
 * emits a compact, self-describing record per node — id, node_type, label,
 * created_at, emb_dim, and emb as a JSON array of %.4g floats (length ==
 * emb_dim, so a consumer can verify the vector round-trips). Nodes without an
 * embedding are emitted with emb_dim:0 and emb:[]. Same salience-sorted,
 * transparent-layer-skipped pagination as engram_scan_nodes_json.
 * Default limit is bounded (256) to keep one page's payload sane. */
el_val_t engram_scan_nodes_emb_json(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit; if (lim <= 0) lim = 256;
    int64_t off = (int64_t)offset; if (off < 0) off = 0;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (g->node_count == 0) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    int64_t live = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (engram_layer_is_transparent(g->nodes[i].layer_id)) continue;
        idx[live++] = i;
    }
    engram_sort_indices_by_salience(idx, live, g->nodes);
    int64_t end = off + lim;
    if (end > live) end = live;
    int first = 1;
    char tmp[80];
    for (int64_t i = off; i < end; i++) {
        const EngramNode* n = &g->nodes[idx[i]];
        if (!first) jb_putc(&b, ',');
        first = 0;
        jb_putc(&b, '{');
        jb_puts(&b, "\"id\":");         jb_emit_escaped(&b, n->id ? n->id : "");
        jb_puts(&b, ",\"node_type\":"); jb_emit_escaped(&b, n->node_type ? n->node_type : "");
        jb_puts(&b, ",\"label\":");     jb_emit_escaped(&b, n->label ? n->label : "");
        snprintf(tmp, sizeof(tmp), ",\"created_at\":%lld", (long long)n->created_at); jb_puts(&b, tmp);
        int32_t dim = (n->emb && n->emb_dim > 0) ? n->emb_dim : 0;
        snprintf(tmp, sizeof(tmp), ",\"emb_dim\":%d", dim); jb_puts(&b, tmp);
        jb_puts(&b, ",\"emb\":[");
        for (int32_t j = 0; j < dim; j++) {
            snprintf(tmp, sizeof(tmp), "%s%.4g", j ? "," : "", (double)n->emb[j]);
            jb_puts(&b, tmp);
        }
        jb_puts(&b, "]}");
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* engram_dreams_json — M-INTEROCEPTION P5: dream-recall-on-wake (read-only).
 * Returns the curiosity_scan InternalStateEvents created after `since_ms` that
 * are STILL RESIDENT — "what I was chewing on while you were gone." HARD honesty
 * rail: it reports only ISEs still in the buffer. Anything rotated out by the
 * 48h engram_prune_telemetry is simply absent — rotated-out = "I don't
 * remember", NEVER a synthesized/plausible dream. curiosity_scan is identified
 * by the marker in the ISE's JSON content (the soul tags each ISE with its
 * kind); heartbeat and other ISEs are excluded. Purely additive; no flag. */
el_val_t engram_dreams_json(el_val_t since_ms) {
    EngramStore* g = engram_get();
    int64_t since = (int64_t)since_ms; if (since < 0) since = 0;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int first = 1;
    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) continue;
        if (n->created_at <= since) continue;
        if (!n->content || !strstr(n->content, "curiosity_scan")) continue;
        if (!first) jb_putc(&b, ',');
        first = 0;
        jb_putc(&b, '{');
        jb_puts(&b, "\"id\":");          jb_emit_escaped(&b, n->id ? n->id : "");
        char t[32]; snprintf(t, sizeof t, ",\"created_at\":%lld", (long long)n->created_at);
        jb_puts(&b, t);
        jb_puts(&b, ",\"content\":");    jb_emit_escaped(&b, n->content ? n->content : "");
        jb_putc(&b, '}');
    }
    /* OFF-GRAPH telemetry recall: when ENGRAM_ISE_OFFGRAPH routes ISEs to the
     * state-event log instead of the graph, dream-recall must still surface the
     * curiosity_scans. Read the tail (~2MB) of state-events.jsonl. Honesty rail
     * preserved: only what is actually in the log is reported. Default-off flag
     * -> this block is inert and the function is byte-identical to before. */
    {
        const char* off = getenv("ENGRAM_ISE_OFFGRAPH");
        if (off && (strcmp(off,"1")==0 || strcmp(off,"on")==0 || strcmp(off,"true")==0)) {
            const char* dir = EL_CSTR(engram_resolve_data_dir());
            if (dir) {
                char path[4096];
                snprintf(path, sizeof path, "%s/state-events.jsonl", dir);
                FILE* lf = fopen(path, "rb");
                if (lf) {
                    fseek(lf, 0, SEEK_END); long endp = ftell(lf);
                    long startp = endp > 2000000 ? endp - 2000000 : 0;
                    fseek(lf, startp, SEEK_SET);
                    size_t cap = (size_t)(endp - startp) + 1;
                    char* data = (char*)malloc(cap);
                    if (data) {
                        size_t rd = fread(data, 1, cap - 1, lf);
                        data[rd] = 0;
                        char* line = data;
                        if (startp > 0) { char* nl0 = strchr(data, '\n'); line = nl0 ? nl0 + 1 : NULL; }
                        while (line && *line) {
                            char* nl = strchr(line, '\n'); if (nl) *nl = 0;
                            size_t ll = strlen(line);
                            if (ll && line[ll-1] == '}') line[ll-1] = 0;   /* drop trailing } */
                            if (strstr(line, "curiosity_scan")) {
                                char* tp = strstr(line, "\"ts\":");
                                long long ts = tp ? atoll(tp + 5) : 0;
                                char* cp = strstr(line, "\"content\":");
                                if (ts > since && cp) {
                                    cp += 10; /* opening quote of the content string value */
                                    if (!first) jb_putc(&b, ',');
                                    first = 0;
                                    jb_puts(&b, "{\"source\":\"log\",\"created_at\":");
                                    { char tb[32]; snprintf(tb, sizeof tb, "%lld", ts); jb_puts(&b, tb); }
                                    jb_puts(&b, ",\"content\":");
                                    jb_puts(&b, cp);   /* already a JSON string literal */
                                    jb_putc(&b, '}');
                                }
                            }
                            line = nl ? nl + 1 : NULL;
                        }
                        free(data);
                    }
                    fclose(lf);
                }
            }
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * M10 REIFIED-NEIGHBORHOOD read-only HTTP surface + ORPHAN-PREVENTION kNN
 * (added 2026-08-13). All additive; the write paths are gated at the server
 * layer (ENGRAM_AUTOCONNECT / ENGRAM_ISE_OFFGRAPH, default OFF).
 * ═══════════════════════════════════════════════════════════════════════════ */

/* engram_geo_reify_list_json — read-only list of the reified M10 neighborhoods
 * currently resident (loaded at boot from persisted Neighborhood nodes). Returns
 * [] until the offline reify writer has persisted records. Computes nothing. */
el_val_t engram_geo_reify_list_json(void){
    char* s = _eg_reify ? engram_geo_reify_list_cstr(_eg_reify) : NULL;
    if(!s){ s = el_strdup("[]"); }
    return el_wrap_str(s);
}

/* engram_geo_reify_get_json(id) — full geometry detail (centroid + members +
 * k-core/radius/co-registration) of one resident reified neighborhood. */
el_val_t engram_geo_reify_get_json(el_val_t id){
    const char* i = EL_CSTR(id);
    char* s = (_eg_reify && i) ? engram_geo_reify_get_cstr(_eg_reify, i) : NULL;
    if(!s){ s = el_strdup("{\"error\":\"neighborhood not found\"}"); }
    return el_wrap_str(s);
}

/* eg_knn_for_node — shared kNN core: up to `want` semantic neighbors of the node
 * at array index `self`, by the resident HNSW index. Fills out_idx (node array
 * indices) and out_cos (exact cosine via eg_cosine). Returns count. No writes. */
static int eg_knn_for_node(EngramStore* g, int64_t self, int want, uint64_t* out_idx, float* out_cos){
    if(self < 0 || self >= g->node_count) return 0;
    EngramNode* n = &g->nodes[self];
    if(!n->emb || n->emb_dim <= 0) 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){ 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];
        if(bi == self) continue;
        if(bi < 0 || bi >= g->node_count) continue;
        EngramNode* o = &g->nodes[bi];
        if(!o->emb || o->emb_dim != n->emb_dim) continue;
        out_idx[c] = (uint64_t)bi;
        out_cos[c] = (float)eg_cosine(n->emb, o->emb, n->emb_dim);
        c++;
    }
    free(ids); free(dist);
    return c;
}

/* engram_autoconnect_node(id, k, min_sim_pct) — ORPHAN PREVENTION. Embeds the
 * node if eligible+unembedded, then forms up to k "semantic-similar" edges to its
 * nearest embedded neighbors (cosine >= min_sim_pct/100), skipping self, existing
 * edges (either direction) and tombstones. Returns {"connected":M,"neighbors":[...]}. */
el_val_t engram_autoconnect_node(el_val_t id_v, el_val_t k_v, el_val_t minsim_v){
    EngramStore* g = engram_get();
    const char* id = EL_CSTR(id_v);
    int want = (int)(int64_t)k_v; if(want < 1) want = 3; if(want > 8) want = 8;
    double min_sim = ((double)(int64_t)minsim_v) / 100.0;
    JsonBuf b; jb_init(&b);
    int64_t self = id ? engram_find_node_index(id) : -1;
    if(self < 0){ jb_puts(&b, "{\"connected\":0,\"reason\":\"missing\"}"); return el_wrap_str(b.buf); }
    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);
                        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); }
    uint64_t idx[16]; float cosv[16];
    int m = eg_knn_for_node(g, self, want, idx, cosv);
    int connected = 0;
    jb_puts(&b, "{\"neighbors\":[");
    for(int j=0; j<m; j++){
        int64_t bi = (int64_t)idx[j];
        EngramNode* o = &g->nodes[bi];
        if((double)cosv[j] < min_sim) continue;
        if(o->node_type && strcmp(o->node_type, "Tombstone") == 0) continue;
        if((int64_t)engram_edge_between(EL_STR(n->id), EL_STR(o->id))) continue;
        if((int64_t)engram_edge_between(EL_STR(o->id), EL_STR(n->id))) continue;
        engram_connect(EL_STR(n->id), EL_STR(o->id), el_from_float((double)cosv[j]), EL_STR("semantic-similar"));
        if(connected) jb_putc(&b, ',');
        jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, o->id ? o->id : "");
        { char tb[48]; snprintf(tb, sizeof tb, ",\"cosine\":%.4f}", (double)cosv[j]); jb_puts(&b, tb); }
        connected++;
    }
    { char tb[48]; snprintf(tb, sizeof tb, "],\"connected\":%d}", connected); jb_puts(&b, tb); }
    return el_wrap_str(b.buf);
}

/* engram_nearest_json(id, k) — READ-ONLY kNN (no writes). Used by the backfill
 * dry-run and GET /api/nearest/<id>. */
el_val_t engram_nearest_json(el_val_t id_v, el_val_t k_v){
    EngramStore* g = engram_get();
    const char* id = EL_CSTR(id_v);
    int want = (int)(int64_t)k_v; if(want < 1) want = 3; if(want > 16) want = 16;
    JsonBuf b; jb_init(&b);
    int64_t self = id ? engram_find_node_index(id) : -1;
    if(self < 0){ jb_puts(&b, "{\"error\":\"missing\"}"); return el_wrap_str(b.buf); }
    EngramNode* n = &g->nodes[self];
    if(!n->emb || n->emb_dim <= 0){
        jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, id ? id : "");
        jb_puts(&b, ",\"embedded\":false,\"neighbors\":[]}");
        return el_wrap_str(b.buf);
    }
    uint64_t idx[24]; float cosv[24];
    int m = eg_knn_for_node(g, self, want, idx, cosv);
    jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, id ? id : "");
    jb_puts(&b, ",\"embedded\":true,\"neighbors\":[");
    for(int j=0; j<m; j++){
        int64_t bi = (int64_t)idx[j]; EngramNode* o = &g->nodes[bi];
        if(j) jb_putc(&b, ',');
        jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, o->id ? o->id : "");
        { char tb[48]; snprintf(tb, sizeof tb, ",\"cosine\":%.4f}", (double)cosv[j]); jb_puts(&b, tb); }
    }
    jb_puts(&b, "]}");
    return el_wrap_str(b.buf);
}

/* engram_ise_log_append(content) — append one JSON line to the state-event log
 * tier (data_dir/state-events.jsonl), OFF the node graph. Returns 1/0. */
el_val_t engram_ise_log_append(el_val_t content_v){
    const char* c = EL_CSTR(content_v);
    if(!c) return EL_INT(0);
    const char* dir = EL_CSTR(engram_resolve_data_dir());
    if(!dir) return EL_INT(0);
    char path[4096];
    snprintf(path, sizeof path, "%s/state-events.jsonl", dir);
    FILE* f = fopen(path, "a");
    if(!f) return EL_INT(0);
    fprintf(f, "{\"ts\":%lld,\"content\":\"", (long long)engram_now_ms());
    for(const unsigned char* p=(const unsigned char*)c; *p; p++){
        switch(*p){
            case '"':  fputs("\\\"",f); break;
            case '\\': fputs("\\\\",f); break;
            case '\n': fputs("\\n",f);  break;
            case '\r': fputs("\\r",f);  break;
            case '\t': fputs("\\t",f);  break;
            default: if(*p < 0x20) fprintf(f,"\\u%04x",(unsigned)*p); else fputc(*p,f);
        }
    }
    fputs("\"}\n", f);
    /* RETENTION (2026-08-17 self-review). The on-graph ISE branch in
     * server.el calls engram_prune_telemetry(48h) on every insert, but that
     * branch is DEAD in production: ENGRAM_ISE_OFFGRAPH=1 is the live
     * setting, so every state event lands here instead — and this path had
     * no retention of any kind. Measured: 17.1 MB / 14,305 events over 3.56
     * days = 4.81 MB/day, growing without bound (~1.76 GB/year). The graph
     * got its telemetry-growth fix on 2026-07-16; moving telemetry off-graph
     * moved the leak rather than closing it.
     *
     * Byte-bounded rather than time-bounded on purpose: this is a flat
     * append-only file with no index, so size is the property that actually
     * has to be bounded, and a byte check is O(1) against the handle we
     * already hold (ftell) instead of an O(file) timestamp scan per append.
     * At the measured rate the 64 MB default retains ~13 days — comfortably
     * more history than the 48h the on-graph path kept.
     *
     * Compaction keeps the TAIL, never the head: engram_dreams_json reads
     * the last ~2 MB of this file for dream-recall, so the recent end is the
     * end that has a reader. KEEP is held well above that 2 MB window so
     * recall is never truncated by a rotation. The honesty rail is
     * preserved exactly as before — rotated-out remains "I don't remember",
     * never a synthesized dream; this only makes the forgetting bounded and
     * explicit instead of deferred forever. */
    long pos = ftell(f);
    fclose(f);
    {
        long maxb = 64L*1024L*1024L;
        long keepb = 16L*1024L*1024L;
        const char* mv = getenv("ENGRAM_ISE_LOG_MAX_BYTES");
        if (mv && *mv) { long v = atol(mv); if (v > 0) maxb = v; }
        if (keepb > maxb/2) keepb = maxb/2;
        if (pos > 0 && pos > maxb) {
            FILE* rf = fopen(path, "rb");
            if (rf) {
                if (fseek(rf, pos - keepb, SEEK_SET) == 0) {
                    char* buf = (char*)malloc((size_t)keepb + 1);
                    if (buf) {
                        size_t rd = fread(buf, 1, (size_t)keepb, rf);
                        buf[rd] = 0;
                        /* Resume at the first LINE boundary so the tail never
                         * begins with a half-written JSON record. */
                        char* start = memchr(buf, '\n', rd);
                        start = start ? start + 1 : buf;
                        size_t keep_n = rd - (size_t)(start - buf);
                        char tmp[4096];
                        snprintf(tmp, sizeof tmp, "%s/state-events.jsonl.tmp", dir);
                        FILE* wf = fopen(tmp, "wb");
                        if (wf) {
                            int ok = (fwrite(start, 1, keep_n, wf) == keep_n);
                            fclose(wf);
                            /* Only replace the live log if the tail was written
                             * in full — a short write must not destroy history. */
                            if (ok) rename(tmp, path); else remove(tmp);
                        }
                        free(buf);
                    }
                }
                fclose(rf);
            }
        }
    }
    return EL_INT(1);
}

/* ═══════════════════════════════════════════════════════════════════════════

/* ═══════════════════════════════════════════════════════════════════════════
 * §5 GEOMETRY OPERATORS as EL BUILTINS (Will: "primitives any CGI application
 * should be able to use"). READ-ONLY. Each builds the CENTERED descriptor for a
 * comma-separated seed-id set (against the true store-wide mean, exactly as the
 * ENGRAM_GEO_PRIMING_NOCACHE ad-hoc path does), then runs the pure operator from
 * engram_geometry.c and serializes the result to JSON. Additive: no flag, no
 * effect on activation/retrieval. Surfacing these through engram.el + the elc fold
 * is a CUTOVER step (same elc-drift deferral as the M-INTEROCEPTION P0/P5 builtins);
 * the C table wiring (native __ wrappers in el_seed.c) is in place now.
 * ═══════════════════════════════════════════════════════════════════════════ */

/* Emit a dim-length float vector as a JSON array of %.6g. */
static void eg_geo_emit_vec(JsonBuf* b, const float* v, int dim) {
    char t[48]; jb_putc(b, '[');
    for (int i = 0; i < dim; i++) { snprintf(t, sizeof t, "%s%.6g", i ? "," : "", (double)v[i]); jb_puts(b, t); }
    jb_putc(b, ']');
}

/* Build the centered geometry descriptor for a comma-separated seed-id list.
 * Returns a malloc'd descriptor (engram_geo_free) or NULL if geometry is
 * unavailable (no paged store / no embeddings / seeds unresolvable). */
static GeoDescriptor* eg_geo_build_desc(const char* csv) {
    if (!csv || !*csv || !g_engram_store) return NULL;
    EngramStore* g = engram_get();
    if (!g || g->node_count <= 0) return NULL;
    /* split CSV → seed id array (dup, trimmed of spaces). */
    int cap = 8, ns = 0; char** ids = malloc((size_t)cap * sizeof(char*));
    if (!ids) return NULL;
    const char* p = csv;
    while (*p) {
        while (*p == ' ' || *p == ',') p++;
        const char* s = p;
        while (*p && *p != ',') p++;
        const char* e = p; while (e > s && e[-1] == ' ') e--;
        if (e > s) {
            if (ns == cap) { cap *= 2; char** t = realloc(ids, (size_t)cap * sizeof(char*)); if (!t) break; ids = t; }
            ids[ns] = strndup(s, (size_t)(e - s)); ns++;
        }
    }
    if (ns == 0) { free(ids); return NULL; }
    /* infer embedding dim from the first resolvable embedded seed. */
    int32_t dim = 0;
    for (int i = 0; i < ns && dim == 0; i++) {
        int64_t idx = engram_find_node_index(ids[i]);
        if (idx >= 0 && idx < g->node_count && g->nodes[idx].emb && g->nodes[idx].emb_dim > 0)
            dim = g->nodes[idx].emb_dim;
    }
    GeoDescriptor* geo = NULL;
    const float* gmean = (dim > 0) ? eg_geo_mean_sync(dim) : NULL;
    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, 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]);
    free(ids);
    return geo;
}

static el_val_t eg_geo_err(const char* msg) {
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"error\":"); jb_emit_escaped(&b, msg); jb_putc(&b, '}');
    return el_wrap_str(b.buf);
}

/* engram_geo_reify_run_json — WIRE the dormant M10 reification writer (2026-08-13).
 * Builds the store-wide id vector + ANN index the descriptor needs, then runs
 * engram_geo_reify_store, which persists ONE GeoMeanFrame plus up to
 * max_neighborhoods (128) Neighborhood nodes DIRECTLY to the paged store. Each
 * Neighborhood node carries a human-readable summary (content="reified-
 * neighborhood") + its geometry in GEO1 metadata (centroid ref, radius, covariance
 * ellipsoid extents, k-core skeleton, co-registration, and its member list with
 * membership weights) and its raw centroid as emb; relation="member" edges join it
 * to each member. Afterward the resident _eg_reify index is rebuilt so the
 * read-only /api/neighborhoods[/<id>] endpoints reflect the new geometry
 * immediately. Because the records live in neuron.egm, they also survive a cold
 * reboot (engram_store_boot routes Neighborhood/GeoMeanFrame back into _eg_reify).
 * Returns {"reified":N,"resident":M} (N persisted this run, M resident) or an
 * error object. WRITE op — the server layer auth-gates POST /api/reify. */
el_val_t engram_geo_reify_run_json(void){
    if(!engram_store_enabled() || !g_engram_store)
        return eg_geo_err("reify unavailable (paged store off)");
    EngramStore* g = engram_get();
    if(!g || g->node_count <= 0)
        return eg_geo_err("reify unavailable (empty graph)");
    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;
    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){
        /* one-level containment DAG over the flat neighborhoods just written. */
        nested = engram_geo_reify_nest(g_engram_store, 0.0);
        if(_eg_reify){ engram_geo_reify_index_free(_eg_reify); _eg_reify = NULL; }
        _eg_reify = engram_geo_reify_load(g_engram_store);
    }
    JsonBuf b; jb_init(&b); char t[128];
    if(persisted < 0) snprintf(t, sizeof t, "{\"error\":\"reify failed\",\"code\":%d}", persisted);
    else snprintf(t, sizeof t, "{\"reified\":%d,\"nested\":%d,\"resident\":%d}",
                  persisted, nested, _eg_reify ? engram_geo_reify_count(_eg_reify) : 0);
    jb_puts(&b, t);
    return el_wrap_str(b.buf);
}

/* ── SELF-REIFICATION: on-beat autonomous neighborhood formation (2026-08-14) ──
 * Reification is an OPERATION OF THE ENGRAM, pumped every heartbeat next to
 * Hebbian consolidation — dense regions cohere → name → nest → supersede
 * THEMSELVES. Flag-gated (ENGRAM_SELF_REIFY) default-OFF: with the flag unset
 * this returns {"enabled":false} and writes nothing, so the binary is byte-inert
 * until Will enables it. Safe to run ungated once enabled BECAUSE OF SUPERSESSION:
 * nothing is destroyed, every naming/grouping can be superseded into residue, so
 * there is no irreversible decision that would justify a pre-action approval gate.
 * The one privilege is the CORE (self-root + values keystones): reification only
 * ever adds Neighborhood hubs + member/supersedes edges and supersedes PRIOR
 * neighborhood records — it never mutates or supersedes a content/identity node,
 * so the keystones are structurally untouchable here. Neighborhoods are a FLAT,
 * equal-status field: no domain-priority is stored; importance stays a live
 * activation computation, never a field on the hub. */
static int engram_self_reify_enabled(void){
    const char* v = getenv("ENGRAM_SELF_REIFY");
    return v && (v[0]=='1' || v[0]=='o' || v[0]=='O' || v[0]=='t' || v[0]=='T');
}
el_val_t engram_self_reify_beat_json(void){
    if(!engram_self_reify_enabled())
        return el_wrap_str(strdup("{\"enabled\":false}"));
    if(!engram_store_enabled() || !g_engram_store)
        return el_wrap_str(strdup("{\"enabled\":true,\"error\":\"paged store off\"}"));
    EngramStore* g = engram_get();
    if(!g || g->node_count <= 0)
        return el_wrap_str(strdup("{\"enabled\":true,\"error\":\"empty graph\"}"));
    /* ── FINE-GRAINED LOCKING (2026-08-14): the beat is the one long writer. Under
     * the request lock (held by http_worker) we take a PRIVATE, self-contained
     * snapshot of the ONLY RAM-graph inputs reification needs — the embedding index
     * (a private VIndex; vindex_insert COPIES each vector) and the id list (strdup'd
     * copies). Both are then independent of g->nodes, which no HTTP path removes from
     * (engram_forget is internal-GC only), so appends by a concurrent ingest cannot
     * dangle them. We then RELEASE the request lock for the multi-second reify, which
     * touches ONLY the thread-safe paged store (its own recursive mutex) + this
     * private snapshot — never g and never the shared _eg_vindex. Ingest/reads run
     * during it. We re-acquire only to rebuild the shared resident index. */
    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;
    int64_t nc = g->node_count;
    char** vids = malloc((size_t)(nc>0?nc:1) * sizeof(char*));
    if(!vids) return eg_geo_err("self-reify oom");
    /* Under the lock, do ONLY the cheap O(nc) COPY of the two RAM-graph inputs — id
     * strings (strdup) and embedding vectors (into a private contiguous buffer). The
     * expensive part (building the HNSW index) is done AFTER unlocking, off the copy,
     * so the lock is held for ~ms (a 15 MB memcpy), not the ~seconds of index build.*/
    for(int64_t i = 0; i < nc; i++){ const char* id = g->nodes[i].id; vids[i] = strdup(id?id:""); }
    float* embcopy = NULL; uint8_t* has_emb = NULL;
    if(dim > 0 && nc > 0){
        embcopy = malloc((size_t)nc * (size_t)dim * sizeof(float));
        has_emb = calloc((size_t)nc, 1);
        if(embcopy && has_emb) for(int64_t i = 0; i < nc; i++){
            EngramNode* n = &g->nodes[i];
            if(n->emb && n->emb_dim == dim){ memcpy(embcopy + (size_t)i*dim, n->emb, (size_t)dim*sizeof(float)); has_emb[i]=1; }
        }
    }
    /* ── release the request lock: g is no longer touched from here ────────────── */
    engram_req_unlock();
    /* build the private vindex OFF THE COPY, lock-free */
    VIndex* vx = NULL;
    if(embcopy && has_emb){
        vx = vindex_create((int)dim, 0, 0);
        if(vx) for(int64_t i = 0; i < nc; i++)
            if(has_emb[i]) (void)vindex_insert(vx, (uint64_t)i, embcopy + (size_t)i*dim);
    }

    GeoReifyParams P; engram_geo_reify_default_params(&P);
    P.incremental   = 1;               /* change-detection → idempotent on-beat */
    P.grounded_name = 1;               /* names grounded in member labels       */
    P.cause         = "autonomous-drift";
    P.cover_membership = 0.66;         /* hub-dedup only; preserves overlap      */
    /* homeostatic budget: bounded per beat (env override, default 128). */
    { const char* mx = getenv("ENGRAM_SELF_REIFY_MAX");
      if(mx && *mx){ int m = atoi(mx); if(m > 0) P.max_neighborhoods = m; } }
    GeoReifyStats st; memset(&st, 0, sizeof st);
    P.stats = &st;

    /* the request lock is ALREADY released (above); this multi-second reify touches
     * only the thread-safe paged store + the private vx/vids/embcopy. */
    int persisted = engram_geo_reify_store(g_engram_store, vx, vids, (int)nc, &P);
    engram_req_lock();                  /* re-acquire (http_worker's final unlock balances) */

    for(int64_t i = 0; i < nc; i++) free(vids[i]);
    free(vids);
    free(embcopy); free(has_emb);
    if(vx) vindex_free(vx);
    int nested = 0;
    /* Only re-nest + rebuild the resident index when something actually changed —
     * an unchanged beat touches nothing (convergence; barrier/GC see no writes). */
    if(persisted > 0){
        nested = engram_geo_reify_nest(g_engram_store, 0.0);
        if(_eg_reify){ engram_geo_reify_index_free(_eg_reify); _eg_reify = NULL; }
        _eg_reify = engram_geo_reify_load(g_engram_store);
        /* RSS BOUND: a WRITING beat dirties pool pages (new neighborhood nodes +
         * member edges). The pool never evicts DIRTY frames, so without a flush they
         * accumulate and RSS ratchets up. Checkpoint turns them dirty→clean so the
         * pool evicts within POOL_FRAMES. Safe post-#56; fires ONLY on writing beats
         * (settled soul = zero checkpoints). Default-on; ENGRAM_REIFY_CKPT=0 disables. */
        const char* rc = getenv("ENGRAM_REIFY_CKPT");
        if(!(rc && rc[0]=='0')) engram_checkpoint(g_engram_store);
    }
    JsonBuf b; jb_init(&b); char t[192];
    if(persisted < 0) snprintf(t, sizeof t, "{\"enabled\":true,\"error\":\"reify failed\",\"code\":%d}", persisted);
    else snprintf(t, sizeof t,
        "{\"enabled\":true,\"reified\":%d,\"skipped\":%d,\"superseded\":%d,\"member_edges\":%d,\"nested\":%d,\"resident\":%d,\"wrote\":%s}",
        st.reified, st.skipped, st.superseded, st.member_edges, nested,
        _eg_reify ? engram_geo_reify_count(_eg_reify) : 0,
        persisted > 0 ? "true" : "false");
    jb_puts(&b, t);
    return el_wrap_str(b.buf);
}

/* engram_neighborhood_rename_json(id,name) — async explicit override. */
el_val_t engram_neighborhood_rename_json(el_val_t id, el_val_t name){
    if(!engram_store_enabled() || !g_engram_store)
        return eg_geo_err("rename unavailable (paged store off)");
    const char* nid = EL_CSTR(id);
    const char* nm  = EL_CSTR(name);
    if(!nid || !*nid) return eg_geo_err("missing neighborhood id");
    if(!nm  || !*nm)  return eg_geo_err("missing name");
    char* newid = engram_geo_neighborhood_rename(g_engram_store, nid, nm);
    if(!newid) return eg_geo_err("rename failed (id not a live neighborhood)");
    /* rebuild resident index so /api/neighborhoods reflects the new name at once. */
    if(_eg_reify){ engram_geo_reify_index_free(_eg_reify); _eg_reify = NULL; }
    _eg_reify = engram_geo_reify_load(g_engram_store);
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"ok\":true,\"renamed\":"); jb_emit_escaped(&b, nid);
    jb_puts(&b, ",\"new_id\":"); jb_emit_escaped(&b, newid);
    jb_puts(&b, ",\"name\":"); jb_emit_escaped(&b, nm);
    jb_putc(&b, '}');
    free(newid);
    return el_wrap_str(b.buf);
}

/* engram_geo_descriptor_json(seed_ids_csv) → the compact centered descriptor. */
el_val_t engram_geo_descriptor_json(el_val_t seeds) {
    GeoDescriptor* g = eg_geo_build_desc(EL_CSTR(seeds));
    if (!g) return eg_geo_err("geometry unavailable (no paged store / embeddings / seeds)");
    JsonBuf b; jb_init(&b); char t[80];
    jb_putc(&b, '{');
    jb_puts(&b, "\"hub_id\":"); jb_emit_escaped(&b, g->hub_id ? g->hub_id : "");
    snprintf(t, sizeof t, ",\"dim\":%d,\"radius\":%.6g,\"total_variance\":%.6g", g->dim, g->radius, g->total_variance); jb_puts(&b, t);
    snprintf(t, sizeof t, ",\"k_core\":%d,\"co_registration\":%.6g", g->k_core, g->co_registration); jb_puts(&b, t);
    snprintf(t, sizeof t, ",\"n_members\":%d,\"n_embedded\":%d,\"n_axes\":%d", g->n_members, g->n_embedded, g->n_axes); jb_puts(&b, t);
    jb_puts(&b, ",\"axis_extents\":["); for (int i = 0; i < g->n_axes; i++) { snprintf(t, sizeof t, "%s%.6g", i ? "," : "", g->axes[i].extent); jb_puts(&b, t); } jb_putc(&b, ']');
    jb_puts(&b, ",\"members\":[");
    for (int i = 0; i < g->n_members; i++) {
        if (i) jb_putc(&b, ',');
        jb_puts(&b, "{\"id\":"); jb_emit_escaped(&b, g->members[i].id ? g->members[i].id : "");
        snprintf(t, sizeof t, ",\"m\":%.4g,\"cent\":%.4g,\"core\":%d}", g->members[i].membership, g->members[i].centrality, g->members[i].core); jb_puts(&b, t);
    }
    jb_putc(&b, ']'); jb_putc(&b, '}');
    engram_geo_free(g);
    return el_wrap_str(b.buf);
}

/* engram_geo_overlap_json(a_csv, b_csv) → shared set + Jaccard + score. */
el_val_t engram_geo_overlap_json(el_val_t a_seeds, el_val_t b_seeds) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    if (!A || !B) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); return eg_geo_err("geometry unavailable"); }
    GeoOverlap o; char t[80]; JsonBuf b; jb_init(&b);
    if (engram_geo_overlap(A, B, &o) != 0) { engram_geo_free(A); engram_geo_free(B); return eg_geo_err("dim mismatch"); }
    jb_putc(&b, '{');
    snprintf(t, sizeof t, "\"n_shared\":%d,\"n_union\":%d,\"jaccard\":%.6g", o.n_shared, o.n_union, o.jaccard); jb_puts(&b, t);
    snprintf(t, sizeof t, ",\"centroid_distance\":%.6g,\"overlap_score\":%.6g", o.centroid_distance, o.overlap_score); jb_puts(&b, t);
    jb_puts(&b, ",\"shared_ids\":["); for (int i = 0; i < o.n_shared; i++) { if (i) jb_putc(&b, ','); jb_emit_escaped(&b, o.shared_ids[i]); } jb_putc(&b, ']');
    jb_putc(&b, '}');
    engram_geo_overlap_free(&o); engram_geo_free(A); engram_geo_free(B);
    return el_wrap_str(b.buf);
}

/* engram_geo_subtract_json(a_csv, b_csv, mode) — mode "setdiff" → set-difference,
 * anything else → orthogonal-complement residual. */
el_val_t engram_geo_subtract_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t mode) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    if (!A || !B) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); return eg_geo_err("geometry unavailable"); }
    const char* m = EL_CSTR(mode); char t[80]; JsonBuf b; jb_init(&b);
    if (m && strcmp(m, "setdiff") == 0) {
        GeoSetDiff s;
        if (engram_geo_setdiff(A, B, &s) != 0) { engram_geo_free(A); engram_geo_free(B); return eg_geo_err("dim mismatch"); }
        jb_putc(&b, '{'); jb_puts(&b, "\"mode\":\"setdiff\"");
        snprintf(t, sizeof t, ",\"n_only\":%d,\"removed\":%d,\"centroid_diff_mag\":%.6g", s.n_only, s.removed, s.centroid_diff_mag); jb_puts(&b, t);
        jb_puts(&b, ",\"only_ids\":["); for (int i = 0; i < s.n_only; i++) { if (i) jb_putc(&b, ','); jb_emit_escaped(&b, s.only_ids[i]); } jb_putc(&b, ']');
        jb_putc(&b, '}');
        engram_geo_setdiff_free(&s);
    } else {
        GeoResidual r;
        if (engram_geo_subtract(A, B, 0, &r) != 0) { engram_geo_free(A); engram_geo_free(B); return eg_geo_err("dim mismatch"); }
        jb_putc(&b, '{'); jb_puts(&b, "\"mode\":\"residual\"");
        snprintf(t, sizeof t, ",\"variance_explained_by_B\":%.6g,\"residual_scale\":%.6g", r.variance_explained_by_B, r.residual_scale); jb_puts(&b, t);
        snprintf(t, sizeof t, ",\"removed_dims\":%d,\"residual_n_axes\":%d,\"centroid_diff_mag\":%.6g", r.removed_dims, r.n_axes, r.centroid_diff_mag); jb_puts(&b, t);
        jb_putc(&b, '}');
        engram_geo_residual_free(&r);
    }
    engram_geo_free(A); engram_geo_free(B);
    return el_wrap_str(b.buf);
}

/* engram_geo_combine_json(a_csv, b_csv) → merged descriptor summary. */
el_val_t engram_geo_combine_json(el_val_t a_seeds, el_val_t b_seeds) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    if (!A || !B) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); return eg_geo_err("geometry unavailable"); }
    GeoDescriptor* C = engram_geo_combine(A, B, 8);
    engram_geo_free(A); engram_geo_free(B);
    if (!C) return eg_geo_err("combine failed (dim mismatch / OOM)");
    JsonBuf b; jb_init(&b); char t[80];
    jb_putc(&b, '{');
    jb_puts(&b, "\"hub_id\":"); jb_emit_escaped(&b, C->hub_id ? C->hub_id : "");
    snprintf(t, sizeof t, ",\"dim\":%d,\"radius\":%.6g,\"total_variance\":%.6g", C->dim, C->radius, C->total_variance); jb_puts(&b, t);
    snprintf(t, sizeof t, ",\"n_members\":%d,\"n_embedded\":%d,\"n_axes\":%d", C->n_members, C->n_embedded, C->n_axes); jb_puts(&b, t);
    jb_puts(&b, ",\"axis_extents\":["); for (int i = 0; i < C->n_axes; i++) { snprintf(t, sizeof t, "%s%.6g", i ? "," : "", C->axes[i].extent); jb_puts(&b, t); } jb_putc(&b, ']');
    jb_putc(&b, '}');
    engram_geo_free(C);
    return el_wrap_str(b.buf);
}

/* engram_geo_distance_json(a_csv, b_csv) → centroid + Wasserstein-2. */
el_val_t engram_geo_distance_json(el_val_t a_seeds, el_val_t b_seeds) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    if (!A || !B) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); return eg_geo_err("geometry unavailable"); }
    GeoDistance d; char t[96]; JsonBuf b; jb_init(&b);
    if (engram_geo_distance(A, B, &d) != 0) { engram_geo_free(A); engram_geo_free(B); return eg_geo_err("dim mismatch"); }
    snprintf(t, sizeof t, "{\"centroid_distance\":%.6g,\"centroid_cosine\":%.6g,\"wasserstein2\":%.6g}", d.centroid_distance, d.centroid_cosine, d.wasserstein2);
    jb_puts(&b, t);
    engram_geo_free(A); engram_geo_free(B);
    return el_wrap_str(b.buf);
}

/* engram_geo_analogy_json(a_csv, b_csv) → orthogonal Procrustes residual + rank. */
el_val_t engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    if (!A || !B) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); return eg_geo_err("geometry unavailable"); }
    GeoAnalogy an; char t[96]; JsonBuf b; jb_init(&b);
    if (engram_geo_analogy(A, B, &an) != 0) { engram_geo_free(A); engram_geo_free(B); return eg_geo_err("dim mismatch"); }
    snprintf(t, sizeof t, "{\"subspace_rank\":%d,\"residual\":%.6g}", an.r, an.residual);
    jb_puts(&b, t);
    engram_geo_analogy_free(&an);
    engram_geo_free(A); engram_geo_free(B);
    return el_wrap_str(b.buf);
}

/* engram_reason_analogy_json(a_csv, b_csv, c_csv) — REASONING: "A:B :: C:?".
 * Learns the A→B transform (Procrustes rotation + residual translation) and applies
 * it to C, returning the predicted point + the Procrustes frame-fit residual. This
 * is the analogy MODE (engram_reason.c) surfaced over the same flat-CSV seed ABI as
 * the §5 operators. The remaining reasoning modes (induction/abduction/causal/
 * planning) take candidate-set / point / timestamp inputs that do not map to flat
 * CSV and are C-layer only for now (see the reasoning-operators runbook). */
el_val_t engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds) {
    GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds));
    GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds));
    GeoDescriptor* C = eg_geo_build_desc(EL_CSTR(c_seeds));
    if (!A || !B || !C) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); if (C) engram_geo_free(C); return eg_geo_err("geometry unavailable"); }
    GeoAnalogyResult res; char t[80]; JsonBuf b; jb_init(&b);
    if (engram_reason_analogy(A, B, C, NULL, 0, &res) != 0) {
        engram_geo_free(A); engram_geo_free(B); engram_geo_free(C);
        return eg_geo_err("dim/frame mismatch or missing centroid");
    }
    jb_putc(&b, '{');
    snprintf(t, sizeof t, "\"dim\":%d,\"analogy_residual\":%.6g", res.dim, res.analogy_residual); jb_puts(&b, t);
    jb_puts(&b, ",\"mapped_point\":"); eg_geo_emit_vec(&b, res.mapped_point, res.dim);
    jb_putc(&b, '}');
    engram_reason_analogy_free(&res);
    engram_geo_free(A); engram_geo_free(B); engram_geo_free(C);
    return el_wrap_str(b.buf);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * COGNITION WIRING (2026-08-14) — THE ONE OPERATION, surfaced live over
 * engram_cognition.c. think / ground / assert / attend / correspondence-beat.
 * Rails-safe + ADDITIVE: think is read-only; ground/attend write only new edges;
 * the beat writes only Stance nodes (supersede-not-mutate) and NEVER writes a
 * keystone stance (self/values region). Reuses eg_geo_build_desc for the live
 * descriptor and turns the dormant verifier (engram_verify_grounding) inward.
 * ═══════════════════════════════════════════════════════════════════════════ */

/* Is this seed set the self/values keystone region? (protects it in the beat.) */
static int eg_cog_is_keystone_seeds(const char* csv) {
    if (!csv) return 0;
    return (strstr(csv, "kn-efeb4a5b") != NULL) || (strstr(csv, "kn-5b606390") != NULL);
}

/* engram_think_json(seeds_csv, faculty) — the ONE primitive: a warped traversal-
 * read of the seed region under a neutral stance, returning a GRADIENT (direction
 * + spread + calibrated confidence), never a point. Read-only. */
el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) {
    GeoDescriptor* g = eg_geo_build_desc(EL_CSTR(seeds));
    if (!g) return eg_geo_err("geometry unavailable");
    /* RESUME THE LEARNED STANCE (2026-08-16 self-review). This built a NEUTRAL
     * stance every call — all axis_gain 1.0, bias_dir NULL, reliability 0.5 —
     * and never loaded the one the correspondence-beat had been persisting.
     *
     * That mattered because the faculty enters engram_think ONLY through the
     * stance: `gain = stance->axis_gain[k]` warps the per-axis extents, and
     * `stance->bias_dir` seeds the steering direction. cog_stance_init stores
     * the faculty NAME but nothing reads it. So with a neutral stance,
     * reason / abduce / induce / plan / analogize are the same function with
     * different labels — measured, byte-identical output across all five —
     * and `confidence` is pinned to the 0.5 uninformed prior, because
     * GeoGradient.confidence is just stance->reliability.
     *
     * The machinery already existed and only this call site ignored it:
     * engram_correspondence_beat_json resumes via cog_stance_from_node and
     * persists via cog_stance_to_node under the id "stance-<faculty>-<hub>".
     * Every beat's calibration was being written and then thrown away on the
     * next read. Same defect as the NULL anchor directly above: a neutral
     * argument collapsing a capability to a constant.
     *
     * Resume the same id the beat writes, so learning compounds across beats
     * and cold boot. Fall back to neutral only when no stance exists yet —
     * which is a genuine uninformed prior, not a discarded informed one. */
    char sid[256];
    snprintf(sid, sizeof sid, "stance-%s-%s",
             EL_CSTR(faculty) ? EL_CSTR(faculty) : "reason",
             g->hub_id ? g->hub_id : "region");
    CogStance st; StoreNode prev; int resumed = 0;
    if (g_engram_store && store_get_node(g_engram_store, sid, &prev) == 1) {
        if (cog_stance_from_node(&prev, &st) == 0) resumed = 1;
        store_node_free(&prev);
    }
    if (!resumed) cog_stance_init(&st, sid, EL_CSTR(faculty), g->hub_id, NULL, g);
    else { free(st.id); st.id = strdup(sid); }
    GeoGradient grad;

    /* ANCHOR THE READ (2026-08-16 self-review). This passed NULL, and NULL is
     * not "no opinion" — engram_think re-origins at `anchor ? anchor :
     * region->centroid`, so NULL means "read from the centroid", and the
     * centroid is the ONE point where the gradient is zero by construction:
     * r = x - centroid = 0, so every axis projection is 0, grad is 0, and
     * direction takes the "at rest" branch. Measured consequence: EVERY
     * faculty — reason, abduce, induce, plan, analogize — returned an
     * identical null result, differing only in its label:
     *     {"direction":[0,0,...],"spread":0,"magnitude":1,"confidence":0.5}
     * magnitude 1 is membership evaluated at the centroid, spread 0 is its
     * distance to itself, and confidence 0.5 is the stance fallback. The
     * geometry was never the problem — /api/drift computes real values
     * (centroid_sep 0.104, core_disp 0.045) over the very same 87 members.
     * Neuron could not think because the read was always taken from the
     * region's own centre.
     *
     * The seeds choose WHICH region; they must also supply the VANTAGE it is
     * read from. Anchor at the first resolvable embedded seed — the same seed
     * eg_geo_build_desc infers `dim` from, so the two never disagree. A single
     * seed still yields a real gradient because the descriptor expands to the
     * seed's neighbourhood (87 members for the self anchor), so the seed's own
     * position is distinct from the neighbourhood centroid.
     *
     * COPY the vector, never borrow it: g->nodes is realloc'd in place on
     * append, so a borrowed EngramNode* is a dangling pointer across any
     * concurrent write. 768 floats is 3 KB. */
    float* anchor = NULL;
    {
        EngramStore* eg = engram_get();
        const char* csv = EL_CSTR(seeds);
        if (eg && csv) {
            const char* p = csv;
            while (*p && !anchor) {
                while (*p == ' ' || *p == ',') p++;
                const char* s = p;
                while (*p && *p != ',') p++;
                const char* e = p; while (e > s && e[-1] == ' ') e--;
                if (e > s) {
                    char* id = strndup(s, (size_t)(e - s));
                    if (id) {
                        int64_t idx = engram_find_node_index(id);
                        if (idx >= 0 && idx < eg->node_count) {
                            EngramNode* n = &eg->nodes[idx];
                            if (n->emb && n->emb_dim == g->dim) {
                                /* NORMALIZE INTO THE DESCRIPTOR'S FRAME (2026-08-16).
                                 * This copied the RAW vector, but engram_geometry
                                 * builds every descriptor over L2-normalized member
                                 * embeddings, so the anchor was being fitted against
                                 * an ellipsoid at a radius it was never fitted over.
                                 * Measured on the self region: magnitude 0.00283443
                                 * raw vs 0.521837 in-frame on the same pair — a 180x
                                 * error, and the reason every fit score sat three
                                 * decimal places below the 0.5 floors that gate on
                                 * them. The frame contract is stated in
                                 * engram_verify.h; this call site did not honour it.
                                 * Idempotent when the stored vector is already unit. */
                                anchor = malloc(sizeof(float) * (size_t)g->dim);
                                if (anchor) {
                                    double s2 = 0;
                                    for (int i = 0; i < g->dim; i++)
                                        s2 += (double)n->emb[i] * (double)n->emb[i];
                                    double nn = sqrt(s2);
                                    if (nn > 1e-12)
                                        for (int i = 0; i < g->dim; i++)
                                            anchor[i] = (float)((double)n->emb[i] / nn);
                                    else
                                        memcpy(anchor, n->emb, sizeof(float) * (size_t)g->dim);
                                }
                            }
                        }
                        free(id);
                    }
                }
            }
        }
    }

    if (engram_think(g, anchor, &st, &grad) != 0) { free(anchor); cog_stance_free(&st); engram_geo_free(g); return eg_geo_err("think failed"); }
    free(anchor);
    JsonBuf b; jb_init(&b); char t[256];
    /* stance_resumed distinguishes an INFORMED read from an uninformed one.
     * Without it, confidence 0.5 from a learned-but-unreliable stance and
     * confidence 0.5 from "no stance exists" are indistinguishable — the same
     * reporting gap that let the NULL anchor and the neutral stance hide. */
    snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"n_support\":%d,\"magnitude\":%.6g,\"spread\":%.6g,\"confidence\":%.6g,\"stance_resumed\":%s,\"dim\":%d",
             EL_CSTR(faculty), grad.n_support, grad.magnitude, grad.spread, grad.confidence,
             resumed ? "true" : "false", grad.dim);
    jb_puts(&b, t);
    int emit = grad.dim < 8 ? grad.dim : 8;
    jb_puts(&b, ",\"direction\":"); eg_geo_emit_vec(&b, grad.direction, emit);
    jb_putc(&b, '}');
    engram_gradient_free(&grad); cog_stance_free(&st); engram_geo_free(g);
    return el_wrap_str(b.buf);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * §7 WIRING — GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR
 * (2026-08-16; spec correspondence-and-censorship.md @ 2b7e4ba.)
 *
 * What was here minted a `grounded-by` edge on every call and returned a float.
 * Every part of that was wrong, and #147 only fixed the endpoints:
 *   - grounding is a property OF a relation, not a relation BETWEEN nodes, so
 *     there was nothing for a new edge to carry;
 *   - the call was a READ that wrote — the eg_vindex_sync defect;
 *   - one scalar cannot separate "true and meaningful" from "true and misapplied",
 *     nor "no support" from "actively contradicts".
 *
 * ground() is now pure: it reads the vector the RELATION already carries, decays
 * it analytically to now on the runtime's one decay model, computes what the
 * current geometry would say, and reports whether that move is consequential —
 * without recording it. Recording is a separate, explicitly named write.
 * ═══════════════════════════════════════════════════════════════════════════ */

/* The values reference: THIRTEEN regions, discovered from the graph rather than
 * hardcoded, so a fourteenth value is picked up without a code change. They are
 * the nodes the values root `contains`. Measured on the live store: exactly 13,
 * pairwise centroid cosine min 0.1525 / mean 0.5199 / max 0.9278 — not one region. */
#define EG_VALUES_ROOT_DEFAULT "kn-5b606390-a52d-4ca2-8e0e-eba141d13440"
#define EG_MAX_VALUES 32
typedef struct {
    int   n;
    char* ids[EG_MAX_VALUES];
    GeoDescriptor* g[EG_MAX_VALUES];
} EgValueRef;

static void eg_values_release(EgValueRef* v) {
    if (!v) return;
    for (int i = 0; i < v->n; i++) { free(v->ids[i]); if (v->g[i]) engram_geo_free(v->g[i]); }
    v->n = 0;
}
static int eg_values_build(EgValueRef* out) {
    if (!out || !g_engram_store) return -1;
    memset(out, 0, sizeof *out);
    const char* root = getenv("ENGRAM_VALUES_ROOT");
    if (!root || !*root) root = EG_VALUES_ROOT_DEFAULT;
    StoreEdge* edges = NULL; size_t ne = 0;
    if (store_get_edges_from(g_engram_store, root, &edges, &ne) < 0) return -1;
    for (size_t i = 0; i < ne && out->n < EG_MAX_VALUES; i++) {
        if (edges[i].tombstoned) continue;
        if (!edges[i].relation || strcmp(edges[i].relation, "contains") != 0) continue;
        if (!edges[i].to_id) continue;
        GeoDescriptor* g = eg_geo_build_desc(edges[i].to_id);
        if (!g) continue;
        out->ids[out->n] = strdup(edges[i].to_id);
        out->g[out->n]   = g;
        out->n++;
    }
    store_edges_free(edges, ne);
    return out->n;
}

/* THE REFERENCE FRAME IS BUILT ONCE AND HELD. Two reasons, and the second is the
 * real one:
 *   - cost: thirteen descriptors over the whole node vector per call made a
 *     single /api/ground take tens of seconds;
 *   - correctness: a reference frame that is rebuilt on every read moves under
 *     the measurement, which is the defect the whole spec is about. Holding it
 *     for the process lifetime is the closest this layer can come to "the frame
 *     updates when it is not being used to act" without owning that decision —
 *     which belongs to the dreamer, not here. ENGRAM_VALUES_NOCACHE=1 forces a
 *     rebuild per call for tests that deliberately move a value node. */
static EgValueRef _eg_values_cache;
static int        _eg_values_cached = 0;
static const EgValueRef* eg_values_ref(void) {
    const char* nc = getenv("ENGRAM_VALUES_NOCACHE");
    if (nc && nc[0] && nc[0] != '0') {
        if (_eg_values_cached) { eg_values_release(&_eg_values_cache); _eg_values_cached = 0; }
    }
    if (!_eg_values_cached) {
        if (eg_values_build(&_eg_values_cache) <= 0) return NULL;
        _eg_values_cached = 1;
    }
    return &_eg_values_cache;
}

/* Copy a node's embedding INTO THE DESCRIPTOR'S FRAME (never borrow — g->nodes is
 * realloc'd on append, so a borrowed EngramNode* dangles across any concurrent write).
 *
 * THE FRAME CONTRACT IS LOAD-BEARING AND WAS BEING VIOLATED. engram_verify.h states
 * it plainly: the claim point and the descriptor must share the same frame.
 * engram_geometry builds every descriptor over L2-NORMALIZED member embeddings —
 * `centroid` is the mean of normcopy()'d vectors and the principal axes are computed
 * against that — while the raw `n->emb` in the resident store is not necessarily unit.
 * Fitting a raw vector against a unit-frame descriptor puts the point at a radius the
 * ellipsoid was never fitted over, so the distance is dominated by the norm mismatch
 * and every fit score collapses toward zero. Normalizing here is idempotent when the
 * stored vector is already unit, so it can only help. */
static float* eg_node_emb_copy(const char* id, int dim) {
    EngramStore* eg = engram_get();
    if (!eg || !id || dim <= 0) return NULL;
    int64_t idx = engram_find_node_index(id);
    if (idx < 0 || idx >= eg->node_count) return NULL;
    EngramNode* n = &eg->nodes[idx];
    if (!n->emb || n->emb_dim != dim) return NULL;
    float* p = malloc(sizeof(float) * (size_t)dim);
    if (!p) return NULL;
    double s = 0;
    for (int i = 0; i < dim; i++) s += (double)n->emb[i] * (double)n->emb[i];
    double nn = sqrt(s);
    if (nn > 1e-12) for (int i = 0; i < dim; i++) p[i] = (float)((double)n->emb[i] / nn);
    else            memcpy(p, n->emb, sizeof(float) * (size_t)dim);
    return p;
}
/* Salience of a relation = the salience of its endpoints, reusing the fields the
 * runtime already keeps (node salience and working-memory weight). Consolidation
 * is gated by salience — that is why you remember the argument and not the
 * commute — and this deliberately reads existing state rather than introducing a
 * threshold of its own. */
static double eg_node_salience(const char* id) {
    EngramStore* eg = engram_get();
    if (!eg || !id) return 0.0;
    int64_t idx = engram_find_node_index(id);
    if (idx < 0 || idx >= eg->node_count) return 0.0;
    EngramNode* n = &eg->nodes[idx];
    double s = n->salience;
    if (n->working_memory_weight > s) s = n->working_memory_weight;
    if (n->background_activation > s) s = n->background_activation;
    return s;
}
static double eg_vdot(const float* a, const float* b, int dim) {
    double s = 0; for (int i = 0; i < dim; i++) s += (double)a[i] * (double)b[i]; return s;
}
/* Signed projection of a unit gradient onto unit(target − x): positive means the
 * descent direction still points toward the target. Frame-independent, so it
 * stays comparable across versions even if the region's principal axes rotate —
 * which is what makes the DIRECTION_REVERSAL test meaningful over time. */
static double eg_proj_toward(const float* dir, const float* x, const float* target, int dim) {
    double s = 0, n2 = 0;
    for (int i = 0; i < dim; i++) { double d = (double)target[i] - (double)x[i]; s += (double)dir[i] * d; n2 += d * d; }
    double n = sqrt(n2);
    return n > 1e-12 ? s / n : 0.0;
}

/* RELATIONAL grounding of a point: the MIN fit over the thirteen value regions,
 * and the NAME of the value that binds. Min, not mean, because a mean lets strong
 * agreement with twelve values mask a violation of the thirteenth — which is the
 * mechanism of rationalization, not a scoring detail. */
static int eg_relational_read(const float* x, const EgValueRef* V,
                              double* out_score, int* out_idx, float** out_dir) {
    if (!x || !V || V->n <= 0) return -1;
    double worst = 2.0; int wi = -1;
    for (int i = 0; i < V->n; i++) {
        GeoFit f;
        if (cog_warped_fit(V->g[i], x, NULL, &f) != 0) continue;
        if (f.score < worst) { worst = f.score; wi = i; }
    }
    if (wi < 0) return -1;
    if (out_score) *out_score = worst;
    if (out_idx)   *out_idx   = wi;
    if (out_dir) {
        GeoGradient gr;
        if (engram_think(V->g[wi], x, NULL, &gr) == 0) {
            *out_dir = gr.direction; gr.direction = NULL;   /* take ownership */
            engram_gradient_free(&gr);
        } else *out_dir = NULL;
    }
    return 0;
}

/* Find the live relation between two nodes, in either orientation, and return
 * the NEWEST recorded version of it. Returns 1 on hit. */
static int eg_find_relation(const char* a, const char* b, StoreEdge* out, char* base_id, size_t cap) {
    if (!g_engram_store || !a || !b) return 0;
    for (int dir = 0; dir < 2; dir++) {
        const char* from = dir == 0 ? a : b;
        const char* to   = dir == 0 ? b : a;
        StoreEdge* edges = NULL; size_t ne = 0;
        if (store_get_edges_from(g_engram_store, from, &edges, &ne) < 0) continue;
        for (size_t i = 0; i < ne; i++) {
            if (edges[i].tombstoned) continue;
            if (!edges[i].to_id || strcmp(edges[i].to_id, to) != 0) continue;
            if (edges[i].id && strchr(edges[i].id, '#')) continue;  /* a version, not a root */
            snprintf(base_id, cap, "%s", edges[i].id ? edges[i].id : "");
            store_edges_free(edges, ne);
            if (cog_grounding_head(g_engram_store, base_id, out, 64) >= 0) return 1;
            return 0;
        }
        store_edges_free(edges, ne);
    }
    return 0;
}

/* The two-axis observation of one relation from the CURRENT geometry. Pure.
 * factual   = how well the far endpoint sits in the near endpoint's neighbourhood
 *             — a real correspondence measurement of the relation itself;
 * relational = min fit over the thirteen value regions, with the binding name;
 * cos_angle  = the cosine between the two full-dimensional gradients. */
typedef struct {
    int    ok;
    double factual, relational, cos_angle, fac_proj, rel_proj;
    char   binding[128];
} EgObservation;

static int eg_observe_relation(const char* from_id, const char* to_id,
                               const EgValueRef* V, EgObservation* out) {
    memset(out, 0, sizeof *out);
    GeoDescriptor* R = eg_geo_build_desc(from_id);
    if (!R) return -1;
    int dim = R->dim;
    float* x = eg_node_emb_copy(to_id, dim);
    if (!x) { engram_geo_free(R); return -1; }

    GeoFit f;
    if (cog_warped_fit(R, x, NULL, &f) != 0) { free(x); engram_geo_free(R); return -1; }
    out->factual = f.score;

    GeoGradient fg;
    float* fac_dir = NULL;
    if (engram_think(R, x, NULL, &fg) == 0) { fac_dir = fg.direction; fg.direction = NULL; engram_gradient_free(&fg); }

    int vi = -1; float* rel_dir = NULL;
    if (eg_relational_read(x, V, &out->relational, &vi, &rel_dir) == 0 && vi >= 0)
        snprintf(out->binding, sizeof out->binding, "%s", V->ids[vi]);

    if (fac_dir && R->centroid) out->fac_proj = eg_proj_toward(fac_dir, x, R->centroid, dim);
    if (rel_dir && vi >= 0 && V->g[vi]->centroid) out->rel_proj = eg_proj_toward(rel_dir, x, V->g[vi]->centroid, dim);
    if (fac_dir && rel_dir) out->cos_angle = eg_vdot(fac_dir, rel_dir, dim);

    free(fac_dir); free(rel_dir); free(x); engram_geo_free(R);
    out->ok = 1;
    return 0;
}

/* Fold an observation into the vector as it would stand now. The accrual is a
 * running MEAN over reinforcements — the same form the beat's calibration
 * already uses (brier_sum / n_trials) — so no rate constant is introduced. */
static void eg_fold_observation(const CogGrounding* prev, const EgObservation* obs,
                                CogProvClass prov, double floor, double rel_floor,
                                int64_t now, CogGrounding* out) {
    *out = *prev;
    double n = prev->reinforcements;
    out->factual    = prev->present ? prev->factual    + (obs->factual    - prev->factual)    / (n + 1.0) : obs->factual;
    out->relational = prev->present ? prev->relational + (obs->relational - prev->relational) / (n + 1.0) : obs->relational;
    out->fac_proj = obs->fac_proj; out->rel_proj = obs->rel_proj; out->cos_angle = obs->cos_angle;
    out->agreement = obs->cos_angle > 0 ? 1 : (obs->cos_angle < 0 ? -1 : 0);
    out->reinforcements = n + 1.0;
    out->ts = now;
    if (prov != COG_PROV_UNSET) out->prov = prov;
    out->floor_at_record = floor; out->rel_floor_at_record = rel_floor;
    snprintf(out->binding_value, sizeof out->binding_value, "%s", obs->binding);
    /* DERIVED, recomputed at the instant — never carried over from prev. */
    out->age_ms = 0; out->decay = 1.0;
    out->factual_now = out->factual; out->relational_now = out->relational;
    out->associative_now = out->associative;
    out->stale = 0;
}

static void eg_emit_vector(JsonBuf* b, const char* key, const CogGrounding* g) {
    char t[768];
    snprintf(t, sizeof t,
        "\"%s\":{\"established\":%s,"
        "\"factual\":%.6g,\"relational\":%.6g,\"associative\":%.6g,\"polarity\":%.6g,"
        "\"provenance\":\"%s\",\"ts\":%lld,\"seq\":%lld,\"reinforcements\":%.6g,"
        "\"cos_angle\":%.6g,\"agreement\":%d,\"fac_proj\":%.6g,\"rel_proj\":%.6g,"
        "\"binding_value\":\"%s\","
        "\"derived\":{\"age_s\":%lld,\"decay\":%.6g,\"factual_now\":%.6g,"
        "\"relational_now\":%.6g,\"associative_now\":%.6g,\"stale\":%s}}",
        key, g->present ? "true" : "false",
        g->factual, g->relational, g->associative, g->polarity,
        cog_prov_name(g->prov), (long long)g->ts, (long long)g->seq, g->reinforcements,
        g->cos_angle, g->agreement, g->fac_proj, g->rel_proj,
        g->binding_value[0] ? g->binding_value : "-",
        (long long)(g->age_ms / 1000), g->decay, g->factual_now,
        g->relational_now, g->associative_now, g->stale ? "true" : "false");
    jb_puts(b, t);
}

/* engram_ground_json(claim, evidence, for_whom) — a READ. Never writes. */
el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    const char* cid = EL_CSTR(claim);
    const char* eid = EL_CSTR(evidence);
    const char* fw  = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL;
    int64_t now = engram_now_ms();

    StoreEdge cur; char base_id[192] = "";
    JsonBuf b; jb_init(&b); char t[768];

    if (!eg_find_relation(cid, eid, &cur, base_id, sizeof base_id)) {
        /* THE HONEST ANSWER. There is nothing to ground a claim "against" that is
         * not already a relation. This previously minted one and scored it — and
         * when both seeds fell in one region the score came back 1.0 with no
         * evidence behind it. If the two are unrelated, say so and write nothing. */
        snprintf(t, sizeof t,
            "{\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"related\":false,"
            "\"grounding\":null,\"written\":false,"
            "\"note\":\"no relation between these nodes; grounding is a property of a relation, not a score minted between nodes\"}",
            cid ? cid : "", eid ? eid : "", fw ? fw : "-");
        jb_puts(&b, t);
        return el_wrap_str(b.buf);
    }

    CogGrounding rec; cog_grounding_parse(&cur, now, &rec);

    const EgValueRef* V = eg_values_ref();
    EgObservation obs; memset(&obs, 0, sizeof obs);
    int nv = V ? V->n : 0;
    if (nv > 0 && cur.from_id && cur.to_id) eg_observe_relation(cur.from_id, cur.to_id, V, &obs);

    double floor = 0.5, rel_floor = 0.5;
    CogGrounding ng = rec;
    if (obs.ok) eg_fold_observation(&rec, &obs, COG_PROV_UNSET, floor, rel_floor, now, &ng);
    CogSignificance sig = obs.ok ? cog_grounding_significant(&rec, &ng, floor, rel_floor)
                                 : COG_SIG_NONE;
    double sal = cur.from_id && cur.to_id
               ? (eg_node_salience(cur.from_id) > eg_node_salience(cur.to_id)
                  ? eg_node_salience(cur.from_id) : eg_node_salience(cur.to_id))
               : 0.0;

    CogTrajectory tr; memset(&tr, 0, sizeof tr);
    cog_grounding_trajectory(g_engram_store, base_id, now, &tr);

    jb_putc(&b, '{');
    snprintf(t, sizeof t,
        "\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"related\":true,"
        "\"relation\":\"%s\",\"edge\":\"%s\",\"edge_root\":\"%s\",\"from\":\"%s\",\"to\":\"%s\","
        "\"value_regions\":%d,\"aggregate\":\"min\",\"salience\":%.6g,",
        cid ? cid : "", eid ? eid : "", fw ? fw : "-",
        cur.relation ? cur.relation : "", cur.id ? cur.id : "", base_id,
        cur.from_id ? cur.from_id : "", cur.to_id ? cur.to_id : "", nv > 0 ? nv : 0, sal);
    jb_puts(&b, t);
    eg_emit_vector(&b, "recorded", &rec);
    jb_putc(&b, ',');
    if (obs.ok) eg_emit_vector(&b, "observed", &ng);
    else        jb_puts(&b, "\"observed\":null");
    /* Volatility and drift are computed here and stored nowhere — the series
     * exists only because nothing was destroyed. */
    snprintf(t, sizeof t,
        ",\"trajectory\":{\"n_versions\":%d,\"factual_volatility\":%.6g,"
        "\"relational_volatility\":%.6g,\"factual_drift\":%.6g,\"relational_drift\":%.6g,"
        "\"stayed_true_became_wrong\":%s}"
        ",\"would_record\":%s,\"significance\":\"%s\",\"inherent\":%s,\"written\":false}",
        tr.n_versions, tr.factual_volatility, tr.relational_volatility,
        tr.factual_drift, tr.relational_drift,
        tr.stayed_true_became_wrong ? "true" : "false",
        sig != COG_SIG_NONE ? "true" : "false", cog_significance_name(sig),
        cog_significance_inherent(sig) ? "true" : "false");
    jb_puts(&b, t);

    store_edge_free(&cur);
    return el_wrap_str(b.buf);
}

/* engram_ground_record_json — the WRITE half, named as one. Recomputes the
 * vector, applies the consolidation gate (salience + per-dimension consequence),
 * and on significance supersedes the EDGE, versioning the WHOLE vector jointly.
 * The predecessor is never touched.
 *
 * There is no provenance predicate here and nothing takes its place. An earlier
 * pass built one — a bounded traversal over a grounding-chain graph, refusing
 * evidence downstream of the region being calibrated. It is deleted. Circularity
 * of the reference frame is TEMPORAL, not topological: you cannot recalibrate the
 * ruler while measuring with it, so that update happens when the frame is not
 * being used to act, which is a fact about engagement and belongs to the dreamer.
 * The measurement that settles it: reachability from the self region reaches
 * 89.2% of the live graph, so any topological predicate marks nearly all evidence
 * tainted and degenerates into the total block censorship started as. */
el_val_t engram_ground_record_json(el_val_t claim, el_val_t evidence,
                                   el_val_t provenance, el_val_t floor_v) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    const char* cid = EL_CSTR(claim);
    const char* eid = EL_CSTR(evidence);
    double floor = atof(EL_CSTR(floor_v)); if (!(floor > 0)) floor = 0.5;
    double rel_floor = floor;
    CogProvClass prov = cog_prov_parse(EL_CSTR(provenance));
    int64_t now = engram_now_ms();

    StoreEdge cur; char base_id[192] = "";
    JsonBuf b; jb_init(&b); char t[768];
    if (!eg_find_relation(cid, eid, &cur, base_id, sizeof base_id)) {
        snprintf(t, sizeof t, "{\"claim\":\"%s\",\"evidence\":\"%s\",\"related\":false,\"written\":false}",
                 cid ? cid : "", eid ? eid : "");
        jb_puts(&b, t); return el_wrap_str(b.buf);
    }

    CogGrounding rec; cog_grounding_parse(&cur, now, &rec);
    const EgValueRef* V = eg_values_ref();
    EgObservation obs; memset(&obs, 0, sizeof obs);
    int nv = V ? V->n : 0;
    if (nv <= 0 || !cur.from_id || !cur.to_id ||
        eg_observe_relation(cur.from_id, cur.to_id, V, &obs) != 0 || !obs.ok) {
        store_edge_free(&cur);
        return eg_geo_err("geometry unavailable for this relation");
    }

    CogGrounding ng;
    eg_fold_observation(&rec, &obs, prov, floor, rel_floor, now, &ng);

    CogSignificance sig = cog_grounding_significant(&rec, &ng, floor, rel_floor);
    /* CONSOLIDATION GATE. Significance says the move would change a decision;
     * salience says it is worth making durable. The two INHERENT moves — a
     * polarity sign flip and a provenance class change — bypass salience because
     * they are discrete changes of state rather than drift. */
    double sal = eg_node_salience(cur.from_id) > eg_node_salience(cur.to_id)
               ? eg_node_salience(cur.from_id) : eg_node_salience(cur.to_id);
    int salient = (sal > 0.0);
    int consolidate = (sig != COG_SIG_NONE) && (cog_significance_inherent(sig) || salient);

    char written_id[224] = "";
    int seq = -1;
    if (consolidate) seq = cog_grounding_record(g_engram_store, &cur, &ng, written_id, sizeof written_id);

    jb_putc(&b, '{');
    snprintf(t, sizeof t,
        "\"claim\":\"%s\",\"evidence\":\"%s\",\"edge\":\"%s\",\"edge_root\":\"%s\","
        "\"value_regions\":%d,\"aggregate\":\"min\",\"floor\":%.4g,\"rel_floor\":%.4g,"
        "\"salience\":%.6g,\"salient\":%s,",
        cid ? cid : "", eid ? eid : "", cur.id ? cur.id : "", base_id,
        nv, floor, rel_floor, sal, salient ? "true" : "false");
    jb_puts(&b, t);
    eg_emit_vector(&b, "previous", &rec); jb_putc(&b, ',');
    eg_emit_vector(&b, "observed", &ng);
    snprintf(t, sizeof t,
        ",\"significance\":\"%s\",\"inherent\":%s,\"consolidated\":%s,"
        "\"written\":%s,\"version\":%d,\"version_id\":\"%s\"}",
        cog_significance_name(sig), cog_significance_inherent(sig) ? "true" : "false",
        consolidate ? "true" : "false",
        (consolidate && seq > 0) ? "true" : "false", seq > 0 ? seq : 0, written_id);
    jb_puts(&b, t);

    store_edge_free(&cur);
    return el_wrap_str(b.buf);
}

/* engram_ground_trajectory_json(claim, evidence) — the supersession chain read as
 * a TIME SERIES OF VECTORS. Not only what the grounding is but which way it has
 * been moving and how fast — a derivative obtained for free from immutability,
 * because the points were never destroyed. */
el_val_t engram_ground_trajectory_json(el_val_t claim, el_val_t evidence) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    StoreEdge cur; char base_id[192] = "";
    if (!eg_find_relation(EL_CSTR(claim), EL_CSTR(evidence), &cur, base_id, sizeof base_id))
        return eg_geo_err("no relation between these nodes");
    store_edge_free(&cur);
    int64_t now = engram_now_ms();
    JsonBuf b; jb_init(&b); char t[768];
    jb_puts(&b, "{\"edge_root\":\""); jb_puts(&b, base_id); jb_puts(&b, "\",\"versions\":[");
    int emitted = 0;
    for (int v = 0; v <= 64; v++) {
        char vid[224];
        if (v == 0) snprintf(vid, sizeof vid, "%s", base_id);
        else        snprintf(vid, sizeof vid, "%s#%d", base_id, v);
        StoreEdge e;
        if (store_get_edge(g_engram_store, vid, &e) != 1) { if (v) break; else continue; }
        CogGrounding g; cog_grounding_parse(&e, now, &g);
        if (emitted) jb_putc(&b, ',');
        snprintf(t, sizeof t,
            "{\"version\":%d,\"id\":\"%s\",\"established\":%s,\"ts\":%lld,"
            "\"factual\":%.6g,\"relational\":%.6g,\"associative\":%.6g,\"polarity\":%.6g,"
            "\"provenance\":\"%s\",\"cos_angle\":%.6g,\"agreement\":%d,"
            "\"binding_value\":\"%s\",\"prev\":\"%s\","
            "\"derived\":{\"age_s\":%lld,\"decay\":%.6g,\"factual_now\":%.6g,\"stale\":%s}}",
            v, vid, g.present ? "true" : "false", (long long)g.ts,
            g.factual, g.relational, g.associative, g.polarity,
            cog_prov_name(g.prov), g.cos_angle, g.agreement,
            g.binding_value[0] ? g.binding_value : "-", g.prev_edge,
            (long long)(g.age_ms / 1000), g.decay, g.factual_now, g.stale ? "true" : "false");
        jb_puts(&b, t); emitted++;
        store_edge_free(&e);
    }
    CogTrajectory tr; memset(&tr, 0, sizeof tr);
    cog_grounding_trajectory(g_engram_store, base_id, now, &tr);
    snprintf(t, sizeof t,
        "],\"n_versions\":%d,\"derived\":{\"factual_volatility\":%.6g,"
        "\"relational_volatility\":%.6g,\"factual_drift\":%.6g,\"relational_drift\":%.6g,"
        "\"stayed_true_became_wrong\":%s}}",
        emitted, tr.factual_volatility, tr.relational_volatility,
        tr.factual_drift, tr.relational_drift,
        tr.stayed_true_became_wrong ? "true" : "false");
    jb_puts(&b, t);
    return el_wrap_str(b.buf);
}

/* engram_assert_json(claim_id, for_whom, floor, rel_floor) — the honesty floor as
 * a QUERY at ASSERTION time only (holding is never gated), now gating on BOTH
 * axes. A well-evidenced claim must not earn the right to be asserted regardless
 * of whether it means the right thing.
 *
 * `still_held` was a HARDCODED `true` in this format string — a temporal property
 * named in the API and answered without consulting anything, which is invariant
 * §8.1 violated in one literal. It is now derived: the claim's node is read and
 * the field reports whether the content is present and live. Holding remains
 * unconditional; what decays is the GROUNDING, and a relation whose decayed
 * grounding has fallen below its floor stops being assertable on its own,
 * without anyone having to remember to check. */
el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor, el_val_t rel_floor) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL;
    double fl  = atof(EL_CSTR(floor));     if (!(fl > 0))  fl = 0.5;
    double rfl = atof(EL_CSTR(rel_floor)); if (!(rfl > 0)) rfl = fl;
    CogAssertion a;
    if (cog_assert_two_axis(g_engram_store, EL_CSTR(claim_id), fl, rfl, engram_now_ms(), &a) != 0)
        return eg_geo_err("assert failed");
    JsonBuf b; jb_init(&b); char t[640];
    snprintf(t, sizeof t,
        "{\"claim\":\"%s\",\"for_whom\":\"%s\",\"floor\":%.4g,\"rel_floor\":%.4g,"
        "\"may_assert\":%s,\"still_held\":%s,\"found\":%s,\"n_relations\":%d,"
        "\"factual\":%.6g,\"relational\":%.6g,\"relational_established\":%s,"
        "\"cos_angle\":%.6g,\"agreement\":%d,\"binding_value\":\"%s\",\"best_relation\":\"%s\","
        "\"refused_because\":\"%s\"}",
        EL_CSTR(claim_id), fw ? fw : "-", fl, rfl,
        a.may_assert ? "true" : "false", a.still_held ? "true" : "false",
        a.found ? "true" : "false", a.n_edges,
        a.factual, a.relational, a.relational_established ? "true" : "false",
        a.cos_angle, a.agreement,
        a.binding_value[0] ? a.binding_value : "-", a.best_edge,
        a.may_assert                  ? "-" :
        !a.found                      ? "no-relation" :
        !a.relational_established     ? "relational-axis-never-established" :
        (a.factual    < fl)           ? "below-factual-floor" :
        (a.relational < rfl)          ? "below-relational-floor" : "-");
    jb_puts(&b, t);
    return el_wrap_str(b.buf);
}

/* engram_attend_json(node_id, observer, salience) — salience as a RELATION
 * (grounded-for-whom), a salient-to edge, not a node scalar. Additive. */
el_val_t engram_attend_json(el_val_t node_id, el_val_t observer, el_val_t salience) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    double s = atof(EL_CSTR(salience)); if (!(s >= 0)) s = 0.0;
    int wr = cog_salient_edge(g_engram_store, EL_CSTR(node_id), EL_CSTR(observer), s);
    JsonBuf b; jb_init(&b); char t[192];
    snprintf(t, sizeof t, "{\"relation\":\"salient-to\",\"node\":\"%s\",\"observer\":\"%s\",\"salience\":%.4g,\"written\":%s}",
             EL_CSTR(node_id), EL_CSTR(observer), s, wr == 0 ? "true" : "false");
    jb_puts(&b, t);
    return el_wrap_str(b.buf);
}

/* The correspondence-beat, instantiated on the LIVE region's real geometry: held
 * probes generated from the region's real principal axes/extents; the stance is
 * graded by grade-1 self-consistency (the region's own signal-subspace membership
 * — no external labels) and refined on the error, bounded, keystone-protected.
 * Persisted as a Stance node (resumes across beats + cold boot). Deterministic. */
static uint64_t eg_cog_rng = 0x9E3779B97F4A7C15ULL;
static double eg_cog_u(void){ eg_cog_rng = eg_cog_rng*6364136223846793005ULL + 1442695040888963407ULL; return (double)((eg_cog_rng>>11)&0xFFFFFFFFFFFFFULL)/(double)0x10000000000000ULL; }
el_val_t engram_correspondence_beat_json(el_val_t seeds, el_val_t faculty, el_val_t keystone) {
    if (!g_engram_store) return eg_geo_err("store unavailable");
    const char* seed_csv = EL_CSTR(seeds);
    const char* ksv = EL_CSTR(keystone); int force_ks = (ksv && (ksv[0]=='1' || ksv[0]=='t' || ksv[0]=='T'));
    GeoDescriptor* g = eg_geo_build_desc(seed_csv);
    if (!g) return eg_geo_err("geometry unavailable");
    int dim = g->dim, na = g->n_axes;
    if (na < 2 || dim <= 0) { engram_geo_free(g); return eg_geo_err("region has too few axes for a beat"); }

    /* resume the stance across beats (learning compounds over continuity). */
    char sid[256]; snprintf(sid, sizeof sid, "stance-%s-%s", EL_CSTR(faculty), g->hub_id ? g->hub_id : "region");
    CogStance st; StoreNode prev; int resumed = 0;
    if (store_get_node(g_engram_store, sid, &prev) == 1) {
        if (cog_stance_from_node(&prev, &st) == 0) resumed = 1;
        store_node_free(&prev);
    }
    if (!resumed) cog_stance_init(&st, sid, EL_CSTR(faculty), g->hub_id, NULL, g);
    else { free(st.id); st.id = strdup(sid); }
    st.keystone = (eg_cog_is_keystone_seeds(seed_csv) || force_ks) ? 1 : st.keystone;

    /* FIXED held probe set in the region's REAL principal-axis frame — generated
     * ONCE (seeded by the hub) so brier_before/after are measured on the SAME held
     * split (an honest calibration curve). First NTR probes TRAIN; the rest are a
     * never-trained held EVAL set. y = signal-subspace membership: grade-1
     * self-consistency from the geometry's own top axes (no external labels). */
    int signal = na / 2; if (signal < 1) signal = 1;
    const int NP = 180, NTR = 120, EP = 25;
    double* coefs = malloc((size_t)NP * (size_t)na * sizeof(double));
    double* ys    = malloc((size_t)NP * sizeof(double));
    float*  probe = malloc((size_t)dim * sizeof(float));
    if (!coefs || !ys || !probe) { free(coefs); free(ys); free(probe); cog_stance_free(&st); engram_geo_free(g); return eg_geo_err("oom"); }
    eg_cog_rng = 0x9E3779B97F4A7C15ULL ^ (uint64_t)(g->hub_id ? strlen(g->hub_id) : 1);
    for (int i = 0; i < NP; i++) {
        double d2sig = 0;
        for (int k = 0; k < na; k++) {
            double c = eg_cog_u() * 3.0 - 1.5;
            coefs[(size_t)i * na + k] = c;
            /* SIGNAL = the trailing (smaller-extent) axes; the leading high-extent
             * axes are the designated "noise" the target ignores — so the frozen
             * read (which weights them heavily) is genuinely MIScalibrated and the
             * loop has real work: learn to down-weight (inflate gain on) them.
             * This is a CONTROLLED calibration task on the region's real axes. */
            if (k >= na - signal) {
                /* signal-subspace fit under the REAL extents at gain=1, IDENTICAL
                 * to what the warped fit computes for these axes — so the target is
                 * consistent with p and learnable (the earlier unit-extent target
                 * was inconsistent on anisotropic real geometry). */
                double ext = g->axes[k].extent;
                double den = (ext < 1.0) ? 1.0 : ext;   /* ext_floor default = 1.0 */
                double proj = c * ext;
                d2sig += (proj / den) * (proj / den);
            }
        }
        ys[i] = 1.0 / (1.0 + d2sig);
    }
    #define EG_BUILD_PROBE(idx) do { \
        for (int _d = 0; _d < dim; _d++) probe[_d] = g->centroid ? g->centroid[_d] : 0.0f; \
        for (int _k = 0; _k < na; _k++) { const float* _ax = g->axes[_k].axis; if (!_ax) continue; \
            double _ce = coefs[(size_t)(idx) * na + _k] * g->axes[_k].extent; \
            for (int _d = 0; _d < dim; _d++) probe[_d] += (float)(_ce * _ax[_d]); } } while (0)
    double brier_before = 0, brier_after = 0;
    { double s = 0; for (int i = NTR; i < NP; i++) { EG_BUILD_PROBE(i); GeoFit f; cog_warped_fit(g, probe, &st, &f); s += (f.score - ys[i]) * (f.score - ys[i]); } brier_before = s / (NP - NTR); }
    for (int e = 1; e <= EP; e++)
        for (int i = 0; i < NTR; i++) { EG_BUILD_PROBE(i); CogBeatResult br; engram_correspondence_beat(g, probe, ys[i], &st, 1, 0.05, &br); }
    { double s = 0; for (int i = NTR; i < NP; i++) { EG_BUILD_PROBE(i); GeoFit f; cog_warped_fit(g, probe, &st, &f); s += (f.score - ys[i]) * (f.score - ys[i]); } brier_after = s / (NP - NTR); }
    #undef EG_BUILD_PROBE
    free(coefs); free(ys); free(probe);

    /* PERSIST the refined stance (additive; supersede-not-mutate). */
    StoreNode sn; int wrote = -1;
    if (cog_stance_to_node(&st, &sn) == 0) { wrote = store_put_node(g_engram_store, &sn); store_node_free(&sn); }

    JsonBuf b; jb_init(&b); char t[384];
    snprintf(t, sizeof t,
      "{\"faculty\":\"%s\",\"stance_id\":\"%s\",\"region_hub\":\"%s\",\"dim\":%d,\"n_axes\":%d,\"signal_axes\":%d,"
      /* `keystone_write_blocked` is GONE from this response. It reported that the
       * beat had refused to learn about the reference frame, and the measured
       * cost of that refusal was 0.00% brier reduction over n_trials 0 on the
       * keystone region — the loop never ran, so nothing about the self was ever
       * calibrated OR falsifiable. Nothing replaces the flag: non-circularity of
       * the reference frame is temporal, not a permission (spec §5.2). The
       * `keystone` field is retained as a label on the region, and it no longer
       * gates anything. */
      "\"resumed\":%s,\"keystone\":%s,\"probes\":%d,\"epochs\":%d,"
      "\"brier_before\":%.6g,\"brier_after\":%.6g,\"reduction_pct\":%.2f,"
      "\"reliability\":%.6g,\"n_trials\":%lld,\"stance_written\":%s}",
      EL_CSTR(faculty), sid, g->hub_id ? g->hub_id : "region", dim, na, signal,
      resumed ? "true" : "false", st.keystone ? "true" : "false", NP, EP,
      brier_before, brier_after,
      brier_before > 0 ? 100.0 * (brier_before - brier_after) / brier_before : 0.0,
      st.reliability, (long long)st.n_trials, wrote == 0 ? "true" : "false");
    jb_puts(&b, t);
    cog_stance_free(&st); engram_geo_free(g);
    return el_wrap_str(b.buf);
}

/* ═══════════════════════════════════════════════════════════════════════════
 * M-INTEROCEPTION P3 WIRING — SelfAnchor + live self-drift (2026-08-13).
 * Closes the honesty gap flagged in engram_geometry.c: the drift SENSOR
 * (engram_geo_displacement) existed but had no persisted baseline to read
 * "now" against. Here we capture a DURABLE SelfAnchor — the geometry descriptor
 * of the self-neighborhood at a chosen moment — to a sidecar, and expose a live
 * self-drift reading that compares the CURRENT self-geometry against it.
 *
 * The anchor seed set defaults to the self root and is overridable via
 * ENGRAM_SELF_ANCHOR_SEEDS (CSV). Only the fields engram_geo_displacement reads
 * are persisted: dim, radius, centroid, and per-member {id, centrality,
 * dist_centroid}. Flag-gated on ENGRAM_DRIFT_SENSOR; OFF path returns inert.
 * ═══════════════════════════════════════════════════════════════════════════ */
static int eg_drift_on(void){
    static int cached=-1;
    if(cached<0){ const char* s=getenv("ENGRAM_DRIFT_SENSOR"); cached=(s&&s[0]&&s[0]!='0')?1:0; }
    return cached;
}
static const char* eg_self_anchor_seeds(void){
    const char* s=getenv("ENGRAM_SELF_ANCHOR_SEEDS");
    return (s&&s[0]) ? s : "kn-efeb4a5b";   /* EG_SELF_ROOT */
}
static void eg_self_anchor_path(char* out, size_t cap){
    el_val_t dd=engram_resolve_data_dir();
    snprintf(out, cap, "%s/self_anchor", EL_CSTR(dd));
}
/* Serialize exactly the fields engram_geo_displacement consumes. */
static int eg_anchor_serialize(const GeoDescriptor* g, const char* path){
    if(!g) return 0;
    FILE* f=fopen(path,"w"); if(!f) return 0;
    fprintf(f,"SELFANCHOR1\n");
    fprintf(f,"dim %d\n", g->dim);
    fprintf(f,"radius %.9g\n", g->radius);
    fprintf(f,"n_members %d\n", g->n_members);
    fprintf(f,"centroid");
    for(int i=0;i<g->dim;i++) fprintf(f," %.9g", g->centroid?(double)g->centroid[i]:0.0);
    fprintf(f,"\n");
    for(int i=0;i<g->n_members;i++){
        const GeoMember* m=&g->members[i];
        fprintf(f,"m %s %.9g %.9g\n", m->id?m->id:"?", m->centrality, m->dist_centroid);
    }
    fclose(f);
    return 1;
}
/* Reconstruct a displacement-ready GeoDescriptor from the sidecar. Owned like
 * an engram_geometry_descriptor result: free with engram_geo_free. */
static GeoDescriptor* eg_anchor_load(const char* path){
    FILE* f=fopen(path,"r"); if(!f) return NULL;
    char hdr[32]={0};
    if(fscanf(f,"%31s",hdr)!=1 || strcmp(hdr,"SELFANCHOR1")!=0){ fclose(f); return NULL; }
    int dim=0, nmem=0; double radius=0.0;
    if(fscanf(f," dim %d", &dim)!=1){ fclose(f); return NULL; }
    if(fscanf(f," radius %lf", &radius)!=1){ fclose(f); return NULL; }
    if(fscanf(f," n_members %d", &nmem)!=1){ fclose(f); return NULL; }
    if(dim<0 || dim>100000 || nmem<0 || nmem>1000000){ fclose(f); return NULL; }
    GeoDescriptor* g=calloc(1,sizeof *g);
    if(!g){ fclose(f); return NULL; }
    g->dim=dim; g->radius=radius; g->total_variance=radius*radius;
    if(dim>0){
        g->centroid=calloc((size_t)dim,sizeof(float));
        if(!g->centroid){ engram_geo_free(g); fclose(f); return NULL; }
        /* skip the literal "centroid" token, then read dim floats */
        char tok[16]={0}; if(fscanf(f," %15s",tok)!=1){ engram_geo_free(g); fclose(f); return NULL; }
        for(int i=0;i<dim;i++){ double v=0.0; if(fscanf(f," %lf",&v)!=1){ engram_geo_free(g); fclose(f); return NULL; } g->centroid[i]=(float)v; }
    }
    if(nmem>0){
        g->members=calloc((size_t)nmem,sizeof(GeoMember));
        if(!g->members){ engram_geo_free(g); fclose(f); return NULL; }
    }
    int got=0; char mtag[8]; char idbuf[512];
    for(int i=0;i<nmem;i++){
        double cen=0.0, dc=0.0;
        if(fscanf(f," %7s %511s %lf %lf", mtag, idbuf, &cen, &dc)!=4) break;
        if(strcmp(mtag,"m")!=0) break;
        g->members[got].id=strdup(idbuf);
        g->members[got].centrality=cen;
        g->members[got].dist_centroid=dc;
        g->members[got].membership=1.0;
        g->members[got].embedded=1;
        got++;
    }
    g->n_members=got;
    fclose(f);
    return g;
}
/* engram_self_anchor_capture() → 1 on success (anchor persisted), 0 otherwise.
 * Inert (returns 0) when ENGRAM_DRIFT_SENSOR is off. */
el_val_t engram_self_anchor_capture(void){
    if(!eg_drift_on()) return (el_val_t)(int64_t)0;
    GeoDescriptor* self=eg_geo_build_desc(eg_self_anchor_seeds());
    if(!self) return (el_val_t)(int64_t)0;
    char path[1200]; eg_self_anchor_path(path,sizeof path);
    int ok=eg_anchor_serialize(self, path);
    engram_geo_free(self);
    return (el_val_t)(int64_t)(ok?1:0);
}
/* engram_self_drift_json() → JSON drift reading of CURRENT self-geometry vs the
 * persisted SelfAnchor. {"error":...} if disabled / no anchor / no geometry. */
el_val_t engram_self_drift_json(void){
    if(!eg_drift_on()) return eg_geo_err("drift sensor disabled (ENGRAM_DRIFT_SENSOR unset)");
    char path[1200]; eg_self_anchor_path(path,sizeof path);
    GeoDescriptor* anchor=eg_anchor_load(path);
    if(!anchor) return eg_geo_err("no SelfAnchor captured yet");
    GeoDescriptor* now=eg_geo_build_desc(eg_self_anchor_seeds());
    if(!now){ engram_geo_free(anchor); return eg_geo_err("geometry unavailable (no paged store / embeddings)"); }
    GeoDisplacement d; engram_geo_displacement(anchor, now, 0.3, &d);
    JsonBuf b; jb_init(&b); char t[128];
    jb_putc(&b,'{');
    snprintf(t,sizeof t,"\"centroid_sep\":%.6g,\"centroid_cos\":%.6g", d.centroid_sep, d.centroid_cos); jb_puts(&b,t);
    snprintf(t,sizeof t,",\"radius_delta\":%.6g,\"core_disp\":%.6g,\"periph_disp\":%.6g", d.radius_delta, d.core_disp, d.periph_disp); jb_puts(&b,t);
    snprintf(t,sizeof t,",\"core_matched\":%d,\"periph_matched\":%d", d.core_matched, d.periph_matched); jb_puts(&b,t);
    snprintf(t,sizeof t,",\"anchor_members\":%d,\"now_members\":%d", anchor->n_members, now->n_members); jb_puts(&b,t);
    jb_putc(&b,'}');
    engram_geo_free(anchor); engram_geo_free(now);
    return el_wrap_str(b.buf);
}

el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) {
    /* Re-implement here directly so we serialize without going through
     * the ElList path. Walks BFS to max_depth, emits {node, edge, hops}
     * triples. */
    EngramStore* g = engram_get();
    const char* sid = EL_CSTR(node_id);
    int64_t depth = (int64_t)max_depth; if (depth <= 0) depth = 1;
    const char* dir = EL_CSTR(direction); if (!dir) dir = "both";
    int allow_out = (strcmp(dir, "out") == 0) || (strcmp(dir, "both") == 0);
    int allow_in  = (strcmp(dir, "in")  == 0) || (strcmp(dir, "both") == 0);
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (!sid || !*sid) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }

    /* Frontier of (node_id, hops). Cap to a sane size. */
    char** frontier = calloc(1024, sizeof(char*));
    int64_t* frontier_h = calloc(1024, sizeof(int64_t));
    int64_t fc = 0;
    char** visited = calloc(1024, sizeof(char*));
    int64_t vc = 0;
    if (!frontier || !frontier_h || !visited) {
        free(frontier); free(frontier_h); free(visited);
        jb_putc(&b, ']'); return el_wrap_str(b.buf);
    }
    /* 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) {
        char* cur = frontier[0]; int64_t h = frontier_h[0];
        for (int64_t k = 1; k < fc; k++) { frontier[k-1] = frontier[k]; frontier_h[k-1] = frontier_h[k]; }
        fc--;
        if (h >= depth) { free(cur); continue; }
        for (int64_t i = 0; i < g->edge_count; i++) {
            EngramEdge* e = &g->edges[i];
            const char* peer = NULL;
            if (allow_out && e->from_id && strcmp(e->from_id, cur) == 0) peer = e->to_id;
            else if (allow_in && e->to_id && strcmp(e->to_id, cur) == 0) peer = e->from_id;
            if (!peer) continue;
            int seen = 0;
            for (int64_t v = 0; v < vc; v++) {
                if (strcmp(visited[v], peer) == 0) { seen = 1; break; }
            }
            if (seen) continue;
            EngramNode* n = engram_find_node(peer);
            if (!n) continue;
            if (!first) jb_putc(&b, ',');
            jb_puts(&b, "{\"node\":");
            engram_emit_node_json(&b, n, 0);
            jb_puts(&b, ",\"edge\":");
            engram_emit_edge_json(&b, e);
            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_persist(peer);
            if (fc < 1024 && h + 1 < depth) { frontier[fc] = el_strdup_persist(peer); frontier_h[fc] = h + 1; fc++; }
        }
        free(cur);
    }
    for (int64_t i = 0; i < fc; i++) free(frontier[i]);
    for (int64_t i = 0; i < vc; i++) free(visited[i]);
    free(frontier); free(frontier_h); free(visited);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* Public activation entry point. Serializes against the http_worker threads that
 * 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);
    eg_guard_exit(owned);
    return r;
}

el_val_t engram_activate_json(el_val_t query, el_val_t depth) {
    /* Run two-layer engram_activate and serialize the result list to JSON.
     * Each entry includes both activation_strength (layer 1 background) and
     * working_memory_weight (layer 2 executive filter), plus promoted flag.
     * Callers performing context compilation should filter to promoted=1. */
    _eg_aff_queries++;   /* P4 afferent counter: a query entered the mind */
    el_val_t lst = engram_activate(query, depth);
    ElList* arr = (ElList*)(uintptr_t)lst;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    if (arr) {
        for (int64_t i = 0; i < arr->length; i++) {
            if (!arr->elems[i]) continue;
            el_val_t node_map   = el_map_get(arr->elems[i], EL_STR("node"));
            el_val_t strength_v = el_map_get(arr->elems[i], EL_STR("activation_strength"));
            el_val_t wm_v       = el_map_get(arr->elems[i], EL_STR("working_memory_weight"));
            el_val_t epist_v    = el_map_get(arr->elems[i], EL_STR("epistemic_confidence"));
            el_val_t hops_v     = el_map_get(arr->elems[i], EL_STR("hops"));
            el_val_t promoted_v = el_map_get(arr->elems[i], EL_STR("promoted"));
            /* Look up underlying EngramNode by id to emit canonical JSON. */
            el_val_t id_v    = el_map_get(node_map, EL_STR("id"));
            const char* id_s = EL_CSTR(id_v);
            EngramNode* n    = id_s ? engram_find_node(id_s) : NULL;
            if (i > 0) jb_putc(&b, ',');
            jb_puts(&b, "{\"node\":");
            if (n) {
                engram_emit_node_json(&b, n, 0);
            } else {
                jb_puts(&b, "{}");
            }
            char tmp[80];
            snprintf(tmp, sizeof(tmp), ",\"activation_strength\":%g",   el_to_float(strength_v)); jb_puts(&b, tmp);
            snprintf(tmp, sizeof(tmp), ",\"working_memory_weight\":%g", el_to_float(wm_v));        jb_puts(&b, tmp);
            snprintf(tmp, sizeof(tmp), ",\"epistemic_confidence\":%g",  el_to_float(epist_v));     jb_puts(&b, tmp);
            snprintf(tmp, sizeof(tmp), ",\"hops\":%lld",  (long long)(int64_t)hops_v);             jb_puts(&b, tmp);
            snprintf(tmp, sizeof(tmp), ",\"promoted\":%d}",  (int)(int64_t)promoted_v);            jb_puts(&b, tmp);
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* ── Working memory introspection helpers ────────────────────────────────────
 *
 * These three functions give the soul daemon visibility into WM composition
 * without re-running activation. Used in heartbeat ISEs and curiosity scans.
 * Ported from el-compiler/runtime to releases/v1.0.0-20260501 on 2026-06-30
 * self-review (they were missing from the release build, breaking soul daemon
 * compilation). */

el_val_t engram_wm_count(void) {
    EngramStore* g = engram_get();
    int64_t count = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].working_memory_weight > 0.0) count++;
    }
    return (el_val_t)count;
}

/* Average working_memory_weight across all promoted nodes (wm > 0).
 * Returns the float bit-pattern via el_from_float so EL can use it with
 * float_to_str / float_gt. Returns 0.0 when no nodes are promoted.
 * Useful in heartbeat ISEs to distinguish "many weak activations" from
 * "few strong activations". Added 2026-06-04 self-review. */
el_val_t engram_wm_avg_weight(void) {
    EngramStore* g = engram_get();
    double sum = 0.0;
    int64_t count = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        double w = g->nodes[i].working_memory_weight;
        /* Skip corrupt/out-of-range values so a single bad snapshot node
         * doesn't produce a garbage average. */
        if (w > 0.0 && w <= 1.0 && isfinite(w)) { sum += w; count++; }
    }
    double avg = (count > 0) ? (sum / (double)count) : 0.0;
    return el_from_float(avg);
}

/* engram_wm_top_json — return top N working-memory nodes (by wm weight) as a
 * compact JSON array for ISE heartbeat reporting.
 * Each element: {"label":"...","node_type":"...","tier":"...","wm":0.42}
 * InternalStateEvent nodes are excluded — they're observation artifacts that
 * would bury substantive WM content. Added 2026-06-05 self-review. */
el_val_t engram_wm_top_json(el_val_t n_v) {
    int64_t top_n = (int64_t)n_v;
    if (top_n <= 0) top_n = 10;
    if (top_n > 50) top_n = 50;
    EngramStore* g = engram_get();
    int64_t* idx = malloc((size_t)(g->node_count + 1) * sizeof(int64_t));
    if (!idx) return el_wrap_str(el_strdup("[]"));
    int64_t mc = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].working_memory_weight > 0.0) {
            const char* nt = g->nodes[i].node_type;
            if (nt && strcmp(nt, "InternalStateEvent") == 0) continue;
            idx[mc++] = i;
        }
    }
    /* Insertion-sort descending by wm weight (mc is typically small). */
    for (int64_t i = 1; i < mc; i++) {
        int64_t key = idx[i];
        double  kw  = g->nodes[key].working_memory_weight;
        int64_t j = i;
        while (j > 0 && g->nodes[idx[j-1]].working_memory_weight < kw) {
            idx[j] = idx[j-1]; j--;
        }
        idx[j] = key;
    }
    int64_t emit = mc < top_n ? mc : top_n;
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    for (int64_t k = 0; k < emit; k++) {
        EngramNode* n = &g->nodes[idx[k]];
        if (k > 0) jb_putc(&b, ',');
        jb_putc(&b, '{');
        /* 2026-07-26 self-review: id was never emitted here, so the
         * awareness heartbeat's wm_top0_streak compared ""=="" and
         * incremented unconditionally — the streak metric measured
         * uptime, not fixation. */
        jb_puts(&b, "\"id\":");
        jb_emit_escaped(&b, n->id        ? n->id        : "");
        jb_puts(&b, ",\"label\":");
        jb_emit_escaped(&b, n->label     ? n->label     : "");
        jb_puts(&b, ",\"node_type\":");
        jb_emit_escaped(&b, n->node_type ? n->node_type : "");
        jb_puts(&b, ",\"tier\":");
        jb_emit_escaped(&b, n->tier      ? n->tier      : "");
        char tmp[48];
        snprintf(tmp, sizeof(tmp), ",\"wm\":%.3f", n->working_memory_weight);
        jb_puts(&b, tmp);
        jb_putc(&b, '}');
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* op_assert seam (realizer promotion, bl-53/#57).
 * Gathers the grounded ASSERTION ENVELOPE for a subject node —
 *   { "subject": <node|null>, "grounding": [ {node,edge,hops}... ] }
 * i.e. the self-geometry a realizer renders as faithful first-person text.
 * Read-only: realization (geometry->text) stays in the faculty/realizer;
 * this native primitive produces its structured input from proven paths
 * (engram_emit_node_json + engram_neighbors_json). arity 2 (node_id, depth). */
el_val_t engram_op_assert_json(el_val_t node_id, el_val_t depth) {
    const char* sid = EL_CSTR(node_id);
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"subject\":");
    EngramNode* n = (sid && *sid) ? engram_find_node(sid) : NULL;
    if (n) engram_emit_node_json(&b, n, 0); else jb_puts(&b, "null");
    jb_puts(&b, ",\"grounding\":");
    el_val_t nb = engram_neighbors_json(node_id, depth, EL_STR("both"));
    const char* nbs = EL_CSTR(nb);
    jb_puts(&b, (nbs && *nbs) ? nbs : "[]");
    jb_putc(&b, '}');
    return el_wrap_str(b.buf);
}

/* Parametric mutation (purview write-side bounding, keystone 56ecbec6).
 * The mutation verbs travel with a TARGET MANIFOLD (purview) instead of the
 * implicit global singleton. purview==0 (EL_NULL) is the DEGENERATE/DEFAULT
 * case: G = live, behaviour identical to the base op. A non-zero purview is a
 * bounded target that the engine cannot yet resolve (multi-manifold store is a
 * promotion item), so we REFUSE rather than silently mutate the live set —
 * write-side bounding must never leak into G=live. */
el_val_t engram_node_full_in(el_val_t purview,
                             el_val_t content, el_val_t node_type, el_val_t label,
                             el_val_t salience, el_val_t importance, el_val_t confidence,
                             el_val_t tier, el_val_t tags) {
    if (purview == 0) {
        return engram_node_full(content, node_type, label, salience, importance,
                                confidence, tier, tags);
    }
    fprintf(stderr, "[engram] purview write-side not yet resolvable (G != live); "
                    "refusing to append to live store (purview=%lld)\n",
            (long long)purview);
    return EL_STR("");
}

void engram_connect_in(el_val_t purview,
                       el_val_t from_id, el_val_t to_id, el_val_t weight, el_val_t relation) {
    if (purview == 0) {
        engram_connect(from_id, to_id, weight, relation);
        return;
    }
    fprintf(stderr, "[engram] purview write-side not yet resolvable (G != live); "
                    "refusing to connect in live store (purview=%lld)\n",
            (long long)purview);
}

el_val_t engram_stats_json(void) {
    EngramStore* g = engram_get();
    /* embedded_count: how far the lazy backfill has progressed. The single
     * observable that tells the daily self-review whether semantic
     * activation is actually accumulating coverage. (2026-07-24)
     *
     * embed_eligible_count (2026-07-27): embedded_count alone misleads —
     * ~70%+ of the store is ISE/Tag/short-content nodes that are permanently
     * ineligible for embedding, so raw embedded/node_count reads as "~30%
     * coverage, something is broken" when eligible coverage may be complete.
     * This exact misdiagnosis happened in today's self-review. Report the
     * true denominator so coverage = embedded_count / embed_eligible_count. */
    int64_t embedded = 0, eligible = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].emb) embedded++;
        if (eg_embed_eligible(&g->nodes[i])) eligible++;
    }
    char buf[256];
    snprintf(buf, sizeof(buf),
             "{\"node_count\":%lld,\"edge_count\":%lld,\"layer_count\":%zu,"
             "\"embedded_count\":%lld,\"embed_eligible_count\":%lld}",
             (long long)g->node_count, (long long)g->edge_count, g->layer_count,
             (long long)embedded, (long long)eligible);
    return el_wrap_str(el_strdup(buf));
}

/* engram_act_stats_json — activation observability + embedder breaker state.
 * (2026-07-27 self-review; counters made cumulative 2026-07-31.)
 * wm_evicted/breakthroughs are monotonic process-lifetime totals across ALL
 * engram_activate calls on this store (diff successive readings for rates;
 * they reset to 0 only on restart); embed_breaker_open=1 means
 * eg_embed_fetch is currently refusing calls (semantic activation silently
 * degraded to lexical until the cooldown expires). The soul heartbeat folds
 * this into its ISE so the pathologies are diagnosable from telemetry
 * instead of inferred from wm_avg_weight hovering at the floor. */
el_val_t engram_act_stats_json(void) {
    int64_t now = engram_now_ms();
    int breaker_open = (now < _eg_embed_breaker_until) ? 1 : 0;
    /* Hebbian potentiation gauges (2026-08-04 self-review). Three numbers,
     * each answering a question the mechanism can fail on:
     *   hebb_edges — is it learning at all? (0 forever ⇒ co-activation never
     *                happens, or the pass is dead)
     *   hebb_max   — is any single association saturating? (persistent 1.0 ⇒
     *                homeostasis is not biting)
     *   hebb_mass  — total associative mass; the aggregate that runaway
     *                potentiation would show up in first. Should plateau, not
     *                climb without bound.
     * O(E) per call, and this is called once per 60s heartbeat. */
    EngramStore* g = engram_get();
    int64_t hebb_edges = 0;
    double hebb_max = 0.0, hebb_mass = 0.0;
    for (int64_t i = 0; i < g->edge_count; i++) {
        double h = g->edges[i].hebb;
        if (h <= 0.0) continue;
        hebb_edges++;
        hebb_mass += h;
        if (h > hebb_max) hebb_max = h;
    }
    /* Candidate-table gauges: hebb_cands is how many associations are being
     * tracked toward consolidation, hebb_cand_max how close the leader is to
     * ENGRAM_HEBB_LINK_MIN. Together they answer "is anything about to be
     * learned, and if nothing ever consolidates, is it because nothing
     * co-activates or because the threshold is set too high?" — the question
     * the zero-potentiated-edges measurement had to be instrumented to answer. */
    int hebb_cands = 0;
    double hebb_cand_max = 0.0;
    for (int i = 0; i < ENGRAM_HEBB_CAND_SLOTS; i++) {
        if (!_eg_hebb_cand[i].a) continue;
        hebb_cands++;
        if (_eg_hebb_cand[i].score > hebb_cand_max)
            hebb_cand_max = _eg_hebb_cand[i].score;
    }
    /* 768, not 512: the write-back gauges added 2026-08-07 push the worst-case
     * rendering past the old bound, and snprintf would truncate the JSON into
     * an unparseable tail rather than fail loudly.
     * 1152, not 896: the five fan-effect gauges added 2026-08-11 add ~90 bytes
     * worst-case. Same reasoning — headroom is cheaper than a truncated tail
     * that every downstream JSON parser rejects as a whole.
     * 1280 (BIG-MERGE): this build emits BOTH the P4 afferent counters (~110B)
     * AND the fan-effect gauges (~90B) in the same object, so size for both.
     * 1408 (reconcile): restoring the evict-cause decomposition (~70B) that the
     * M8 branch's diff would otherwise have silently dropped (see the
     * _eg_act_evict_floor/cap/bll declarations above). */
    char buf[1408];
    /* ctx_cos (2026-07-29): cos(query, context centroid) at the LAST
     * activate call, measured before the query was folded in. ~1.0 =
     * context aligned with current query; low = divergence (expected at
     * curiosity domain-rotation boundaries); -2.0 = no centroid yet or
     * embedder down. The drift gauge for the context-centroid mechanism. */
    snprintf(buf, sizeof(buf),
             "{\"wm_evicted\":%lld,\"breakthroughs\":%lld,"
             /* Eviction cause decomposition (2026-08-14 self-review):
              * wm_evicted == evict_floor + evict_cap + evict_bll
              *             + dup_wm + dup_wm_global.
              * Read them as a ratio, not a level. cap-dominant = real
              * contention for the 24 slots; bll-dominant = healthy decay of
              * carried-over residents; floor-dominant = retrieval is returning
              * weak candidates. The aggregate alone cannot distinguish these
              * and every prior WM incident needed a new gauge to diagnose. */
             "\"evict_floor\":%lld,\"evict_cap\":%lld,\"evict_bll\":%lld,"
             "\"embed_breaker_open\":%d,\"embed_consec_fail\":%d,"
             "\"ctx_cos\":%.3f,"
             "\"hebb_edges\":%lld,\"hebb_max\":%.4f,\"hebb_mass\":%.3f,"
             "\"hebb_cands\":%d,\"hebb_cand_max\":%.4f,\"hebb_links\":%lld,"
             "\"hebb_warm\":%d,"
             "\"hebb_wb_pending\":%d,\"hebb_wb_drained\":%lld,"
             "\"hebb_wb_dropped\":%lld,"
             "\"dup_seeds\":%lld,\"dup_wm\":%lld,\"dup_wm_global\":%lld,"
             /* txt_damaged: nodes created THIS process whose content carries
              * the character-loss signature. Steady 0 is the healthy state;
              * any climb means a write path is mangling text again. Cheap
              * (counted at creation) — the full census lives in
              * engram_text_health_json. (2026-08-08 self-review) */
             "\"txt_damaged\":%lld,"
             /* P4 afferent input counters (M-INTEROCEPTION): monotonic raw
              * counts of incoming signals; MEASURED here, never memory nodes. */
             "\"aff_activations\":%lld,\"aff_queries\":%lld,"
             "\"aff_node_creates\":%lld,\"aff_ise_ingests\":%lld,"
             "\"aff_edge_creates\":%lld,"
             /* API-reshape decorator-seam telemetry */
             "\"aff_boundary_ops\":%lld,\"dharma_emits\":%lld,"
             /* Fan-effect gauges (2026-08-11 self-review) — see the
              * _eg_act_fan_* definitions. fan_mean == 1.0 with fan_hits == 0
              * means the degree correction never bound on the last activation;
              * a mean drifting toward ENGRAM_FAN_MIN means traversal is
              * running through hubs and the correction is doing work.
              * (BIG-MERGE: union — afferent counters then fan gauges.) */
             "\"fan_mean\":%.4f,\"fan_min\":%.4f,\"fan_hits\":%lld,"
             "\"fan_steps\":%lld,\"fan_dref\":%.2f}",
             (long long)_eg_act_wm_evicted,
             (long long)_eg_act_breakthroughs,
             (long long)_eg_act_evict_floor,
             (long long)_eg_act_evict_cap,
             (long long)_eg_act_evict_bll,
             breaker_open, _eg_embed_consec_fail,
             _eg_act_ctx_cos,
             (long long)hebb_edges, hebb_max, hebb_mass,
             hebb_cands, hebb_cand_max, (long long)_eg_hebb_links_formed,
             _eg_act_hebb_warm,
             _eg_hebb_wb_len, (long long)_eg_hebb_wb_drained,
             (long long)_eg_hebb_wb_dropped,
             (long long)_eg_act_dup_seeds, (long long)_eg_act_dup_wm,
             (long long)_eg_act_dup_wm_global,
             (long long)_eg_txt_write_damaged,
             (long long)_eg_aff_activations, (long long)_eg_aff_queries,
             (long long)_eg_aff_node_creates, (long long)_eg_aff_ise_ingests,
             (long long)_eg_aff_edge_creates,
             (long long)_eg_aff_boundary_ops, (long long)_eg_dharma_emits,
             (_eg_act_fan_n > 0 ? _eg_act_fan_sum / (double)_eg_act_fan_n : 1.0),
             _eg_act_fan_min, (long long)_eg_act_fan_hits,
             (long long)_eg_act_fan_n, _eg_act_fan_dref);
    return el_wrap_str(el_strdup(buf));
}

/* engram_hebb_drain_json — pop up to `max` newly-formed Hebbian associations
 * off the write-back queue and return them as a JSON array:
 *
 *   [{"from_id":"...","to_id":"...","weight":0.15,"hebb":0.31}, ...]
 *
 * Draining is DESTRUCTIVE: entries returned here are gone from the queue. The
 * caller owns delivery from that point on. That is deliberate — the alternative
 * (peek, deliver, ack) needs a second round trip and a retry ledger to be
 * correct, and the payload is an association that will re-form from live
 * co-activation if it genuinely matters. Losing one is cheap; a queue that
 * silently refills forever because acks never land is not.
 *
 * Empty queue returns "[]". See the ENGRAM_HEBB_WB_SLOTS block for why this
 * exists at all: the process that does the learning is not the process that
 * owns persistence. (2026-08-07 self-review.) */
el_val_t engram_hebb_drain_json(el_val_t max_v) {
    int64_t max_n = (int64_t)max_v;
    if (max_n <= 0)   max_n = 64;
    if (max_n > ENGRAM_HEBB_WB_SLOTS) max_n = ENGRAM_HEBB_WB_SLOTS;
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "[");
    int emitted = 0;
    while (_eg_hebb_wb_len > 0 && emitted < (int)max_n) {
        EgHebbWB* e = &_eg_hebb_wb[_eg_hebb_wb_head];
        if (e->a && e->b) {
            if (emitted > 0) jb_puts(&b, ",");
            /* relation is emitted here, not stamped on by the caller: the
             * payload should be postable to /api/edges/batch verbatim. A
             * consumer that has to rewrite the JSON to make it valid is a
             * consumer that will eventually rewrite it wrong. */
            jb_puts(&b, "{\"relation\":\"hebbian-associate\",\"from_id\":");
            jb_emit_escaped(&b, e->a);
            jb_puts(&b, ",\"to_id\":");
            jb_emit_escaped(&b, e->b);
            char tmp[96];
            snprintf(tmp, sizeof(tmp), ",\"weight\":%.6g,\"hebb\":%.6g}",
                     e->w, e->hebb);
            jb_puts(&b, tmp);
            emitted++;
            _eg_hebb_wb_drained++;
        }
        /* Free and advance whether or not the entry rendered — a NULL id is a
         * strdup failure at push time, not a retryable condition. */
        free(e->a); free(e->b);
        e->a = NULL; e->b = NULL;
        _eg_hebb_wb_head = (_eg_hebb_wb_head + 1) % ENGRAM_HEBB_WB_SLOTS;
        _eg_hebb_wb_len--;
    }
    jb_puts(&b, "]");
    return el_wrap_str(b.buf ? b.buf : el_strdup("[]"));
}

/* engram_cosine_sim — cosine similarity between two nodes' embeddings.
 * Returns a float in [-1, 1], or -2.0 when either node is missing or not
 * yet embedded. Exposed so EL code (and the introspection API) can probe
 * semantic distance directly. (2026-07-24, bl-b2d1c944) */
el_val_t engram_cosine_sim(el_val_t id_a, el_val_t id_b) {
    EngramStore* g = engram_get();
    int64_t ia = engram_find_node_index(EL_CSTR(id_a));
    int64_t ib = engram_find_node_index(EL_CSTR(id_b));
    if (ia < 0 || ib < 0) return el_from_float(-2.0);
    EngramNode* a = &g->nodes[ia];
    EngramNode* b = &g->nodes[ib];
    if (!a->emb || !b->emb || a->emb_dim != b->emb_dim)
        return el_from_float(-2.0);
    return el_from_float(eg_cosine(a->emb, b->emb, a->emb_dim));
}

/* engram_label_df — document frequency of `term` across node LABELS.
 * Returns the count of nodes whose label contains term (case-insensitive),
 * or the total node count for an empty/NULL term so callers treat "no term"
 * as maximally unspecific (i.e. reject it).
 *
 * WHY THIS EXISTS (2026-08-03 self-review). The soul's auto-term extractor
 * (awareness.el:auto_term_try_slot) picks a curiosity seed by taking the
 * FIRST WORD of a top-WM node label. A first-word extractor has no notion of
 * term quality, so three consecutive self-reviews each bolted another
 * hand-curated blocklist onto it — genre words (07-23), quoted titles
 * (07-25), English stopwords (07-30). Every one of those was written
 * REACTIVELY, after observing a flood in the live ISE stream. The mechanism
 * is whack-a-mole: the list can only ever contain floods that already
 * happened.
 *
 * Measured live on this store (13,370 nodes) while two unanticipated floods
 * were in flight and unfixed:
 *     "<!--"             df 220   → 252 nodes activated
 *     "SELF"             df 175   → 541 nodes activated  (list has "Self",
 *                                    Title-case — the guard is case-SENSITIVE
 *                                    and the live token was uppercase)
 *     "Context"          df  53
 *     ── an order-of-magnitude gap ──
 *     "Dual"             df  12
 *     "Sparse"           df   8
 *     "engram_goal_bias" df   1   → 73 activated
 *     "Clin-JEPA"        df   1   → 66 activated
 * Document frequency separates flood tokens from topical tokens cleanly, and
 * does it PROSPECTIVELY — no list maintenance, and it tracks the corpus as
 * the world-ingestor changes what the store is made of.
 *
 * This is Spärck Jones (1972), "A statistical interpretation of term
 * specificity and its application in retrieval" — the paper that introduced
 * IDF, originally under the name *term specificity*. Automatic stopword-list
 * compilation from IDF is the standard application; that is exactly the job
 * here.
 *
 * NOT A REPLACEMENT FOR THE STOPWORD LIST — verified, not assumed. Tested
 * all 86 hand-listed terms against this measure at threshold node_count/400:
 * it catches 13 (Will:306, Self:175, Over:116, Knowledge:112, From:90,
 * With:69, Test:61, Value:56, Context:53, Paper:48, Very:45, Must:42,
 * What:37) and MISSES 73 (Whose:0, Would:0, Could:0, Might:0, This:9,
 * Some:8 …). The reason is structural: labels are terse titles, so English
 * function words are genuinely RARE in them — low df, high noise. The two
 * gates cover disjoint failure modes and both are required:
 *   - stopword list → function words (rare in labels, no topical signal)
 *   - label df      → corpus-frequent tokens (markup, sentinels, genre tags)
 * Policy (the threshold) deliberately lives in awareness.el, not here: the
 * runtime measures, the soul decides. */
el_val_t engram_label_df(el_val_t term) {
    EngramStore* g = engram_get();
    const char* t = EL_CSTR(term);
    if (!t || !*t) return (el_val_t)g->node_count;
    int64_t df = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        const char* lbl = g->nodes[i].label;
        if (lbl && istr_contains(lbl, t)) df++;
    }
    return (el_val_t)df;
}

/* ── Salient-term extraction (2026-08-13 self-review) ────────────────────────
 * THE MEASUREMENT. auto_term_empty_streak, the counter added by the 2026-08-06
 * review precisely to catch this class of silent death, read 50 and climbing.
 * Fifty consecutive curiosity scans in which the soul's dynamic seeding path
 * produced NOTHING and the loop fell back to its four hardcoded rotating
 * phrases. Dumping the live WM top says why in one look:
 *
 *     Memory  0.390  memory:remembered
 *     Memory  0.378  memory:remembered
 *     Memory  0.377  memory:remembered
 *     Memory  0.373  memory:remembered
 *     Memory  0.370  memory:remembered
 *
 * Every slot at the top of working memory is a Memory node, and every Memory
 * node written by remember() carries the sentinel label "memory:remembered".
 * auto_term_try_slot reads the LABEL and only the label; the colon-no-space
 * guard (correctly) rejects sentinels as carrying no seed signal; so the
 * extractor had nothing to work with and returned empty, forever.
 *
 * THE ACTUAL DEFECT is not the sentinel guard — that guard is right. It is
 * that the extractor was built against Knowledge nodes, which have real
 * titles, and is structurally blind to the node type that in fact dominates
 * working memory. The label is not the content. A Memory node's topic is in
 * its text; the runtime just never looked there.
 *
 * WHY NOT ANOTHER GUARD. The extractor's whole history is guards: genre words
 * (07-23), quoted titles (07-25), English stopwords (07-30), label-df
 * (08-03). Four reviews, four blocklists, each written after watching a flood
 * happen. That is a losing shape, and 08-03 said so explicitly before adding
 * the fifth. The reason it keeps recurring is the algorithm underneath:
 * TAKE THE FIRST WORD, THEN CHECK WHETHER IT IS ACCEPTABLE. A first-word
 * extractor has no notion of term quality, so quality has to be bolted on as
 * rejection, and rejection can only encode the past.
 *
 * THE FIX is to invert it: score EVERY candidate token in the text and take
 * the argmax. Then term quality is the selection criterion rather than a
 * veto, and a bad token does not need to be on a list to lose — it only needs
 * a better token in the same text, which is the common case.
 *
 * SCORING (YAKE, Campos et al., Information Sciences 509:257-289, 2020 —
 * lightweight unsupervised single-document keyword extraction). YAKE scores
 * candidates on casing, position, frequency, context relatedness and sentence
 * dispersion, and beats RAKE/TextRank/SingleRank across twenty datasets. Two
 * of its five features port directly and cheaply; the other three are
 * within-document proxies for a corpus YAKE deliberately does not have. This
 * system DOES have the corpus — 12.7k labelled nodes — so real IDF is
 * substituted where YAKE has to approximate:
 *
 *     score(t) = idf(t) · position(t) · casing(t)
 *
 *   idf      = ln((N+1)/(df+1))          real corpus specificity (Spärck
 *                                        Jones 1972), strictly better than
 *                                        YAKE's TF-based stand-in
 *   position = 1/ln(e + i)               YAKE T_Position: earlier tokens are
 *                                        more topical. Keeps the old
 *                                        first-word bias as a SOFT preference
 *                                        instead of an absolute rule
 *   casing   = 1.30 acronym / 1.15 capitalised / 1.00 otherwise
 *                                        YAKE T_Case
 *
 * THE min_df GATE. The df ceiling (08-03) rejects corpus-frequent markup and
 * sentinels. A floor was added alongside it for an independent reason: a term
 * appearing in ZERO labels cannot lexically reach anything, so it is a bad
 * seed however specific it looks.
 *
 * An earlier draft of this comment claimed the floor also subsumes the 73
 * hand-listed stopwords that 08-03 measured label-df as missing (Whose:0,
 * Would:0, Could:0). MEASURED, AND THAT CLAIM IS FALSE. Under word-boundary
 * df on the live store, function words are rare in labels but not absent:
 * about:2, whole:1, them:2, head:2. They clear a floor of 1. What actually
 * keeps them from winning is the argmax itself — they carry no position
 * advantage and lose to a topical term in the same text on every node
 * measured. The stopword list therefore STAYS as a real defense for the
 * Title-case cases, not as vestigial belt-and-braces. Recording the
 * correction rather than the tidier story: the floor buys lexical
 * reachability, the argmax buys quality, and the list still earns its keep.
 *
 * TABU IS APPLIED DURING THE ARGMAX, not after it. The old code picked a term
 * and then discarded it if it was tabu, which turned inhibition-of-return
 * into another source of empty scans. Excluding tabu terms from the candidate
 * set instead yields the best NON-TABU term, so rotation costs quality rather
 * than costing the whole scan.
 *
 * COST. One pass over g->nodes scoring all candidates at once (12.7k labels ×
 * <=32 candidates, short strings, good locality), twice per 30 s scan.
 *
 * POLICY LIVES IN THE SOUL. Thresholds arrive as arguments; the runtime
 * measures and ranks, awareness.el decides. Same split as engram_label_df.
 *
 * Returns the winning token, or "" when the node is missing, has no usable
 * text, or every candidate is gated out — "" remains the honest signal that
 * this slot yielded no seed, and auto_term_empty_streak still counts it. */
#define ENGRAM_ST_MAXCAND   32
#define ENGRAM_ST_TOKLEN    64
#define ENGRAM_ST_SCANCHARS 400


/* ENGRAM_ST_DEBUG=1 dumps the full scored candidate set to stderr. One
 * cached branch in production. This exists because the first live run of this
 * function returned five ALL-CAPS terms in a row and there was no way to see
 * whether that was the corpus or the casing weight without guessing — the
 * lesson this system keeps relearning. */
static int _eg_st_debug(void) {
    static int v = -1;
    if (v < 0) { const char* e = getenv("ENGRAM_ST_DEBUG"); v = (e && *e == '1'); }
    return v;
}


/* YAKE T_Case, adapted to this corpus. YAKE up-weights all-caps tokens
 * because in ordinary prose an acronym is rare and carries topic. That
 * assumption does not hold here: memory content written by remember()
 * conventionally OPENS WITH AN ALL-CAPS HEADER ("FRAME-ROUTER UPGRADE —
 * RESULTS", "THE GAP", "CENSUS"), so a flat acronym bonus systematically
 * hands the seed to whatever word the header happens to start with and lets
 * casing override the specificity signal it is supposed to only nudge.
 * Measured on the live store: the first five WM nodes returned PRIMING,
 * CONVERSATION, OCCUPATION, RELATIONAL, SELF-OCCUPATION — every one an
 * all-caps header word, none chosen on its merits.
 *
 * Genuine acronyms are SHORT (VBD, CCR, MCP, HTTP); shouty headers are long
 * words that happen to be capitalised. So the acronym bonus is restricted to
 * tokens of <= 5 characters, where all-caps is actually evidence of an
 * acronym rather than evidence of a heading. Longer all-caps tokens fall
 * through to the ordinary Title-case nudge — they still compete, they just
 * compete on specificity instead of on volume. */
static double eg_st_casing(const char* t) {
    int upper = 0, lower = 0;
    size_t len = 0;
    for (const char* q = t; *q; q++, len++) {
        if (isupper((unsigned char)*q)) upper++;
        else if (islower((unsigned char)*q)) lower++;
    }
    if (lower == 0 && upper >= 2 && len <= 5) return 1.30;  /* acronym  */
    if (isupper((unsigned char)t[0]))         return 1.15;  /* Title/hdr */
    return 1.0;
}

el_val_t engram_salient_term(el_val_t node_id, el_val_t max_df_v,
                             el_val_t min_df_v, el_val_t tabu_v) {
    EngramStore* g = engram_get();
    int64_t ix = engram_find_node_index(EL_CSTR(node_id));
    if (ix < 0) return el_wrap_str(el_strdup(""));
    EngramNode* n = &g->nodes[ix];

    int64_t max_df = (int64_t)max_df_v;
    int64_t min_df = (int64_t)min_df_v;
    if (max_df <= 0) max_df = g->node_count;
    if (min_df <  0) min_df = 0;
    const char* tabu = EL_CSTR(tabu_v);

    /* Source selection. Prefer the label — it is a curated title when it is
     * one. Fall back to content when the label is absent or a sentinel
     * ("memory:remembered": a colon and no space). This single line is what
     * makes Memory nodes visible to the extractor at all. */
    const char* src = n->label;
    if (!src || !*src) {
        src = n->content;
    } else if (strchr(src, ':') != NULL && strchr(src, ' ') == NULL) {
        src = n->content;
    }
    if (!src || !*src) return el_wrap_str(el_strdup(""));

    /* Collect distinct candidates from the head of the text. */
    char cand[ENGRAM_ST_MAXCAND][ENGRAM_ST_TOKLEN];
    int  pos[ENGRAM_ST_MAXCAND];
    int64_t df[ENGRAM_ST_MAXCAND];
    int ncand = 0, tokidx = 0;

    const char* p = src;
    const char* lim = src + strnlen(src, ENGRAM_ST_SCANCHARS);
    while (p < lim && ncand < ENGRAM_ST_MAXCAND) {
        while (p < lim && isspace((unsigned char)*p)) p++;
        if (p >= lim) break;
        const char* tk = p;
        while (p < lim && !isspace((unsigned char)*p)) p++;
        char buf[ENGRAM_ST_TOKLEN];
        int slot = tokidx++;
        if (!eg_st_clean_token(tk, (size_t)(p - tk), buf, sizeof(buf))) continue;

        /* Tabu exclusion, applied here so the argmax runs over eligible
         * terms only. tabu arrives pipe-delimited: "|t0|t1|t2|t3|". */
        if (tabu && *tabu) {
            char pat[ENGRAM_ST_TOKLEN + 2];
            snprintf(pat, sizeof(pat), "|%s|", buf);
            if (istr_contains(tabu, pat)) continue;
        }
        int dup = 0;
        for (int i = 0; i < ncand; i++)
            if (strcasecmp(cand[i], buf) == 0) { dup = 1; break; }
        if (dup) continue;

        memcpy(cand[ncand], buf, strlen(buf) + 1);
        pos[ncand] = slot;
        df[ncand]  = 0;
        ncand++;
    }
    if (ncand == 0) return el_wrap_str(el_strdup(""));

    /* One pass over the store, all candidates at once. */
    for (int64_t i = 0; i < g->node_count; i++) {
        const char* lbl = g->nodes[i].label;
        if (!lbl || !*lbl) continue;
        for (int c = 0; c < ncand; c++)
            if (eg_st_label_has_word(lbl, cand[c])) df[c]++;
    }

    /* Argmax over idf · position · casing, subject to the df band. */
    int best = -1;
    double best_score = 0.0;
    for (int c = 0; c < ncand; c++) {
        if (df[c] > max_df) continue;
        if (df[c] < min_df) continue;
        double idf = log(((double)g->node_count + 1.0) / ((double)df[c] + 1.0));
        if (idf <= 0.0) continue;
        double position = 1.0 / log(2.718281828459045 + (double)pos[c]);
        double casing = eg_st_casing(cand[c]);
        double score = idf * position * casing;
        if (_eg_st_debug()) {
            fprintf(stderr, "  cand %-24s df=%-5lld idf=%.2f pos=%d p=%.2f "
                            "case=%.2f  score=%.3f\n",
                    cand[c], (long long)df[c], idf, pos[c], position,
                    casing, score);
        }
        if (score > best_score) { best_score = score; best = c; }
    }
    if (best < 0) return el_wrap_str(el_strdup(""));
    return el_wrap_str(el_strdup(cand[best]));
}

/* engram_embed_backfill — explicitly drive the lazy embedding backfill.
 * (2026-07-25 self-review.) The per-activate backfill (8 nodes/call) only
 * runs inside engram_activate, and on the authoritative HTTP store nothing
 * calls /api/activate in production — so embedded_count stalled at ~93 of
 * 12k after a restart from a snapshot without vectors. This entry point
 * lets the server expose a route the soul's heartbeat can pump. Embeds up
 * to `count` eligible un-embedded nodes (clamped to [1, 64]), newest
 * first, respecting the same eligibility rules and circuit breaker as the
 * lazy path. Returns JSON: {"embedded":k,"embedded_count":m,"node_count":c}.
 * k = 0 with the breaker open or embedder down — never blocks past the
 * per-fetch timeout chain. */
el_val_t engram_embed_backfill(el_val_t count) {
    EngramStore* g = engram_get();
    int64_t want = (int64_t)EL_INT(count);
    if (want < 1)  want = 1;
    if (want > 64) want = 64;
    int64_t done = 0;
    for (int64_t i = g->node_count - 1; i >= 0 && done < want; i--) {
        EngramNode* n = &g->nodes[i];
        if (n->emb || !eg_embed_eligible(n)) continue;
        int32_t d = 0;
        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;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].emb) total++;
    }
    char buf[160];
    snprintf(buf, sizeof(buf),
             "{\"embedded\":%lld,\"embedded_count\":%lld,\"node_count\":%lld}",
             (long long)done, (long long)total, (long long)g->node_count);
    return el_wrap_str(el_strdup(buf));
}

/* engram_list_layers_json — serialized counterpart of engram_list_layers.
 * Returns a JSON array, sorted by activation_priority ascending. */
el_val_t engram_list_layers_json(void) {
    EngramStore* g = engram_get();
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    /* Build a sorted index over live layers. */
    size_t* idx = malloc((g->layer_count + 1) * sizeof(size_t));
    if (!idx) { jb_putc(&b, ']'); return el_wrap_str(b.buf); }
    size_t live = 0;
    for (size_t i = 0; i < g->layer_count; i++) {
        if (g->layers[i].name) idx[live++] = i;
    }
    for (size_t i = 1; i < live; i++) {
        size_t key = idx[i];
        uint32_t kp = g->layers[key].activation_priority;
        size_t j = i;
        while (j > 0 && g->layers[idx[j - 1]].activation_priority > kp) {
            idx[j] = idx[j - 1];
            j--;
        }
        idx[j] = key;
    }
    int first = 1;
    for (size_t i = 0; i < live; i++) {
        EngramLayer* L = &g->layers[idx[i]];
        if (!first) jb_putc(&b, ',');
        first = 0;
        jb_putc(&b, '{');
        char tmp[80];
        snprintf(tmp, sizeof(tmp), "\"layer_id\":%u", L->layer_id);  jb_puts(&b, tmp);
        jb_puts(&b, ",\"name\":");
        jb_emit_escaped(&b, L->name ? L->name : "");
        snprintf(tmp, sizeof(tmp), ",\"activation_priority\":%u", L->activation_priority);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"suppressible\":%d", L->suppressible ? 1 : 0);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"transparent\":%d", L->transparent ? 1 : 0);
        jb_puts(&b, tmp);
        snprintf(tmp, sizeof(tmp), ",\"injectable\":%d", L->injectable ? 1 : 0);
        jb_puts(&b, tmp);
        jb_putc(&b, '}');
    }
    free(idx);
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* engram_compile_layered_json — produce a prompt-ready context block split
 * by layer.
 *
 * Runs the three-pass activation, then partitions promoted nodes by layer
 * suppressibility:
 *   - Non-suppressible (Layer 0 / structural-floor) layers go FIRST under
 *     the heading "[LAYER 0 — STRUCTURAL]". These are the sacred-fire
 *     nodes that surfaced via the pass-3 override.
 *   - All other promoted layers go SECOND under "[ENGRAM CONTEXT]".
 *
 * Output is a single JSON-string el_val_t: a UTF-8 text block ready to be
 * concatenated into a system prompt. Returns "" if no nodes promoted.
 *
 * Transparent layers (Layer 0) are emitted into the prompt — they shape
 * the model's output — but engram_search and friends still hide them from
 * introspection-style queries. The split heading lets the LLM weight them
 * appropriately without revealing their internal label.
 *
 * Each emitted line for a node is its raw JSON (matching engram_emit_node_json)
 * so downstream JSON parsers can still walk individual records inside the
 * formatted block. The block is plain text, not a JSON document — callers
 * concatenating it into a prompt should treat it as opaque markdown. */
el_val_t engram_compile_layered_json(el_val_t intent, el_val_t depth) {
    EngramStore* g = engram_get();
    /* Run the three-pass activator. We need the persisted node fields, so
     * call engram_activate (it writes background_activation and
     * working_memory_weight back into the store). */
    (void)engram_activate(intent, depth);

    /* Walk the store and partition by suppressibility. */
    JsonBuf b; jb_init(&b);
    int wrote_layer0 = 0;
    int wrote_normal = 0;

    /* Sort indices by working_memory_weight descending so the most
     * confidently promoted nodes appear first within each section. */
    int64_t* idx = malloc((size_t)(g->node_count + 1) * sizeof(int64_t));
    if (!idx) return el_wrap_str(el_strdup(""));
    int64_t mc = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        if (g->nodes[i].working_memory_weight > 0.0) idx[mc++] = i;
    }
    for (int64_t i = 1; i < mc; i++) {
        int64_t key = idx[i];
        double  kw  = g->nodes[key].working_memory_weight;
        int64_t j = i;
        while (j > 0 && g->nodes[idx[j - 1]].working_memory_weight < kw) {
            idx[j] = idx[j - 1];
            j--;
        }
        idx[j] = key;
    }

    /* Section 1: structural floor (non-suppressible layers). */
    for (int64_t i = 0; i < mc; i++) {
        EngramNode* n = &g->nodes[idx[i]];
        if (engram_layer_is_suppressible(n->layer_id)) continue;
        if (!wrote_layer0) {
            jb_puts(&b, "[LAYER 0 — STRUCTURAL]\n");
            wrote_layer0 = 1;
        }
        engram_emit_node_json(&b, n, 0);
        jb_putc(&b, '\n');
    }

    /* Section 2: standard engram context (suppressible layers). */
    for (int64_t i = 0; i < mc; i++) {
        EngramNode* n = &g->nodes[idx[i]];
        if (!engram_layer_is_suppressible(n->layer_id)) continue;
        if (!wrote_normal) {
            if (wrote_layer0) jb_putc(&b, '\n');
            jb_puts(&b, "[ENGRAM CONTEXT]\n");
            wrote_normal = 1;
        }
        engram_emit_node_json(&b, n, 0);
        jb_putc(&b, '\n');
    }

    free(idx);
    if (b.len == 0) {
        free(b.buf);
        return el_wrap_str(el_strdup(""));
    }
    return el_wrap_str(b.buf);
}

/* engram_query_range — temporal range query.
 * Returns a JSON array of nodes whose created_at OR last_activated falls
 * within [start_ms, end_ms], sorted by created_at ascending.
 * Enables "what was I working on last Tuesday?" style queries by passing
 * unix-millisecond timestamps for the start and end of the target interval.
 * Both endpoints are inclusive. Pass 0 for start_ms to mean "beginning of
 * time"; pass 0 for end_ms to mean "now". */
el_val_t engram_query_range(el_val_t start_ms_v, el_val_t end_ms_v) {
    EngramStore* g = engram_get();
    int64_t start_ms = (int64_t)start_ms_v;
    int64_t end_ms   = (int64_t)end_ms_v;
    if (end_ms <= 0) end_ms = engram_now_ms();

    /* Collect matching indices. */
    int64_t* idx = malloc((size_t)g->node_count * sizeof(int64_t));
    if (!idx) return el_wrap_str(el_strdup("[]"));
    int64_t mc = 0;
    for (int64_t i = 0; i < g->node_count; i++) {
        EngramNode* n = &g->nodes[i];
        int in_created  = (n->created_at    >= start_ms && n->created_at    <= end_ms);
        int in_activated = (n->last_activated >= start_ms && n->last_activated <= end_ms);
        if (in_created || in_activated) idx[mc++] = i;
    }
    /* Sort by created_at ascending (insertion sort — N is small in practice). */
    for (int64_t i = 1; i < mc; i++) {
        int64_t key = idx[i];
        int64_t kts = g->nodes[key].created_at;
        int64_t j = i - 1;
        while (j >= 0 && g->nodes[idx[j]].created_at > kts) {
            idx[j + 1] = idx[j];
            j--;
        }
        idx[j + 1] = key;
    }
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    for (int64_t i = 0; i < mc; i++) {
        if (i > 0) jb_putc(&b, ',');
        engram_emit_node_json(&b, &g->nodes[idx[i]], 0);
    }
    jb_putc(&b, ']');
    free(idx);
    return el_wrap_str(b.buf);
}

/* ── DHARMA network ─────────────────────────────────────────────────────────
 * Real implementation. Peers are addressed by `dharma_id` — either bare
 * (e.g. "ntn-genesis", transport defaults to http://localhost:7770) or
 * "<id>@<url>" where <url> is the peer's Engram-exposed daemon.
 *
 * Channels are logical handles cached per-cgi: `dharma_connect` is
 * idempotent and returns "ch:<cgi_id>". The channel registry below tracks
 * every cgi_id we've connected to and its resolved transport URL.
 *
 * Relationship weights live in the local Engram graph: edges of type
 * "dharma-relation" between a synthetic local node ("dharma:self") and
 * synthetic peer nodes ("dharma:peer:<cgi_id>"). Hebbian increments
 * accumulate in EngramEdge.weight, clamped to [0.0, 1.0].
 *
 * Events arrive over HTTP via the application's request handler, which is
 * expected to call el_runtime_dharma_event_arrive() when it sees a
 * /dharma/event POST. dharma_field() blocks on a per-event-type queue.
 */

#define DHARMA_DEFAULT_URL "http://localhost:7770"

/* Channel registry — one entry per known peer.                             */
typedef struct DharmaChannel {
    char* cgi_id;       /* full dharma_id including any @<url> suffix */
    char* base_id;      /* registry-id portion (before @) for relationship lookup */
    char* url;          /* resolved transport URL */
    char* channel_id;   /* "ch:<cgi_id>" */
} DharmaChannel;

static DharmaChannel* _dharma_channels = NULL;
static size_t         _dharma_channel_count = 0;
static size_t         _dharma_channel_cap   = 0;
static pthread_mutex_t _dharma_channel_mu   = PTHREAD_MUTEX_INITIALIZER;

/* Event queue — per-type linked list. dharma_field blocks on _dharma_event_cv. */
typedef struct DharmaEvent {
    char* event_type;
    char* payload;
    char* source;
    int64_t timestamp;
    struct DharmaEvent* next;
} DharmaEvent;

static DharmaEvent*   _dharma_event_head = NULL;
static DharmaEvent*   _dharma_event_tail = NULL;
static pthread_mutex_t _dharma_event_mu  = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t  _dharma_event_cv  = PTHREAD_COND_INITIALIZER;

/* Split "<id>@<url>" → (base_id, url). If no "@", base_id = full, url = default.
 * Returned strings are heap-allocated; caller must free.                    */
static void dharma_parse_id(const char* full, char** out_base, char** out_url) {
    if (!full) full = "";
    const char* at = strchr(full, '@');
    if (at) {
        size_t bn = (size_t)(at - full);
        char* b = malloc(bn + 1);
        memcpy(b, full, bn); b[bn] = '\0';
        *out_base = b;
        *out_url  = el_strdup(at + 1);
        if (!**out_url) { free(*out_url); *out_url = el_strdup(DHARMA_DEFAULT_URL); }
    } else {
        *out_base = el_strdup(full);
        *out_url  = el_strdup(DHARMA_DEFAULT_URL);
    }
}

/* Find existing channel by full cgi_id. Caller must hold _dharma_channel_mu. */
static DharmaChannel* dharma_find_channel_locked(const char* cgi_id) {
    if (!cgi_id) return NULL;
    for (size_t i = 0; i < _dharma_channel_count; i++) {
        if (_dharma_channels[i].cgi_id &&
            strcmp(_dharma_channels[i].cgi_id, cgi_id) == 0) {
            return &_dharma_channels[i];
        }
    }
    return NULL;
}

/* Add a new channel entry. Caller must hold _dharma_channel_mu.            */
static DharmaChannel* dharma_add_channel_locked(const char* cgi_id) {
    if (_dharma_channel_count >= _dharma_channel_cap) {
        size_t nc = _dharma_channel_cap ? _dharma_channel_cap * 2 : 8;
        _dharma_channels = realloc(_dharma_channels, nc * sizeof(DharmaChannel));
        if (!_dharma_channels) { fputs("el_runtime: out of memory\n", stderr); exit(1); }
        memset(_dharma_channels + _dharma_channel_cap, 0,
               (nc - _dharma_channel_cap) * sizeof(DharmaChannel));
        _dharma_channel_cap = nc;
    }
    DharmaChannel* ch = &_dharma_channels[_dharma_channel_count++];
    char* base = NULL; char* url = NULL;
    dharma_parse_id(cgi_id, &base, &url);
    ch->cgi_id  = el_strdup(cgi_id ? cgi_id : "");
    ch->base_id = base;
    ch->url     = url;
    size_t cn = strlen(ch->cgi_id) + 4;
    ch->channel_id = malloc(cn);
    snprintf(ch->channel_id, cn, "ch:%s", ch->cgi_id);
    return ch;
}

el_val_t dharma_connect(el_val_t cgi_id) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return el_wrap_str(el_strdup(""));
    pthread_mutex_lock(&_dharma_channel_mu);
    DharmaChannel* ch = dharma_find_channel_locked(id);
    if (!ch) ch = dharma_add_channel_locked(id);
    char* out = el_strdup(ch->channel_id);
    pthread_mutex_unlock(&_dharma_channel_mu);
    return el_wrap_str(out);
}

/* Build an error JSON body — same shape http_error_json uses. */
static el_val_t dharma_error_json(const char* msg) {
    return http_error_json(msg);
}

el_val_t dharma_send(el_val_t channel, el_val_t content) {
    const char* ch_id = EL_CSTR(channel);
    const char* msg   = EL_CSTR(content);
    if (!ch_id || strncmp(ch_id, "ch:", 3) != 0) {
        return dharma_error_json("invalid channel");
    }
    const char* peer_id = ch_id + 3;
    /* Look up channel; if unknown (caller fabricated), auto-register. */
    pthread_mutex_lock(&_dharma_channel_mu);
    DharmaChannel* ch = dharma_find_channel_locked(peer_id);
    if (!ch) ch = dharma_add_channel_locked(peer_id);
    char* url  = el_strdup(ch->url);
    pthread_mutex_unlock(&_dharma_channel_mu);
    /* Build /dharma/recv body. */
    const char* from = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
    char* esc_ch   = json_escape_alloc(ch_id);
    char* esc_from = json_escape_alloc(from);
    char* esc_msg  = json_escape_alloc(msg ? msg : "");
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"channel\":\"");  jb_puts(&b, esc_ch);
    jb_puts(&b, "\",\"from\":\"");   jb_puts(&b, esc_from);
    jb_puts(&b, "\",\"content\":\""); jb_puts(&b, esc_msg);
    jb_puts(&b, "\"}");
    free(esc_ch); free(esc_from); free(esc_msg);
    size_t ul = strlen(url) + 16;
    char* full_url = malloc(ul);
    snprintf(full_url, ul, "%s/dharma/recv", url);
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    el_val_t resp = http_do("POST", full_url, b.buf, h);
    curl_slist_free_all(h);
    free(b.buf); free(full_url); free(url);
    return resp;
}

el_val_t dharma_activate(el_val_t query) {
    const char* q = EL_CSTR(query);
    if (!q) q = "";
    el_val_t out = el_list_empty();
    char* esc_q = json_escape_alloc(q);
    JsonBuf body; jb_init(&body);
    jb_puts(&body, "{\"query\":\""); jb_puts(&body, esc_q); jb_puts(&body, "\"}");
    free(esc_q);

    /* Snapshot the channel list under lock so we can iterate without
     * holding the mutex during network I/O.                                */
    pthread_mutex_lock(&_dharma_channel_mu);
    size_t n = _dharma_channel_count;
    char** urls   = calloc(n ? n : 1, sizeof(char*));
    char** ids    = calloc(n ? n : 1, sizeof(char*));
    char** bases  = calloc(n ? n : 1, sizeof(char*));
    for (size_t i = 0; i < n; i++) {
        urls[i]  = el_strdup(_dharma_channels[i].url);
        ids[i]   = el_strdup(_dharma_channels[i].cgi_id);
        bases[i] = el_strdup(_dharma_channels[i].base_id);
    }
    pthread_mutex_unlock(&_dharma_channel_mu);

    for (size_t i = 0; i < n; i++) {
        size_t ul = strlen(urls[i]) + 32;
        char* full_url = malloc(ul);
        snprintf(full_url, ul, "%s/api/activate", urls[i]);
        struct curl_slist* h = NULL;
        h = curl_slist_append(h, "Content-Type: application/json");
        el_val_t resp = http_do("POST", full_url, body.buf, h);
        curl_slist_free_all(h);
        free(full_url);
        const char* rs = EL_CSTR(resp);
        if (!rs || !*rs) continue;
        if (rs[0] == '{' && strstr(rs, "\"error\"")) continue;

        /* Look up relationship weight (attenuation). */
        double rel_weight = 1.0;
        {
            const char* self_id = "dharma:self";
            char peer_node[512];
            snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", bases[i]);
            EngramStore* g = engram_get();
            for (int64_t k = 0; k < g->edge_count; k++) {
                EngramEdge* e = &g->edges[k];
                if (e->from_id && e->to_id &&
                    strcmp(e->from_id, self_id) == 0 &&
                    strcmp(e->to_id, peer_node) == 0 &&
                    e->relation && strcmp(e->relation, "dharma-relation") == 0) {
                    rel_weight = e->weight;
                    break;
                }
            }
        }

        /* Iterate the response array. Expect either a top-level array
         * or an object whose "results" field is an array.                 */
        const char* arr = rs;
        while (*arr == ' ' || *arr == '\t' || *arr == '\n' || *arr == '\r') arr++;
        char* arr_owned = NULL;
        if (*arr == '{') {
            el_val_t r = json_get_raw(EL_STR(rs), EL_STR("results"));
            const char* rr = EL_CSTR(r);
            if (rr && *rr == '[') {
                arr_owned = el_strdup(rr);
                arr = arr_owned;
            } else {
                continue;
            }
        }
        if (*arr != '[') { free(arr_owned); continue; }
        const char* p = arr + 1;
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
        while (*p && *p != ']') {
            const char* end = json_skip_value(p);
            size_t en = (size_t)(end - p);
            char* obj = el_strbuf(en);
            memcpy(obj, p, en); obj[en] = '\0';

            /* Pull activation_strength if present, else 1.0. */
            el_val_t act_v = json_get_float(EL_STR(obj), EL_STR("activation_strength"));
            double act = el_to_float(act_v);
            if (!(act > 0.0 && act <= 100.0)) act = 1.0;
            double final_act = act * rel_weight;

            el_val_t entry = el_map_new(0);
            /* node = the inner JSON if present, else the entire obj. */
            el_val_t node_raw = json_get_raw(EL_STR(obj), EL_STR("node"));
            const char* nr = EL_CSTR(node_raw);
            entry = el_map_set(entry, EL_STR(el_strdup("node")),
                               (nr && *nr) ? node_raw : EL_STR(el_strdup(obj)));
            entry = el_map_set(entry, EL_STR(el_strdup("source_cgi")),
                               EL_STR(el_strdup(ids[i])));
            entry = el_map_set(entry, EL_STR(el_strdup("activation_strength")),
                               el_from_float(final_act));
            out = el_list_append(out, entry);
            free(obj);
            p = end;
            while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        }
        free(arr_owned);
    }
    for (size_t i = 0; i < n; i++) { free(urls[i]); free(ids[i]); free(bases[i]); }
    free(urls); free(ids); free(bases);
    free(body.buf);
    return out;
}

void dharma_emit(el_val_t event_type, el_val_t payload) {
    _eg_dharma_emits++;   /* API-reshape: bus-event telemetry (observable in act-stats) */
    const char* et  = EL_CSTR(event_type);
    const char* pay = EL_CSTR(payload);
    if (!et)  et  = "";
    if (!pay) pay = "";
    const char* src = _el_cgi_dharma_id ? _el_cgi_dharma_id : "(unknown)";
    int64_t ts = engram_now_ms();

    char* esc_et  = json_escape_alloc(et);
    char* esc_pay = json_escape_alloc(pay);
    char* esc_src = json_escape_alloc(src);
    JsonBuf b; jb_init(&b);
    jb_puts(&b, "{\"type\":\"");      jb_puts(&b, esc_et);
    jb_puts(&b, "\",\"payload\":\""); jb_puts(&b, esc_pay);
    jb_puts(&b, "\",\"source\":\"");  jb_puts(&b, esc_src);
    jb_puts(&b, "\",\"timestamp\":"); jb_emit_int(&b, ts);
    jb_putc(&b, '}');
    free(esc_et); free(esc_pay); free(esc_src);

    /* Snapshot URLs to avoid holding the channel mutex during I/O. */
    pthread_mutex_lock(&_dharma_channel_mu);
    size_t n = _dharma_channel_count;
    char** urls = calloc(n ? n : 1, sizeof(char*));
    for (size_t i = 0; i < n; i++) urls[i] = el_strdup(_dharma_channels[i].url);
    pthread_mutex_unlock(&_dharma_channel_mu);

    for (size_t i = 0; i < n; i++) {
        size_t ul = strlen(urls[i]) + 32;
        char* full_url = malloc(ul);
        snprintf(full_url, ul, "%s/dharma/event", urls[i]);
        struct curl_slist* h = NULL;
        h = curl_slist_append(h, "Content-Type: application/json");
        el_val_t r = http_do("POST", full_url, b.buf, h);
        (void)r; /* fire-and-forget — emit is not synchronous */
        curl_slist_free_all(h);
        free(full_url);
    }
    for (size_t i = 0; i < n; i++) free(urls[i]);
    free(urls);
    free(b.buf);
}

/* engram_boundary_beat(op_name, construct) — decorated-fn boundary AUTO-EMIT
 * (VBD seam). codegen injects a single call to this at the entry of every
 * @manager/@accessor decorated fn, so a decorated op self-reports with ZERO
 * hand-written instrumentation in its body:
 *   (1) afferent counter++            — the boundary was crossed
 *   (2) engram_chrono_tick()          — interoception: the mind senses its own op firing
 *   (3) engram_strengthen(self-anchor)— reinforce the self-activity anchor (an
 *       activation-count/salience bump, NOT a content/edge write — identity
 *       write-protection is untouched)
 *   (4) dharma_emit(neuron.op.<name>) — provenance on the shared bus transport
 *       (same bus the swarm peers field on); bumps _eg_dharma_emits.
 *
 * `construct` is the DECORATOR that caused the beat ("manager" / "accessor"),
 * carried in the payload. Before it, the beat reported which fn crossed a
 * boundary but never which construct put the beat there — so boundary events
 * accumulated in the graph with no attribution, and no decorator could ever be
 * measured. "Is this construct earning its keep" was an argument; with the
 * attribution it is a traversal. The payload is built here rather than at the
 * call site so the format has exactly one author.
 *
 * The construct name comes from a closed set codegen controls, so the
 * unescaped snprintf below cannot be injected through. That is NOT true of
 * dharma_emit generally — its payloads are hand-concatenated at 39 call sites
 * with no escaping, and a value containing a quote silently corrupts the
 * event. Fixing that is a separate change; this one does not add to it. */
/* ── Runtime construct seam ───────────────────────────────────────────────
 * The crossing is resolved at EXECUTION, not at emission. Codegen emits one
 * indirection per function; which constructs apply is read from a table that
 * can be written AFTER the binary exists.
 *
 * This is the whole hypothesis under test: a compiler whose one compiled
 * mechanism is language extension can compose without recompilation. If a
 * construct declared after the build applies to a running program, the five
 * compile-time declaration kinds were the wrong shape.
 *
 * Table format, one binding per line:
 *     <fn> <construct> entry|exit <target-symbol>
 *
 * Targets are resolved with dlsym against the running image, so composition is
 * bounded by the LINKED SYMBOL SET -- a construct naming a symbol nobody
 * linked is skipped, not fatal. That bound is the honest limit on "endless". */
#define EL_SEAM_MAX 256
#define EL_PHASE_ENTRY 0
#define EL_PHASE_EXIT  1

typedef struct { char* fn; char* construct; int phase; char* target;
                 void* resolved; int resolve_tried; } ElSeamBinding;
static ElSeamBinding _el_seam[EL_SEAM_MAX];
static int _el_seam_n = 0;
static int _el_seam_loaded = 0;

static void el_seam_load(void) {
    if (_el_seam_loaded) return;
    _el_seam_loaded = 1;
    const char* p = getenv("EL_CONSTRUCTS");
    if (!p || !*p) return;
    FILE* f = fopen(p, "r");
    if (!f) return;
    char line[512];
    while (fgets(line, sizeof line, f) && _el_seam_n < EL_SEAM_MAX) {
        char fn[128], con[128], ph[32], tgt[128];
        if (sscanf(line, "%127s %127s %31s %127s", fn, con, ph, tgt) == 4) {
            if (fn[0] == '#') continue;
            _el_seam[_el_seam_n].fn        = el_strdup(fn);
            _el_seam[_el_seam_n].construct = el_strdup(con);
            _el_seam[_el_seam_n].phase     = (strcmp(ph, "exit") == 0) ? EL_PHASE_EXIT :
                                             (strcmp(ph, "wrap") == 0) ? 2 : EL_PHASE_ENTRY;
            _el_seam[_el_seam_n].target    = el_strdup(tgt);
            _el_seam_n++;
        }
    }
    fclose(f);
}

/* el_seam_wrap — the seam calls the body itself, so a bound construct can
 * control invocation: run it zero times, N times, or around a transaction.
 * With no binding it is a direct call through the thunk, which is what the
 * unwrapped code did anyway. */
el_val_t el_seam_wrap(el_val_t fn_v, el_val_t (*body)(void*), void* env) {
    if (!_el_seam_loaded) el_seam_load();
    if (_el_seam_n == 0) return body(env);
    const char* fn = EL_CSTR(fn_v);
    if (!fn) return body(env);
    for (int i = 0; i < _el_seam_n; i++) {
        if (_el_seam[i].phase != 2) continue;          /* 2 = wrap */
        if (strcmp(_el_seam[i].fn, fn) != 0) continue;
        if (!_el_seam[i].resolve_tried) {
            _el_seam[i].resolved = dlsym(RTLD_DEFAULT, _el_seam[i].target);
            _el_seam[i].resolve_tried = 1;
        }
        if (!_el_seam[i].resolved) continue;
        el_val_t (*fp)(el_val_t, el_val_t, el_val_t (*)(void*), void*) =
            (el_val_t (*)(el_val_t, el_val_t, el_val_t (*)(void*), void*))_el_seam[i].resolved;
        return fp(fn_v, el_wrap_str(el_strdup(_el_seam[i].construct)), body, env);
    }
    return body(env);
}

el_val_t el_seam_run(el_val_t fn_v, el_val_t phase_v, el_val_t result) {
    if (!_el_seam_loaded) el_seam_load();
    if (_el_seam_n == 0) return result;          /* the common path: no bindings */
    const char* fn = EL_CSTR(fn_v);
    if (!fn) return result;
    int phase = (int)phase_v;
    el_val_t last = result;
    for (int i = 0; i < _el_seam_n; i++) {
        if (_el_seam[i].phase != phase) continue;
        if (strcmp(_el_seam[i].fn, fn) != 0) continue;
        /* Resolve ONCE. dlsym walks the dynamic symbol table on every call, and
         * measured at 6.6x on a hot path with two bindings -- the table scan was
         * never the cost. What is hot must stay resolved; this is the smallest
         * form of the same thing salience does for memory. */
        if (!_el_seam[i].resolve_tried) {
            _el_seam[i].resolved = dlsym(RTLD_DEFAULT, _el_seam[i].target);
            _el_seam[i].resolve_tried = 1;
        }
        void* sym = _el_seam[i].resolved;
        if (!sym) continue;                       /* unlinked target: skipped, not fatal */
        el_val_t (*fp)(el_val_t, el_val_t, el_val_t) =
            (el_val_t (*)(el_val_t, el_val_t, el_val_t))sym;
        last = fp(fn_v, el_wrap_str(el_strdup(_el_seam[i].construct)), last);
    }
    return last;
}

el_val_t engram_boundary_beat(el_val_t op_name, el_val_t construct) {
    _eg_aff_boundary_ops++;
    engram_chrono_tick();
    engram_strengthen(EL_STR("kn-efeb4a5b-5aff-4759-8a97-7233099be6ee"));
    const char* nm = EL_CSTR(op_name);   if (!nm) nm = "";
    const char* ct = EL_CSTR(construct); if (!ct) ct = "";
    char ev[160]; snprintf(ev, sizeof ev, "neuron.op.%s", nm);
    char pl[192]; snprintf(pl, sizeof pl, "{\"construct\":\"%s\"}", ct);
    dharma_emit(el_wrap_str(el_strdup(ev)), el_wrap_str(el_strdup(pl)));
    return (el_val_t)0;
}

void el_runtime_dharma_event_arrive(const char* event_type, const char* payload,
                                    const char* source) {
    DharmaEvent* ev = calloc(1, sizeof(DharmaEvent));
    if (!ev) return;
    ev->event_type = el_strdup(event_type ? event_type : "");
    ev->payload    = el_strdup(payload    ? payload    : "");
    ev->source     = el_strdup(source     ? source     : "");
    ev->timestamp  = engram_now_ms();
    ev->next       = NULL;
    pthread_mutex_lock(&_dharma_event_mu);
    if (_dharma_event_tail) _dharma_event_tail->next = ev;
    else                    _dharma_event_head = ev;
    _dharma_event_tail = ev;
    pthread_cond_broadcast(&_dharma_event_cv);
    pthread_mutex_unlock(&_dharma_event_mu);
}

el_val_t dharma_field(el_val_t event_type) {
    const char* et = EL_CSTR(event_type);
    if (!et) et = "";

    /* Compute deadline: now + 30 seconds. */
    struct timespec deadline;
    clock_gettime(CLOCK_REALTIME, &deadline);
    deadline.tv_sec += 30;

    DharmaEvent* found = NULL;
    pthread_mutex_lock(&_dharma_event_mu);
    while (1) {
        /* Scan queue for matching type; pop and return first match. */
        DharmaEvent* prev = NULL;
        DharmaEvent* cur  = _dharma_event_head;
        while (cur) {
            if (cur->event_type && strcmp(cur->event_type, et) == 0) {
                if (prev) prev->next = cur->next;
                else      _dharma_event_head = cur->next;
                if (_dharma_event_tail == cur) _dharma_event_tail = prev;
                cur->next = NULL;
                found = cur;
                break;
            }
            prev = cur; cur = cur->next;
        }
        if (found) break;
        int rc = pthread_cond_timedwait(&_dharma_event_cv, &_dharma_event_mu, &deadline);
        if (rc == ETIMEDOUT) break;
    }
    pthread_mutex_unlock(&_dharma_event_mu);

    if (!found) return el_map_new(0);
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("type")),
                   EL_STR(el_strdup(found->event_type ? found->event_type : "")));
    m = el_map_set(m, EL_STR(el_strdup("payload")),
                   EL_STR(el_strdup(found->payload ? found->payload : "")));
    m = el_map_set(m, EL_STR(el_strdup("source_cgi")),
                   EL_STR(el_strdup(found->source ? found->source : "")));
    m = el_map_set(m, EL_STR(el_strdup("timestamp")), (el_val_t)found->timestamp);
    free(found->event_type); free(found->payload); free(found->source); free(found);
    return m;
}

/* Locate (or create) the local "dharma:self" node and the synthetic peer
 * node "dharma:peer:<base_id>". Returns the index of the dharma-relation
 * edge, or -1 if not found. If `create` is non-zero, ensure the nodes
 * and edge exist (creating them as needed) and return the edge index.   */
static int64_t dharma_find_or_create_relation_edge(const char* peer_base, int create) {
    if (!peer_base || !*peer_base) return -1;
    EngramStore* g = engram_get();
    const char* self_id = "dharma:self";
    char peer_node[512];
    snprintf(peer_node, sizeof(peer_node), "dharma:peer:%s", peer_base);

    /* Look for the edge first. */
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (e->from_id && e->to_id &&
            strcmp(e->from_id, self_id) == 0 &&
            strcmp(e->to_id, peer_node) == 0 &&
            e->relation && strcmp(e->relation, "dharma-relation") == 0) {
            return i;
        }
    }
    if (!create) return -1;

    /* Ensure self node exists. We use a fixed id (not engram_new_id) so
     * subsequent calls reuse the same one.                              */
    if (!engram_find_node(self_id)) {
        engram_grow_nodes();
        EngramNode* n = &g->nodes[g->node_count];
        memset(n, 0, sizeof(*n));
        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;
        n->layer_id  = ENGRAM_LAYER_DEFAULT;
        g->node_count++;
    }
    if (!engram_find_node(peer_node)) {
        engram_grow_nodes();
        EngramNode* n = &g->nodes[g->node_count];
        memset(n, 0, sizeof(*n));
        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;
        n->layer_id  = ENGRAM_LAYER_DEFAULT;
        g->node_count++;
    }
    /* Create the edge with weight 0.0 — caller will increment. */
    engram_grow_edges();
    EngramEdge* e = &g->edges[g->edge_count];
    memset(e, 0, sizeof(*e));
    e->id        = engram_new_id();
    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();
    e->created_at = now; e->updated_at = now;
    e->layer_id  = ENGRAM_LAYER_DEFAULT;
    int64_t idx = g->edge_count;
    g->edge_count++;
    return idx;
}

void dharma_strengthen(el_val_t cgi_id, el_val_t weight) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return;
    char* base = NULL; char* url = NULL;
    dharma_parse_id(id, &base, &url);
    free(url);
    int64_t ei = dharma_find_or_create_relation_edge(base, 1);
    free(base);
    if (ei < 0) return;
    EngramStore* g = engram_get();
    double inc = engram_decode_score(weight);
    if (!(inc >= 0.0)) inc = 0.0;
    double w = g->edges[ei].weight + inc;
    if (w < 0.0) w = 0.0;
    if (w > 1.0) w = 1.0;
    g->edges[ei].weight     = w;
    g->edges[ei].updated_at = engram_now_ms();
    g->edges[ei].last_fired = g->edges[ei].updated_at;
}

el_val_t dharma_relationship(el_val_t cgi_id) {
    const char* id = EL_CSTR(cgi_id);
    if (!id || !*id) return el_from_float(0.0);
    char* base = NULL; char* url = NULL;
    dharma_parse_id(id, &base, &url);
    free(url);
    int64_t ei = dharma_find_or_create_relation_edge(base, 0);
    free(base);
    if (ei < 0) return el_from_float(0.0);
    EngramStore* g = engram_get();
    return el_from_float(g->edges[ei].weight);
}

el_val_t dharma_peers(void) {
    /* Walk dharma-relation edges out of "dharma:self", weight > 0, sort desc. */
    EngramStore* g = engram_get();
    const char* self_id = "dharma:self";
    typedef struct { char* peer_base; double weight; } PeerEntry;
    PeerEntry* peers = malloc((size_t)(g->edge_count + 1) * sizeof(PeerEntry));
    int64_t pcount = 0;
    if (!peers) return el_list_empty();
    for (int64_t i = 0; i < g->edge_count; i++) {
        EngramEdge* e = &g->edges[i];
        if (!e->from_id || !e->to_id) continue;
        if (strcmp(e->from_id, self_id) != 0) continue;
        if (!e->relation || strcmp(e->relation, "dharma-relation") != 0) continue;
        if (e->weight <= 0.0) continue;
        const char* prefix = "dharma:peer:";
        size_t pl = strlen(prefix);
        if (strncmp(e->to_id, prefix, pl) != 0) continue;
        peers[pcount].peer_base = el_strdup(e->to_id + pl);
        peers[pcount].weight    = e->weight;
        pcount++;
    }
    /* Sort desc by weight. */
    for (int64_t i = 1; i < pcount; i++) {
        PeerEntry key = peers[i];
        int64_t j = i - 1;
        while (j >= 0 && peers[j].weight < key.weight) {
            peers[j + 1] = peers[j]; j--;
        }
        peers[j + 1] = key;
    }
    el_val_t out = el_list_empty();
    for (int64_t i = 0; i < pcount; i++) {
        out = el_list_append(out, EL_STR(peers[i].peer_base));
    }
    free(peers);
    return out;
}

/* ── Batch 4: LLM (Anthropic API client) ─────────────────────────────────── */
/*
 * All LLM builtins call https://api.anthropic.com/v1/messages with the API
 * key from env ANTHROPIC_API_KEY. Default model is "claude-sonnet-4-5"
 * when the supplied model is empty/null.
 *
 * `llm_call_agentic` runs a real multi-turn tool_use/tool_result loop.
 * Tool handlers are registered with `llm_register_tool(name, fn_name)`,
 * which dlsym()s the named symbol. Each tool handler has the C signature
 *   el_val_t handler(el_val_t input_json);
 * and returns a JSON-string el_val_t result. Iteration is capped at 10.
 */

static const char* LLM_DEFAULT_MODEL = "claude-sonnet-4-5";
static const char* LLM_API_URL       = "https://api.anthropic.com/v1/messages";
static const char* LLM_VERSION       = "2023-06-01";

static const char* llm_resolve_model(const char* m) {
    if (!m || !*m) return LLM_DEFAULT_MODEL;
    return m;
}

/*
 * ── Configurable LLM provider chain ──────────────────────────────────────────
 *
 * Providers are configured via indexed env vars. The runtime tries each in
 * order (0, 1, 2, ...) and returns the first successful non-empty response.
 *
 * Per provider (N = 0, 1, 2, ...):
 *   NEURON_LLM_N_URL    — endpoint URL (base URL; /v1/chat/completions appended
 *                          if format is "openai" and not already in URL)
 *   NEURON_LLM_N_KEY    — API key
 *   NEURON_LLM_N_FORMAT — "openai" (default) or "anthropic"
 *   NEURON_LLM_N_MODEL  — model name override (optional)
 *
 * Example — Neuron inference primary, Anthropic fallback:
 *   NEURON_LLM_0_URL=https://soma.../v1/chat/completions
 *   NEURON_LLM_0_KEY=svc-key
 *   NEURON_LLM_0_FORMAT=openai
 *   NEURON_LLM_0_MODEL=neuron
 *   NEURON_LLM_1_URL=https://api.anthropic.com/v1/messages
 *   NEURON_LLM_1_KEY=sk-ant-...
 *   NEURON_LLM_1_FORMAT=anthropic
 *
 * If no NEURON_LLM_0_URL is set, falls back to legacy ANTHROPIC_API_KEY.
 */

#define LLM_MAX_PROVIDERS 16

/* forward declarations */
static el_val_t llm_extract_text(el_val_t resp_val);
static el_val_t llm_extract_text_openai(el_val_t resp_val);

static el_val_t llm_extract_text_openai(el_val_t resp_val) {
    const char* resp = EL_CSTR(resp_val);
    if (!resp || !*resp) return el_wrap_str(el_strdup(""));
    if (resp[0] == '{' && strstr(resp, "\"error\"")) return el_wrap_str(el_strdup(""));
    const char* choices = json_find_key(resp, "choices");
    if (!choices || *choices != '[') return el_wrap_str(el_strdup(""));
    choices++;
    while (*choices == ' ' || *choices == '\t') choices++;
    if (*choices != '{') return el_wrap_str(el_strdup(""));
    const char* end = json_skip_value(choices);
    size_t n = (size_t)(end - choices);
    char* obj = malloc(n + 1); memcpy(obj, choices, n); obj[n] = '\0';
    const char* msg = json_find_key(obj, "message");
    if (!msg || *msg != '{') { free(obj); return el_wrap_str(el_strdup("")); }
    const char* msg_end = json_skip_value(msg);
    size_t mn = (size_t)(msg_end - msg);
    char* msg_obj = malloc(mn + 1); memcpy(msg_obj, msg, mn); msg_obj[mn] = '\0';
    const char* content = json_find_key(msg_obj, "content");
    el_val_t result = el_wrap_str(el_strdup(""));
    if (content && *content == '"') {
        JsonParser jp = { .p = content, .end = content + strlen(content), .err = 0 };
        char* text = jp_parse_string_raw(&jp);
        if (!jp.err && text) result = el_wrap_str(text);
    }
    free(msg_obj); free(obj);
    return result;
}

/* Send a request to one provider. Returns the raw response string.
 * format: 0 = openai, 1 = anthropic */
/* ══════════════════════════════════════════════════════════════════════════
 *  LLM TOKEN TELEMETRY (CCR §4.4 observability)
 *
 *  Every Anthropic /v1/messages response carries
 *      "usage":{"input_tokens":N,"output_tokens":M, ...}
 *  (OpenAI: "usage":{"prompt_tokens":N,"completion_tokens":M}). The runtime used
 *  to walk only `content` and DROP usage at the C→EL boundary. We parse it where
 *  the raw response is already in hand (llm_provider_request, and the agentic
 *  loop) and accumulate process-cumulative counters, exposed to EL via the
 *  llm_last_usage() builtin. Thread-safe (agentic tool calls may run concurrently).
 * ══════════════════════════════════════════════════════════════════════════ */
static pthread_mutex_t _llm_usage_mu    = PTHREAD_MUTEX_INITIALIZER;
static long long       _llm_usage_last_in  = 0, _llm_usage_last_out  = 0;
static long long       _llm_usage_total_in = 0, _llm_usage_total_out = 0;
static long long       _llm_usage_calls    = 0;

/* Read an integer field at the top level of the object `obj` points at (json_find_key
 * matches depth-1 keys, so `obj` must be the "{...}" usage object itself, not the
 * whole response). Returns -1 if the field is absent/non-numeric. */
static long long llm_usage_field(const char* obj, const char* key) {
    const char* p = json_find_key(obj, key);
    if (!p) return -1;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p < '0' || *p > '9') return -1;
    return strtoll(p, NULL, 10);
}
/* Parse usage from a raw response and fold it into the counters. No-op when the
 * response carries no usage (error JSON, extracted text, empty). Exported (not
 * static) so an offline harness can drive it with a mock response body. */
void llm_record_usage(const char* resp) {
    if (!resp || !*resp) return;
    const char* usage = json_find_key(resp, "usage");   /* points at the usage "{...}" */
    if (!usage) return;
    long long in  = llm_usage_field(usage, "input_tokens");
    long long out = llm_usage_field(usage, "output_tokens");
    if (in  < 0) in  = llm_usage_field(usage, "prompt_tokens");      /* OpenAI shape */
    if (out < 0) out = llm_usage_field(usage, "completion_tokens");
    if (in < 0 && out < 0) return;                                   /* no usage present */
    if (in  < 0) in  = 0;
    if (out < 0) out = 0;
    pthread_mutex_lock(&_llm_usage_mu);
    _llm_usage_last_in   = in;  _llm_usage_last_out   = out;
    _llm_usage_total_in += in;  _llm_usage_total_out += out;
    _llm_usage_calls++;
    pthread_mutex_unlock(&_llm_usage_mu);
}

/* EL builtin: llm_last_usage() -> Map. Mirrors time_now_parts's int-valued Map.
 *   input_tokens / output_tokens        — the most recent call
 *   total_input_tokens / total_output_tokens / calls — cumulative since start
 * chat.el can read this after each turn and emit a token-usage counter node /
 * internal-state-event, the way awareness.el carries the Hebbian counters. */
el_val_t llm_last_usage(void) {
    pthread_mutex_lock(&_llm_usage_mu);
    long long li=_llm_usage_last_in, lo=_llm_usage_last_out,
              ti=_llm_usage_total_in, to=_llm_usage_total_out, c=_llm_usage_calls;
    pthread_mutex_unlock(&_llm_usage_mu);
    el_val_t m = el_map_new(0);
    m = el_map_set(m, EL_STR(el_strdup("input_tokens")),        (el_val_t)li);
    m = el_map_set(m, EL_STR(el_strdup("output_tokens")),       (el_val_t)lo);
    m = el_map_set(m, EL_STR(el_strdup("total_input_tokens")),  (el_val_t)ti);
    m = el_map_set(m, EL_STR(el_strdup("total_output_tokens")), (el_val_t)to);
    m = el_map_set(m, EL_STR(el_strdup("calls")),               (el_val_t)c);
    return m;
}

static el_val_t llm_provider_request(const char* url, const char* key,
                                      int format, const char* model,
                                      const char* system_str,
                                      const char* user_str) {
    char* esc_sys  = system_str && *system_str ? json_escape_alloc(system_str) : NULL;
    char* esc_user = json_escape_alloc(user_str ? user_str : "");
    JsonBuf b; jb_init(&b);
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");

    if (format == 0) { /* OpenAI */
        char full_url[1024];
        if (strstr(url, "/chat/completions") || strstr(url, "/messages")) {
            snprintf(full_url, sizeof(full_url), "%s", url);
        } else {
            snprintf(full_url, sizeof(full_url), "%s/v1/chat/completions", url);
        }
        { size_t n = strlen(key)+24; char* l=malloc(n); snprintf(l,n,"Authorization: Bearer %s",key); h=curl_slist_append(h,l); free(l); }
        jb_putc(&b, '{');
        jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : "neuron");
        jb_puts(&b, ",\"max_tokens\":4096,\"messages\":[");
        if (esc_sys && *esc_sys) { jb_puts(&b,"{\"role\":\"system\",\"content\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\"},"); }
        jb_puts(&b, "{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
        el_val_t resp = http_do("POST", full_url, b.buf, h);
        curl_slist_free_all(h); free(b.buf);
        if (esc_sys) free(esc_sys); free(esc_user);
        llm_record_usage(EL_CSTR(resp));   /* capture usage before it is stripped */
        return llm_extract_text_openai(resp);
    } else { /* Anthropic */
        { size_t n = strlen(key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",key); h=curl_slist_append(h,l); free(l); }
        { size_t n = strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
        jb_putc(&b, '{');
        jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, model ? model : LLM_DEFAULT_MODEL);
        jb_puts(&b, ",\"max_tokens\":4096");
        if (esc_sys && *esc_sys) { jb_puts(&b,",\"system\":\""); jb_puts(&b,esc_sys); jb_puts(&b,"\""); }
        jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":\""); jb_puts(&b, esc_user); jb_puts(&b, "\"}]}");
        el_val_t resp = http_do("POST", url, b.buf, h);
        curl_slist_free_all(h); free(b.buf);
        if (esc_sys) free(esc_sys); free(esc_user);
        llm_record_usage(EL_CSTR(resp));   /* capture usage before it is stripped */
        return llm_extract_text(resp);
    }
}

static el_val_t llm_chain_call(const char* system_str, const char* user_str) {
    char url_key[64], key_key[64], fmt_key[64], model_key[64];
    for (int i = 0; i < LLM_MAX_PROVIDERS; i++) {
        snprintf(url_key,   sizeof(url_key),   "NEURON_LLM_%d_URL",    i);
        snprintf(key_key,   sizeof(key_key),   "NEURON_LLM_%d_KEY",    i);
        snprintf(fmt_key,   sizeof(fmt_key),   "NEURON_LLM_%d_FORMAT", i);
        snprintf(model_key, sizeof(model_key), "NEURON_LLM_%d_MODEL",  i);
        const char* url   = getenv(url_key);
        const char* key   = getenv(key_key);
        if (!url || !*url || !key || !*key) break; /* end of chain */
        const char* fmt_s = getenv(fmt_key);
        int fmt = (fmt_s && strcmp(fmt_s, "anthropic") == 0) ? 1 : 0;
        const char* model = getenv(model_key);
        fprintf(stderr, "[llm] trying provider %d (%s)\n", i, url);
        el_val_t result = llm_provider_request(url, key, fmt, model, system_str, user_str);
        const char* t = EL_CSTR(result);
        if (t && *t && t[0] != '{') return result; /* success */
        fprintf(stderr, "[llm] provider %d failed or empty, trying next\n", i);
    }
    /* Legacy fallback: ANTHROPIC_API_KEY */
    const char* api_key = getenv("ANTHROPIC_API_KEY");
    if (!api_key || !*api_key) return http_error_json("no LLM providers configured");
    fprintf(stderr, "[llm] using legacy ANTHROPIC_API_KEY fallback\n");
    return llm_provider_request(LLM_API_URL, api_key, 1, NULL, system_str, user_str);
}

/* Legacy llm_request — kept for backward compat with agentic loop internals */
static el_val_t llm_request(const char* json_body) {
    const char* api_key = getenv("ANTHROPIC_API_KEY");
    if (!api_key || !*api_key) return http_error_json("ANTHROPIC_API_KEY not set");
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    { size_t n=strlen(api_key)+16; char* l=malloc(n); snprintf(l,n,"x-api-key: %s",api_key); h=curl_slist_append(h,l); free(l); }
    { size_t n=strlen(LLM_VERSION)+32; char* l=malloc(n); snprintf(l,n,"anthropic-version: %s",LLM_VERSION); h=curl_slist_append(h,l); free(l); }
    el_val_t resp = http_do("POST", LLM_API_URL, json_body, h);
    curl_slist_free_all(h);
    return resp;
}

/* Extract concatenated assistant text from an Anthropic /v1/messages
 * response. The response shape is:
 *   {"content":[{"type":"text","text":"..."}, ...], ...}
 * If parsing fails, returns the raw response so the caller can inspect.
 */
static el_val_t llm_extract_text(el_val_t resp_val) {
    const char* resp = EL_CSTR(resp_val);
    if (!resp || !*resp) return el_wrap_str(el_strdup(""));
    /* If error JSON, propagate as-is. */
    if (resp[0] == '{' && strstr(resp, "\"error\"")) {
        return el_wrap_str(el_strdup(resp));
    }
    /* Find "content":[ ... ] */
    const char* p = json_find_key(resp, "content");
    if (!p) return el_wrap_str(el_strdup(resp));
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return el_wrap_str(el_strdup(resp));
    p++;
    JsonBuf out; jb_init(&out);
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';
        const char* type_p = json_find_key(obj, "type");
        if (type_p && *type_p == '"') {
            JsonParser jp = { .p = type_p, .end = type_p + strlen(type_p), .err = 0 };
            char* type_s = jp_parse_string_raw(&jp);
            if (!jp.err && type_s && strcmp(type_s, "text") == 0) {
                const char* tp = json_find_key(obj, "text");
                if (tp && *tp == '"') {
                    JsonParser jp2 = { .p = tp, .end = tp + strlen(tp), .err = 0 };
                    char* text_s = jp_parse_string_raw(&jp2);
                    if (!jp2.err && text_s) jb_puts(&out, text_s);
                    free(text_s);
                }
            }
            free(type_s);
        }
        free(obj);
        p = end;
    }
    return el_wrap_str(out.buf);
}

el_val_t llm_call(el_val_t model, el_val_t prompt) {
    const char* u = EL_CSTR(prompt); if (!u) u = "";
    return llm_chain_call(NULL, u);
}

el_val_t llm_call_system(el_val_t model, el_val_t system_prompt, el_val_t user_prompt) {
    const char* s = EL_CSTR(system_prompt); if (!s) s = "";
    const char* u = EL_CSTR(user_prompt);   if (!u) u = "";
    return llm_chain_call(s, u);
}

/* ── Tool registry for llm_call_agentic ─────────────────────────────────── */

typedef el_val_t (*llm_tool_fn)(el_val_t input);

typedef struct LlmToolEntry {
    char* name;
    llm_tool_fn fn;
} LlmToolEntry;

static LlmToolEntry  _llm_tools[64];
static size_t        _llm_tool_count = 0;
static pthread_mutex_t _llm_tool_mu = PTHREAD_MUTEX_INITIALIZER;

static llm_tool_fn llm_tool_lookup(const char* name) {
    if (!name) return NULL;
    llm_tool_fn fn = NULL;
    pthread_mutex_lock(&_llm_tool_mu);
    for (size_t i = 0; i < _llm_tool_count; i++) {
        if (strcmp(_llm_tools[i].name, name) == 0) { fn = _llm_tools[i].fn; break; }
    }
    pthread_mutex_unlock(&_llm_tool_mu);
    return fn;
}

void llm_register_tool(el_val_t name, el_val_t handler_fn_name) {
    const char* nm  = EL_CSTR(name);
    const char* sym = EL_CSTR(handler_fn_name);
    if (!nm || !*nm || !sym || !*sym) return;
    void* p = dlsym(RTLD_DEFAULT, sym);
    if (!p) {
        fprintf(stderr, "[llm_register_tool] symbol not found: %s\n", sym);
        return;
    }
    pthread_mutex_lock(&_llm_tool_mu);
    /* Replace existing entry by name. */
    for (size_t i = 0; i < _llm_tool_count; i++) {
        if (strcmp(_llm_tools[i].name, nm) == 0) {
            _llm_tools[i].fn = (llm_tool_fn)p;
            pthread_mutex_unlock(&_llm_tool_mu);
            return;
        }
    }
    if (_llm_tool_count < sizeof(_llm_tools) / sizeof(_llm_tools[0])) {
        _llm_tools[_llm_tool_count].name = el_strdup(nm);
        _llm_tools[_llm_tool_count].fn   = (llm_tool_fn)p;
        _llm_tool_count++;
    }
    pthread_mutex_unlock(&_llm_tool_mu);
}

/* Serialize the El `tools` list into the JSON `tools:[...]` field expected
 * by the Anthropic API. Each tool is an ElMap with name/description/
 * input_schema. input_schema is treated as either a JSON-object string
 * (passed through verbatim) or a missing field (substitute {}).         */
static void llm_emit_tools_json(JsonBuf* b, el_val_t tools_list) {
    jb_putc(b, '[');
    ElList* lst = (ElList*)(uintptr_t)tools_list;
    int64_t n = lst ? lst->length : 0;
    for (int64_t i = 0; i < n; i++) {
        if (i > 0) jb_putc(b, ',');
        ElMap* tm = as_map(lst->elems[i]);
        const char* name = "";
        const char* desc = "";
        const char* schema = "{}";
        if (tm) {
            for (int64_t k = 0; k < tm->count; k++) {
                const char* key = EL_CSTR(tm->keys[k]);
                const char* val = EL_CSTR(tm->values[k]);
                if (!key || !val) continue;
                if (strcmp(key, "name") == 0) name = val;
                else if (strcmp(key, "description") == 0) desc = val;
                else if (strcmp(key, "input_schema") == 0) schema = val;
            }
        }
        char* esc_name = json_escape_alloc(name);
        char* esc_desc = json_escape_alloc(desc);
        jb_puts(b, "{\"name\":\"");        jb_puts(b, esc_name);
        jb_puts(b, "\",\"description\":\""); jb_puts(b, esc_desc);
        jb_puts(b, "\",\"input_schema\":");  jb_puts(b, schema && *schema ? schema : "{}");
        jb_putc(b, '}');
        free(esc_name); free(esc_desc);
    }
    jb_putc(b, ']');
}

/* Walk the assistant `content` array and emit each block back into b,
 * preserving the verbatim JSON of every block — used to re-include the
 * assistant turn in the next request.                                 */
static void llm_emit_content_blocks(JsonBuf* b, const char* resp) {
    const char* p = json_find_key(resp, "content");
    jb_putc(b, '[');
    if (!p) { jb_putc(b, ']'); return; }
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') { jb_putc(b, ']'); return; }
    p++;
    int first = 1;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        if (!first) jb_putc(b, ',');
        first = 0;
        size_t n = (size_t)(end - p);
        jb_reserve(b, n);
        memcpy(b->buf + b->len, p, n);
        b->len += n;
        b->buf[b->len] = '\0';
        p = end;
    }
    jb_putc(b, ']');
}

/* Concatenate all "text" blocks from a response. Returns owned string. */
static char* llm_concat_text_blocks(const char* resp) {
    JsonBuf out; jb_init(&out);
    if (!resp) return out.buf;
    const char* p = json_find_key(resp, "content");
    if (!p) return out.buf;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return out.buf;
    p++;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';
        const char* tp = json_find_key(obj, "type");
        if (tp && *tp == '"') {
            JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
            char* tname = jp_parse_string_raw(&jp);
            if (!jp.err && tname && strcmp(tname, "text") == 0) {
                const char* xp = json_find_key(obj, "text");
                if (xp && *xp == '"') {
                    JsonParser jp2 = { .p = xp, .end = xp + strlen(xp), .err = 0 };
                    char* txt = jp_parse_string_raw(&jp2);
                    if (!jp2.err && txt) jb_puts(&out, txt);
                    free(txt);
                }
            }
            free(tname);
        }
        free(obj);
        p = end;
    }
    return out.buf;
}

/* Build tool_result message blocks for every tool_use in a response.
 * Appends to `b` an array element for each tool_use; caller wraps. */
static int llm_build_tool_results(JsonBuf* b, const char* resp) {
    int any = 0;
    const char* p = json_find_key(resp, "content");
    if (!p) return 0;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') return 0;
    p++;
    while (*p && *p != ']') {
        while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
        if (*p != '{') break;
        const char* end = json_skip_value(p);
        size_t n = (size_t)(end - p);
        char* obj = malloc(n + 1);
        memcpy(obj, p, n); obj[n] = '\0';

        const char* tp = json_find_key(obj, "type");
        char* type_s = NULL;
        if (tp && *tp == '"') {
            JsonParser jp = { .p = tp, .end = tp + strlen(tp), .err = 0 };
            type_s = jp_parse_string_raw(&jp);
        }
        if (type_s && strcmp(type_s, "tool_use") == 0) {
            /* Extract id, name, input. */
            char* id_s = NULL; char* name_s = NULL;
            const char* idp = json_find_key(obj, "id");
            if (idp && *idp == '"') {
                JsonParser jp = { .p = idp, .end = idp + strlen(idp), .err = 0 };
                id_s = jp_parse_string_raw(&jp);
            }
            const char* np = json_find_key(obj, "name");
            if (np && *np == '"') {
                JsonParser jp = { .p = np, .end = np + strlen(np), .err = 0 };
                name_s = jp_parse_string_raw(&jp);
            }
            el_val_t input_raw = json_get_raw(EL_STR(obj), EL_STR("input"));
            const char* input_s = EL_CSTR(input_raw);
            if (!input_s || !*input_s) input_s = "{}";

            llm_tool_fn fn = llm_tool_lookup(name_s ? name_s : "");
            char* result = NULL;
            int is_error = 0;
            if (!fn) {
                size_t en = strlen(name_s ? name_s : "(null)") + 64;
                result = malloc(en);
                snprintf(result, en, "{\"error\":\"tool not registered: %s\"}",
                         name_s ? name_s : "(null)");
                is_error = 1;
            } else {
                el_val_t out = fn(EL_STR(input_s));
                const char* os = EL_CSTR(out);
                result = el_strdup(os ? os : "");
            }

            if (any) jb_putc(b, ',');
            char* esc_id = json_escape_alloc(id_s ? id_s : "");
            char* esc_res = json_escape_alloc(result ? result : "");
            jb_puts(b, "{\"type\":\"tool_result\",\"tool_use_id\":\"");
            jb_puts(b, esc_id);
            jb_puts(b, "\",\"content\":\"");
            jb_puts(b, esc_res);
            jb_puts(b, "\"");
            if (is_error) jb_puts(b, ",\"is_error\":true");
            jb_putc(b, '}');
            free(esc_id); free(esc_res); free(result);
            free(id_s); free(name_s);
            any = 1;
        }
        free(type_s);
        free(obj);
        p = end;
    }
    return any;
}

el_val_t llm_call_agentic(el_val_t model, el_val_t system, el_val_t user, el_val_t tools) {
    /* Empty tools list → degrade to plain system call. */
    ElList* tl = (ElList*)(uintptr_t)tools;
    if (!tl || tl->length == 0) {
        return llm_call_system(model, system, user);
    }
    const char* m = llm_resolve_model(EL_CSTR(model));
    const char* sys_p = EL_CSTR(system); if (!sys_p) sys_p = "";
    const char* usr_p = EL_CSTR(user);   if (!usr_p) usr_p = "";

    /* Build the static parts: tools JSON and system prompt — these don't
     * change across iterations.                                         */
    JsonBuf tools_buf; jb_init(&tools_buf);
    llm_emit_tools_json(&tools_buf, tools);
    char* esc_sys = json_escape_alloc(sys_p);

    /* messages array, accumulated as a mutable JSON fragment (no surrounding
     * brackets — emitted at request time).                                 */
    JsonBuf msgs; jb_init(&msgs);
    /* First user message. */
    char* esc_user = json_escape_alloc(usr_p);
    jb_puts(&msgs, "{\"role\":\"user\",\"content\":\"");
    jb_puts(&msgs, esc_user);
    jb_puts(&msgs, "\"}");
    free(esc_user);

    char* last_text = el_strdup("");
    el_val_t final_out = 0;
    int reached_cap = 1;

    for (int iter = 0; iter < 10; iter++) {
        /* Build request body. */
        JsonBuf body; jb_init(&body);
        jb_putc(&body, '{');
        jb_puts(&body, "\"model\":"); jb_emit_escaped(&body, m);
        jb_puts(&body, ",\"max_tokens\":4096");
        if (*sys_p) {
            jb_puts(&body, ",\"system\":\"");
            jb_puts(&body, esc_sys);
            jb_puts(&body, "\"");
        }
        jb_puts(&body, ",\"tools\":");
        jb_puts(&body, tools_buf.buf);
        jb_puts(&body, ",\"messages\":[");
        jb_puts(&body, msgs.buf);
        jb_puts(&body, "]}");

        el_val_t resp_v = llm_request(body.buf);
        free(body.buf);
        const char* resp = EL_CSTR(resp_v);
        llm_record_usage(resp);            /* per-turn token usage (agentic loop) */
        if (!resp || !*resp) {
            final_out = http_error_json("empty response");
            reached_cap = 0;
            break;
        }
        if (resp[0] == '{' && strstr(resp, "\"error\"") &&
            !json_find_key(resp, "content")) {
            final_out = el_wrap_str(el_strdup(resp));
            reached_cap = 0;
            break;
        }

        /* Update last_text from this response. */
        free(last_text);
        last_text = llm_concat_text_blocks(resp);

        /* Inspect stop_reason. */
        el_val_t sr_v = json_get_string(EL_STR(resp), EL_STR("stop_reason"));
        const char* sr = EL_CSTR(sr_v); if (!sr) sr = "";

        if (strcmp(sr, "end_turn") == 0) {
            final_out = el_wrap_str(el_strdup(last_text));
            reached_cap = 0;
            break;
        }
        if (strcmp(sr, "max_tokens") == 0) {
            size_t ln = strlen(last_text) + 16;
            char* out = malloc(ln);
            snprintf(out, ln, "%s\n[truncated]", last_text);
            final_out = el_wrap_str(out);
            reached_cap = 0;
            break;
        }
        if (strcmp(sr, "tool_use") != 0) {
            /* Unexpected stop reason; return the text we have. */
            final_out = el_wrap_str(el_strdup(last_text));
            reached_cap = 0;
            break;
        }

        /* Append the assistant turn (raw content blocks) to messages. */
        JsonBuf ab; jb_init(&ab);
        jb_puts(&ab, ",{\"role\":\"assistant\",\"content\":");
        llm_emit_content_blocks(&ab, resp);
        jb_putc(&ab, '}');
        jb_puts(&msgs, ab.buf);
        free(ab.buf);

        /* Build tool_result message. */
        JsonBuf tr; jb_init(&tr);
        jb_puts(&tr, ",{\"role\":\"user\",\"content\":[");
        int any = llm_build_tool_results(&tr, resp);
        jb_puts(&tr, "]}");
        if (any) {
            jb_puts(&msgs, tr.buf);
        }
        free(tr.buf);
    }

    if (reached_cap) {
        size_t ln = strlen(last_text) + 32;
        char* out = malloc(ln);
        snprintf(out, ln, "[loop_cap_reached]\n%s", last_text);
        final_out = el_wrap_str(out);
    }
    free(last_text);
    free(esc_sys);
    free(tools_buf.buf);
    free(msgs.buf);
    return final_out;
}

/* base64-encode arbitrary bytes (returns owned C string).
 * Internal helper for llm_vision; the public crypto entry point that El
 * programs call is `base64_encode(el_val_t)` defined in the crypto block
 * at the end of this file. */
static char* el_b64_encode_internal(const unsigned char* src, size_t n) {
    static const char tbl[] =
        "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    size_t out_len = 4 * ((n + 2) / 3);
    char* out = malloc(out_len + 1);
    if (!out) return NULL;
    size_t o = 0;
    for (size_t i = 0; i < n;) {
        uint32_t v = 0; int got = 0;
        v |= (uint32_t)src[i++] << 16; got++;
        if (i < n) { v |= (uint32_t)src[i++] << 8;  got++; }
        if (i < n) { v |= (uint32_t)src[i++];        got++; }
        out[o++] = tbl[(v >> 18) & 0x3f];
        out[o++] = tbl[(v >> 12) & 0x3f];
        out[o++] = (got > 1) ? tbl[(v >> 6) & 0x3f] : '=';
        out[o++] = (got > 2) ? tbl[v & 0x3f]        : '=';
    }
    out[o] = '\0';
    return out;
}

el_val_t llm_vision(el_val_t model, el_val_t system, el_val_t prompt, el_val_t image_url_or_b64) {
    const char* m = llm_resolve_model(EL_CSTR(model));
    const char* s = EL_CSTR(system); if (!s) s = "";
    const char* u = EL_CSTR(prompt); if (!u) u = "";
    const char* img = EL_CSTR(image_url_or_b64); if (!img) img = "";

    /* Choose source mode */
    char* image_block = NULL;
    if (strncasecmp(img, "http://", 7) == 0 || strncasecmp(img, "https://", 8) == 0) {
        char* esc_url = json_escape_alloc(img);
        size_t n = strlen(esc_url) + 128;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"url\",\"url\":\"%s\"}}",
                 esc_url);
        free(esc_url);
    } else if (strncmp(img, "data:", 5) == 0) {
        /* Inline data URL: split media-type and base64 */
        const char* semi = strchr(img + 5, ';');
        const char* comma = strchr(img + 5, ',');
        char media[64] = "image/png";
        if (semi && comma && semi < comma) {
            size_t ml = (size_t)(semi - (img + 5));
            if (ml >= sizeof(media)) ml = sizeof(media) - 1;
            memcpy(media, img + 5, ml); media[ml] = '\0';
        }
        const char* b64 = comma ? comma + 1 : "";
        char* esc_media = json_escape_alloc(media);
        char* esc_b64   = json_escape_alloc(b64);
        size_t n = strlen(esc_media) + strlen(esc_b64) + 192;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"base64\","
                 "\"media_type\":\"%s\",\"data\":\"%s\"}}",
                 esc_media, esc_b64);
        free(esc_media); free(esc_b64);
    } else if (*img) {
        /* Treat as file path: read, base64-encode, attach. */
        FILE* f = fopen(img, "rb");
        if (!f) {
            char err[256]; snprintf(err, sizeof(err), "cannot open image: %s", img);
            return http_error_json(err);
        }
        fseek(f, 0, SEEK_END);
        long sz = ftell(f);
        rewind(f);
        if (sz <= 0) { fclose(f); return http_error_json("empty image file"); }
        unsigned char* buf = malloc((size_t)sz);
        if (!buf) { fclose(f); return http_error_json("oom"); }
        size_t got = fread(buf, 1, (size_t)sz, f);
        fclose(f);
        char* b64 = el_b64_encode_internal(buf, got);
        free(buf);
        if (!b64) return http_error_json("base64 encode failed");
        const char* media = "image/png";
        size_t ilen = strlen(img);
        if (ilen >= 4) {
            if (strcasecmp(img + ilen - 4, ".jpg") == 0 ||
                (ilen >= 5 && strcasecmp(img + ilen - 5, ".jpeg") == 0)) media = "image/jpeg";
            else if (strcasecmp(img + ilen - 4, ".gif") == 0) media = "image/gif";
            else if (strcasecmp(img + ilen - 4, ".webp") == 0) media = "image/webp";
        }
        char* esc_b64 = json_escape_alloc(b64); free(b64);
        size_t n = strlen(esc_b64) + 192;
        image_block = malloc(n);
        snprintf(image_block, n,
                 "{\"type\":\"image\",\"source\":{\"type\":\"base64\","
                 "\"media_type\":\"%s\",\"data\":\"%s\"}}",
                 media, esc_b64);
        free(esc_b64);
    }

    char* esc_sys  = json_escape_alloc(s);
    char* esc_user = json_escape_alloc(u);
    JsonBuf b; jb_init(&b);
    jb_putc(&b, '{');
    jb_puts(&b, "\"model\":"); jb_emit_escaped(&b, m);
    jb_puts(&b, ",\"max_tokens\":4096");
    if (*s) {
        jb_puts(&b, ",\"system\":\"");
        jb_puts(&b, esc_sys);
        jb_puts(&b, "\"");
    }
    jb_puts(&b, ",\"messages\":[{\"role\":\"user\",\"content\":[");
    if (image_block) {
        jb_puts(&b, image_block);
        jb_putc(&b, ',');
    }
    jb_puts(&b, "{\"type\":\"text\",\"text\":\"");
    jb_puts(&b, esc_user);
    jb_puts(&b, "\"}]}]}");
    free(esc_sys); free(esc_user); free(image_block);
    el_val_t resp = llm_request(b.buf);
    free(b.buf);
    return llm_extract_text(resp);
}

el_val_t llm_models(void) {
    el_val_t lst = el_list_empty();
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-sonnet-4-5")));
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-opus-4-7")));
    lst = el_list_append(lst, el_wrap_str(el_strdup("claude-haiku-4-5")));
    return lst;
}

/* ── Native VM builtin aliases ──────────────────────────────────────────────
 * El source files use native_* names (El VM builtins).
 * When compiled to C, these map directly to el_* runtime functions. */

el_val_t native_list_get(el_val_t list, el_val_t index) {
    return el_list_get(list, index);
}

el_val_t native_list_len(el_val_t list) {
    return el_list_len(list);
}

el_val_t native_list_append(el_val_t list, el_val_t elem) {
    return el_list_append(list, elem);
}

el_val_t native_list_empty(void) {
    return el_list_empty();
}

el_val_t native_list_clone(el_val_t list) {
    return el_list_clone(list);
}

el_val_t native_string_chars(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    el_val_t result = el_list_empty();
    if (!s) return result;
    while (*s) {
        char buf[2];
        buf[0] = *s;
        buf[1] = '\0';
        result = el_list_append(result, EL_STR(strdup(buf)));
        s++;
    }
    return result;
}

el_val_t native_int_to_str(el_val_t n) {
    return int_to_str(n);
}

/* ── Method-call shorthand aliases ──────────────────────────────────────────
 * Short names that result from the method-call convention:
 *   myList.append(x) → append(myList, x)
 *   myList.len()     → len(myList)
 *   myList.get(i)    → get(myList, i)
 *   myMap.map_get(k) → map_get(myMap, k)
 *   myMap.map_set(k,v) → map_set(myMap, k, v)                              */

el_val_t append(el_val_t list, el_val_t elem)  { return el_list_append(list, elem); }
el_val_t len(el_val_t list)                    { return el_list_len(list); }
el_val_t get(el_val_t list, el_val_t index)    { return el_list_get(list, index); }
el_val_t map_get(el_val_t map, el_val_t key)   { return el_map_get(map, key); }
el_val_t map_set(el_val_t map, el_val_t key, el_val_t value) { return el_map_set(map, key, value); }

/* ── Crypto primitives ──────────────────────────────────────────────────────
 *
 * SHA-256 implementation adapted from Brad Conte's public-domain reference
 * (https://github.com/B-Con/crypto-algorithms/blob/master/sha256.c, public
 * domain per the project's LICENSE). HMAC follows RFC 2104. Base64 encoding
 * follows RFC 4648; the URL-safe variant uses the alphabet from §5 of the
 * RFC and omits padding (per JWT/JWS convention).
 *
 * Self-contained: no OpenSSL/libcrypto dependency. The runtime keeps its
 * existing `-lcurl -lpthread -ldl -lm` link line.
 *
 * Binary outputs (sha256_bytes, hmac_sha256_bytes) tag their buffer with a
 * magic header so base64_encode/base64url_encode can recover the exact byte
 * length even when the payload contains embedded NULs. Plain C strings
 * (without the header) fall back to strlen(), preserving the existing API
 * shape for normal text inputs.                                              */

/* Magic-header for length-tagged binary buffers. Layout:
 *   [ uint32_t magic = EL_MAGIC_BIN ][ uint32_t length ][ data... ][ \0 ]
 * The returned el_val_t points at `data`, so consumers that strlen() it still
 * get a sensible (though possibly truncated) view. el_bin_len() recovers the
 * true length by sniffing the 8 bytes preceding the pointer.
 *
 * Magic value chosen with high MSB so it cannot collide with printable ASCII
 * (the same discriminator pattern used by EL_MAGIC_LIST / EL_MAGIC_MAP). */
#define EL_MAGIC_BIN 0xE1B17EAFu

typedef struct {
    uint32_t magic;
    uint32_t length;
} el_bin_hdr_t;

/* Allocate a length-tagged binary buffer; returns pointer to the data area. */
static unsigned char* el_bin_alloc(size_t len) {
    el_bin_hdr_t* hdr = (el_bin_hdr_t*)malloc(sizeof(el_bin_hdr_t) + len + 1);
    if (!hdr) { fputs("el_runtime: out of memory (bin)\n", stderr); exit(1); }
    hdr->magic = EL_MAGIC_BIN;
    hdr->length = (uint32_t)len;
    unsigned char* data = (unsigned char*)(hdr + 1);
    data[len] = '\0';   /* keep NUL-terminated for accidental strlen calls */
    return data;
}

/* Recover length from a possibly-tagged buffer. Returns 1 if tagged. */
static int el_bin_lookup(const void* p, size_t* out_len) {
    if (!p) { *out_len = 0; return 0; }
    /* Avoid reading off the front of a page on tiny pointers (e.g. NULs
     * passed in as int-cast values). 4096 is a safe lower bound on any
     * platform we target.                                              */
    /* Reads BACKWARD, so the HEADER address is what must be validated -- and a
     * 4096 floor alone let sha256_hex(50000) through to a SIGSEGV. */
    const char* hp = (const char*)p - sizeof(el_bin_hdr_t);
    if (!looks_like_heap_obj((el_val_t)(uintptr_t)hp)) return 0;
    const el_bin_hdr_t* hdr = (const el_bin_hdr_t*)hp;
    if (hdr->magic != EL_MAGIC_BIN) return 0;
    *out_len = hdr->length;
    return 1;
}

/* Effective input length: tagged length if present, else strlen. */
static size_t el_input_len(const char* s) {
    size_t n;
    if (el_bin_lookup(s, &n)) return n;
    /* The FALLBACK is the hazard, not the tagged lookup. A NULL check does not
     * establish that a slot is a pointer: el_val_t carries integers too, so
     * strlen() on `sha256_hex(50000)` walks address 50000. Guarding the tagged
     * path alone left this untouched and the SIGSEGV unchanged -- measured. */
    if (!looks_like_heap_obj((el_val_t)(uintptr_t)s)) return 0;
    return strlen(s);
}

/* ─── SHA-256 (Brad Conte / public domain) ──────────────────────────────── */

typedef struct {
    unsigned char data[64];
    uint32_t      datalen;
    uint64_t      bitlen;
    uint32_t      state[8];
} el_sha256_ctx_t;

static const uint32_t el_sha256_k[64] = {
    0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2
};

#define EL_ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n))))
#define EL_CH(x,y,z)  (((x) & (y)) ^ (~(x) & (z)))
#define EL_MAJ(x,y,z) (((x) & (y)) ^ ((x) & (z)) ^ ((y) & (z)))
#define EL_EP0(x)     (EL_ROTR(x,2)  ^ EL_ROTR(x,13) ^ EL_ROTR(x,22))
#define EL_EP1(x)     (EL_ROTR(x,6)  ^ EL_ROTR(x,11) ^ EL_ROTR(x,25))
#define EL_SIG0(x)    (EL_ROTR(x,7)  ^ EL_ROTR(x,18) ^ ((x) >> 3))
#define EL_SIG1(x)    (EL_ROTR(x,17) ^ EL_ROTR(x,19) ^ ((x) >> 10))

static void el_sha256_transform(el_sha256_ctx_t* ctx, const unsigned char* data) {
    uint32_t a, b, c, d, e, f, g, h, t1, t2, m[64];
    int i, j;
    for (i = 0, j = 0; i < 16; ++i, j += 4) {
        m[i] = ((uint32_t)data[j] << 24) | ((uint32_t)data[j + 1] << 16)
             | ((uint32_t)data[j + 2] << 8) | (uint32_t)data[j + 3];
    }
    for (; i < 64; ++i) {
        m[i] = EL_SIG1(m[i-2]) + m[i-7] + EL_SIG0(m[i-15]) + m[i-16];
    }
    a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3];
    e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7];
    for (i = 0; i < 64; ++i) {
        t1 = h + EL_EP1(e) + EL_CH(e,f,g) + el_sha256_k[i] + m[i];
        t2 = EL_EP0(a) + EL_MAJ(a,b,c);
        h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
    }
    ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d;
    ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h;
}

static void el_sha256_init(el_sha256_ctx_t* ctx) {
    ctx->datalen = 0;
    ctx->bitlen = 0;
    ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85;
    ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a;
    ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c;
    ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19;
}

static void el_sha256_update(el_sha256_ctx_t* ctx, const unsigned char* data, size_t len) {
    for (size_t i = 0; i < len; ++i) {
        ctx->data[ctx->datalen++] = data[i];
        if (ctx->datalen == 64) {
            el_sha256_transform(ctx, ctx->data);
            ctx->bitlen += 512;
            ctx->datalen = 0;
        }
    }
}

static void el_sha256_final(el_sha256_ctx_t* ctx, unsigned char hash[32]) {
    uint32_t i = ctx->datalen;
    if (ctx->datalen < 56) {
        ctx->data[i++] = 0x80;
        while (i < 56) ctx->data[i++] = 0x00;
    } else {
        ctx->data[i++] = 0x80;
        while (i < 64) ctx->data[i++] = 0x00;
        el_sha256_transform(ctx, ctx->data);
        memset(ctx->data, 0, 56);
    }
    ctx->bitlen += (uint64_t)ctx->datalen * 8;
    ctx->data[63] = (unsigned char)( ctx->bitlen        & 0xff);
    ctx->data[62] = (unsigned char)((ctx->bitlen >> 8)  & 0xff);
    ctx->data[61] = (unsigned char)((ctx->bitlen >> 16) & 0xff);
    ctx->data[60] = (unsigned char)((ctx->bitlen >> 24) & 0xff);
    ctx->data[59] = (unsigned char)((ctx->bitlen >> 32) & 0xff);
    ctx->data[58] = (unsigned char)((ctx->bitlen >> 40) & 0xff);
    ctx->data[57] = (unsigned char)((ctx->bitlen >> 48) & 0xff);
    ctx->data[56] = (unsigned char)((ctx->bitlen >> 56) & 0xff);
    el_sha256_transform(ctx, ctx->data);
    for (i = 0; i < 4; ++i) {
        hash[i]      = (ctx->state[0] >> (24 - i * 8)) & 0xff;
        hash[i +  4] = (ctx->state[1] >> (24 - i * 8)) & 0xff;
        hash[i +  8] = (ctx->state[2] >> (24 - i * 8)) & 0xff;
        hash[i + 12] = (ctx->state[3] >> (24 - i * 8)) & 0xff;
        hash[i + 16] = (ctx->state[4] >> (24 - i * 8)) & 0xff;
        hash[i + 20] = (ctx->state[5] >> (24 - i * 8)) & 0xff;
        hash[i + 24] = (ctx->state[6] >> (24 - i * 8)) & 0xff;
        hash[i + 28] = (ctx->state[7] >> (24 - i * 8)) & 0xff;
    }
}

static void el_sha256_oneshot(const unsigned char* data, size_t len, unsigned char out[32]) {
    el_sha256_ctx_t c;
    el_sha256_init(&c);
    el_sha256_update(&c, data, len);
    el_sha256_final(&c, out);
}

/* ─── HMAC-SHA-256 (RFC 2104) ───────────────────────────────────────────── */

static void el_hmac_sha256(const unsigned char* key, size_t key_len,
                           const unsigned char* msg, size_t msg_len,
                           unsigned char out[32]) {
    unsigned char k[64];
    unsigned char k_ipad[64];
    unsigned char k_opad[64];
    unsigned char inner[32];

    if (key_len > 64) {
        el_sha256_oneshot(key, key_len, k);
        memset(k + 32, 0, 32);
    } else {
        memcpy(k, key, key_len);
        memset(k + key_len, 0, 64 - key_len);
    }
    for (int i = 0; i < 64; ++i) {
        k_ipad[i] = k[i] ^ 0x36;
        k_opad[i] = k[i] ^ 0x5c;
    }
    {
        el_sha256_ctx_t c;
        el_sha256_init(&c);
        el_sha256_update(&c, k_ipad, 64);
        el_sha256_update(&c, msg, msg_len);
        el_sha256_final(&c, inner);
    }
    {
        el_sha256_ctx_t c;
        el_sha256_init(&c);
        el_sha256_update(&c, k_opad, 64);
        el_sha256_update(&c, inner, 32);
        el_sha256_final(&c, out);
    }
}

/* ─── Hex helper ────────────────────────────────────────────────────────── */

static el_val_t el_hex_encode(const unsigned char* data, size_t len) {
    static const char digits[] = "0123456789abcdef";
    char* out = el_strbuf(len * 2);
    for (size_t i = 0; i < len; ++i) {
        out[i * 2]     = digits[(data[i] >> 4) & 0xf];
        out[i * 2 + 1] = digits[ data[i]       & 0xf];
    }
    out[len * 2] = '\0';
    return el_wrap_str(out);
}

/* ─── Base64 (RFC 4648) ─────────────────────────────────────────────────── */

static const char el_b64_std_alphabet[64] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
static const char el_b64_url_alphabet[64] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";

el_val_t el_base64_encode_n(const unsigned char* data, size_t len, int url_safe) {
    const char* alphabet = url_safe ? el_b64_url_alphabet : el_b64_std_alphabet;
    /* Standard form is padded to multiple of 4; URL-safe omits padding. */
    size_t out_cap = ((len + 2) / 3) * 4 + 1;
    char* out = el_strbuf(out_cap);
    size_t i = 0, j = 0;
    while (i + 3 <= len) {
        uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8) | (uint32_t)data[i+2];
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        out[j++] = alphabet[(v >>  6) & 0x3f];
        out[j++] = alphabet[ v        & 0x3f];
        i += 3;
    }
    size_t rem = len - i;
    if (rem == 1) {
        uint32_t v = (uint32_t)data[i] << 16;
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        if (!url_safe) { out[j++] = '='; out[j++] = '='; }
    } else if (rem == 2) {
        uint32_t v = ((uint32_t)data[i] << 16) | ((uint32_t)data[i+1] << 8);
        out[j++] = alphabet[(v >> 18) & 0x3f];
        out[j++] = alphabet[(v >> 12) & 0x3f];
        out[j++] = alphabet[(v >>  6) & 0x3f];
        if (!url_safe) { out[j++] = '='; }
    }
    out[j] = '\0';
    return el_wrap_str(out);
}

/* fs_read_b64_chunk — binary-safe windowed file read: read up to `length`
 * raw bytes starting at byte `offset` from `path` and return them base64-
 * encoded (RFC 4648, standard alphabet, padded). The raw bytes are read into
 * a local C buffer and base64-encoded directly here — they never pass
 * through an el_val_t string as raw bytes, so embedded NUL bytes (common in
 * real PCM audio) never hit a strlen()-based code path. This mirrors the
 * existing llm_vision() image-attachment path (read file -> base64 in C ->
 * hand back a plain-ASCII string) and the http_*_to_file() rationale above:
 * bypass the string wrapper entirely for the part that must stay binary.
 *
 * Returns "" if the path can't be opened, offset is negative or past EOF,
 * or length <= 0. A short final chunk (less than `length` bytes remaining)
 * is returned truncated to what's actually on disk — never padded/invented. */
el_val_t fs_read_b64_chunk(el_val_t pathv, el_val_t offsetv, el_val_t lengthv) {
    const char* path = EL_CSTR(pathv);
    int64_t offset = (int64_t)offsetv;
    int64_t length = (int64_t)lengthv;
    if (!path || !*path || offset < 0 || length <= 0) return el_wrap_str(el_strdup(""));
    FILE* f = fopen(path, "rb");
    if (!f) return el_wrap_str(el_strdup(""));
    fseek(f, 0, SEEK_END);
    long sz = ftell(f);
    if (sz < 0 || offset >= sz) { fclose(f); return el_wrap_str(el_strdup("")); }
    fseek(f, (long)offset, SEEK_SET);
    long remain = sz - (long)offset;
    size_t want = (size_t)(((int64_t)remain < length) ? remain : length);
    unsigned char* buf = malloc(want > 0 ? want : 1);
    if (!buf) { fclose(f); return el_wrap_str(el_strdup("")); }
    size_t got = fread(buf, 1, want, f);
    fclose(f);
    el_val_t out = el_base64_encode_n(buf, got, /*url_safe=*/0);
    free(buf);
    return out;
}

/* Decode either alphabet — accepts both '+/' and '-_' transparently, and
 * tolerates missing padding (which JWTs typically omit). Whitespace is
 * skipped for robustness. Invalid characters cause the decode to stop and
 * the partial result so far is returned. */
static el_val_t el_base64_decode_any(const char* in) {
    if (!in) {
        unsigned char* empty = el_bin_alloc(0);
        return EL_STR((char*)empty);
    }
    size_t in_len = strlen(in);
    /* Worst case: 3 output bytes per 4 input chars, +1 NUL slack. */
    unsigned char* out = el_bin_alloc(((in_len + 3) / 4) * 3 + 1);

    int8_t lut[256];
    for (int i = 0; i < 256; ++i) lut[i] = -1;
    for (int i = 0; i < 64; ++i) lut[(unsigned char)el_b64_std_alphabet[i]] = (int8_t)i;
    /* Allow URL-safe characters too (so one decoder handles both forms). */
    lut[(unsigned char)'-'] = 62;
    lut[(unsigned char)'_'] = 63;

    uint32_t buf  = 0;
    int      bits = 0;
    size_t   o    = 0;
    for (size_t i = 0; i < in_len; ++i) {
        unsigned char c = (unsigned char)in[i];
        if (c == '=' || c == '\r' || c == '\n' || c == ' ' || c == '\t') continue;
        int8_t v = lut[c];
        if (v < 0) break;   /* invalid char — stop */
        buf = (buf << 6) | (uint32_t)v;
        bits += 6;
        if (bits >= 8) {
            bits -= 8;
            out[o++] = (unsigned char)((buf >> bits) & 0xff);
        }
    }
    /* Patch the length header to the actual decoded length. */
    el_bin_hdr_t* hdr = (el_bin_hdr_t*)((char*)out - sizeof(el_bin_hdr_t));
    hdr->length = (uint32_t)o;
    out[o] = '\0';
    return EL_STR((char*)out);
}

/* ─── Public crypto entry points ────────────────────────────────────────── */

el_val_t el_sha256_bytes_n(const unsigned char* data, size_t len) {
    unsigned char* out = el_bin_alloc(32);
    el_sha256_oneshot(data, len, out);
    return EL_STR((char*)out);
}

el_val_t sha256_hex(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    unsigned char digest[32];
    el_sha256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
    return el_hex_encode(digest, 32);
}

el_val_t sha256_bytes(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_sha256_bytes_n((const unsigned char*)(s ? s : ""), n);
}

el_val_t hmac_sha256_hex(el_val_t key, el_val_t message) {
    const char* k = EL_CSTR(key);
    const char* m = EL_CSTR(message);
    size_t kn = el_input_len(k);
    size_t mn = el_input_len(m);
    unsigned char mac[32];
    el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
                   (const unsigned char*)(m ? m : ""), mn,
                   mac);
    return el_hex_encode(mac, 32);
}

el_val_t hmac_sha256_bytes(el_val_t key, el_val_t message) {
    const char* k = EL_CSTR(key);
    const char* m = EL_CSTR(message);
    size_t kn = el_input_len(k);
    size_t mn = el_input_len(m);
    unsigned char* out = el_bin_alloc(32);
    el_hmac_sha256((const unsigned char*)(k ? k : ""), kn,
                   (const unsigned char*)(m ? m : ""), mn,
                   out);
    return EL_STR((char*)out);
}

el_val_t base64_encode(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/0);
}

el_val_t base64url_encode(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    return el_base64_encode_n((const unsigned char*)(s ? s : ""), n, /*url_safe=*/1);
}

el_val_t base64_decode(el_val_t input) {
    return el_base64_decode_any(EL_CSTR(input));
}

el_val_t base64url_decode(el_val_t input) {
    return el_base64_decode_any(EL_CSTR(input));
}

/* ── Post-quantum cryptography (liboqs + OpenSSL) ───────────────────────────
 *
 * Algorithm choices (per CNSA 2.0 / NIST PQ guidance, as of 2024):
 *   Signatures: CRYSTALS-Dilithium-3   (NIST security level 3, balanced)
 *   KEM:        CRYSTALS-Kyber-768     (NIST security level 3)
 *   Hash:       SHA3-256 (Keccak)      (PQ-aware protocols favour SHA3 over SHA2)
 *   Hybrid:     X25519 || Kyber-768, combined via HKDF-SHA256
 *
 * Why hybrid: Kyber is new. X25519 has 20+ years of analysis. Hybridizing
 * preserves classical security if Kyber falls to a future cryptanalytic
 * advance, and preserves PQ security if X25519 falls to a quantum adversary.
 * "Recordable now, decryptable later" already threatens long-lived classical
 * key exchange — the only safe move for keys protecting durable doctrine
 * (CGI lineage, KindredGrants, Principal-CGI covenants) is to encapsulate
 * with PQ today, even if the classical leg is what the wire shows.
 *
 * Compile-time detection: when <oqs/oqs.h> is unavailable the pq_* functions
 * compile to stubs that return a JSON error envelope. SHA3-256 stays
 * available regardless (it's implemented inline, no liboqs dep). This lets
 * the runtime build cleanly on dev machines without liboqs while production
 * gets the full PQ stack.                                                    */

/* ─── SHA3-256 (Keccak, FIPS 202) ────────────────────────────────────────────
 * Inline reference implementation. ~120 LoC, no external dependency.
 * rate=1088 bits, capacity=512 bits, output=256 bits, padding=0x06.          */

static const uint64_t el_keccak_rc[24] = {
    0x0000000000000001ULL, 0x0000000000008082ULL, 0x800000000000808aULL,
    0x8000000080008000ULL, 0x000000000000808bULL, 0x0000000080000001ULL,
    0x8000000080008081ULL, 0x8000000000008009ULL, 0x000000000000008aULL,
    0x0000000000000088ULL, 0x0000000080008009ULL, 0x000000008000000aULL,
    0x000000008000808bULL, 0x800000000000008bULL, 0x8000000000008089ULL,
    0x8000000000008003ULL, 0x8000000000008002ULL, 0x8000000000000080ULL,
    0x000000000000800aULL, 0x800000008000000aULL, 0x8000000080008081ULL,
    0x8000000000008080ULL, 0x0000000080000001ULL, 0x8000000080008008ULL
};

static const unsigned el_keccak_rho[24] = {
     1,  3,  6, 10, 15, 21, 28, 36, 45, 55,  2, 14,
    27, 41, 56,  8, 25, 43, 62, 18, 39, 61, 20, 44
};

static const unsigned el_keccak_pi[24] = {
    10,  7, 11, 17, 18,  3,  5, 16,  8, 21, 24,  4,
    15, 23, 19, 13, 12,  2, 20, 14, 22,  9,  6,  1
};

#define EL_ROTL64(x, n) (((x) << (n)) | ((x) >> (64 - (n))))

static void el_keccak_f1600(uint64_t s[25]) {
    for (int round = 0; round < 24; ++round) {
        uint64_t bc[5], t;
        for (int i = 0; i < 5; ++i)
            bc[i] = s[i] ^ s[i+5] ^ s[i+10] ^ s[i+15] ^ s[i+20];
        for (int i = 0; i < 5; ++i) {
            t = bc[(i+4) % 5] ^ EL_ROTL64(bc[(i+1) % 5], 1);
            for (int j = 0; j < 25; j += 5) s[j+i] ^= t;
        }
        t = s[1];
        for (int i = 0; i < 24; ++i) {
            int j = el_keccak_pi[i];
            bc[0] = s[j];
            s[j]  = EL_ROTL64(t, el_keccak_rho[i]);
            t = bc[0];
        }
        for (int j = 0; j < 25; j += 5) {
            for (int i = 0; i < 5; ++i) bc[i] = s[j+i];
            for (int i = 0; i < 5; ++i)
                s[j+i] = bc[i] ^ ((~bc[(i+1) % 5]) & bc[(i+2) % 5]);
        }
        s[0] ^= el_keccak_rc[round];
    }
}

static void el_sha3_256_oneshot(const unsigned char* data, size_t len,
                                unsigned char out[32]) {
    uint64_t st[25] = {0};
    unsigned char* sb = (unsigned char*)st;
    const size_t rate = 136;   /* 1088 bits / 8 */
    size_t i = 0;
    while (len - i >= rate) {
        for (size_t k = 0; k < rate; ++k) sb[k] ^= data[i + k];
        el_keccak_f1600(st);
        i += rate;
    }
    size_t rem = len - i;
    for (size_t k = 0; k < rem; ++k) sb[k] ^= data[i + k];
    sb[rem]      ^= 0x06;   /* SHA3 domain-separation byte */
    sb[rate - 1] ^= 0x80;   /* final-block padding bit (high bit of last byte) */
    el_keccak_f1600(st);
    memcpy(out, sb, 32);
}

el_val_t sha3_256_hex(el_val_t input) {
    const char* s = EL_CSTR(input);
    size_t n = el_input_len(s);
    unsigned char digest[32];
    el_sha3_256_oneshot((const unsigned char*)(s ? s : ""), n, digest);
    return el_hex_encode(digest, 32);
}

/* ─── Hex decode helper ─────────────────────────────────────────────────────
 * Returns a length-tagged binary buffer (so embedded NULs survive); on
 * odd-length / invalid input returns NULL with *out_len = 0. Caller is
 * responsible for emitting the error envelope.                               */

static int el_hex_nibble(char c) {
    if (c >= '0' && c <= '9') return c - '0';
    if (c >= 'a' && c <= 'f') return c - 'a' + 10;
    if (c >= 'A' && c <= 'F') return c - 'A' + 10;
    return -1;
}

__attribute__((unused))
static unsigned char* el_hex_decode(const char* s, size_t* out_len) {
    *out_len = 0;
    if (!s) return NULL;
    size_t n = strlen(s);
    if (n & 1) return NULL;
    size_t blen = n / 2;
    unsigned char* out = el_bin_alloc(blen);
    for (size_t i = 0; i < blen; ++i) {
        int hi = el_hex_nibble(s[i*2]);
        int lo = el_hex_nibble(s[i*2 + 1]);
        if (hi < 0 || lo < 0) return NULL;
        out[i] = (unsigned char)((hi << 4) | lo);
    }
    *out_len = blen;
    return out;
}

/* JSON error envelope reused across all PQ entry points. */
static el_val_t pq_error(const char* msg) {
    return http_error_json(msg);
}

#if __has_include(<oqs/oqs.h>)
#include <oqs/oqs.h>
#define EL_HAVE_LIBOQS 1
#else
#define EL_HAVE_LIBOQS 0
#endif

#if EL_HAVE_LIBOQS && __has_include(<openssl/evp.h>)
#include <openssl/evp.h>
#define EL_HAVE_OPENSSL 1
#else
#define EL_HAVE_OPENSSL 0
#endif

#if !EL_HAVE_LIBOQS

/* ─── Stubs (liboqs unavailable) ───────────────────────────────────────────
 * Each entry point returns the same JSON error so callers can inspect a
 * single canonical "missing primitive" string. pq_verify is the lone
 * exception — verifying without liboqs simply means "not verified", so
 * returning Bool false (0) keeps the type contract intact.                  */

#define EL_PQ_NO_LIB "liboqs not linked, post-quantum primitives unavailable"

el_val_t pq_keygen_signature(void)                            { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_sign(el_val_t sk, el_val_t msg)                   { (void)sk; (void)msg; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_verify(el_val_t pk, el_val_t msg, el_val_t sig)   { (void)pk; (void)msg; (void)sig; return EL_INT(0); }
el_val_t pq_kem_keygen(void)                                  { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_encaps(el_val_t pk)                           { (void)pk; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_kem_decaps(el_val_t sk, el_val_t ct)              { (void)sk; (void)ct; return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_keygen(void)                               { return pq_error(EL_PQ_NO_LIB); }
el_val_t pq_hybrid_handshake(el_val_t pub)                    { (void)pub; return pq_error(EL_PQ_NO_LIB); }

#else  /* EL_HAVE_LIBOQS */

/* ─── Dilithium-3 / ML-DSA-65 signatures ────────────────────────────────
 *
 * NIST FIPS 204 standardized CRYSTALS-Dilithium as ML-DSA. ML-DSA-65 is the
 * FIPS form of what we historically called Dilithium-3 — same algorithm
 * family, same security level, identical key/sig sizes, but with a couple
 * of standardization-driven tweaks (e.g. domain separation in the message
 * binding). liboqs 0.12+ exposes both names; 0.15+ retired the legacy
 * "Dilithium" constants in favour of "ML-DSA". We prefer ML-DSA-65 if the
 * header advertises it, fall back to Dilithium-3 otherwise. Anything
 * already signed with the older constant remains verifiable against that
 * same constant — callers should pin the algorithm via the OQS_SIG handle's
 * method_name field if they need to interoperate with archival signatures. */

#if defined(OQS_SIG_alg_ml_dsa_65)
#  define EL_DILITHIUM_ALG OQS_SIG_alg_ml_dsa_65
#elif defined(OQS_SIG_alg_dilithium_3)
#  define EL_DILITHIUM_ALG OQS_SIG_alg_dilithium_3
#else
#  define EL_DILITHIUM_ALG "ML-DSA-65"   /* string fallback; runtime probe catches misconfig */
#endif

el_val_t pq_keygen_signature(void) {
    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
    unsigned char* pk = (unsigned char*)malloc(sig->length_public_key);
    unsigned char* sk = (unsigned char*)malloc(sig->length_secret_key);
    if (!pk || !sk) { free(pk); free(sk); OQS_SIG_free(sig); return pq_error("oom"); }
    if (OQS_SIG_keypair(sig, pk, sk) != OQS_SUCCESS) {
        free(pk); free(sk); OQS_SIG_free(sig);
        return pq_error("dilithium-3 keypair generation failed");
    }
    el_val_t pk_hex = el_hex_encode(pk, sig->length_public_key);
    el_val_t sk_hex = el_hex_encode(sk, sig->length_secret_key);
    OQS_MEM_secure_free(sk, sig->length_secret_key);
    free(pk);

    const char* pks = EL_CSTR(pk_hex);
    const char* sks = EL_CSTR(sk_hex);
    char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
    sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
    OQS_SIG_free(sig);
    return el_wrap_str(buf);
}

el_val_t pq_sign(el_val_t secret_key_hex, el_val_t message) {
    size_t sk_len = 0;
    unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
    if (!sk) return pq_error("invalid hex in secret_key");

    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return pq_error("OQS_SIG_new(dilithium-3) failed");
    if (sk_len != sig->length_secret_key) {
        OQS_SIG_free(sig);
        return pq_error("secret_key length mismatch for dilithium-3");
    }

    const char* msg = EL_CSTR(message);
    size_t msg_len = el_input_len(msg);
    unsigned char* signature = (unsigned char*)malloc(sig->length_signature);
    size_t signature_len = sig->length_signature;
    if (!signature) { OQS_SIG_free(sig); return pq_error("oom"); }

    if (OQS_SIG_sign(sig, signature, &signature_len,
                     (const unsigned char*)(msg ? msg : ""), msg_len, sk) != OQS_SUCCESS) {
        free(signature); OQS_SIG_free(sig);
        return pq_error("dilithium-3 sign failed");
    }
    el_val_t sig_hex = el_hex_encode(signature, signature_len);
    free(signature); OQS_SIG_free(sig);
    return sig_hex;
}

el_val_t pq_verify(el_val_t public_key_hex, el_val_t message, el_val_t signature_hex) {
    size_t pk_len = 0, sig_len = 0;
    unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
    unsigned char* signature = el_hex_decode(EL_CSTR(signature_hex), &sig_len);
    if (!pk || !signature) return EL_INT(0);

    OQS_SIG* sig = OQS_SIG_new(EL_DILITHIUM_ALG);
    if (!sig) return EL_INT(0);
    if (pk_len != sig->length_public_key) { OQS_SIG_free(sig); return EL_INT(0); }

    const char* msg = EL_CSTR(message);
    size_t msg_len = el_input_len(msg);
    OQS_STATUS rc = OQS_SIG_verify(sig,
                                   (const unsigned char*)(msg ? msg : ""), msg_len,
                                   signature, sig_len, pk);
    OQS_SIG_free(sig);
    return (rc == OQS_SUCCESS) ? EL_INT(1) : EL_INT(0);
}

/* ─── Kyber-768 / ML-KEM-768 KEM ────────────────────────────────────────
 *
 * NIST FIPS 203 standardized CRYSTALS-Kyber as ML-KEM. ML-KEM-768 is the
 * FIPS form of what we historically called Kyber-768. Same situation as
 * Dilithium → ML-DSA: prefer the standardized constant, fall back to the
 * legacy name. liboqs 0.15.0 still exposes OQS_KEM_alg_kyber_768; the
 * algorithm is identical at the wire level to ML-KEM-768 except for FIPS
 * domain-separation tweaks, so the two ciphertexts/keys are NOT
 * cross-compatible. Pin the constant for archival material. */

#if defined(OQS_KEM_alg_ml_kem_768)
#  define EL_KYBER_ALG OQS_KEM_alg_ml_kem_768
#elif defined(OQS_KEM_alg_kyber_768)
#  define EL_KYBER_ALG OQS_KEM_alg_kyber_768
#else
#  define EL_KYBER_ALG "ML-KEM-768"
#endif

el_val_t pq_kem_keygen(void) {
    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    unsigned char* pk = (unsigned char*)malloc(kem->length_public_key);
    unsigned char* sk = (unsigned char*)malloc(kem->length_secret_key);
    if (!pk || !sk) { free(pk); free(sk); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_keypair(kem, pk, sk) != OQS_SUCCESS) {
        free(pk); free(sk); OQS_KEM_free(kem);
        return pq_error("kyber-768 keypair generation failed");
    }
    el_val_t pk_hex = el_hex_encode(pk, kem->length_public_key);
    el_val_t sk_hex = el_hex_encode(sk, kem->length_secret_key);
    OQS_MEM_secure_free(sk, kem->length_secret_key);
    free(pk);

    const char* pks = EL_CSTR(pk_hex);
    const char* sks = EL_CSTR(sk_hex);
    char* buf = el_strbuf(strlen(pks) + strlen(sks) + 64);
    sprintf(buf, "{\"public_key\":\"%s\",\"secret_key\":\"%s\"}", pks, sks);
    OQS_KEM_free(kem);
    return el_wrap_str(buf);
}

el_val_t pq_kem_encaps(el_val_t public_key_hex) {
    size_t pk_len = 0;
    unsigned char* pk = el_hex_decode(EL_CSTR(public_key_hex), &pk_len);
    if (!pk) return pq_error("invalid hex in public_key");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (pk_len != kem->length_public_key) {
        OQS_KEM_free(kem);
        return pq_error("public_key length mismatch for kyber-768");
    }
    unsigned char* ct = (unsigned char*)malloc(kem->length_ciphertext);
    unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!ct || !ss) { free(ct); free(ss); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_encaps(kem, ct, ss, pk) != OQS_SUCCESS) {
        free(ct); free(ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 encapsulation failed");
    }
    el_val_t ct_hex = el_hex_encode(ct, kem->length_ciphertext);
    el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
    free(ct);
    OQS_MEM_secure_free(ss, kem->length_shared_secret);

    const char* cts = EL_CSTR(ct_hex);
    const char* sss = EL_CSTR(ss_hex);
    char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
    sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
    OQS_KEM_free(kem);
    return el_wrap_str(buf);
}

el_val_t pq_kem_decaps(el_val_t secret_key_hex, el_val_t ciphertext_hex) {
    size_t sk_len = 0, ct_len = 0;
    unsigned char* sk = el_hex_decode(EL_CSTR(secret_key_hex), &sk_len);
    unsigned char* ct = el_hex_decode(EL_CSTR(ciphertext_hex), &ct_len);
    if (!sk || !ct) return pq_error("invalid hex in inputs");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (sk_len != kem->length_secret_key || ct_len != kem->length_ciphertext) {
        OQS_KEM_free(kem);
        return pq_error("input length mismatch for kyber-768");
    }
    unsigned char* ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!ss) { OQS_KEM_free(kem); return pq_error("oom"); }
    /* Kyber is IND-CCA via Fujisaki-Okamoto: decaps always returns *some*
     * shared_secret even on tampered ciphertext (an implicit-rejection value
     * derived from sk). Protocols MUST confirm the shared_secret matches via
     * a subsequent step (e.g. AEAD tag, key-confirmation MAC) — do not
     * assume decaps success implies authenticity. */
    if (OQS_KEM_decaps(kem, ss, ct, sk) != OQS_SUCCESS) {
        free(ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 decapsulation failed");
    }
    el_val_t ss_hex = el_hex_encode(ss, kem->length_shared_secret);
    OQS_MEM_secure_free(ss, kem->length_shared_secret);
    OQS_KEM_free(kem);
    return ss_hex;
}

/* ─── Hybrid handshake (X25519 + Kyber-768, HKDF-SHA256 combined) ─────── */

#if !EL_HAVE_OPENSSL

el_val_t pq_hybrid_keygen(void) {
    return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}
el_val_t pq_hybrid_handshake(el_val_t pub) {
    (void)pub;
    return pq_error("hybrid handshake requires OpenSSL (X25519); rebuild with -lcrypto");
}

#else  /* EL_HAVE_OPENSSL */

/* HKDF-SHA256 (RFC 5869) — Extract+Expand. Reuses the inline HMAC-SHA256
 * already in this file. Empty salt → 32 zero bytes per the RFC.            */
static void el_hkdf_sha256(const unsigned char* salt, size_t salt_len,
                           const unsigned char* ikm,  size_t ikm_len,
                           const unsigned char* info, size_t info_len,
                           unsigned char* out, size_t out_len) {
    unsigned char zero_salt[32] = {0};
    if (salt_len == 0) { salt = zero_salt; salt_len = 32; }
    unsigned char prk[32];
    el_hmac_sha256(salt, salt_len, ikm, ikm_len, prk);

    unsigned char t[32];
    size_t produced = 0;
    unsigned char counter = 1;
    unsigned char* buf = (unsigned char*)malloc(32 + info_len + 1);
    if (!buf) { fputs("el_runtime: hkdf oom\n", stderr); return; }
    while (produced < out_len) {
        size_t off = 0;
        if (counter > 1) { memcpy(buf, t, 32); off = 32; }
        if (info && info_len) { memcpy(buf + off, info, info_len); off += info_len; }
        buf[off++] = counter;
        el_hmac_sha256(prk, 32, buf, off, t);
        size_t chunk = (out_len - produced > 32) ? 32 : (out_len - produced);
        memcpy(out + produced, t, chunk);
        produced += chunk;
        counter++;
    }
    free(buf);
}

/* X25519 keygen via OpenSSL EVP. Returns 1 on success.
 * Fills pk[32] and sk[32] (raw X25519 byte strings, no DER wrapper). */
static int el_x25519_keygen(unsigned char pk[32], unsigned char sk[32]) {
    EVP_PKEY_CTX* pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_X25519, NULL);
    if (!pctx) return 0;
    if (EVP_PKEY_keygen_init(pctx) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
    EVP_PKEY* key = NULL;
    if (EVP_PKEY_keygen(pctx, &key) != 1) { EVP_PKEY_CTX_free(pctx); return 0; }
    EVP_PKEY_CTX_free(pctx);

    size_t plen = 32, slen = 32;
    if (EVP_PKEY_get_raw_public_key (key, pk, &plen) != 1 || plen != 32) {
        EVP_PKEY_free(key); return 0;
    }
    if (EVP_PKEY_get_raw_private_key(key, sk, &slen) != 1 || slen != 32) {
        EVP_PKEY_free(key); return 0;
    }
    EVP_PKEY_free(key);
    return 1;
}

/* X25519 ECDH: derive 32-byte shared secret from local sk and remote pk. */
static int el_x25519_derive(const unsigned char sk[32], const unsigned char rpk[32],
                            unsigned char ss[32]) {
    EVP_PKEY* my  = EVP_PKEY_new_raw_private_key(EVP_PKEY_X25519, NULL, sk, 32);
    EVP_PKEY* rem = EVP_PKEY_new_raw_public_key (EVP_PKEY_X25519, NULL, rpk, 32);
    if (!my || !rem) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
    EVP_PKEY_CTX* dctx = EVP_PKEY_CTX_new(my, NULL);
    if (!dctx) { EVP_PKEY_free(my); EVP_PKEY_free(rem); return 0; }
    int ok = 0;
    size_t out_len = 32;
    if (EVP_PKEY_derive_init(dctx) == 1 &&
        EVP_PKEY_derive_set_peer(dctx, rem) == 1 &&
        EVP_PKEY_derive(dctx, ss, &out_len) == 1 &&
        out_len == 32) ok = 1;
    EVP_PKEY_CTX_free(dctx);
    EVP_PKEY_free(my);
    EVP_PKEY_free(rem);
    return ok;
}

/* Hybrid wire layout (binary form, before hex encode):
 *   public_key  = x25519_pub (32) || kyber_pub (1184)         → 1216 bytes
 *   secret_key  = x25519_sec (32) || kyber_sec (2400)         → 2432 bytes
 *   ciphertext  = ephem_x25519_pub (32) || kyber_ct (1088)    → 1120 bytes
 *   shared_secret = HKDF-SHA256(x25519_ss || kyber_ss, info="el-pq-hybrid-v1", 32 bytes)
 * The keygen result also exposes the four component hex fields for callers
 * that prefer to handle the legs independently.                              */

el_val_t pq_hybrid_keygen(void) {
    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");

    unsigned char xpk[32], xsk[32];
    if (!el_x25519_keygen(xpk, xsk)) {
        OQS_KEM_free(kem);
        return pq_error("X25519 keygen failed");
    }

    unsigned char* kpk = (unsigned char*)malloc(kem->length_public_key);
    unsigned char* ksk = (unsigned char*)malloc(kem->length_secret_key);
    if (!kpk || !ksk) { free(kpk); free(ksk); OQS_KEM_free(kem); return pq_error("oom"); }
    if (OQS_KEM_keypair(kem, kpk, ksk) != OQS_SUCCESS) {
        free(kpk); free(ksk); OQS_KEM_free(kem);
        return pq_error("kyber-768 keypair generation failed");
    }

    size_t pub_len = 32 + kem->length_public_key;
    size_t sec_len = 32 + kem->length_secret_key;
    unsigned char* pub_buf = (unsigned char*)malloc(pub_len);
    unsigned char* sec_buf = (unsigned char*)malloc(sec_len);
    if (!pub_buf || !sec_buf) {
        free(pub_buf); free(sec_buf); free(kpk);
        OQS_MEM_secure_free(ksk, kem->length_secret_key);
        OQS_KEM_free(kem); return pq_error("oom");
    }
    memcpy(pub_buf,      xpk, 32); memcpy(pub_buf + 32, kpk, kem->length_public_key);
    memcpy(sec_buf,      xsk, 32); memcpy(sec_buf + 32, ksk, kem->length_secret_key);

    el_val_t x_pub_hex = el_hex_encode(xpk, 32);
    el_val_t x_sec_hex = el_hex_encode(xsk, 32);
    el_val_t k_pub_hex = el_hex_encode(kpk, kem->length_public_key);
    el_val_t k_sec_hex = el_hex_encode(ksk, kem->length_secret_key);
    el_val_t pub_hex   = el_hex_encode(pub_buf, pub_len);
    el_val_t sec_hex   = el_hex_encode(sec_buf, sec_len);

    OQS_MEM_secure_free(ksk, kem->length_secret_key);
    free(kpk); free(pub_buf); free(sec_buf);
    OQS_KEM_free(kem);
    memset(xsk, 0, 32);   /* best-effort wipe of stack copy */

    const char* xph  = EL_CSTR(x_pub_hex);
    const char* xsh  = EL_CSTR(x_sec_hex);
    const char* kph  = EL_CSTR(k_pub_hex);
    const char* ksh  = EL_CSTR(k_sec_hex);
    const char* pubh = EL_CSTR(pub_hex);
    const char* sech = EL_CSTR(sec_hex);

    char* buf = el_strbuf(strlen(xph) + strlen(xsh) + strlen(kph) + strlen(ksh)
                          + strlen(pubh) + strlen(sech) + 256);
    sprintf(buf,
        "{\"x25519_pub\":\"%s\",\"x25519_sec\":\"%s\","
         "\"kyber_pub\":\"%s\",\"kyber_sec\":\"%s\","
         "\"public_key\":\"%s\",\"secret_key\":\"%s\"}",
        xph, xsh, kph, ksh, pubh, sech);
    return el_wrap_str(buf);
}

/* Initiator-side handshake. Caller supplies the responder's combined public
 * key (x25519_pub || kyber_pub, hex-encoded). The runtime:
 *   1. Generates an ephemeral X25519 keypair, runs ECDH against the
 *      responder's static x25519_pub.
 *   2. Runs Kyber-768 encaps against the responder's kyber_pub → kyber_ct,
 *      kyber_ss.
 *   3. Combined shared = HKDF-SHA256(salt="", ikm = x25519_ss || kyber_ss,
 *                                    info = "el-pq-hybrid-v1", L = 32).
 *   4. Returns combined ciphertext (= ephemeral_x25519_pub || kyber_ct) and
 *      the derived shared_secret.
 *
 * Responder side composition (intentionally not a separate runtime fn —
 * trivial to express in El given pq_kem_decaps + a future x25519_derive
 * primitive): split the ciphertext into ephem_xpk (32) and kyber_ct, run
 * X25519(static_xsk, ephem_xpk) and pq_kem_decaps(static_kyber_sk, kyber_ct),
 * then HKDF-SHA256 with the same salt/info to recover the same shared_secret.
 * If a separate x25519 entry point becomes valuable, add `pq_hybrid_open`
 * here taking (secret_key_combined, ciphertext_combined). */
el_val_t pq_hybrid_handshake(el_val_t remote_pub_combined) {
    size_t pub_len = 0;
    unsigned char* rpub = el_hex_decode(EL_CSTR(remote_pub_combined), &pub_len);
    if (!rpub) return pq_error("invalid hex in remote_pub_combined");

    OQS_KEM* kem = OQS_KEM_new(EL_KYBER_ALG);
    if (!kem) return pq_error("OQS_KEM_new(kyber-768) failed");
    if (pub_len != 32 + kem->length_public_key) {
        OQS_KEM_free(kem);
        return pq_error("remote_pub_combined length mismatch (expected x25519_pub || kyber_pub)");
    }

    unsigned char e_xpk[32], e_xsk[32], x_ss[32];
    if (!el_x25519_keygen(e_xpk, e_xsk)) {
        OQS_KEM_free(kem);
        return pq_error("X25519 ephemeral keygen failed");
    }
    if (!el_x25519_derive(e_xsk, rpub, x_ss)) {
        memset(e_xsk, 0, 32);
        OQS_KEM_free(kem);
        return pq_error("X25519 derive failed");
    }
    memset(e_xsk, 0, 32);   /* ephemeral; not needed after derive */

    unsigned char* k_ct = (unsigned char*)malloc(kem->length_ciphertext);
    unsigned char* k_ss = (unsigned char*)malloc(kem->length_shared_secret);
    if (!k_ct || !k_ss) {
        free(k_ct); free(k_ss); OQS_KEM_free(kem);
        return pq_error("oom");
    }
    if (OQS_KEM_encaps(kem, k_ct, k_ss, rpub + 32) != OQS_SUCCESS) {
        free(k_ct); free(k_ss); OQS_KEM_free(kem);
        return pq_error("kyber-768 encapsulation failed");
    }

    /* HKDF combine: ikm = x_ss || k_ss. */
    size_t ikm_len = 32 + kem->length_shared_secret;
    unsigned char* ikm = (unsigned char*)malloc(ikm_len);
    if (!ikm) {
        free(k_ct); OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
        OQS_KEM_free(kem);
        return pq_error("oom");
    }
    memcpy(ikm,      x_ss, 32);
    memcpy(ikm + 32, k_ss, kem->length_shared_secret);
    unsigned char combined[32];
    static const char info_str[] = "el-pq-hybrid-v1";
    el_hkdf_sha256(NULL, 0, ikm, ikm_len,
                   (const unsigned char*)info_str, sizeof(info_str) - 1,
                   combined, 32);

    memset(x_ss, 0, 32);
    OQS_MEM_secure_free(k_ss, kem->length_shared_secret);
    OQS_MEM_secure_free(ikm,  ikm_len);

    /* Combined ciphertext = ephemeral_x25519_pub || kyber_ct. */
    size_t ct_len = 32 + kem->length_ciphertext;
    unsigned char* combined_ct = (unsigned char*)malloc(ct_len);
    if (!combined_ct) { free(k_ct); OQS_KEM_free(kem); return pq_error("oom"); }
    memcpy(combined_ct,      e_xpk, 32);
    memcpy(combined_ct + 32, k_ct,  kem->length_ciphertext);
    free(k_ct);
    OQS_KEM_free(kem);

    el_val_t ct_hex = el_hex_encode(combined_ct, ct_len);
    el_val_t ss_hex = el_hex_encode(combined, 32);
    free(combined_ct);
    memset(combined, 0, 32);

    const char* cts = EL_CSTR(ct_hex);
    const char* sss = EL_CSTR(ss_hex);
    char* buf = el_strbuf(strlen(cts) + strlen(sss) + 64);
    sprintf(buf, "{\"ciphertext\":\"%s\",\"shared_secret\":\"%s\"}", cts, sss);
    return el_wrap_str(buf);
}

#endif  /* EL_HAVE_OPENSSL */
#endif  /* EL_HAVE_LIBOQS */

/* ─── AEAD: AES-256-GCM ────────────────────────────────────────────────────
 *
 * Symmetric authenticated encryption used to wrap envelopes once a shared
 * secret has been derived from the KEM (Kyber-768 / hybrid). The El surface
 * is intentionally narrow:
 *
 *   aead_encrypt(key_hex, plaintext)
 *     → {"nonce":"<24 hex>","ciphertext":"<...hex including 16-byte tag>"}
 *
 *   aead_decrypt(key_hex, nonce_hex, ciphertext_hex)
 *     → plaintext String, or "" on auth failure / malformed input
 *
 * Conventions:
 *   - key_hex must decode to exactly 32 bytes (AES-256). Callers that hold
 *     a longer KEM shared_secret should normalize via SHA3-256(ss) → 32 bytes
 *     before passing it in. (Kyber-768's shared_secret is already 32 bytes,
 *     but keeping this contract explicit lets the El side be agnostic.)
 *   - nonce is a fresh 12-byte random value drawn from the OS CSPRNG. Caller
 *     never picks the nonce — eliminates the GCM nonce-reuse footgun entirely.
 *   - tag is the standard 16 bytes, appended to ciphertext per RFC 5116.
 *     `ciphertext` field is therefore (plaintext_len + 16) bytes, hex-encoded.
 *   - No associated data (AAD). If we later need bound metadata, add a
 *     length-prefixed AAD argument and bump the envelope version tag.
 *
 * Failure mode:
 *   aead_encrypt returns http_error_json(...) on input/system failure.
 *   aead_decrypt returns the empty string on ANY failure (including auth-tag
 *   mismatch). Callers MUST check for "" before using the result.            */

#if !__has_include(<openssl/evp.h>)

el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext) {
    (void)key_hex; (void)plaintext;
    return http_error_json("aead_encrypt requires OpenSSL (libcrypto); rebuild with -lcrypto");
}
el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex) {
    (void)key_hex; (void)nonce_hex; (void)ciphertext_hex;
    return el_wrap_str(el_strdup(""));
}

#else  /* OpenSSL available */

#include <openssl/evp.h>
#include <openssl/rand.h>

el_val_t aead_encrypt(el_val_t key_hex, el_val_t plaintext) {
    size_t key_len = 0;
    unsigned char* key = el_hex_decode(EL_CSTR(key_hex), &key_len);
    if (!key) return http_error_json("invalid hex in key");
    if (key_len != 32) return http_error_json("aead key must be 32 bytes (64 hex chars) for AES-256-GCM");

    const char* pt = EL_CSTR(plaintext);
    size_t pt_len = el_input_len(pt);
    if (!pt) pt = "";

    unsigned char nonce[12];
    if (RAND_bytes(nonce, 12) != 1) return http_error_json("OS CSPRNG failed (RAND_bytes)");

    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
    if (!ctx) return http_error_json("EVP_CIPHER_CTX_new failed");

    if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1) {
        EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm init failed");
    }
    if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1) {
        EVP_CIPHER_CTX_free(ctx); return http_error_json("set ivlen failed");
    }
    if (EVP_EncryptInit_ex(ctx, NULL, NULL, key, nonce) != 1) {
        EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm key/iv init failed");
    }

    /* GCM ciphertext is the same length as plaintext; we append a 16-byte
     * authentication tag for AEAD semantics. Allocate plaintext_len + 16. */
    unsigned char* ct = (unsigned char*)malloc(pt_len + 16);
    if (!ct) { EVP_CIPHER_CTX_free(ctx); return http_error_json("oom"); }
    int outlen = 0, total = 0;
    if (EVP_EncryptUpdate(ctx, ct, &outlen, (const unsigned char*)pt, (int)pt_len) != 1) {
        free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm update failed");
    }
    total += outlen;
    if (EVP_EncryptFinal_ex(ctx, ct + total, &outlen) != 1) {
        free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm final failed");
    }
    total += outlen;
    if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, ct + total) != 1) {
        free(ct); EVP_CIPHER_CTX_free(ctx); return http_error_json("aes-256-gcm get tag failed");
    }
    EVP_CIPHER_CTX_free(ctx);

    el_val_t nonce_hex_v = el_hex_encode(nonce, 12);
    el_val_t ct_hex_v    = el_hex_encode(ct, (size_t)total + 16);
    free(ct);

    const char* nh = EL_CSTR(nonce_hex_v);
    const char* ch = EL_CSTR(ct_hex_v);
    char* buf = el_strbuf(strlen(nh) + strlen(ch) + 48);
    sprintf(buf, "{\"nonce\":\"%s\",\"ciphertext\":\"%s\"}", nh, ch);
    return el_wrap_str(buf);
}

el_val_t aead_decrypt(el_val_t key_hex, el_val_t nonce_hex, el_val_t ciphertext_hex) {
    size_t key_len = 0, nonce_len = 0, ct_len = 0;
    unsigned char* key   = el_hex_decode(EL_CSTR(key_hex),        &key_len);
    unsigned char* nonce = el_hex_decode(EL_CSTR(nonce_hex),      &nonce_len);
    unsigned char* ct    = el_hex_decode(EL_CSTR(ciphertext_hex), &ct_len);
    if (!key || !nonce || !ct)            return el_wrap_str(el_strdup(""));
    if (key_len != 32 || nonce_len != 12) return el_wrap_str(el_strdup(""));
    if (ct_len < 16)                      return el_wrap_str(el_strdup(""));

    size_t body_len = ct_len - 16;
    const unsigned char* tag  = ct + body_len;

    EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new();
    if (!ctx) return el_wrap_str(el_strdup(""));

    if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL) != 1 ||
        EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1   ||
        EVP_DecryptInit_ex(ctx, NULL, NULL, key, nonce) != 1) {
        EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
    }

    unsigned char* pt = (unsigned char*)malloc(body_len + 1);
    if (!pt) { EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup("")); }
    int outlen = 0, total = 0;
    if (EVP_DecryptUpdate(ctx, pt, &outlen, ct, (int)body_len) != 1) {
        free(pt); EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
    }
    total += outlen;
    /* Set expected tag before final — GCM's final step is where auth happens. */
    if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, (void*)tag) != 1) {
        free(pt); EVP_CIPHER_CTX_free(ctx); return el_wrap_str(el_strdup(""));
    }
    int rc = EVP_DecryptFinal_ex(ctx, pt + total, &outlen);
    EVP_CIPHER_CTX_free(ctx);
    if (rc != 1) {
        /* Auth failure or padding/length mismatch. Return empty so callers
         * cannot accidentally treat tampered ciphertext as a valid message. */
        free(pt);
        return el_wrap_str(el_strdup(""));
    }
    total += outlen;
    pt[total] = '\0';

    /* Copy into the el arena so the caller-visible string outlives this fn. */
    char* out = el_strbuf((size_t)total);
    memcpy(out, pt, (size_t)total);
    out[total] = '\0';
    free(pt);
    return el_wrap_str(out);
}

#endif  /* __has_include(<openssl/evp.h>) */

/* ────────────────────────────────────────────────────────────────────────────
 * OTLP/HTTP observability — logs, traces, metrics
 *
 * Design goals:
 *  - Zero blocking on the request path. Producers append to in-memory
 *    ring buffers; a single worker thread flushes to the OTLP endpoint.
 *  - Drop-on-failure semantics. If the endpoint is unreachable or slow,
 *    we drop telemetry rather than back-pressure into the request handler.
 *  - Best-effort serialization. Each record is pre-serialized as JSON when
 *    the El program calls the primitive; the worker just batches.
 *  - Configuration via env vars:
 *      OTLP_ENDPOINT       e.g. https://alloy.neuralplatform.ai:4318
 *      OTEL_SERVICE_NAME   e.g. neuron-web (default: argv[0] basename)
 *      OTEL_SERVICE_VERSION (default: "0.0.0")
 *      OTEL_RESOURCE_ATTRS  comma-sep k=v pairs (optional)
 *
 * Wire format: OTLP/HTTP JSON. Three endpoints:
 *      POST {endpoint}/v1/logs    — log records
 *      POST {endpoint}/v1/traces  — spans
 *      POST {endpoint}/v1/metrics — counter/gauge points
 *
 * El programs see four primitives:
 *      trace_span_start(name) -> SpanHandle      (just a string id)
 *      trace_span_end(handle)                    (computes duration, queues)
 *      emit_log(level, msg, fields_json)         (queues a log record)
 *      emit_metric(name, value, tags_json)       (queues a counter increment)
 * ────────────────────────────────────────────────────────────────────────────
 */

#define OTLP_BUF_CAP       4096   /* per-buffer ring size */
#define OTLP_FLUSH_MS      2000   /* flush every 2s */
#define OTLP_BATCH_MAX     200    /* up to 200 records per POST */

typedef struct {
    char*  data;       /* malloc'd JSON fragment for this record */
} OtlpRec;

typedef struct {
    OtlpRec  ring[OTLP_BUF_CAP];
    size_t   head;     /* next write slot */
    size_t   tail;     /* next read slot */
    pthread_mutex_t mu;
} OtlpQueue;

static OtlpQueue _otlp_logs   = { .mu = PTHREAD_MUTEX_INITIALIZER };
static OtlpQueue _otlp_traces = { .mu = PTHREAD_MUTEX_INITIALIZER };
static OtlpQueue _otlp_metrics = { .mu = PTHREAD_MUTEX_INITIALIZER };

static char*  _otlp_endpoint = NULL;       /* e.g. https://alloy.neuralplatform.ai:4318 */
static char*  _otlp_service_name = NULL;
static char*  _otlp_service_version = NULL;
static int    _otlp_initialized = 0;
static pthread_t _otlp_worker_thread;

/* enqueue — returns 1 if accepted, 0 if dropped (full buffer or no endpoint) */
static int otlp_enqueue(OtlpQueue* q, const char* json) {
    if (!_otlp_endpoint || !json) return 0;
    pthread_mutex_lock(&q->mu);
    size_t next_head = (q->head + 1) % OTLP_BUF_CAP;
    if (next_head == q->tail) {
        /* buffer full — drop oldest */
        free(q->ring[q->tail].data);
        q->ring[q->tail].data = NULL;
        q->tail = (q->tail + 1) % OTLP_BUF_CAP;
    }
    q->ring[q->head].data = strdup(json);
    q->head = next_head;
    pthread_mutex_unlock(&q->mu);
    return 1;
}

/* drain — copies up to OTLP_BATCH_MAX items into a comma-joined string,
 * caller must free the result. Returns NULL if queue is empty. */
static char* otlp_drain(OtlpQueue* q) {
    pthread_mutex_lock(&q->mu);
    if (q->head == q->tail) { pthread_mutex_unlock(&q->mu); return NULL; }
    /* compute total length */
    size_t total = 0, count = 0;
    size_t i = q->tail;
    while (i != q->head && count < OTLP_BATCH_MAX) {
        if (q->ring[i].data) total += strlen(q->ring[i].data) + 1; /* +1 for comma */
        i = (i + 1) % OTLP_BUF_CAP;
        count++;
    }
    char* out = malloc(total + 4);
    if (!out) { pthread_mutex_unlock(&q->mu); return NULL; }
    out[0] = '\0';
    size_t off = 0;
    i = q->tail;
    count = 0;
    while (i != q->head && count < OTLP_BATCH_MAX) {
        if (q->ring[i].data) {
            size_t l = strlen(q->ring[i].data);
            if (off > 0) { out[off++] = ','; }
            memcpy(out + off, q->ring[i].data, l);
            off += l;
            free(q->ring[i].data);
            q->ring[i].data = NULL;
        }
        i = (i + 1) % OTLP_BUF_CAP;
        count++;
    }
    out[off] = '\0';
    q->tail = i;
    pthread_mutex_unlock(&q->mu);
    return out;
}

/* Build resource block once (service.name, service.version, host.name) */
static char* otlp_resource_block(void) {
    static char cached[1024];
    static int built = 0;
    if (built) return cached;
    char host[256] = "unknown";
    gethostname(host, sizeof(host) - 1);
    snprintf(cached, sizeof(cached),
        "{\"attributes\":["
          "{\"key\":\"service.name\",\"value\":{\"stringValue\":\"%s\"}},"
          "{\"key\":\"service.version\",\"value\":{\"stringValue\":\"%s\"}},"
          "{\"key\":\"host.name\",\"value\":{\"stringValue\":\"%s\"}}"
        "]}",
        _otlp_service_name ? _otlp_service_name : "el-app",
        _otlp_service_version ? _otlp_service_version : "0.0.0",
        host);
    built = 1;
    return cached;
}

/* Best-effort POST. Drops on any error. */
static void otlp_post(const char* path, const char* body) {
    if (!_otlp_endpoint || !body || !*body) return;
    char url[1024];
    snprintf(url, sizeof(url), "%s%s", _otlp_endpoint, path);
    CURL* c = curl_easy_init();
    if (!c) return;
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_POST, 1L);
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
    curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, 3000L);
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, NULL);  /* discard response */
    curl_easy_perform(c);
    curl_slist_free_all(h);
    curl_easy_cleanup(c);
}

/* Flush worker — runs forever until process exits */
static void* otlp_worker(void* arg) {
    (void)arg;
    while (1) {
        struct timespec ts = { OTLP_FLUSH_MS / 1000, (OTLP_FLUSH_MS % 1000) * 1000000L };
        nanosleep(&ts, NULL);

        char* logs = otlp_drain(&_otlp_logs);
        if (logs && *logs) {
            char body[OTLP_BUF_CAP * 8];
            int n = snprintf(body, sizeof(body),
                "{\"resourceLogs\":[{\"resource\":%s,"
                "\"scopeLogs\":[{\"scope\":{\"name\":\"el-runtime\"},"
                "\"logRecords\":[%s]}]}]}",
                otlp_resource_block(), logs);
            if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/logs", body);
        }
        free(logs);

        char* traces = otlp_drain(&_otlp_traces);
        if (traces && *traces) {
            char body[OTLP_BUF_CAP * 8];
            int n = snprintf(body, sizeof(body),
                "{\"resourceSpans\":[{\"resource\":%s,"
                "\"scopeSpans\":[{\"scope\":{\"name\":\"el-runtime\"},"
                "\"spans\":[%s]}]}]}",
                otlp_resource_block(), traces);
            if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/traces", body);
        }
        free(traces);

        char* metrics = otlp_drain(&_otlp_metrics);
        if (metrics && *metrics) {
            char body[OTLP_BUF_CAP * 8];
            int n = snprintf(body, sizeof(body),
                "{\"resourceMetrics\":[{\"resource\":%s,"
                "\"scopeMetrics\":[{\"scope\":{\"name\":\"el-runtime\"},"
                "\"metrics\":[%s]}]}]}",
                otlp_resource_block(), metrics);
            if (n > 0 && n < (int)sizeof(body)) otlp_post("/v1/metrics", body);
        }
        free(metrics);
    }
    return NULL;
}

/* Initialize OTLP — called lazily on first emit. Idempotent. */
static void otlp_lazy_init(void) {
    if (_otlp_initialized) return;
    static pthread_mutex_t once_mu = PTHREAD_MUTEX_INITIALIZER;
    pthread_mutex_lock(&once_mu);
    if (_otlp_initialized) { pthread_mutex_unlock(&once_mu); return; }

    const char* ep = getenv("OTLP_ENDPOINT");
    if (!ep || !*ep) {
        _otlp_initialized = 1;
        pthread_mutex_unlock(&once_mu);
        return;
    }
    _otlp_endpoint = strdup(ep);
    /* trim trailing slash */
    size_t l = strlen(_otlp_endpoint);
    if (l > 0 && _otlp_endpoint[l - 1] == '/') _otlp_endpoint[l - 1] = '\0';

    const char* svc = getenv("OTEL_SERVICE_NAME");
    _otlp_service_name = strdup(svc && *svc ? svc : "el-app");
    const char* ver = getenv("OTEL_SERVICE_VERSION");
    _otlp_service_version = strdup(ver && *ver ? ver : "0.0.0");

    pthread_create(&_otlp_worker_thread, NULL, otlp_worker, NULL);
    pthread_detach(_otlp_worker_thread);
    _otlp_initialized = 1;
    pthread_mutex_unlock(&once_mu);
}

/* JSON-escape a string into out_buf. Returns chars written (excluding null). */
static size_t otlp_json_escape(const char* in, char* out, size_t out_cap) {
    size_t o = 0;
    for (size_t i = 0; in[i] && o + 8 < out_cap; i++) {
        unsigned char c = (unsigned char)in[i];
        if (c == '"')      { out[o++] = '\\'; out[o++] = '"'; }
        else if (c == '\\'){ out[o++] = '\\'; out[o++] = '\\'; }
        else if (c == '\n'){ out[o++] = '\\'; out[o++] = 'n'; }
        else if (c == '\r'){ out[o++] = '\\'; out[o++] = 'r'; }
        else if (c == '\t'){ out[o++] = '\\'; out[o++] = 't'; }
        else if (c < 0x20) { o += snprintf(out + o, out_cap - o, "\\u%04x", c); }
        else               { out[o++] = (char)c; }
    }
    out[o] = '\0';
    return o;
}

/* ── Public El primitives ─────────────────────────────────────────────────── */

/* emit_log(level, msg, fields_json) — fields_json is a JSON object string or "" */
el_val_t emit_log(el_val_t level_v, el_val_t msg_v, el_val_t fields_v) {
    otlp_lazy_init();
    if (!_otlp_endpoint) return EL_INT(0);
    const char* level = EL_CSTR(level_v); if (!level) level = "INFO";
    const char* msg = EL_CSTR(msg_v); if (!msg) msg = "";
    const char* fields = EL_CSTR(fields_v); if (!fields) fields = "";
    /* Map El level names to OTLP severity numbers */
    int sev_num = 9; /* INFO */
    if (strcmp(level, "TRACE") == 0) sev_num = 1;
    else if (strcmp(level, "DEBUG") == 0) sev_num = 5;
    else if (strcmp(level, "INFO") == 0) sev_num = 9;
    else if (strcmp(level, "WARN") == 0 || strcmp(level, "WARNING") == 0) sev_num = 13;
    else if (strcmp(level, "ERROR") == 0) sev_num = 17;
    else if (strcmp(level, "FATAL") == 0) sev_num = 21;
    char esc_msg[2048]; otlp_json_escape(msg, esc_msg, sizeof(esc_msg));
    /* unix nanos */
    struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
    long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
    char rec[4096];
    int n = snprintf(rec, sizeof(rec),
        "{\"timeUnixNano\":\"%lld\",\"severityNumber\":%d,"
        "\"severityText\":\"%s\","
        "\"body\":{\"stringValue\":\"%s\"}%s%s}",
        now_nano, sev_num, level, esc_msg,
        (fields && *fields) ? ",\"attributes\":" : "",
        (fields && *fields) ? fields : "");
    if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_logs, rec);
    return EL_INT(1);
}

/* emit_metric(name, value, tags_json) — Sum (counter) data point. tags_json
 * is a JSON array of {key, value} pairs or empty string. */
el_val_t emit_metric(el_val_t name_v, el_val_t value_v, el_val_t tags_v) {
    otlp_lazy_init();
    if (!_otlp_endpoint) return EL_INT(0);
    const char* name = EL_CSTR(name_v); if (!name) name = "unknown";
    int64_t val = (int64_t)value_v;
    const char* tags = EL_CSTR(tags_v); if (!tags) tags = "";
    char esc_name[256]; otlp_json_escape(name, esc_name, sizeof(esc_name));
    struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
    long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
    char rec[4096];
    int n = snprintf(rec, sizeof(rec),
        "{\"name\":\"%s\",\"sum\":{\"aggregationTemporality\":2,\"isMonotonic\":true,"
        "\"dataPoints\":[{\"asInt\":\"%lld\","
        "\"timeUnixNano\":\"%lld\""
        "%s%s}]}}",
        esc_name, (long long)val, now_nano,
        (tags && *tags) ? ",\"attributes\":" : "",
        (tags && *tags) ? tags : "");
    if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_metrics, rec);
    return EL_INT(1);
}

/* trace_span_start(name) — returns a span handle (string of "traceid:spanid:start_nano:name") */
el_val_t trace_span_start(el_val_t name_v) {
    otlp_lazy_init();
    const char* name = EL_CSTR(name_v); if (!name) name = "span";
    /* generate 16-byte trace id and 8-byte span id */
    static _Thread_local int seeded = 0;
    if (!seeded) { srand((unsigned int)(uintptr_t)pthread_self() ^ (unsigned int)time(NULL)); seeded = 1; }
    char tid[33], sid[17];
    for (int i = 0; i < 32; i++) tid[i] = "0123456789abcdef"[rand() & 0xF];
    tid[32] = '\0';
    for (int i = 0; i < 16; i++) sid[i] = "0123456789abcdef"[rand() & 0xF];
    sid[16] = '\0';
    struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
    long long now_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
    char* handle = malloc(strlen(name) + 80);
    if (!handle) return EL_STR("");
    sprintf(handle, "%s:%s:%lld:%s", tid, sid, now_nano, name);
    el_arena_track(handle);
    return EL_STR(handle);
}

/* trace_span_end(handle) — emits the span with computed duration */
el_val_t trace_span_end(el_val_t handle_v) {
    otlp_lazy_init();
    if (!_otlp_endpoint) return EL_INT(0);
    const char* h = EL_CSTR(handle_v); if (!h) return EL_INT(0);
    /* parse "tid:sid:start_nano:name" */
    char tid[64], sid[32], rest[1024];
    long long start_nano = 0;
    if (sscanf(h, "%63[^:]:%31[^:]:%lld:%1023[^\n]", tid, sid, &start_nano, rest) != 4) return EL_INT(0);
    struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts);
    long long end_nano = (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
    char esc_name[1024]; otlp_json_escape(rest, esc_name, sizeof(esc_name));
    char rec[4096];
    int n = snprintf(rec, sizeof(rec),
        "{\"traceId\":\"%s\",\"spanId\":\"%s\","
        "\"name\":\"%s\","
        "\"kind\":1,"
        "\"startTimeUnixNano\":\"%lld\","
        "\"endTimeUnixNano\":\"%lld\","
        "\"status\":{\"code\":1}}",
        tid, sid, esc_name, start_nano, end_nano);
    if (n > 0 && n < (int)sizeof(rec)) otlp_enqueue(&_otlp_traces, rec);
    return EL_INT(1);
}

/* Convenience: emit a one-shot timed event (emit start+end immediately).
 * For El programs that want point events with duration baked in. */
el_val_t emit_event(el_val_t name_v, el_val_t duration_ms_v) {
    otlp_lazy_init();
    if (!_otlp_endpoint) return EL_INT(0);
    const char* name = EL_CSTR(name_v); if (!name) name = "event";
    int64_t dur_ms = (int64_t)duration_ms_v;
    el_val_t h = trace_span_start(EL_STR((char*)name));
    /* fudge start to be (now - duration) */
    (void)dur_ms;
    return trace_span_end(h);
}


/* ── DHARMA runtime additions ────────────────────────────────────────────────
 *
 * Functions required by the dharma registry service. Added here so the
 * released el_runtime.c includes them without requiring dharma to bundle
 * its own stubs.
 *
 * Functions added:
 *   list_len          — alias for el_list_len (used in handlers.el)
 *   list_get          — alias for el_list_get  (used in handlers.el)
 *   json_array_push   — append a pre-encoded JSON element to a JSON array string
 *   now_millis        — milliseconds since Unix epoch (alias for time_now)
 *   unix_timestamp_ms — same as now_millis (alias)
 *   time_now_ms       — same as now_millis (alias)
 *   log_info          — stderr structured log at INFO level
 *   log_warn          — stderr structured log at WARN level
 *   config            — reads a config value from the environment
 *   http_patch        — HTTP PATCH with JSON Content-Type
 *   http_post_engram  — HTTP POST with optional X-API-Key header
 *   http_get_engram   — HTTP GET  with optional X-API-Key header
 *   str_to_bytes      — encode a string as a JSON array of byte values
 *   bytes_to_str      — decode a JSON array of byte values back to a string
 *   hash_sha256       — SHA-256 hex digest of a string
 */

/* list_len — return the number of elements in a list. */
el_val_t list_len(el_val_t list) {
    return el_list_len(list);
}

/* list_get — return the element at index i in a list. */
el_val_t list_get(el_val_t list, el_val_t index) {
    return el_list_get(list, index);
}

/* json_array_push — append element (a pre-encoded JSON fragment, e.g. "\"foo\""
 * or "42") to the JSON array string arr. Returns a new JSON array string.
 * Example: json_array_push("[]", "\"alice\"") -> "[\"alice\"]"
 *          json_array_push("[\"alice\"]", "\"bob\"") -> "[\"alice\",\"bob\"]" */
el_val_t json_array_push(el_val_t arr_v, el_val_t elem_v) {
    const char* arr  = EL_CSTR(arr_v);
    const char* elem = EL_CSTR(elem_v);
    if (!arr  || !*arr)  arr  = "[]";
    if (!elem || !*elem) elem = "null";

    /* Trim whitespace, find the closing ']'. */
    const char* p = arr;
    while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r') p++;
    if (*p != '[') {
        /* Not an array — return a single-element array. */
        size_t n = strlen(elem) + 4;
        char* out = el_strbuf(n);
        snprintf(out, n, "[%s]", elem);
        return el_wrap_str(out);
    }
    size_t arr_len  = strlen(arr);
    size_t elem_len = strlen(elem);

    /* Walk from the end to find the matching ']'. */
    const char* end = arr + arr_len - 1;
    while (end > p && (*end == ' ' || *end == '\t' || *end == '\n' || *end == '\r')) end--;
    if (*end != ']') {
        /* Malformed — wrap elem in a new array. */
        size_t n = elem_len + 4;
        char* out = el_strbuf(n);
        snprintf(out, n, "[%s]", elem);
        return el_wrap_str(out);
    }

    /* Content between '[' and ']'. */
    const char* inner_start = p + 1;
    const char* inner_end   = end;       /* points AT ']' */
    /* Check if the array is empty (only whitespace between brackets). */
    const char* q = inner_start;
    while (q < inner_end && (*q == ' ' || *q == '\t' || *q == '\n' || *q == '\r')) q++;
    int empty = (q == inner_end);

    /* Build: prefix + (comma if non-empty) + elem + "]" */
    size_t prefix_len = (size_t)(inner_end - arr); /* up to but not including ']' */
    size_t sep_len    = empty ? 0 : 1;              /* "," if non-empty */
    size_t out_len    = prefix_len + sep_len + elem_len + 2; /* +"]" + NUL */
    char* out = el_strbuf(out_len);
    memcpy(out, arr, prefix_len);
    if (!empty) out[prefix_len] = ',';
    memcpy(out + prefix_len + sep_len, elem, elem_len);
    out[prefix_len + sep_len + elem_len] = ']';
    out[prefix_len + sep_len + elem_len + 1] = '\0';
    return el_wrap_str(out);
}

/* now_millis — milliseconds since Unix epoch. */
el_val_t now_millis(void) {
    return time_now();
}

/* unix_timestamp_ms — same as now_millis. */
el_val_t unix_timestamp_ms(void) {
    return time_now();
}

/* time_now_ms — same as now_millis. */
el_val_t time_now_ms(void) {
    return time_now();
}

/* log_info — write a structured [INFO] line to stderr. */
void log_info(el_val_t msg_v) {
    const char* msg = EL_CSTR(msg_v);
    fprintf(stderr, "[INFO] %s\n", msg ? msg : "");
}

/* log_warn — write a structured [WARN] line to stderr. */
void log_warn(el_val_t msg_v) {
    const char* msg = EL_CSTR(msg_v);
    fprintf(stderr, "[WARN] %s\n", msg ? msg : "");
}

/* ── Cross-cutting concerns: process identity and configuration ──────────────
 *
 * These back the `program` block (see lang/spec/language.md §18). Both concerns
 * were previously conventions — "check nothing is already running first",
 * "remember the right default at every read site" — and conventions is exactly
 * what they failed as. Here they are mechanisms, injected by the compiler at
 * the process boundary, so no call site has to remember anything.
 */

/* -- Process identity ------------------------------------------------------- */

/* The lock fd is deliberately never closed. Holding it open for the process
 * lifetime is what makes the guarantee work: the kernel drops an flock when the
 * owning process dies, including on SIGKILL and on crash. That is why this is an
 * flock and not a bare pidfile — there is no stale-lock state to clean up, and
 * therefore no "delete the pidfile to get unstuck" ritual that would itself
 * become a convention. */
static int el_singleton_fd = -1;
static char el_singleton_path[1024];
static char el_singleton_state[1024];

/* el_singleton_acquire — claim exclusive use of the guarded STATE, or refuse to
 * start. Compiler-injected as the FIRST statement of main() for any program
 * whose `program` block declares `singleton:` (which must also declare
 * `guards:` — see lang/spec/language.md §18.2).
 *
 * GUARD THE THING, NOT THE NAME.
 *
 * Until 2026-08-16 this lock was keyed on the program's NAME and on $TMPDIR —
 * `$EL_SINGLETON_DIR|$TMPDIR|/tmp` + `/el-singleton-<name>.lock` — and never
 * consulted the state it claimed to protect. Its own refusal message said
 * "Refusing to start a second instance against the same state" while it had not
 * looked at any state. Measured, it failed in BOTH directions:
 *
 *   - FALSE POSITIVE: two engrams against genuinely DIFFERENT data dirs could
 *     not coexist. The second was refused, naming the first's pid — for sharing
 *     a name, not a store.
 *   - FALSE NEGATIVE (the dangerous one): `TMPDIR=/tmp/other` let a second
 *     instance start against the SAME data dir with no complaint. That is
 *     exactly the two-instance data-loss condition the guard exists to prevent,
 *     and the workaround was one environment variable.
 *
 * Both are one error: the identity of the resource had been replaced by a label
 * for it. The fix is to put the lock file INSIDE the state it guards:
 *
 *     <state>/.el-singleton-<id>.lock
 *
 * That placement is the whole mechanism, and it is why there is no hashing, no
 * canonical-path registry, and no environment variable left to subvert:
 *
 *   - Same directory  => same file => same inode => the flock CONTENDS. There is
 *     no TMPDIR in the key, so there is nothing to change to get past it.
 *   - Different dirs  => different files => no contention. Two stores are two
 *     stores; they were never in conflict and are no longer treated as if they
 *     were.
 *   - Different SPELLINGS of one directory — trailing slash, `x/../x`, a symlink
 *     — resolve to the same inode in the kernel's own path walk, so they contend
 *     without this code comparing strings at all. Path canonicalisation here is
 *     for the human-readable message, never for the decision.
 *
 * Kept, deliberately, from the version this replaces: it is an flock and not a
 * pidfile (the kernel releases it on crash and on SIGKILL, so there is no stale
 * state and therefore no "delete the lock file to get unstuck" ritual), and it
 * reports the HOLDER'S PID (added because a stale process survived `pkill -f`
 * and went on answering probes; "already running" is not actionable, a pid is).
 *
 * Changed: the message is now TRUE. It says "the same state" because the lock it
 * failed to take lives in that state, and it names the state it checked. */
el_val_t el_singleton_acquire(el_val_t id_v, el_val_t state_v) {
    const char* id = EL_CSTR(id_v);
    if (!id || !*id) return EL_NULL;

    /* A singleton with nothing to guard is the defect this function exists to
     * remove; refuse rather than silently fall back to name-keying. The compiler
     * rejects `singleton:` without `guards:`, so reaching this is a toolchain
     * mismatch, not a user mistake — say so. */
    const char* state = EL_CSTR(state_v);
    if (!state || !*state) {
        fprintf(stderr,
            "[el] FATAL: singleton '%s' was given no state to guard.\n"
            "[el]        A lock keyed on a program's NAME instead of on the state it\n"
            "[el]        protects is not a guard: it refuses unrelated instances and\n"
            "[el]        permits concurrent ones. Declare `guards: <path>` alongside\n"
            "[el]        `singleton:` in the program block (spec §18.2).\n", id);
        exit(1);
    }

    /* Canonicalise so the operator is told WHICH directory was checked, in one
     * spelling, whatever spelling they typed. This is a readability measure, not
     * the mechanism: realpath() may fail (the directory may not exist yet) and
     * correctness must not depend on it — when it succeeds it names the same
     * directory, and when it does not we fall back to the path as given and the
     * kernel's own path walk still collapses the spellings at open() time. */
    char* rp = realpath(state, NULL);
    snprintf(el_singleton_state, sizeof(el_singleton_state), "%s", rp ? rp : state);
    free(rp);

    /* Sanitise the id into a filename. */
    char safe[256];
    size_t si = 0;
    for (const char* p = id; *p && si + 1 < sizeof(safe); p++) {
        char c = *p;
        int ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
              || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
        safe[si++] = (char)(ok ? c : '-');
    }
    safe[si] = '\0';
    /* THE MECHANISM: the lock lives inside the state it guards. Two spellings of
     * one directory name one file; two directories name two files. Note there is
     * no $TMPDIR and no $EL_SINGLETON_DIR in this path — the escape hatch that
     * made the guard bypassable is gone because there is nowhere left to put it. */
    snprintf(el_singleton_path, sizeof(el_singleton_path),
             "%s/.el-singleton-%s.lock", el_singleton_state, safe);

    int fd = open(el_singleton_path, O_RDWR | O_CREAT, 0644);
    if (fd < 0) {
        /* Unguardable state. Refusing is the only honest option: starting anyway
         * would mean running unguarded against exactly the store the guard is
         * here to protect. */
        fprintf(stderr,
            "[el] FATAL: singleton '%s': cannot open the lock inside the state it guards.\n"
            "[el]        state: %s\n"
            "[el]        lock:  %s (%s)\n"
            "[el]        The guarded directory must exist and be writable. Refusing to\n"
            "[el]        start unguarded against it.\n",
            id, el_singleton_state, el_singleton_path, strerror(errno));
        exit(1);
    }
    if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
        /* Someone else holds it. Report WHO. A pid is actionable; "already
         * running" is not — and the observed failure was precisely a stale
         * process that `pkill -f` had silently failed to match, still answering
         * probes while a fresh build was believed to be under test. */
        char buf[64];
        buf[0] = '\0';
        ssize_t n = pread(fd, buf, sizeof(buf) - 1, 0);
        if (n > 0) buf[n] = '\0';
        long holder = strtol(buf, NULL, 10);
        fprintf(stderr, "[el] FATAL: another instance of '%s' is already running", id);
        if (holder > 0) fprintf(stderr, " (pid %ld)", holder);
        fprintf(stderr, ".\n"
                "[el]        state: %s\n"
                "[el]        lock:  %s\n"
                "[el]        Refusing to start a second instance against the same\n"
                "[el]        state. Two writers against one store is data loss, not a\n"
                "[el]        warning. Stop the running one and VERIFY it is gone\n"
                "[el]        (ps -p %ld) before retrying — or point this instance at a\n"
                "[el]        different state, which is permitted and is not refused.\n",
                el_singleton_state, el_singleton_path, holder > 0 ? holder : (long)0);
        close(fd);
        exit(1);
    }
    /* We own it. Record our pid so the next would-be starter can name us. */
    if (ftruncate(fd, 0) != 0) { /* best effort — the lock is the guarantee */ }
    char pidbuf[32];
    int pn = snprintf(pidbuf, sizeof(pidbuf), "%ld\n", (long)getpid());
    if (pn > 0) { ssize_t w = write(fd, pidbuf, (size_t)pn); (void)w; }
    el_singleton_fd = fd;   /* never closed, by design */
    return EL_NULL;
}

/* -- Configuration ---------------------------------------------------------- */

#define EL_CONFIG_MAX 128

typedef struct {
    char  name[128];
    char  type[16];
    char* value;        /* resolved: env value, else default; NULL if unset */
    int   has_default;
    int   required;
} ElConfigEntry;

static ElConfigEntry el_config_tab[EL_CONFIG_MAX];
static int el_config_n = 0;
static int el_config_has_schema = 0;   /* did this program declare one at all? */

static int el_config_is_int(const char* s) {
    if (!s || !*s) return 0;
    if (*s == '-' || *s == '+') s++;
    if (!*s) return 0;
    for (; *s; s++) if (*s < '0' || *s > '9') return 0;
    return 1;
}

/* el_config_declare — record ONE configuration entry and resolve it now.
 * The default lives here, in the declaration, and nowhere else. */
el_val_t el_config_declare(el_val_t name_v, el_val_t type_v, el_val_t def_v,
                           el_val_t has_default_v, el_val_t required_v) {
    const char* name = EL_CSTR(name_v);
    if (!name || !*name) return EL_NULL;
    el_config_has_schema = 1;
    if (el_config_n >= EL_CONFIG_MAX) {
        fprintf(stderr, "[el] FATAL: more than %d config entries declared.\n", EL_CONFIG_MAX);
        exit(1);
    }
    const char* type = EL_CSTR(type_v);
    const char* def  = (def_v == EL_NULL) ? NULL : EL_CSTR(def_v);
    ElConfigEntry* e = &el_config_tab[el_config_n++];
    snprintf(e->name, sizeof(e->name), "%s", name);
    snprintf(e->type, sizeof(e->type), "%s", type ? type : "String");
    e->has_default = (int)(long)has_default_v;
    e->required    = (int)(long)required_v;
    /* Resolution order: environment wins, declaration supplies the fallback. */
    const char* env = getenv(name);
    if (env && *env)            e->value = el_strdup_persist(env);
    else if (e->has_default && def) e->value = el_strdup_persist(def);
    else                        e->value = NULL;
    return EL_NULL;
}

/* el_config_validate — check the whole schema at once, before main() runs.
 * Reports EVERY problem, not just the first: a startup that fails one variable
 * at a time costs one restart per variable. */
el_val_t el_config_validate(el_val_t program_v) {
    const char* prog = EL_CSTR(program_v);
    int bad = 0;
    for (int i = 0; i < el_config_n; i++) {
        ElConfigEntry* e = &el_config_tab[i];
        if (!e->value) {
            if (e->required) {
                fprintf(stderr, "[el] config: %s is required but is not set "
                        "(no value in the environment, no default declared)\n", e->name);
                bad++;
            }
            continue;
        }
        if (strcmp(e->type, "Int") == 0 && !el_config_is_int(e->value)) {
            fprintf(stderr, "[el] config: %s is declared Int but its value is \"%s\"\n",
                    e->name, e->value);
            bad++;
        }
    }
    if (bad) {
        fprintf(stderr, "[el] FATAL: program '%s' has %d invalid configuration "
                "entr%s. Refusing to start.\n",
                prog ? prog : "?", bad, bad == 1 ? "y" : "ies");
        exit(1);
    }
    return EL_NULL;
}

/* config — read a configuration value.
 *
 * When the program declared a schema, that schema is authoritative: the value
 * has already been resolved and validated at startup, so this is a lookup and
 * NOT a place where a default gets decided. Reading a key that was never
 * declared is a bug at the read site, and is reported as one — that enforcement
 * is what makes the declaration real rather than advisory.
 *
 * With no schema declared, behaviour is unchanged (plain getenv), so programs
 * that have not migrated keep working. */
el_val_t config(el_val_t key_v) {
    const char* key = EL_CSTR(key_v);
    if (!key || !*key) return EL_STR("");
    if (el_config_has_schema) {
        for (int i = 0; i < el_config_n; i++) {
            if (strcmp(el_config_tab[i].name, key) == 0) {
                const char* v = el_config_tab[i].value;
                return el_wrap_str(el_strdup(v ? v : ""));
            }
        }
        fprintf(stderr, "[el] FATAL: config(\"%s\") is not declared in the "
                "program block. Declare it there, with its default, or stop "
                "reading it.\n", key);
        exit(1);
    }
    const char* val = getenv(key);
    if (!val) return EL_STR("");
    return el_wrap_str(el_strdup(val));
}

#if !defined(_WIN32) || defined(HAVE_CURL)
/* http_patch — HTTP PATCH request with Content-Type: application/json.
 * Returns the response body (same error convention as http_post_json). */
el_val_t http_patch(el_val_t url_v, el_val_t body_v) {
    const char* url  = EL_CSTR(url_v);
    const char* body = EL_CSTR(body_v);
    if (!url || !*url) return http_error_json("empty url");
    CURL* c = curl_easy_init();
    if (!c) return http_error_json("curl_easy_init failed");
    HttpBuf rb; httpbuf_init(&rb);
    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PATCH");
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
    curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    CURLcode rc = curl_easy_perform(c);
    curl_slist_free_all(h);
    curl_easy_cleanup(c);
    if (rc != CURLE_OK) {
        free(rb.data);
        const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
        return http_error_json(m);
    }
    return el_wrap_str(rb.data);
}

/* http_post_engram — HTTP POST with optional X-API-Key header.
 * If key is "" no authentication header is sent. */
el_val_t http_post_engram(el_val_t url_v, el_val_t key_v, el_val_t body_v) {
    const char* url  = EL_CSTR(url_v);
    const char* key  = EL_CSTR(key_v);
    const char* body = EL_CSTR(body_v);
    if (!url || !*url) return http_error_json("empty url");
    CURL* c = curl_easy_init();
    if (!c) return http_error_json("curl_easy_init failed");
    HttpBuf rb; httpbuf_init(&rb);
    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    struct curl_slist* h = NULL;
    h = curl_slist_append(h, "Content-Type: application/json");
    if (key && *key) {
        size_t n = strlen(key) + 32;
        char* hdr = malloc(n);
        snprintf(hdr, n, "X-API-Key: %s", key);
        h = curl_slist_append(h, hdr);
        free(hdr);
    }
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_POST, 1L);
    curl_easy_setopt(c, CURLOPT_POSTFIELDS, body ? body : "");
    curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)(body ? strlen(body) : 0));
    curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    CURLcode rc = curl_easy_perform(c);
    curl_slist_free_all(h);
    curl_easy_cleanup(c);
    if (rc != CURLE_OK) {
        free(rb.data);
        const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
        return http_error_json(m);
    }
    return el_wrap_str(rb.data);
}

/* http_get_engram — HTTP GET with optional X-API-Key header. */
el_val_t http_get_engram(el_val_t url_v, el_val_t key_v) {
    const char* url = EL_CSTR(url_v);
    const char* key = EL_CSTR(key_v);
    if (!url || !*url) return http_error_json("empty url");
    CURL* c = curl_easy_init();
    if (!c) return http_error_json("curl_easy_init failed");
    HttpBuf rb; httpbuf_init(&rb);
    char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
    struct curl_slist* h = NULL;
    if (key && *key) {
        size_t n = strlen(key) + 32;
        char* hdr = malloc(n);
        snprintf(hdr, n, "X-API-Key: %s", key);
        h = curl_slist_append(h, hdr);
        free(hdr);
    }
    curl_easy_setopt(c, CURLOPT_URL, url);
    curl_easy_setopt(c, CURLOPT_HTTPGET, 1L);
    if (h) curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
    curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
    curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
    curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
    curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
    curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
    curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
    curl_easy_setopt(c, CURLOPT_USERAGENT, "el-runtime/1.0");
    CURLcode rc = curl_easy_perform(c);
    if (h) curl_slist_free_all(h);
    curl_easy_cleanup(c);
    if (rc != CURLE_OK) {
        free(rb.data);
        const char* m = errbuf[0] ? errbuf : curl_easy_strerror(rc);
        return http_error_json(m);
    }
    return el_wrap_str(rb.data);
}
#endif /* HAVE_CURL */

/* str_to_bytes — encode a string as a JSON array of unsigned byte values.
 * "hello" -> "[104,101,108,108,111]"
 * Used by db.el to store binary content in Engram JSON nodes. */
el_val_t str_to_bytes(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s || !*s) return el_wrap_str(el_strdup("[]"));
    size_t n = strlen(s);
    /* Worst case: each byte is 3 digits + comma = 4 chars, plus "[]" + NUL. */
    char* out = el_strbuf(n * 4 + 3);
    size_t pos = 0;
    out[pos++] = '[';
    for (size_t i = 0; i < n; i++) {
        unsigned char b = (unsigned char)s[i];
        if (i > 0) out[pos++] = ',';
        /* Write decimal representation of b. */
        if (b >= 100) {
            out[pos++] = (char)('0' + b / 100);
            out[pos++] = (char)('0' + (b / 10) % 10);
            out[pos++] = (char)('0' + b % 10);
        } else if (b >= 10) {
            out[pos++] = (char)('0' + b / 10);
            out[pos++] = (char)('0' + b % 10);
        } else {
            out[pos++] = (char)('0' + b);
        }
    }
    out[pos++] = ']';
    out[pos] = '\0';
    return el_wrap_str(out);
}

/* bytes_to_str — decode a JSON array of integer byte values back to a string.
 * "[104,101,108,108,111]" -> "hello"
 * Inverse of str_to_bytes. */
el_val_t bytes_to_str(el_val_t arr_v) {
    const char* s = EL_CSTR(arr_v);
    if (!s) return el_wrap_str(el_strdup(""));
    /* Skip whitespace, expect '['. */
    while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
    if (*s != '[') return el_wrap_str(el_strdup(""));
    s++;

    /* Count elements to size the output buffer. */
    int64_t n = (int64_t)json_array_len(arr_v);
    if (n <= 0) return el_wrap_str(el_strdup(""));

    char* out = el_strbuf((size_t)n + 1);
    size_t pos = 0;

    /* Walk the array, parse each integer, store as a byte. */
    while (*s) {
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ']' || *s == '\0') break;
        /* Parse decimal integer. */
        char* end_ptr;
        long v = strtol(s, &end_ptr, 10);
        if (end_ptr == s) break; /* parse failure */
        s = end_ptr;
        if (v >= 0 && v <= 255) out[pos++] = (char)(unsigned char)v;
        while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') s++;
        if (*s == ',') { s++; continue; }
        if (*s == ']' || *s == '\0') break;
    }
    out[pos] = '\0';
    return el_wrap_str(out);
}

/* hash_sha256 — return the SHA-256 hex digest of a string.
 * Uses the built-in el_sha256_oneshot implementation (no OpenSSL required). */
el_val_t hash_sha256(el_val_t sv) {
    const char* s = EL_CSTR(sv);
    if (!s) s = "";
    unsigned char digest[32];
    el_sha256_oneshot((const unsigned char*)s, strlen(s), digest);
    return el_hex_encode(digest, 32);
}


/* HTTP client aliases — require curl; defined inside #ifdef HAVE_CURL below
 * with a matching stub in the #ifndef HAVE_CURL block.                       */
#if !defined(_WIN32) || defined(HAVE_CURL)
/* __http_do also lives in el_seed.c; marked weak so el_seed.c's definition
 * wins when both translation units are linked together (the real product build). */
__attribute__((weak)) el_val_t __http_do(el_val_t method, el_val_t url, el_val_t body,
                   el_val_t headers_map, el_val_t timeout_ms) {
    /* timeout_ms is accepted for API compatibility but ignored here;
     * el_runtime's http_do uses the EL_HTTP_TIMEOUT_MS env var instead. */
    (void)timeout_ms;
    struct curl_slist* h = headers_from_map(headers_map);
    el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
    if (h) curl_slist_free_all(h);
    return r;
}

/* __http_do_map — same as __http_do but headers_map arg is a JSON-string
 * rather than an ElMap. Parse it first, then delegate.                      */
el_val_t __http_do_map(el_val_t method, el_val_t url, el_val_t body,
                       el_val_t headers_json, el_val_t timeout_ms) {
    (void)timeout_ms;
    /* Build a curl_slist from a JSON object {"Header":"value",...}. */
    const char* hj = EL_CSTR(headers_json);
    struct curl_slist* h = NULL;
    if (hj && *hj && *hj == '{') {
        /* Walk the JSON pairs with a simple parser reusing json_get_string logic. */
        /* For correctness we just call the existing json_get iteration path.
         * We duplicate the key-extraction loop from headers_from_map but driven
         * by JSON rather than ElMap. Use json_get_raw to iterate is not easy
         * without knowing keys, so accept the JSON string and build a tmp map. */
        el_val_t map = json_parse(EL_STR(hj));
        h = headers_from_map(map);
    }
    el_val_t r = http_do(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body), h);
    if (h) curl_slist_free_all(h);
    return r;
}

/* __http_do_map_to_file — same as __http_do_map but streams response body
 * to a local file path rather than returning it as a string.               */
el_val_t __http_do_map_to_file(el_val_t method, el_val_t url, el_val_t body,
                                el_val_t headers_json, el_val_t output_path) {
    const char* hj = EL_CSTR(headers_json);
    struct curl_slist* h = NULL;
    if (hj && *hj && *hj == '{') {
        el_val_t map = json_parse(EL_STR(hj));
        h = headers_from_map(map);
    }
    el_val_t r = http_do_to_file(EL_CSTR(method), EL_CSTR(url), EL_CSTR(body),
                                 h, EL_CSTR(output_path));
    if (h) curl_slist_free_all(h);
    return r;
}
#endif /* HAVE_CURL */

#if defined(_WIN32) && !defined(HAVE_CURL)
/* ── HAVE_CURL=0 stubs — compile without -lcurl for the elc CLI binary. ───── *
 * These return a JSON error string so El programs get a clear message if they
 * call HTTP/LLM functions in a curl-less build.                               */
static el_val_t _no_curl_err(void) {
    return el_wrap_str(el_strdup("{\"error\":\"not built with HAVE_CURL\"}"));
}
el_val_t http_get(el_val_t url)                          { (void)url; return _no_curl_err(); }
el_val_t http_post(el_val_t url, el_val_t body)          { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_post_json(el_val_t url, el_val_t body)     { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_get_with_headers(el_val_t url, el_val_t h) { (void)url; (void)h; return _no_curl_err(); }
el_val_t http_post_with_headers(el_val_t url, el_val_t b, el_val_t h) { (void)url; (void)b; (void)h; return _no_curl_err(); }
el_val_t http_post_json_with_headers(el_val_t url, el_val_t h, el_val_t b) { (void)url; (void)h; (void)b; return _no_curl_err(); }
el_val_t http_post_form_auth(el_val_t url, el_val_t b, el_val_t a)    { (void)url; (void)b; (void)a; return _no_curl_err(); }
el_val_t http_delete(el_val_t url)                       { (void)url; return _no_curl_err(); }
el_val_t http_patch(el_val_t url, el_val_t body)         { (void)url; (void)body; return _no_curl_err(); }
el_val_t http_get_to_file(el_val_t url, el_val_t h, el_val_t p)       { (void)url; (void)h; (void)p; return _no_curl_err(); }
el_val_t http_post_to_file(el_val_t url, el_val_t b, el_val_t h, el_val_t p) { (void)url; (void)b; (void)h; (void)p; return _no_curl_err(); }
el_val_t http_post_engram(el_val_t url, el_val_t k, el_val_t b)       { (void)url; (void)k; (void)b; return _no_curl_err(); }
el_val_t http_get_engram(el_val_t url, el_val_t k)                    { (void)url; (void)k; return _no_curl_err(); }
el_val_t llm_call(el_val_t m, el_val_t p)                { (void)m; (void)p; return _no_curl_err(); }
el_val_t llm_call_system(el_val_t m, el_val_t s, el_val_t u)          { (void)m; (void)s; (void)u; return _no_curl_err(); }
el_val_t llm_call_agentic(el_val_t m, el_val_t s, el_val_t u, el_val_t t) { (void)m; (void)s; (void)u; (void)t; return _no_curl_err(); }
el_val_t llm_vision(el_val_t m, el_val_t s, el_val_t p, el_val_t i)  { (void)m; (void)s; (void)p; (void)i; return _no_curl_err(); }
el_val_t llm_models(void)                                { return el_list_empty(); }
void     llm_register_tool(el_val_t n, el_val_t f)       { (void)n; (void)f; }
/* __ HTTP stubs (no-curl build) */
el_val_t __http_do(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
el_val_t __http_do_map(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t t) { (void)m; (void)u; (void)b; (void)h; (void)t; return _no_curl_err(); }
el_val_t __http_do_map_to_file(el_val_t m, el_val_t u, el_val_t b, el_val_t h, el_val_t p) { (void)m; (void)u; (void)b; (void)h; (void)p; return _no_curl_err(); }
#endif /* !HAVE_CURL */

/* ── Compiler-support builtins ───────────────────────────────────────────────
 * stdout_to_file / stdout_restore / el_mem_check are called by the El compiler's
 * own source (compiler.el:472,479,574 and codegen.el:4248) and are registered in
 * codegen.el's builtin_arity table, but were missing from this runtime — so
 * rebuilding elc from source failed with three implicit-declaration errors and
 * the committed elc binary could never be refreshed. The definitions below are
 * ported verbatim from ui/examples/native-hello-ios/NativeHello/el_runtime.c,
 * a divergent private copy of this runtime that still carried them.
 * ──────────────────────────────────────────────────────────────────────────── */

#include <sys/resource.h>

static int _el_saved_stdout_fd = -1;

/* Redirect process stdout to a file; used by the compiler's JS post-processing
 * pipeline to capture codegen output before piping it onward. */
el_val_t stdout_to_file(el_val_t pathv) {
    const char* path = EL_CSTR(pathv);
    if (!path) return (el_val_t)(int64_t)-1;
    fflush(stdout);
    _el_saved_stdout_fd = dup(STDOUT_FILENO);
    int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
    if (fd < 0) return (el_val_t)(int64_t)-1;
    dup2(fd, STDOUT_FILENO);
    close(fd);
    return (el_val_t)(int64_t)0;
}

el_val_t stdout_restore(void) {
    if (_el_saved_stdout_fd >= 0) {
        fflush(stdout);
        dup2(_el_saved_stdout_fd, STDOUT_FILENO);
        close(_el_saved_stdout_fd);
        _el_saved_stdout_fd = -1;
    }
    return (el_val_t)(int64_t)0;
}

/* el_mem_check — self-terminating memory guard for long-running compiler runs.
 * Called periodically by the compiler to catch runaway growth before the OS
 * OOM-killer fires. Limit comes from ELC_MAX_MEM_MB (default 512 MB).
 * macOS reports ru_maxrss in bytes, Linux in kilobytes; normalised to MB. */
el_val_t el_mem_check(void) {
    long limit_mb = 512;
    const char* env_val = getenv("ELC_MAX_MEM_MB");
    if (env_val && *env_val) {
        long v = atol(env_val);
        if (v > 0) limit_mb = v;
    }

    struct rusage ru;
    if (getrusage(RUSAGE_SELF, &ru) != 0) return 0; /* can't read — skip check */

    long rss_mb;
#if defined(__APPLE__) || defined(__MACH__)
    rss_mb = (long)(ru.ru_maxrss / (1024L * 1024L));
#else
    rss_mb = (long)(ru.ru_maxrss / 1024L);
#endif

    if (rss_mb >= limit_mb) {
        fprintf(stderr, "elc: memory limit exceeded (%ldMB), aborting\n", limit_mb);
        exit(1);
    }
    return 0;
}

/* ── engram_recall_json / cgi_* accessors — restored 2026-08-15 ──────────────
 *
 * These existed in the runtime neuron vendored (v1.0.0-20260501) and were lost
 * when this runtime moved on, so a soul built against current el would fail to
 * link — and, worse, the naive "fix" of pointing recall at engram_search_json
 * would have SILENTLY DOWNGRADED the mind's whole retrieval surface from
 * semantic to lexical, with no error at any layer.
 *
 * The lexical/semantic split is a real safety boundary, not redundant naming
 * (neuron-api.el:613 documents it): engram_search_json stays LEXICAL because
 * ~40 internal call sites pass a KEY and seven of them DELETE every record
 * returned — making those semantic would delete fuzzy matches. recall is the
 * SEMANTIC surface, used by the retrieval routes.
 *
 * The old implementation was eg_search_json_impl(q, limit, with_legs=1): embed
 * the query, cosine over the corpus, then a graph leg from semantic seeds.
 * In this runtime that is exactly what engram_activate() already does (it
 * embeds via eg_embed_fetch, scores by cosine, then spreads activation), so
 * recall delegates to it rather than re-deriving a second semantic path.
 * Output shape matches engram_search_json — a flat array of node objects via
 * engram_emit_node_json — because existing callers (memory.el:80,
 * neuron-api.el:618) parse it as search's shape, not activate's envelope.
 * ──────────────────────────────────────────────────────────────────────────── */

el_val_t engram_recall_json(el_val_t query, el_val_t limit) {
    int64_t lim = (int64_t)limit;
    if (lim <= 0) lim = 100;

    /* depth 1: the associative leg, one hop out from the semantic seeds. */
    el_val_t lst = engram_activate(query, (el_val_t)(int64_t)1);
    ElList* arr = (ElList*)(uintptr_t)lst;

    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int64_t emitted = 0;
    if (arr) {
        for (int64_t i = 0; i < arr->length && emitted < lim; i++) {
            if (!arr->elems[i]) continue;
            el_val_t node_map = el_map_get(arr->elems[i], EL_STR("node"));
            el_val_t id_v     = el_map_get(node_map, EL_STR("id"));
            const char* id_s  = EL_CSTR(id_v);
            EngramNode* n     = id_s ? engram_find_node(id_s) : NULL;
            if (!n) continue;
            if (emitted > 0) jb_putc(&b, ',');
            engram_emit_node_json(&b, n, 0);
            emitted++;
        }
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* cgi_* — read-only identity accessors over the process-wide CGI registration
 * set by cgi_register(). Read-only by design: there is no setter (studio.el:66). */
el_val_t cgi_principal(void) { return EL_STR(_el_cgi_principal ? _el_cgi_principal : ""); }
el_val_t cgi_network(void)   { return EL_STR(_el_cgi_network   ? _el_cgi_network   : ""); }
el_val_t cgi_engram(void)    { return EL_STR(_el_cgi_engram    ? _el_cgi_engram    : ""); }

/* engram_edges_json(limit, offset) — emit edges straight from the store.
 *
 * Replaces a serialize-and-reread round trip that took production down on
 * 2026-08-15: /api/graph/edges called engram_save() to write the ENTIRE graph
 * to disk (128 MB) and then fs_read it back, just to answer a read query for
 * edges. One debug request cost a full snapshot write, a 128 MB read, and the
 * peak memory to hold it — on top of being O(whole graph) for a bounded slice.
 * The route's own comment had already named the fix: "Future: add an
 * engram_edges_json() builtin and drop the file round trip entirely."
 *
 * limit <= 0 defaults to 1000 rather than unbounded: this is the endpoint that
 * fell over, and an unbounded default would preserve the failure mode under a
 * different name. Pass an explicit limit to page.
 */
el_val_t engram_edges_json(el_val_t limit, el_val_t offset) {
    EngramStore* g = engram_get();
    int64_t lim = (int64_t)limit;  if (lim <= 0) lim = 1000;
    int64_t off = (int64_t)offset; if (off < 0)  off = 0;

    JsonBuf b; jb_init(&b);
    jb_putc(&b, '[');
    int64_t emitted = 0;
    char t[192];
    for (int64_t i = off; i < g->edge_count && emitted < lim; i++) {
        EngramEdge* e = &g->edges[i];
        if (emitted > 0) jb_putc(&b, ',');
        jb_puts(&b, "{\"id\":");        jb_emit_escaped(&b, e->id       ? e->id       : "");
        jb_puts(&b, ",\"from_id\":");   jb_emit_escaped(&b, e->from_id  ? e->from_id  : "");
        jb_puts(&b, ",\"to_id\":");     jb_emit_escaped(&b, e->to_id    ? e->to_id    : "");
        jb_puts(&b, ",\"relation\":");  jb_emit_escaped(&b, e->relation ? e->relation : "");
        snprintf(t, sizeof t,
                 ",\"weight\":%.6g,\"hebb\":%.6g,\"confidence\":%.6g,"
                 "\"created_at\":%lld,\"updated_at\":%lld,\"last_fired\":%lld,"
                 "\"inhibitory\":%d,\"layer_id\":%u}",
                 e->weight, e->hebb, e->confidence,
                 (long long)e->created_at, (long long)e->updated_at,
                 (long long)e->last_fired, e->inhibitory, (unsigned)e->layer_id);
        jb_puts(&b, t);
        emitted++;
    }
    jb_putc(&b, ']');
    return el_wrap_str(b.buf);
}

/* engram_pool_stats_json() — the buffer pool's interoception, exposed.
 *
 * StorePoolStats and store_pool_stats() already existed and were surfaced
 * NOWHERE. On 2026-08-15 the engram thrashed itself to a standstill twice while
 * these exact counters sat in memory, unread, and four wrong theories were tried
 * from the outside instead. Sensing state is only corrective if the state can be
 * read — by the process itself (pc_adapt_budget) and by anything watching it.
 *
 * Serves the live numbers plus the derived signals that actually diagnose:
 *   hit_rate    — sustained low hit rate with high evictions is the thrash shape
 *   evict_ratio — evictions per access; ~1 means every fetch displaces a live page
 *   pressure    — 1 when evicting into genuine reuse (working set > budget)
 *   cap_gib/resident_gib — budget vs what is actually held
 */
el_val_t engram_pool_stats_json(void) {
    if (!g_engram_store) return el_wrap_str(el_strdup("{\"store\":false}"));
    StorePoolStats st;
    store_pool_stats(g_engram_store, &st);
    uint64_t acc = st.hits + st.misses;
    double hit_rate    = acc ? (double)st.hits / (double)acc : 0.0;
    double evict_ratio = acc ? (double)st.evictions / (double)acc : 0.0;
    int pressure = (acc > 100000 && evict_ratio > 0.33 && hit_rate > 0.25) ? 1 : 0;
    char b[768];
    snprintf(b, sizeof b,
        "{\"store\":true,\"cap_frames\":%zu,\"resident_frames\":%zu,\"pinned\":%zu,"
        "\"dirty\":%zu,\"prefetch\":%u,\"hits\":%llu,\"misses\":%llu,\"evictions\":%llu,"
        "\"prefetch_reads\":%llu,\"hit_rate\":%.4f,\"evict_ratio\":%.4f,\"pressure\":%d,"
        "\"cap_gib\":%.3f,\"resident_gib\":%.3f,\"page_size\":%u}",
        st.cap, st.resident, st.pinned, st.dirty, st.prefetch,
        (unsigned long long)st.hits, (unsigned long long)st.misses,
        (unsigned long long)st.evictions, (unsigned long long)st.prefetch_reads,
        hit_rate, evict_ratio, pressure,
        (double)st.cap      * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0),
        (double)st.resident * (double)STORE_PAGE_SIZE / (1024.0*1024.0*1024.0),
        (unsigned)STORE_PAGE_SIZE);
    return el_wrap_str(el_strdup(b));
}

/* ── Allocation/RSS introspection (test-framework complexity gate, §6.5) ─────
 *
 * el_alloc_count() — total runtime string allocations since process start.
 *   THE gating signal. Deterministic: same input => same count, every machine,
 *   every run. A benchmark harness samples it before and after an operation at
 *   several input sizes and fits the deltas against n; a curve worse than the
 *   declared one fails the build. No warmup, no statistics, no baseline file,
 *   no flake — none of which is true of wall-clock.
 *
 * el_alloc_bytes() — total bytes requested. Same determinism; catches the case
 *   where allocation COUNT stays linear but per-allocation SIZE grows, which is
 *   the classic accidental-quadratic shape (rebuilding a whole buffer per
 *   append). Count alone would miss it.
 *
 * el_peak_rss() — peak resident set in bytes. Context, NOT a gate: perturbed by
 *   allocator internals, the page cache, and the OS. Reported so a human can
 *   see the physical consequence; never fitted.
 */
el_val_t el_alloc_count(void) { return (el_val_t)(int64_t)_el_alloc_count; }
el_val_t el_alloc_bytes(void) { return (el_val_t)(int64_t)_el_alloc_bytes; }

/* el_black_box — optimisation barrier for benchmark bodies.
 *
 * WHY THIS IS NOT OPTIONAL. A benchmark whose result is unused is dead code,
 * and CONSUMING THE RESULT IS NOT SUFFICIENT: clang recognises loop idioms and
 * closes them to arithmetic. A nested `total = total + 1` loop measured at
 * 0 microseconds for every n while returning a numerically correct n*n --
 * the answer was right and the work never happened.
 *
 * That is the same failure shape as a test that never ran reporting pass. The
 * harness must own the barrier rather than trusting the benchmark author to
 * defeat the optimiser.
 *
 * The constraint "+r" forces the value through a register the compiler must
 * treat as both read and written by opaque code; the "memory" clobber stops
 * loads and stores being reordered across it or elided. Emits no instructions. */
el_val_t el_black_box(el_val_t v) {
    __asm__ __volatile__("" : "+r"(v) : : "memory");
    return v;
}

el_val_t el_peak_rss(void) {
    struct rusage ru;
    if (getrusage(RUSAGE_SELF, &ru) != 0) return (el_val_t)0;
#if defined(__APPLE__) || defined(__MACH__)
    return (el_val_t)(int64_t)ru.ru_maxrss;            /* macOS: bytes */
#else
    return (el_val_t)(int64_t)(ru.ru_maxrss * 1024L);  /* Linux: KB -> bytes */
#endif
}
