Files
el/engram/spec/grounded-edge-propagation/gep_core.h
bigmerge 5f3ddb8b8d
El SDK CI - dev / build-and-test (pull_request) Failing after 14m31s
Add grounded edge-propagation spec (task #50): core algorithm, proof harness, gated integration patches
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

300 lines
16 KiB
C

/* ─────────────────────────────────────────────────────────────────────────
* 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 */