self-review 2026-07-22: ACT-R/Petrov base-level WM decay replaces per-call multiplicative carry-over

The old carry-over (weight *= 0.7 per engram_activate call) was call-rate-
dependent — carried context died in seconds under rapid curiosity scans and
lingered for hours under quiet loops — and a decayed scalar cannot represent
access frequency at all.

Now: k=10 access-timestamp ring + Petrov (2006) closed-form tail, d=0.5.
WM promotion and engram_strengthen record presentations; carry-over evicts
at base-level tau=-3.0 (Soar forgetting, ~403s single-touch) and shapes the
weight held at promotion (wm_anchor) with the ACT-R retrieval logistic
(s=0.4) — a pure function of wall-clock time, idempotent per call.
Persisted as access_ts/wm_anchor in snapshots; legacy nodes fall back to
the optimized form ln(n/(1-d)) - d*ln(L). base_level exposed in both node
serializers for observability.

Backing spec: 2026-07-21 integration brief (bl-b17facdd). Verified live:
carried weight ~anchor seconds after two disjoint activations (old code:
0.49x); frequency-hot nodes hold B=1.9 vs -0.14 single-touch.
This commit is contained in:
2026-07-22 08:44:39 -05:00
parent dc39a61e2c
commit 409ec99397
3 changed files with 207 additions and 11 deletions
+174 -9
View File
@@ -5581,10 +5581,10 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal,
* 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: per-turn decay applied to working_memory_weight for
* nodes NOT re-activated in the current turn (conversational thread
* continuity: a node promoted in turn N persists with reduced weight
* into turn N+1 without re-activation cost).
* 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.
@@ -5617,6 +5617,42 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal,
#define ENGRAM_WM_CAP 24
#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
/* 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;
@@ -5753,8 +5789,75 @@ typedef struct EngramNode {
* 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;
} 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/(1d)) 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·(nk)/(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++;
}
}
typedef struct EngramEdge {
char* id;
char* from_id;
@@ -6284,6 +6387,10 @@ static el_val_t engram_node_to_map(const EngramNode* n) {
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())));
return m;
}
@@ -6579,6 +6686,8 @@ void engram_strengthen(el_val_t node_id) {
n->activation_count++;
n->last_activated = engram_now_ms();
n->updated_at = n->last_activated;
/* Explicit strengthen is a presentation too (2026-07-22 self-review). */
engram_bll_record_access(n, n->last_activated);
}
void engram_forget(el_val_t node_id) {
@@ -7093,7 +7202,7 @@ static double engram_temporal_proximity_bonus(int64_t node_created,
*
* Working memory persistence (turn continuity):
* Nodes promoted in the previous turn retain a decayed working_memory_weight
* (weight *= ENGRAM_WM_DECAY) without needing re-activation. This models
* (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.
*
@@ -7527,12 +7636,34 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
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. Apply decay
* rather than zero so recently-active context persists. */
double decayed = g->nodes[i].working_memory_weight * ENGRAM_WM_DECAY;
g->nodes[i].working_memory_weight = (decayed < 0.01) ? 0.0 : decayed;
* 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. */
EngramNode* cn = &g->nodes[i];
double anchor = (cn->wm_anchor > 0.0) ? cn->wm_anchor
: cn->working_memory_weight;
double B = engram_bll_base_level(cn, now_ms);
if (B < ENGRAM_BLL_TAU) {
cn->working_memory_weight = 0.0;
} else {
double keep = 1.0 / (1.0 + exp(-(B - ENGRAM_BLL_TAU)
/ ENGRAM_BLL_S));
double w = anchor * keep;
cn->working_memory_weight = (w < 0.01) ? 0.0 : 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. */
if (wm_weights[i] > 0.0) g->nodes[i].wm_anchor = wm_weights[i];
}
}
@@ -7605,6 +7736,9 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
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);
}
/* ── Collect all background-activated nodes for the return value ────
@@ -7688,6 +7822,23 @@ static void engram_emit_node_json(JsonBuf* b, const EngramNode* n) {
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);
/* 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, '"');
}
jb_putc(b, '}');
}
@@ -7926,6 +8077,13 @@ el_val_t engram_load(el_val_t path) {
} 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); }
}
int64_t load_idx = g->node_count;
g->node_count++;
if (nn->id && *nn->id) engram_idmap_put(g, nn->id, load_idx);
@@ -8119,6 +8277,13 @@ el_val_t engram_load_merge(el_val_t path) {
} 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); }
}
int64_t merge_idx = g->node_count;
g->node_count++;
added_nodes++;