diff --git a/engram/src/server.el b/engram/src/server.el index 2cab032..004a2d1 100644 --- a/engram/src/server.el +++ b/engram/src/server.el @@ -76,6 +76,20 @@ fn route_stats(method: String, path: String, body: String) -> String { engram_stats_json() } +// route_act_stats — GET /api/act-stats +// (2026-08-04 self-review) engram_act_stats_json() has existed since the +// 2026-07-27 review but was reachable ONLY through the soul daemon's heartbeat +// binding. Every activation-layer gauge — WM evictions, breakthroughs, embedder +// breaker state, context drift, and now the Hebbian counters — was therefore +// invisible unless the soul happened to be running and its ISEs were read back +// out of the store. Diagnosing the activation layer required a working soul, +// which is exactly backwards: the lower layer should be observable on its own. +// This review needed it to verify link formation and could not get at it. One +// line of plumbing, and the whole activation layer becomes directly diagnosable. +fn route_act_stats(method: String, path: String, body: String) -> String { + engram_act_stats_json() +} + // (2026-07-18 self-review) Scoping sweep: `let` inside an if-block creates an // inner scope only — it does NOT mutate the outer binding (documented with // evidence in awareness.el, 2026-05-25). Every default/reassignment below used @@ -507,6 +521,9 @@ fn handle_request(method: String, path: String, body: String) -> String { if str_eq(method, "GET") && (str_eq(clean, "/api/stats") || str_eq(clean, "/stats")) { return route_stats(method, path, body) } + if str_eq(method, "GET") && (str_eq(clean, "/api/act-stats") || str_eq(clean, "/act-stats")) { + return route_act_stats(method, path, body) + } // Nodes if str_eq(method, "POST") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/nodes")) { diff --git a/lang/releases/v1.0.0-20260501/el_runtime.c b/lang/releases/v1.0.0-20260501/el_runtime.c index 4cc00dd..9c05363 100644 --- a/lang/releases/v1.0.0-20260501/el_runtime.c +++ b/lang/releases/v1.0.0-20260501/el_runtime.c @@ -6048,6 +6048,134 @@ static void engram_bll_parse_access(EngramNode* nn, const char* s) { #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. */ +#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 + +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; + static float* _eg_ctx_c = NULL; static int32_t _eg_ctx_dim = 0; static double _eg_act_ctx_cos = -2.0; @@ -6239,6 +6367,11 @@ typedef struct EngramEdge { 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; @@ -7447,6 +7580,7 @@ static el_val_t engram_edge_to_map(const EngramEdge* e) { 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); @@ -7529,6 +7663,91 @@ el_val_t engram_edge_count(void) { /* 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)++; + } 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. */ +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; +} + +/* 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; +} + static double engram_temporal_decay(const EngramNode* n, int64_t now_ms) { int64_t age_ms = now_ms - n->last_activated; if (age_ms <= 0) return 1.0; @@ -7987,8 +8206,11 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { double c = cosq[oi] > 0.0 ? cosq[oi] : 0.0; qgate = ENGRAM_QGATE_FLOOR + (1.0 - ENGRAM_QGATE_FLOOR) * c; } - double new_act = f.act * e->weight * SPREAD_DECAY * (1.0 + tbonus) - * tdecay * dampen * qgate; + /* 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; /* 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 @@ -8291,44 +8513,45 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { * 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; - cn->wm_anchor = 0.0; /* keep anchor coherent with eviction */ - _eg_act_wm_evicted++; /* was uncounted before 2026-08-02 */ - } else { - double keep = 1.0 / (1.0 + exp(-(B - ENGRAM_BLL_TAU) - / ENGRAM_BLL_S)); - /* Occupancy inhibition (2026-07-26): decay the carried - * weight with hold time so an unreached incumbent cannot - * hold its anchor verbatim indefinitely. See - * ENGRAM_CARRY_TC comment for derivation. */ - 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); - double w = anchor * keep * occ; - /* 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; - _eg_act_wm_evicted++; /* was uncounted before 2026-08-02 */ - } else { - cn->working_memory_weight = w; - } - } - } else { + 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. - * Zero the anchor when the slot empties (2026-07-30): a stale + * 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. */ - if (wm_weights[i] > 0.0) g->nodes[i].wm_anchor = wm_weights[i]; - else g->nodes[i].wm_anchor = 0.0; + g->nodes[i].wm_anchor = 0.0; } } @@ -8421,6 +8644,185 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { 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++; + } + } + /* 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. */ + for (int64_t ei = 0; ei < g->edge_count; ei++) { + EngramEdge* e = &g->edges[ei]; + double h = e->hebb * ENGRAM_HEBB_DECAY; + if (wm_n > 1 && !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 + && in_wm[a] && in_wm[b]) { + h += ENGRAM_HEBB_ETA; + 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; + } + /* Reinforce every co-active pair's candidate score. */ + for (int x = 0; x < wm_k; x++) { + for (int y = x + 1; y < wm_k; y++) { + const char* ia = g->nodes[wm_idx[x]].id; + const char* ib = g->nodes[wm_idx[y]].id; + if (!ia || !ib) continue; + int s = eg_hebb_slot(ia, ib); + if (s < 0) continue; + EgHebbCand* c = &_eg_hebb_cand[s]; + if (!c->a) { /* free slot: claim */ + c->a = el_strdup_persist(ia); + c->b = el_strdup_persist(ib); + c->score = ENGRAM_HEBB_ETA; + } else if (eg_hebb_slot_holds(c, ia, ib)) { + c->score += ENGRAM_HEBB_ETA; /* ours: reinforce */ + } else if (c->score <= ENGRAM_HEBB_ETA) { + eg_hebb_slot_clear(c); /* dead incumbent */ + c->a = el_strdup_persist(ia); + c->b = el_strdup_persist(ib); + c->score = ENGRAM_HEBB_ETA; + } + /* else: live incumbent keeps the slot this round. */ + } + } + /* 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); + for (int s = 0; s < ENGRAM_HEBB_CAND_SLOTS + && formed < ENGRAM_HEBB_LINK_PER_CALL + && hebb_edge_total < hebb_edge_cap; s++) { + EgHebbCand* c = &_eg_hebb_cand[s]; + if (!c->a || c->score < ENGRAM_HEBB_LINK_MIN) continue; + 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++; + g->adj_dirty = 1; + _eg_hebb_links_formed++; + hebb_edge_total++; + formed++; + 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, @@ -8574,6 +8976,12 @@ static void engram_emit_edge_json(JsonBuf* b, const EngramEdge* e) { 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); @@ -8859,6 +9267,7 @@ el_val_t engram_load(el_val_t path) { 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"); @@ -9082,6 +9491,7 @@ el_val_t engram_load_merge(el_val_t path) { 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"); @@ -9515,7 +9925,41 @@ 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[256]; + /* 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; + } + char buf[512]; /* 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 @@ -9524,11 +9968,15 @@ el_val_t engram_act_stats_json(void) { snprintf(buf, sizeof(buf), "{\"wm_evicted\":%lld,\"breakthroughs\":%lld," "\"embed_breaker_open\":%d,\"embed_consec_fail\":%d," - "\"ctx_cos\":%.3f}", + "\"ctx_cos\":%.3f," + "\"hebb_edges\":%lld,\"hebb_max\":%.4f,\"hebb_mass\":%.3f," + "\"hebb_cands\":%d,\"hebb_cand_max\":%.4f,\"hebb_links\":%lld}", (long long)_eg_act_wm_evicted, (long long)_eg_act_breakthroughs, breaker_open, _eg_embed_consec_fail, - _eg_act_ctx_cos); + _eg_act_ctx_cos, + (long long)hebb_edges, hebb_max, hebb_mass, + hebb_cands, hebb_cand_max, (long long)_eg_hebb_links_formed); return el_wrap_str(el_strdup(buf)); }