self-review 2026-08-04: restore working-memory continuity; learn graph structure from co-activation
WM continuity (the significant one). A node reached by the current query but scoring under its type threshold was zeroed outright, while a node the query did NOT reach got the full ACT-R carry-over treatment. Being found was punished relative to not being found. Measured consequence: WM turned over 100% every call — three activations of a byte-identical query gave |A∩B| = |B∩C| = 0 — and wm_evicted stayed 0 the whole time because that path never counted. WM was not a working set; it was six suppression-breakthrough nodes re-drawn per call. Both exits from a WM slot now share one extracted retention rule. Result: WM 6 -> 24 nodes (the designed Cowan capacity), top weight 0.097 -> 0.748 (natural promotion, not the breakthrough floor), and contents that are actually query-relevant. Hebbian learning. Edge weights were written once at engram_connect and never changed; last_fired's only writer in 12.5k lines was an unrelated dharma path. Every learning mechanism operated on nodes — the wiring between them was frozen. Adds co-activation potentiation (HeLa-Mem arXiv:2604.16839) in a separate `hebb` field so authored structure is never mutated, with homeostatic per-node scaling the source lacks (PNAS 2422602122) to prevent hub saturation. Measuring it produced the finding that mattered: zero edges existed between co-active WM members, so reweighting existing edges was a no-op. This graph's 41k edges were all authored by explicit tool calls — nothing had ever formed an association from experience. So Hebb literally: if the wire is absent, grow it. Consolidation is gated hard (sustained EWMA past 0.15, <=2/call, 5% ceiling, in-memory candidates discarded on restart) because it permanently mutates the graph. Two bugs caught only by instrumenting rather than assuming: the snap-to-zero floor sat above the per-step increment, so nothing could ever accumulate; and the reached-but-sub-threshold eviction above. Verified live end to end — 53 links formed under load, then discarded with the test snapshot. Also exposes engram_act_stats_json over GET /api/act-stats. It had existed since 2026-07-27 but was reachable only through the soul daemon, so diagnosing the activation layer required a working soul. This review needed it and could not get at it.
This commit is contained in:
@@ -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")) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user