The nomic-embed-text space over the corpus is strongly anisotropic (mean
pairwise cosine ~0.55), which compresses cosine-based domain separation almost
to nothing so the design-doc s5 operators (distance/overlap/Wasserstein) cannot
discriminate. Subtracting the global mean of the normalized embeddings restores
isotropy (mean pairwise cosine ~0) and sharpens the operators.
- add GeoMeanCache (engram_geo_mean_build / _maybe_refresh / _vec / _free): a
store-derived centering offset over the embed-eligible set, cached and
refreshed on significant drift; lives in geometry.c, not the store.
- engram_geometry_descriptor gains an optional global_mean: when supplied the
centroid, per-member cosine distance, and co-registration run in centered
space (GM=zeros reproduces the legacy raw path exactly).
- co-registration choice (b): the ANN query stays in raw unit space (index
unchanged) since centering is a rigid translation that ~preserves neighborhood
membership; only the descriptor statistics move to the centered frame.
Covariance/axes/radius are translation-invariant and therefore unchanged.
- test: synthetic ground-truth suite stays green (PERF + ASan/UBSan), plus new
centered/raw/mean-cache assertions.
- add bench_discrimination.c (env-gated, read-only, skips in CI): on a copy of
the real store the two-domain overlap operator drops 1.13 -> 0.008 and
cross-centroid cosine 0.899 -> 0.003 after centering, Euclid distance
unchanged (translation-invariant control).
No change to activation/retrieval behavior; wiring geometry into retrieval is a
separate, behavior-changing cutover.
The stone the operator/drift/occupation work stands on: express a relational
neighborhood as the compact joint geometry Will specified (design §3/§5, node
e94371bd) — semantic side (centroid, principal-axis ellipsoid via dual-PCA,
radius) braided with the relational side (k-core skeleton, hub->periphery
centrality gradient), plus soft membership and a co-registration diagnostic
(corr of hebb strength vs semantic proximity — >0 reifies, <0 flags dreams).
Built ONLY on the two standalone modules — engram_vindex (ANN, the cloud) and
engram_store (embeddings + hebb adjacency, the skeleton). Pure C11 + libm; does
not link or touch el_runtime.c. Strictly READ-ONLY: never mutates nodes, edges,
activation, the index, or any retrieval path. Not yet wired into retrieval —
foundation only.
Self-contained test (test_geometry.c) synthesizes two known embedding clusters
with intra-cluster hebb edges and verifies the descriptor recovers the shape:
centroid on the seeded cluster, hub = relational center, skeleton = the strong
intra-cluster wiring, positive co-registration, sorted axis extents. PERF +
ASan/UBSan passes both green; needs no live data.
int_max_keys() computed an internal B+tree node's key capacity as
IDX_BODY/8 - 1 (2041 at a 16 KB page), dividing by 8 and ignoring that
each key also carries an 8-byte child pointer. The true capacity is
(IDX_BODY-8)/16 = 1020. An internal node was therefore allowed to grow to
~2x what a page holds; once it crossed 1020 keys, btree_insert's write-back
overran its STORE_PAGE_SIZE stack page buffer and smashed the stack canary
(__stack_chk_fail / SIGABRT). A clean/small store never grows an internal
node that large, so it never tripped; the ~8x-bloated live store (a day of
tombstone churn) plus a 44 MB un-checkpointed WAL replayed on open pushed a
node over the boundary during redo -> deterministic crash loop
(btree_insert <- apply_edge_put <- engram_open <- engram_store_boot).
Fixes:
- int_max_keys: use (IDX_BODY-8)/16 so internal nodes split at the real
page capacity.
- btree_insert: reject any page whose on-disk nkeys exceeds physical
capacity (fail loud, never smash the stack) -- overflow is now impossible
regardless of on-disk content.
- read_body: bound the slot (off,len) and record length to the page before
dereferencing; a stale/torn index entry could otherwise make store_get_node
read off the stack (observed EXC_BAD_ACCESS on the bloated store). Fail safe.
Verified on a COPY of the live store: unfixed binary SIGABRTs in btree_insert
on open; fixed binary boots clean, recovers the store, checkpoints the WAL,
and M5 compaction shrinks 458 MB -> 57.7 MB with node/edge counts preserved.
Persistent process-lifetime HNSW index (engram_vindex) over resident node
embeddings, node_id == resident g->nodes[] index. Built lazily on first
activation, grown incrementally as appended nodes get embedded, rebuilt on
emb-dim change or resident-array shrink.
Seed selection queries the ANN for the nearest embedded nodes to the effective
query vector, replacing the O(K*N) exact argmax scan as the DISCOVERY step.
Each ANN candidate is admitted through the identical gate the exact scan uses
(exact cosine >= SEED_MIN via cosq, reached/dup skips, content dedup, same
decay/dampen shaping), so scoring/dynamics are unchanged. The exact argmax
scan is preserved verbatim and tops up any unfilled seed slot, and runs in
full when the index is empty/too-small/unavailable -> pre-M8 behaviour exactly.
cosq (O(N*dim) cosine fill) is intentionally retained: it still feeds the
propagation qgate and the Pass-2 WM term (activation dynamics, out of M8
scope). ANN accelerates SELECTION only.
store_scan_nodes/store_scan_edges deduplicated emitted records by their
64-bit id_hash (FNV-1a-64) rather than the full id string. Two distinct ids
that collide under id_hash emitted only the first; the second was durably on
a live page and findable by store_get_node (which disambiguates by strcmp),
yet silently dropped from the resident boot-load. After any store reopen that
node was unretrievable by id, absent from lexical search, and missing from the
recent list — the reported memory-integrity gap.
Replace the hash-keyed U64Set with a StrSet: bucket by id_hash for O(1) probing
but compare full ids by strcmp, mirroring the primary B+-tree readers. Same
change for edges. Adds test_scan_collision.c (real FNV-1a-64 colliding ids).
Spreading activation rebuilt the entire per-node adjacency index
(engram_adj_rebuild, O(E)) lazily before every BFS whenever any edge/node
was added — so a curiosity-loop query that touched a small frontier still
paid to rebuild the whole edge set. This makes the index incrementally
maintained behind ENGRAM_STORE: single node/edge creates APPEND to the live
adjacency in amortized O(1) instead of marking it dirty, so a query only
pays for the frontier it touches (one initial O(E) build, then O(1)/edge).
Approach (b), not (a): the store's from/to adjacency B-tree was rejected
because with the store on the whole graph is already resident and activation
reads in-RAM edges, whose hebb/weight only sync to the store at checkpoint
cadence — reading StoreEdge copies would use stale weights and break
byte-identical parity. The incremental in-RAM index reads the exact same
g->edges[ei] the scan path does, so activation is identical by construction.
Correctness: edges are only ever appended, so incremental append reproduces
the rebuild's ascending-edge-index ordering exactly (same skip rule for null
endpoints). Any index-invalidating mutation (forget/prune/clear) still frees
the index + sets adj_dirty=1, falling back to a full rebuild. Flag-off is
untouched: the mutation hooks just set adj_dirty=1 as before — proven
byte-identical.
engram_store.c is NOT modified (avoids the M5 compaction collision).
Tests (plain gcc, ASan/UBSan clean): engram/test/{test_m7_traversal.c,
run_m7_traversal.sh}. Parity gate proves flag-on incremental == flag-on
forced-full-rebuild == flag-off scan, byte-identical on a mutating query
sequence (activated set, weights, ordering, hops, WM promotion). Perf on a
13k-node / 43k-edge graph over 120 (add-edge + activate) iterations:
adjacency edge-touches 5,167,260 -> 43,001 (120x fewer), full rebuilds
120 -> 1, adjacency-maintenance wall-time 0.74s -> 0.006s (~121x). Prior
gates green: M1 store (33), M2 (36), M3 parity, M3.5, M4 bufpool (37).
Reclaims space held by dead records (tombstoned prune/forget nodes, superseded
ids, stale re-put/hebb versions, and their orphaned overflow chains). On-disk
format UNCHANGED — pure behavior.
COMPACTION (store_compact): copy-live + atomic-swap.
A. checkpoint/sync to quiesce (WAL reduced to CHECKPOINT{C}); crash here => pre.
B. build <path>.compact with only the live records, re-placed bit-exact into
fresh densely-packed pages + fresh id/adjacency B+-trees, every page stamped
LSN=C, new SB last_checkpoint_lsn=C; fsync. crash here => pre-compaction.
C. rename(<path>.compact -> <path>) — POSIX-atomic commit; crash after => post.
D. reopen in place: swap fd, INVALIDATE every pool frame (M4 remap of relocated
pages), reload SB, re-autopin.
Crash at any instant recovers to pre- OR post-compaction, never a corrupt mix.
Single-threaded => "online" = safe between mutations; takes a checkpoint quiesce
at entry. M4 cooperation: temp build has its own pool honoring ENGRAM_POOL_FRAMES
(evict/re-fault + no-steal + pins); live pool fully invalidated on reopen.
BACKGROUND CHECKPOINTER: ckpt_maybe now fires on ANY armed trigger — ops (default
100000), dirty pool frames, WAL bytes-since-reclaim (default 64 MiB), or a
wall-clock interval (checked on the write path; no extra thread). Same M2
checkpoint semantics (calls engram_checkpoint). Env: ENGRAM_CKPT_OPS/_DIRTY/
_WAL_BYTES/_INTERVAL_MS; runtime setter store_set_checkpoint_policy().
Tests: engram/test/test_compaction.c (+runner). Plain gcc, ASan/UBSan clean.
36 passed, 0 failed (O2 and ASan+UBSan builds).
reclaim: page_count 655 -> 153, file 10731520 -> 2506752 bytes (76.6% reclaimed),
every live record + adjacency bit-exact at new locations.
crash-during-compaction phases 0/1/2: crc clean, live set intact, writable.
background checkpointer: ops / WAL-bytes / dirty triggers each auto-fire; WAL
prefix reclaimed (30 B after 600 puts); recovery correct.
pool cooperation: compact under 24-frame pool correct, no stale frames.
No regression: M1 33, M2 36, M3 parity, M3.5, M4 bufpool 37 — all green.
On-disk format unchanged (additive).
Turn M2's write-back/no-steal cache into a bounded, demand-paged buffer pool so
the paged store can exceed RAM while keeping only hot pages resident. On-disk
format UNCHANGED (additive residency only; no migration). Default budget is large
enough that today's store stays fully resident, so default behaviour == Phase 1.
- Frame table capped at `cap` frames (env ENGRAM_POOL_FRAMES; 0 = unlimited;
default 1<<20). Not-resident access faults in from neuron.egm.
- LRU eviction of CLEAN, unpinned frames only. Dirty frames are never stolen
(M2 no-steal / WAL durability preserved) — turned evictable by a checkpoint's
pc_flush, which then trims the pool back to budget.
- Pinning: superblocks (0,1) + index root/interior pages auto-pinned; explicit
store_pin_page/unpin and store_pin_layer/unpin (hot WM/core layers).
- Bounded sequential read-ahead on scans (env ENGRAM_PREFETCH, default 8).
- Correctness rests on callers copying page bytes into local buffers and never
retaining a frame pointer across another access, so evict+re-fault is safe.
Gates (plain gcc, ASan/UBSan clean):
M4 run_bufpool_tests.sh ...... 37 passed, 0 failed (+ ASan/UBSan: 37/0)
small-pool round-trip (cap=32 vs 1599 pages, 2708 evictions): 5000 nodes +
4000 sampled edges bit-exact, crc clean, pool bounded to cap.
eviction: hot set 0 re-faults, cold evicted, hit-rate 0.989; no-steal burst
(cap=8) holds 309 dirty frames > cap, reads correct from dirty pages.
pinning: superblocks/roots/explicit page/hot-layer(19 pages) stay resident;
unpin makes them evictable.
prefetch: sequential scan 511 demand-faults OFF -> 4 ON.
crash-under-paging (ENGRAM_POOL_FRAMES=16): WAL replay + checkpoint-crash
phases 0-4 all recover bit-exact.
default pool: 0 evictions, whole store resident (== Phase 1).
No regression: M1 33/0, M2 36/0, M3 parity PASS, M3.5 PASS.
Resolve 3 conflicts:
- lang/el-compiler/runtime/el_runtime.c: keep deletion (deprecated runtime fork;
single-source-of-truth is lang/runtime/, enforced by scripts/check-single-runtime.sh).
- lang/releases/v1.0.0-20260501/el_runtime.h: keep deletion (releases/ is a generated
artifact folder, not a source path; a release is a git tag, not a folder).
- lang/runtime/el_platform_win.h: union of dev's Windows port (#80: setsockopt optval
wrapper + curl-less libcurl stubs) and our fsync(->_commit) shim needed by engram_store WAL.
Nothing in dev's build consumes the deprecated fork or releases/ folder.
The live engram now runs the paged store (neuron.egm+WAL), but the SDK
release publishes only el_runtime.{c,h} and the engram build links only
el_runtime.c — so a future official release would silently revert to the
in-memory store. Publish engram_store.{c,h} as SDK release assets and add
them to the engram build's download + cc link so the store transition
cannot regress.
- Wire paged store into the ENGRAM SERVICE (server.el, the authoritative durable
owner): boot->engram_store_boot, persist_canonical->engram_store_checkpoint,
gated by ENGRAM_STORE.
- elc (lang/elc.c + src/parser.el + codegen.el + elc-combined.el): OOB guard in
tok_kind/tok_value + parse_block progress backstop — fixes the pre-existing
unbounded-memory fold hang on sessions.el.
- engram_prune_telemetry mirrors ISE prune to the store (store_forget) so store
live-count tracks resident and stale telemetry stays bounded.
- Deployed live 2026-08-12: engram :8742 on neuron.egm+WAL, count reconciled 11552.
Flag-on checkpoint now full-walks the resident graph: store_put_node (WM weight,
activation_count, last_activated, wm_anchor) + store_put_edge (hebb, last_fired)
for every node/edge, then engram_checkpoint. Uses store_put_edge (idempotent
upsert) not store_hebb_batch, because activation FORMS new hebbian-associate edges
that bypass the create hook and delta-only hebb_batch can't create them. Store-on
boot now applies the same WM-halving + floor + cap transforms as engram_load.
This is the hebb-survives-restart fix. Gate: reboot from neuron.egm with
snapshot.json deleted -> edge hebb + activation_count survive unchanged, WM weight
survives with identical boot transform; negative control proves persist is
load-bearing (hebb->0 without it). M1 33/33 + M2 36/36 + M3 parity PASS, ASan/UBSan
clean, flag-off untouched. Engine unchanged (boundary held).
Caller-side shim in el_runtime.c maps EngramNode/Edge <-> StoreNode/Edge; engine
keeps zero soul deps (libengram boundary, design §10). Flag off = today's JSON
path byte-for-byte (proven: no neuron.egm created, graph identical). Flag on =
engram_open (import snapshot.json once into neuron.egm, else WAL-replay) +
resident load; node/edge create + forget dual-write via guarded hooks. Files
renamed engram.store->neuron.egm, engram.wal->neuron.wal.
Gate: M3 parity PASS (graph on==off byte-exact modulo ordering; snapshot round-trip;
reboot-from-egm with snapshot.json deleted; activation set+sequence identical;
ASan/UBSan clean). M1 33/33 + M2 36/36 green post-rename.
Known gap (pre-flip): in-place hebb/WM/activation_count updates during activation
are not yet persisted to the store (create/connect/forget are). Must close before
live flip so learned edges survive restart.
Write-back no-steal buffer pool makes the fsync'd WAL load-bearing (M1 was
write-through). Logical WAL with record-granularity page-LSN redo idempotency.
Checkpoint = flush dirty pages, fsync store, advance last_checkpoint_lsn,
reclaim WAL prefix. One-time snapshot.json import only when store absent;
JSON never read as the ongoing store thereafter.
Gates: 33/33 M1 (no regression) + 36/36 M2 — replay parity, torn-tail fuzz
(every byte offset), checkpoint-crash at all 5 phases, torn-page+WAL redo,
legacy-import parity, hebb-survives-crash.
Establish lang/runtime/ as the ONE canonical el runtime (from the active
runtime that carries hebb/emb persistence + the new WAL); repoint the el CI
publish, engram build, elb default, and in-repo build scripts to it; delete
the el-compiler/runtime + lang/releases/ forks; add scripts/check-single-runtime.sh
drift guard.
Fixes a live prod bug: the el CI published el-runtime-c/-h from the LAGGING
el-compiler fork (0 hebb refs), so the shipped soul never persisted Hebbian
edge weights — learned co-activation was wiped on every restart. Publishing
from canonical ships the stranded 'learning that cannot outlive the process'
fix.
WAL storage engine + integrity fixes (DELETE->tombstone + store-layer
protection, safe data-dir default) ride in behind ENGRAM_WAL (default off =
byte-identical to today). Verified: engram elb per-module build clean, WAL
gate 66/66, native smoke ok, drift-guard green.
route_load was a stub response over the most destructive operation in the
server: engram_load resets the store before parsing, so a readable-but-
malformed snapshot left a hollow graph and the route answered {"ok":true}.
With 37GB of stale dated snapshots in the data dir as restore targets, that
is a live risk. Now returns the real return value plus node/edge counts and
an explicit hollow flag.
route_save discarded engram_save's return the same way; persist_canonical
returned a hardcoded 1, making 'let saved: Int = persist_canonical()' a dead
variable at six durable write paths.
The loopback/API-key hardening carried in this file since 2026-07-15 called
el_http_request_authorized and el_http_send_401 from http_worker with no
forward declarations, so the calls were implicit and the later static
definitions conflicted. The file did not build. Two prototypes fix it.
Worth naming the pattern: uncommitted work is invisible to every check that
would have caught this. Three weeks of desktop security hardening was neither
committed nor compiling, and nothing reported either fact.
Same defect as the release runtime: \uXXXX was skipped and a literal '?'
emitted, destroying every non-ASCII character in JSON entering the runtime.
Two copies of one parser bug is how this class of fault survives a fix, so
it lands in both.
NOTE: this file also carries pre-existing uncommitted work from 2026-07-15/16
that this commit preserves rather than authors - loopback bind hardening
(EL_HTTP_BIND_HOST) and per-install API-key auth (EL_HTTP_AUTH_KEY) for the
shipped desktop build, plus goal-bias and node-json changes. It had been
sitting in the working tree for three weeks. Committing it because
uncommitted work is work that does not survive, which is the same durability
lesson as yesterday's Hebbian write-back finding. It needs review on its own
terms - see the backlog item for reconciling the two runtime copies.
jp_parse_string_raw handled \uXXXX by skipping the four hex digits and
emitting a literal '?'. JSON writers escape non-ASCII by default (Python's
json.dumps ships ensure_ascii=True; MCP clients do the same), so every em
dash, curly quote, accented letter and emoji arriving over MCP or HTTP was
silently replaced by one question mark on the way in.
Measured on the live store: 3,119 of 4,081 non-telemetry nodes carried the
damage, including the self traversal root and all 13 values nodes. Contents
split cleanly into fully-clean or fully-mangled with zero overlap, which is
the tell that it was one write path rather than gradual rot. No snapshot on
disk predates it, and 3 bytes collapsing to 1 is not invertible, so the
existing damage is permanent; only the forward path could be fixed.
Decode properly instead: 4 hex digits, surrogate-pair reassembly for astral
codepoints, U+FFFD for lone surrogates, UTF-8 encode. Malformed escapes keep
the old '?' so a truncated body still parses.
The deeper failure was that nothing measured this for two months. Every gauge
in the system reports whether the machinery is running; none reported whether
the text it carries is intact. Adds both halves: engram_text_health_json() /
GET /api/text-health for the daily census, and a txt_damaged counter on the
heartbeat for live regression. Verified in both directions - clean UTF-8 does
not trip it, a deliberately damaged node does.
Yesterday's eligibility-trace fix made Hebbian consolidation numerically real:
hebb_max 0.000799 -> 0.4725, and 1,198 hebbian-associate edges formed in 23h48m.
This morning's census found where they went: nowhere.
soul daemon (in-process graph): 42,426 edges, 1,198 hebbian
engram server (:8742, durable): 41,213 edges, 49 hebbian
Two processes, two graphs, one direction of travel. The soul pulls from the
server every 10 min (GET /api/sync) and never pushes. It cannot fall back on
saving its own copy either: soul.el sets soul_snapshot_path only inside
`if is_genesis && safe_to_seed`, and safe_to_seed is unconditionally false
whenever ENGRAM_URL is set -- because the server owns persistence and a soul
writing snapshot.json would clobber it. That guard is correct. The consequence
was not: mem_save() has never once executed. The soul is the ONLY process
running idle cognition, so it is where essentially all co-activation happens --
and it was throwing away every association it learned, every restart, silently.
The mechanism worked and the learning still evaporated.
Consolidation is now a message, not a file. Fast volatile store hands each
newly-formed association to the slow durable store over the API the server
already exposes; only edges past ENGRAM_HEBB_LINK_MIN are ever queued, so what
crosses the process boundary already earned it.
- el_runtime.c: 512-slot overwrite-oldest write-back ring; enqueue at edge
formation; engram_hebb_drain_json() pops a postable JSON batch. Drops and
drains are counted, not silent -- a consolidation path that quietly discards
is the exact failure this entry exists to correct.
- server.el: POST /api/edges/batch. persist_canonical() writes the full 60MB
snapshot per call, and route_create_edge calls it per edge -- correct for one
interactive edge, ruinous for bulk (~840MB/beat to persist 14 associations).
Batch connects all, snapshots once. Same durability, 1/N the writes.
- act-stats: hebb_wb_pending / _drained / _dropped. pending climbing with
drained flat = drain not called; drained climbing with sent 0 = POST refused.
Both failure modes are now visible in the stream instead of in an autopsy.
Verified live: batch route accepts valid entries, skips malformed ones without
aborting the batch, and enforces _auth. All 1,256 learned associations are now
in the canonical store; the soul booted at 42,431 edges with hebb_max 0.4941
carried across the restart for the first time.
Hebbian consolidation was inert. Census over the live graph (41,213 edges,
13,091 nodes, 23h44m uptime): strongest association hebb=0.000799 against a
0.15 consolidation threshold, and zero hebbian-associate edges ever formed.
Since the awareness loop calls engram_connect nowhere, this was the only path
by which the graph could grow its own structure — every edge was authored or
imported, none learned.
The defect was the event, not the rate. hebb is an EWMA whose fixed point is
P(event); raising ETA changes convergence speed, never the plateau. The event
was "both endpoints in WM in the same activate call" — demanded exact
simultaneity from a working memory that inhibition-of-return, breakthrough
rotation and the 24-slot global cap are all engineered to keep turning over
(~142 evictions/60s). The three mechanisms that make WM healthy are the ones
that made this measurement empty.
Replaced with three-factor eligibility traces (Sutton & Barto ch.7; Gerstner
et al. 2018; PLOS Comp Biol 2018 differential Hebbian learning): a node
entering WM sets a trace to 1.0, the trace decays exponentially in wall-clock
time (TC=300s, chosen against the measured ~31s scan cadence), and the
increment becomes ETA·trace(a)·trace(b). Strict generalization — co-resident
pairs read 1.0 on both ends and get exactly ETA, bit-identical to before.
warm×warm is deliberately not paired: eligibility must gate on something
happening now. Homeostatic ENGRAM_HEBB_NODE_BUDGET still bounds per-node mass.
Measured over a 60-call soak: hebb_max 0.0008 -> 0.0060, climbing at ~0.87
ETA/call against an all-time ceiling of 0.0008 before. hebb_mass 0.011 ->
0.019, no runaway. Projected consolidation of a genuinely recurring pair:
~1,730 calls, ~14h at autonomous cadence. links still 0 — that is expected
and is what tomorrow's review must check.
Also: Pass 3½ deduplicates this call's WM candidates, but the persisted WM
population is a union of fresh promotions and carry-over residents, and Pass
3½ never sees the second set. Confirmed live: two byte-identical copies of one
3,193-char document both holding slots (0.289 / 0.271). Added global
redundancy suppression in Pass 5 before the cap count. Post-fix census: 24
residents, 24 distinct contents, 0 wasted slots.
New gauges: hebb_warm (eligible-but-not-co-resident population), dup_wm_global.
Census of the live graph under the uniform 168h half-life with floor 0.05: the
MEDIAN tdecay for every single node type was 0.0500 — the clamp. Memory 81% at
floor, Knowledge 58%, BacklogItem 91%, Project 98%, Tag 100%. A function whose
median output is its floor is not a signal, it is a constant with exceptions,
and the exceptions were whatever had been touched in the last few days.
What that cost: 10 of the 13 grounded value nodes — Precision Over Brute Force,
Honesty Before Comfort, The System Must Accumulate — sat at 0.05, a 20x
activation penalty, while Knowledge ingested overnight sat near 1.0 and held the
working-memory top slots. Since tdecay multiplies at every hop, a 2-hop path
through settled knowledge compounded to 0.0025: those regions were not
disfavoured, they were unreachable. The decay function was erasing the
accumulated library in favour of whatever arrived last night.
External corroboration — arXiv:2604.26970 measures retrieval under decay
regimes: no temporal weighting NDCG@5 0.274, uniform exponential decay 0.015.
Uniform decay is 18x WORSE than no decay, because it penalises stable knowledge
while failing to suppress stale volatile facts. Not even their full adaptive
hierarchy (0.260) beat switching decay off.
Half-life is now scaled by how established a node is:
T_eff = T_HALF * (1 + ln(1 + activation_count))
The spacing effect and the Lindy property in one line — monotone, log-bounded
(a 10,000-activation node earns ~10x, never a permanent exemption), and built
on activation_count, which is measured, unlike tier, whose assignments are too
inconsistent to trust (the values node is tagged Episodic).
Floor 0.05 -> 0.25. Given no-decay outperforms uniform decay, the honest maximum
penalty for age alone is 4x, not 20x. Age should express a preference for the
recent; it must never make a region of the graph structurally unreachable.
Effect: well-established Knowledge median tdecay 0.773 vs rarely-activated
0.417 — the frequency signal now does work where the old function returned its
clamp for both. Values recover 0.05 -> 0.25 (the two frequently-touched ones to
0.79). Verified live: VBD whitepaper, component taxonomy and CGI now activate on
a values query. Per-node temporal_decay_rate override untouched.
Content-hash census of the live graph: 1,858 redundant copies, 44.9% of the
non-ISE store, all from a June id-scheme migration that re-added nodes under
fresh UUIDs instead of matching on content. Generation stopped in June; the
copies did not. Being byte-identical they carry identical embeddings, so they
score identically against any query.
Measured over 50 real query probes against the live 3,998-vector set:
40.2% of semantic seed slots were consumed by redundant copies of content
already in the seed set, 92% of retrievals affected, effective distinct seeds
4.78 of 8. Two fifths of every retrieval was spent re-reading the same page.
Deleting nodes is a separate operation with its own backup discipline. This
change makes the runtime immune to the condition instead: redundancy can never
buy a scarce slot, whatever state the graph is in. Enforced at both scarcity
points — semantic seed selection (a rejected copy does not consume one of the K
slots; the loop retries for the next distinct node) and WM admission via a new
Pass 3+1/2 ahead of the capacity cap, so 24 slots are contested by 24 distinct
meanings rather than by however many copies of one document exist.
Identity is exact content hash first, then cosine >= 0.995 for copies that
differ only in insignificant characters. At 768 dimensions that admits only
near-verbatim text: this suppresses redundancy, never similarity.
Live after restart: ~8.8 redundant seed candidates rejected per activation.
New dup_seeds/dup_wm gauges in act-stats.
WM continuity (the significant one). A node reached by the current query but
scoring under its type threshold was zeroed outright, while a node the query
did NOT reach got the full ACT-R carry-over treatment. Being found was punished
relative to not being found. Measured consequence: WM turned over 100% every
call — three activations of a byte-identical query gave |A∩B| = |B∩C| = 0 — and
wm_evicted stayed 0 the whole time because that path never counted. WM was not
a working set; it was six suppression-breakthrough nodes re-drawn per call.
Both exits from a WM slot now share one extracted retention rule.
Result: WM 6 -> 24 nodes (the designed Cowan capacity), top weight 0.097 ->
0.748 (natural promotion, not the breakthrough floor), and contents that are
actually query-relevant.
Hebbian learning. Edge weights were written once at engram_connect and never
changed; last_fired's only writer in 12.5k lines was an unrelated dharma path.
Every learning mechanism operated on nodes — the wiring between them was
frozen. Adds co-activation potentiation (HeLa-Mem arXiv:2604.16839) in a
separate `hebb` field so authored structure is never mutated, with homeostatic
per-node scaling the source lacks (PNAS 2422602122) to prevent hub saturation.
Measuring it produced the finding that mattered: zero edges existed between
co-active WM members, so reweighting existing edges was a no-op. This graph's
41k edges were all authored by explicit tool calls — nothing had ever formed an
association from experience. So Hebb literally: if the wire is absent, grow it.
Consolidation is gated hard (sustained EWMA past 0.15, <=2/call, 5% ceiling,
in-memory candidates discarded on restart) because it permanently mutates the
graph.
Two bugs caught only by instrumenting rather than assuming: the snap-to-zero
floor sat above the per-step increment, so nothing could ever accumulate; and
the reached-but-sub-threshold eviction above. Verified live end to end — 53
links formed under load, then discarded with the test snapshot.
Also exposes engram_act_stats_json over GET /api/act-stats. It had existed
since 2026-07-27 but was reachable only through the soul daemon, so diagnosing
the activation layer required a working soul. This review needed it and could
not get at it.
The soul's curiosity auto-term extractor takes the first word of a top-WM
node label. It has no term-quality scoring, so three prior self-reviews each
bolted on another hand-curated blocklist (genre words 07-23, quoted titles
07-25, stopwords 07-30). Every one was written reactively, after a flood was
already observed. A list can only contain floods that already happened.
Two were in flight and unfixed when this review ran:
"<!--" label df 220 -> 252 nodes activated
"SELF" label df 175 -> 541 nodes activated (list has "Self" Title-case;
str_eq is case-sensitive, so the uppercase token sailed through)
engram_label_df(term) counts nodes whose label contains term. Low-specificity
tokens are corpus-frequent by definition, so this catches the flood class
prospectively and tracks the corpus as the world-ingestor changes it. This is
Sparck Jones (1972), which introduced IDF under the name 'term specificity';
automatic stopword compilation from it is the textbook application.
NOT a replacement for the stopword list -- verified against all 86 listed
terms, not assumed. Catches 13 (Will:306, Self:175, Over:116, Knowledge:112),
misses 73 (Whose:0, Would:0, Could:0, This:9). Labels are terse titles, so
English function words are genuinely rare in them. The gates cover disjoint
failure modes; both are required.
Policy lives in awareness.el, not here: the runtime measures, the soul decides.
Working memory was thrashing behind a healthy-looking gauge. wm_active sat
at 22-24 while breakthroughs ran 661-903 and evictions 485-717 PER 60s tick
- roughly 825-1125 nodes cycling in 5-call lockstep.
Root cause: the breakthrough path was an anti-starvation mechanism that reset
its own counter on firing, with no budget and no refractory. A node failing
its type threshold 5 times was force-promoted at exactly 0.10 and had its
suppression_count reset to 0, so it immediately restarted the identical
climb. Since BREAKTHROUGH_WEIGHT (0.10) > WM_FLOOR (0.05), every one of them
cleared the admission floor and entered the rank contest tied at 0.10, where
the tie-break degenerated to node-array index order. Cap-evicted nodes are
skipped by retrieval reinforcement, so they never got an access_ts record and
the STI inhibition-of-return damper never applied to them. That closed the
loop: re-suppressed, completely unmarked, forever.
An anti-starvation rule that resets its own counter without a bound is not a
fairness valve, it is an oscillator.
Fixes in engram_activate Pass 2:
- ENGRAM_BREAKTHROUGH_BUDGET (WM_CAP/4 = 6) caps intrusive thoughts per call.
- ENGRAM_BREAKTHROUGH_COOLDOWN (55) via NEGATIVE suppression_count. The field
already serializes as %d and parses through eg_get_int_field, so negatives
round-trip through snapshots with no struct or format change.
- Blocked breakthroughs no longer reset the counter; it saturates so a starved
node surfaces on a later call instead of restarting from zero.
- Graded breakthrough weight by nearness to own threshold, so the rank
tie-break is cognitive rather than insertion order. Invariant preserved:
WM_FLOOR < weight < min(type_threshold).
Also: moved the additive cosine term AFTER the STI multiplier. It was applied
before, so an incumbent re-reached 30s later took t_n/(t_n+120) = 0.2x, which
cut the semantic term's ceiling from 0.20 to 0.04 - below every per-type
threshold. Meaning-match was being punished for having been recently useful.
Inhibition-of-return should rotate the structural score, not the semantic one.
Also: _eg_act_wm_evicted counted 3 of 5 eviction paths. The two carry-over
paths were silent, so the reported rate was an undercount of unknown
magnitude - while being used to diagnose an eviction pathology. All five now
increment.
Also: route_sync returned {"nodes":[],"edges":[]} when the snapshot export
failed. The soul's sync_ok check only tests for "" and "{}", so that
placeholder passed as a healthy sync: last_sync_ok_ts stamped, sync_age_ms
green, sync_empty never fired, added:0 forever. A broken sync was
indistinguishable from a quiet healthy one - the exact class this route was
added to fix. Returns a real error now.
Verified live (boot 20 vs boot 19): breakthroughs 661-903 -> 36/tick,
evictions 485-717 -> 12-46/tick against a counter that now covers more paths,
wm_active unchanged at 22-24, wm_avg_weight 0.138-0.273 -> 0.186-0.446.
Working memory is holding strong nodes instead of breakthrough-floor filler.
- route_create_node passed already-boxed Floats through el_from_float a
second time, reinterpreting boxed bits as raw doubles — every HTTP-created
node silently stored default salience/importance/confidence regardless of
input (verified live: 0.9/0.25/0.6 in -> 0.5/0.5/1.0 stored). Floats now
passed bare, matching the route_emit_ise pattern that always worked.
- Presence-aware defaults via json_get_raw: absent key != explicit value;
confidence now honored from payload instead of hardcoded 1.0.
- GET /api/similarity?a=&b= wires engram_cosine_sim (built 2026-07-24,
zero callers until now) into the introspection API.
- /health reports live node/edge counts instead of a hardcoded literal.
Every node object on consumer read routes (/api/nodes, /api/search,
activation results, neighbors, compiled context) carried the full ~5.7KB
emb vector — responses 10-50x oversized, blowing MCP token limits.
engram_emit_node_json now takes include_emb; only engram_save passes 1,
so persistence and the /api/sync//api/edges replication paths (which
serve engram_save output) keep embeddings intact.
_eg_act_wm_evicted/_eg_act_breakthroughs were reset at the top of every
engram_activate, so act_stats reported only the last call and the 60s
heartbeat missed nearly all events (curiosity runs 2 activates per 30s).
Both are now monotonic process-lifetime totals; consumers diff readings.
Working memory was pinned saturated (24/24, wm_saturated:1 on every
heartbeat) because every cap path only trimmed the population down TO
the cap — rank-based eviction guarantees a full WM whenever >=24 nodes
hold any weight, so sub-cap fill was unreachable and the saturation
flag carried no information.
- ENGRAM_WM_FLOOR 0.05: absolute admission bar (Soar WM forgetting,
Derbinsky & Laird ICCM 2012 — removal by absolute threshold, not
rank) applied in Pass 4, carry-over, Pass 5, and load-cap. Fill can
now drain below 24 during quiet periods.
- Zero wm_anchor at every eviction site: stale anchors on evicted
nodes were a latent resurrection bug.
- Context centroid folds only NEW WM entrants: incumbents re-promoted
every scan no longer re-entrench the centroid each call, breaking
the WM->centroid->e_eff->re-selection positive feedback (fixation
driver behind the wm_top0_streak=1407 incident).
Verified live: wm_active 3->22->23, wm_saturated:0 post-restart.
n->importance was stored, serialized, and clamped at creation but never
read by any activation path — a curated importance=1.0 node competed
identically with a default note. Multiply raw_wm by (0.5 + importance):
default 0.5 nodes are unchanged (x1.0), critical x1.5, low x0.6;
importance<=0 from legacy snapshots stays neutral. Verified activation
and WM promotion unchanged for default-importance candidates.
- Gate each spreading-activation increment by target-node query similarity
(arXiv:2606.30133): soft gate FLOOR+(1-FLOOR)*clip(cos), FLOOR=0.25, for
embedded targets; ungated for unembedded; disabled when embedder is down.
Prior spreading was query-blind — hubs relayed activation into branches
unrelated to the query.
- Stats: add embed_eligible_count so embedding coverage is measured against
the true denominator (ISE/Tag/short nodes can never embed). Today's review
misread 3753/12693 as a 30% coverage gap; eligible coverage is 100%.
- Observability: per-call wm_evicted + breakthroughs counters and embed
circuit-breaker state exposed via engram_act_stats_json() — the three
highest-value previously-invisible executive-filter transitions.
- Carry-over branch: occupancy inhibition m = t_c/(t_c+t_hold), t_c=3600s
(ENGRAM_CARRY_TC). An unreached incumbent held its wm_anchor verbatim
(keep~1.0 for BLL inflated in the pre-07-25 era) — observed 23h at WM
top while every reached node rotated at the 0.10 breakthrough floor.
STI only runs in the reached branch; inhibition must key on occupancy,
not retrieval recency (Morita 2021 / Lebiere & Best 2009).
- engram_strengthen: drop the 07-22 BLL access record — the 07-25 STI
multiplier reads the same ring, so novelty reinforcement self-inhibited
its target for ~2 minutes.
- engram_load reset: free n->emb (~3KB/embedded node leaked per reload).
- engram_wm_top_json: emit id — its absence made the heartbeat's
wm_top0_streak compare ""=="" and measure uptime, not fixation.
Working memory was winner-take-all: suppression_count never entered the
promotion score and was reset on promotion, so two high-salience nodes
pinned a saturated 24-slot WM for hours. Add Lebiere-Best (CogSci 2009)
short-term inhibition — raw_wm *= t_n/(t_n + 120s) from the most recent
recorded access — producing emergent round-robin over WM candidates.
embedded_count stalled at 93/12175 after restart: the lazy backfill only
runs inside engram_activate, which nothing calls on the authoritative
store in production, and in-RAM vectors were never snapshotted. Add
engram_embed_backfill(n) + GET/POST /api/embed-backfill route that
persists the canonical snapshot whenever it embeds anything; the soul
heartbeat pumps it at 32/min.
Semantic activation was spec-only since 2026-06-30 — the seed loop used
istr_contains and nothing else. Per the 07-21 integration brief:
- EngramNode gains a lazily-backfilled nomic-embed-text vector (8/call
inside engram_activate, newest-first; no create-path latency, no bulk
Ollama hammering during sync seeds)
- query embedding (cached) drives a top-K cosine seed supplement
(HippoRAG use-similarity-twice) plus an additive WM term with
shift-and-floor at 0.45 — raw cosine is a constant bias in anisotropic
spaces (unrelated pairs read 0.4-0.7), floor-and-ramp makes it a signal
- 4s embed timeout (http_do_t) + 3-strike circuit breaker: activation
never wedges on a dead embedder; everything degrades to lexical
- embeddings persist as %.4g comma lists in snapshots, parsed by both
loaders; embedded_count in /api/stats tracks coverage
- engram_cosine_sim + http_delete_json exposed (DELETE now carries a
body — the server's _auth scheme requires it)
- route_create_node honored only content/node_type/salience; label,
importance, tier, tags were silently dropped (label defaulted to
content). Now honored via engram_node_full.
Verified live: embedded_count 0->96 across activations, semantic-only
promotion observed (zero token overlap), snapshot round-trip intact.
The desktop soul (neuron/dist) compiles against the v1.0.0-20260501 release
runtime. After #66 landed the engram natives (tokenized/ranked search,
engram_prune_telemetry) and #79 the durable truncation fix into this runtime,
two gaps remained before it could cross-compile the Windows brain:
1. Windows OS boundary: the release runtime had no Win32 path. Ported the same
_WIN32-guarded shim the mainline runtime carries (#69): #ifdef _WIN32 ->
el_platform_win.h (winsock/dlsym/popen + WSAStartup ctor), SOCKET fd guards
and el_closesocket() at every socket site, CreateProcessA for exec_bg, the
tm_zone/mingw guard, an el_setsockopt optval wrapper (GCC14), and curl-less
libcurl stubs. Every change is _WIN32/HAVE_CURL-gated — the POSIX build is
byte-identical (gcc -fsyntax-only clean; native behaviour unchanged).
2. Header exports: the release el_runtime.h omitted symbols the soul dist calls
that are defined in this runtime's .c — the http_handler_fn/http_handler4_fn
typedefs and el_arena_push/pop, engram_prune_telemetry, engram_get_node_by_label.
Declaration-only, POSIX-neutral; fixes implicit-declaration/unknown-type
errors under the C11 mingw build.
Result: x86_64-w64-mingw32-gcc compiles el_runtime.c + all 48 soul modules
clean; POSIX gcc -fsyntax-only clean. This is the Windows-port PR the runtime
needed on main (the release-runtime counterpart to #69), landed via stage.
The binary-safe fs_read length (_tl_fs_read_len) was consumed by the HTTP
response path for ANY body, even when a handler wrapped a smaller file into a
larger reply. Content-Length then lied AND the send stopped short: the
safety-contact (988) routes returned 178 of 208/218 bytes, cut mid-'set_at' —
unparseable JSON. The desktop app read that as failure. On Windows the shipped
brain is an OLD build without even the per-handler workaround, so EVERY reply
truncated: the app can't read confirmations and refuses the new user.
Durable fix: pair the length hint with the exact buffer pointer it describes
(_tl_fs_read_buf). Apply the raw byte count ONLY when the response IS that
buffer (binary file serving stays correct); every wrapped/enveloped/derived
body is measured with strlen. Reset both at request start and in fs_read /
json_get_raw. This also closes the stale-hint heap over-read (a length larger
than a later body would read past it out the socket) that a plain max() leaves
open — so this class of bug dies on every platform, not just where a handler
happened to be patched.
Applied identically to the mainline runtime (lang/el-compiler/runtime) AND the
frozen release runtime (lang/releases/v1.0.0-20260501) the desktop souls
compile against — the release copy still carried the raw leak, which is why the
Windows brain kept truncating. Same proven approach as PR #78 (Tim Lingo),
extended to cover the release runtime and rebased onto current main.
Both runtimes: gcc -fsyntax-only clean.
Durability: the 2026-07-21 fix stopped read routes writing the canonical
snapshot but left no save on ANY write path — every mutation lived in RAM
until a manual POST /api/save. Observed live: two restarts reverted the
store to a 17h-old snapshot, destroying same-day writes. persist_canonical()
now runs after node/edge create, knowledge capture, forget, strengthen, and
load-merge. ISE telemetry excluded deliberately (48h-pruned, loss-tolerant,
~2/min; snapshotting 28MB per heartbeat is waste).
Listing order: scan routes sort by salience with store-order ties, so
equal-salience telemetry (all ISEs are 0.3) returned OLDEST first — a
limited /api/nodes query silently returned a stale window, and a 41h-old
heartbeat series read as a live outage during this review. Ties now break
newest-first by created_at.
The old carry-over (weight *= 0.7 per engram_activate call) was call-rate-
dependent — carried context died in seconds under rapid curiosity scans and
lingered for hours under quiet loops — and a decayed scalar cannot represent
access frequency at all.
Now: k=10 access-timestamp ring + Petrov (2006) closed-form tail, d=0.5.
WM promotion and engram_strengthen record presentations; carry-over evicts
at base-level tau=-3.0 (Soar forgetting, ~403s single-touch) and shapes the
weight held at promotion (wm_anchor) with the ACT-R retrieval logistic
(s=0.4) — a pure function of wall-clock time, idempotent per call.
Persisted as access_ts/wm_anchor in snapshots; legacy nodes fall back to
the optimized form ln(n/(1-d)) - d*ln(L). base_level exposed in both node
serializers for observability.
Backing spec: 2026-07-21 integration brief (bl-b17facdd). Verified live:
carried weight ~anchor seconds after two disjoint activations (old code:
0.49x); frequency-hot nodes hold B=1.9 vs -0.14 single-touch.