diff --git a/neuron-api.el b/neuron-api.el index 6f7c625..6ad2627 100644 --- a/neuron-api.el +++ b/neuron-api.el @@ -430,7 +430,130 @@ fn handle_api_node_update(body: String) -> String { 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 { // 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". @@ -450,8 +573,7 @@ fn handle_api_recall(method: String, path: String, body: String) -> String { if str_eq(eff_q, "") { return api_or_empty(engram_scan_nodes_json(limit, 0)) } - let results: String = engram_search_json(eff_q, limit) - return api_or_empty(results) + return api_retrieve(eff_q, path, body, limit) } // ── 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 { 10 } else { limit } if str_eq(q, "") { return api_err("query is required") } - let results: String = engram_search_json(q, limit) - if str_eq(results, "") { return "[]" } - let first: String = str_slice(results, 0, 1) - if !str_eq(first, "[") && !str_eq(first, "{") { - return api_or_empty(engram_activate_json(q, 2)) - } - return results + // Same retrieval path as recall — and it is the SAME change, not a copy of + // one. The "activate fallback" this replaced was unreachable dead code: it + // only fired when engram_search_json's return did not start with '[' or '{', + // and engram_search_json always emits a '['-prefixed array (el_runtime.c + // jb_putc('[') before any hit test), so the guard was false on every call + // including the zero-hit "[]" case. Knowledge search therefore had exactly + // 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.