From 7f665295103a3b807e999c48ba49ac58e2c0cbc5 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 30 Jul 2026 08:45:15 -0500 Subject: [PATCH] self-review 2026-07-30: WM absolute admission floor + anchor coherence + centroid new-entrant gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Working memory was pinned saturated (24/24, wm_saturated:1 on every heartbeat) because every cap path only trimmed the population down TO the cap — rank-based eviction guarantees a full WM whenever >=24 nodes hold any weight, so sub-cap fill was unreachable and the saturation flag carried no information. - ENGRAM_WM_FLOOR 0.05: absolute admission bar (Soar WM forgetting, Derbinsky & Laird ICCM 2012 — removal by absolute threshold, not rank) applied in Pass 4, carry-over, Pass 5, and load-cap. Fill can now drain below 24 during quiet periods. - Zero wm_anchor at every eviction site: stale anchors on evicted nodes were a latent resurrection bug. - Context centroid folds only NEW WM entrants: incumbents re-promoted every scan no longer re-entrench the centroid each call, breaking the WM->centroid->e_eff->re-selection positive feedback (fixation driver behind the wm_top0_streak=1407 incident). Verified live: wm_active 3->22->23, wm_saturated:0 post-restart. --- lang/releases/v1.0.0-20260501/el_runtime.c | 221 ++++++++++++++++++++- 1 file changed, 212 insertions(+), 9 deletions(-) diff --git a/lang/releases/v1.0.0-20260501/el_runtime.c b/lang/releases/v1.0.0-20260501/el_runtime.c index 149fa4d..5d6ad3f 100644 --- a/lang/releases/v1.0.0-20260501/el_runtime.c +++ b/lang/releases/v1.0.0-20260501/el_runtime.c @@ -5642,6 +5642,22 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal, * 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) ────── @@ -5965,6 +5981,40 @@ static void engram_bll_parse_access(EngramNode* nn, const char* s) { #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 + +static float* _eg_ctx_c = NULL; +static int32_t _eg_ctx_dim = 0; +static double _eg_act_ctx_cos = -2.0; + static int _eg_embed_consec_fail = 0; static int64_t _eg_embed_breaker_until = 0; @@ -6003,6 +6053,38 @@ static double eg_cosine(const float* a, const float* b, int32_t dim) { return dot / (sqrt(na) * sqrt(nb)); } +/* 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). */ @@ -7580,6 +7662,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { /* Reset per-call observability counters (2026-07-27). */ _eg_act_breakthroughs = 0; _eg_act_wm_evicted = 0; + _eg_act_ctx_cos = -2.0; /* ── Embedding backfill + query embedding (2026-07-24, bl-b2d1c944) ── * Backfill: embed up to N un-embedded eligible nodes per call, newest @@ -7621,21 +7704,52 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { q_emb = v; q_dim = d; } } - /* Per-node cosine vs the query, computed once, consumed twice: semantic + /* ── 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; if (q_emb) { + const float* qv = e_eff ? e_eff : 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; + ? eg_cosine(n->emb, qv, q_dim) : -2.0; } } } + free(e_eff); e_eff = NULL; /* only needed to fill cosq */ /* Per-node layer-1 tracking. */ double* best_bg = calloc((size_t)g->node_count, sizeof(double)); @@ -7996,6 +8110,16 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { * 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++; + } + } int64_t cap_count = 0; for (int64_t i = 0; i < g->node_count; i++) { if (wm_weights[i] > 0.0) cap_count++; @@ -8035,6 +8159,19 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { } } + /* 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): @@ -8070,6 +8207,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { double B = engram_bll_base_level(cn, now_ms); if (B < ENGRAM_BLL_TAU) { cn->working_memory_weight = 0.0; + cn->wm_anchor = 0.0; /* keep anchor coherent with eviction */ } else { double keep = 1.0 / (1.0 + exp(-(B - ENGRAM_BLL_TAU) / ENGRAM_BLL_S)); @@ -8081,13 +8219,24 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { if (hold_s < 0.0) hold_s = 0.0; double occ = ENGRAM_CARRY_TC / (ENGRAM_CARRY_TC + hold_s); double w = anchor * keep * occ; - cn->working_memory_weight = (w < 0.01) ? 0.0 : w; + /* Evict floor raised 0.01 → ENGRAM_WM_FLOOR (2026-07-30): + * one consistent absolute bar across all WM entry/exit paths. */ + if (w < ENGRAM_WM_FLOOR) { + cn->working_memory_weight = 0.0; + cn->wm_anchor = 0.0; + } else { + cn->working_memory_weight = w; + } } } else { 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. */ + * from this fixed point rather than compounding per call. + * 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. */ if (wm_weights[i] > 0.0) g->nodes[i].wm_anchor = wm_weights[i]; + else g->nodes[i].wm_anchor = 0.0; } } @@ -8101,6 +8250,19 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { * 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++; + } + } 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++; @@ -8131,6 +8293,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { continue; /* fills a slot */ } n->working_memory_weight = 0.0; /* evict: over global cap */ + n->wm_anchor = 0.0; /* keep anchor coherent */ } } /* If malloc failed, skip — WM over cap this call, no data corruption. */ @@ -8174,7 +8337,8 @@ 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); free(cosq); return out; + free(fr); free(inhibition); free(wm_weights); free(cosq); + free(was_wm); return out; } for (int64_t i = 0; i < g->node_count; i++) { if (!reached[i]) continue; @@ -8201,6 +8365,28 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { } 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")), @@ -8402,6 +8588,15 @@ static const char* eg_skip_ws(const char* p) { * 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++; @@ -8428,6 +8623,7 @@ static void eg_enforce_wm_cap_on_load(EngramStore* g) { 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 */ } } @@ -8508,7 +8704,7 @@ el_val_t engram_load(el_val_t path) { * continuity across a restart while stale pinned weights decay * out over successive boots; sub-0.05 residue drops to zero. */ nn->working_memory_weight *= 0.5; - if (nn->working_memory_weight < 0.05) nn->working_memory_weight = 0.0; + 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 @@ -9218,13 +9414,20 @@ el_val_t engram_stats_json(void) { el_val_t engram_act_stats_json(void) { int64_t now = engram_now_ms(); int breaker_open = (now < _eg_embed_breaker_until) ? 1 : 0; - char buf[192]; + char buf[256]; + /* 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," - "\"embed_breaker_open\":%d,\"embed_consec_fail\":%d}", + "\"embed_breaker_open\":%d,\"embed_consec_fail\":%d," + "\"ctx_cos\":%.3f}", (long long)_eg_act_wm_evicted, (long long)_eg_act_breakthroughs, - breaker_open, _eg_embed_consec_fail); + breaker_open, _eg_embed_consec_fail, + _eg_act_ctx_cos); return el_wrap_str(el_strdup(buf)); }