self-review 2026-08-07: learning that cannot outlive the process is not learning

Yesterday's eligibility-trace fix made Hebbian consolidation numerically real:
hebb_max 0.000799 -> 0.4725, and 1,198 hebbian-associate edges formed in 23h48m.
This morning's census found where they went: nowhere.

  soul daemon (in-process graph):   42,426 edges, 1,198 hebbian
  engram server (:8742, durable):   41,213 edges,    49 hebbian

Two processes, two graphs, one direction of travel. The soul pulls from the
server every 10 min (GET /api/sync) and never pushes. It cannot fall back on
saving its own copy either: soul.el sets soul_snapshot_path only inside
`if is_genesis && safe_to_seed`, and safe_to_seed is unconditionally false
whenever ENGRAM_URL is set -- because the server owns persistence and a soul
writing snapshot.json would clobber it. That guard is correct. The consequence
was not: mem_save() has never once executed. The soul is the ONLY process
running idle cognition, so it is where essentially all co-activation happens --
and it was throwing away every association it learned, every restart, silently.
The mechanism worked and the learning still evaporated.

Consolidation is now a message, not a file. Fast volatile store hands each
newly-formed association to the slow durable store over the API the server
already exposes; only edges past ENGRAM_HEBB_LINK_MIN are ever queued, so what
crosses the process boundary already earned it.

- el_runtime.c: 512-slot overwrite-oldest write-back ring; enqueue at edge
  formation; engram_hebb_drain_json() pops a postable JSON batch. Drops and
  drains are counted, not silent -- a consolidation path that quietly discards
  is the exact failure this entry exists to correct.
- server.el: POST /api/edges/batch. persist_canonical() writes the full 60MB
  snapshot per call, and route_create_edge calls it per edge -- correct for one
  interactive edge, ruinous for bulk (~840MB/beat to persist 14 associations).
  Batch connects all, snapshots once. Same durability, 1/N the writes.
- act-stats: hebb_wb_pending / _drained / _dropped. pending climbing with
  drained flat = drain not called; drained climbing with sent 0 = POST refused.
  Both failure modes are now visible in the stream instead of in an autopsy.

Verified live: batch route accepts valid entries, skips malformed ones without
aborting the batch, and enforces _auth. All 1,256 learned associations are now
in the canonical store; the soul booted at 42,431 edges with hebb_max 0.4941
carried across the restart for the first time.
This commit is contained in:
2026-08-07 08:46:37 -05:00
parent 9f1db8278c
commit 971b21751a
4 changed files with 263 additions and 15 deletions
+140 -1
View File
@@ -6250,6 +6250,79 @@ static void engram_bll_parse_access(EngramNode* nn, const char* s) {
* learn real associations, not enough to drown what was authored. */
#define ENGRAM_HEBB_LINK_MAX_FRAC 0.05
/* ── Systems consolidation: the durable write-back queue (2026-08-07) ────────
*
* THE DEFECT THIS CLOSES. Yesterday's eligibility-trace fix made Hebbian
* learning numerically real: hebb_max went 0.000799 0.4725 and 1,198
* `hebbian-associate` edges formed in 23h48m. Today's census found where they
* went: nowhere. Measured on the live system
*
* soul daemon (pid 3269, in-process graph): 42,426 edges, 1,198 hebbian
* engram server (:8742, the persistent store): 41,213 edges, 49 hebbian
*
* Two processes, two graphs, one direction of travel. The soul pulls from the
* server every 10 min via GET /api/sync and merges. It never pushes. And it
* cannot fall back on saving its own copy: `soul_snapshot_path` is set only
* inside `if is_genesis && safe_to_seed` in soul.el, and safe_to_seed is
* unconditionally false whenever ENGRAM_URL is set (it is, in the launchd
* plist) because the HTTP server owns persistence and a soul that wrote
* snapshot.json would clobber it. That guard is correct. The consequence was
* not: mem_save() has never once executed, so every association the soul
* learns lives in RAM until the process dies.
*
* The soul is the ONLY process that runs idle cognition curiosity scans
* every ~8 min, around the clock. It is where essentially all co-activation
* happens. So the system's entire capacity to grow its own structure was
* pointed at a volatile store. 1,198 associations/day, discarded at restart,
* every day, silently. The mechanism worked and the learning still evaporated.
*
* WHY A QUEUE AND NOT A SAVE. The fix is not to let the soul write the
* snapshot that reintroduces the clobber the guard exists to prevent. It is
* to make consolidation a MESSAGE, not a file: the fast volatile store hands
* each newly-formed association to the slow durable store, one edge at a time,
* over the API the server already exposes (POST /api/edges). This is the
* hippocampalneocortical split the rest of this file is already modeled on.
* Fast store learns online and forgets; slow store receives what survived the
* threshold and keeps it. Only edges that already cleared ENGRAM_HEBB_LINK_MIN
* are enqueued, so what crosses the process boundary is what earned it.
*
* SHAPE. Fixed 512-slot ring, overwrite-oldest. 512 is ~18x the observed
* formation rate per drain interval (14 links per 8-min heartbeat), so the
* queue only saturates when the writer is down and when it is, keeping the
* freshest associations is the right loss. Drops are counted, not silent:
* a consolidation path that quietly discards is the failure mode this whole
* entry exists to correct. Enqueue is strdup'd because g->edges may realloc
* and node ids may be freed by later pruning; the queue owns its copies. */
#define ENGRAM_HEBB_WB_SLOTS 512
typedef struct { char* a; char* b; double w; double hebb; } EgHebbWB;
static EgHebbWB _eg_hebb_wb[ENGRAM_HEBB_WB_SLOTS];
static int _eg_hebb_wb_head = 0; /* index of the oldest live entry */
static int _eg_hebb_wb_len = 0;
static int64_t _eg_hebb_wb_dropped = 0; /* lost to a full queue, cumulative */
static int64_t _eg_hebb_wb_drained = 0; /* handed to the durable store, cum. */
static void eg_hebb_wb_push(const char* a, const char* b, double w, double h) {
if (!a || !b) return;
int slot;
if (_eg_hebb_wb_len >= ENGRAM_HEBB_WB_SLOTS) {
slot = _eg_hebb_wb_head;
free(_eg_hebb_wb[slot].a);
free(_eg_hebb_wb[slot].b);
_eg_hebb_wb_head = (_eg_hebb_wb_head + 1) % ENGRAM_HEBB_WB_SLOTS;
_eg_hebb_wb_dropped++;
} else {
slot = (_eg_hebb_wb_head + _eg_hebb_wb_len) % ENGRAM_HEBB_WB_SLOTS;
_eg_hebb_wb_len++;
}
_eg_hebb_wb[slot].a = strdup(a);
_eg_hebb_wb[slot].b = strdup(b);
_eg_hebb_wb[slot].w = w;
_eg_hebb_wb[slot].hebb = h;
/* strdup failure leaves a NULL id; the drain skips those rather than
* emitting a malformed edge. */
}
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;
@@ -9314,6 +9387,13 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
_eg_hebb_links_formed++;
hebb_edge_total++;
formed++;
/* Hand the association to the durable store. In the soul
* daemon this local edge is the ONLY copy and dies with the
* process see the ENGRAM_HEBB_WB_SLOTS block. Enqueue
* from ne->from_id/to_id rather than c->a/c->b: the slot is
* cleared on the next line and the edge now owns the ids. */
eg_hebb_wb_push(ne->from_id, ne->to_id,
ne->weight, ne->hebb);
eg_hebb_slot_clear(c); /* the edge is the record now */
}
}
@@ -10457,7 +10537,10 @@ el_val_t engram_act_stats_json(void) {
if (_eg_hebb_cand[i].score > hebb_cand_max)
hebb_cand_max = _eg_hebb_cand[i].score;
}
char buf[512];
/* 768, not 512: the write-back gauges added 2026-08-07 push the worst-case
* rendering past the old bound, and snprintf would truncate the JSON into
* an unparseable tail rather than fail loudly. */
char buf[768];
/* 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
@@ -10470,6 +10553,8 @@ el_val_t engram_act_stats_json(void) {
"\"hebb_edges\":%lld,\"hebb_max\":%.4f,\"hebb_mass\":%.3f,"
"\"hebb_cands\":%d,\"hebb_cand_max\":%.4f,\"hebb_links\":%lld,"
"\"hebb_warm\":%d,"
"\"hebb_wb_pending\":%d,\"hebb_wb_drained\":%lld,"
"\"hebb_wb_dropped\":%lld,"
"\"dup_seeds\":%lld,\"dup_wm\":%lld,\"dup_wm_global\":%lld}",
(long long)_eg_act_wm_evicted,
(long long)_eg_act_breakthroughs,
@@ -10478,11 +10563,65 @@ el_val_t engram_act_stats_json(void) {
(long long)hebb_edges, hebb_max, hebb_mass,
hebb_cands, hebb_cand_max, (long long)_eg_hebb_links_formed,
_eg_act_hebb_warm,
_eg_hebb_wb_len, (long long)_eg_hebb_wb_drained,
(long long)_eg_hebb_wb_dropped,
(long long)_eg_act_dup_seeds, (long long)_eg_act_dup_wm,
(long long)_eg_act_dup_wm_global);
return el_wrap_str(el_strdup(buf));
}
/* engram_hebb_drain_json — pop up to `max` newly-formed Hebbian associations
* off the write-back queue and return them as a JSON array:
*
* [{"from_id":"...","to_id":"...","weight":0.15,"hebb":0.31}, ...]
*
* Draining is DESTRUCTIVE: entries returned here are gone from the queue. The
* caller owns delivery from that point on. That is deliberate the alternative
* (peek, deliver, ack) needs a second round trip and a retry ledger to be
* correct, and the payload is an association that will re-form from live
* co-activation if it genuinely matters. Losing one is cheap; a queue that
* silently refills forever because acks never land is not.
*
* Empty queue returns "[]". See the ENGRAM_HEBB_WB_SLOTS block for why this
* exists at all: the process that does the learning is not the process that
* owns persistence. (2026-08-07 self-review.) */
el_val_t engram_hebb_drain_json(el_val_t max_v) {
int64_t max_n = (int64_t)max_v;
if (max_n <= 0) max_n = 64;
if (max_n > ENGRAM_HEBB_WB_SLOTS) max_n = ENGRAM_HEBB_WB_SLOTS;
JsonBuf b; jb_init(&b);
jb_puts(&b, "[");
int emitted = 0;
while (_eg_hebb_wb_len > 0 && emitted < (int)max_n) {
EgHebbWB* e = &_eg_hebb_wb[_eg_hebb_wb_head];
if (e->a && e->b) {
if (emitted > 0) jb_puts(&b, ",");
/* relation is emitted here, not stamped on by the caller: the
* payload should be postable to /api/edges/batch verbatim. A
* consumer that has to rewrite the JSON to make it valid is a
* consumer that will eventually rewrite it wrong. */
jb_puts(&b, "{\"relation\":\"hebbian-associate\",\"from_id\":");
jb_emit_escaped(&b, e->a);
jb_puts(&b, ",\"to_id\":");
jb_emit_escaped(&b, e->b);
char tmp[96];
snprintf(tmp, sizeof(tmp), ",\"weight\":%.6g,\"hebb\":%.6g}",
e->w, e->hebb);
jb_puts(&b, tmp);
emitted++;
_eg_hebb_wb_drained++;
}
/* Free and advance whether or not the entry rendered — a NULL id is a
* strdup failure at push time, not a retryable condition. */
free(e->a); free(e->b);
e->a = NULL; e->b = NULL;
_eg_hebb_wb_head = (_eg_hebb_wb_head + 1) % ENGRAM_HEBB_WB_SLOTS;
_eg_hebb_wb_len--;
}
jb_puts(&b, "]");
return el_wrap_str(b.buf ? b.buf : el_strdup("[]"));
}
/* engram_cosine_sim — cosine similarity between two nodes' embeddings.
* Returns a float in [-1, 1], or -2.0 when either node is missing or not
* yet embedded. Exposed so EL code (and the introspection API) can probe
@@ -619,6 +619,11 @@ el_val_t engram_activate_json(el_val_t query, el_val_t depth);
el_val_t engram_stats_json(void);
el_val_t engram_act_stats_json(void);
el_val_t engram_cosine_sim(el_val_t id_a, el_val_t id_b);
/* Destructively pop up to `max` newly-formed Hebbian associations as a JSON
* array of {from_id,to_id,weight,hebb}. The learning process (soul daemon) is
* not the process that owns persistence (engram HTTP server); this is how a
* self-formed association crosses that boundary. (2026-08-07 self-review.) */
el_val_t engram_hebb_drain_json(el_val_t max);
/* Document frequency of a term across node labels — term-specificity signal
* for curiosity seed selection. (2026-08-03 self-review.) */
el_val_t engram_label_df(el_val_t term);