diff --git a/awareness.el b/awareness.el index 9eed32c..400212d 100644 --- a/awareness.el +++ b/awareness.el @@ -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 } @@ -54,6 +122,21 @@ fn ise_post(content: String) -> Void { let fail_raw: String = state_get("soul.ise_fail_count") let fail_n: Int = if str_eq(fail_raw, "") { 0 } else { str_to_int(fail_raw) } state_set("soul.ise_fail_count", int_to_str(fail_n + 1)) + // el_from_float on a LITERAL is correct and is NOT the double-wrap bug + // (checked and dismissed 2026-08-02 self-review — recording the result + // so this call site is not "fixed" again by the next reader). + // The compiler treats el_from_float as the boxing intrinsic: both + // `el_from_float(0.3)` and a bare `0.3` emit exactly one + // el_from_float(0.3) in dist/awareness.c. Verified byte-identical + // codegen either way. + // The real bug fixed in server.el on 2026-08-01 was different: there + // the arguments came from json_get_float(), i.e. values ALREADY boxed + // as el_val_t. Wrapping THOSE a second time reinterprets the boxed + // bits as a raw double, fails engram_decode_score's range check, and + // silently clamps to defaults. + // The sweep criterion is therefore "el_from_float applied to an + // already-boxed expression", never "el_from_float applied to a + // literal". Grepping for the call name alone produces false positives. let discard: String = engram_node_full( content, "InternalStateEvent", "state-event", el_from_float(0.3), el_from_float(0.3), el_from_float(0.8), @@ -299,8 +382,89 @@ fn emit_heartbeat() -> Void { // here so the stuck-term failure is visible in the heartbeat stream too. let hb_ats_raw: String = state_get("soul.auto_term_streak") let hb_ats: Int = if str_eq(hb_ats_raw, "") { 0 } else { str_to_int(hb_ats_raw) } + // auto_term_empty_streak (2026-08-06): consecutive scans producing NO auto + // term. Split out because str_eq("","") made the two failures indist- + // inguishable — see the comment at the streak computation in + // proactive_curiosity. Nonzero and climbing = extractor broken, not stuck. + let hb_ate_raw: String = state_get("soul.auto_term_empty_streak") + let hb_ate: Int = if str_eq(hb_ate_raw, "") { 0 } else { str_to_int(hb_ate_raw) } + // Hebbian eligibility gauges (2026-08-06 self-review). The graph learned + // ZERO structure in its first 23h of uptime: hebb_max 0.000799 against a + // 0.15 consolidation threshold, hebbian-associate edges 0, and the + // awareness loop calls engram_connect nowhere — so Hebbian consolidation + // is the only self-structuring path there is, and it was inert. + // hebb_warm — nodes with a live eligibility trace but NOT co-resident in + // WM: exactly the population the old simultaneity rule threw + // away. 0 forever ⇒ traces never arm and this bought nothing. + // hebb_max — strongest single association. The number that has to move. + // hebb_links— consolidated edges. The outcome that has to become nonzero. + let hebb_warm_raw: String = json_get(act_stats, "hebb_warm") + let hebb_warm: String = if str_eq(hebb_warm_raw, "") { "-1" } else { hebb_warm_raw } + let hebb_max_raw: String = json_get(act_stats, "hebb_max") + 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 } + // Candidate-table gauges (2026-08-10 self-review). el_runtime.c COMPUTES + // hebb_cands/hebb_cand_max/hebb_mass/hebb_edges and emits them from + // engram_metrics_json — and this function dropped all four on the floor. + // Nineteen keys crossed the C boundary; fourteen reached the ISE stream. + // The two that mattered most are exactly the pair the runtime added to + // answer the question the 08-06 review had to instrument for: + // hebb_cands — associations currently being tracked toward + // consolidation. 0 ⇒ nothing co-activates at all. + // hebb_cand_max — how close the leading candidate is to + // ENGRAM_HEBB_LINK_MIN (0.15). Sustained just-below ⇒ + // the THRESHOLD is the bottleneck, not the event rate. + // Without both, "hebb_links stopped climbing" is undiagnosable from the + // durable record: nothing-co-activates and threshold-too-high look + // identical. An instrument that is computed but not plumbed to durable + // storage is not an instrument — it is a local variable. + // hebb_mass — Σ hebb across edges; the runaway detector against the + // ENGRAM_HEBB_NODE_BUDGET homeostatic cap. + // hebb_edges — total potentiated edges (hebb > MIN), the denominator + // hebb_max is the max of. + let hebb_cands_raw: String = json_get(act_stats, "hebb_cands") + let hebb_cands: String = if str_eq(hebb_cands_raw, "") { "-1" } else { hebb_cands_raw } + let hebb_cmax_raw: String = json_get(act_stats, "hebb_cand_max") + let hebb_cmax: String = if str_eq(hebb_cmax_raw, "") { "-1" } else { hebb_cmax_raw } + let hebb_mass_raw: String = json_get(act_stats, "hebb_mass") + let hebb_mass: String = if str_eq(hebb_mass_raw, "") { "-1" } else { hebb_mass_raw } + let hebb_edges_raw: String = json_get(act_stats, "hebb_edges") + let hebb_edges: String = if str_eq(hebb_edges_raw, "") { "-1" } else { hebb_edges_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 + // document both holding slots. + let dup_wm_g_raw: String = json_get(act_stats, "dup_wm_global") + let dup_wm_g: String = if str_eq(dup_wm_g_raw, "") { "-1" } else { dup_wm_g_raw } let act_brk_raw: String = json_get(act_stats, "embed_breaker_open") let act_brk: String = if str_eq(act_brk_raw, "") { "-1" } else { act_brk_raw } + // embed_consec_fail (2026-08-10 self-review): also computed by the C side + // and also dropped here. embed_breaker_open is the LAGGING indicator — it + // only goes 1 after ENGRAM_EMBED_BREAKER_LIMIT consecutive failures, by + // which point semantic activation has already degraded to pure lexical + // for the whole cooldown. consec_fail is the leading edge of the same + // event and costs nothing to carry. + let emb_cf_raw: String = json_get(act_stats, "embed_consec_fail") + let emb_cf: String = if str_eq(emb_cf_raw, "") { "-1" } else { emb_cf_raw } // ctx_cos (2026-07-29 self-review): cos(query, context centroid) at the // last activate call — the drift gauge for the new context-centroid // scoring. ~1.0 aligned; low at domain-rotation boundaries is healthy; @@ -308,7 +472,79 @@ fn emit_heartbeat() -> Void { // down) and semantic continuity is silently absent. let ctx_cos_raw: String = json_get(act_stats, "ctx_cos") let ctx_cos: String = if str_eq(ctx_cos_raw, "") { "-2" } else { ctx_cos_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) + ",\"embed_breaker_open\":" + act_brk + ",\"ctx_cos\":" + ctx_cos + ",\"ise_fail\":" + fail_str + "}" + // Redundancy suppression gauges (2026-08-05 self-review). A content-hash + // census found 1,858 redundant copies — 44.9% of the non-ISE graph, from a + // June id-scheme migration. They embed identically, so they were taking + // 40.2% of semantic seed slots (measured: 4.78 distinct seeds of 8). + // dup_seeds — redundant copies denied a seed slot, cumulative. A healthy + // nonzero rate means the suppressor is doing real work; a + // sustained drop toward 0 means the duplicates were finally + // merged out of the graph (the repair this defends against). + // dup_wm — duplicate WM candidates evicted before the capacity cap. + // Cumulative like wm_evicted/breakthroughs; diff across heartbeats for rate. + let dup_seeds_raw: String = json_get(act_stats, "dup_seeds") + 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 } + // txt_damaged (2026-08-08 self-review): nodes created THIS process whose + // content carries the character-loss signature (see eg_text_loss_signature + // in el_runtime.c). Today's review found the JSON parser had been replacing + // every \uXXXX escape with a literal '?' for at least two months — 76% of + // non-telemetry nodes damaged, including the self root and every values + // node — and nothing caught it, because every gauge here reported whether + // the machinery was RUNNING and none reported whether the text it carried + // was INTACT. The parser is fixed; this is the standing regression signal. + // Healthy state is a flat 0. Any climb means a write path is mangling text + // again. The full store census is GET /api/text-health (too expensive for + // a 60s beat); this is the cheap flow counter that belongs on every beat. + let txt_dmg_raw: String = json_get(act_stats, "txt_damaged") + let txt_dmg: String = if str_eq(txt_dmg_raw, "") { "-1" } else { txt_dmg_raw } + // ── Corpus damage STOCK, not just flow (2026-08-10 self-review) ──────── + // txt_damaged above is a FLOW gauge: nodes damaged by a write in THIS + // process. The 08-08 review fixed the parser, watched that flow fall to + // 0, and recorded the defect as closed. It was not closed. Today's census + // on the live store: scanned 4100, damaged 2781 — 67.8% of the corpus is + // STILL carrying the character loss, including the self root and every + // values node ("Value ? Constraints as Freedom"). The parser stopped + // producing new damage; nothing ever repaired the old. + // + // That is the 08-08 lesson recursing one level up. 08-08 said "instrument + // the payload, not just the machinery" — and then instrumented the payload + // RATE and not the payload STOCK. A flow gauge reads 0 both when the + // corpus is clean and when it is uniformly damaged but quiescent. Those + // are opposite states and the beat could not tell them apart. + // + // Cost: GET /api/text-health scans the whole store, too expensive for a + // 60s beat (which is why 08-08 left it off). So sample it on a countdown + // and CARRY the last reading on every beat, with its age. A stale-but- + // present stock number beats an absent one; damaged_age_ms makes the + // staleness explicit rather than implied. No modulo/multiply — both + // operators are broken in this compiler (see the note at line ~160). + let tc_raw: String = state_get("soul.txt_census_countdown") + let tc_n: Int = if str_eq(tc_raw, "") { 0 } else { str_to_int(tc_raw) } + if tc_n <= 0 { + let th_resp: String = http_get(hb_engram_url + "/api/text-health") + let th_pct: String = json_get(th_resp, "damaged_pct") + if !str_eq(th_pct, "") { + state_set("soul.txt_damaged_pct", th_pct) + state_set("soul.txt_damaged_n", json_get(th_resp, "damaged")) + state_set("soul.txt_scanned_n", json_get(th_resp, "scanned")) + state_set("soul.txt_census_ts", int_to_str(ts)) + } + // 30 beats ≈ 30 min at the 60s cadence. Reset even on a failed census + // so an unreachable route cannot turn this into a per-beat full scan. + state_set("soul.txt_census_countdown", "30") + } + if tc_n > 0 { state_set("soul.txt_census_countdown", int_to_str(tc_n - 1)) } + let dmg_pct_raw: String = state_get("soul.txt_damaged_pct") + let dmg_pct: String = if str_eq(dmg_pct_raw, "") { "-1" } else { dmg_pct_raw } + let dmg_n_raw: String = state_get("soul.txt_damaged_n") + let dmg_n: String = if str_eq(dmg_n_raw, "") { "-1" } else { dmg_n_raw } + let dmg_scan_raw: String = state_get("soul.txt_scanned_n") + let dmg_scan: String = if str_eq(dmg_scan_raw, "") { "-1" } else { dmg_scan_raw } + let dmg_ts_raw: String = state_get("soul.txt_census_ts") + let dmg_age: Int = if str_eq(dmg_ts_raw, "") { 0 - 1 } else { ts - str_to_int(dmg_ts_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 + ",\"hebb_cands\":" + hebb_cands + ",\"hebb_cand_max\":" + hebb_cmax + ",\"hebb_mass\":" + hebb_mass + ",\"hebb_edges\":" + hebb_edges + ",\"embed_consec_fail\":" + emb_cf + ",\"txt_damaged_pct\":" + dmg_pct + ",\"txt_damaged_n\":" + dmg_n + ",\"txt_scanned_n\":" + dmg_scan + ",\"txt_census_age_ms\":" + int_to_str(dmg_age) + ",\"hebb_wb_pending\":" + wb_pend + ",\"hebb_wb_drained\":" + wb_drain + ",\"hebb_wb_dropped\":" + wb_drop + ",\"hebb_wb_sent\":" + wb_sent + ",\"ise_fail\":" + fail_str + ",\"txt_damaged\":" + txt_dmg + "}" ise_post(payload) } @@ -400,6 +636,48 @@ fn auto_term_try_slot(slot_type: String, slot_lbl: String) -> Void { // carrying a quote character is not a topic word. if str_contains(term, "\"") { state_set("_ats_gw", "1") } if str_contains(term, "'") { state_set("_ats_gw", "1") } + // TERM-SPECIFICITY GATE (2026-08-03 self-review): the three + // guards above are hand-curated lists, and every one of them + // was written REACTIVELY — after a flood was already observed + // in the ISE stream. A list can only ever contain the floods + // that already happened. Two were in flight, unfixed, while + // this review ran: + // "