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:
+100
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user