feat(engine): recall now spreads activation through the graph instead of matching substrings #135

Closed
tim.lingo wants to merge 1 commits from feat/recall-through-activation into main
+134 -10
View File
@@ -430,7 +430,130 @@ fn handle_api_node_update(body: String) -> String {
return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}" return "{\"id\":\"" + new_id + "\",\"supersedes\":\"" + id + "\",\"ok\":true}"
} }
// handle_api_recall search or activate memory by query. // Recall through spreading activation
//
// api_activation_depth traversal depth for a retrieval query. Honours ?depth= /
// body "depth" for callers that want a wider or tighter associative horizon;
// defaults to 2, matching every other production activation caller (the
// knowledge-search path here, chat.el's per-turn activation) one hop reaches a
// node's direct associations, two reaches its siblings through a shared hub,
// which is exactly the sibling-recovery case recall was failing.
fn api_activation_depth(path: String, body: String) -> Int {
let d: Int = api_query_int(path, "depth", 0)
let d = if d == 0 { json_get_int(body, "depth") } else { d }
if d <= 0 { return 2 }
return d
}
// api_merge_activated_nodes project an activation result array down to a bare
// node array in activation order, then backfill from the lexical seed list until
// `limit` nodes are collected. Deduped by node id.
//
// SHAPE CONTRACT: the return value is a BARE array of full engram node objects
// byte-for-byte the same node JSON engram_search_json emits, so every existing
// /recall consumer keeps working unchanged (the MCP wrapper's recall/
// searchKnowledge, tools/telegram-gateway.sh which reads `.value.content`,
// cli/neuron_mcp.py). Activation strength is a RANKING input here, not a payload
// change; the scalars stay available on /api/activate and in compileCtx.
fn api_merge_activated_nodes(act_raw: String, lex_raw: String, limit: Int) -> String {
let seen: String = ""
let out: String = ""
let n: Int = 0
// Pass 1 activation-ranked. engram_activate_json already sorts promoted
// (working-memory) nodes first by wm_weight desc, then background-only nodes
// by background_activation desc, so element order IS the activation ranking.
let an: Int = if api_nonempty(act_raw) { json_array_len(act_raw) } else { 0 }
let i: Int = 0
while i < an && n < limit {
let entry: String = json_array_get(act_raw, i)
let anode: String = json_get_raw(entry, "node")
let aid: String = json_get(anode, "id")
let adup: Bool = str_eq(aid, "") || str_contains(seen, "<" + aid + ">")
let asep: String = if n == 0 { "" } else { "," }
let out = if adup { out } else { out + asep + anode }
let seen = if adup { seen } else { seen + "<" + aid + ">" }
let n = if adup { n } else { n + 1 }
let i = i + 1
}
// Pass 2 lexical seed backfill (see the exact-lookup note on
// handle_api_recall). Only runs when activation left room under `limit`.
let ln: Int = if api_nonempty(lex_raw) { json_array_len(lex_raw) } else { 0 }
let j: Int = 0
while j < ln && n < limit {
let lnode: String = json_array_get(lex_raw, j)
let lid: String = json_get(lnode, "id")
let ldup: Bool = str_eq(lid, "") || str_contains(seen, "<" + lid + ">")
let lsep: String = if n == 0 { "" } else { "," }
let out = if ldup { out } else { out + lsep + lnode }
let seen = if ldup { seen } else { seen + "<" + lid + ">" }
let n = if ldup { n } else { n + 1 }
let j = j + 1
}
return "[" + out + "]"
}
// api_retrieve THE retrieval path. Spreading activation over the weighted
// directed graph, lexical seeds backfilling the tail.
//
// WAS (until 2026-08-07): `engram_search_json(q, limit)` alone a case-
// insensitive substring matcher scored by how many distinct query tokens appear
// in a node's content/label/tags, tie-broken by raw salience. It never read a
// single edge. Recall could not see an association: querying an identity value
// returned unrelated documents that happened to contain the word, and NOT the
// twelve sibling value nodes one hop off the same hub.
//
// NOW: recall runs the spreading-activation traversal that has been compiled
// into the runtime the whole time (engram_activate / engram_activate_json,
// el_runtime.c) and ranks by the resulting activation strength. This restores
// the designed retrieval mechanism Engram provisional 64/064,260, claim 1:
// "no data is retrieved from the weighted directed graph except through the
// spreading activation traversal", with activation strength computed as the
// PRODUCT of parent strength, edge weight, target salience, and query/target
// cosine similarity, because "the multiplication of all four factors enforces a
// conjunctive property... addition would allow many weak associations to
// accumulate into false relevance."
//
// SEEDING derived from the runtime, not assumed. engram_activate takes the
// query TEXT (not seed ids) and seeds internally in two passes: (1) lexical
// every node matching at least one query token seeds, with initial activation
// = salience x temporal_decay x dampening x token_coverage, so a node covering
// the whole phrase ignites harder than one covering a single word; (2) semantic
// supplement the top-K unreached nodes by cosine against the query embedding.
// All four other production call sites (neuron-api.el begin_session/compileCtx,
// chat.el:352/1715, awareness.el's curiosity scans) pass query text the same
// way, so this follows the established convention exactly. The consequence for
// recall is direct: the lexical surface recall used to RETURN is now the SEED
// SET of the traversal, and what comes back is what those seeds activate. That
// is why multi-word queries stop returning nothing every token that matches
// anything ignites, and the traversal ranks the resulting field.
//
// EXACT-LOOKUP GUARANTEE (no regression): engram_activate's result collector
// drops any reached node whose background_activation x confidence < 0.1 unless
// it was promoted to working memory, and it never seeds from InternalStateEvent
// nodes. So a rare exact token on a dormant, low-salience node can seed the
// traversal and still go unreported. Retrieval therefore appends the lexical
// seed list after the activated ranking, deduped by id, until `limit` is filled.
// This is a seeded hybrid, not a parallel search bolted alongside activation:
// the backfill is the SAME seed set the traversal itself computed, restored to
// the tail of the result rather than recomputed by a different mechanism.
// Activation always leads the ranking; nothing that used to be findable becomes
// unfindable.
//
// COST/EFFECT NOTE: activation is a stateful read by design claim 29, "update
// the last-activation timestamp and increment the activation count... in
// response to any access to that node record during spreading activation
// traversal". Promoted nodes get reinforced, working-memory weights are
// rewritten, and the query folds into the context centroid. That is the
// intended semantics of retrieval-as-activation and is already what every chat
// turn does; it does mean recall now participates in shaping working memory.
fn api_retrieve(q: String, path: String, body: String, limit: Int) -> String {
let depth: Int = api_activation_depth(path, body)
let act_raw: String = engram_activate_json(q, depth)
let lex_raw: String = engram_search_json(q, limit)
return api_or_empty(api_merge_activated_nodes(act_raw, lex_raw, limit))
}
// handle_api_recall retrieve memory by query, through spreading activation.
fn handle_api_recall(method: String, path: String, body: String) -> String { fn handle_api_recall(method: String, path: String, body: String) -> String {
// Accept the query from the URL ?query= / ?q= params, or, when those are // Accept the query from the URL ?query= / ?q= params, or, when those are
// empty (e.g. a POST with a JSON body), from the body fields "query"/"q". // empty (e.g. a POST with a JSON body), from the body fields "query"/"q".
@@ -450,8 +573,7 @@ fn handle_api_recall(method: String, path: String, body: String) -> String {
if str_eq(eff_q, "") { if str_eq(eff_q, "") {
return api_or_empty(engram_scan_nodes_json(limit, 0)) return api_or_empty(engram_scan_nodes_json(limit, 0))
} }
let results: String = engram_search_json(eff_q, limit) return api_retrieve(eff_q, path, body, limit)
return api_or_empty(results)
} }
// Knowledge // Knowledge
@@ -470,13 +592,15 @@ fn handle_api_search_knowledge(method: String, path: String, body: String) -> St
let limit = if limit == 0 { json_get_int(body, "limit") } else { limit } let limit = if limit == 0 { json_get_int(body, "limit") } else { limit }
let limit = if limit == 0 { 10 } else { limit } let limit = if limit == 0 { 10 } else { limit }
if str_eq(q, "") { return api_err("query is required") } if str_eq(q, "") { return api_err("query is required") }
let results: String = engram_search_json(q, limit) // Same retrieval path as recall and it is the SAME change, not a copy of
if str_eq(results, "") { return "[]" } // one. The "activate fallback" this replaced was unreachable dead code: it
let first: String = str_slice(results, 0, 1) // only fired when engram_search_json's return did not start with '[' or '{',
if !str_eq(first, "[") && !str_eq(first, "{") { // and engram_search_json always emits a '['-prefixed array (el_runtime.c
return api_or_empty(engram_activate_json(q, 2)) // jb_putc('[') before any hit test), so the guard was false on every call
} // including the zero-hit "[]" case. Knowledge search therefore had exactly
return results // the substring-matcher behavior recall had, with a comment claiming
// otherwise. Routing it through api_retrieve makes the claim true.
return api_retrieve(q, path, body, limit)
} }
// handle_api_browse_knowledge list Knowledge nodes. // handle_api_browse_knowledge list Knowledge nodes.