self-review 2026-08-07: push what was learned; stop a read route writing the canonical store

Two fixes, one found by making the other.

1. HEBBIAN WRITE-BACK. This daemon learned 1,198 associations in 23h48m and
kept none of them: it syncs FROM the engram server and never pushes, and
mem_save() is unreachable in HTTP mode by design (soul.el only sets
soul_snapshot_path inside `is_genesis && safe_to_seed`, false whenever
ENGRAM_URL is set, because the server owns persistence). So the one process
that runs idle cognition -- where essentially all co-activation happens -- was
the one process that could not remember what it learned.

hebb_consolidate() now drains the runtime's write-back queue on every heartbeat
and POSTs it as ONE batch to /api/edges/batch. One request, one durable write,
not one 60MB snapshot per edge. Also drains on clean shutdown, so an exit
between beats doesn't take the last 8 minutes of learning with it.

_auth is required and its absence is silent: check_auth_ok exempts GET and
/api/neuron/state-events (which is why ise_post works keyless) but gates every
other mutation on "_auth" in the BODY -- http_serve surfaces no headers, so
there is no Bearer path. An unauthorized reply is NON-EMPTY, so the obvious
`if resp == "" return 0` check would have reported delivery of edges that were
refused, after the drain had already destroyed them. Caught before it shipped.
Gauges hebb_wb_pending/_drained/_dropped/_sent go into the heartbeat so a
consolidation path that stops delivering is visible in the stream.

2. A READ ROUTE MUST NEVER WRITE THE CANONICAL SNAPSHOT. GET /api/graph/edges
serialized this process's graph straight over $HOME/.neuron/engram/snapshot.json
-- the engram SERVER's durable store -- and read the edges back out of it. I
triggered it myself this morning fetching edges for the census above:
snapshot.json went from the server's 41,213 edges to the soul's 42,431, and the
next engram restart loaded the soul's graph as canonical. It happened to be a
superset (Knowledge 1198->1218, Memory 1238->1242, no durable type down), so
nothing was lost. That was luck. Had the soul been running a partial load --
the exact failure soul.el's safe_to_seed guard exists to catch -- one GET would
have destroyed the store, with no write-side guard able to see it coming.

The engram server fixed this same class of bug on 2026-07-21 by routing exports
to a dotted sidecar; the soul kept the original pattern. Same fix: exports go to
.soul-edges-export.json. Also stops a 60MB serialize-and-reread per GET.

Verified: boot 26 loaded 42,432 edges with hebb_max 0.4941 carried across the
restart -- the first time this daemon has ever started knowing what it learned.
This commit is contained in:
2026-08-07 08:46:53 -05:00
parent 97d22ffe44
commit 86e269fa91
12 changed files with 380 additions and 193 deletions
+100 -1
View File
@@ -30,6 +30,74 @@ fn idle_reset() -> Void {
// read decide where telemetry goes. The in-process write remains only as a
// last resort when the HTTP POST itself fails, and is tagged ise-fallback-local
// so misrouting is visible in the stream instead of silent.
// hebb_consolidate push self-formed associations to the durable store.
//
// WHY THIS EXISTS (2026-08-07 self-review, measured on the live system).
// Yesterday's eligibility-trace fix made Hebbian learning work: hebb_max
// 0.000799 -> 0.4725, and 1,198 hebbian-associate edges formed in 23h48m.
// A census this morning found all 1,198 of them living in this process's RAM
// and nowhere else:
//
// soul daemon in-process graph: 42,426 edges, 1,198 hebbian
// engram server (:8742, durable): 41,213 edges, 49 hebbian
//
// The soul pulls from the server every 10 min (GET /api/sync) and never
// pushes. It also cannot save its own snapshot: soul.el only sets
// soul_snapshot_path inside `if is_genesis && safe_to_seed`, and safe_to_seed
// is unconditionally false when ENGRAM_URL is set which it is, in the
// launchd plist because the HTTP server owns persistence and a soul writing
// snapshot.json would clobber it. That guard is right. So mem_save() below has
// literally never run, and this daemon (the ONLY process doing idle cognition,
// therefore where essentially all co-activation happens) was throwing away
// every association it learned, every restart, silently.
//
// The fix is not to let the soul write the file. It is to make consolidation a
// message: hand each newly-formed edge to the durable store over the API the
// server already exposes. Fast volatile store learns online; slow durable store
// keeps what cleared the threshold. Only edges past ENGRAM_HEBB_LINK_MIN are
// ever queued, so what crosses the boundary already earned it.
//
// Failure is non-fatal by construction: a drained entry that fails to POST is
// gone, and that is fine a real association re-forms from live co-activation.
// The counts go into the heartbeat (hebb_wb_*) so a consolidation path that has
// stopped delivering is visible in the stream rather than in a later autopsy.
fn hebb_consolidate() -> Int {
let batch: String = engram_hebb_drain_json(64)
if str_eq(batch, "") { return 0 }
if str_eq(batch, "[]") { return 0 }
let n: Int = json_array_len(batch)
if n == 0 { return 0 }
let url_env: String = env("SOUL_ISE_URL")
let url_state: String = if str_eq(url_env, "") { state_get("soul_engram_url") } else { url_env }
let engram_url: String = if str_eq(url_state, "") { "http://localhost:8742" } else { url_state }
// ONE request for the whole batch, not one per edge. The server's
// persist_canonical() writes the full 60MB snapshot on every durable
// write, so per-edge POSTs would cost ~840MB of disk per heartbeat to
// persist ~14 associations. /api/edges/batch connects them all and
// snapshots once. The drain payload is already the right shape; it only
// needs an envelope: the drain already emits the relation per entry.
//
// _auth is REQUIRED and its absence is silent. check_auth_ok() in server.el
// exempts GET and /api/neuron/state-events (which is why ise_post works
// without a key) but gates every other mutation on "_auth" in the BODY
// http_serve does not surface request headers, so there is no Bearer path.
// A batch posted without it comes back {"error":"unauthorized"}, which is a
// non-empty response: the naive `if resp == "" return 0` check would read
// that as success and report edges delivered that were in fact refused,
// after the drain had already destroyed them. Hence both the key and the
// accepted-count check below. Fall back to env when the state key is empty
// never let a corruptible state read decide whether learning persists.
let key_state: String = state_get("soul_engram_api_key")
let api_key: String = if str_eq(key_state, "") { env("ENGRAM_API_KEY") } else { key_state }
let auth_part: String = if str_eq(api_key, "") { "" } else { ",\"_auth\":\"" + api_key + "\"" }
let body: String = "{\"edges\":" + batch + auth_part + "}"
let resp: String = http_post_json(engram_url + "/api/edges/batch", body)
if str_eq(resp, "") { return 0 }
let acc: String = json_get(resp, "accepted")
if str_eq(acc, "") { return 0 }
return str_to_int(acc)
}
fn ise_post(content: String) -> Void {
let ise_url: String = env("SOUL_ISE_URL")
let state_url: String = if str_eq(ise_url, "") { state_get("soul_engram_url") } else { ise_url }
@@ -336,6 +404,24 @@ fn emit_heartbeat() -> Void {
let hebb_max: String = if str_eq(hebb_max_raw, "") { "-1" } else { hebb_max_raw }
let hebb_links_raw: String = json_get(act_stats, "hebb_links")
let hebb_links: String = if str_eq(hebb_links_raw, "") { "-1" } else { hebb_links_raw }
// Consolidation write-back gauges (2026-08-07 self-review). hebb_links
// counts what this process LEARNED; these three count what SURVIVES it.
// The distinction is the whole finding: 1,198 links formed, 0 persisted,
// because the learner is not the persistence owner (see hebb_consolidate).
// wb_pending — queued, not yet handed over. Climbing ⇒ writer is down.
// wb_drained — cumulative popped for delivery. Flat while hebb_links
// climbs ⇒ the drain is not being called at all.
// wb_dropped — lost to a full queue. Must stay 0; nonzero means the
// durable store has been unreachable long enough to matter.
// wb_sent — POSTs the durable store actually accepted this beat.
let wb_pend_raw: String = json_get(act_stats, "hebb_wb_pending")
let wb_pend: String = if str_eq(wb_pend_raw, "") { "-1" } else { wb_pend_raw }
let wb_drain_raw: String = json_get(act_stats, "hebb_wb_drained")
let wb_drain: String = if str_eq(wb_drain_raw, "") { "-1" } else { wb_drain_raw }
let wb_drop_raw: String = json_get(act_stats, "hebb_wb_dropped")
let wb_drop: String = if str_eq(wb_drop_raw, "") { "-1" } else { wb_drop_raw }
let wb_sent_raw: String = state_get("soul.hebb_wb_sent")
let wb_sent: String = if str_eq(wb_sent_raw, "") { "0" } else { wb_sent_raw }
// dup_wm_global (2026-08-06): redundant WM residents that arrived via the
// carry-over path, which Pass 3½ structurally could not see. Confirmed live
// by a census that caught two byte-identical copies of one 3,193-char
@@ -365,7 +451,7 @@ fn emit_heartbeat() -> Void {
let dup_seeds: String = if str_eq(dup_seeds_raw, "") { "-1" } else { dup_seeds_raw }
let dup_wm_raw: String = json_get(act_stats, "dup_wm")
let dup_wm: String = if str_eq(dup_wm_raw, "") { "-1" } else { dup_wm_raw }
let payload: String = "{\"event\":\"heartbeat\",\"pulse\":" + pulse + ",\"tick\":" + pulse + ",\"boot\":" + boot + ",\"idle\":" + idle + ",\"idle_ms\":" + int_to_str(idle_ms) + ",\"node_count\":" + int_to_str(nc) + ",\"edge_count\":" + int_to_str(ec) + ",\"node_delta\":" + int_to_str(node_delta) + ",\"edge_delta\":" + int_to_str(edge_delta) + ",\"wm_active\":" + int_to_str(wmc) + ",\"wm_delta\":" + int_to_str(wm_delta) + ",\"wm_saturated\":" + int_to_str(wm_sat) + ",\"wm_top0_streak\":" + int_to_str(t0streak) + ",\"wm_churn\":" + int_to_str(wm_churn) + ",\"wm_top0_wm\":" + wm_top0_wm + ",\"sync_added_total\":" + sat_str + ",\"sync_age_ms\":" + int_to_str(sync_age) + ",\"wm_avg_weight\":" + wm_avg_str + ",\"wm_top\":" + wm_top + ",\"ts\":" + int_to_str(ts) + ",\"uptime_ms\":" + int_to_str(up_ms) + ",\"uptime\":\"" + up_human + "\",\"embed_ok\":" + int_to_str(emb_ok) + ",\"embed_backfilled\":" + bf_done + ",\"embed_count\":" + bf_total + ",\"embed_eligible\":" + embed_elig + ",\"wm_evicted\":" + act_evict + ",\"wm_evicted_delta\":" + int_to_str(evict_delta) + ",\"breakthroughs\":" + act_bt + ",\"breakthroughs_delta\":" + int_to_str(bt_delta) + ",\"auto_term_streak\":" + int_to_str(hb_ats) + ",\"auto_term_empty_streak\":" + int_to_str(hb_ate) + ",\"embed_breaker_open\":" + act_brk + ",\"ctx_cos\":" + ctx_cos + ",\"dup_seeds\":" + dup_seeds + ",\"dup_wm\":" + dup_wm + ",\"dup_wm_global\":" + dup_wm_g + ",\"hebb_warm\":" + hebb_warm + ",\"hebb_max\":" + hebb_max + ",\"hebb_links\":" + hebb_links + ",\"ise_fail\":" + fail_str + "}"
let payload: String = "{\"event\":\"heartbeat\",\"pulse\":" + pulse + ",\"tick\":" + pulse + ",\"boot\":" + boot + ",\"idle\":" + idle + ",\"idle_ms\":" + int_to_str(idle_ms) + ",\"node_count\":" + int_to_str(nc) + ",\"edge_count\":" + int_to_str(ec) + ",\"node_delta\":" + int_to_str(node_delta) + ",\"edge_delta\":" + int_to_str(edge_delta) + ",\"wm_active\":" + int_to_str(wmc) + ",\"wm_delta\":" + int_to_str(wm_delta) + ",\"wm_saturated\":" + int_to_str(wm_sat) + ",\"wm_top0_streak\":" + int_to_str(t0streak) + ",\"wm_churn\":" + int_to_str(wm_churn) + ",\"wm_top0_wm\":" + wm_top0_wm + ",\"sync_added_total\":" + sat_str + ",\"sync_age_ms\":" + int_to_str(sync_age) + ",\"wm_avg_weight\":" + wm_avg_str + ",\"wm_top\":" + wm_top + ",\"ts\":" + int_to_str(ts) + ",\"uptime_ms\":" + int_to_str(up_ms) + ",\"uptime\":\"" + up_human + "\",\"embed_ok\":" + int_to_str(emb_ok) + ",\"embed_backfilled\":" + bf_done + ",\"embed_count\":" + bf_total + ",\"embed_eligible\":" + embed_elig + ",\"wm_evicted\":" + act_evict + ",\"wm_evicted_delta\":" + int_to_str(evict_delta) + ",\"breakthroughs\":" + act_bt + ",\"breakthroughs_delta\":" + int_to_str(bt_delta) + ",\"auto_term_streak\":" + int_to_str(hb_ats) + ",\"auto_term_empty_streak\":" + int_to_str(hb_ate) + ",\"embed_breaker_open\":" + act_brk + ",\"ctx_cos\":" + ctx_cos + ",\"dup_seeds\":" + dup_seeds + ",\"dup_wm\":" + dup_wm + ",\"dup_wm_global\":" + dup_wm_g + ",\"hebb_warm\":" + hebb_warm + ",\"hebb_max\":" + hebb_max + ",\"hebb_links\":" + hebb_links + ",\"hebb_wb_pending\":" + wb_pend + ",\"hebb_wb_drained\":" + wb_drain + ",\"hebb_wb_dropped\":" + wb_drop + ",\"hebb_wb_sent\":" + wb_sent + ",\"ise_fail\":" + fail_str + "}"
ise_post(payload)
}
@@ -973,8 +1059,15 @@ fn awareness_run() -> Void {
// still leaves no trace that absence is itself the crash signal.)
let sd_boot_raw: String = state_get("soul_boot_count")
let sd_boot: String = if str_eq(sd_boot_raw, "") { "0" } else { sd_boot_raw }
// Final consolidation before exit. The periodic drain runs on the
// heartbeat (~8 min), so a clean shutdown between beats would take
// everything learned since the last one to the grave the exact
// loss this whole path exists to stop, just at a smaller scale.
// Best-effort: if the durable store is already down we exit anyway.
let sd_wb: Int = hebb_consolidate()
ise_post("{\"event\":\"shutdown\",\"boot\":" + sd_boot
+ ",\"pulse\":" + int_to_str(pulse_count())
+ ",\"hebb_wb_sent\":" + int_to_str(sd_wb)
+ ",\"uptime_ms\":" + int_to_str(elapsed_ms())
+ ",\"ts\":" + int_to_str(time_now()) + "}")
println("[awareness] exiting")
@@ -1001,6 +1094,12 @@ fn awareness_run() -> Void {
let beat_elapsed: Int = now_ts - last_beat_ts
let should_beat: Bool = beat_elapsed >= beat_ms
if should_beat {
// Consolidate BEFORE the heartbeat so the gauges the heartbeat
// reports describe the state this beat actually left behind, not
// the state one beat stale. See hebb_consolidate for why a daemon
// that learns 1,198 associations a day was keeping none of them.
let wb_sent_n: Int = hebb_consolidate()
state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
emit_heartbeat()
state_set("soul.last_beat_ts", int_to_str(now_ts))
// Persist in-process Engram (sessions, memories, conversation nodes)