Compare commits

...

2 Commits

Author SHA1 Message Date
will.anderson 02bf2e7d81 Fix five latent bugs from temporal-precision code review
1. parse_salience_100: handle 3+ decimal digit salience strings correctly.
   The two-branch 'else { stripped }' case treated any N-digit decimal value
   as hundredths, so "0.125" (stripped=125) clamped to 100 instead of 12.
   Now divides by 10^(N-2) for N>2, mapping "0.125"->12, "0.375"->37, etc.

2. mem_consolidate Canonical scan: replaced single engram_scan_nodes_json(50,0)
   call with a paginated loop (page_size=50, advancing offset) so Canonical nodes
   beyond index 50 are no longer silently excluded from the periodic boost.

3. mem_consolidate Canonical strengthening: add salience ceiling guard so nodes
   already at the runtime maximum (serialised as "1" by %g) are skipped. Prevents
   monotonic unbounded salience growth across successive consolidation passes.

4. soul.el affective cutoff: replaced json_get(aff_node, "ts") with
   json_get(aff_node, "created_at") / "updated_at" fallback, consistent with
   handle_chat. The old "ts" field is not a standard engram node field; missing
   it caused the fallback to ts_now (always passes cutoff), over-including stale
   nodes. New behaviour defaults to 0 on missing timestamps (conservative exclude).

5. History byte-cap: implemented the existing TODO 32KB byte-cap. Added
   hist_trim_to_byte_cap() and applied it after count-based trim in both
   handle_chat and handle_chat_agentic. Prevents 100KB+ state entries at 40 turns
   during long technical sessions with large assistant responses.
2026-06-22 13:35:52 -05:00
will.anderson 0ede112d05 feat(recall): temporal-precision improvements
Neuron Soul CI / build (pull_request) Has been cancelled
Fix critical float parsing bug in engram_score_node: str_replace('.','')
then str_to_int silently miscored single-decimal salience strings (0.9->9,
0.7->7, 1.0->1). Introduce parse_salience_100() which detects decimal
position and scales correctly (no decimal: *100; one decimal: *10;
two decimals: as-is).

Replace flat 30-day linear decay with tier-aware decay curves: Canonical
nodes use a 365-day window (foundational identity resists aging), Episodic
nodes use 90 days, Working/untiered keep the existing 30-day slope. Floor
stays at 10 for all tiers.

Use max(created_at, updated_at) as the recency reference so revised nodes
are not penalised for their original creation date.

Extend affective context windows from 72h/7d to 14 days across all three
paths (engram_compile, handle_chat, soul.el load_identity_context) so a
Friday crisis carries into Monday sessions and all paths present consistent
context. The 72h/7d split caused conflicting affective context between
soul.el (which loaded a 5-day-old crisis node) and chat.el (which excluded
it on subsequent turns).

Add salience evolution to mem_consolidate: strengthen top working-memory
nodes (recently recalled across sessions) and Canonical-tier nodes
(foundational identity must not decay to the floor). Previously consolidate
returned structural counts only with no salience changes.

Expand conversation window from 20 to 40 turns in both handle_chat and the
agentic history trim. Long technical sessions were losing early problem
framing at 10 user + 10 assistant pairs.
2026-06-22 12:53:29 -05:00
3 changed files with 266 additions and 43 deletions
+160 -34
View File
@@ -12,39 +12,107 @@ fn chat_default_model() -> String {
return "claude-sonnet-4-5"
}
// parse_salience_100 convert a salience/importance float string (as serialized by
// %g format) to an integer in the range 0..100.
//
// The runtime serializes floats with %g which drops trailing zeros:
// 1.0 -> "1" (no decimal at all)
// 0.9 -> "0.9" (one decimal digit)
// 0.85 -> "0.85" (two decimal digits)
// 0.125 -> "0.125" (three decimal digits %g does not round to 2 dp)
//
// The old approach of str_replace(s, ".", "") then str_to_int was broken:
// "0.9" -> "09" -> str_to_int -> 9 (should be 90)
// "0.5" -> "05" -> str_to_int -> 5 (should be 50)
// "1" -> "1" -> str_to_int -> 1 (should be 100)
// "0.85" -> "085" -> str_to_int -> 85 (accidentally correct)
// "0.125" -> "0125" -> str_to_int -> 125 -> clamped to 100 (wrong: should be 12)
//
// Fix: detect presence and position of the decimal point, then scale accordingly.
// - No decimal (e.g. "1"): multiply by 100.
// - One decimal digit (e.g. "0.9"): multiply by 10 to get 90.
// - Two decimal digits (e.g. "0.85"): use as-is (already hundredths).
// - Three+ decimal digits: stripped integer is in units of 10^N (where N=digits
// after the dot), so divide by 10^(N-2) to reduce to hundredths. Examples:
// "0.125" -> stripped=125, N=3 -> 125/10 = 12
// "0.375" -> stripped=375, N=3 -> 375/10 = 37
// "0.625" -> stripped=625, N=3 -> 625/10 = 62
// "0.875" -> stripped=875, N=3 -> 875/10 = 87
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 {
let v: Int = str_to_int(s)
v * 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 {
if decimal_digits == 2 {
stripped
} else {
// 3+ decimal digits: divide out the extra precision to get hundredths.
// extra = decimal_digits - 2; divisor = 10^extra.
let extra: Int = decimal_digits - 2
let divisor: Int = if extra == 1 { 10 } else {
if extra == 2 { 100 } else {
if extra == 3 { 1000 } else {
if extra == 4 { 10000 } else { 100000 }
}
}
}
stripped / divisor
}
}
}
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.
//
// Recency uses a tier-aware decay curve instead of a flat linear slope:
// - Canonical tiers decay very slowly: 365-day window (foundational identity).
// - Episodic tiers decay at a moderate rate: 90-day window (conversation context).
// - Working/untiered nodes decay at 30 days (transient task state).
// - Floor is 10 (never zero) for all tiers.
//
// Uses max(created_at, updated_at) so recently-revised nodes are not penalised.
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 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 } }
}
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.
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) }
let updated_ts: Int = if str_eq(updated_str, "") { 0 } else { str_to_int(updated_str) }
let ref_ts: Int = if updated_ts > created_ts { updated_ts } else { created_ts }
let age_secs: Int = now_ts - ref_ts
let age_days: Int = if age_secs < 0 { 0 } else { age_secs / 86400 }
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 { 90 } else { age_days * 90 / 365 }
100 - drop
} else {
if is_episodic {
if age_days >= 90 { 10 } else { 100 - age_days }
} else {
if age_days >= 30 { 10 } else { 100 - (age_days * 3) }
}
}
if decay < 10 { 10 } else { decay }
}
// Combined score 0-1000000 (no floats): salience * importance * recency / 10000
return salience_100 * importance_100 * recency_100 / 10000
}
@@ -151,16 +219,17 @@ fn engram_compile(intent: String) -> String {
}
// Affective context: always include the most recent high-emotion memory if one
// exists within 72 hours. This ensures continuity of care across turns when
// the user was in distress earlier in the session (or recently), that context
// travels into every subsequent LLM call so the response register stays aware.
// exists within 14 days. This ensures continuity of care across sessions a
// crisis on Friday must still carry into Monday (72h was too narrow for multi-day
// distress arcs such as grief or recurring suicidal ideation). 14-day window
// (1,209,600 seconds) covers sustained emotional arcs while excluding ancient
// history. Unified with handle_chat and soul.el affective checks.
// We search for BellEvent nodes specifically; these are written by auto_persist
// when safety_detect_bell_level fires. The 72h window (259200 seconds) is wide
// enough to span a multi-session day without pulling ancient history.
// when safety_detect_bell_level fires.
let bell_nodes: String = engram_search_json("bell:soft bell:hard BellEvent", 3)
let bell_ok: Bool = !str_eq(bell_nodes, "") && !str_eq(bell_nodes, "[]")
let now_ts: Int = time_now()
let cutoff_ts: Int = now_ts - 259200
let cutoff_ts: Int = now_ts - 1209600
let recent_bell: String = if bell_ok {
let bn0: String = json_array_get(bell_nodes, 0)
// created_at is not present in engram node JSON for BellEvent nodes.
@@ -354,6 +423,47 @@ fn hist_trim_with_bell_guard(hist: String) -> String {
return hist
}
// hist_trim_to_byte_cap drop oldest user+assistant pairs until the history blob
// is at or below `cap_bytes` in length, or until only 2 entries remain (the minimum
// safe window). Uses the same structural json_array_len/json_array_get approach as
// hist_trim to stay immune to content containing JSON marker strings.
//
// Called after count-based trimming to enforce a hard size ceiling on the history
// blob. Without this cap, long technical sessions with large assistant responses
// (code blocks, logs, analysis) can push the 40-turn window to 100KB+, which causes
// engram_node_full writes to grow state entries unboundedly.
fn hist_trim_to_byte_cap(hist: String, cap_bytes: Int) -> String {
let current: String = hist
let current_len: Int = str_len(current)
while current_len > cap_bytes {
let total: Int = json_array_len(current)
// Never trim below 2 entries (1 pair).
if total <= 2 {
let current_len = 0 // exit loop
} else {
// Drop entries 0 and 1 (oldest pair).
let result: String = ""
let i: Int = 2
while i < total {
let entry: String = json_array_get(current, i)
let result = if str_eq(result, "") {
entry
} else {
result + "," + entry
}
let i = i + 1
}
if str_eq(result, "") {
let current_len = 0 // exit loop
} else {
let current = "[" + result + "]"
let current_len = str_len(current)
}
}
}
return current
}
// clean_llm_response strips GPT-2 BPE byte-to-unicode artifacts that vLLM
// emits when the tokenizer hasn't decoded back to raw bytes.
//
@@ -482,12 +592,14 @@ fn handle_chat(body: String) -> String {
}
// Cross-session affective context: on session start (no history yet), check engram
// for recent distress signals within 72h and prepend a care directive if found.
// for recent distress signals within 14 days and prepend a care directive if found.
// Extended from 72h: multi-day crisis must persist across Monday sessions starting
// 3+ days after a Friday event. Consistent with engram_compile and soul.el checks.
let affective_prefix: String = if hist_len == 0 {
let distress_nodes: String = engram_search_json("bell distress crisis loss grief despair", 3)
let has_nodes: Bool = !str_eq(distress_nodes, "") && !str_eq(distress_nodes, "[]")
let now_ts: Int = time_now()
let cutoff: Int = now_ts - 259200
let cutoff: Int = now_ts - 1209600
let found_recent: Bool = if has_nodes {
let dn0: String = json_array_get(distress_nodes, 0)
let ts0_raw: String = json_get(dn0, "created_at")
@@ -606,16 +718,23 @@ fn handle_chat(body: String) -> String {
let updated_hist2: String = hist_append(updated_hist, "assistant", raw_response)
// Use bell-guarded trim: if the evicted turn triggered a bell event, it is
// preserved to engram before being dropped from the in-memory window.
// Issue #8 (NO MAX SIZE GUARD): the 20-turn count limit bounds entry count, but individual
// messages can be arbitrarily large (up to max_tokens = 4096 tokens each). At 20 turns the
// history blob can reach ~80KB before trim fires. engram_node_full has no apparent size cap.
// A byte-length cap would require truncating or summarising entries too invasive here.
// TODO: add a byte-length cap (e.g. 32KB) that drops oldest entries until under limit.
let final_hist: String = if json_array_len(updated_hist2) > 20 {
// Increased from 20 to 40 turns: long technical sessions lose early context at 20
// (10 user + 10 assistant pairs). 40 turns preserves problem framing for multi-step
// tasks while the bell guard still persists evicted distress turns to engram.
// Byte-cap: after count-based trim, also trim oldest pairs until the history blob
// is under 32KB. Long technical sessions with large assistant responses (code blocks,
// analysis) can produce 100-160KB+ state entries at 40 turns; the count limit alone
// is insufficient. We retain at least 2 entries (1 user + 1 assistant pair) regardless.
let count_trimmed: String = if json_array_len(updated_hist2) > 40 {
hist_trim_with_bell_guard(updated_hist2)
} else {
updated_hist2
}
let final_hist: String = if str_len(count_trimmed) > 32768 {
hist_trim_to_byte_cap(count_trimmed, 32768)
} else {
count_trimmed
}
state_set("conv_history", final_hist)
conv_history_persist(final_hist)
@@ -1193,7 +1312,14 @@ fn handle_chat_agentic(body: String) -> String {
let discard_hist: Bool = if !str_eq(reply_text, "") {
let updated: String = hist_append(agentic_hist, "user", message)
let updated2: String = hist_append(updated, "assistant", reply_text)
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
// Increased from 20 to 40 turns: consistent with handle_chat window expansion.
// Byte-cap: also trim if the blob exceeds 32KB, consistent with handle_chat.
let count_trimmed2: String = if json_array_len(updated2) > 40 { hist_trim(updated2) } else { updated2 }
let trimmed: String = if str_len(count_trimmed2) > 32768 {
hist_trim_to_byte_cap(count_trimmed2, 32768)
} else {
count_trimmed2
}
state_set(hist_key, trimmed)
// Only persist the default global session to engram named sessions are ephemeral.
if str_eq(hist_key, "conv_history") {
+83 -3
View File
@@ -35,14 +35,94 @@ fn mem_forget(node_id: String) -> Void {
engram_forget(node_id)
}
// mem_consolidate structural scan plus salience-evolution pass.
//
// Previously this only returned structural counts (scanned, total_nodes, total_edges)
// with no salience updates. No node salience ever changed based on recall frequency
// or time; foundational nodes decayed identically to ephemeral chat; frequently-recalled
// nodes were never promoted. This made consolidation a no-op.
//
// New behavior:
// (a) Strengthen frequently-activated nodes: nodes in the top working-memory list
// (engram_wm_top_json) are strengthened they have been recalled recently
// and deserve higher salience. Raises effective salience for nodes that prove
// relevant across multiple sessions.
// (b) Strengthen Canonical-tier nodes: identity and foundational nodes should not
// decay; each consolidation pass re-strengthens them so they resist the
// tier-aware decay curve without requiring active recall.
// (c) Structural counts are still returned for observability.
//
// Called by awareness_run() on the "consolidate" inbox action.
fn mem_consolidate() -> String {
let scanned: Int = engram_node_count()
let dummy: String = engram_scan_nodes_json(100, 0)
let total_nodes: Int = engram_node_count()
let total_edges: Int = engram_edge_count()
let strengthened: Int = 0
// (a) Strengthen top working-memory nodes recalled recently across sessions.
// Cap at 10 to keep consolidation fast.
let wm_top: String = engram_wm_top_json(10)
let wm_len: Int = json_array_len(wm_top)
let wi: Int = 0
while wi < wm_len {
let wm_node: String = json_array_get(wm_top, wi)
let wm_id: String = json_get(wm_node, "id")
if !str_eq(wm_id, "") {
engram_strengthen(wm_id)
let strengthened = strengthened + 1
}
let wi = wi + 1
}
// (b) Strengthen Canonical-tier nodes from a full paginated scan so they resist
// temporal decay. Canonical nodes encode foundational identity they must not
// silently floor at 10. Page size 50, scanning until fewer than 50 nodes are
// returned (last page), so all Canonical nodes are reached even in large graphs.
// Without pagination, only the first 50 nodes in the graph were eligible; any
// Canonical node at index 50+ was silently excluded from the boost.
// Strengthening is skipped if the node's current salience is already at the
// runtime ceiling (represented as "1" by %g) to avoid monotonic unbounded growth.
// Canonical nodes with salience < 1.0 are strengthened each consolidation pass;
// once they reach the ceiling the runtime will no longer raise them further, so
// calling engram_strengthen at the ceiling is a no-op in the runtime anyway, but
// the explicit check makes the intent clear and avoids any runtime log noise.
let page_size: Int = 50
let scan_offset: Int = 0
let scan_done: Bool = false
while !scan_done {
let scan_result: String = engram_scan_nodes_json(page_size, scan_offset)
let scan_len: Int = json_array_len(scan_result)
if scan_len == 0 {
let scan_done = true
} else {
let si: Int = 0
while si < scan_len {
let s_node: String = json_array_get(scan_result, si)
let s_tier: String = json_get(s_node, "tier")
let s_id: String = json_get(s_node, "id")
let s_sal: String = json_get(s_node, "salience")
// Only strengthen if below the ceiling to prevent unbounded salience growth.
// engram serialises the ceiling as "1" (%g drops the decimal part when it
// is exactly zero). Any other value is below ceiling and should be boosted.
let at_ceiling: Bool = str_eq(s_sal, "1")
if str_eq(s_tier, "Canonical") && !str_eq(s_id, "") && !at_ceiling {
engram_strengthen(s_id)
let strengthened = strengthened + 1
}
let si = si + 1
}
let scan_offset = scan_offset + scan_len
// Fewer results than page_size means we've reached the last page.
if scan_len < page_size {
let scan_done = true
}
}
}
let total_nodes: Int = engram_node_count()
return "{\"scanned\":" + int_to_str(scanned)
+ ",\"total_nodes\":" + int_to_str(total_nodes)
+ ",\"total_edges\":" + int_to_str(total_edges) + "}"
+ ",\"total_edges\":" + int_to_str(total_edges)
+ ",\"strengthened\":" + int_to_str(strengthened) + "}"
}
fn mem_save(path: String) -> Void {
+23 -6
View File
@@ -166,23 +166,40 @@ fn load_identity_context() -> Void {
// Cross-session affective context: query engram for recent distress/crisis signals
// at session start. Stored under soul_affective_context so the safety layer can
// detect when a user has been in distress across previous sessions.
// Soft recency guard: nodes with a ts field older than 7 days are skipped.
// Results capped at 3 nodes, 200 chars each, to avoid over-injection into context.
// Recency guard: nodes older than 14 days (1,209,600 seconds) are skipped.
// Unified at 14 days with chat.el engram_compile and handle_chat affective checks
// so all three paths present consistent affective context. The previous 7-day
// (604800s) window was inconsistent with the 72h chat.el window, causing
// conflicting context: soul.el loaded a 5-day-old crisis node while chat.el
// did not include it on subsequent turns. Both now use 14 days.
// Results capped at 3 nodes, 200 chars each, to limit context inflation.
// TODO(recency): engram_search_json sorts by relevance, not timestamp. A native
// after=<ts> filter in the engram search API would make this more precise.
let affective_raw: String = engram_search_json("distress crisis upset hopeless", 3)
let affective_raw: String = engram_search_json("distress crisis upset hopeless bell BellEvent", 3)
let affective_ok: Bool = !str_eq(affective_raw, "") && !str_eq(affective_raw, "[]")
if affective_ok {
let ts_now: Int = time_now()
let ts_cutoff: Int = ts_now - 604800
let ts_cutoff: Int = ts_now - 1209600
let aff_total: Int = json_array_len(affective_raw)
let aff_ctx: String = ""
let ai: Int = 0
while ai < aff_total {
let aff_node: String = json_array_get(affective_raw, ai)
let aff_content: String = json_get(aff_node, "content")
let aff_ts_str: String = json_get(aff_node, "ts")
let aff_ts: Int = if str_eq(aff_ts_str, "") { ts_now } else { str_to_int(aff_ts_str) }
// Use created_at (the standard engram node timestamp field), consistent
// with handle_chat which reads created_at / updated_at. The previous
// field name "ts" is not a standard engram field: it was present in some
// BellEvent content payloads but absent from standard engram node JSON,
// causing json_get to return "" and the fallback to ts_now meaning ALL
// nodes with a missing "ts" field appeared recent, over-including stale
// content. With the 14-day window, this amplification was significant.
// Fix: read created_at first, fall back to updated_at, then default to 0
// (same as handle_chat). A ts of 0 always fails the cutoff check, so nodes
// missing both timestamp fields are conservatively excluded rather than
// blindly included.
let aff_ca: String = json_get(aff_node, "created_at")
let aff_ts_str: String = if str_eq(aff_ca, "") { json_get(aff_node, "updated_at") } else { aff_ca }
let aff_ts: Int = if str_eq(aff_ts_str, "") { 0 } else { str_to_int(aff_ts_str) }
let is_recent: Bool = aff_ts >= ts_cutoff
let snip: String = if str_len(aff_content) > 200 { str_slice(aff_content, 0, 200) } else { aff_content }
let aff_ctx = if is_recent && !str_eq(snip, "") {