self-review 2026-07-24: wire embedding cosine similarity into activation (bl-b2d1c944)
Semantic activation was spec-only since 2026-06-30 — the seed loop used istr_contains and nothing else. Per the 07-21 integration brief: - EngramNode gains a lazily-backfilled nomic-embed-text vector (8/call inside engram_activate, newest-first; no create-path latency, no bulk Ollama hammering during sync seeds) - query embedding (cached) drives a top-K cosine seed supplement (HippoRAG use-similarity-twice) plus an additive WM term with shift-and-floor at 0.45 — raw cosine is a constant bias in anisotropic spaces (unrelated pairs read 0.4-0.7), floor-and-ramp makes it a signal - 4s embed timeout (http_do_t) + 3-strike circuit breaker: activation never wedges on a dead embedder; everything degrades to lexical - embeddings persist as %.4g comma lists in snapshots, parsed by both loaders; embedded_count in /api/stats tracks coverage - engram_cosine_sim + http_delete_json exposed (DELETE now carries a body — the server's _auth scheme requires it) - route_create_node honored only content/node_type/salience; label, importance, tier, tags were silently dropped (label defaulted to content). Now honored via engram_node_full. Verified live: embedded_count 0->96 across activations, semantic-only promotion observed (zero token overlap), snapshot round-trip intact.
This commit is contained in:
@@ -788,9 +788,12 @@ static long el_http_timeout_ms(void) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/* Internal: do a libcurl request; takes optional body/headers, optional method override. */
|
||||
static el_val_t http_do(const char* method, const char* url, const char* body,
|
||||
struct curl_slist* extra_headers) {
|
||||
/* 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");
|
||||
@@ -800,7 +803,8 @@ static el_val_t http_do(const char* method, const char* url, const char* body,
|
||||
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_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");
|
||||
@@ -811,6 +815,13 @@ static el_val_t http_do(const char* method, const char* url, const char* 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);
|
||||
@@ -822,6 +833,12 @@ static el_val_t http_do(const char* method, const char* url, const char* body,
|
||||
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);
|
||||
}
|
||||
@@ -895,6 +912,16 @@ 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
|
||||
@@ -5801,6 +5828,14 @@ typedef struct EngramNode {
|
||||
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;
|
||||
} EngramNode;
|
||||
|
||||
/* Record an access (ACT-R "presentation") into the base-level ring buffer. */
|
||||
@@ -5858,6 +5893,159 @@ static void engram_bll_parse_access(EngramNode* nn, const char* s) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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
|
||||
#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
|
||||
|
||||
static int _eg_embed_consec_fail = 0;
|
||||
static int64_t _eg_embed_breaker_until = 0;
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/* 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()) + 64;
|
||||
char* body = malloc(blen);
|
||||
if (!body) { free(esc); return NULL; }
|
||||
snprintf(body, blen, "{\"model\":\"%s\",\"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;
|
||||
@@ -6391,6 +6579,9 @@ static el_val_t engram_node_to_map(const EngramNode* n) {
|
||||
* 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;
|
||||
}
|
||||
|
||||
@@ -6700,6 +6891,7 @@ void engram_forget(el_val_t node_id) {
|
||||
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];
|
||||
@@ -6785,6 +6977,7 @@ el_val_t engram_prune_telemetry(el_val_t older_than_ms) {
|
||||
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++;
|
||||
@@ -7321,12 +7514,68 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
|
||||
int64_t now_ms = engram_now_ms();
|
||||
|
||||
/* ── 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;
|
||||
backfilled++;
|
||||
}
|
||||
}
|
||||
/* Query embedding, cached single-slot: the curiosity loop re-issues the
|
||||
* same 4 rotating phrases, so consecutive identical queries skip the
|
||||
* HTTP round-trip entirely. */
|
||||
static char* _eg_qcache_text = NULL;
|
||||
static float* _eg_qcache_emb = NULL;
|
||||
static int32_t _eg_qcache_dim = 0;
|
||||
float* q_emb = NULL;
|
||||
int32_t q_dim = 0;
|
||||
if (_eg_qcache_text && strcmp(_eg_qcache_text, q) == 0) {
|
||||
q_emb = _eg_qcache_emb; q_dim = _eg_qcache_dim;
|
||||
} else {
|
||||
int32_t d = 0;
|
||||
float* v = eg_embed_fetch(q, &d);
|
||||
if (v) {
|
||||
free(_eg_qcache_text); free(_eg_qcache_emb);
|
||||
_eg_qcache_text = strdup(q);
|
||||
_eg_qcache_emb = v;
|
||||
_eg_qcache_dim = d;
|
||||
q_emb = v; q_dim = d;
|
||||
}
|
||||
}
|
||||
/* Per-node cosine vs the query, 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;
|
||||
if (q_emb) {
|
||||
cosq = calloc((size_t)g->node_count, sizeof(double));
|
||||
if (cosq) {
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
cosq[i] = (n->emb && n->emb_dim == q_dim)
|
||||
? eg_cosine(n->emb, q_emb, q_dim) : -2.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 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); return out;
|
||||
free(best_bg); free(best_hops); free(reached); free(cosq); return out;
|
||||
}
|
||||
|
||||
/* ── LAYER 1: broad fan-out (background activation) ─────────────────
|
||||
@@ -7337,7 +7586,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
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); return out;
|
||||
free(best_bg); free(best_hops); free(reached); free(cosq); 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
|
||||
@@ -7376,6 +7625,35 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
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) {
|
||||
for (int k = 0; k < ENGRAM_EMBED_SEED_K; k++) {
|
||||
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 (cosq[i] > bc) { bc = cosq[i]; bi = i; }
|
||||
}
|
||||
if (bi < 0) break;
|
||||
EngramNode* n = &g->nodes[bi];
|
||||
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;
|
||||
}
|
||||
}
|
||||
/* 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
|
||||
@@ -7394,7 +7672,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
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); return out;
|
||||
free(best_bg); free(best_hops); free(reached); free(seeds); free(cosq); return out;
|
||||
}
|
||||
int64_t fhead = 0, ftail = 0;
|
||||
int64_t fcap = (int64_t)((size_t)(g->node_count * (max_depth + 1)) + 16);
|
||||
@@ -7488,7 +7766,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
double* inhibition = calloc((size_t)g->node_count, sizeof(double));
|
||||
if (!inhibition) {
|
||||
free(best_bg); free(best_hops); free(reached); free(seeds); free(fr);
|
||||
return out;
|
||||
free(cosq); return out;
|
||||
}
|
||||
for (int64_t ei = 0; ei < g->edge_count; ei++) {
|
||||
EngramEdge* e = &g->edges[ei];
|
||||
@@ -7513,7 +7791,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
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); return out;
|
||||
free(fr); free(inhibition); free(cosq); return out;
|
||||
}
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
if (!reached[i] || best_bg[i] <= 0.0) continue;
|
||||
@@ -7542,6 +7820,16 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
if (inh > 1.0) inh = 1.0;
|
||||
double suppress = 1.0 - (1.0 - ENGRAM_INHIBITION_FACTOR) * inh;
|
||||
raw_wm *= suppress;
|
||||
/* 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. */
|
||||
if (cosq && cosq[i] > ENGRAM_EMBED_S0) {
|
||||
raw_wm += ENGRAM_EMBED_WM_WEIGHT
|
||||
* (cosq[i] - 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) {
|
||||
@@ -7760,7 +8048,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
int64_t rcount = 0;
|
||||
if (!results) {
|
||||
free(best_bg); free(best_hops); free(reached); free(seeds);
|
||||
free(fr); free(inhibition); free(wm_weights); return out;
|
||||
free(fr); free(inhibition); free(wm_weights); free(cosq); return out;
|
||||
}
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
if (!reached[i]) continue;
|
||||
@@ -7805,6 +8093,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
|
||||
}
|
||||
free(best_bg); free(best_hops); free(reached);
|
||||
free(seeds); free(fr); free(inhibition); free(wm_weights); free(results);
|
||||
free(cosq);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -7849,6 +8138,19 @@ static void engram_emit_node_json(JsonBuf* b, const EngramNode* n) {
|
||||
}
|
||||
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 (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, '}');
|
||||
}
|
||||
|
||||
@@ -8094,6 +8396,10 @@ el_val_t engram_load(el_val_t path) {
|
||||
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);
|
||||
@@ -8294,6 +8600,10 @@ el_val_t engram_load_merge(el_val_t path) {
|
||||
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++;
|
||||
@@ -8738,13 +9048,38 @@ el_val_t engram_wm_top_json(el_val_t n_v) {
|
||||
|
||||
el_val_t engram_stats_json(void) {
|
||||
EngramStore* g = engram_get();
|
||||
char buf[128];
|
||||
/* 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) */
|
||||
int64_t embedded = 0;
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
if (g->nodes[i].emb) embedded++;
|
||||
}
|
||||
char buf[192];
|
||||
snprintf(buf, sizeof(buf),
|
||||
"{\"node_count\":%lld,\"edge_count\":%lld,\"layer_count\":%zu}",
|
||||
(long long)g->node_count, (long long)g->edge_count, g->layer_count);
|
||||
"{\"node_count\":%lld,\"edge_count\":%lld,\"layer_count\":%zu,"
|
||||
"\"embedded_count\":%lld}",
|
||||
(long long)g->node_count, (long long)g->edge_count, g->layer_count,
|
||||
(long long)embedded);
|
||||
return el_wrap_str(el_strdup(buf));
|
||||
}
|
||||
|
||||
/* 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_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) {
|
||||
|
||||
@@ -117,6 +117,15 @@ el_val_t el_min(el_val_t a, el_val_t b);
|
||||
void el_retain(el_val_t v);
|
||||
void el_release(el_val_t v);
|
||||
|
||||
/* ── Arena scoping ────────────────────────────────────────────────────────────
|
||||
* el_arena_push() activates the string arena (if not already active) and
|
||||
* returns a mark; el_arena_pop(mark) frees all strings allocated since that
|
||||
* mark. Used by codegen for per-function/statement scoping and by long-running
|
||||
* EL loops (e.g. the soul daemon's awareness tick) to reclaim per-iteration
|
||||
* allocations. */
|
||||
el_val_t el_arena_push(void);
|
||||
el_val_t el_arena_pop(el_val_t mark);
|
||||
|
||||
/* ── List ────────────────────────────────────────────────────────────────── */
|
||||
|
||||
el_val_t el_list_new(el_val_t count, ...);
|
||||
@@ -142,6 +151,7 @@ el_val_t http_get_with_headers(el_val_t url, el_val_t headers_map);
|
||||
el_val_t http_post_with_headers(el_val_t url, el_val_t body, el_val_t headers_map);
|
||||
el_val_t http_post_form_auth(el_val_t url, el_val_t form_body, el_val_t auth_header);
|
||||
el_val_t http_delete(el_val_t url);
|
||||
el_val_t http_delete_json(el_val_t url, el_val_t json_body);
|
||||
void http_serve(el_val_t port, el_val_t handler);
|
||||
void http_set_handler(el_val_t name);
|
||||
|
||||
@@ -167,6 +177,11 @@ void http_set_handler(el_val_t name);
|
||||
void http_serve_v2(el_val_t port, el_val_t handler);
|
||||
void http_set_handler_v2(el_val_t name);
|
||||
|
||||
/* Non-blocking variant of http_serve: runs the accept loop in a background
|
||||
* pthread and returns immediately so the caller can continue (used by the
|
||||
* soul daemon to run awareness_run() after starting its HTTP API). */
|
||||
void http_serve_async(el_val_t port, el_val_t handler);
|
||||
|
||||
/* Build an HTTP response envelope. `headers_json` should be a JSON object
|
||||
* literal like `{"WWW-Authenticate":"Basic"}` (or "" / "{}" for none). The
|
||||
* returned string carries the discriminator `{"el_http_response":1,...}`
|
||||
@@ -576,6 +591,7 @@ el_val_t engram_list_layers(void);
|
||||
el_val_t engram_get_node(el_val_t id);
|
||||
void engram_strengthen(el_val_t node_id);
|
||||
void engram_forget(el_val_t node_id);
|
||||
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
|
||||
el_val_t engram_node_count(void);
|
||||
el_val_t engram_search(el_val_t query, el_val_t limit);
|
||||
el_val_t engram_scan_nodes(el_val_t limit, el_val_t offset);
|
||||
@@ -594,12 +610,14 @@ el_val_t engram_load(el_val_t path);
|
||||
* can pass results straight through without round-tripping ElList/ElMap
|
||||
* through json_stringify. */
|
||||
el_val_t engram_get_node_json(el_val_t id);
|
||||
el_val_t engram_get_node_by_label(el_val_t label);
|
||||
el_val_t engram_search_json(el_val_t query, el_val_t limit);
|
||||
el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
|
||||
el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction);
|
||||
el_val_t engram_activate_json(el_val_t query, el_val_t depth);
|
||||
el_val_t engram_stats_json(void);
|
||||
el_val_t engram_cosine_sim(el_val_t id_a, el_val_t id_b);
|
||||
el_val_t engram_list_layers_json(void);
|
||||
/* Working memory introspection — count, mean weight, and top-N snapshot.
|
||||
* Ported from el-compiler/runtime on 2026-06-30 self-review. */
|
||||
|
||||
Reference in New Issue
Block a user