Compare commits

...

2 Commits

Author SHA1 Message Date
will.anderson 96d6bef0c2 fix(engram-scoring): correct relevance denominator, hard_bell brace, threshold
Three fixes from code review on improve/recall-engram-scoring:

1. CRITICAL — relevance denominator /10000 → /100: parse_salience_100 already
   scales floats to 0-100 (e.g. "0.7" → 70), so the product of two such values
   must be divided by 100 to stay in 0-100 range. The /10000 divisor caused
   integer truncation to 0 for every real-world node (sal=0.7, imp=0.7 →
   70*70/10000 = 0). engram_compile_ranked was returning empty string for all
   inputs, leaving the soul with zero memory context.

2. CRITICAL — missing closing brace for hard_bell if-block in handle_chat_agentic
   (line ~1050): the return statement was not followed by the closing `}`, making
   the entire non-bell code path dead code inside the branch. All agentic turns
   that were not a hard_bell would silently fall through the open block.

3. HIGH — threshold 15 → 10 in engram_compile_ranked: even after the /100 fix,
   threshold=15 was marginally too aggressive for low-salience nodes near the
   Working-tier recency floor. sal=0.5 imp=0.5 at floor scores 16 (just above
   15), so the margin was only 1 point. Lowering to 10 gives comfortable headroom
   while still filtering genuine noise (sal=0.1 imp=0.1 → score ≤ 1).
2026-06-22 13:35:00 -05:00
will.anderson 76c2e47d0f feat(recall): fix engram-scoring — float parsing, recency, threshold, sentinels
Neuron Soul CI / build (pull_request) Has been cancelled
Fix critical float parsing bug: %g serializes 0.70 as '0.7', naive str_replace
dot-strip gives str_to_int('07')=7 not 70. New parse_salience_100() uses
str_index_of to detect single-decimal strings and multiplies by 10 to correct.
Affects conv nodes (0.6/0.7), default memories (0.5/0.5), utterance nodes (0.6)
— the majority of the graph was scoring near zero and filtered by threshold=25.

Fix recency to use max(created_at, updated_at, last_activated) so nodes
strengthened by engram_strengthen() after chat turns score as fresh, not by
original write time. A node referenced yesterday but created 25 days ago
was borderline-filtered; now correctly scores fresh.

Compress recency dynamic range from 10x (10-100) to 1.54x (65-100) via
formula (50 + recency/2). Old formula: sal*imp*recency/10000 let recency
dominate — a canonical high-importance node at 30 days scored identical to
a fresh noise node. New: high-importance nodes remain competitive when old.

Add tier-aware decay with softer floor (30 not 10): Canonical nodes decay
over 365 days, Episodic over 90 days, working/untiered over 35 days. Long-
term identity and persona nodes are no longer permanently filtered.

Lower threshold from 25 to 15 to admit moderately-relevant older nodes that
pass scoring with the corrected formula. Backfills recall coverage lost when
single-decimal nodes were being silently discarded.

Apply scoring to activation nodes: engram_compile_ranked(activate_json, 5)
replaces unconditional pass-through. Threshold 5 preserves recall while
excluding genuinely zero-quality stale nodes.

Extend sentinel cleanup in engram_compile_ranked from _sel_0-9 to _sel_0-19
so max_nodes can safely be increased past 10 without JSON corruption.
2026-06-22 12:53:35 -05:00
+116 -35
View File
@@ -12,47 +12,113 @@ fn chat_default_model() -> String {
return "claude-sonnet-4-5"
}
// parse_salience_100 convert a %g-serialized float to integer * 100.
// The C runtime serializes floats with %g which trims trailing zeros:
// 0.70 "0.7", 0.60 "0.6", 0.50 "0.5", 1.0 "1"
// The naive str_replace(".", "") approach breaks for single-decimal strings:
// "0.7" "07" str_to_int 7 (WRONG, should be 70)
// "0.5" "05" str_to_int 5 (WRONG, should be 50)
// "0.85" "085" str_to_int 85 (accidentally correct two decimal digits)
// Fix: use str_index_of to find the decimal point and scale accordingly:
// No decimal ("1"): multiply raw by 100
// One decimal digit ("0.7"): multiply stripped value by 10
// Two+ decimal digits ("0.85"): stripped value is already in hundredths
fn parse_salience_100(s: String) -> Int {
if str_eq(s, "") { return 70 }
let dot_pos: Int = str_index_of(s, ".")
let raw: Int = if dot_pos < 0 {
// No decimal point integer like "1" means 100%
str_to_int(s) * 100
} else {
let after_dot: String = str_slice(s, dot_pos + 1, str_len(s))
let decimal_digits: Int = str_len(after_dot)
let stripped: Int = str_to_int(str_replace(s, ".", ""))
if decimal_digits == 1 { stripped * 10 } else { stripped }
}
if raw > 100 { 100 } else { if raw < 0 { 0 } else { raw } }
}
// engram_score_node compute a recency x relevance score for a single engram
// node JSON object. Higher is better. Score = salience * importance * recency_factor.
// recency_factor decays linearly over 30 days: nodes updated today score 1.0,
// nodes 30+ days old score 0.1 (floor). Nodes with no created_at score 0.5.
// This keeps fresh, high-salience nodes at the top and pushes stale low-signal
// nodes to the bottom so they get trimmed when we cap context size.
// node JSON object. Higher is better.
//
// Bugs fixed vs original implementation:
// 1. FLOAT PARSING: parse_salience_100 correctly handles %g single-decimal output.
// "0.7" 70, "0.6" 60, "0.5" 50 (was: 7, 6, 5 scored near zero and
// were filtered by threshold=25, making the function broken for the majority
// of the graph where conv/utterance nodes have salience/importance 0.6/0.7).
// 2. RECENCY USES LAST TOUCH: uses max(created_at, updated_at, last_activated) so
// nodes strengthened by engram_strengthen() after chat turns are not penalised
// for a stale created_at. A node referenced yesterday but created 25 days ago
// now correctly scores as fresh rather than borderline-filtered.
// 3. COMPRESSED RECENCY RANGE: old formula (sal * imp * recency / 10000) gave
// recency a 10x dynamic range (10-100) vs 1.9x for salience/importance. A
// canonical high-importance node at 30 days scored the same as a fresh noise
// node. New formula compresses recency to 1.54x via (50 + recency/2) weight.
// 4. SOFTER FLOOR: recency floor raised from 10 to 30 with tier-aware decay windows
// so canonical identity/persona nodes never bottom out to near-zero.
fn engram_score_node(node_json: String) -> Int {
let salience_str: String = json_get(node_json, "salience")
let importance_str: String = json_get(node_json, "importance")
let created_str: String = json_get(node_json, "created_at")
let updated_str: String = json_get(node_json, "updated_at")
let activated_str: String = json_get(node_json, "last_activated")
let tier_str: String = json_get(node_json, "tier")
// Parse as floats via * 100 integer arithmetic (el has no float math)
let salience_100: Int = if str_eq(salience_str, "") { 70 } else {
let s: Int = str_to_int(str_replace(salience_str, ".", ""))
// Clamp to 0-100 range (value was e.g. "0.85" -> parsed "085" = 85)
if s > 100 { 100 } else { if s < 0 { 0 } else { s } }
}
let importance_100: Int = if str_eq(importance_str, "") { 70 } else {
let v: Int = str_to_int(str_replace(importance_str, ".", ""))
if v > 100 { 100 } else { if v < 0 { 0 } else { v } }
}
// parse_salience_100 handles "0.7" 70, "0.85" 85, "1.0" 100, "1" 100
let salience_100: Int = parse_salience_100(salience_str)
let importance_100: Int = parse_salience_100(importance_str)
// Recency: decay from 100 (today) to 10 (30+ days). created_at is Unix seconds.
// Recency: use max(created_at, updated_at, last_activated).
// last_activated is updated by engram_strengthen() every chat turn nodes
// actively referenced score fresh regardless of original write time.
let now_ts: Int = time_now()
let recency_100: Int = if str_eq(created_str, "") { 50 } else {
let created_ts: Int = str_to_int(created_str)
let age_secs: Int = now_ts - created_ts
let age_days: Int = age_secs / 86400
let decay: Int = if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
if decay < 10 { 10 } else { decay }
let created_ts: Int = if str_eq(created_str, "") { 0 } else { str_to_int(created_str) }
let updated_ts: Int = if str_eq(updated_str, "") { 0 } else { str_to_int(updated_str) }
let activated_ts: Int = if str_eq(activated_str, "") { 0 } else { str_to_int(activated_str) }
let best_ts_ab: Int = if updated_ts > created_ts { updated_ts } else { created_ts }
let best_ts: Int = if activated_ts > best_ts_ab { activated_ts } else { best_ts_ab }
let recency_100: Int = if best_ts == 0 { 50 } else {
let age_secs: Int = now_ts - best_ts
// Guard against clock skew (future timestamps): treat as brand new.
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
// Tier-aware decay, softer floor (30 not 10):
// Canonical: 365-day window foundational identity/persona nodes.
// Episodic: 90-day window conversation context fades moderately.
// Working/untiered: 35-day window transient task state.
let is_canonical: Bool = str_eq(tier_str, "Canonical")
let is_episodic: Bool = str_eq(tier_str, "Episodic")
let decay: Int = if is_canonical {
let drop: Int = if age_days >= 365 { 70 } else { age_days * 70 / 365 }
100 - drop
} else {
if is_episodic {
if age_days >= 90 { 30 } else { 100 - (age_days * 70 / 90) }
} else {
if age_days >= 35 { 30 } else { 100 - (age_days * 2) }
}
}
if decay < 30 { 30 } else { decay }
}
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
return salience_100 * importance_100 * recency_100 / 10000
// Compressed recency weight (50 + recency/2): range 65-100 (1.54x dynamic range).
// Old formula had 10x recency range which drowned out relevance for old-but-important
// nodes. New: relevance (0-100) × recency_weight (65-100) / 100 score 0-100.
// salience_100 and importance_100 are already in the 0-100 range (parse_salience_100
// returns e.g. 70 for "0.7"). Dividing by 100 keeps relevance in 0-100.
// Dividing by 10000 caused integer truncation to 0 for all real-world nodes
// (e.g., sal=0.7, imp=0.7 70*70/10000 = 0 instead of 49).
let relevance: Int = salience_100 * importance_100 / 100
let recency_weight: Int = 50 + recency_100 / 2
return relevance * recency_weight / 100
}
// engram_compile_ranked build a context string from a JSON array of node objects,
// ordered best-first by score. Only nodes above a minimum score (25 = salience 0.5 *
// importance 0.5 * recency 1.0) are included; the rest are noise. Returns at most
// max_nodes entries concatenated as JSON array text. Because el has no sort primitive,
// we do a single selection pass picking the top N by linear scan (N=10 cap).
// ordered best-first by score. Only nodes above threshold=10 are included.
// With corrected formula (sal*imp/100): sal=0.5*imp=0.5 at max recency scores 25;
// sal=0.5*imp=0.5 at Working floor (recency=30, weight=65) scores 16.
// Threshold=10 gives safe headroom for low-salience nodes near the recency floor,
// while still filtering near-zero noise (e.g., sal=0.1*imp=0.1 score1).
// Returns at most max_nodes entries. max_nodes must not exceed 20 (sentinel limit).
fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
if str_eq(nodes_json, "") { return "" }
if str_eq(nodes_json, "[]") { return "" }
@@ -73,8 +139,10 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
while ci < total {
let node: String = json_array_get(nodes_json, ci)
let score: Int = engram_score_node(node)
// Only include reasonably relevant nodes (threshold=25)
let above_thresh: Bool = score >= 25
// Threshold=10: allows moderately-relevant older nodes while filtering noise.
// Example: sal=0.5 imp=0.5 at Working recency floor (35+ days) score 16,
// which passes. A near-zero node (sal=0.1 imp=0.1) score 1, filtered.
let above_thresh: Bool = score >= 10
// Check this index wasn't already selected (sentinel: look for idx marker)
let idx_marker: String = "\"_sel_" + int_to_str(ci) + "\""
let already_picked: Bool = str_contains(selected, idx_marker)
@@ -101,7 +169,7 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
// Strip the _sel_N sentinel fields that were used for duplicate-detection bookkeeping.
// The sentinels have the form "\"_sel_N\":1," (trailing comma, space before next key).
// We injected them as the first field in each object, so the pattern is predictable.
// Because el has no regex, remove up to 10 possible sentinel variants by literal replace.
// Because el has no regex, remove up to 20 possible sentinel variants by literal replace.
let clean: String = "[" + selected + "]"
let c0: String = str_replace(clean, "\"_sel_0\":1,", "")
let c1: String = str_replace(c0, "\"_sel_1\":1,", "")
@@ -113,7 +181,17 @@ fn engram_compile_ranked(nodes_json: String, max_nodes: Int) -> String {
let c7: String = str_replace(c6, "\"_sel_7\":1,", "")
let c8: String = str_replace(c7, "\"_sel_8\":1,", "")
let c9: String = str_replace(c8, "\"_sel_9\":1,", "")
return c9
let c10: String = str_replace(c9, "\"_sel_10\":1,", "")
let c11: String = str_replace(c10, "\"_sel_11\":1,", "")
let c12: String = str_replace(c11, "\"_sel_12\":1,", "")
let c13: String = str_replace(c12, "\"_sel_13\":1,", "")
let c14: String = str_replace(c13, "\"_sel_14\":1,", "")
let c15: String = str_replace(c14, "\"_sel_15\":1,", "")
let c16: String = str_replace(c15, "\"_sel_16\":1,", "")
let c17: String = str_replace(c16, "\"_sel_17\":1,", "")
let c18: String = str_replace(c17, "\"_sel_18\":1,", "")
let c19: String = str_replace(c18, "\"_sel_19\":1,", "")
return c19
}
fn engram_compile(intent: String) -> String {
@@ -124,8 +202,11 @@ fn engram_compile(intent: String) -> String {
let act_ok: Bool = !str_eq(activate_json, "") && !str_eq(activate_json, "[]")
let srch_ok: Bool = !str_eq(search_json, "") && !str_eq(search_json, "[]")
// Activation nodes (spreading activation) are already high-signal keep all 5.
let act_part: String = if act_ok { activate_json } else { "" }
// Activation nodes (spreading activation) are high-signal but apply scoring via
// engram_compile_ranked with threshold=5 to exclude genuinely zero-quality stale
// nodes that happen to be graph-connected. The threshold of 5 is well below the
// search path threshold of 15 to preserve the activation path's higher recall.
let act_part: String = if act_ok { engram_compile_ranked(activate_json, 5) } else { "" }
// Rank search results and keep only the top 8 (was: flat 15 unranked).
// This cuts context noise roughly in half while preserving the best-scoring nodes.
@@ -974,7 +1055,7 @@ fn handle_chat_agentic(body: String) -> String {
if str_eq(screen_action, "hard_bell") {
safety_log_bell("hard", json_get(screen_result, "reason"), str_slice(message, 0, 80))
return "{\"reply\":\"" + json_safe(safety_validate("", "hard_bell")) + "\",\"model\":\"\",\"agentic\":true,\"tools_used\":[]}"
}
let req_model: String = json_get(body, "model")
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }