Compare commits

...

28 Commits

Author SHA1 Message Date
bigmerge 5f3ddb8b8d Add grounded edge-propagation spec (task #50): core algorithm, proof harness, gated integration patches
El SDK CI - dev / build-and-test (pull_request) Failing after 14m31s
LTP/LTD-style belief grounding propagated along graph edges, with
union-find independence-guarded corroboration. Package: core C algorithm
(gep_core.h), a self-contained deterministic proof harness with recorded
output, staged runtime integration, and gated .el patches for the beat
hook and HTTP route.

Per the author's own LEDGER.md: built + proven on a clone, GATED pending
the engine/HNSW cutover — not wired into the live beat or routes.
Preserved here as a spec/reference artifact, not a request to merge into
the live path.
2026-08-15 14:26:06 -05:00
will.anderson d71fc4c1c0 Merge pull request 'promote stage -> main: reconciled el runtime (engram search + natives + durable truncation fix + Windows port)' (#82) from stage into main
El SDK Release / build-and-release (push) Successful in 8m31s
El SDK CI - dev / build-and-test (pull_request) Successful in 8m41s
2026-07-22 21:44:01 +00:00
will.anderson a118d19393 Merge pull request 'promote dev -> stage: el cluster (#66 engram + #79 truncation fix + release-runtime Windows port)' (#81) from dev into stage
El SDK CI - stage / build-and-test (push) Successful in 7m58s
El SDK Release / build-and-release (pull_request) Successful in 4m16s
2026-07-22 21:20:17 +00:00
will.anderson c6aa1e5c53 Merge pull request 'Land el cluster: #66 engram search + natives, #79 truncation fix, + release-runtime Windows port (reconciled)' (#80) from reconcile/el-cluster-windows-runtime into dev
El SDK CI - dev / build-and-test (push) Successful in 8m3s
El SDK CI - stage / build-and-test (pull_request) Successful in 4m25s
Land el cluster (#66 + #79 + release-runtime Windows-port reconciliation) into dev
2026-07-22 21:06:36 +00:00
will.anderson ff577391f2 reconcile(release-runtime): Windows-port + complete v1.0.0 release runtime so the desktop soul cross-compiles
El SDK CI - dev / build-and-test (pull_request) Successful in 7m7s
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.
2026-07-22 15:56:08 -05:00
will.anderson ee0d5f9b97 Merge #79: durable HTTP response-truncation fix, both runtimes (via stage) 2026-07-22 15:46:02 -05:00
will.anderson 391bd818ea Merge #66: tokenized+ranked engram lexical search + engram natives (via stage) 2026-07-22 15:45:54 -05:00
will.anderson 43636aed99 runtime: pair fs_read length hint with its buffer in BOTH runtimes — kill response truncation for good
El SDK Release / build-and-release (pull_request) Failing after 7s
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.
2026-07-22 15:04:25 -05:00
will.anderson 2baa0b9a41 Merge pull request 'release: promote stage -> main (ci publish hardening for sdk-release)' (#77) from stage into main
El SDK Release / build-and-release (push) Successful in 7m55s
2026-07-15 21:21:28 +00:00
will.anderson 6a8b2461cd Merge pull request 'release: promote dev -> stage (ci publish hardening for stage/main)' (#76) from dev into stage
El SDK CI - stage / build-and-test (push) Successful in 8m19s
El SDK Release / build-and-release (pull_request) Failing after 13m1s
2026-07-15 21:16:11 +00:00
will.anderson bcb356fe69 Merge pull request 'ci(stage,main): decouple ci-base rebuild, make SDK publish fail loudly' (#75) from hotfix/ci-stage-main-publish-hardening into dev
El SDK CI - stage / build-and-test (pull_request) Successful in 4m27s
El SDK CI - dev / build-and-test (push) Failing after 14m3s
2026-07-15 21:15:27 +00:00
will.anderson dd7827059a ci(stage,main): decouple ci-base rebuild, make SDK publish fail loudly
El SDK CI - dev / build-and-test (pull_request) Failing after 14m30s
Mirror the PR #72 fix (applied to ci-dev.yaml) onto ci-stage.yaml and
sdk-release.yaml. The stage and prod release jobs reported FAILURE even
when the el-runtime-c/-h publish SUCCEEDED, because the ancillary ci-base
Docker rebuild (a CI-cache optimization on the fragile host-mode GCE
runner) reddened the whole job.

- Rebuild ci-base step: continue-on-error: true — never blocks/reddens
  the job; the SDK publish is the deliverable.
- Publish step: set -euo pipefail + empty-key guard + active-account echo
  so a real publish failure still fails loud and is diagnosable.
2026-07-15 16:14:50 -05:00
will.anderson 208e36c899 Merge pull request 'release: promote stage -> main (tokenized search, get_node_by_label, epm fix, win portability)' (#74) from stage into main
El SDK Release / build-and-release (push) Successful in 8m23s
2026-07-15 18:24:39 +00:00
will.anderson b97ce74d1f Merge pull request 'release: promote dev -> stage (tokenized search, get_node_by_label, epm fix)' (#73) from dev into stage
El SDK CI - stage / build-and-test (push) Failing after 8m45s
El SDK Release / build-and-release (pull_request) Successful in 4m1s
2026-07-15 17:20:11 +00:00
will.anderson 155a449c4e Merge pull request 'ci(dev): make SDK publish fail loudly, decouple ci-base rebuild' (#72) from hotfix/ci-dev-publish-hardening into dev
El SDK CI - dev / build-and-test (push) Successful in 8m56s
El SDK CI - stage / build-and-test (pull_request) Successful in 4m10s
2026-07-15 16:34:14 +00:00
will.anderson 4696fd6833 ci(dev): make SDK publish fail loudly, decouple ci-base rebuild
El SDK CI - dev / build-and-test (pull_request) Successful in 8m51s
The dev push build went green-then-red while nothing published: the
Publish step had no set -e, so an auth/upload failure exited 0 (silent
no-publish), while the ci-base rebuild (set -euo pipefail + Docker on the
host-mode runner) hard-failed the job. Add set -euo pipefail + an empty-key
guard + active-account echo to the Publish step so failures surface with a
retrievable log, and mark the ci-base cache rebuild continue-on-error so
the fragile Docker step can never block the actual SDK artifact publish.
2026-07-15 11:33:37 -05:00
will.anderson 581a351fb1 Merge pull request 'integrate: stack PRs #65–#69 (elc OOM guard, tokenized+semantic engram search, get_node_by_label, win portability) for green CI' (#71) from hotfix/stage-elc-engram-integration into dev
El SDK CI - dev / build-and-test (push) Failing after 14m31s
2026-07-15 15:49:57 +00:00
will.anderson 8ce8656de2 epm: declare cross-module callees as extern fn so strict compilers accept generated C
El SDK CI - dev / build-and-test (pull_request) Successful in 7m33s
epm's sibling modules (registry/install/update) call functions defined in other
modules and in the El runtime (config, read_installed, registry_find,
manifest_deps, manifest_name, registry_latest_version, registry_token,
install_vessel, installed_version) without importing them, so elc emits no C
prototype for those calls. gcc<=13 treated the resulting implicit declarations
as warnings; gcc>=14 and clang reject them as hard errors, which is why the
"Build epm" CI step fails and blocks the whole dev/stage pipeline.

Add `extern fn` forward declarations -- El's own separate-compilation mechanism
-- for each cross-module callee at the top of registry/install/update. This
gives elc the correct C prototype in every generated translation unit, so the
calls compile cleanly and still resolve at link time. Simply suppressing
-Wimplicit-function-declaration would be unsafe: an implicit int return
truncates the 64-bit pointer returns of config/registry_find into a latent
crash, so declaring the true signatures is the correct fix. Localized to epm;
touches neither elc nor the runtime.
2026-07-15 10:14:43 -05:00
will.anderson 1e49560f1f Merge remote-tracking branch 'origin/feat/engram-semantic-search' into hotfix/stage-elc-engram-integration
El SDK CI - dev / build-and-test (pull_request) Failing after 14m39s
# Conflicts:
#	lang/el-compiler/runtime/el_runtime.c
2026-07-15 09:33:05 -05:00
will.anderson e8f0b5a9de Merge remote-tracking branch 'origin/fix/engram-lexical-tokenized-search' into hotfix/stage-elc-engram-integration 2026-07-15 09:28:44 -05:00
will.anderson 40287c4cfc Merge remote-tracking branch 'origin/hotfix/win-runtime-portability' into hotfix/stage-elc-engram-integration 2026-07-15 09:28:44 -05:00
will.anderson 0481bea44d Merge remote-tracking branch 'origin/hotfix/runtime-engram-get-node-by-label' into hotfix/stage-elc-engram-integration 2026-07-15 09:28:44 -05:00
will.anderson 9d565ca080 Merge remote-tracking branch 'origin/hotfix/elc-fixes' into hotfix/stage-elc-engram-integration 2026-07-15 09:28:44 -05:00
will.anderson 4773dd0aa2 runtime: make Windows soul reproducible from a clean el checkout
El SDK Release / build-and-release (pull_request) Failing after 16s
Two el_runtime portability defects only ever lived in staged local copies
used to hand-build neuron-ui PR #136's curl-enabled Windows neuron.exe.
gcc 15 promotes both to hard errors, so a clean el checkout cannot rebuild
that soul. Upstream the minimal fixes so the build is reproducible:

- http_serve_async: cast setsockopt optval to (const char*). Win32/mingw
  setsockopt wants const char*, not int*; the cast is a no-op on POSIX and
  matches the four already-cast sites elsewhere in this file.
- engram_save persist path: map fsync -> _commit in the _WIN32-only
  el_platform_win.h (io.h already included). Windows has no fsync(); the
  POSIX path is untouched.
2026-07-15 04:24:08 -05:00
will.anderson 6b9d9e6c4a Add engram_get_node_by_label runtime native to unblock soul link
El SDK Release / build-and-release (pull_request) Failing after 22s
chat.el calls the runtime native engram_get_node_by_label to fetch
well-known nodes (conv:history, session:summary) by stable label rather
than by ID — immune to vector-index drift across restarts. The current
runtime never defined it, so the regenerated dist/soul.c fails to link.

Backport the function verbatim (idiom-adapted to jb_finish) from release
runtime v1.0.0-20260501 and register it as an EL builtin exactly like its
siblings: runtime definition + prototype, __-prefixed seed wrapper +
prototype, and codegen arity entry. No search-site code is touched.
2026-07-15 04:07:33 -05:00
will.anderson b4967af13e feat(engram): semantic search layer via nomic-embed-text (cosine ∪ lexical)
Lexical istr_contains alone can't surface a node whose words don't appear
in the query. This adds an optional dense-vector layer: node content and the
query are embedded through Ollama (nomic-embed-text), and nodes are ranked by
cosine similarity unioned with lexical hits, so a paraphrase query reaches the
right node.

Wired into all three query entry points in el_runtime.c:
  - engram_search_json (HTTP /api/search): collect lexical ∪ semantic
    candidates, score (lexical base 1.0 + cosine; pure-semantic = cosine),
    rank, emit top-N. Stable sort preserves old order when semantic is off.
  - engram_search (internal el_val twin): lexical ∪ semantic union.
  - engram_activate seed loop (HTTP /api/activate): a node seeds if it
    lexically matches OR clears the cosine threshold; pure-semantic seeds
    enter scaled by cosine so paraphrase spreads without overpowering.

Degradable by design: the whole layer is gated on HAVE_CURL plus a one-shot
runtime probe. If curl is compiled out, Ollama is unreachable, or
ENGRAM_SEMANTIC=0, every entry point yields zero semantic signal and callers
fall back byte-for-byte to the pre-existing lexical search.

Node embeddings are cached in process memory keyed by node id with an FNV-1a
content hash for invalidation; the query is embedded once per call — so the
graph is not re-embedded on every query. nomic task prefixes
(search_query:/search_document:) are applied for retrieval separation.

Build steps gain -DHAVE_CURL so the engram artifact compiles the layer in
(-lcurl was already linked). Env: ENGRAM_SEMANTIC, ENGRAM_EMBED_URL,
ENGRAM_EMBED_MODEL, ENGRAM_SEMANTIC_MIN (cosine threshold, default 0.6).
2026-07-14 18:48:16 -05:00
will.anderson 2b2a1246e7 Merge pull request 'runtime: fix the memory-leak + write-corruption pair in el_runtime.c' (#64) from hotfix/el-runtime-leak-and-persist into main
El SDK Release / build-and-release (push) Failing after 10m52s
2026-07-13 21:23:31 +00:00
will.anderson 5c41c66a0f Merge pull request 'fix(windows): guard el_mem_check with _WIN32 — rusage is POSIX-only' (#60) from fix/windows-rusage-guard into stage
El SDK CI - stage / build-and-test (push) Failing after 13m21s
fix(windows): guard el_mem_check with _WIN32 — rusage is POSIX-only
2026-06-25 16:48:13 +00:00
25 changed files with 1825 additions and 76 deletions
+15
View File
@@ -214,9 +214,18 @@ jobs:
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
# Fail loudly: previously this step had no `set -e`, so an auth or
# upload failure was swallowed (step exited 0 on the trailing echo)
# and the SDK silently never published. Surface failures now.
set -euo pipefail
if [ -z "${GCP_SA_KEY:-}" ]; then
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
exit 1
fi
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
VERSION="${GITHUB_SHA:0:8}"
@@ -268,6 +277,12 @@ jobs:
# Patches ci-base:dev in-place: pulls the existing image (which has all
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
#
# continue-on-error: this is a CI-cache optimization, NOT the release
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
# runner where DinD/Docker availability is fragile. A failure here must
# never block or redden the job — the SDK publish above is the deliverable.
continue-on-error: true
if: github.event_name == 'push'
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
+15
View File
@@ -212,12 +212,21 @@ jobs:
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
# Fail loudly: previously this step had no `set -e`, so an auth or
# upload failure was swallowed (step exited 0 on the trailing echo)
# and the SDK silently never published. Surface failures now.
set -euo pipefail
if [ -z "${GCP_SA_KEY:-}" ]; then
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
exit 1
fi
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates curl
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
VERSION="${GITHUB_SHA:0:8}"
@@ -253,6 +262,12 @@ jobs:
# Patches ci-base:stage in-place: pulls the existing image (which has all
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
#
# continue-on-error: this is a CI-cache optimization, NOT the release
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
# runner where DinD/Docker availability is fragile. A failure here must
# never block or redden the job — the SDK publish above is the deliverable.
continue-on-error: true
if: github.event_name == 'push'
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
+15
View File
@@ -288,12 +288,21 @@ jobs:
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
run: |
# Fail loudly: previously this step had no `set -e`, so an auth or
# upload failure was swallowed (step exited 0 on the trailing echo)
# and the SDK silently never published. Surface failures now.
set -euo pipefail
if [ -z "${GCP_SA_KEY:-}" ]; then
echo "FATAL: GCP_SA_KEY secret is empty — cannot authenticate to publish" >&2
exit 1
fi
echo "${GCP_SA_KEY}" > /tmp/gcp-key.json
apt-get install -y -qq apt-transport-https ca-certificates curl
echo "deb [trusted=yes] https://packages.cloud.google.com/apt cloud-sdk main" > /etc/apt/sources.list.d/google-cloud-sdk.list
apt-get update -qq && apt-get install -y google-cloud-cli
gcloud auth activate-service-account --key-file=/tmp/gcp-key.json
gcloud config set project neuron-785695
echo "Publishing as active account: $(gcloud config get-value account 2>/dev/null)"
VERSION="${GITHUB_SHA:0:8}"
@@ -345,6 +354,12 @@ jobs:
# Patches ci-base:latest in-place: pulls the existing image (which has all
# system deps — Node, Go, gcloud, Docker CLI, etc.) and overlays the freshly
# built El SDK on top. Keeps the full ci-base rebuild fast and incremental.
#
# continue-on-error: this is a CI-cache optimization, NOT the release
# artifact. It runs Docker (pull/build/push ~600MB) on the host-mode GCE
# runner where DinD/Docker availability is fragile. A failure here must
# never block or redden the job — the SDK publish above is the deliverable.
continue-on-error: true
if: github.event_name == 'push'
env:
GCP_SA_KEY: ${{ secrets.GCP_SA_KEY }}
+1 -1
View File
@@ -81,7 +81,7 @@ jobs:
# Link to produce the engram binary
- name: Link engram binary
run: |
cc -std=c11 -O2 \
cc -std=c11 -O2 -DHAVE_CURL \
-I /usr/local/lib/el \
-o dist/engram \
dist/engram.c \
+1 -1
View File
@@ -88,7 +88,7 @@ jobs:
# Link to produce the engram binary
- name: Link engram binary
run: |
cc -std=c11 -O2 \
cc -std=c11 -O2 -DHAVE_CURL \
-I /usr/local/lib/el \
-o dist/engram \
dist/engram.c \
+1 -1
View File
@@ -62,7 +62,7 @@ jobs:
# Link to produce the engram binary
- name: Link engram binary
run: |
cc -std=c11 -O2 \
cc -std=c11 -O2 -DHAVE_CURL \
-I /usr/local/lib/el \
-o dist/engram \
dist/engram.c \
@@ -0,0 +1,162 @@
# Task #50 — Edge-aware, dream-coupled consolidation with GROUNDED EDGE-PROPAGATION
**Status:** built + proven on a clone; **GATED, not promoted.** The main loop
sequences live promotion after the engine/HNSW cutover settles.
**Date:** 2026-08-15 · **Worktree:** `agent-a6577c8211c332c5b` (isolated).
Grounding mechanism designed with Will (memory `9e09a59f`, refining
`1a861007`). This is the HOW for #50.
---
## (a) How grounded edge-propagation integrates into the dream/consolidation cycle
The beat already exists. `neuron/awareness.el` runs a heartbeat (~every
`beat_ms`); each beat calls `hebb_consolidate()` — which drains the self-formed
Hebbian associations out of the fast in-process store and writes them, over the
threshold `ENGRAM_HEBB_LINK_MIN`, into the durable engram (`:8742`) — and then
`emit_heartbeat()`.
Grounded edge-propagation slots into the **same beat, immediately after
consolidation** (awareness.el line 12861288):
```
hebb_consolidate() // lay down the tethers (edges) that cleared threshold
ground_propagate() // <-- NEW: grade beliefs ALONG those tethers
emit_heartbeat() // report gep_* gauges beside hebb_*
```
This ordering is the point. Consolidation lays down the wiring; propagation
grades the beliefs along it, in the same breath. Memory `69b8babe`:
memory-consolidation and staying-yourself are one physics — forming a memory and
grading a belief are the same gravity run in two passes of one beat.
The propagation runs **inside the engram** as the native
`engram_ground_propagate()` over the durable flat node/edge arrays (the store
the consolidated edges just landed in). The soul invokes it over HTTP
(`POST /api/ground/propagate`) and folds the returned `gep_*` telemetry into the
heartbeat stream next to `hebb_cands / hebb_mass / hebb_edges`.
**Bounded by construction** (per the live-graph reality — 70.7% of nodes
isolated, connected core ~28%, hub first-hop fan-out in the thousands):
- **1-hop only.** No BFS spreading activation — a belief is graded from its
DIRECT grounded neighbors, so there is no per-hop breadth explosion.
- **Beam-capped** at `GEP_MAX_CORR = 256` corroborators per belief.
- **Salience-ordered, `GEP_BELIEFS_PER_BEAT = 512`** beliefs per beat; the rest
next beat. Work per beat is O(beliefs × degree), hard-bounded.
- **Isolated / starved beliefs** are counted and surfaced (`gep_isolated`,
`gep_starved`) as an interoceptive sparse-region signal for the
edge-formation / embedding pass (#20). #50 CONSUMES edges; it does not form
them. A belief with no grounded neighbor has nothing to tether to — correct
per the anti-delusion gravity law (`0b15017c`), not a gap.
---
## (b) The implementation
Represented faithfully to the spec — **grounding is a Hebbian-weighted
collection over time, never a scalar.**
- **Grounding = an append-only event ring** on the node (`GepGrounding`),
structurally parallel to the ACT-R base-level access ring already in
`EngramNode` (`access_ts[K]`). Each event is `{ts, sign±, mag, corroborator
signature}`. Append-only, supersede-not-delete; events aged out of the ring
are counted (`older_count`), never faked away.
- **Standing is DERIVED, recency-weighted, never stored** —
`standing = clamp(GEP_BASE + Σ_events sign·mag·age^(-D), 0, 1)`, exactly the
ACT-R base-level shape `ln Σ t^-d` (`ENGRAM_BLL_D = 0.5`) but sign-carrying so
LTD subtracts. Memory `1a861007`: the collection is primary, the standing is
its emergent aggregate. Mirrored onto `confidence` each beat so downstream
reads (verifier #43, realizer calibration `0041d917`) never speak above the
grounding.
- **Update = LTP/LTD with a threshold.** Per belief, gather corroborators along
incident edges, weighted by `edge.weight` (the Hebbian weight) × the
neighbor's own standing. **Anti-delusion gravity:** only neighbors already
`≥ GEP_LIKELY_MIN` may corroborate — grounding flows FROM the grounded core.
- **Convergent INDEPENDENT corroboration** is the driver. Independence is
enforced by **union-find over the corroborator set**: two corroborators are
the same independent source if they are the same node, reached by multiple
edges, or linked to each other (an echo chain / shared derivation). Support is
summed **per independent component** (max-magnitude member), and the threshold
gate requires BOTH a mass floor (`pos ≥ GEP_THETA`) AND an independence-count
floor (`n_independent ≥ GEP_N_MIN`). The count gate is the guard against one
node echoed N times.
- **Sub-threshold is transient.** Support present but below threshold →
`subthreshold_hits++`, no durable event, no lasting shift (Will's exact spec).
- **Graduation / decay.** Cross up → LTP event appended → standing climbs
`conjecture → likely → grounded`. Contradiction past threshold → LTD →
`grounded → likely → conjecture`. Nothing latches; withdraw support and the
collection ages and relaxes (`271f1163`, nothing is settled).
### Files
| File | Role |
|---|---|
| `gep_core.h` | The mechanism. Pure C, libm only (own-the-core). Single source of truth: `GepGrounding`, `gep_standing`, `gep_append`, union-find independence, `gep_propagate_node`, `gep_beat`. |
| `gep_proof.c` | Self-contained proof harness — builds the three scenarios, prints raw before/after. |
| `engram_ground_propagate.staged.c` | GATED runtime native. Wires the SAME `gep_core.h` primitives to the live `EngramStore` (adj cache, flat arrays). Splice plan + relation→polarity + belief gate. Compiles only when spliced (verified: every runtime symbol it references — `engram_adj_rebuild`, `adj_from_len`, `engram_find_node_index`, `ENGRAM_LAYER_SAFETY`, `istr_contains`, … — exists in the release runtime). |
| `awareness.beat.patch.el` | GATED beat hook — `ground_propagate()` + the insert between `hebb_consolidate()` and `emit_heartbeat()`. |
| `server.route.patch.el` | GATED route — `POST /api/ground/propagate`. |
### Constants
`BASE=0.10 LIKELY_MIN=0.34 GROUNDED_MIN=0.66 N_MIN=3 THETA=0.30 D=0.5`
(`N_MIN` parameterizes Will's "13 adjacent things" — the count threshold is a
knob; 3 here for a crisp proof.)
---
## (c) PROOF LEDGER — raw grounding before/after
Deterministic. Build `cc -std=c11 -O2 -o gep_proof gep_proof.c -lm`, run
`./gep_proof` (full transcript in `PROOF_OUTPUT.txt`).
### (a) STRENGTHEN — convergent independent corroboration graduates a conjecture
| beat | event | pos_mass (n_indep) | action | standing before → after | band |
|---|---|---|---|---|---|
| 1 | 3 independent grounded corroborators | 0.4050 (3) | **LTP** | 0.1000 → **0.4842** | conjecture → **likely** ⬆ |
| 2 | neighborhood grows to 5 | 0.6750 (5) | **LTP** | 0.1496 → **0.7379** | conjecture → **grounded** ⬆ |
| 3 | support sustained (5) | 0.6750 (5) | LTP | 0.2110 → 0.7993 | grounded (sustained) |
| 4 | corroboration withdrawn (+10min) | 0.0000 (0) | isolated | 0.1612 → 0.1612 | relaxing |
| 5 | still withdrawn (+1h) | — | isolated | 0.1263 | relaxing |
| 6 | still withdrawn (+4h) | — | isolated | 0.1130 | → conjecture |
Grounding grew **on its own** past threshold and graduated conjecture → likely →
grounded, then **relaxed** once independent support stopped. Living, not a
latched flag.
### (b) DECAY — convergent independent contradiction erodes a grounded belief
| beat | event | neg_mass (n_indep) | action | standing before → after | band |
|---|---|---|---|---|---|
| — | seed (prior LTP) | — | — | **0.9500** | grounded |
| 1 | 3 independent contradictions | 0.5400 (3) | **LTD** | 0.9500 → **0.4570** | grounded → **likely** ⬇ |
| 2 | contradiction broadens to 5 | 0.9000 (5) | **LTD** | 0.1461 → **0.0000** | conjecture ⬇ |
| 34 | contradiction sustained (5) | 0.9000 (5) | LTD | 0.0000 | conjecture |
Grounding decayed grounded → likely → conjecture under accreting independent
contradiction. The door never shut — history is retained (the event ring keeps
growing), the belief stays falsifiable in both directions.
### (c) INDEPENDENCE GUARD — the load-bearing property
Identical fan-in (N=5), identical edge weight (0.30), identical corroborator
standing (~0.90). **The only difference is whether the five are independent.**
| sub-case | topology | pos_mass | **n_indep** | action | standing 0.1000 → |
|---|---|---|---|---|---|
| **C1** | 5 DISTINCT, no inter-links | 1.3500 | **5** | **LTP** | **0.9741 (grounded)** ⬆ |
| **C2** | 5 mutually-linked (echo of one source) | 0.2700 | **1** | sub-threshold | 0.1000 (unchanged) |
| **C3** | 1 node reached by 5 parallel edges | 0.2700 | **1** | sub-threshold | 0.1000 (unchanged) |
Same raw fan-in, opposite outcome. Union-find collapses the echoes to a single
independent component; the count gate (`n_indep ≥ N_MIN`) then refuses them.
**Circular self-reinforcement cannot manufacture grounding** — a conjecture can
only be grounded by evidence that is genuinely independent of itself.
---
**RAILS honored:** isolated worktree; built/proven on a clone; the live soul
(`:8742` / `:7770`) untouched; no fight with the cutover (built against current
release source; staged native rebases cleanly onto it); no new libraries
(libm only); identity keystones untouched. **Not promoted** — gated artifact +
ledger for the main loop to sequence.
@@ -0,0 +1,75 @@
GROUNDED EDGE-PROPAGATION — PROOF LEDGER (task #50)
constants: BASE=0.10 LIKELY_MIN=0.34 GROUNDED_MIN=0.66 N_MIN=3 THETA=0.30 D=0.5
=== SCENARIO A — STRENGTHEN: convergent independent corroboration ===
seed: conjecture has NO grounding events; corroborators pre-grounded.
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 3 independent grounded corroborators appear
incident_edges=3 pos_mass=0.4050 (n_indep=3) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1000 (conjecture) -> 0.4842 (likely) [GRADUATED]
beat 2 (t=+60s) neighborhood grows to 5 corroborators
incident_edges=5 pos_mass=0.6750 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1496 (conjecture) -> 0.7379 (grounded) [GRADUATED]
beat 3 (t=+120s) support sustained (5)
incident_edges=5 pos_mass=0.6750 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.2110 (conjecture) -> 0.7993 (grounded) [GRADUATED]
beat 4 (t=+720s) corroboration withdrawn (+10min)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1612 (conjecture) -> 0.1612 (conjecture)
beat 5 (t=+3600s) still withdrawn (+1h)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1263 (conjecture) -> 0.1263 (conjecture)
beat 6 (t=+14400s) still withdrawn (+4h)
incident_edges=0 pos_mass=0.0000 (n_indep=0) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> isolated (no edges) standing 0.1130 (conjecture) -> 0.1130 (conjecture)
RESULT: grounding grew automatically past threshold and graduated,
then relaxed once the independent support stopped — living,
not a latched flag.
=== SCENARIO B — DECAY: convergent independent CONTRADICTION ===
seed: belief pre-grounded by a strong prior LTP event.
belief standing=0.9500 band=grounded events=1 subthresh=0
beat 1 (t=+0s) 3 independent contradictions
incident_edges=3 pos_mass=0.0000 (n_indep=0) neg_mass=0.5400 (n_indep=3) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.9500 (grounded) -> 0.4570 (likely) [DEMOTED]
beat 2 (t=+60s) contradiction broadens to 5
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.1461 (conjecture) -> 0.0000 (conjecture)
beat 3 (t=+120s) contradiction sustained (5)
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.0401 (conjecture) -> 0.0000 (conjecture)
beat 4 (t=+180s) contradiction sustained (5)
incident_edges=5 pos_mass=0.0000 (n_indep=0) neg_mass=0.9000 (n_indep=5) THETA=0.30 N_MIN=3
-> LTD (decay) standing 0.0000 (conjecture) -> 0.0000 (conjecture)
RESULT: grounding decayed grounded->likely->conjecture under
convergent independent contradiction. The door never shut
on the belief; its history is retained (events keep growing).
=== SCENARIO C — INDEPENDENCE GUARD (the load-bearing property) ===
Both sub-cases: N=5 corroborators, edge weight 0.30, corroborator
standing ~0.90. ONLY difference: whether the 5 are independent.
-- C1: 5 DISTINCT independent corroborators --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 5 independent corroborators (no inter-links)
incident_edges=5 pos_mass=1.3500 (n_indep=5) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> LTP (strengthen) standing 0.1000 (conjecture) -> 0.9741 (grounded) [GRADUATED]
-- C2: 5 corroborators, but mutually-linked (echo of ONE source) --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) 5 echoed (mutually-linked) corroborators
incident_edges=5 pos_mass=0.2700 (n_indep=1) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> sub-threshold (no shift) standing 0.1000 (conjecture) -> 0.1000 (conjecture)
-- C3: ONE corroborator, reached by 5 parallel edges --
conjecture standing=0.1000 band=conjecture events=0 subthresh=0
beat 1 (t=+0s) same node, 5 parallel edges
incident_edges=5 pos_mass=0.2700 (n_indep=1) neg_mass=0.0000 (n_indep=0) THETA=0.30 N_MIN=3
-> sub-threshold (no shift) standing 0.1000 (conjecture) -> 0.1000 (conjecture)
RESULT: identical raw fan-in (5) and mass inputs; C1 grounds because
the corroboration is INDEPENDENT (5 components), C2/C3 do not
because it collapses to ONE source. Circular self-reinforcement
cannot manufacture grounding.
DONE.
@@ -0,0 +1,60 @@
//
// awareness.beat.patch.el GATED integration hook for task #50.
// NOT APPLIED. Shows exactly how grounded edge-propagation couples into the
// dream/consolidation beat in neuron/awareness.el. Promotion sequenced by the
// main loop after the engine cutover settles.
//
// WHY HERE. The heartbeat is the beat. Today it runs hebb_consolidate() to
// drain the self-formed Hebbian associations into the durable store, then
// emit_heartbeat(). Grounded edge-propagation belongs in the SAME beat, AFTER
// consolidation: the edges hebb_consolidate() just wrote are the tethers
// grounding propagates along. Consolidation lays down the wiring; propagation
// grades the beliefs along it. One beat, coupled memory 69b8babe: memory-
// consolidation and staying-yourself are one physics.
//
// The propagation itself runs INSIDE the engram (native engram_ground_propagate
// over the durable flat node/edge arrays). The soul invokes it over HTTP and
// folds the gep_* telemetry into the heartbeat stream next to the hebb_* gauges.
//
// [1] New helper sibling to hebb_consolidate() (awareness.el ~line 99).
// Fires one grounded edge-propagation beat on the durable store and returns
// its JSON telemetry ({"gep_strengthened":..,"gep_graduations":.., ...}).
fn ground_propagate() -> String {
let url_env: String = env("SOUL_ISE_URL")
let url_state: String = if str_eq(url_env, "") { state_get("soul_engram_url") } else { url_env }
let engram_url: String = if str_eq(url_state, "") { "http://localhost:8742" } else { url_state }
// Same auth envelope as hebb_consolidate this is a graph mutation (it
// appends grounding events + updates confidence), so it is gated on _auth.
let key_state: String = state_get("soul_engram_api_key")
let api_key: String = if str_eq(key_state, "") { env("ENGRAM_API_KEY") } else { key_state }
let auth_part: String = if str_eq(api_key, "") { "{}" } else { "{\"_auth\":\"" + api_key + "\"}" }
let resp: String = http_post_json(engram_url + "/api/ground/propagate", auth_part)
if str_eq(resp, "") { return "" }
return resp
}
// [2] Beat hook insert between hebb_consolidate() and emit_heartbeat()
// (awareness.el line 1286-1288). Replaces:
//
// let wb_sent_n: Int = hebb_consolidate()
// state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
// emit_heartbeat()
//
// with:
//
// let wb_sent_n: Int = hebb_consolidate()
// state_set("soul.hebb_wb_sent", int_to_str(wb_sent_n))
// // Grounded edge-propagation grade beliefs along the tethers
// // consolidation just laid down. Threshold-gated by convergent
// // independent corroboration; automatic, salience-ordered, bounded.
// let gep_tel: String = ground_propagate()
// state_set("soul.gep_last", gep_tel)
// emit_heartbeat()
//
// [3] emit_heartbeat() (awareness.el ~line 201) folds soul.gep_last into the
// heartbeat payload beside the hebb_* gauges, so graduation/decay counts
// are visible in the durable ISE stream the same observability discipline
// the Hebbian rule earned (a mechanism you cannot see in the stream is a
// mechanism you cannot trust): read state_get("soul.gep_last") and splice
// it into the heartbeat JSON object.
@@ -0,0 +1,188 @@
/* ─────────────────────────────────────────────────────────────────────────
* engram_ground_propagate.staged.c — GATED runtime native for task #50.
*
* STAGED, NOT COMPILED INTO THE LIVE BINARY. This mirrors the
* geometric_retrieve.staged.c staging pattern (memory 1cc231ec): it references
* runtime-internal types (EngramStore, EngramNode, EngramEdge, engram_global,
* engram_now_ms, the adj cache) and therefore compiles ONLY when spliced into
* lang/releases/v1.0.0-20260501/el_runtime.c. Splice + promotion is sequenced
* by the main loop AFTER the engine+HNSW cutover settles — do NOT hand-apply.
*
* It is the production form of the mechanism proven in gep_proof.c: the SAME
* gep_core.h primitives (GepGrounding ring, gep_standing, gep_append,
* union-find independence), wired directly to the live flat node/edge arrays.
*
* ── SPLICE PLAN (three additive edits to el_runtime.c; nothing removed) ──────
*
* [1] EngramNode struct (~line 6061, after hebb_elig_ts): add the grounding
* collection. Additive; zero-initialized by the existing calloc/memset
* paths, so legacy snapshots degrade gracefully to an empty history.
*
* GepGrounding grounding; // task #50 — append-only grounding ring
*
* [2] #include "gep_core.h" near the other engram includes, and paste the
* body of this file below the Hebbian section (after engram_hebb_drain_json).
*
* [3] Persistence (engram_save node JSON ~7934 / engram_load parser ~8186):
* serialize the grounding ring as a compact "grounding" array of
* [ts,sign,mag] triples + subthreshold_hits so standing survives a
* round-trip. Helpers gep_grounding_to_json / gep_grounding_parse below.
* Until wired, grounding is in-RAM only (like the Hebbian eligibility
* trace) — correct for a first gated rollout, but standing resets on boot.
*
* [4] EL surface: declare engram_ground_propagate in el_runtime.h + el_seed.c,
* add route_ground_propagate to engram/src/server.el, called from the
* awareness.el consolidation beat (see awareness.beat.patch.el).
* ───────────────────────────────────────────────────────────────────────── */
#include "gep_core.h"
/* Relation → evidential polarity. Supportive relations transmit grounding
* gravity (+1); contradictory relations erode it (-1); everything else is a
* NON-evidential edge (structural / navigational) and is ignored (0) — an
* association is not a corroboration. Extend deliberately; a mis-classified
* relation is a false corroboration. */
static int8_t gep_relation_polarity(const char* rel) {
if (!rel) return 0;
if (!strcmp(rel, "supports") || !strcmp(rel, "corroborates") ||
!strcmp(rel, "derived-from") || !strcmp(rel, "hebbian-associate") ||
!strcmp(rel, "grounds") || !strcmp(rel, "confirms")) return +1;
if (!strcmp(rel, "contradicts") || !strcmp(rel, "refutes") ||
!strcmp(rel, "negates") || !strcmp(rel, "conflicts-with")) return -1;
return 0;
}
/* Which nodes are BELIEFS/CONJECTURES subject to grounding propagation. Facts
* imported as knowledge are already grounded by provenance; identity/safety
* layers are never re-graded here. Gate on node_type + the conjecture tag. */
static int gep_is_belief(const EngramNode* n) {
if (!n || !n->node_type) return 0;
if (n->layer_id == ENGRAM_LAYER_SAFETY) return 0; /* never re-grade safety */
return !strcmp(n->node_type, "Memory") ||
!strcmp(n->node_type, "Conjecture") ||
!strcmp(n->node_type, "Hypothesis") ||
!strcmp(n->node_type, "Belief") ||
(n->tags && istr_contains(n->tags, "conjecture"));
}
/* Grounding standing of an engram node, derived from its collection. This is
* the value the verifier (#43) and realizer (calibrated assertion, 0041d917)
* read — and it is written back into epistemic_confidence-equivalent surfaces
* so "never speak above the grounding" is enforced from one source of truth. */
double engram_grounding_standing(const EngramNode* n, int64_t now_ms) {
return gep_standing(&n->grounding, now_ms);
}
/* ── The beat: one pass of grounded edge-propagation over the whole store ────
* Called from the consolidation/dream heartbeat. 1-hop, beam-capped, salience-
* ordered so a bounded slice of the highest-salience beliefs is processed per
* beat (the rest next beat) — never a full-graph blow-up on a 12k-node store.
* Returns JSON telemetry for the heartbeat stream. */
#define GEP_BELIEFS_PER_BEAT 512 /* bound work per beat; salience-prioritized */
el_val_t engram_ground_propagate(void) {
EngramStore* g = engram_get();
int64_t now = engram_now_ms();
engram_adj_rebuild(g); /* ensure adj_from/adj_to are current */
int strengthened = 0, decayed = 0, subthreshold = 0;
int graduations = 0, demotions = 0, isolated = 0, starved = 0, processed = 0;
for (int64_t bi = 0; bi < g->node_count && processed < GEP_BELIEFS_PER_BEAT; bi++) {
EngramNode* b = &g->nodes[bi];
if (!gep_is_belief(b)) continue;
processed++;
int before = gep_band_rank(gep_standing(&b->grounding, now));
/* Gather independent corroborators over incident edges (both directions),
* anti-delusion gated (neighbor must already be ≥ LIKELY_MIN). */
GepCorrSet cs; cs.n = 0; int incident = 0;
int* out = g->adj_from[bi]; int out_n = g->adj_from_len[bi];
int* in = g->adj_to[bi]; int in_n = g->adj_to_len[bi];
for (int pass = 0; pass < 2; pass++) {
int* lst = pass ? in : out; int ln = pass ? in_n : out_n;
for (int k = 0; k < ln; k++) {
EngramEdge* e = &g->edges[lst[k]];
int8_t pol = gep_relation_polarity(e->relation);
if (pol == 0) continue;
incident++;
const char* cid = pass ? e->from_id : e->to_id;
int64_t ci = engram_find_node_index(cid);
if (ci < 0 || ci == bi) continue;
double cstand = gep_standing(&g->nodes[ci].grounding, now);
if (cstand < GEP_LIKELY_MIN) continue; /* no tether */
double contrib = e->weight * cstand * (double)pol;
int ex = -1;
for (int q = 0; q < cs.n; q++) if (cs.node_idx[q] == (int)ci) { ex = q; break; }
if (ex >= 0) { if (fabs(contrib) > fabs(cs.contrib[ex])) cs.contrib[ex] = contrib; }
else if (cs.n < GEP_MAX_CORR) {
cs.node_idx[cs.n] = (int)ci; cs.contrib[cs.n] = contrib;
cs.parent[cs.n] = cs.n; cs.n++;
}
}
}
/* Collapse mutually-derived corroborators (an edge between two of them)
* into one independent component — the independence guard. */
for (int x = 0; x < cs.n; x++) {
int64_t nx = cs.node_idx[x];
int* xout = g->adj_from[nx]; int xn = g->adj_from_len[nx];
for (int k = 0; k < xn; k++) {
const char* tid = g->edges[xout[k]].to_id;
int64_t ti = engram_find_node_index(tid);
for (int y = 0; y < cs.n; y++)
if (cs.node_idx[y] == (int)ti) { gep_uf_union(&cs, x, y); break; }
}
}
/* Per-component max-magnitude, split by polarity → convergent independent
* support mass + independence count. */
double comp_best[GEP_MAX_CORR]; int comp_root[GEP_MAX_CORR], ncomp = 0;
for (int i = 0; i < cs.n; i++) {
int r = gep_uf_find(&cs, i), slot = -1;
for (int kk = 0; kk < ncomp; kk++) if (comp_root[kk] == r) { slot = kk; break; }
if (slot < 0) { slot = ncomp++; comp_root[slot] = r; comp_best[slot] = cs.contrib[i]; }
else if (fabs(cs.contrib[i]) > fabs(comp_best[slot])) comp_best[slot] = cs.contrib[i];
}
double pos = 0, neg = 0; int np = 0, nn = 0; uint64_t sig = 1469598103934665603ULL;
for (int k = 0; k < ncomp; k++) {
if (comp_best[k] > 0) { pos += comp_best[k]; np++; }
else if (comp_best[k] < 0) { neg += -comp_best[k]; nn++; }
sig = (sig ^ (uint64_t)comp_root[k]) * 1099511628211ULL;
}
double net = pos - neg;
if (net > 0 && pos >= GEP_THETA && np >= GEP_N_MIN) {
gep_append(&b->grounding, now, +1, tanh(GEP_MAG_GAIN * net), sig);
strengthened++;
} else if (net < 0 && neg >= GEP_THETA && nn >= GEP_N_MIN) {
gep_append(&b->grounding, now, -1, tanh(GEP_MAG_GAIN * (-net)), sig);
decayed++;
} else if (np > 0 || nn > 0) {
b->grounding.subthreshold_hits++; subthreshold++;
} else if (incident == 0) { isolated++; }
else { starved++; }
/* Mirror the derived standing onto confidence so downstream reads
* (activate epistemic_confidence, realizer calibration) never exceed the
* grounding. Faithful representation, single source of truth. */
double stand = gep_standing(&b->grounding, now);
b->confidence = stand;
b->updated_at = now;
int after = gep_band_rank(stand);
if (after > before) graduations++;
if (after < before) demotions++;
}
/* Heartbeat telemetry — the gep_* line, sibling to the hebb_* gauges. */
char buf[512];
snprintf(buf, sizeof buf,
"{\"gep_processed\":%d,\"gep_strengthened\":%d,\"gep_decayed\":%d,"
"\"gep_subthreshold\":%d,\"gep_graduations\":%d,\"gep_demotions\":%d,"
"\"gep_isolated\":%d,\"gep_starved\":%d}",
processed, strengthened, decayed, subthreshold,
graduations, demotions, isolated, starved);
return EL_STR(el_strdup(buf));
}
@@ -0,0 +1,299 @@
/* ─────────────────────────────────────────────────────────────────────────
* gep_core.h — Grounded Edge-Propagation, the core mechanism (task #50).
*
* Edge-aware, dream-coupled consolidation. Runs DURING the consolidation/dream
* beat (awareness.el hebb_consolidate → engram_ground_propagate). Grounding
* propagates + strengthens/decays along edges, threshold-gated by CONVERGENT
* INDEPENDENT corroboration from adjacent grounded nodes.
*
* This header is the single source of truth for the algorithm. It is pure C
* (libm only — own-the-core, no new libraries) and operates on a compact graph
* view (GepGraph) that both the proof harness and the runtime native populate
* from the live EngramStore (nodes/edges flat arrays + adj_from/adj_to).
*
* SPEC (Will, 2026-08-15; memory 9e09a59f, refines 1a861007):
* - A grounding is a VECTOR + its HEBBIAN WEIGHTS — a weighted structure over
* the evidential neighborhood, NOT a scalar and NOT a flat list. It APPENDS
* and GROWS on SIGNIFICANT change. => grounding = an APPEND-ONLY event ring
* (GepGrounding), parallel to the ACT-R base-level access_ts ring already in
* EngramNode. Current standing is DERIVED, recency-weighted, never stored.
* - UPDATE = LTP/LTD with a THRESHOLD (the key nonlinearity). Sub-threshold =
* recorded in history but TRANSIENT (no lasting shift). Cross the threshold
* of convergent support → grounding STRENGTHENS. Contradiction/erosion past
* threshold → grounding DECAYS. Automatic, event-driven, salience-gated.
* - DRIVER = CONVERGENT INDEPENDENT CORROBORATION (coherentism, mechanized):
* when N INDEPENDENT adjacent nodes ground as likely-true around a
* conjecture (Will's example: 13), its grounding grows on its own.
* - INDEPENDENCE is load-bearing: N DISTINCT corroborators, not one node
* echoed N times. Guards against circular self-reinforcement.
* - ANTI-DELUSION GRAVITY (memory 0b15017c): support flows only FROM already-
* grounded neighbors. A belief cannot ground from ungrounded speculation,
* however self-consistent — nothing tethers it to the grounded core.
* - NOTHING IS SETTLED (memory 271f1163): grounded is strongly-held, still
* falsifiable. Decay path stays open on every node; history is append-only,
* supersede-not-delete.
* ───────────────────────────────────────────────────────────────────────── */
#ifndef GEP_CORE_H
#define GEP_CORE_H
#include <stdint.h>
#include <math.h>
#include <string.h>
/* ── Constants ──────────────────────────────────────────────────────────────
* GEP_DECAY_D matches ENGRAM_BLL_D (0.5, canonical ACT-R): the derived standing
* is recency-weighted over the grounding-event collection exactly as the
* base-level term is recency-weighted over the access ring (memory 1a861007:
* "structurally the ACT-R base-level pattern, a sum over time-stamped events").
*/
#define GEP_DECAY_D 0.5 /* ACT-R power-law recency exponent */
#define GEP_BASE 0.10 /* standing floor of a bare conjecture */
#define GEP_LIKELY_MIN 0.34 /* band: conjecture < LIKELY ≤ likely */
#define GEP_GROUNDED_MIN 0.66 /* band: likely < GROUNDED ≤ grounded */
#define GEP_N_MIN 3 /* min INDEPENDENT corroborators to cross */
#define GEP_THETA 0.30 /* min convergent-support MASS to cross */
#define GEP_MAG_GAIN 1.0 /* net-support → event-magnitude gain (tanh) */
#define GEP_EVENT_RING 32 /* grounding-history depth kept exactly */
/* A single grounding event — one contact with the evidential neighborhood.
* Append-only; the ring is the collection-over-time, the standing is derived. */
typedef struct {
int64_t ts; /* wall-clock ms of the grounding event */
int8_t sign; /* +1 = LTP (strengthen), -1 = LTD (decay) */
double mag; /* magnitude in (0,1], = tanh(gain·|net independent support|)*/
uint64_t sig; /* signature of the independent corroborator set (audit) */
} GepEvent;
/* The grounding of one node: an append-only ring of events + transient counters.
* older_count keeps the tail (events aged out of the ring) so the collection is
* never silently lost — supersede-not-delete. subthreshold_hits records beats
* where support was present but did NOT cross threshold (transient, no shift). */
typedef struct {
GepEvent ev[GEP_EVENT_RING];
int head; /* next write slot */
int filled; /* valid entries (≤ GEP_EVENT_RING) */
int64_t older_count; /* durable events aged past the ring */
int subthreshold_hits; /* transient sub-threshold beats, no shift */
} GepGrounding;
typedef struct {
const char* id;
GepGrounding gr;
int is_belief; /* 1 = subject to propagation (conjecture/belief) */
} GepNode;
/* An edge carries a HEBBIAN WEIGHT (EngramEdge.weight) and a polarity derived
* from its relation: supportive (supports/corroborates/derived-from/hebbian-
* associate) = +1, contradictory (contradicts/refutes) = -1. */
typedef struct {
int from; /* node index */
int to; /* node index */
double weight; /* Hebbian edge weight, [0,1] */
int8_t polarity; /* +1 supportive, -1 contradictory */
} GepEdge;
typedef struct {
GepNode* nodes; int n_nodes;
GepEdge* edges; int n_edges;
} GepGraph;
typedef struct {
int strengthened; /* beliefs that took an LTP event this beat */
int decayed; /* beliefs that took an LTD event this beat */
int subthreshold; /* beliefs with support present but below threshold */
int graduations; /* band-up transitions (conjecture→likely→grounded) */
int demotions; /* band-down transitions */
int isolated; /* belief nodes with ZERO incident edges (sparse graph) */
int starved; /* belief nodes with edges but NO grounded corroborator */
} GepBeatStats;
/* Real-graph note (live measurement 2026-08-15): 70.7% of nodes are isolated,
* connected core ~28%. Grounded edge-propagation is definitionally scoped to
* the connected core — a belief with no grounded neighbor has nothing to
* tether to (anti-delusion gravity). isolated/starved are surfaced as an
* interoceptive signal for the edge-formation / embedding pass (#20) to try to
* connect them; #50 CONSUMES edges, it does not form them. */
/* ── Standing derivation: collection → scalar, recency-weighted ─────────────
* standing = clamp( GEP_BASE + Σ_events sign·mag·age^(-D) , 0, 1 ).
* Exactly the ACT-R base-level shape (Σ t^-d) but sign-carrying so LTD subtracts.
* The value is a pure function of wall-clock time — idempotent, never stored. */
static inline double gep_standing(const GepGrounding* g, int64_t now_ms) {
double raw = 0.0;
for (int i = 0; i < g->filled; i++) {
double age = (double)(now_ms - g->ev[i].ts) / 1000.0;
if (age < 1.0) age = 1.0; /* clock-skew / same-beat → 1s */
raw += (double)g->ev[i].sign * g->ev[i].mag * pow(age, -GEP_DECAY_D);
}
double s = GEP_BASE + raw;
if (s < 0.0) s = 0.0;
if (s > 1.0) s = 1.0;
return s;
}
/* Band label from a standing value. */
static inline const char* gep_band(double standing) {
if (standing >= GEP_GROUNDED_MIN) return "grounded";
if (standing >= GEP_LIKELY_MIN) return "likely";
return "conjecture";
}
static inline int gep_band_rank(double standing) {
if (standing >= GEP_GROUNDED_MIN) return 2;
if (standing >= GEP_LIKELY_MIN) return 1;
return 0;
}
/* Append one grounding event to the ring (append-only; oldest slot recycles,
* its loss counted in older_count so the collection's depth is never faked). */
static inline void gep_append(GepGrounding* g, int64_t ts, int8_t sign,
double mag, uint64_t sig) {
if (g->filled >= GEP_EVENT_RING) g->older_count++;
g->ev[g->head].ts = ts;
g->ev[g->head].sign = sign;
g->ev[g->head].mag = mag;
g->ev[g->head].sig = sig;
g->head = (g->head + 1) % GEP_EVENT_RING;
if (g->filled < GEP_EVENT_RING) g->filled++;
}
/* ── Independence via union-find over corroborators ─────────────────────────
* Two corroborators are the SAME independent source if they are the same node,
* or if a direct edge links them (mutually-derived / echoed through a chain).
* Counting DISTINCT components — not raw corroborator count — is the guard
* against one node echoed N times reading as N independent corroborations. */
#define GEP_MAX_CORR 256
typedef struct {
int node_idx[GEP_MAX_CORR]; /* corroborator node index */
double contrib[GEP_MAX_CORR]; /* weight·standing(c) */
int parent[GEP_MAX_CORR]; /* union-find parent */
int n;
} GepCorrSet;
static int gep_uf_find(GepCorrSet* s, int x) {
while (s->parent[x] != x) { s->parent[x] = s->parent[s->parent[x]]; x = s->parent[x]; }
return x;
}
static void gep_uf_union(GepCorrSet* s, int a, int b) {
int ra = gep_uf_find(s, a), rb = gep_uf_find(s, b);
if (ra != rb) s->parent[ra] = rb;
}
/* index of node_idx within the corroborator set, or -1 */
static int gep_corr_index_of(const GepCorrSet* s, int node_idx) {
for (int i = 0; i < s->n; i++) if (s->node_idx[i] == node_idx) return i;
return -1;
}
/* ── The beat: grounded edge-propagation over one belief node ───────────────
* Returns +1 if an LTP event was appended, -1 if LTD, 0 if sub-threshold/none.
* out_pos/out_neg/out_np/out_nn expose the raw support decomposition for the
* proof ledger (mass and independent-component counts on each polarity). */
static int gep_propagate_node(GepGraph* g, int b, int64_t now_ms,
double* out_pos, double* out_neg,
int* out_np, int* out_nn, int* out_incident) {
GepCorrSet cs; cs.n = 0;
int incident = 0; /* any edge touching b at all — isolation detector */
/* 1. Gather corroborators along incident edges. Anti-delusion gravity:
* only ALREADY-grounded neighbors (standing ≥ LIKELY_MIN) may corroborate.
* Each contributes weight·standing; polarity kept via signed contrib.
* 1-HOP ONLY — no BFS fan-out, so no per-hop breadth explosion. The
* corroborator working set is hard-capped at GEP_MAX_CORR (beam bound
* against hub belief nodes with thousands of incident edges). */
for (int e = 0; e < g->n_edges; e++) {
int c = -1; int8_t pol = 0;
if (g->edges[e].from == b) { c = g->edges[e].to; pol = g->edges[e].polarity; }
else if (g->edges[e].to == b) { c = g->edges[e].from; pol = g->edges[e].polarity; }
else continue;
incident++;
if (c < 0 || c == b) continue;
double cs_standing = gep_standing(&g->nodes[c].gr, now_ms);
if (cs_standing < GEP_LIKELY_MIN) continue; /* ungrounded ⇒ no pull */
double contribution = g->edges[e].weight * cs_standing * (double)pol;
int existing = gep_corr_index_of(&cs, c);
if (existing >= 0) {
/* same corroborator id reached twice (multi-edge echo): keep the
* strongest-magnitude contribution, do NOT add — one source, one vote */
if (fabs(contribution) > fabs(cs.contrib[existing]))
cs.contrib[existing] = contribution;
} else if (cs.n < GEP_MAX_CORR) { /* beam bound against hub belief nodes */
cs.node_idx[cs.n] = c;
cs.contrib[cs.n] = contribution;
cs.parent[cs.n] = cs.n;
cs.n++;
}
}
if (out_incident) *out_incident = incident;
/* 2. Collapse mutually-derived corroborators (an edge between two of them =
* echo chain / shared derivation) into one independent component. */
for (int e = 0; e < g->n_edges; e++) {
int ia = gep_corr_index_of(&cs, g->edges[e].from);
int ib = gep_corr_index_of(&cs, g->edges[e].to);
if (ia >= 0 && ib >= 0) gep_uf_union(&cs, ia, ib);
}
/* 3. Per independent component, take the MAX-magnitude member (echoes don't
* inflate mass either), split by polarity. Convergent INDEPENDENT support
* = sum over components; independence count = number of components. */
double comp_best[GEP_MAX_CORR];
int comp_root[GEP_MAX_CORR]; int n_comp = 0;
for (int i = 0; i < cs.n; i++) {
int r = gep_uf_find(&cs, i);
int slot = -1;
for (int k = 0; k < n_comp; k++) if (comp_root[k] == r) { slot = k; break; }
if (slot < 0) { slot = n_comp++; comp_root[slot] = r; comp_best[slot] = cs.contrib[i]; }
else if (fabs(cs.contrib[i]) > fabs(comp_best[slot])) comp_best[slot] = cs.contrib[i];
}
double pos = 0.0, neg = 0.0; int np = 0, nn = 0;
uint64_t sig = 1469598103934665603ULL; /* FNV offset — signature of the set */
for (int k = 0; k < n_comp; k++) {
if (comp_best[k] > 0.0) { pos += comp_best[k]; np++; }
else if (comp_best[k] < 0.0) { neg += -comp_best[k]; nn++; }
sig = (sig ^ (uint64_t)comp_root[k]) * 1099511628211ULL;
}
if (out_pos) *out_pos = pos; if (out_neg) *out_neg = neg;
if (out_np) *out_np = np; if (out_nn) *out_nn = nn;
double net = pos - neg;
/* 4. Threshold gate. Convergent independent corroboration must clear BOTH a
* MASS threshold (THETA) and an INDEPENDENCE-count threshold (N_MIN).
* The count gate is the independence guard: echoed support collapses to
* one component and never reaches N_MIN however large the raw fan-in. */
if (net > 0.0 && pos >= GEP_THETA && np >= GEP_N_MIN) {
double mag = tanh(GEP_MAG_GAIN * net);
gep_append(&g->nodes[b].gr, now_ms, +1, mag, sig);
return +1;
}
if (net < 0.0 && neg >= GEP_THETA && nn >= GEP_N_MIN) {
double mag = tanh(GEP_MAG_GAIN * (-net));
gep_append(&g->nodes[b].gr, now_ms, -1, mag, sig);
return -1;
}
/* Sub-threshold: support seen but did not cross. Recorded, transient, no
* lasting shift — exactly Will's "recorded in history but transient". */
if (np > 0 || nn > 0) g->nodes[b].gr.subthreshold_hits++;
return 0;
}
/* Run one consolidation/dream beat over every belief node in the graph. */
static inline GepBeatStats gep_beat(GepGraph* g, int64_t now_ms) {
GepBeatStats st; memset(&st, 0, sizeof st);
for (int b = 0; b < g->n_nodes; b++) {
if (!g->nodes[b].is_belief) continue;
int before = gep_band_rank(gep_standing(&g->nodes[b].gr, now_ms));
double pos, neg; int np, nn, incident;
int r = gep_propagate_node(g, b, now_ms, &pos, &neg, &np, &nn, &incident);
int after = gep_band_rank(gep_standing(&g->nodes[b].gr, now_ms));
if (r > 0) st.strengthened++;
else if (r < 0) st.decayed++;
else if (np > 0 || nn > 0) st.subthreshold++;
else if (incident == 0) st.isolated++; /* sparse-graph reality */
else st.starved++; /* has edges, no grounded neighbor */
if (after > before) st.graduations++;
if (after < before) st.demotions++;
}
return st;
}
#endif /* GEP_CORE_H */
@@ -0,0 +1,232 @@
/* ─────────────────────────────────────────────────────────────────────────
* gep_proof.c — PROOF LEDGER for grounded edge-propagation (task #50).
*
* Self-contained. Builds three scenarios on an in-memory GepGraph that mirrors
* the live EngramStore's flat node/edge arrays, runs the consolidation/dream
* beat (gep_beat), and prints RAW grounding before/after for each:
*
* (A) STRENGTHEN — a conjecture + N independent grounded corroborators.
* Grounding grows past threshold, GRADUATES conjecture→
* likely→grounded, then RELAXES when corroboration stops
* (nothing is settled).
* (B) DECAY — a grounded belief meets N independent CONTRADICTORY
* corroborators. Grounding decays grounded→likely→conjecture.
* (C) INDEPENDENCE GUARD — identical fan-in of N=5, weights, and standings.
* C1: 5 DISTINCT independent corroborators → grounds.
* C2: the SAME support echoed (5 mutually-linked / one node
* repeated) → collapses to 1 independent → does NOT.
*
* Build: cc -std=c11 -O2 -o gep_proof gep_proof.c -lm
* Run: ./gep_proof
* ───────────────────────────────────────────────────────────────────────── */
#include <stdio.h>
#include <stdlib.h>
#include "gep_core.h"
#define T0 1786000000000LL /* fixed base time (ms) — deterministic */
#define BEAT_MS 60000LL /* 60s heartbeat cadence (awareness.el) */
/* Seed a node's grounding with a prior LTP event so it reads as already-grounded
* (a member of the grounded core that gravity radiates from). mag→standing:
* standing = GEP_BASE + mag (event at ~now). */
static void seed_grounded(GepNode* n, double mag, int64_t ts) {
memset(&n->gr, 0, sizeof n->gr);
gep_append(&n->gr, ts, +1, mag, 0);
}
/* Re-anchor every NON-belief node (the corroborators/refuters) as a freshly-
* grounded member of the core AT time `now`. These nodes are, by definition,
* sustained members of the grounded core — each has its OWN ongoing
* corroboration — so their standing must be read as grounded at each beat, not
* left to power-law-decay out of the core between beats. The belief-under-test
* is NEVER re-anchored: its trajectory is driven only by the propagation. */
static void anchor_core(GepGraph* g, int64_t now, double mag) {
for (int i = 0; i < g->n_nodes; i++)
if (!g->nodes[i].is_belief) seed_grounded(&g->nodes[i], mag, now);
}
static void print_node(const char* tag, GepNode* n, int64_t now) {
double s = gep_standing(&n->gr, now);
printf(" %-14s standing=%.4f band=%-10s events=%d subthresh=%d\n",
tag, s, gep_band(s), n->gr.filled, n->gr.subthreshold_hits);
}
/* Run one beat over a single belief node b and print the raw support decomposition. */
static void beat_and_report(GepGraph* g, int b, int64_t now, int beatno,
const char* note) {
anchor_core(g, now, 0.80); /* corroborators stay grounded at each beat */
double s_before = gep_standing(&g->nodes[b].gr, now);
int r_before = gep_band_rank(s_before);
double pos, neg; int np, nn, incident;
int r = gep_propagate_node(g, b, now, &pos, &neg, &np, &nn, &incident);
double s_after = gep_standing(&g->nodes[b].gr, now);
int r_after = gep_band_rank(s_after);
const char* action = (r > 0) ? "LTP (strengthen)"
: (r < 0) ? "LTD (decay)"
: (np || nn) ? "sub-threshold (no shift)"
: (incident == 0) ? "isolated (no edges)"
: "starved (no grounded neighbor)";
printf(" beat %d (t=+%llds) %s\n", beatno,
(long long)((now - T0) / 1000), note ? note : "");
printf(" incident_edges=%d pos_mass=%.4f (n_indep=%d) neg_mass=%.4f (n_indep=%d)"
" THETA=%.2f N_MIN=%d\n",
incident, pos, np, neg, nn, (double)GEP_THETA, GEP_N_MIN);
printf(" -> %-26s standing %.4f (%s) -> %.4f (%s)%s\n",
action, s_before, gep_band(s_before), s_after, gep_band(s_after),
(r_after > r_before) ? " [GRADUATED]"
: (r_after < r_before) ? " [DEMOTED]" : "");
}
/* ── Scenario A — STRENGTHEN + graduation + relaxation ───────────────────── */
static void scenario_A(void) {
printf("\n=== SCENARIO A — STRENGTHEN: convergent independent corroboration ===\n");
/* nodes[0] = the conjecture (belief). nodes[1..8] = independent corroborators,
* each already grounded, each tethered to the conjecture by a weak young
* hebbian-associate edge (weight 0.15 = ENGRAM_HEBB_LINK_W0). The corroborators
* are NOT linked to each other → fully independent. */
static GepNode nodes[9];
static GepEdge edges[8];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1; /* bare: standing = BASE */
for (int i = 1; i <= 8; i++) {
nodes[i].id = "corroborator";
seed_grounded(&nodes[i], 0.80, T0); /* standing ≈ 0.90 → grounded core */
}
GepGraph g = { nodes, 9, edges, 0 };
printf(" seed: conjecture has NO grounding events; corroborators pre-grounded.\n");
print_node("conjecture", &nodes[0], T0);
/* Beat 1: 3 independent corroborators have grounded up around the conjecture. */
g.n_edges = 0;
for (int i = 1; i <= 3; i++)
edges[g.n_edges++] = (GepEdge){ 0, i, 0.15, +1 };
beat_and_report(&g, 0, T0, 1, "3 independent grounded corroborators appear");
/* Beat 2: the neighborhood fills in — 5 independent corroborators now. */
g.n_edges = 0;
for (int i = 1; i <= 5; i++)
edges[g.n_edges++] = (GepEdge){ 0, i, 0.15, +1 };
beat_and_report(&g, 0, T0 + BEAT_MS, 2, "neighborhood grows to 5 corroborators");
/* Beat 3: support sustained at 5 (grounding refreshed). */
beat_and_report(&g, 0, T0 + 2 * BEAT_MS, 3, "support sustained (5)");
/* Beats 4-6: corroboration REMOVED (neighbors superseded / no longer ground).
* No new events; the collection ages → standing relaxes. Nothing is settled. */
g.n_edges = 0;
beat_and_report(&g, 0, T0 + 12 * BEAT_MS, 4, "corroboration withdrawn (+10min)");
beat_and_report(&g, 0, T0 + 60 * BEAT_MS, 5, "still withdrawn (+1h)");
beat_and_report(&g, 0, T0 + 240 * BEAT_MS, 6, "still withdrawn (+4h)");
printf(" RESULT: grounding grew automatically past threshold and graduated,\n"
" then relaxed once the independent support stopped — living,\n"
" not a latched flag.\n");
}
/* ── Scenario B — DECAY via accreting contradiction ─────────────────────── */
static void scenario_B(void) {
printf("\n=== SCENARIO B — DECAY: convergent independent CONTRADICTION ===\n");
static GepNode nodes[6];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "belief"; nodes[0].is_belief = 1;
/* Seed the belief as already GROUNDED via a strong prior LTP event. */
seed_grounded(&nodes[0], 0.85, T0);
for (int i = 1; i <= 5; i++) {
nodes[i].id = "refuter";
seed_grounded(&nodes[i], 0.80, T0); /* grounded contradictors */
}
GepGraph g = { nodes, 6, edges, 0 };
printf(" seed: belief pre-grounded by a strong prior LTP event.\n");
print_node("belief", &nodes[0], T0);
/* Contradiction accretes over successive beats: 3 then 5 independent grounded
* refuters (polarity -1). Each beat past threshold appends an LTD event.
* Beat 1 runs at the seed instant so the trajectory starts from grounded. */
g.n_edges = 0;
for (int i = 1; i <= 3; i++) edges[g.n_edges++] = (GepEdge){ 0, i, 0.20, -1 };
beat_and_report(&g, 0, T0, 1, "3 independent contradictions");
g.n_edges = 0;
for (int i = 1; i <= 5; i++) edges[g.n_edges++] = (GepEdge){ 0, i, 0.20, -1 };
beat_and_report(&g, 0, T0 + BEAT_MS, 2, "contradiction broadens to 5");
beat_and_report(&g, 0, T0 + 2 * BEAT_MS, 3, "contradiction sustained (5)");
beat_and_report(&g, 0, T0 + 3 * BEAT_MS, 4, "contradiction sustained (5)");
printf(" RESULT: grounding decayed grounded->likely->conjecture under\n"
" convergent independent contradiction. The door never shut\n"
" on the belief; its history is retained (events keep growing).\n");
}
/* ── Scenario C — INDEPENDENCE GUARD ─────────────────────────────────────── */
static void scenario_C(void) {
printf("\n=== SCENARIO C — INDEPENDENCE GUARD (the load-bearing property) ===\n");
printf(" Both sub-cases: N=5 corroborators, edge weight 0.30, corroborator\n"
" standing ~0.90. ONLY difference: whether the 5 are independent.\n");
/* C1 — 5 DISTINCT INDEPENDENT corroborators (no edges among them). */
{
printf("\n -- C1: 5 DISTINCT independent corroborators --\n");
static GepNode nodes[6];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
for (int i = 1; i <= 5; i++) { nodes[i].id = "corr"; seed_grounded(&nodes[i], 0.80, T0); }
for (int i = 1; i <= 5; i++) edges[i-1] = (GepEdge){ 0, i, 0.30, +1 };
GepGraph g = { nodes, 6, edges, 5 };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "5 independent corroborators (no inter-links)");
}
/* C2 — the SAME support echoed: 5 corroborators that are all mutually linked
* (a derivation clique — one source echoed through the chain). Same fan-in to
* the conjecture, same weights, same standings. Union-find collapses them to
* ONE independent component → below N_MIN → NO strengthening. */
{
printf("\n -- C2: 5 corroborators, but mutually-linked (echo of ONE source) --\n");
static GepNode nodes[6];
static GepEdge edges[9]; /* 5 to conjecture + 4 chaining corr1..corr5 */
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
for (int i = 1; i <= 5; i++) { nodes[i].id = "corr"; seed_grounded(&nodes[i], 0.80, T0); }
int ne = 0;
for (int i = 1; i <= 5; i++) edges[ne++] = (GepEdge){ 0, i, 0.30, +1 };
/* chain corr1-corr2-corr3-corr4-corr5: they are the same source echoed */
for (int i = 1; i <= 4; i++) edges[ne++] = (GepEdge){ i, i+1, 0.30, +1 };
GepGraph g = { nodes, 6, edges, ne };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "5 echoed (mutually-linked) corroborators");
}
/* C3 — degenerate echo: literally ONE corroborator reached by 5 parallel edges. */
{
printf("\n -- C3: ONE corroborator, reached by 5 parallel edges --\n");
static GepNode nodes[2];
static GepEdge edges[5];
memset(nodes, 0, sizeof nodes);
nodes[0].id = "conjecture"; nodes[0].is_belief = 1;
nodes[1].id = "corr"; seed_grounded(&nodes[1], 0.80, T0);
for (int i = 0; i < 5; i++) edges[i] = (GepEdge){ 0, 1, 0.30, +1 };
GepGraph g = { nodes, 2, edges, 5 };
print_node("conjecture", &nodes[0], T0);
beat_and_report(&g, 0, T0, 1, "same node, 5 parallel edges");
}
printf("\n RESULT: identical raw fan-in (5) and mass inputs; C1 grounds because\n"
" the corroboration is INDEPENDENT (5 components), C2/C3 do not\n"
" because it collapses to ONE source. Circular self-reinforcement\n"
" cannot manufacture grounding.\n");
}
int main(void) {
printf("GROUNDED EDGE-PROPAGATION — PROOF LEDGER (task #50)\n");
printf("constants: BASE=%.2f LIKELY_MIN=%.2f GROUNDED_MIN=%.2f "
"N_MIN=%d THETA=%.2f D=%.1f\n",
(double)GEP_BASE, (double)GEP_LIKELY_MIN, (double)GEP_GROUNDED_MIN,
GEP_N_MIN, (double)GEP_THETA, (double)GEP_DECAY_D);
scenario_A();
scenario_B();
scenario_C();
printf("\nDONE.\n");
return 0;
}
@@ -0,0 +1,29 @@
//
// server.route.patch.el GATED route for task #50, for engram/src/server.el.
// NOT APPLIED. Exposes the engram_ground_propagate native over HTTP so the
// soul's consolidation beat can fire one grounded edge-propagation pass.
//
// [1] New handler add beside route_strengthen (server.el ~line 194).
// Mutation (appends grounding events, updates confidence), so it is gated
// on _auth via check_auth_ok, exactly like /api/edges. Persists once after
// the beat the whole point of running propagation as one batched beat
// rather than per-node is to pay the snapshot cost a single time.
fn route_ground_propagate(method: String, path: String, body: String) -> String {
if !check_auth_ok(method, body) { return err_json("unauthorized") }
let tel: String = engram_ground_propagate() // native one beat over the store
let saved: Int = persist_canonical()
return tel // gep_* telemetry JSON straight through
}
// [2] Dispatch register in handle_request (server.el ~line 461, next to the
// /api/strengthen arm):
//
// if str_eq(method, "POST") && (str_eq(clean, "/api/ground/propagate")) {
// return route_ground_propagate(method, clean, body)
// }
//
// [3] Native declaration engram_ground_propagate must be declared as an
// extern runtime builtin (el_runtime.h) and seed-wrapped (el_seed.c /
// el_seed.h __engram_ground_propagate) so the EL side can call it, same as
// engram_strengthen / engram_hebb_drain_json.
+10
View File
@@ -17,6 +17,16 @@
// 4. Append dep to order after all its transitive deps
// 5. Deduplicate: skip already-ordered vessels
// Cross-module forward declarations
// Defined in sibling epm modules; resolved at link time. The `extern fn` decls
// give elc the C prototypes so generated install.c compiles cleanly under strict
// compilers (gcc>=14 / clang) that reject implicit function declarations.
extern fn manifest_name(src: String) -> String // manifest.el
extern fn manifest_deps(src: String) -> String // manifest.el
extern fn registry_token() -> String // registry.el
extern fn registry_find(name: String, version: String) -> String // registry.el
extern fn registry_latest_version(name: String) -> String // registry.el
// Install paths
// packages_dir returns the root directory for installed vessels.
+9
View File
@@ -14,6 +14,15 @@
// EPM_REGISTRY_ORG org name that hosts vessel repos (default: neuron-technologies)
// EPM_TOKEN Gitea personal access token (required for publish)
// Cross-module forward declarations
// These symbols are defined in sibling epm modules or the El runtime and are
// resolved at link time. The `extern fn` decls give elc the C prototype so the
// generated registry.c compiles cleanly under strict compilers (gcc>=14 / clang)
// that reject implicit function declarations. Signature arity must match the
// definition; return/param types are informational (all lower to el_val_t).
extern fn config(key: String) -> String // El runtime builtin
extern fn read_installed() -> String // install.el
// Config helpers
// registry_api_url returns the Gitea API base URL with no trailing slash.
+9
View File
@@ -6,6 +6,15 @@
// Depends on: registry.el (registry_latest_version, registry_find),
// install.el (read_installed, install_vessel, installed_version)
// Cross-module forward declarations
// Defined in sibling epm modules; resolved at link time. The `extern fn` decls
// give elc the C prototypes so generated update.c compiles cleanly under strict
// compilers (gcc>=14 / clang) that reject implicit function declarations.
extern fn read_installed() -> String // install.el
extern fn installed_version(name: String) -> String // install.el
extern fn install_vessel(name: String, version: String) -> Bool // install.el
extern fn registry_latest_version(name: String) -> String // registry.el
// Semver helpers
// semver_part extracts the Nth dot-separated component from a semver string.
@@ -75,6 +75,7 @@ static inline void* el_win_dlsym(void* handle, const char* name) {
#include <direct.h> /* _mkdir */
#define mkdir(path, mode) _mkdir(path) /* POSIX mkdir(path,mode) → _mkdir(path) */
#define timegm _mkgmtime /* UTC tm → time_t */
#define fsync(fd) _commit(fd) /* no fsync() on Windows; _commit() (<io.h>) is the equiv */
/* setenv/unsetenv: not in the Windows CRT; map to _putenv_s / SetEnvironmentVariable. */
static inline int setenv(const char* name, const char* value, int overwrite) {
+387 -51
View File
@@ -82,8 +82,14 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
static _Thread_local int _tl_arena_active = 0;
/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
* Allows serving PNGs and other binary files without strlen truncation. */
static _Thread_local size_t _tl_fs_read_len = 0;
* Allows serving PNGs and other binary files without strlen truncation.
* PAIRED with the buffer pointer it describes: the length may only be applied
* to the exact buffer fs_read returned. Without the pairing, any handler that
* fs_read a file and then WRAPPED it into a larger response had that response
* truncated to the file's length (Content-Length lied AND the send stopped
* short) the safety-contact onboarding trap, 2026-07-17. */
static _Thread_local size_t _tl_fs_read_len = 0;
static _Thread_local const char* _tl_fs_read_buf = NULL;
static void el_arena_track(char* p) {
if (!_tl_arena_active || !p) return;
@@ -101,6 +107,8 @@ static void el_arena_track(char* p) {
void el_request_start(void) {
_tl_arena.count = 0;
_tl_arena_active = 1;
_tl_fs_read_len = 0; /* never let a previous request's file length */
_tl_fs_read_buf = NULL; /* leak into this response's byte accounting */
}
/* Called by http_worker after the El handler returns and the response is sent.
@@ -1484,11 +1492,14 @@ static void http_send_response(int fd, const char* body) {
}
const char* eff_body = is_envelope ? env_body : body;
/* Use the real byte count from fs_read if available (handles binary files
* with embedded null bytes PNG, WOFF2, etc.). Fall back to strlen for
* normal text/JSON responses where _tl_fs_read_len is 0. */
size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
/* Use the real byte count from fs_read ONLY when this body IS the exact
* buffer fs_read returned (binary files with embedded null bytes PNG,
* WOFF2, etc.). Any other body wrapped, enveloped, or derived must be
* measured with strlen, or it is truncated/over-read to the file's size. */
size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf)
? _tl_fs_read_len : strlen(eff_body);
_tl_fs_read_len = 0; /* consume — one-shot per response */
_tl_fs_read_buf = NULL;
int head_only = _tl_http_head_only;
JsonBuf hdrs; jb_init(&hdrs);
@@ -1568,11 +1579,22 @@ static void* http_worker(void* arg) {
const char* rs = EL_CSTR(r);
/* Copy response out BEFORE arena teardown.
* For binary files, _tl_fs_read_len holds the real byte count
* use memcpy instead of strdup so null bytes are preserved. */
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
* use memcpy instead of strdup so null bytes are preserved.
* The stored length applies ONLY when the response IS the exact
* fs_read buffer; a wrapped/derived response must use strlen or
* it gets truncated (or over-read) to the file's length. */
size_t rlen;
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
} else {
rlen = rs ? strlen(rs) : 0;
_tl_fs_read_len = 0; /* hint doesn't describe this body */
_tl_fs_read_buf = NULL;
}
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
} else {
response = el_strdup_persist("el-runtime: no http handler registered");
}
@@ -1822,10 +1844,20 @@ static void* http_worker_v2(void* arg) {
el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
const char* rs = EL_CSTR(r);
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
/* Same pairing rule as the v1 worker: the fs_read length is only
* trustworthy for the exact buffer fs_read returned. */
size_t rlen;
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
} else {
rlen = rs ? strlen(rs) : 0;
_tl_fs_read_len = 0; /* hint doesn't describe this body */
_tl_fs_read_buf = NULL;
}
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
el_release(hmap);
} else {
response = el_strdup_persist(
@@ -1963,8 +1995,9 @@ void http_serve_async(el_val_t port, el_val_t handler) {
int sock = socket(AF_INET6, SOCK_STREAM, 0);
if (sock < 0) { perror("socket"); return; }
int yes = 1; int no = 0;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, &no, sizeof(no));
/* Win32/mingw setsockopt takes optval as (const char*); the cast is portable on POSIX too. */
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&yes, sizeof(yes));
setsockopt(sock, IPPROTO_IPV6, IPV6_V6ONLY, (const char*)&no, sizeof(no));
struct sockaddr_in6 addr;
memset(&addr, 0, sizeof(addr));
addr.sin6_family = AF_INET6;
@@ -2023,6 +2056,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
el_val_t fs_read(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
_tl_fs_read_len = 0;
_tl_fs_read_buf = NULL;
if (!path) return el_wrap_str(el_strdup(""));
FILE* f = fopen(path, "rb");
if (!f) return el_wrap_str(el_strdup(""));
@@ -2034,6 +2068,7 @@ el_val_t fs_read(el_val_t pathv) {
size_t got = fread(buf, 1, (size_t)sz, f);
buf[got] = '\0';
_tl_fs_read_len = got; /* store real byte count for binary-safe send */
_tl_fs_read_buf = buf; /* ...valid ONLY for this exact buffer */
fclose(f);
return el_wrap_str(buf);
}
@@ -3576,8 +3611,10 @@ el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
/* Clear fs_read binary-length hint — result is a fresh null-terminated
* string, not the raw file bytes, so Content-Length must use strlen. */
* string, not the raw file bytes, so Content-Length must use strlen.
* (Kept although the pointer pairing now makes this redundant.) */
_tl_fs_read_len = 0;
_tl_fs_read_buf = NULL;
if (!p) return el_wrap_str(el_strdup(""));
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
@@ -6895,6 +6932,243 @@ static int engram_rank_cmp(const void* a, const void* b) {
return 0;
}
/* ══════════════════════════════════════════════════════════════════════════
* SEMANTIC SEARCH LAYER nomic-embed-text via Ollama /api/embeddings
*
* Augments the lexical (istr_contains) matcher with dense-vector retrieval.
* Node content and the query are embedded through a local Ollama server;
* nodes are ranked by cosine similarity and UNIONED with lexical hits. This
* lets a paraphrase query surface a node whose words never appear in it.
*
* DEGRADABLE BY DESIGN. The whole layer is gated on HAVE_CURL plus a one-shot
* runtime probe of the embedding endpoint. If curl is not compiled in, or
* Ollama is unreachable, or ENGRAM_SEMANTIC=0, every entry point returns
* "no semantic signal" and callers fall back to pure lexical behaviour
* byte-for-byte the pre-existing search.
*
* CACHE. Node embeddings are computed lazily on first use and cached in
* process memory keyed by node id, with an FNV-1a content hash for
* invalidation (edited content re-embeds). The query is embedded once per
* search call. This is what "avoid re-embedding the whole graph every query"
* buys us: a warm cache serves cosine from RAM. (A cold process still pays
* O(N) embed calls the first time each node is scanned persisting the cache
* to a snapshot sidecar is the documented next step, not done here.)
*
* nomic task prefixes ("search_query:" / "search_document:") are applied
* because nomic-embed-text is trained with them; they materially improve
* retrieval separation (empirically: paraphrase 0.72 vs distractors <0.48).
*
* ENV:
* ENGRAM_SEMANTIC "0" disables; unset/other = auto-probe
* ENGRAM_EMBED_URL default http://localhost:11434/api/embeddings
* ENGRAM_EMBED_MODEL default nomic-embed-text
* ENGRAM_SEMANTIC_MIN cosine threshold for a pure-semantic match (def 0.6)
* */
static double engram_semantic_min(void) {
static double v = -1.0;
if (v >= 0.0) return v;
const char* s = getenv("ENGRAM_SEMANTIC_MIN");
double d = 0.6;
if (s && *s) { char* e = NULL; double t = strtod(s, &e);
if (e != s && t >= 0.0 && t <= 1.0) d = t; }
v = d; return v;
}
#ifdef HAVE_CURL
typedef struct { char* id; uint64_t hash; float* vec; int dim; } EngramEmbEntry;
static EngramEmbEntry* g_emb_items = NULL;
static int64_t g_emb_count = 0, g_emb_cap = 0;
static int g_emb_state = 0; /* 0=unprobed, 1=available, -1=disabled */
static uint64_t engram_fnv1a(const char* s) {
uint64_t h = 1469598103934665603ULL;
if (s) for (const unsigned char* p = (const unsigned char*)s; *p; p++) {
h ^= *p; h *= 1099511628211ULL;
}
return h;
}
/* Parse "embedding":[f,f,...] from an Ollama response. malloc'd vec, or NULL. */
static float* engram_parse_embedding(const char* json, int* out_dim) {
if (!json) return NULL;
const char* p = strstr(json, "\"embedding\"");
if (!p) return NULL;
p = strchr(p, '[');
if (!p) return NULL;
p++;
int cap = 1024, n = 0;
float* v = malloc((size_t)cap * sizeof(float));
if (!v) return NULL;
while (*p && *p != ']') {
while (*p == ' ' || *p == '\t' || *p == '\n' || *p == '\r' || *p == ',') p++;
if (*p == ']' || !*p) break;
char* e = NULL;
double d = strtod(p, &e);
if (e == p) break;
if (n >= cap) { cap *= 2; float* nv = realloc(v, (size_t)cap * sizeof(float));
if (!nv) { free(v); return NULL; } v = nv; }
v[n++] = (float)d;
p = e;
}
if (n == 0) { free(v); return NULL; }
*out_dim = n;
return v;
}
/* JSON-escape src into a malloc'd buffer (no surrounding quotes). */
static char* engram_json_escape(const char* src) {
if (!src) src = "";
size_t n = strlen(src);
char* out = malloc(n * 2 + 1);
if (!out) return NULL;
size_t j = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)src[i];
if (c == '"') { out[j++] = '\\'; out[j++] = '"'; }
else if (c == '\\') { out[j++] = '\\'; out[j++] = '\\'; }
else if (c == '\n') { out[j++] = '\\'; out[j++] = 'n'; }
else if (c == '\r') { out[j++] = '\\'; out[j++] = 'r'; }
else if (c == '\t') { out[j++] = '\\'; out[j++] = 't'; }
else if (c < 0x20) { /* drop other control bytes */ }
else { out[j++] = (char)c; }
}
out[j] = '\0';
return out;
}
/* Embed `prefix+text` via Ollama. Returns malloc'd vec (caller frees), or NULL. */
static float* engram_embed_raw(const char* prefix, const char* text, int* out_dim) {
if (!text) return NULL;
const char* url = getenv("ENGRAM_EMBED_URL");
if (!url || !*url) url = "http://localhost:11434/api/embeddings";
const char* model = getenv("ENGRAM_EMBED_MODEL");
if (!model || !*model) model = "nomic-embed-text";
/* Bound content length to keep latency/memory sane on huge nodes. */
char* trunc = NULL;
size_t maxlen = 8192;
if (strlen(text) > maxlen) {
trunc = malloc(maxlen + 1);
if (trunc) { memcpy(trunc, text, maxlen); trunc[maxlen] = '\0'; text = trunc; }
}
char* esc_prefix = engram_json_escape(prefix ? prefix : "");
char* esc = engram_json_escape(text);
free(trunc);
if (!esc || !esc_prefix) { free(esc); free(esc_prefix); return NULL; }
size_t blen = strlen(esc) + strlen(esc_prefix) + strlen(model) + 64;
char* body = malloc(blen);
if (!body) { free(esc); free(esc_prefix); return NULL; }
snprintf(body, blen, "{\"model\":\"%s\",\"prompt\":\"%s%s\"}", model, esc_prefix, esc);
free(esc); free(esc_prefix);
CURL* c = curl_easy_init();
if (!c) { free(body); return NULL; }
HttpBuf rb; httpbuf_init(&rb);
struct curl_slist* h = curl_slist_append(NULL, "Content-Type: application/json");
char errbuf[CURL_ERROR_SIZE]; errbuf[0] = '\0';
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, http_write_cb);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &rb);
curl_easy_setopt(c, CURLOPT_POST, 1L);
curl_easy_setopt(c, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(c, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
curl_easy_setopt(c, CURLOPT_HTTPHEADER, h);
curl_easy_setopt(c, CURLOPT_TIMEOUT_MS, el_http_timeout_ms());
curl_easy_setopt(c, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(c, CURLOPT_ERRORBUFFER, errbuf);
CURLcode rc = curl_easy_perform(c);
curl_slist_free_all(h);
curl_easy_cleanup(c);
free(body);
if (rc != CURLE_OK) { free(rb.data); return NULL; }
float* v = engram_parse_embedding(rb.data, out_dim);
free(rb.data);
return v;
}
/* One-shot probe: is semantic search available? Caches the verdict. */
static int engram_semantic_enabled(void) {
if (g_emb_state != 0) return g_emb_state == 1;
const char* s = getenv("ENGRAM_SEMANTIC");
if (s && strcmp(s, "0") == 0) { g_emb_state = -1; return 0; }
int dim = 0;
float* v = engram_embed_raw("search_query: ", "probe", &dim);
if (v && dim > 0) { free(v); g_emb_state = 1; return 1; }
free(v);
g_emb_state = -1; return 0;
}
/* Embed the query. Returns malloc'd vec (caller frees), or NULL if semantic off. */
static float* engram_embed_query(const char* q, int* dim) {
if (!engram_semantic_enabled()) return NULL;
if (!q || !*q) return NULL;
return engram_embed_raw("search_query: ", q, dim);
}
/* Cached node embedding. Returns a pointer OWNED BY THE CACHE — do not free. */
static const float* engram_node_vec(EngramNode* n, int* out_dim) {
if (!n || !n->id) return NULL;
uint64_t h = engram_fnv1a(n->content);
for (int64_t i = 0; i < g_emb_count; i++) {
if (g_emb_items[i].id && strcmp(g_emb_items[i].id, n->id) == 0) {
if (g_emb_items[i].hash == h && g_emb_items[i].vec) {
*out_dim = g_emb_items[i].dim; return g_emb_items[i].vec;
}
/* content changed → re-embed in place */
int dim = 0;
float* v = engram_embed_raw("search_document: ", n->content ? n->content : "", &dim);
if (!v) return NULL;
free(g_emb_items[i].vec);
g_emb_items[i].vec = v; g_emb_items[i].dim = dim; g_emb_items[i].hash = h;
*out_dim = dim; return v;
}
}
int dim = 0;
float* v = engram_embed_raw("search_document: ", n->content ? n->content : "", &dim);
if (!v) return NULL;
if (g_emb_count >= g_emb_cap) {
int64_t nc = g_emb_cap ? g_emb_cap * 2 : 256;
EngramEmbEntry* ni = realloc(g_emb_items, (size_t)nc * sizeof(EngramEmbEntry));
if (!ni) { free(v); return NULL; }
g_emb_items = ni; g_emb_cap = nc;
}
g_emb_items[g_emb_count].id = strdup(n->id);
g_emb_items[g_emb_count].hash = h;
g_emb_items[g_emb_count].vec = v;
g_emb_items[g_emb_count].dim = dim;
g_emb_count++;
*out_dim = dim; return v;
}
static double engram_cosine(const float* a, const float* b, int dim) {
double dot = 0, na = 0, nb = 0;
for (int i = 0; i < dim; i++) { dot += (double)a[i] * b[i];
na += (double)a[i] * a[i];
nb += (double)b[i] * b[i]; }
if (na <= 0 || nb <= 0) return 0.0;
return dot / (sqrt(na) * sqrt(nb));
}
/* Cosine of node n against the query vector; 0 if unavailable / dim mismatch. */
static double engram_node_cosine(EngramNode* n, const float* qvec, int qdim) {
if (!qvec || qdim <= 0) return 0.0;
int ndim = 0;
const float* nv = engram_node_vec(n, &ndim);
if (!nv || ndim != qdim) return 0.0;
return engram_cosine(qvec, nv, qdim);
}
#else /* !HAVE_CURL — semantic layer compiled out; callers stay pure-lexical.
* Only the two boundary functions the always-compiled search/activate
* code calls are stubbed; the query embed always yields NULL so every
* cosine is 0 and every caller collapses to lexical-only. */
static float* engram_embed_query(const char* q, int* dim) { (void)q; (void)dim; return NULL; }
static double engram_node_cosine(EngramNode* n, const float* qvec, int qdim) {
(void)n; (void)qvec; (void)qdim; return 0.0;
}
#endif /* HAVE_CURL */
el_val_t engram_search(el_val_t query, el_val_t limit) {
EngramStore* g = engram_get();
const char* q = EL_CSTR(query);
@@ -6905,8 +7179,15 @@ el_val_t engram_search(el_val_t query, el_val_t limit) {
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
if (ntok == 0) return lst;
/* Semantic augmentation: embed the query once; a node is a hit if it covers
* >=1 query token (tokenized-lexical, #66) OR its cosine clears the
* threshold (#67). qvec is NULL (cosine 0) when semantic is unavailable
* pure tokenized-lexical, byte-identical to the lexical-only behaviour. */
int qdim = 0;
float* qvec = engram_embed_query(q, &qdim);
double sem_min = engram_semantic_min();
EngramRankEntry* hits = malloc((size_t)g->node_count * sizeof(EngramRankEntry));
if (!hits) return lst;
if (!hits) { free(qvec); return lst; }
int64_t nhits = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
@@ -6916,20 +7197,24 @@ el_val_t engram_search(el_val_t query, el_val_t limit) {
* + engram_compile_layered_json that's the legitimate path. */
if (engram_layer_is_transparent(n->layer_id)) continue;
int sc = engram_node_match_score(n, toks, ntok);
if (sc > 0) {
double sem = qvec ? engram_node_cosine(n, qvec, qdim) : 0.0;
if (sc > 0 || sem >= sem_min) {
hits[nhits].idx = i;
hits[nhits].score = sc;
hits[nhits].salience = n->salience;
nhits++;
}
}
/* Rank by distinct tokens matched (desc) then salience (desc), then cap. */
/* Rank by distinct tokens matched (desc) then salience (desc), then cap.
* Pure-semantic hits (token score 0) sort after every lexical hit a
* lexical semantic union with lexical precedence. */
qsort(hits, (size_t)nhits, sizeof(EngramRankEntry), engram_rank_cmp);
int64_t end = nhits < lim ? nhits : lim;
for (int64_t k = 0; k < end; k++) {
lst = el_list_append(lst, engram_node_to_map(&g->nodes[hits[k].idx]));
}
free(hits);
free(qvec);
return lst;
}
@@ -7279,21 +7564,31 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
if (!seeds) {
free(best_bg); free(best_hops); free(reached); return out;
}
/* Tokenize once: a node seeds if it matches ANY query token, and its seed
* activation is scaled by token coverage (fraction of distinct query
* tokens it contains) so a node matching all words seeds more strongly
* than one matching a single word. Single-word queries coverage 1.0,
* identical to the prior whole-query behavior. */
/* Tokenized + semantic seeding: a node seeds if it covers >=1 query token
* (tokenized-lexical, #66) OR its cosine clears the threshold (#67). A
* lexical seed's activation is scaled by token coverage (fraction of
* distinct query tokens covered) so a node matching all words seeds more
* strongly than one matching a single word; single-word queries coverage
* 1.0. A pure-semantic seed (no token match) is instead down-weighted by
* its cosine so paraphrase matches spread without overpowering exact seeds.
* q_vec is NULL (cosine 0) when semantic is unavailable the seed set is
* exactly the tokenized-lexical one. q_vec is freed right after this loop
* so the many downstream early-returns need no cleanup change. */
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
int q_dim = 0;
float* q_vec = engram_embed_query(q, &q_dim);
double q_sem_min = engram_semantic_min();
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
int sc = engram_node_match_score(n, toks, ntok);
if (sc > 0) {
double sem = q_vec ? engram_node_cosine(n, q_vec, q_dim) : 0.0;
if (sc > 0 || sem >= q_sem_min) {
double tdecay = engram_temporal_decay(n, now_ms);
double dampen = engram_activation_dampen(n);
double cover = ntok > 0 ? (double)sc / (double)ntok : 1.0;
double act = n->salience * tdecay * dampen * cover;
double act = n->salience * tdecay * dampen;
if (sc > 0) act *= (ntok > 0 ? (double)sc / (double)ntok : 1.0);
else act *= sem; /* pure-semantic seed: down-weight by cosine */
seeds[seed_count].idx = i;
seeds[seed_count].act = act;
seeds[seed_count].created_at = n->created_at;
@@ -7303,6 +7598,7 @@ el_val_t engram_activate(el_val_t query, el_val_t depth) {
reached[i] = 1;
}
}
free(q_vec);
/* Compute mean seed created_at for temporal proximity bonus. */
int64_t seed_epoch = 0;
if (seed_count > 0) {
@@ -7854,6 +8150,35 @@ el_val_t engram_get_node_json(el_val_t id) {
return el_wrap_str(jb_finish(&b));
}
/* engram_get_node_by_label — find the first node whose label field exactly
* matches the given string. Returns the node as a JSON object string, or "{}"
* if no match is found.
*
* Used by chat.el to retrieve well-known nodes (e.g. "conv:history",
* "session:summary") by their stable label rather than by ID, which is immune
* to vector index drift across restarts.
*
* Exact match (strcmp, not istr_contains) because labels like "conv:history"
* must not collide with nodes whose content happens to contain that substring.
*
* Backported verbatim (idiom-adapted to jb_finish) from release runtime
* v1.0.0-20260501 to unblock the soul regen link: chat.el references this
* native but the current runtime lacked its definition. */
el_val_t engram_get_node_by_label(el_val_t label) {
const char* lbl = EL_CSTR(label);
if (!lbl || !*lbl) return el_wrap_str(el_strdup("{}"));
EngramStore* g = engram_get();
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (n->label && strcmp(n->label, lbl) == 0) {
JsonBuf b; jb_init(&b);
engram_emit_node_json(&b, n);
return el_wrap_str(jb_finish(&b));
}
}
return el_wrap_str(el_strdup("{}"));
}
el_val_t engram_search_json(el_val_t query, el_val_t limit) {
EngramStore* g = engram_get();
const char* q = EL_CSTR(query);
@@ -7861,39 +8186,50 @@ el_val_t engram_search_json(el_val_t query, el_val_t limit) {
if (lim <= 0) lim = 100;
JsonBuf b; jb_init(&b);
jb_putc(&b, '[');
int first = 1;
if (q && *q) {
if (q && *q && g->node_count > 0) {
/* Collect candidates from the UNION of tokenized-lexical and semantic
* matches, score each, rank by score, emit the top `lim`. A node is a
* candidate if it covers >=1 query token (tokenized-lexical, #66) OR its
* query cosine clears the threshold (#67). Lexical score is the distinct
* token count (>=1), so any lexical hit outranks a pure-semantic hit
* (cosine < 1); pure-semantic hits are scored by cosine alone. When
* semantic is unavailable qvec is NULL, sem is 0, only tokenized-lexical
* hits are collected, and the stable insertion sort preserves order. */
char toks[ENGRAM_MAX_QTOKENS][ENGRAM_QTOK_LEN];
int ntok = engram_tokenize_query(q, toks, ENGRAM_MAX_QTOKENS);
if (ntok > 0) {
EngramRankEntry* hits =
malloc((size_t)g->node_count * sizeof(EngramRankEntry));
if (hits) {
int64_t nhits = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
/* Filter transparent layers — same as engram_search. */
if (engram_layer_is_transparent(n->layer_id)) continue;
int sc = engram_node_match_score(n, toks, ntok);
if (sc > 0) {
hits[nhits].idx = i;
hits[nhits].score = sc;
hits[nhits].salience = n->salience;
nhits++;
}
int qdim = 0;
float* qvec = engram_embed_query(q, &qdim);
double sem_min = engram_semantic_min();
typedef struct { int64_t idx; double score; } Cand;
Cand* cand = malloc((size_t)g->node_count * sizeof(Cand));
if (cand) {
int64_t nc = 0;
for (int64_t i = 0; i < g->node_count; i++) {
EngramNode* n = &g->nodes[i];
if (engram_layer_is_transparent(n->layer_id)) continue;
int sc = engram_node_match_score(n, toks, ntok);
double sem = qvec ? engram_node_cosine(n, qvec, qdim) : 0.0;
if (sc > 0 || sem >= sem_min) {
cand[nc].idx = i;
cand[nc].score = (double)sc + sem;
nc++;
}
/* Rank by distinct tokens matched (desc) then salience (desc). */
qsort(hits, (size_t)nhits, sizeof(EngramRankEntry),
engram_rank_cmp);
int64_t end = nhits < lim ? nhits : lim;
for (int64_t k = 0; k < end; k++) {
if (!first) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[hits[k].idx]);
first = 0;
}
free(hits);
}
/* Insertion sort by score desc; stable for equal scores. */
for (int64_t i = 1; i < nc; i++) {
Cand k = cand[i]; int64_t j = i - 1;
while (j >= 0 && cand[j].score < k.score) { cand[j + 1] = cand[j]; j--; }
cand[j + 1] = k;
}
int first = 1;
for (int64_t i = 0; i < nc && i < lim; i++) {
if (!first) jb_putc(&b, ',');
engram_emit_node_json(&b, &g->nodes[cand[i].idx]);
first = 0;
}
free(cand);
}
free(qvec);
}
jb_putc(&b, ']');
return el_wrap_str(jb_finish(&b));
+1
View File
@@ -632,6 +632,7 @@ el_val_t engram_load(el_val_t path);
* can pass results straight through without round-tripping ElList/ElMap
* through json_stringify. */
el_val_t engram_get_node_json(el_val_t id);
el_val_t engram_get_node_by_label(el_val_t label);
el_val_t engram_search_json(el_val_t query, el_val_t limit);
el_val_t engram_scan_nodes_json(el_val_t limit, el_val_t offset);
el_val_t engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+1
View File
@@ -1072,6 +1072,7 @@ el_val_t __engram_save(el_val_t path) { return engram_save
el_val_t __engram_load(el_val_t path) { return engram_load(path); }
el_val_t __engram_get_node_json(el_val_t id) { return engram_get_node_json(id); }
el_val_t __engram_get_node_by_label(el_val_t label) { return engram_get_node_by_label(label); }
el_val_t __engram_search_json(el_val_t query, el_val_t limit) {
return engram_search_json(query, limit);
+1
View File
@@ -226,6 +226,7 @@ el_val_t __engram_activate(el_val_t query, el_val_t depth);
el_val_t __engram_save(el_val_t path);
el_val_t __engram_load(el_val_t path);
el_val_t __engram_get_node_json(el_val_t id);
el_val_t __engram_get_node_by_label(el_val_t label);
el_val_t __engram_search_json(el_val_t query, el_val_t limit);
el_val_t __engram_scan_nodes_json(el_val_t limit, el_val_t offset);
el_val_t __engram_scan_nodes_by_type_json(el_val_t node_type, el_val_t limit, el_val_t offset);
+1
View File
@@ -2670,6 +2670,7 @@ fn builtin_arity(name: String) -> Int {
if str_eq(name, "engram_save") { return 1 }
if str_eq(name, "engram_load") { return 1 }
if str_eq(name, "engram_get_node_json") { return 1 }
if str_eq(name, "engram_get_node_by_label") { return 1 }
if str_eq(name, "engram_search_json") { return 2 }
if str_eq(name, "engram_scan_nodes_json") { return 2 }
if str_eq(name, "engram_neighbors_json") { return 3 }
@@ -0,0 +1,186 @@
#ifndef EL_PLATFORM_WIN_H
#define EL_PLATFORM_WIN_H
/*
* el_platform_win.h — Windows OS-boundary shim for el_runtime.c.
*
* Branch: feat/windows-el-runtime. Included ONLY when _WIN32 is defined; the POSIX build is
* untouched. Goal: let el_runtime.c (a BSD-sockets / dlfcn / fork host) compile and link with
* mingw-w64 into a native neuron.exe, with no behavioural change to the Linux/macOS build.
*
* What it maps:
* - sockets : winsock2 (same call names: socket/bind/listen/accept/recv/send/setsockopt).
* Sockets close with closesocket() (see el_closesocket), and the stack must be
* started once with WSAStartup — done automatically via a load-time constructor.
* - dlsym : el_runtime.c uses dlsym(RTLD_DEFAULT, name) to resolve callback/tool symbols
* exported by the main module. Windows equivalent: GetProcAddress on the process
* module. Link the soul with -Wl,--export-all-symbols so the symbols are findable.
* - popen : mapped to _popen/_pclose.
* - threads : UNCHANGED. mingw-w64 ships winpthreads, so <pthread.h> + -lpthread just work.
*/
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#include <io.h>
#include <process.h>
/* Portable headers mingw-w64 provides (verified present). */
#include <stdarg.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h> /* strcasecmp */
#include <ctype.h>
#include <math.h>
#include <time.h>
#include <sys/time.h> /* mingw-w64 provides gettimeofday here */
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
/* ── socket close ─────────────────────────────────────────────────────────── */
/* Winsock closes sockets with closesocket(), not close() (close() is for file fds). The POSIX
build defines the same helper as close() so the call sites are identical across platforms. */
static inline int el_closesocket(SOCKET s) { return closesocket(s); }
/* ── setsockopt optval type ───────────────────────────────────────────────── */
/* Winsock's setsockopt takes optval as (const char*); POSIX takes (const void*), so el_runtime.c
passes &int directly. GCC 14+ makes that an error under -Wincompatible-pointer-types. Wrap it so
the runtime's POSIX-style call sites compile unchanged (defined before the macro so the wrapper
itself resolves to the real winsock setsockopt). */
static inline int el_setsockopt(SOCKET s, int level, int optname, const void* optval, int optlen) {
return setsockopt(s, level, optname, (const char*)optval, optlen);
}
#define setsockopt(s, l, o, v, n) el_setsockopt((s), (l), (o), (v), (int)(n))
/* ── winsock init (once, at load) ─────────────────────────────────────────── */
static void el__win_net_init(void) {
static int inited = 0;
if (!inited) { WSADATA w; WSAStartup(MAKEWORD(2, 2), &w); inited = 1; }
}
__attribute__((constructor)) static void el__win_ctor(void) { el__win_net_init(); }
/* ── dlsym → GetProcAddress ───────────────────────────────────────────────── */
#ifndef RTLD_DEFAULT
#define RTLD_DEFAULT ((void*)0)
#endif
static inline void* el_win_dlsym(void* handle, const char* name) {
(void)handle;
return (void*)(uintptr_t)GetProcAddress(GetModuleHandleA(NULL), name);
}
#define dlsym(h, n) el_win_dlsym((h), (n))
/* ── popen / pclose ───────────────────────────────────────────────────────── */
#define popen _popen
#define pclose _pclose
/* ── misc POSIX → Win32 shims ─────────────────────────────────────────────── */
#include <direct.h> /* _mkdir */
#define mkdir(path, mode) _mkdir(path) /* POSIX mkdir(path,mode) → _mkdir(path) */
#define timegm _mkgmtime /* UTC tm → time_t */
/* setenv/unsetenv: not in the Windows CRT; map to _putenv_s / SetEnvironmentVariable. */
static inline int setenv(const char* name, const char* value, int overwrite) {
(void)overwrite;
return _putenv_s(name, value ? value : "");
}
static inline int unsetenv(const char* name) {
/* _putenv_s(name, "") sets VAR="" rather than removing it.
* SetEnvironmentVariableA(name, NULL) truly deletes it from the Win32
* env block; then we sync the CRT cache with _putenv("NAME="). */
SetEnvironmentVariableA(name, NULL);
size_t len = strlen(name);
char *buf = (char*)malloc(len + 2);
if (!buf) return -1;
memcpy(buf, name, len);
buf[len] = '=';
buf[len + 1] = '\0';
_putenv(buf);
free(buf);
return 0;
}
/* nanosleep — not available in MSVC/UCRT; approximate with Sleep(). */
static inline int el_nanosleep(const struct timespec *req, struct timespec *rem) {
(void)rem;
DWORD ms = (DWORD)((req->tv_sec * 1000ULL) + (req->tv_nsec / 1000000ULL));
Sleep(ms ? ms : 1);
return 0;
}
#define nanosleep(req, rem) el_nanosleep((req), (rem))
/* localtime_r/gmtime_r: Windows offers localtime_s/gmtime_s with reversed arg order. */
static inline struct tm* localtime_r(const time_t* t, struct tm* out) {
return localtime_s(out, t) == 0 ? out : (struct tm*)0;
}
static inline struct tm* gmtime_r(const time_t* t, struct tm* out) {
return gmtime_s(out, t) == 0 ? out : (struct tm*)0;
}
/* ── libcurl: degradable stubs for the curl-less Windows build ─────────────── */
/* The curl-less validation build (WITH_CURL=0) links no libcurl. el_runtime.c uses libcurl
* unconditionally for its HTTP client / LLM layer; these stubs let it compile and link so the
* runtime, HTTP *server*, graph and memory work natively on Windows. Live outbound HTTP/LLM calls
* degrade to a runtime error (curl_easy_perform returns an error) — matching the documented
* curl-less contract. When HAVE_CURL is defined (WITH_CURL=1) the real <curl/curl.h> is used and
* this whole block is compiled out. POSIX never sees this header, so the POSIX build is untouched. */
#ifndef HAVE_CURL
typedef void CURL;
typedef int CURLcode;
#define CURLE_OK 0
#define CURLE_HTTP_RETURNED_ERROR 22
#define CURL_ERROR_SIZE 256
/* Option ids: values are irrelevant to the no-op setopt below; kept distinct for readability. */
#define CURLOPT_URL 10002
#define CURLOPT_WRITEFUNCTION 20011
#define CURLOPT_WRITEDATA 10001
#define CURLOPT_POSTFIELDS 10015
#define CURLOPT_POSTFIELDSIZE 120
#define CURLOPT_POST 47
#define CURLOPT_HTTPHEADER 10023
#define CURLOPT_TIMEOUT_MS 155
#define CURLOPT_NOSIGNAL 99
#define CURLOPT_USERAGENT 10018
#define CURLOPT_FOLLOWLOCATION 52
#define CURLOPT_ERRORBUFFER 10010
#define CURLOPT_CUSTOMREQUEST 10036
#define CURLOPT_FAILONERROR 45
struct curl_slist { char* data; struct curl_slist* next; };
static inline struct curl_slist* curl_slist_append(struct curl_slist* list, const char* s) {
struct curl_slist* node = (struct curl_slist*)malloc(sizeof(struct curl_slist));
if (!node) return list;
node->data = s ? strdup(s) : NULL;
node->next = NULL;
if (!list) return node;
struct curl_slist* p = list;
while (p->next) p = p->next;
p->next = node;
return list;
}
static inline void curl_slist_free_all(struct curl_slist* list) {
while (list) { struct curl_slist* n = list->next; free(list->data); free(list); list = n; }
}
static inline CURL* curl_easy_init(void) { return (CURL*)malloc(1); }
static inline CURLcode curl_easy_setopt(CURL* h, int opt, ...) { (void)h; (void)opt; return CURLE_OK; }
static inline CURLcode curl_easy_perform(CURL* h) { (void)h; return 7 /* CURLE_COULDNT_CONNECT */; }
static inline void curl_easy_cleanup(CURL* h) { free(h); }
static inline const char* curl_easy_strerror(CURLcode c) {
(void)c; return "libcurl not built in (curl-less build)";
}
#endif /* !HAVE_CURL */
#endif /* EL_PLATFORM_WIN_H */
+114 -22
View File
@@ -21,6 +21,10 @@
#include "el_runtime.h"
#ifdef _WIN32
/* Windows OS-boundary shim (winsock/dlsym/popen). Threading stays on <pthread.h> (winpthreads). */
#include "el_platform_win.h"
#else
#include <stdarg.h>
#include <strings.h> /* strcasecmp */
#include <stdint.h>
@@ -42,7 +46,16 @@
#include <dirent.h>
#include <errno.h>
#include <pthread.h>
/* On POSIX, sockets close with the same close() as files; el_platform_win.h supplies the Windows
variant. Defined here so the socket call sites are identical across platforms. */
static inline int el_closesocket(int s) { return close(s); }
#endif
/* libcurl: present on POSIX and on the WITH_CURL Windows build; absent on the curl-less Windows
validation build, where el_platform_win.h supplies degradable stubs. On POSIX (_WIN32 undefined)
this is always taken, so the POSIX build is unchanged. */
#if !defined(_WIN32) || defined(HAVE_CURL)
#include <curl/curl.h>
#endif
/* ── Internal allocators ─────────────────────────────────────────────────── */
@@ -71,8 +84,14 @@ static _Thread_local ElArena _tl_arena = {NULL, 0, 0};
static _Thread_local int _tl_arena_active = 0;
/* Binary-safe fs_read length — set by fs_read, consumed by http_send_response.
* Allows serving PNGs and other binary files without strlen truncation. */
static _Thread_local size_t _tl_fs_read_len = 0;
* Allows serving PNGs and other binary files without strlen truncation.
* PAIRED with the buffer pointer it describes: the length may only be applied
* to the exact buffer fs_read returned. Without the pairing, any handler that
* fs_read a file and then WRAPPED it into a larger response had that response
* truncated to the file's length (Content-Length lied AND the send stopped
* short) the safety-contact onboarding trap, 2026-07-17. */
static _Thread_local size_t _tl_fs_read_len = 0;
static _Thread_local const char* _tl_fs_read_buf = NULL;
static void el_arena_track(char* p) {
if (!_tl_arena_active || !p) return;
@@ -90,6 +109,8 @@ static void el_arena_track(char* p) {
void el_request_start(void) {
_tl_arena.count = 0;
_tl_arena_active = 1;
_tl_fs_read_len = 0; /* never let a previous request's file length */
_tl_fs_read_buf = NULL; /* leak into this response's byte accounting */
}
/* Called by http_worker after the El handler returns and the response is sent.
@@ -1403,11 +1424,14 @@ static void http_send_response(int fd, const char* body) {
}
const char* eff_body = is_envelope ? env_body : body;
/* Use the real byte count from fs_read if available (handles binary files
* with embedded null bytes PNG, WOFF2, etc.). Fall back to strlen for
* normal text/JSON responses where _tl_fs_read_len is 0. */
size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
/* Use the real byte count from fs_read ONLY when this body IS the exact
* buffer fs_read returned (binary files with embedded null bytes PNG,
* WOFF2, etc.). Any other body wrapped, enveloped, or derived must be
* measured with strlen, or it is truncated/over-read to the file's size. */
size_t blen = (_tl_fs_read_len > 0 && eff_body == _tl_fs_read_buf)
? _tl_fs_read_len : strlen(eff_body);
_tl_fs_read_len = 0; /* consume — one-shot per response */
_tl_fs_read_buf = NULL;
int head_only = _tl_http_head_only;
JsonBuf hdrs; jb_init(&hdrs);
@@ -1457,12 +1481,20 @@ static void http_send_response(int fd, const char* body) {
}
typedef struct {
#ifdef _WIN32
SOCKET fd;
#else
int fd;
#endif
} HttpWorkerArg;
static void* http_worker(void* arg) {
HttpWorkerArg* a = (HttpWorkerArg*)arg;
#ifdef _WIN32
SOCKET fd = a->fd;
#else
int fd = a->fd;
#endif
free(a);
char *method = NULL, *path = NULL, *body = NULL;
if (http_read_request(fd, &method, &path, &body, NULL) == 0) {
@@ -1479,11 +1511,22 @@ static void* http_worker(void* arg) {
const char* rs = EL_CSTR(r);
/* Copy response out BEFORE arena teardown.
* For binary files, _tl_fs_read_len holds the real byte count
* use memcpy instead of strdup so null bytes are preserved. */
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
* use memcpy instead of strdup so null bytes are preserved.
* The stored length applies ONLY when the response IS the exact
* fs_read buffer; a wrapped/derived response must use strlen or
* it gets truncated (or over-read) to the file's length. */
size_t rlen;
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
} else {
rlen = rs ? strlen(rs) : 0;
_tl_fs_read_len = 0; /* hint doesn't describe this body */
_tl_fs_read_buf = NULL;
}
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
} else {
response = el_strdup_persist("el-runtime: no http handler registered");
}
@@ -1494,7 +1537,7 @@ static void* http_worker(void* arg) {
free(response);
}
free(method); free(path); free(body);
close(fd);
el_closesocket(fd);
/* release a slot */
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
@@ -1524,14 +1567,18 @@ void http_serve(el_val_t port, el_val_t handler) {
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons((uint16_t)p);
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(sock); return;
perror("bind"); el_closesocket(sock); return;
}
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return; }
fprintf(stderr, "[http] listening on [::]:%d (dual-stack)\n", p);
while (1) {
struct sockaddr_in6 cli;
socklen_t clen = sizeof(cli);
#ifdef _WIN32
SOCKET cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#else
int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#endif
if (cfd < 0) {
if (errno == EINTR) continue;
perror("accept"); break;
@@ -1543,11 +1590,11 @@ void http_serve(el_val_t port, el_val_t handler) {
_http_conn_active++;
pthread_mutex_unlock(&_http_conn_mu);
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
if (!arg) { close(cfd); continue; }
if (!arg) { el_closesocket(cfd); continue; }
arg->fd = cfd;
pthread_t tid;
if (pthread_create(&tid, NULL, http_worker, arg) != 0) {
close(cfd); free(arg);
el_closesocket(cfd); free(arg);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
@@ -1556,7 +1603,7 @@ void http_serve(el_val_t port, el_val_t handler) {
}
pthread_detach(tid);
}
close(sock);
el_closesocket(sock);
}
/* ── http_serve_async — non-blocking HTTP server ─────────────────────────── */
@@ -1792,7 +1839,11 @@ static el_val_t http_build_headers_map(const char* hdr_block) {
static void* http_worker_v2(void* arg) {
HttpWorkerArg* a = (HttpWorkerArg*)arg;
#ifdef _WIN32
SOCKET fd = a->fd;
#else
int fd = a->fd;
#endif
free(a);
char *method = NULL, *path = NULL, *body = NULL, *hdr_block = NULL;
if (http_read_request(fd, &method, &path, &body, &hdr_block) == 0) {
@@ -1805,10 +1856,20 @@ static void* http_worker_v2(void* arg) {
el_val_t hmap = http_build_headers_map(hdr_block ? hdr_block : "");
el_val_t r = h(EL_STR(dispatch_method), EL_STR(path), hmap, EL_STR(body));
const char* rs = EL_CSTR(r);
size_t rlen = _tl_fs_read_len > 0 ? _tl_fs_read_len : (rs ? strlen(rs) : 0);
/* Same pairing rule as the v1 worker: the fs_read length is only
* trustworthy for the exact buffer fs_read returned. */
size_t rlen;
if (_tl_fs_read_len > 0 && rs && rs == _tl_fs_read_buf) {
rlen = _tl_fs_read_len; /* raw file bytes — binary-safe */
} else {
rlen = rs ? strlen(rs) : 0;
_tl_fs_read_len = 0; /* hint doesn't describe this body */
_tl_fs_read_buf = NULL;
}
response = malloc(rlen + 1);
if (response && rs) { memcpy(response, rs, rlen); response[rlen] = '\0'; }
else if (response) { response[0] = '\0'; }
if (_tl_fs_read_len > 0) _tl_fs_read_buf = response; /* hint follows the copy */
el_release(hmap);
} else {
response = el_strdup_persist(
@@ -1822,7 +1883,7 @@ static void* http_worker_v2(void* arg) {
free(response);
}
free(method); free(path); free(body); free(hdr_block);
close(fd);
el_closesocket(fd);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
@@ -1852,14 +1913,18 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
addr.sin6_addr = in6addr_any;
addr.sin6_port = htons((uint16_t)p);
if (bind(sock, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind"); close(sock); return;
perror("bind"); el_closesocket(sock); return;
}
if (listen(sock, 64) < 0) { perror("listen"); close(sock); return; }
if (listen(sock, 64) < 0) { perror("listen"); el_closesocket(sock); return; }
fprintf(stderr, "[http v2] listening on [::]:%d (dual-stack)\n", p);
while (1) {
struct sockaddr_in6 cli;
socklen_t clen = sizeof(cli);
#ifdef _WIN32
SOCKET cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#else
int cfd = accept(sock, (struct sockaddr*)&cli, &clen);
#endif
if (cfd < 0) {
if (errno == EINTR) continue;
perror("accept"); break;
@@ -1871,11 +1936,11 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
_http_conn_active++;
pthread_mutex_unlock(&_http_conn_mu);
HttpWorkerArg* arg = malloc(sizeof(HttpWorkerArg));
if (!arg) { close(cfd); continue; }
if (!arg) { el_closesocket(cfd); continue; }
arg->fd = cfd;
pthread_t tid;
if (pthread_create(&tid, NULL, http_worker_v2, arg) != 0) {
close(cfd); free(arg);
el_closesocket(cfd); free(arg);
pthread_mutex_lock(&_http_conn_mu);
_http_conn_active--;
pthread_cond_signal(&_http_conn_cv);
@@ -1884,7 +1949,7 @@ void http_serve_v2(el_val_t port, el_val_t handler) {
}
pthread_detach(tid);
}
close(sock);
el_closesocket(sock);
}
/* Build the response envelope a 4-arg handler can return. We hand-write
@@ -1924,6 +1989,7 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
el_val_t fs_read(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
_tl_fs_read_len = 0;
_tl_fs_read_buf = NULL;
if (!path) return el_wrap_str(el_strdup(""));
FILE* f = fopen(path, "rb");
if (!f) return el_wrap_str(el_strdup(""));
@@ -1935,6 +2001,7 @@ el_val_t fs_read(el_val_t pathv) {
size_t got = fread(buf, 1, (size_t)sz, f);
buf[got] = '\0';
_tl_fs_read_len = got; /* store real byte count for binary-safe send */
_tl_fs_read_buf = buf; /* ...valid ONLY for this exact buffer */
fclose(f);
return el_wrap_str(buf);
}
@@ -2029,6 +2096,23 @@ el_val_t exec(el_val_t cmdv) {
el_val_t exec_bg(el_val_t cmdv) {
const char* cmd = EL_CSTR(cmdv);
if (!cmd || !*cmd) return el_wrap_str(el_strdup(""));
#ifdef _WIN32
/* Windows: no fork/exec. Launch a detached `cmd /c <command>` with no console window via
CreateProcess (DETACHED_PROCESS | CREATE_NO_WINDOW). Returns the PID as a string, "" on fail.
Mirrors the POSIX branch: child runs independently, caller is not blocked. */
char cmdline[8192];
snprintf(cmdline, sizeof(cmdline), "cmd.exe /c %s", cmd);
STARTUPINFOA si; ZeroMemory(&si, sizeof(si)); si.cb = sizeof(si);
PROCESS_INFORMATION pi; ZeroMemory(&pi, sizeof(pi));
BOOL ok = CreateProcessA(NULL, cmdline, NULL, NULL, FALSE,
DETACHED_PROCESS | CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
if (!ok) return el_wrap_str(el_strdup(""));
char pidbuf[32];
snprintf(pidbuf, sizeof(pidbuf), "%lu", (unsigned long)pi.dwProcessId);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return el_wrap_str(el_strdup(pidbuf));
#else
pid_t pid = fork();
if (pid < 0) {
/* fork failed */
@@ -2051,6 +2135,7 @@ el_val_t exec_bg(el_val_t cmdv) {
char pidbuf[32];
snprintf(pidbuf, sizeof(pidbuf), "%d", (int)pid);
return el_wrap_str(el_strdup(pidbuf));
#endif
}
el_val_t fs_list(el_val_t pathv) {
@@ -3305,8 +3390,10 @@ el_val_t json_get_raw(el_val_t json_str, el_val_t key) {
const char* k = EL_CSTR(key);
const char* p = json_find_key(json, k);
/* Clear fs_read binary-length hint — result is a fresh null-terminated
* string, not the raw file bytes, so Content-Length must use strlen. */
* string, not the raw file bytes, so Content-Length must use strlen.
* (Kept although the pointer pairing now makes this redundant.) */
_tl_fs_read_len = 0;
_tl_fs_read_buf = NULL;
if (!p) return el_wrap_str(el_strdup(""));
const char* end = json_skip_value(p);
size_t n = (size_t)(end - p);
@@ -4178,7 +4265,12 @@ static int _el_decompose_earth(el_caltime_t* ct, struct tm* tm_out, int* abbr_le
localtime_r(&s, &tm);
*tm_out = tm;
if (abbr_buf && abbr_cap > 0) {
/* mingw's struct tm has no tm_zone (BSD/glibc extension); no abbrev available there. */
#ifdef _WIN32
const char* z_str = "";
#else
const char* z_str = tm.tm_zone ? tm.tm_zone : "";
#endif
size_t n = strlen(z_str);
if (n >= abbr_cap) n = abbr_cap - 1;
memcpy(abbr_buf, z_str, n);
@@ -758,6 +758,18 @@ el_val_t trace_span_start(el_val_t name);
el_val_t trace_span_end(el_val_t span_handle);
el_val_t emit_event(el_val_t name, el_val_t duration_ms);
/* ── Runtime symbols required by the soul modules ──────────────────────────── */
/* All implemented in el_runtime.c but omitted from this release header; the soul dist modules
* reference them directly, so the public header must export them. Declarations only — mirrors the
* mainline el_runtime.h and is platform-independent (no behavioural change to the POSIX build). */
typedef el_val_t (*http_handler_fn)(el_val_t method, el_val_t path, el_val_t body);
typedef el_val_t (*http_handler4_fn)(el_val_t method, el_val_t path, el_val_t body, el_val_t headers);
el_val_t el_arena_push(void);
el_val_t el_arena_pop(el_val_t mark);
void http_serve_async(el_val_t port, el_val_t handler);
el_val_t engram_get_node_by_label(el_val_t label);
el_val_t engram_prune_telemetry(el_val_t older_than_ms);
#ifdef __cplusplus
}
#endif