self-review 2026-05-16: tier-based decay rates, implement knowledge_promote, ISE label extraction

Three research-grounded improvements:

1. Tier-based temporal decay in el_runtime.c (engram_node_full, engram_node_layered):
   Working=48h, Episodic=72h, Semantic=336h, Procedural=720h half-lives.
   Grounded in ACT-R literature — differentiated decay by chunk type. The
   temporal_decay_rate field existed but was always 0 (global 168h for everything).
   New nodes now carry the correct half-life for their tier from creation.

2. Implement route_neuron_knowledge_promote in server.el (was a silent stub):
   Reads existing node, creates promoted-tier copy with supersedes edge,
   checkpoints. promote_knowledge MCP tool now has real effect.

3. ISE label extraction + offset support in route_neuron_state_events:
   POST now extracts 'event' field from content JSON as label (heartbeat,
   wm_promotion, etc.) instead of always writing 'state-event'. GET now
   accepts ?offset= for pagination to reach recent ISEs.
This commit is contained in:
2026-05-16 08:40:01 -05:00
parent fde3ef539c
commit d917165aaf
4 changed files with 140 additions and 10 deletions
BIN
View File
Binary file not shown.
+62 -3
View File
@@ -685,7 +685,62 @@ el_val_t route_neuron_knowledge_evolve(el_val_t method, el_val_t path, el_val_t
}
el_val_t route_neuron_knowledge_promote(el_val_t method, el_val_t path, el_val_t body) {
return EL_STR("{\"ok\":true}");
el_val_t id = json_get_string(body, EL_STR("id"));
if (str_eq(id, EL_STR(""))) {
return EL_STR("{\"ok\":true}");
}
el_val_t node_json = engram_get_node_json(id);
if (str_eq(node_json, EL_STR(""))) {
return err_json(EL_STR("node not found"));
}
if (str_eq(node_json, EL_STR("null"))) {
return err_json(EL_STR("node not found"));
}
el_val_t content = json_get_string(node_json, EL_STR("content"));
if (str_eq(content, EL_STR(""))) {
return err_json(EL_STR("node has no content"));
}
el_val_t label = json_get_string(node_json, EL_STR("label"));
el_val_t tags = json_get_string(node_json, EL_STR("tags"));
el_val_t current_tier = json_get_string(node_json, EL_STR("tier"));
el_val_t tier_raw = json_get_string(body, EL_STR("tier"));
el_val_t new_tier = EL_STR("");
if (str_eq(tier_raw, EL_STR("lesson"))) {
new_tier = EL_STR("Semantic");
}
if (str_eq(tier_raw, EL_STR("canonical"))) {
new_tier = EL_STR("Procedural");
}
if (str_eq(tier_raw, EL_STR("note"))) {
new_tier = EL_STR("Episodic");
}
if (str_eq(new_tier, EL_STR(""))) {
if (str_eq(current_tier, EL_STR("Working"))) {
new_tier = EL_STR("Episodic");
}
if (str_eq(current_tier, EL_STR("Episodic"))) {
new_tier = EL_STR("Semantic");
}
if (str_eq(current_tier, EL_STR("Semantic"))) {
new_tier = EL_STR("Procedural");
}
if (str_eq(current_tier, EL_STR("Procedural"))) {
new_tier = EL_STR("Procedural");
}
}
if (str_eq(new_tier, EL_STR(""))) {
new_tier = EL_STR("Semantic");
}
el_val_t new_id = engram_node_full(content, EL_STR("Knowledge"), label, el_from_float(0.7), el_from_float(0.8), el_from_float(1.0), new_tier, tags);
if (!str_eq(new_id, EL_STR(""))) {
engram_connect(new_id, id, el_from_float(1.0), EL_STR("supersedes"));
}
el_val_t dir = env(EL_STR("ENGRAM_DATA_DIR"));
if (str_eq(dir, EL_STR(""))) {
dir = EL_STR("/tmp/engram");
}
engram_write_binary_el(el_str_concat(dir, EL_STR("/engram.db")));
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"id\":\""), new_id), EL_STR("\",\"promoted_from\":\"")), id), EL_STR("\",\"tier\":\"")), new_tier), EL_STR("\"}"));
return 0;
}
@@ -768,13 +823,17 @@ el_val_t route_neuron_state_events(el_val_t method, el_val_t path, el_val_t body
if (str_eq(method, EL_STR("GET"))) {
el_val_t limit_str = query_param(path, EL_STR("limit"));
el_val_t limit = ({ el_val_t _if_result_6 = 0; if (str_eq(limit_str, EL_STR(""))) { _if_result_6 = (50); } else { _if_result_6 = (str_to_int(limit_str)); } _if_result_6; });
return engram_scan_nodes_by_type_json(EL_STR("InternalStateEvent"), limit, 0);
el_val_t offset_str = query_param(path, EL_STR("offset"));
el_val_t offset = ({ el_val_t _if_result_7 = 0; if (str_eq(offset_str, EL_STR(""))) { _if_result_7 = (0); } else { _if_result_7 = (str_to_int(offset_str)); } _if_result_7; });
return engram_scan_nodes_by_type_json(EL_STR("InternalStateEvent"), limit, offset);
}
el_val_t content = json_get_string(body, EL_STR("content"));
if (str_eq(content, EL_STR(""))) {
content = body;
}
el_val_t id = engram_node_full(content, EL_STR("InternalStateEvent"), EL_STR("state-event"), el_from_float(0.3), el_from_float(0.3), el_from_float(1.0), EL_STR("Working"), EL_STR("internal-state"));
el_val_t event_label = json_get_string(content, EL_STR("event"));
el_val_t label = ({ el_val_t _if_result_8 = 0; if (str_eq(event_label, EL_STR(""))) { _if_result_8 = (EL_STR("state-event")); } else { _if_result_8 = (event_label); } _if_result_8; });
el_val_t id = engram_node_full(content, EL_STR("InternalStateEvent"), label, el_from_float(0.3), el_from_float(0.3), el_from_float(1.0), EL_STR("Working"), EL_STR("internal-state"));
return el_str_concat(el_str_concat(EL_STR("{\"ok\":true,\"id\":\""), id), EL_STR("\"}"));
return 0;
}
+62 -5
View File
@@ -629,9 +629,57 @@ fn route_neuron_knowledge_evolve(method: String, path: String, body: String) ->
"{\"ok\":true,\"id\":\"" + id + "\"}"
}
// route_neuron_knowledge_promote stub: ok
// route_neuron_knowledge_promote promote a knowledge node to a higher tier.
// Creates a new node with the promoted tier (same content) and connects
// via a "supersedes" edge from new old. Tier mapping:
// note/Episodic lesson/Semantic canonical/Procedural
fn route_neuron_knowledge_promote(method: String, path: String, body: String) -> String {
"{\"ok\":true}"
let id: String = json_get_string(body, "id")
if str_eq(id, "") { return "{\"ok\":true}" }
// Read existing node
let node_json: String = engram_get_node_json(id)
if str_eq(node_json, "") { return err_json("node not found") }
if str_eq(node_json, "null") { return err_json("node not found") }
let content: String = json_get_string(node_json, "content")
if str_eq(content, "") { return err_json("node has no content") }
let label: String = json_get_string(node_json, "label")
let tags: String = json_get_string(node_json, "tags")
let current_tier: String = json_get_string(node_json, "tier")
// Determine target tier: explicit override or auto-promote one level
let tier_raw: String = json_get_string(body, "tier")
let new_tier: String = ""
// Explicit tier takes precedence
if str_eq(tier_raw, "lesson") { let new_tier = "Semantic" }
if str_eq(tier_raw, "canonical") { let new_tier = "Procedural" }
if str_eq(tier_raw, "note") { let new_tier = "Episodic" }
// Auto-promote one level if no explicit tier
if str_eq(new_tier, "") {
if str_eq(current_tier, "Working") { let new_tier = "Episodic" }
if str_eq(current_tier, "Episodic") { let new_tier = "Semantic" }
if str_eq(current_tier, "Semantic") { let new_tier = "Procedural" }
if str_eq(current_tier, "Procedural") { let new_tier = "Procedural" }
}
if str_eq(new_tier, "") { let new_tier = "Semantic" }
// Create promoted node higher importance (0.8) signals durable knowledge
let new_id: String = engram_node_full(content, "Knowledge", label, 0.7, 0.8, 1.0, new_tier, tags)
// Wire supersedes edge: new node supersedes old
if !str_eq(new_id, "") {
engram_connect(new_id, id, 1.0, "supersedes")
}
// Checkpoint
let dir: String = env("ENGRAM_DATA_DIR")
if str_eq(dir, "") { let dir = "/tmp/engram" }
engram_write_binary_el(dir + "/engram.db")
"{\"ok\":true,\"id\":\"" + new_id + "\",\"promoted_from\":\"" + id + "\",\"tier\":\"" + new_tier + "\"}"
}
// route_neuron_recall search or list nodes
@@ -702,16 +750,25 @@ fn route_neuron_config(method: String, path: String, body: String) -> String {
"{\"key\":\"" + key + "\",\"value\":\"\"}"
}
// route_neuron_state_events GET lists ISEs, POST logs a new one
// route_neuron_state_events GET lists ISEs, POST logs a new one.
// GET supports ?limit=N&offset=M for pagination; ?label=X to extract label
// from the ISE content's "event" field.
// Use a high offset to skip to recent ISEs (ISEs fill quickly and sort by
// salience then insertion order older entries dominate the front of scans).
fn route_neuron_state_events(method: String, path: String, body: String) -> String {
if str_eq(method, "GET") {
let limit_str: String = query_param(path, "limit")
let limit: Int = if str_eq(limit_str, "") { 50 } else { str_to_int(limit_str) }
return engram_scan_nodes_by_type_json("InternalStateEvent", limit, 0)
let offset_str: String = query_param(path, "offset")
let offset: Int = if str_eq(offset_str, "") { 0 } else { str_to_int(offset_str) }
return engram_scan_nodes_by_type_json("InternalStateEvent", limit, offset)
}
let content: String = json_get_string(body, "content")
if str_eq(content, "") { let content = body }
let id: String = engram_node_full(content, "InternalStateEvent", "state-event", 0.3, 0.3, 1.0, "Working", "internal-state")
// Extract label from content JSON "event" field for better ISE searchability
let event_label: String = json_get_string(content, "event")
let label: String = if str_eq(event_label, "") { "state-event" } else { event_label }
let id: String = engram_node_full(content, "InternalStateEvent", label, 0.3, 0.3, 1.0, "Working", "internal-state")
"{\"ok\":true,\"id\":\"" + id + "\"}"
}
+16 -2
View File
@@ -6070,7 +6070,20 @@ el_val_t engram_node_full(el_val_t content, el_val_t node_type, el_val_t label,
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
if (n->importance <= 0.0 || n->importance > 1.0) n->importance = 0.5;
if (n->confidence <= 0.0 || n->confidence > 1.0) n->confidence = 1.0;
n->temporal_decay_rate = 0.0; /* 0 = use global default ENGRAM_DECAY_LAMBDA */
/* Tier-based temporal decay (2026-05-16 self-review, grounded in ACT-R literature).
* Working-memory events fade fastest; semantic knowledge persists 2 weeks;
* procedural/canonical knowledge lasts ~30 days before 50% decay.
* Formula: lambda = ENGRAM_DECAY_LAMBDA * ENGRAM_T_HALF_HOURS / target_half_life_h
* Working=48h 2.310, Episodic=72h 1.617, Semantic=336h 0.347, Procedural=720h 0.162 */
if (ti && *ti) {
if (strcmp(ti, "Working") == 0) n->temporal_decay_rate = 2.310;
else if (strcmp(ti, "Episodic") == 0) n->temporal_decay_rate = 1.617;
else if (strcmp(ti, "Semantic") == 0) n->temporal_decay_rate = 0.347;
else if (strcmp(ti, "Procedural") == 0) n->temporal_decay_rate = 0.162;
else n->temporal_decay_rate = 0.0;
} else {
n->temporal_decay_rate = 0.0; /* global default: 168h half-life */
}
n->activation_count = 0;
int64_t now = engram_now_ms();
n->last_activated = now;
@@ -6146,7 +6159,8 @@ el_val_t engram_node_layered(el_val_t content, el_val_t node_type, el_val_t labe
if (n->salience <= 0.0 || n->salience > 1.0) n->salience = 0.5;
if (n->importance <= 0.0 || n->importance > 1.0) n->importance = 0.5;
if (n->confidence <= 0.0 || n->confidence > 1.0) n->confidence = 1.0;
n->temporal_decay_rate = 0.0;
/* engram_node_layered always creates Working-tier nodes — apply 48h decay */
n->temporal_decay_rate = 2.310;
n->activation_count = 0;
int64_t now = engram_now_ms();
n->last_activated = now;