Compare commits

...

6 Commits

Author SHA1 Message Date
Tim Lingo bca7d8ac99 feat(recall): retrieve through spreading activation, not substring matching
Neuron Soul CI / build (pull_request) Blocked by required conditions
Neuron Soul CI / deploy (pull_request) Blocked by required conditions
recall and searchKnowledge both ended at engram_search_json — 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. Meanwhile engram_activate / engram_activate_json — real BFS
spreading activation over the weighted directed graph, four-factor
multiplicative scoring, two-layer background/working-memory filter — has been
implemented and compiled into the shipped runtime the whole time, called from
four places, none of them retrieval.

This wires retrieval to the traversal, restoring the designed mechanism:
Engram provisional 64/064,260 claim 1, "no data is retrieved from the weighted
directed graph except through the spreading activation traversal."

Seeding follows the runtime's own convention (all four existing call sites pass
query TEXT, not seed ids): engram_activate seeds lexically — every node
matching >=1 query token, initial activation = salience x temporal_decay x
dampening x token_coverage — then supplements with the top-K nodes by cosine
against the query embedding. So the lexical surface recall used to RETURN is
now the SEED SET of the traversal, and what comes back is what those seeds
activate.

Exact lookup is not regressed. engram_activate's collector drops any reached
node whose background_activation x confidence < 0.1 unless it was promoted to
working memory, so a rare token on a dormant node can seed and still go
unreported. Retrieval therefore appends the lexical seed list after the
activated ranking, deduped by id, until `limit` is filled — the same seed set
the traversal already computed, restored to the tail, not a parallel search.

searchKnowledge gets the identical path. Its existing "activate fallback" was
unreachable dead code: it fired only when engram_search_json's return did not
start with '[' or '{', and that function always emits a '['-prefixed array.

Response shape is unchanged — a bare array of full engram node objects, so the
MCP wrapper, tools/telegram-gateway.sh (.value.content) and cli/neuron_mcp.py
keep working. Activation strength is a ranking input here, not a payload change.

Measured, cold-start, two builds of this tree against the same 79,250-node /
14,214-edge graph (main @ 18714e6 vs this branch):

  "volatility-based decomposition"  before: 1 of 10 results relevant
                                    after:  6 of 10, incl. architecture/styles/
                                    vbd/glossary.md and project-design
                                    foundations
  "Structure is not inherited"      before: persona boilerplate, "1", a
                                    Disneyland fragment, a corrupted node
                                    after:  self/voice registers, neuron/
                                    user-imprint/boundary-definition,
                                    diagrams/vbd.md
  "inherited"                       Value - Structure Is Not Inherited:
                                    rank 23 -> rank 3; Self - Values hub:
                                    rank 32 -> rank 4
  searchKnowledge, same query       2 of 5 relevant -> 5 of 5
  "HNSW" / "Fayetteville" (rare)    1 result both builds - no regression
  nonsense control                  0 results both builds

Known limit, unchanged by this commit: the 12 sibling Value nodes still do not
surface. A ~58-day-dormant seed's activation (0.7 salience x 0.05 decay floor x
0.34 dampening ~= 0.012) lands below the runtime's 0.02 firing threshold, so it
cannot propagate to its neighbours at all. That is runtime tuning inside the
vendored el_runtime.c, not the wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 12:27:51 -05:00
tim.lingo 18714e6142 Merge pull request 'fix(engine): restore multi-turn crisis escalation on the agentic path (P0, closes #129)' (#130) from fix/129-history-amplification into main
Neuron Soul CI / build (push) Failing after 14m37s
Neuron Soul CI / deploy (push) Has been skipped
2026-08-07 15:54:41 +00:00
tim.lingo 4936099c39 Merge pull request 'fix(engine): the daemon survives a client leaving, and says it is working while it works' (#127) from fix/liveness-engine-91 into main
Neuron Soul CI / build (push) Has been cancelled
Neuron Soul CI / deploy (push) Has been cancelled
2026-08-07 15:54:15 +00:00
tim.lingo f1471763f5 Merge pull request 'fix(engine): approving a researched mission completes — the resume replay read a tool id out of the conversation (BUG-42, both faces)' (#115) from fix/resume-server-tool-replay into main
Neuron Soul CI / build (push) Has been cancelled
Neuron Soul CI / deploy (push) Has been cancelled
2026-08-07 15:53:51 +00:00
tim.lingo 5850793b67 Merge pull request 'fix(engine): history keeps its provenance and its session — kills the false confession, the blank stare, and the "to.Good" seams' (#114) from fix/soul-history-provenance-20260805 into main
Neuron Soul CI / build (push) Has been cancelled
Neuron Soul CI / deploy (push) Has been cancelled
2026-08-07 15:53:32 +00:00
tim.lingo fc1745c652 Merge pull request 'feat(engine): plain chat generates at L3 — inside the safety cycle, not around it (+ crisis-path segfault fix)' (#109) from feat/soul-plain-chat-generation-20260805 into main
Neuron Soul CI / build (push) Failing after 10m39s
Neuron Soul CI / deploy (push) Failing after 14m47s
2026-08-07 15:53:08 +00:00
+134 -10
View File
@@ -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.