self-review 2026-07-30: WM absolute admission floor + anchor coherence + centroid new-entrant gate
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.
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user