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()
|
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
|
// (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
|
// inner scope only — it does NOT mutate the outer binding (documented with
|
||||||
// evidence in awareness.el, 2026-05-25). Every default/reassignment below used
|
// 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")) {
|
if str_eq(method, "GET") && (str_eq(clean, "/api/stats") || str_eq(clean, "/stats")) {
|
||||||
return route_stats(method, path, body)
|
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
|
// Nodes
|
||||||
if str_eq(method, "POST") && (str_eq(clean, "/api/nodes") || str_eq(clean, "/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_QALPHA 0.65
|
||||||
#define ENGRAM_CTX_TOUCH_MAX 8
|
#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 float* _eg_ctx_c = NULL;
|
||||||
static int32_t _eg_ctx_dim = 0;
|
static int32_t _eg_ctx_dim = 0;
|
||||||
static double _eg_act_ctx_cos = -2.0;
|
static double _eg_act_ctx_cos = -2.0;
|
||||||
@@ -6239,6 +6367,11 @@ typedef struct EngramEdge {
|
|||||||
char* relation;
|
char* relation;
|
||||||
char* metadata;
|
char* metadata;
|
||||||
double weight;
|
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;
|
double confidence;
|
||||||
int64_t created_at;
|
int64_t created_at;
|
||||||
int64_t updated_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("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("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("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("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("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);
|
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.
|
/* Compute temporal decay factor for a node given current time.
|
||||||
* effective contribution = salience * exp(-lambda * age_hours / T_half)
|
* effective contribution = salience * exp(-lambda * age_hours / T_half)
|
||||||
* Clamped to [0.05, 1.0] so very old nodes retain a meaningful floor. */
|
* 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) {
|
static double engram_temporal_decay(const EngramNode* n, int64_t now_ms) {
|
||||||
int64_t age_ms = now_ms - n->last_activated;
|
int64_t age_ms = now_ms - n->last_activated;
|
||||||
if (age_ms <= 0) return 1.0;
|
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;
|
double c = cosq[oi] > 0.0 ? cosq[oi] : 0.0;
|
||||||
qgate = ENGRAM_QGATE_FLOOR + (1.0 - ENGRAM_QGATE_FLOOR) * c;
|
qgate = ENGRAM_QGATE_FLOOR + (1.0 - ENGRAM_QGATE_FLOOR) * c;
|
||||||
}
|
}
|
||||||
double new_act = f.act * e->weight * SPREAD_DECAY * (1.0 + tbonus)
|
/* eg_edge_eff_weight, not e->weight: edges that have repeatedly
|
||||||
* tdecay * dampen * qgate;
|
* 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
|
/* Firing threshold per classic spreading-activation: sub-threshold
|
||||||
* activation neither updates the target nor enqueues it, so weak
|
* activation neither updates the target nor enqueues it, so weak
|
||||||
* signals die out instead of flooding the whole graph with tiny
|
* 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
|
* above τ, shape the weight held at promotion (wm_anchor) by the
|
||||||
* retrieval-probability logistic. Pure function of wall-clock
|
* retrieval-probability logistic. Pure function of wall-clock
|
||||||
* time — idempotent no matter how often activate is called. */
|
* time — idempotent no matter how often activate is called. */
|
||||||
EngramNode* cn = &g->nodes[i];
|
eg_wm_carry_over(&g->nodes[i], now_ms, &_eg_act_wm_evicted);
|
||||||
double anchor = (cn->wm_anchor > 0.0) ? cn->wm_anchor
|
} else if (wm_weights[i] > 0.0) {
|
||||||
: 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 {
|
|
||||||
g->nodes[i].working_memory_weight = wm_weights[i];
|
g->nodes[i].working_memory_weight = wm_weights[i];
|
||||||
/* Anchor the promotion weight: carry-over decay above computes
|
/* 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
|
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
|
* anchor on an evicted node was a latent resurrection bug if the
|
||||||
* carry-over entry guard ever changes. */
|
* carry-over entry guard ever changes. */
|
||||||
if (wm_weights[i] > 0.0) g->nodes[i].wm_anchor = wm_weights[i];
|
g->nodes[i].wm_anchor = 0.0;
|
||||||
else 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);
|
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 ────
|
/* ── Collect all background-activated nodes for the return value ────
|
||||||
* Callers see both layers. Context compilation uses only promoted nodes
|
* Callers see both layers. Context compilation uses only promoted nodes
|
||||||
* (working_memory_weight > 0). Sort: promoted first by wm_weight desc,
|
* (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 : "{}");
|
jb_puts(b, ",\"metadata\":"); jb_emit_escaped(b, e->metadata ? e->metadata : "{}");
|
||||||
char tmp[64];
|
char tmp[64];
|
||||||
snprintf(tmp, sizeof(tmp), ",\"weight\":%g", e->weight); jb_puts(b, tmp);
|
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), ",\"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), ",\"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);
|
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");
|
ee->metadata = eg_get_str_field(obj, "metadata");
|
||||||
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup_persist("{}"); }
|
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = el_strdup_persist("{}"); }
|
||||||
ee->weight = eg_get_num_field(obj, "weight");
|
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->confidence = eg_get_num_field(obj, "confidence");
|
||||||
ee->created_at = eg_get_int_field(obj, "created_at");
|
ee->created_at = eg_get_int_field(obj, "created_at");
|
||||||
ee->updated_at = eg_get_int_field(obj, "updated_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");
|
ee->metadata = eg_get_str_field(obj, "metadata");
|
||||||
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = strdup("{}"); }
|
if (!ee->metadata || !*ee->metadata) { free(ee->metadata); ee->metadata = strdup("{}"); }
|
||||||
ee->weight = eg_get_num_field(obj, "weight");
|
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->confidence = eg_get_num_field(obj, "confidence");
|
||||||
ee->created_at = eg_get_int_field(obj, "created_at");
|
ee->created_at = eg_get_int_field(obj, "created_at");
|
||||||
ee->updated_at = eg_get_int_field(obj, "updated_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) {
|
el_val_t engram_act_stats_json(void) {
|
||||||
int64_t now = engram_now_ms();
|
int64_t now = engram_now_ms();
|
||||||
int breaker_open = (now < _eg_embed_breaker_until) ? 1 : 0;
|
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
|
/* ctx_cos (2026-07-29): cos(query, context centroid) at the LAST
|
||||||
* activate call, measured before the query was folded in. ~1.0 =
|
* activate call, measured before the query was folded in. ~1.0 =
|
||||||
* context aligned with current query; low = divergence (expected at
|
* 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),
|
snprintf(buf, sizeof(buf),
|
||||||
"{\"wm_evicted\":%lld,\"breakthroughs\":%lld,"
|
"{\"wm_evicted\":%lld,\"breakthroughs\":%lld,"
|
||||||
"\"embed_breaker_open\":%d,\"embed_consec_fail\":%d,"
|
"\"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_wm_evicted,
|
||||||
(long long)_eg_act_breakthroughs,
|
(long long)_eg_act_breakthroughs,
|
||||||
breaker_open, _eg_embed_consec_fail,
|
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));
|
return el_wrap_str(el_strdup(buf));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user