diff --git a/engram/dist/engram b/engram/dist/engram index d6d5bc5..f0865e6 100755 Binary files a/engram/dist/engram and b/engram/dist/engram differ diff --git a/engram/dist/engram.c b/engram/dist/engram.c index 23a325f..095422b 100644 --- a/engram/dist/engram.c +++ b/engram/dist/engram.c @@ -24,6 +24,7 @@ el_val_t route_save(el_val_t method, el_val_t path, el_val_t body); el_val_t route_load(el_val_t method, el_val_t path, el_val_t body); el_val_t route_health(el_val_t method, el_val_t path, el_val_t body); el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body); +el_val_t route_load_merge(el_val_t method, el_val_t path, el_val_t body); el_val_t route_emit_ise(el_val_t method, el_val_t path, el_val_t body); el_val_t route_capture_knowledge(el_val_t method, el_val_t path, el_val_t body); el_val_t check_auth_ok(el_val_t method, el_val_t body); @@ -35,6 +36,7 @@ el_val_t port; el_val_t data_dir_raw; el_val_t data_dir; el_val_t snapshot_path; +el_val_t boot_snap; el_val_t parse_port(el_val_t bind) { el_val_t colon = str_index_of(bind, EL_STR(":")); @@ -147,7 +149,7 @@ el_val_t route_scan_nodes(el_val_t method, el_val_t path, el_val_t body) { el_val_t route_scan_edges(el_val_t method, el_val_t path, el_val_t body) { el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR")); el_val_t dir = ({ el_val_t _if_result_3 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_3 = (EL_STR("/tmp/engram")); } else { _if_result_3 = (dir_raw); } _if_result_3; }); - el_val_t snap_path = el_str_concat(dir, EL_STR("/snapshot.json")); + el_val_t snap_path = el_str_concat(dir, EL_STR("/.scan-export.json")); engram_save(snap_path); el_val_t snap = fs_read(snap_path); if (str_eq(snap, EL_STR(""))) { @@ -252,7 +254,7 @@ el_val_t route_health(el_val_t method, el_val_t path, el_val_t body) { el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body) { el_val_t dir_raw = env(EL_STR("ENGRAM_DATA_DIR")); el_val_t dir = ({ el_val_t _if_result_16 = 0; if (str_eq(dir_raw, EL_STR(""))) { _if_result_16 = (EL_STR("/tmp/engram")); } else { _if_result_16 = (dir_raw); } _if_result_16; }); - el_val_t snap_path = el_str_concat(dir, EL_STR("/snapshot.json")); + el_val_t snap_path = el_str_concat(dir, EL_STR("/.sync-export.json")); engram_save(snap_path); el_val_t snap = fs_read(snap_path); if (str_eq(snap, EL_STR(""))) { @@ -262,6 +264,23 @@ el_val_t route_sync(el_val_t method, el_val_t path, el_val_t body) { return 0; } +el_val_t route_load_merge(el_val_t method, el_val_t path, el_val_t body) { + el_val_t p = json_get_string(body, EL_STR("path")); + if (str_eq(p, EL_STR(""))) { + return err_json(EL_STR("path is required")); + } + if (str_eq(fs_read(p), EL_STR(""))) { + return err_json(EL_STR("file missing or empty")); + } + el_val_t before_n = engram_node_count(); + el_val_t before_e = engram_edge_count(); + engram_load_merge(p); + el_val_t added_n = (engram_node_count() - before_n); + el_val_t added_e = (engram_edge_count() - before_e); + return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"nodes_added\":"), int_to_str(added_n)), EL_STR(",\"edges_added\":")), int_to_str(added_e)), EL_STR(",\"node_count\":")), int_to_str(engram_node_count())), EL_STR("}")); + return 0; +} + el_val_t route_emit_ise(el_val_t method, el_val_t path, el_val_t body) { el_val_t content = json_get_string(body, EL_STR("content")); if (str_eq(content, EL_STR(""))) { @@ -385,6 +404,9 @@ el_val_t handle_request(el_val_t method, el_val_t path, el_val_t body) { if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/load")) || str_eq(clean, EL_STR("/load")))) { return route_load(method, path, body); } + if (str_eq(method, EL_STR("POST")) && (str_eq(clean, EL_STR("/api/load-merge")) || str_eq(clean, EL_STR("/load-merge")))) { + return route_load_merge(method, path, body); + } if (str_eq(method, EL_STR("GET")) && str_eq(clean, EL_STR("/api/sync"))) { return route_sync(method, path, body); } @@ -401,6 +423,15 @@ int main(int _argc, char** _argv) { data_dir = ({ el_val_t _if_result_25 = 0; if (str_eq(data_dir_raw, EL_STR(""))) { _if_result_25 = (EL_STR("/tmp/engram")); } else { _if_result_25 = (data_dir_raw); } _if_result_25; }); snapshot_path = el_str_concat(data_dir, EL_STR("/snapshot.json")); engram_load(snapshot_path); + boot_snap = fs_read(snapshot_path); + if (!str_eq(boot_snap, EL_STR(""))) { + if (engram_node_count() == 0) { + println(EL_STR("[engram] WARNING: snapshot.json is non-empty but load produced 0 nodes \xe2\x80\x94 preserving copy at snapshot.failed-load.json")); + fs_write(el_str_concat(data_dir, EL_STR("/snapshot.failed-load.json")), boot_snap); + } else { + fs_write(el_str_concat(data_dir, EL_STR("/snapshot.boot-backup.json")), boot_snap); + } + } println(EL_STR("[engram] runtime-native graph engine")); println(el_str_concat(EL_STR("[engram] data_dir="), data_dir)); println(el_str_concat(EL_STR("[engram] node_count="), int_to_str(engram_node_count()))); diff --git a/lang/releases/v1.0.0-20260501/el_runtime.c b/lang/releases/v1.0.0-20260501/el_runtime.c index b452dbf..209f2fb 100644 --- a/lang/releases/v1.0.0-20260501/el_runtime.c +++ b/lang/releases/v1.0.0-20260501/el_runtime.c @@ -5581,10 +5581,10 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal, * 0.25 Lesson, 0.30 Belief/Entity, 0.40 Note/Memory/Working). This constant * is NOT used in engram_activate(); it matches the Canonical tier value only * by coincidence. (2026-07-01 self-review: clarified stale doc) - * ENGRAM_WM_DECAY: per-turn decay applied to working_memory_weight for - * nodes NOT re-activated in the current turn (conversational thread - * continuity: a node promoted in turn N persists with reduced weight - * into turn N+1 without re-activation cost). + * ENGRAM_WM_DECAY: SUPERSEDED (2026-07-22 self-review, bl-b17facdd) — the + * per-turn multiplicative carry-over decay was replaced by the ACT-R/ + * Petrov base-level scheme (see ENGRAM_BLL_* below). Kept for reference; + * no longer used in engram_activate. * ENGRAM_SUPPRESSION_BREAKTHROUGH: after this many consecutive suppressions * a latent node forces itself into working memory at reduced weight, * modelling the brain's "intrusive thought" / unresolved-tension surfacing. @@ -5617,6 +5617,42 @@ void el_cgi_init(el_val_t name, el_val_t dharma_id, el_val_t principal, #define ENGRAM_WM_CAP 24 #define ENGRAM_INHIBITION_FACTOR 0.1 +/* ── ACT-R / Petrov hybrid base-level learning (2026-07-22 self-review) ────── + * Replaces the per-turn multiplicative WM carry-over decay (weight *= 0.7 per + * engram_activate call) with wall-clock power-law decay over actual access + * history. The old scheme was call-rate-dependent: 10 curiosity scans in 10 + * seconds decayed a carried node as much as 10 scans across an hour, and a + * scalar weight loses access FREQUENCY entirely — a node touched 50 times + * decayed identically to one touched once. + * + * Petrov (2006) hybrid: keep the K most recent access timestamps exactly, + * approximate the older tail in closed form: + * + * B = ln( Σ_{j=1..k} t_j^(-d) + * + (n-k) · (t_n^(1-d) - t_k^(1-d)) / ((1-d) · (t_n - t_k)) ) + * + * d = 0.5 (canonical ACT-R decay), t_j = seconds since j-th recent access, + * t_k = seconds since oldest RETAINED access, t_n = seconds since first + * presentation (node creation), n = total presentations. + * + * This is the scheme Soar ships for working-memory forgetting (Derbinsky & + * Laird 2012). Calibration: a single-touch node has B(t) = -0.5·ln(t); with + * τ = -3.0 it falls below threshold at t = e^6 ≈ 403 s. Frequently-touched + * nodes accumulate Σ t^-0.5 mass and persist proportionally longer — recency + * AND frequency in one formula, which a decayed scalar cannot represent. + * + * Carry-over weight above threshold is shaped by the ACT-R retrieval- + * probability logistic P = σ((B − τ)/s), s = 0.4, applied to the weight the + * node held at promotion (wm_anchor) — NOT re-multiplied per call, so the + * carried weight is a pure function of wall-clock time regardless of how + * often engram_activate runs. + * Sources: alexpetrov.com/pub/iccm06 · Soar cli/cmd_wm defaults · + * arxiv.org/html/2412.05112v1 (2026-07-21 integration brief, bl-b17facdd). */ +#define ENGRAM_BLL_K 10 +#define ENGRAM_BLL_D 0.5 +#define ENGRAM_BLL_TAU (-3.0) +#define ENGRAM_BLL_S 0.4 + /* qsort comparator — descending double, used by WM cap enforcement. */ static int engram_cmp_double_desc(const void* a, const void* b) { double da = *(const double*)a; @@ -5753,8 +5789,75 @@ typedef struct EngramNode { * created via engram_node / engram_node_full and for snapshots that * predate the layered schema. */ uint32_t layer_id; + /* ACT-R base-level learning state (2026-07-22 self-review, ENGRAM_BLL_*). + * access_ts: ring buffer of the most recent access timestamps (ms). + * access_head: next write slot. access_filled: valid entries (≤ K). + * wm_anchor: the WM weight the node held when last promoted; carry-over + * decay is computed from this anchor as a pure function of wall-clock + * time. All fields zero via memset in creation/load paths; snapshots + * without them degrade gracefully to the optimized-form approximation + * in engram_bll_base_level. */ + int64_t access_ts[ENGRAM_BLL_K]; + int32_t access_head; + int32_t access_filled; + double wm_anchor; } EngramNode; +/* Record an access (ACT-R "presentation") into the base-level ring buffer. */ +static void engram_bll_record_access(EngramNode* n, int64_t now_ms) { + n->access_ts[n->access_head] = now_ms; + n->access_head = (n->access_head + 1) % ENGRAM_BLL_K; + if (n->access_filled < ENGRAM_BLL_K) n->access_filled++; +} + +/* Petrov hybrid base-level activation B in nats. Exact sum over the retained + * ring, closed-form tail for older presentations. Falls back to the ACT-R + * optimized-learning approximation B = ln(n/(1−d)) − d·ln(L) for nodes with + * no retained access history (legacy snapshots). */ +static double engram_bll_base_level(const EngramNode* n, int64_t now_ms) { + /* Total presentations: creation counts as the first, retrievals add. */ + double n_total = (double)n->activation_count + 1.0; + double t_life = (double)(now_ms - n->created_at) / 1000.0; + if (t_life < 1.0) t_life = 1.0; + if (n->access_filled == 0) { + return log(n_total / (1.0 - ENGRAM_BLL_D)) + - ENGRAM_BLL_D * log(t_life); + } + double sum = 0.0; + double t_oldest_ring = 0.0; /* seconds since oldest retained access */ + for (int32_t j = 0; j < n->access_filled; j++) { + double t = (double)(now_ms - n->access_ts[j]) / 1000.0; + if (t < 1.0) t = 1.0; + sum += pow(t, -ENGRAM_BLL_D); + if (t > t_oldest_ring) t_oldest_ring = t; + } + /* Closed-form tail for the (n − k) presentations older than the ring. + * With d = 0.5 this reduces to 2·(n−k)/(√t_n + √t_k). */ + double n_older = n_total - (double)n->access_filled; + if (n_older > 0.0 && t_life > t_oldest_ring) { + sum += n_older * (pow(t_life, 1.0 - ENGRAM_BLL_D) + - pow(t_oldest_ring, 1.0 - ENGRAM_BLL_D)) + / ((1.0 - ENGRAM_BLL_D) * (t_life - t_oldest_ring)); + } + if (sum <= 0.0) return -99.0; + return log(sum); +} + +/* Parse a persisted "access_ts" comma list (chronological order) into the + * ring buffer. Tolerates absence, empty strings, and stray whitespace. */ +static void engram_bll_parse_access(EngramNode* nn, const char* s) { + if (!s) return; + const char* p = s; + while (*p) { + char* endp = NULL; + long long v = strtoll(p, &endp, 10); + if (endp == p) break; + if (v > 0) engram_bll_record_access(nn, (int64_t)v); + p = endp; + while (*p == ',' || *p == ' ') p++; + } +} + typedef struct EngramEdge { char* id; char* from_id; @@ -6284,6 +6387,10 @@ static el_val_t engram_node_to_map(const EngramNode* n) { m = el_map_set(m, EL_STR(el_strdup("working_memory_weight")), el_from_float(n->working_memory_weight)); m = el_map_set(m, EL_STR(el_strdup("suppression_count")), (el_val_t)n->suppression_count); m = el_map_set(m, EL_STR(el_strdup("layer_id")), (el_val_t)(int64_t)n->layer_id); + /* Observability (2026-07-22): expose the current ACT-R base-level and + * promotion anchor so heartbeat ISEs / API consumers can see decay state. */ + m = el_map_set(m, EL_STR(el_strdup("wm_anchor")), el_from_float(n->wm_anchor)); + m = el_map_set(m, EL_STR(el_strdup("base_level")), el_from_float(engram_bll_base_level(n, engram_now_ms()))); return m; } @@ -6579,6 +6686,8 @@ void engram_strengthen(el_val_t node_id) { n->activation_count++; n->last_activated = engram_now_ms(); n->updated_at = n->last_activated; + /* Explicit strengthen is a presentation too (2026-07-22 self-review). */ + engram_bll_record_access(n, n->last_activated); } void engram_forget(el_val_t node_id) { @@ -7093,7 +7202,7 @@ static double engram_temporal_proximity_bonus(int64_t node_created, * * Working memory persistence (turn continuity): * Nodes promoted in the previous turn retain a decayed working_memory_weight - * (weight *= ENGRAM_WM_DECAY) without needing re-activation. This models + * (ACT-R base-level carry-over, 2026-07-22) without needing re-activation. This models * conversational thread continuity — once a topic is in working memory, * it persists slightly into the next turn. * @@ -7527,12 +7636,34 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { for (int64_t i = 0; i < g->node_count; i++) { if (!reached[i] && g->nodes[i].working_memory_weight > 0.0) { /* Carry-over decay: node held WM weight from prior activation but - * the current query's BFS fan-out did not reach it. Apply decay - * rather than zero so recently-active context persists. */ - double decayed = g->nodes[i].working_memory_weight * ENGRAM_WM_DECAY; - g->nodes[i].working_memory_weight = (decayed < 0.01) ? 0.0 : decayed; + * the current query's BFS fan-out did not reach it. + * + * 2026-07-22 self-review (bl-b17facdd): replaced the per-call + * multiplicative decay (weight *= ENGRAM_WM_DECAY) with the + * ACT-R/Petrov base-level scheme. The old form was call-rate- + * dependent — carried context died in seconds under rapid + * curiosity scans and lingered for hours under quiet loops. + * Now: hard-evict when base-level < τ (Soar-style forgetting); + * above τ, shape the weight held at promotion (wm_anchor) by the + * retrieval-probability logistic. Pure function of wall-clock + * time — idempotent no matter how often activate is called. */ + EngramNode* cn = &g->nodes[i]; + double anchor = (cn->wm_anchor > 0.0) ? cn->wm_anchor + : cn->working_memory_weight; + double B = engram_bll_base_level(cn, now_ms); + if (B < ENGRAM_BLL_TAU) { + cn->working_memory_weight = 0.0; + } else { + double keep = 1.0 / (1.0 + exp(-(B - ENGRAM_BLL_TAU) + / ENGRAM_BLL_S)); + double w = anchor * keep; + cn->working_memory_weight = (w < 0.01) ? 0.0 : w; + } } else { g->nodes[i].working_memory_weight = wm_weights[i]; + /* Anchor the promotion weight: carry-over decay above computes + * from this fixed point rather than compounding per call. */ + if (wm_weights[i] > 0.0) g->nodes[i].wm_anchor = wm_weights[i]; } } @@ -7605,6 +7736,9 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) { if (n->working_memory_weight <= 0.0) continue; /* evicted by cap */ n->last_activated = now_ms; n->activation_count++; + /* Base-level presentation: WM promotion is the retrieval event. + * (2026-07-22 self-review — feeds engram_bll_base_level.) */ + engram_bll_record_access(n, now_ms); } /* ── Collect all background-activated nodes for the return value ──── @@ -7688,6 +7822,23 @@ static void engram_emit_node_json(JsonBuf* b, const EngramNode* n) { snprintf(tmp, sizeof(tmp), ",\"working_memory_weight\":%g", n->working_memory_weight); jb_puts(b, tmp); snprintf(tmp, sizeof(tmp), ",\"suppression_count\":%d", n->suppression_count); jb_puts(b, tmp); snprintf(tmp, sizeof(tmp), ",\"layer_id\":%u", n->layer_id); jb_puts(b, tmp); + snprintf(tmp, sizeof(tmp), ",\"wm_anchor\":%g", n->wm_anchor); jb_puts(b, tmp); + snprintf(tmp, sizeof(tmp), ",\"base_level\":%g", + engram_bll_base_level(n, engram_now_ms())); jb_puts(b, tmp); + /* Base-level access history: chronological (oldest→newest) compact + * string. Loaders replay it through engram_bll_record_access; absent + * field = empty ring (optimized-form fallback). (2026-07-22) */ + if (n->access_filled > 0) { + jb_puts(b, ",\"access_ts\":\""); + for (int32_t j = 0; j < n->access_filled; j++) { + int32_t idx = (n->access_head - n->access_filled + j + + 2 * ENGRAM_BLL_K) % ENGRAM_BLL_K; + snprintf(tmp, sizeof(tmp), "%s%lld", j ? "," : "", + (long long)n->access_ts[idx]); + jb_puts(b, tmp); + } + jb_putc(b, '"'); + } jb_putc(b, '}'); } @@ -7926,6 +8077,13 @@ el_val_t engram_load(el_val_t path) { } else { nn->layer_id = ENGRAM_LAYER_DEFAULT; } + /* Base-level state (2026-07-22): absent fields leave the ring + * empty (memset) → optimized-form fallback. */ + nn->wm_anchor = eg_get_num_field(obj, "wm_anchor"); + { + char* ats = eg_get_str_field(obj, "access_ts"); + if (ats) { engram_bll_parse_access(nn, ats); free(ats); } + } int64_t load_idx = g->node_count; g->node_count++; if (nn->id && *nn->id) engram_idmap_put(g, nn->id, load_idx); @@ -8119,6 +8277,13 @@ el_val_t engram_load_merge(el_val_t path) { } else { nn->layer_id = ENGRAM_LAYER_DEFAULT; } + /* Base-level history merges with the node (2026-07-22); + * wm_anchor stays 0 — WM state is local-only (see the + * working_memory_weight comment above). */ + { + char* ats = eg_get_str_field(obj, "access_ts"); + if (ats) { engram_bll_parse_access(nn, ats); free(ats); } + } int64_t merge_idx = g->node_count; g->node_count++; added_nodes++;