be0f9d1afe
The design rejects what this system did: promotion must link candidates 'using
typed semantic edges rather than appending as unlinked content' (CCR claim 29).
Unlinked append was the only behaviour we had. Measured 2026-08-09: 14,214 edges
over 80,936 nodes, 5% of nodes connected to anything, and no edge created by any
write since 2026-07-19 across 27,000+ new nodes. A memory with no connections is
unreachable by spreading activation, so retrieval degrades to literal matching.
On write, a memory is now linked to its top related existing memories.
Bounds, each bought with a specific failure:
- max 3 edges per memory (link_memories.py's cap: precision over spray)
- never link to identity. The existing policy is explicit that 'memories must
not pollute the self traversal by similarity; only an explicit citation may
touch identity'.
- never link telemetry (state-event, soul-response, boot_count, loop-outcome,
search-result): ~97% of daily write volume. Linking it would add thousands of
noise edges a day and re-flatten the graph in the name of connecting it.
- fail-soft: a failed association never fails the write
- associate only AFTER durability, so no edge points at a node that did not
persist — that is the dangling-edge defect the 08-09 cleanup removed 830 of
Two defects found by measuring rather than reading, both fixed here:
1. Hooking mem_store alone produced ZERO edges across four real writes. The HTTP
memory route writes via wt_node directly; mem_store serves only the awareness
telemetry paths we refuse to link.
2. A lowercase-only identity check let a memory link to 'Self — Values
(grounded)' — the exact pollution the policy forbids. My verification shared
the blind spot and printed PASS. Now uses str_lower.
Measured, lab, same corpus: ~1-2 edges per memory; identity edges unchanged at
475; zero identity leaks post-fix including an adversarial batch of six memories
written about values. Edges route through wt_edge so they reach the owner.
Rung: E2E-VERIFIED in an isolated lab. Not deployed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
376 lines
18 KiB
EmacsLisp
376 lines
18 KiB
EmacsLisp
import "persist.el"
|
|
|
|
fn tier_working() -> String { return "Working" }
|
|
fn tier_episodic() -> String { return "Episodic" }
|
|
fn tier_canonical() -> String { return "Canonical" }
|
|
|
|
// ── Association on write ──────────────────────────────────────────────────────
|
|
// DESIGN: "promotion integrates candidate nodes by linking them to existing nodes
|
|
// using typed semantic edges RATHER THAN APPENDING AS UNLINKED CONTENT" (CCR
|
|
// claim 29). Unlinked append is the explicitly rejected behaviour — and it is the
|
|
// only behaviour this system had. Measured 2026-08-09 on Tim's graph: 14,214 edges
|
|
// across 80,936 nodes, 5% of nodes connected to anything, and NO edge created by
|
|
// any write since 2026-07-19 while 27,000+ nodes were added. A memory that forms
|
|
// no connections cannot be reached by spreading activation, so retrieval silently
|
|
// degrades to literal matching.
|
|
//
|
|
// BOUNDS, each one bought with a specific failure:
|
|
// * max 3 edges per memory — link_memories.py's cap, precision over spray
|
|
// * never link to identity (self/*, Value): the existing policy is explicit that
|
|
// "memories must not pollute the self traversal by similarity; only an explicit
|
|
// citation may touch identity". Similarity is not citation.
|
|
// * never link telemetry (state-event, soul-response, boot_count, loop-outcome):
|
|
// these are ~97% of daily write volume (1,020 vs 31 real memories on 08-08).
|
|
// Linking them would add ~3,000 noise edges a day and re-flatten the graph in
|
|
// the name of connecting it.
|
|
// * fail-soft: a failed association never fails the write.
|
|
// Edges go through wt_edge so they reach the owner and survive restart.
|
|
fn mem_assoc_skip_label(label: String) -> Bool {
|
|
if str_contains(label, "state-event") { return true }
|
|
if str_contains(label, "soul-response") { return true }
|
|
if str_contains(label, "soul-outbox") { return true }
|
|
if str_contains(label, "boot_count") { return true }
|
|
if str_contains(label, "loop-outcome") { return true }
|
|
if str_contains(label, "search-result") { return true }
|
|
return false
|
|
}
|
|
|
|
// A candidate is linkable only if it is a real, distinct, non-identity node.
|
|
fn mem_assoc_ok(cand_id: String, cand_label: String, self_id: String) -> Bool {
|
|
if str_eq(cand_id, "") { return false }
|
|
if str_eq(cand_id, self_id) { return false }
|
|
// CASE MATTERS — measured 2026-08-09. A lowercase-only check let a memory link
|
|
// to "Self — Values (grounded)", i.e. it polluted the self traversal, which is
|
|
// the one thing this policy exists to prevent. My verification had the same
|
|
// blind spot and printed PASS. Check every casing the graph actually uses, and
|
|
// exclude identity node TYPES as well as labels.
|
|
let lab: String = str_lower(cand_label)
|
|
if str_starts_with(lab, "self") { return false }
|
|
if str_starts_with(lab, "value") { return false }
|
|
if str_contains(lab, "values") { return false }
|
|
if str_contains(lab, "identity") { return false }
|
|
if mem_assoc_skip_label(cand_label) { return false }
|
|
return true
|
|
}
|
|
|
|
// One slot of the association. Manual unroll rather than a loop: EL's codegen
|
|
// mis-emits accumulating while-loops (documented at soul.el:212, which unrolled
|
|
// three affective slots for the same reason).
|
|
fn mem_assoc_slot(results: String, idx: Int, new_id: String) -> Void {
|
|
if idx >= json_array_len(results) { return }
|
|
let cand: String = json_array_get(results, idx)
|
|
let cid: String = json_get(cand, "id")
|
|
let clabel: String = json_get(cand, "label")
|
|
let ctype: String = json_get(cand, "node_type")
|
|
if str_eq(ctype, "Value") { return }
|
|
if str_eq(ctype, "DharmaSelf") { return }
|
|
if str_eq(ctype, "Safety") { return }
|
|
if mem_assoc_ok(cid, clabel, new_id) {
|
|
wt_edge(new_id, cid, el_from_float(0.5), "related")
|
|
}
|
|
}
|
|
|
|
// mem_associate — connect a freshly written memory to what it is about.
|
|
fn mem_associate(new_id: String, content: String, label: String) -> Void {
|
|
if str_eq(new_id, "") { return }
|
|
if mem_assoc_skip_label(label) { return }
|
|
// Ask the graph what this memory resembles. Now that the store carries
|
|
// meaning-vectors this is semantic, not merely lexical.
|
|
let probe: String = str_slice(content, 0, 400)
|
|
let results: String = engram_recall_json(probe, 4)
|
|
if str_eq(results, "") { return }
|
|
mem_assoc_slot(results, 0, new_id)
|
|
mem_assoc_slot(results, 1, new_id)
|
|
mem_assoc_slot(results, 2, new_id)
|
|
}
|
|
|
|
fn mem_store(content: String, label: String, tags: String) -> String {
|
|
let id: String = wt_node(
|
|
content,
|
|
"Memory",
|
|
label,
|
|
el_from_float(0.5),
|
|
el_from_float(0.5),
|
|
el_from_float(0.8),
|
|
"Working",
|
|
tags
|
|
)
|
|
if str_eq(id, "") {
|
|
println("[memory] write rejected by engram (empty id): label=" + label)
|
|
return ""
|
|
}
|
|
// wt_node has already read the node back locally and returns "" if it did
|
|
// not land, so the old duplicate read-back here is gone.
|
|
//
|
|
// HONESTY (neuron#117): the receipt now says WHERE the write is.
|
|
// The old unconditional "write verified" line asserted against the soul's
|
|
// own RAM — true in memory, false on disk — and printed ~115,000 times on
|
|
// Tim's machine while the canonical snapshot sat frozen for three days.
|
|
// wt_commit flushes the spool and then asks the OWNER. When it says false
|
|
// the node is real and recallable but not yet durable, and the log says so
|
|
// rather than claiming a save that did not happen. The id is still returned:
|
|
// the local write DID succeed, and the queued delta will be retried.
|
|
let durable: Bool = wt_commit(id)
|
|
// Associate AFTER the node is durable: an edge to a node that did not persist
|
|
// is a dangling edge, which is the defect the 2026-08-09 cleanup removed 830 of.
|
|
mem_associate(id, content, label)
|
|
if durable {
|
|
println("[memory] write persisted at owner: " + id + " label=" + label)
|
|
} else {
|
|
println("[memory] write IN MEMORY ONLY (queued for owner, not yet durable): " + id + " label=" + label)
|
|
}
|
|
return id
|
|
}
|
|
|
|
fn mem_remember(content: String, tags: String) -> String {
|
|
return mem_store(content, "soul-memory", tags)
|
|
}
|
|
|
|
fn mem_recall(query: String, depth: Int) -> String {
|
|
return engram_activate_json(query, depth)
|
|
}
|
|
|
|
fn mem_search(query: String, limit: Int) -> String {
|
|
return engram_search_json(query, limit)
|
|
}
|
|
|
|
fn mem_strengthen(node_id: String) -> Void {
|
|
engram_strengthen(node_id)
|
|
}
|
|
|
|
// mem_tombstone — immutable "delete": KEEP the node and all its edges; record a
|
|
// Tombstone marker (content = target id, label "tombstone:<id>", wired with a
|
|
// "tombstones" edge). Never engram_forget. Default bounded list reads hide
|
|
// tombstoned nodes; ?include_deleted=1 recovers them. This is the ONE canonical
|
|
// tombstone helper — every forget path routes through it. Defined here in
|
|
// memory.el (imported first) so awareness.el and neuron-api.el can both call it.
|
|
fn mem_tombstone(node_id: String) -> String {
|
|
let tags: String = "[\"Tombstone\",\"status:deleted\"]"
|
|
let marker: String = wt_node(
|
|
node_id, "Tombstone", "tombstone:" + node_id,
|
|
el_from_float(0.01), el_from_float(0.01), el_from_float(1.0),
|
|
"Episodic", tags)
|
|
if !str_eq(marker, "") {
|
|
wt_edge(marker, node_id, el_from_float(1.0), "tombstones")
|
|
}
|
|
return marker
|
|
}
|
|
|
|
// mem_forget — NOTE: no longer a hard delete. Engram nodes are immutable, so
|
|
// this now TOMBSTONES (via mem_tombstone): the node and its edges are kept and
|
|
// stay recoverable. Every caller (the /memory/forget route and the cultivate
|
|
// forget op) is non-destructive as a result. Internal GC that genuinely needs
|
|
// removal (session-summary replace, telemetry pruning) calls engram_forget
|
|
// directly and is unaffected by this.
|
|
fn mem_forget(node_id: String) -> Void {
|
|
let _marker: String = mem_tombstone(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 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 scan so they resist temporal decay.
|
|
// Canonical nodes encode foundational identity — they must not silently floor at 10.
|
|
let scan_result: String = engram_scan_nodes_json(50, 0)
|
|
let scan_len: Int = json_array_len(scan_result)
|
|
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")
|
|
if str_eq(s_tier, "Canonical") && !str_eq(s_id, "") {
|
|
engram_strengthen(s_id)
|
|
let strengthened = strengthened + 1
|
|
}
|
|
let si = si + 1
|
|
}
|
|
|
|
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)
|
|
+ ",\"strengthened\":" + int_to_str(strengthened) + "}"
|
|
}
|
|
|
|
fn mem_save(path: String) -> Void {
|
|
// engram_save returns an Int (1 = ok, 0 = failure), NOT a String. Calling
|
|
// str_eq on it casts EL_CSTR(1) -> (char*)0x1 and SIGSEGVs on a SUCCESSFUL
|
|
// save — which is exactly what a fresh-install genesis boot does first
|
|
// (seeds the brain, saves, crashes). This is issue #150. Check the Int.
|
|
let saved: Int = engram_save(path)
|
|
if saved == 0 {
|
|
println("[memory] mem_save: engram_save failed for " + path + " — snapshot may be incomplete")
|
|
}
|
|
}
|
|
|
|
fn mem_load(path: String) -> Void {
|
|
engram_load(path)
|
|
}
|
|
|
|
// mem_boot_count_get — retrieve current boot count from engram.
|
|
// Searches for the "soul:boot_count" node and returns its numeric value.
|
|
// Returns 0 if not found.
|
|
fn mem_boot_count_get() -> Int {
|
|
let results: String = engram_search_json("soul:boot_count", 3)
|
|
if str_eq(results, "") { return 0 }
|
|
if str_eq(results, "[]") { return 0 }
|
|
let node: String = json_array_get(results, 0)
|
|
let content: String = json_get(node, "content")
|
|
let prefix: String = "soul:boot_count:"
|
|
if !str_starts_with(content, prefix) { return 0 }
|
|
let num_str: String = str_slice(content, str_len(prefix), str_len(content))
|
|
return str_to_int(num_str)
|
|
}
|
|
|
|
// mem_boot_count_inc — increment boot counter, store a single canonical node, return new count.
|
|
// Prunes ALL existing soul:boot_count nodes before inserting the new one so there is
|
|
// always at most ONE such node in the graph. Without pruning, engram_node_full inserts
|
|
// a new node every boot (no upsert) and the old ones accumulate. The search-first
|
|
// approach also fixes a latent ordering bug: engram_search_json returns oldest-first,
|
|
// so mem_boot_count_get() with limit=3 would read a stale (lower) count once more
|
|
// than 3 copies accumulate.
|
|
fn mem_boot_count_inc() -> Int {
|
|
let current: Int = mem_boot_count_get()
|
|
let next: Int = current + 1
|
|
// Prune all existing boot_count nodes — keep exactly one.
|
|
let old_results: String = engram_search_json("soul:boot_count", 50)
|
|
if !str_eq(old_results, "") && !str_eq(old_results, "[]") {
|
|
let old_len: Int = json_array_len(old_results)
|
|
let oi: Int = 0
|
|
while oi < old_len {
|
|
let old_node: String = json_array_get(old_results, oi)
|
|
let old_id: String = json_get(old_node, "id")
|
|
if !str_eq(old_id, "") {
|
|
engram_forget(old_id)
|
|
}
|
|
let oi = oi + 1
|
|
}
|
|
}
|
|
let content: String = "soul:boot_count:" + int_to_str(next)
|
|
let tags: String = "[\"soul-meta\",\"boot-counter\"]"
|
|
// TELEMETRY DEMOTION (2026-07-24 self-review): this counter was written at
|
|
// salience 0.9 / importance 0.9 / tier Canonical — Canonical gets +0.2
|
|
// goal bias and the 0.15 promotion threshold, so three stale copies of a
|
|
// BOOT COUNTER held the top working-memory slots (wm 0.31+) for 23h,
|
|
// crowding out real context. It is plumbing, not memory. Working tier:
|
|
// 0.40 threshold, no tier bias, and the /api/sync route excludes
|
|
// Working-tier nodes — so the counter also stops leaking to the engram
|
|
// server graph, which is where the duplicate copies accumulated (the
|
|
// prune below only reaches the soul's local graph). Persistence across
|
|
// restarts comes from the soul's own snapshot (mem_save), not from sync.
|
|
let boot_node_id: String = engram_node_full(
|
|
content, "Memory", "soul:boot_count",
|
|
el_from_float(0.55), el_from_float(0.2), el_from_float(1.0),
|
|
"Working", tags
|
|
)
|
|
if str_eq(boot_node_id, "") {
|
|
println("[memory] mem_boot_count_inc: write rejected (empty id) — boot counter node lost (count=" + int_to_str(next) + ")")
|
|
return next
|
|
}
|
|
let boot_readback: String = engram_get_node_json(boot_node_id)
|
|
if str_eq(boot_readback, "") || str_eq(boot_readback, "{}") {
|
|
println("[memory] mem_boot_count_inc: WRITE VERIFY FAILED id=" + boot_node_id + " count=" + int_to_str(next))
|
|
}
|
|
// HTTP WRITE-BACK (2026-07-24 self-review): in HTTP-engram mode the server
|
|
// owns persistence and the soul's in-process graph dies with the process —
|
|
// the local create above is invisible to the next boot. The counter only
|
|
// ever "persisted" via a long-gone push path, which is why the log shows
|
|
// boot #5 on three consecutive boots. Mirror the persona write-back
|
|
// pattern: delete stale server copies (dupes were the 23h WM-pollution
|
|
// bug), then create the demoted replacement server-side. Boot seeding
|
|
// reads /api/nodes, which includes Working-tier nodes, so the count
|
|
// survives restarts; the periodic /api/sync excludes Working tier, so it
|
|
// never re-imports mid-session. Fail-soft: on any HTTP error the counter
|
|
// is in-memory-only this session, same as before.
|
|
let wb_url: String = env("ENGRAM_URL")
|
|
let wb_key: String = env("ENGRAM_API_KEY")
|
|
if !str_eq(wb_url, "") && !str_eq(wb_key, "") {
|
|
let auth_body: String = "{\"_auth\":\"" + json_safe(wb_key) + "\"}"
|
|
let srv_old: String = http_get(wb_url + "/api/search?q=soul:boot_count&limit=20")
|
|
if !str_eq(srv_old, "") && !str_eq(srv_old, "[]") {
|
|
let srv_len: Int = json_array_len(srv_old)
|
|
let si: Int = 0
|
|
while si < srv_len {
|
|
let srv_node: String = json_array_get(srv_old, si)
|
|
// Match by CONTENT prefix, not label: route_create_node sets
|
|
// label = content, so server-side counter nodes carry labels
|
|
// like "soul:boot_count:6". (2026-07-24)
|
|
let srv_content: String = json_get(srv_node, "content")
|
|
if str_starts_with(srv_content, "soul:boot_count:") {
|
|
let srv_id: String = json_get(srv_node, "id")
|
|
if !str_eq(srv_id, "") {
|
|
http_delete_json(wb_url + "/api/nodes/" + srv_id, auth_body)
|
|
}
|
|
}
|
|
let si = si + 1
|
|
}
|
|
}
|
|
let wb_body: String = "{\"content\":\"" + content + "\",\"node_type\":\"Memory\",\"label\":\"soul:boot_count\",\"salience\":0.55,\"importance\":0.2,\"tier\":\"Working\",\"tags\":\"[\\\"soul-meta\\\",\\\"boot-counter\\\"]\",\"_auth\":\"" + json_safe(wb_key) + "\"}"
|
|
let wb_resp: String = http_post_json(wb_url + "/api/nodes", wb_body)
|
|
if str_contains(wb_resp, "\"error\"") {
|
|
println("[memory] mem_boot_count_inc: HTTP write-back failed (count in-memory only): " + wb_resp)
|
|
}
|
|
}
|
|
return next
|
|
}
|
|
|
|
// mem_emit_state_event — log an internal state event as structured memory.
|
|
// Schema: {trigger, kind, content, boot, ts}
|
|
// This creates an auditable evidence trail of cognitive decisions.
|
|
fn mem_emit_state_event(trigger: String, kind: String, content: String) -> String {
|
|
let boot: Int = mem_boot_count_get()
|
|
let ts: Int = time_now()
|
|
let safe_trigger: String = str_replace(trigger, "\"", "'")
|
|
let safe_content: String = str_replace(content, "\"", "'")
|
|
let payload: String = "{\"trigger\":\"" + safe_trigger + "\""
|
|
+ ",\"kind\":\"" + kind + "\""
|
|
+ ",\"content\":\"" + safe_content + "\""
|
|
+ ",\"boot\":" + int_to_str(boot)
|
|
+ ",\"ts\":" + int_to_str(ts) + "}"
|
|
let tags: String = "[\"internal-state\",\"pre-reasoning\",\"InternalStateEvent\"]"
|
|
let event_id: String = engram_node_full(
|
|
payload, "InternalStateEvent", "state-event:" + kind,
|
|
el_from_float(0.85), el_from_float(0.8), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
if str_eq(event_id, "") {
|
|
println("[memory] mem_emit_state_event: write rejected (empty id): kind=" + kind)
|
|
}
|
|
return event_id
|
|
}
|