diff --git a/engram/src/server.el b/engram/src/server.el index 874c1f4..2cdad82 100644 --- a/engram/src/server.el +++ b/engram/src/server.el @@ -1118,6 +1118,11 @@ fn route_faculty(path: String, faculty: String) -> String { fn route_boundary_proof(method: String, path: String, body: String) -> String { return "{\"op\":\"boundary_proof\",\"body_instrumentation\":\"none\",\"seam\":\"@manager -> engram_boundary_beat auto-injected\"}" } +// ── GROUNDING: an attribute of the RELATION, and the relation's weight is a +// VECTOR (factual, relational, associative, polarity, provenance, timestamp). +// /api/ground READS it — it never writes. /api/ground/record is the write, +// named as one, and it consolidates only on a consequential + salient move. +// /api/ground/trajectory reads the supersession chain as a time series. fn route_ground(method: String, path: String, body: String) -> String { let claim: String = json_get_string(body, "claim") let evidence: String = json_get_string(body, "evidence") @@ -1126,12 +1131,31 @@ fn route_ground(method: String, path: String, body: String) -> String { if str_eq(evidence, "") { return err_json("missing evidence") } return engram_ground_json(claim, evidence, for_whom) } +fn route_ground_record(method: String, path: String, body: String) -> String { + let claim: String = json_get_string(body, "claim") + let evidence: String = json_get_string(body, "evidence") + let provenance: String = json_get_string(body, "provenance") + let floor: String = json_get_string(body, "floor") + if str_eq(claim, "") { return err_json("missing claim") } + if str_eq(evidence, "") { return err_json("missing evidence") } + return engram_ground_record_json(claim, evidence, provenance, floor) +} +fn route_ground_trajectory(method: String, path: String, body: String) -> String { + let claim: String = query_param(path, "claim") + let evidence: String = query_param(path, "evidence") + if str_eq(claim, "") { return err_json("missing claim") } + if str_eq(evidence, "") { return err_json("missing evidence") } + return engram_ground_trajectory_json(claim, evidence) +} fn route_assert(method: String, path: String, body: String) -> String { let claim: String = query_param(path, "claim") if str_eq(claim, "") { return err_json("missing claim") } let for_whom: String = query_param(path, "for_whom") let floor: String = query_param(path, "floor") - return engram_assert_json(claim, for_whom, floor) + // Both floors. A well-evidenced claim does not earn the right to be asserted + // regardless of whether it means the right thing. rel_floor defaults to floor. + let rel_floor: String = query_param(path, "rel_floor") + return engram_assert_json(claim, for_whom, floor, rel_floor) } fn route_attend(method: String, path: String, body: String) -> String { let node: String = json_get_string(body, "node") @@ -1885,6 +1909,14 @@ fn handle_request(method: String, path: String, body: String) -> String { if str_eq(method, "GET") && str_starts_with(clean, "/api/plan") { return route_faculty(path, "plan") } + // Order matters: the more specific paths must be tested before the /api/ground + // prefix match below, which would otherwise swallow them. + if str_eq(method, "POST") && str_starts_with(clean, "/api/ground/record") { + return route_ground_record(method, path, body) + } + if str_eq(method, "GET") && str_starts_with(clean, "/api/ground/trajectory") { + return route_ground_trajectory(method, path, body) + } if str_eq(method, "POST") && str_starts_with(clean, "/api/ground") { return route_ground(method, path, body) } diff --git a/engram/test/run_grounding_vector_tests.sh b/engram/test/run_grounding_vector_tests.sh new file mode 100755 index 0000000..7279d96 --- /dev/null +++ b/engram/test/run_grounding_vector_tests.sh @@ -0,0 +1,40 @@ +#!/bin/sh +# Build + RUN the §7 GROUNDING-VECTOR tests (engram_cognition.c): the one decay +# model, the consequence gate, and the stored/derived split. Closed-form +# constructed cases — no server, no store, no network. Pure C11 (stdlib + libm). +# Standalone — NOT folded through elc. Two passes: +# 1. PERF — optimised (-O2, no sanitizer): the functional gate. +# 2. SAFETY — ASan + UBSan on the same suite. +# +# NEGATIVE CONTROL (invariant §8.6 — no test without one). Every symbol this +# suite exercises (cog_decay_factor, cog_grounding_significant, +# cog_significance_inherent, CogGrounding, CogProvClass) is introduced by the +# change under test, so the suite does not COMPILE against the pre-change source. +# To reproduce: +# git show origin/dev:lang/runtime/engram_cognition.h > /tmp/pre/engram_cognition.h +# git show origin/dev:lang/runtime/engram_cognition.c > /tmp/pre/engram_cognition.c +# cc -I/tmp/pre engram/test/test_grounding_vector.c /tmp/pre/engram_cognition.c ... +# => error: unknown type name 'CogGrounding'; no binary produced. +set -e +HERE=$(cd "$(dirname "$0")" && pwd) +RT="$HERE/../../lang/runtime" +CC=${CC:-cc} +SRC="$HERE/test_grounding_vector.c $RT/engram_cognition.c $RT/engram_reason.c $RT/engram_geometry.c $RT/engram_store.c $RT/engram_vindex.c" +WARN="-std=c11 -Wall -Wextra" +# engram_store.c declares emit_log as a WEAK symbol and null-checks it, which is +# how a test links the store without the EL runtime. Darwin's ld does not resolve +# an undefined weak symbol at static-link time, so it must be allowed explicitly. +# (The pre-existing runners in this directory — run_verify_tests.sh among them — +# do not do this and therefore fail to link on macOS. Unrelated to this change.) +LDX="" +[ "$(uname -s)" = "Darwin" ] && LDX="-Wl,-U,_emit_log" +TMP=$(mktemp -d) + +echo "### PASS 1: PERF (optimised, un-sanitised) — functional gate" +$CC $WARN -O2 -I"$RT" $SRC -lm -lpthread $LDX -o "$TMP/perf" +"$TMP/perf" + +echo +echo "### PASS 2: SAFETY (ASan/UBSan)" +$CC $WARN -O1 -g -fsanitize=address,undefined -fno-omit-frame-pointer -I"$RT" $SRC -lm -lpthread $LDX -o "$TMP/safe" +ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0} UBSAN_OPTIONS=halt_on_error=1 "$TMP/safe" diff --git a/engram/test/test_grounding_vector.c b/engram/test/test_grounding_vector.c new file mode 100644 index 0000000..171cf22 --- /dev/null +++ b/engram/test/test_grounding_vector.c @@ -0,0 +1,176 @@ +/* test_grounding_vector.c — deterministic tests for §7: the one decay model, the + * consequence gate, and the stored/derived split. Links engram_cognition.c + * directly; no server, no store, no network. See run_grounding_vector_tests.sh. + * + * NEGATIVE CONTROL (invariant §8.6). Every symbol exercised here — + * cog_decay_factor, cog_grounding_significant, cog_significance_inherent, + * CogGrounding, CogProvClass — is introduced by the change under test, so this + * suite does not COMPILE against the pre-change source, let alone pass. The + * runner documents the exact reproduction. + */ +#include "engram_cognition.h" +#include +#include +#include +#include + +static int fails = 0; +static void ok(int cond, const char* what) { + printf(" %-62s %s\n", what, cond ? "PASS" : "*** FAIL ***"); + if (!cond) fails++; +} + +/* The decay formula exactly as el_runtime.c carried it before the move, so the + * refactor can be shown to be bit-identical rather than merely similar. */ +static double old_engram_temporal_decay(long long age_ms, long long activation_count, + double temporal_decay_rate) { + if (age_ms <= 0) return 1.0; + double lambda = (temporal_decay_rate > 0.0) ? temporal_decay_rate : 0.693147; + double age_hours = (double)age_ms / 3600000.0; + double t_half = 168.0 * (1.0 + log(1.0 + (double)activation_count)); + double factor = exp(-lambda * age_hours / t_half); + if (factor < 0.25) factor = 0.25; + return factor; +} + +static CogGrounding base(void) { + CogGrounding g; memset(&g, 0, sizeof g); + g.present = 1; + g.factual = 0.60; g.relational = 0.60; + g.factual_now = 0.60; g.relational_now = 0.60; + g.associative = 0.1; g.polarity = 1.0; + g.prov = COG_PROV_TOLD; + g.fac_proj = 1.0; g.rel_proj = 1.0; + g.cos_angle = 0.9; g.agreement = 1; + g.ts = 1000; g.seq = 1; g.reinforcements = 3; + return g; +} + +int main(void) { + const double F = 0.5, R = 0.5; + + printf("\n== 1. DECAY IS THE ONE MODEL, AND IT IS BIT-IDENTICAL TO WHAT IT REPLACED ==\n"); + { + long long ages[] = {0, 3600000LL, 86400000LL, 7*86400000LL, 30*86400000LL, 365*86400000LL}; + int allsame = 1; + for (int i = 0; i < 6; i++) + for (int ac = 0; ac < 4; ac++) { + long long acs[] = {0, 1, 10, 1000}; + double a = cog_decay_factor(ages[i], (double)acs[ac], 0.0); + double b = old_engram_temporal_decay(ages[i], acs[ac], 0.0); + if (a != b) allsame = 0; + } + ok(allsame, "cog_decay_factor == the pre-move engram_temporal_decay (24 pts)"); + ok(cog_decay_factor(0, 0, 0.0) == 1.0, "age 0 -> no decay"); + } + printf("\n DECAY OVER ELAPSED TIME (reinforcements = 0, default rate):\n"); + printf(" %10s %10s\n", "elapsed", "decay"); + { + struct { const char* label; long long ms; } pts[] = { + {"0", 0LL}, + {"1 hour", 3600000LL}, + {"1 day", 86400000LL}, + {"3 days", 3LL*86400000LL}, + {"7 days", 7LL*86400000LL}, + {"14 days", 14LL*86400000LL}, + {"30 days", 30LL*86400000LL}, + {"90 days", 90LL*86400000LL}, + }; + double prev = 2.0; int monotone = 1; + for (unsigned i = 0; i < sizeof pts / sizeof pts[0]; i++) { + double d = cog_decay_factor(pts[i].ms, 0, 0.0); + printf(" %10s %10.6f\n", pts[i].label, d); + if (d > prev) monotone = 0; + prev = d; + } + ok(monotone, "decay is monotone non-increasing in elapsed time"); + ok(fabs(cog_decay_factor(7LL*86400000LL, 0, 0.0) - 0.5) < 1e-6, + "7 days at zero reinforcements == exactly one half-life (0.5)"); + ok(cog_decay_factor(7LL*86400000LL, 100, 0.0) > cog_decay_factor(7LL*86400000LL, 0, 0.0), + "reinforcement slows ageing (Lindy term)"); + ok(cog_decay_factor(3650LL*86400000LL, 0, 0.0) == 0.25, + "floor is a preference not a cliff: bottoms out at 0.25"); + } + + printf("\n== 2. CONSEQUENCE GATE: EVERY TRIGGER, AND NO EPSILON ANYWHERE ==\n"); + { + CogGrounding p = base(), n = base(); + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE, + "identical vectors -> NONE (a re-read must not consolidate)"); + + n = base(); n.factual = 0.9999; n.factual_now = 0.9999; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE, + "factual 0.60 -> 0.9999 without crossing the floor -> NONE"); + + n = base(); n.relational = 0.5001; n.relational_now = 0.5001; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_NONE, + "relational 0.60 -> 0.5001, still above floor -> NONE"); + + n = base(); n.factual_now = 0.4999; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_FACTUAL_FLOOR, + "a 0.1001 drop that CROSSES the floor -> FACTUAL_FLOOR"); + + n = base(); n.relational_now = 0.4999; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_RELATIONAL_FLOOR, + "relational crossing its floor -> RELATIONAL_FLOOR"); + + n = base(); n.cos_angle = -0.05; n.agreement = -1; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_AGREEMENT_FLIP, + "agreement +1 -> -1 -> AGREEMENT_FLIP"); + + n = base(); n.fac_proj = -0.2; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_DIRECTION_REVERSAL, + "factual gradient reverses -> DIRECTION_REVERSAL"); + + n = base(); n.rel_proj = -0.2; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_DIRECTION_REVERSAL, + "relational gradient reverses -> DIRECTION_REVERSAL"); + + n = base(); n.polarity = -1.0; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_POLARITY_FLIP, + "support -> contradiction -> POLARITY_FLIP (inherent)"); + + n = base(); n.polarity = 0.0; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_POLARITY_FLIP, + "support -> ignorance (zero) -> POLARITY_FLIP: not the same state"); + + n = base(); n.prov = COG_PROV_OBSERVED; + ok(cog_grounding_significant(&p, &n, F, R) == COG_SIG_PROVENANCE_CHANGE, + "told -> observed -> PROVENANCE_CHANGE (inherent)"); + + CogGrounding fresh; memset(&fresh, 0, sizeof fresh); + ok(cog_grounding_significant(&fresh, &n, F, R) == COG_SIG_FIRST_RECORD, + "no prior version -> FIRST_RECORD"); + } + + printf("\n== 3. INHERENT MOVES BYPASS THE SALIENCE GATE ==\n"); + ok(cog_significance_inherent(COG_SIG_POLARITY_FLIP), "polarity flip is inherent"); + ok(cog_significance_inherent(COG_SIG_PROVENANCE_CHANGE), "provenance change is inherent"); + ok(cog_significance_inherent(COG_SIG_FIRST_RECORD), "first record is inherent"); + ok(!cog_significance_inherent(COG_SIG_FACTUAL_FLOOR), "a floor crossing is NOT inherent"); + ok(!cog_significance_inherent(COG_SIG_NONE), "NONE is not inherent"); + + printf("\n== 4. THE STORED/DERIVED SPLIT: DERIVED VALUES ARE NEVER SERIALIZED ==\n"); + { + CogGrounding g = base(); + g.decay = 0.3333; g.factual_now = 0.1234; g.relational_now = 0.2345; + g.associative_now = 0.4567; g.age_ms = 999999; g.stale = 1; + char* m = cog_grounding_metadata("pre-existing=keepme", &g); + ok(m != NULL, "serializer returns a document"); + ok(m && strstr(m, "pre-existing=keepme"), "pre-existing edge metadata preserved verbatim"); + ok(m && strstr(m, "GRD1"), "GRD1 magic present"); + ok(m && !strstr(m, "0.3333"), "decay is NOT stored"); + ok(m && !strstr(m, "0.1234"), "factual_now is NOT stored"); + ok(m && !strstr(m, "0.2345"), "relational_now is NOT stored"); + ok(m && !strstr(m, "0.4567"), "associative_now is NOT stored"); + ok(m && !strstr(m, "999999"), "age is NOT stored"); + ok(m && strstr(m, "told"), "provenance class IS stored"); + ok(m && strstr(m, "0.6"), "the factual/relational dimensions ARE stored"); + if (m) { printf("\n --- serialized GRD1 block ---\n%s -----------------------------\n", m); } + free(m); + } + + printf("\n%s (%d failure%s)\n\n", fails ? "SOME TESTS FAILED" : "ALL TESTS PASSED", + fails, fails == 1 ? "" : "s"); + return fails ? 1 : 0; +} diff --git a/lang/runtime/el_runtime.c b/lang/runtime/el_runtime.c index a1cbce5..028ba7c 100644 --- a/lang/runtime/el_runtime.c +++ b/lang/runtime/el_runtime.c @@ -9740,18 +9740,17 @@ el_val_t engram_consolidate_permanence(el_val_t node_id){ * Explicit per-node temporal_decay_rate still overrides lambda (77 nodes carry * one) — that path is untouched and remains the escape hatch for content that * genuinely should expire fast. */ -#define ENGRAM_DECAY_FLOOR 0.25 +/* 2026-08-16: the body moved to cog_decay_factor (engram_cognition.c) so that + * NODE decay and EDGE-GROUNDING decay are one implementation with one set of + * constants, rather than a decay model and a parallel copy of it. The mapping is + * exact — reinforcements := activation_count, lambda_override := + * temporal_decay_rate — so this path is bit-identical to what it replaced. + * COG_T_HALF_HOURS / COG_DECAY_LAMBDA / COG_DECAY_FLOOR carry the same values + * ENGRAM_T_HALF_HOURS / ENGRAM_DECAY_LAMBDA / 0.25 carried here. */ static double engram_temporal_decay(const EngramNode* n, int64_t now_ms) { - int64_t age_ms = now_ms - n->last_activated; - if (age_ms <= 0) return 1.0; - double lambda = (n->temporal_decay_rate > 0.0) ? n->temporal_decay_rate - : ENGRAM_DECAY_LAMBDA; - double age_hours = (double)age_ms / 3600000.0; - double t_half = ENGRAM_T_HALF_HOURS * - (1.0 + log(1.0 + (double)n->activation_count)); - double factor = exp(-lambda * age_hours / t_half); - if (factor < ENGRAM_DECAY_FLOOR) factor = ENGRAM_DECAY_FLOOR; - return factor; + return cog_decay_factor(now_ms - n->last_activated, + (double)n->activation_count, + n->temporal_decay_rate); } /* Activation dampening: high activation_count nodes are "well-known" context @@ -14425,9 +14424,30 @@ el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) { if (idx >= 0 && idx < eg->node_count) { EngramNode* n = &eg->nodes[idx]; if (n->emb && n->emb_dim == g->dim) { + /* NORMALIZE INTO THE DESCRIPTOR'S FRAME (2026-08-16). + * This copied the RAW vector, but engram_geometry + * builds every descriptor over L2-normalized member + * embeddings, so the anchor was being fitted against + * an ellipsoid at a radius it was never fitted over. + * Measured on the self region: magnitude 0.00283443 + * raw vs 0.521837 in-frame on the same pair — a 180x + * error, and the reason every fit score sat three + * decimal places below the 0.5 floors that gate on + * them. The frame contract is stated in + * engram_verify.h; this call site did not honour it. + * Idempotent when the stored vector is already unit. */ anchor = malloc(sizeof(float) * (size_t)g->dim); - if (anchor) memcpy(anchor, n->emb, - sizeof(float) * (size_t)g->dim); + if (anchor) { + double s2 = 0; + for (int i = 0; i < g->dim; i++) + s2 += (double)n->emb[i] * (double)n->emb[i]; + double nn = sqrt(s2); + if (nn > 1e-12) + for (int i = 0; i < g->dim; i++) + anchor[i] = (float)((double)n->emb[i] / nn); + else + memcpy(anchor, n->emb, sizeof(float) * (size_t)g->dim); + } } } free(id); @@ -14455,88 +14475,522 @@ el_val_t engram_think_json(el_val_t seeds, el_val_t faculty) { return el_wrap_str(b.buf); } -/* engram_ground_json(claim_csv, evidence_csv, for_whom) — grounding as a RELATION. - * Turns the DORMANT verifier inward for real: verifies the claim region's centroid - * against the evidence region, then writes a grounded-by edge (weight = grounding, - * grounded-for-whom). Additive. */ +/* ═══════════════════════════════════════════════════════════════════════════ + * §7 WIRING — GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR + * (2026-08-16; spec correspondence-and-censorship.md @ 2b7e4ba.) + * + * What was here minted a `grounded-by` edge on every call and returned a float. + * Every part of that was wrong, and #147 only fixed the endpoints: + * - grounding is a property OF a relation, not a relation BETWEEN nodes, so + * there was nothing for a new edge to carry; + * - the call was a READ that wrote — the eg_vindex_sync defect; + * - one scalar cannot separate "true and meaningful" from "true and misapplied", + * nor "no support" from "actively contradicts". + * + * ground() is now pure: it reads the vector the RELATION already carries, decays + * it analytically to now on the runtime's one decay model, computes what the + * current geometry would say, and reports whether that move is consequential — + * without recording it. Recording is a separate, explicitly named write. + * ═══════════════════════════════════════════════════════════════════════════ */ + +/* The values reference: THIRTEEN regions, discovered from the graph rather than + * hardcoded, so a fourteenth value is picked up without a code change. They are + * the nodes the values root `contains`. Measured on the live store: exactly 13, + * pairwise centroid cosine min 0.1525 / mean 0.5199 / max 0.9278 — not one region. */ +#define EG_VALUES_ROOT_DEFAULT "kn-5b606390-a52d-4ca2-8e0e-eba141d13440" +#define EG_MAX_VALUES 32 +typedef struct { + int n; + char* ids[EG_MAX_VALUES]; + GeoDescriptor* g[EG_MAX_VALUES]; +} EgValueRef; + +static void eg_values_release(EgValueRef* v) { + if (!v) return; + for (int i = 0; i < v->n; i++) { free(v->ids[i]); if (v->g[i]) engram_geo_free(v->g[i]); } + v->n = 0; +} +static int eg_values_build(EgValueRef* out) { + if (!out || !g_engram_store) return -1; + memset(out, 0, sizeof *out); + const char* root = getenv("ENGRAM_VALUES_ROOT"); + if (!root || !*root) root = EG_VALUES_ROOT_DEFAULT; + StoreEdge* edges = NULL; size_t ne = 0; + if (store_get_edges_from(g_engram_store, root, &edges, &ne) < 0) return -1; + for (size_t i = 0; i < ne && out->n < EG_MAX_VALUES; i++) { + if (edges[i].tombstoned) continue; + if (!edges[i].relation || strcmp(edges[i].relation, "contains") != 0) continue; + if (!edges[i].to_id) continue; + GeoDescriptor* g = eg_geo_build_desc(edges[i].to_id); + if (!g) continue; + out->ids[out->n] = strdup(edges[i].to_id); + out->g[out->n] = g; + out->n++; + } + store_edges_free(edges, ne); + return out->n; +} + +/* THE REFERENCE FRAME IS BUILT ONCE AND HELD. Two reasons, and the second is the + * real one: + * - cost: thirteen descriptors over the whole node vector per call made a + * single /api/ground take tens of seconds; + * - correctness: a reference frame that is rebuilt on every read moves under + * the measurement, which is the defect the whole spec is about. Holding it + * for the process lifetime is the closest this layer can come to "the frame + * updates when it is not being used to act" without owning that decision — + * which belongs to the dreamer, not here. ENGRAM_VALUES_NOCACHE=1 forces a + * rebuild per call for tests that deliberately move a value node. */ +static EgValueRef _eg_values_cache; +static int _eg_values_cached = 0; +static const EgValueRef* eg_values_ref(void) { + const char* nc = getenv("ENGRAM_VALUES_NOCACHE"); + if (nc && nc[0] && nc[0] != '0') { + if (_eg_values_cached) { eg_values_release(&_eg_values_cache); _eg_values_cached = 0; } + } + if (!_eg_values_cached) { + if (eg_values_build(&_eg_values_cache) <= 0) return NULL; + _eg_values_cached = 1; + } + return &_eg_values_cache; +} + +/* Copy a node's embedding INTO THE DESCRIPTOR'S FRAME (never borrow — g->nodes is + * realloc'd on append, so a borrowed EngramNode* dangles across any concurrent write). + * + * THE FRAME CONTRACT IS LOAD-BEARING AND WAS BEING VIOLATED. engram_verify.h states + * it plainly: the claim point and the descriptor must share the same frame. + * engram_geometry builds every descriptor over L2-NORMALIZED member embeddings — + * `centroid` is the mean of normcopy()'d vectors and the principal axes are computed + * against that — while the raw `n->emb` in the resident store is not necessarily unit. + * Fitting a raw vector against a unit-frame descriptor puts the point at a radius the + * ellipsoid was never fitted over, so the distance is dominated by the norm mismatch + * and every fit score collapses toward zero. Normalizing here is idempotent when the + * stored vector is already unit, so it can only help. */ +static float* eg_node_emb_copy(const char* id, int dim) { + EngramStore* eg = engram_get(); + if (!eg || !id || dim <= 0) return NULL; + int64_t idx = engram_find_node_index(id); + if (idx < 0 || idx >= eg->node_count) return NULL; + EngramNode* n = &eg->nodes[idx]; + if (!n->emb || n->emb_dim != dim) return NULL; + float* p = malloc(sizeof(float) * (size_t)dim); + if (!p) return NULL; + double s = 0; + for (int i = 0; i < dim; i++) s += (double)n->emb[i] * (double)n->emb[i]; + double nn = sqrt(s); + if (nn > 1e-12) for (int i = 0; i < dim; i++) p[i] = (float)((double)n->emb[i] / nn); + else memcpy(p, n->emb, sizeof(float) * (size_t)dim); + return p; +} +/* Salience of a relation = the salience of its endpoints, reusing the fields the + * runtime already keeps (node salience and working-memory weight). Consolidation + * is gated by salience — that is why you remember the argument and not the + * commute — and this deliberately reads existing state rather than introducing a + * threshold of its own. */ +static double eg_node_salience(const char* id) { + EngramStore* eg = engram_get(); + if (!eg || !id) return 0.0; + int64_t idx = engram_find_node_index(id); + if (idx < 0 || idx >= eg->node_count) return 0.0; + EngramNode* n = &eg->nodes[idx]; + double s = n->salience; + if (n->working_memory_weight > s) s = n->working_memory_weight; + if (n->background_activation > s) s = n->background_activation; + return s; +} +static double eg_vdot(const float* a, const float* b, int dim) { + double s = 0; for (int i = 0; i < dim; i++) s += (double)a[i] * (double)b[i]; return s; +} +/* Signed projection of a unit gradient onto unit(target − x): positive means the + * descent direction still points toward the target. Frame-independent, so it + * stays comparable across versions even if the region's principal axes rotate — + * which is what makes the DIRECTION_REVERSAL test meaningful over time. */ +static double eg_proj_toward(const float* dir, const float* x, const float* target, int dim) { + double s = 0, n2 = 0; + for (int i = 0; i < dim; i++) { double d = (double)target[i] - (double)x[i]; s += (double)dir[i] * d; n2 += d * d; } + double n = sqrt(n2); + return n > 1e-12 ? s / n : 0.0; +} + +/* RELATIONAL grounding of a point: the MIN fit over the thirteen value regions, + * and the NAME of the value that binds. Min, not mean, because a mean lets strong + * agreement with twelve values mask a violation of the thirteenth — which is the + * mechanism of rationalization, not a scoring detail. */ +static int eg_relational_read(const float* x, const EgValueRef* V, + double* out_score, int* out_idx, float** out_dir) { + if (!x || !V || V->n <= 0) return -1; + double worst = 2.0; int wi = -1; + for (int i = 0; i < V->n; i++) { + GeoFit f; + if (cog_warped_fit(V->g[i], x, NULL, &f) != 0) continue; + if (f.score < worst) { worst = f.score; wi = i; } + } + if (wi < 0) return -1; + if (out_score) *out_score = worst; + if (out_idx) *out_idx = wi; + if (out_dir) { + GeoGradient gr; + if (engram_think(V->g[wi], x, NULL, &gr) == 0) { + *out_dir = gr.direction; gr.direction = NULL; /* take ownership */ + engram_gradient_free(&gr); + } else *out_dir = NULL; + } + return 0; +} + +/* Find the live relation between two nodes, in either orientation, and return + * the NEWEST recorded version of it. Returns 1 on hit. */ +static int eg_find_relation(const char* a, const char* b, StoreEdge* out, char* base_id, size_t cap) { + if (!g_engram_store || !a || !b) return 0; + for (int dir = 0; dir < 2; dir++) { + const char* from = dir == 0 ? a : b; + const char* to = dir == 0 ? b : a; + StoreEdge* edges = NULL; size_t ne = 0; + if (store_get_edges_from(g_engram_store, from, &edges, &ne) < 0) continue; + for (size_t i = 0; i < ne; i++) { + if (edges[i].tombstoned) continue; + if (!edges[i].to_id || strcmp(edges[i].to_id, to) != 0) continue; + if (edges[i].id && strchr(edges[i].id, '#')) continue; /* a version, not a root */ + snprintf(base_id, cap, "%s", edges[i].id ? edges[i].id : ""); + store_edges_free(edges, ne); + if (cog_grounding_head(g_engram_store, base_id, out, 64) >= 0) return 1; + return 0; + } + store_edges_free(edges, ne); + } + return 0; +} + +/* The two-axis observation of one relation from the CURRENT geometry. Pure. + * factual = how well the far endpoint sits in the near endpoint's neighbourhood + * — a real correspondence measurement of the relation itself; + * relational = min fit over the thirteen value regions, with the binding name; + * cos_angle = the cosine between the two full-dimensional gradients. */ +typedef struct { + int ok; + double factual, relational, cos_angle, fac_proj, rel_proj; + char binding[128]; +} EgObservation; + +static int eg_observe_relation(const char* from_id, const char* to_id, + const EgValueRef* V, EgObservation* out) { + memset(out, 0, sizeof *out); + GeoDescriptor* R = eg_geo_build_desc(from_id); + if (!R) return -1; + int dim = R->dim; + float* x = eg_node_emb_copy(to_id, dim); + if (!x) { engram_geo_free(R); return -1; } + + GeoFit f; + if (cog_warped_fit(R, x, NULL, &f) != 0) { free(x); engram_geo_free(R); return -1; } + out->factual = f.score; + + GeoGradient fg; + float* fac_dir = NULL; + if (engram_think(R, x, NULL, &fg) == 0) { fac_dir = fg.direction; fg.direction = NULL; engram_gradient_free(&fg); } + + int vi = -1; float* rel_dir = NULL; + if (eg_relational_read(x, V, &out->relational, &vi, &rel_dir) == 0 && vi >= 0) + snprintf(out->binding, sizeof out->binding, "%s", V->ids[vi]); + + if (fac_dir && R->centroid) out->fac_proj = eg_proj_toward(fac_dir, x, R->centroid, dim); + if (rel_dir && vi >= 0 && V->g[vi]->centroid) out->rel_proj = eg_proj_toward(rel_dir, x, V->g[vi]->centroid, dim); + if (fac_dir && rel_dir) out->cos_angle = eg_vdot(fac_dir, rel_dir, dim); + + free(fac_dir); free(rel_dir); free(x); engram_geo_free(R); + out->ok = 1; + return 0; +} + +/* Fold an observation into the vector as it would stand now. The accrual is a + * running MEAN over reinforcements — the same form the beat's calibration + * already uses (brier_sum / n_trials) — so no rate constant is introduced. */ +static void eg_fold_observation(const CogGrounding* prev, const EgObservation* obs, + CogProvClass prov, double floor, double rel_floor, + int64_t now, CogGrounding* out) { + *out = *prev; + double n = prev->reinforcements; + out->factual = prev->present ? prev->factual + (obs->factual - prev->factual) / (n + 1.0) : obs->factual; + out->relational = prev->present ? prev->relational + (obs->relational - prev->relational) / (n + 1.0) : obs->relational; + out->fac_proj = obs->fac_proj; out->rel_proj = obs->rel_proj; out->cos_angle = obs->cos_angle; + out->agreement = obs->cos_angle > 0 ? 1 : (obs->cos_angle < 0 ? -1 : 0); + out->reinforcements = n + 1.0; + out->ts = now; + if (prov != COG_PROV_UNSET) out->prov = prov; + out->floor_at_record = floor; out->rel_floor_at_record = rel_floor; + snprintf(out->binding_value, sizeof out->binding_value, "%s", obs->binding); + /* DERIVED, recomputed at the instant — never carried over from prev. */ + out->age_ms = 0; out->decay = 1.0; + out->factual_now = out->factual; out->relational_now = out->relational; + out->associative_now = out->associative; + out->stale = 0; +} + +static void eg_emit_vector(JsonBuf* b, const char* key, const CogGrounding* g) { + char t[768]; + snprintf(t, sizeof t, + "\"%s\":{\"established\":%s," + "\"factual\":%.6g,\"relational\":%.6g,\"associative\":%.6g,\"polarity\":%.6g," + "\"provenance\":\"%s\",\"ts\":%lld,\"seq\":%lld,\"reinforcements\":%.6g," + "\"cos_angle\":%.6g,\"agreement\":%d,\"fac_proj\":%.6g,\"rel_proj\":%.6g," + "\"binding_value\":\"%s\"," + "\"derived\":{\"age_s\":%lld,\"decay\":%.6g,\"factual_now\":%.6g," + "\"relational_now\":%.6g,\"associative_now\":%.6g,\"stale\":%s}}", + key, g->present ? "true" : "false", + g->factual, g->relational, g->associative, g->polarity, + cog_prov_name(g->prov), (long long)g->ts, (long long)g->seq, g->reinforcements, + g->cos_angle, g->agreement, g->fac_proj, g->rel_proj, + g->binding_value[0] ? g->binding_value : "-", + (long long)(g->age_ms / 1000), g->decay, g->factual_now, + g->relational_now, g->associative_now, g->stale ? "true" : "false"); + jb_puts(b, t); +} + +/* engram_ground_json(claim, evidence, for_whom) — a READ. Never writes. */ el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom) { if (!g_engram_store) return eg_geo_err("store unavailable"); - GeoDescriptor* C = eg_geo_build_desc(EL_CSTR(claim)); - GeoDescriptor* E = eg_geo_build_desc(EL_CSTR(evidence)); - if (!C || !E) { if (C) engram_geo_free(C); if (E) engram_geo_free(E); return eg_geo_err("geometry unavailable"); } - const GeoDescriptor* ev[1] = { E }; - GeoGrounding gr; - int rc = engram_verify_grounding(C->centroid, C->dim, ev, 1, 1.0, 0.5, &gr); - double grounding = (rc == 0) ? gr.grounding : 0.0; - if (rc == 0) engram_verify_grounding_free(&gr); - const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL; - - /* GROUND THE NODE ASKED ABOUT, AND SAY WHAT WAS RESOLVED (2026-08-16 - * self-review). This wrote the grounded-by edge between the two REGION - * HUBS and then echoed those hubs back in the "claim"/"evidence" fields - * as though they were the caller's input. Three consequences, all measured - * against the live store: - * - * 1. The edge landed on a node the caller never named. Asking to ground - * 3b9ced5d against 6edf8c79 wrote an edge on 6edf8c79 -> d0406dfd, - * because those were the hubs of the two regions. - * 2. When both seeds resolve into the same region, the hubs coincide and - * the call grounds a node against ITSELF, returning grounding = 1 — - * a perfect score with no evidence behind it. Two independent agents - * hit this and reported 0.885 / 0.909 self-groundings as confident. - * 3. The echo concealed both, because the response looked exactly like a - * successful grounding of the ids that were passed in. - * - * The region is HOW a claim is evaluated; it is not WHAT the claim is - * about. So the edge attaches to the requested ids, and the resolved hubs - * are reported separately under claim_region / evidence_region. When the - * two regions coincide, the grounding is degenerate by construction and is - * reported as such rather than as a confident 1.0. */ const char* cid = EL_CSTR(claim); const char* eid = EL_CSTR(evidence); - const char* chub = C->hub_id ? C->hub_id : cid; - const char* ehub = E->hub_id ? E->hub_id : eid; - /* Degeneracy is broader than chub == ehub. Three circular shapes, each of - * which yields a high score for structural reasons rather than evidential - * ones, and all three were previously invisible: - * same-region both seeds resolve to one region — grounding a thing - * against itself. - * claim-in-ev the claim's region hub IS the evidence node: the evidence - * sits at the centre of the claim's own neighbourhood. - * ev-in-claim the mirror case. - * Measured: grounding 3b9ced5d against 6edf8c79 scored 0.98883 purely - * because 6edf8c79 is the hub of 3b9ced5d's region. */ - const char* degenerate = NULL; - if (chub && ehub && strcmp(chub, ehub) == 0) degenerate = "same-region"; - else if (chub && eid && strcmp(chub, eid) == 0) degenerate = "claim-region-is-evidence"; - else if (ehub && cid && strcmp(ehub, cid) == 0) degenerate = "evidence-region-is-claim"; - if (degenerate) grounding = 0.0; /* circular support is not support */ + const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL; + int64_t now = engram_now_ms(); - /* Do not write an edge for a grounding that is degenerate by construction. */ - int wr = degenerate ? -1 : cog_ground_edge(g_engram_store, cid, eid, grounding, fw); - JsonBuf b; jb_init(&b); char t[512]; - snprintf(t, sizeof t, "{\"relation\":\"grounded-by\",\"claim\":\"%s\",\"evidence\":\"%s\"," - "\"claim_region\":\"%s\",\"evidence_region\":\"%s\",\"degenerate\":%s%s%s," - "\"for_whom\":\"%s\",\"grounding\":%.6g,\"written\":%s}", - cid ? cid : "", eid ? eid : "", chub ? chub : "", ehub ? ehub : "", - degenerate ? "\"" : "false", degenerate ? degenerate : "", degenerate ? "\"" : "", - fw ? fw : "-", grounding, wr == 0 ? "true" : "false"); + StoreEdge cur; char base_id[192] = ""; + JsonBuf b; jb_init(&b); char t[768]; + + if (!eg_find_relation(cid, eid, &cur, base_id, sizeof base_id)) { + /* THE HONEST ANSWER. There is nothing to ground a claim "against" that is + * not already a relation. This previously minted one and scored it — and + * when both seeds fell in one region the score came back 1.0 with no + * evidence behind it. If the two are unrelated, say so and write nothing. */ + snprintf(t, sizeof t, + "{\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"related\":false," + "\"grounding\":null,\"written\":false," + "\"note\":\"no relation between these nodes; grounding is a property of a relation, not a score minted between nodes\"}", + cid ? cid : "", eid ? eid : "", fw ? fw : "-"); + jb_puts(&b, t); + return el_wrap_str(b.buf); + } + + CogGrounding rec; cog_grounding_parse(&cur, now, &rec); + + const EgValueRef* V = eg_values_ref(); + EgObservation obs; memset(&obs, 0, sizeof obs); + int nv = V ? V->n : 0; + if (nv > 0 && cur.from_id && cur.to_id) eg_observe_relation(cur.from_id, cur.to_id, V, &obs); + + double floor = 0.5, rel_floor = 0.5; + CogGrounding ng = rec; + if (obs.ok) eg_fold_observation(&rec, &obs, COG_PROV_UNSET, floor, rel_floor, now, &ng); + CogSignificance sig = obs.ok ? cog_grounding_significant(&rec, &ng, floor, rel_floor) + : COG_SIG_NONE; + double sal = cur.from_id && cur.to_id + ? (eg_node_salience(cur.from_id) > eg_node_salience(cur.to_id) + ? eg_node_salience(cur.from_id) : eg_node_salience(cur.to_id)) + : 0.0; + + CogTrajectory tr; memset(&tr, 0, sizeof tr); + cog_grounding_trajectory(g_engram_store, base_id, now, &tr); + + jb_putc(&b, '{'); + snprintf(t, sizeof t, + "\"claim\":\"%s\",\"evidence\":\"%s\",\"for_whom\":\"%s\",\"related\":true," + "\"relation\":\"%s\",\"edge\":\"%s\",\"edge_root\":\"%s\",\"from\":\"%s\",\"to\":\"%s\"," + "\"value_regions\":%d,\"aggregate\":\"min\",\"salience\":%.6g,", + cid ? cid : "", eid ? eid : "", fw ? fw : "-", + cur.relation ? cur.relation : "", cur.id ? cur.id : "", base_id, + cur.from_id ? cur.from_id : "", cur.to_id ? cur.to_id : "", nv > 0 ? nv : 0, sal); jb_puts(&b, t); - engram_geo_free(C); engram_geo_free(E); + eg_emit_vector(&b, "recorded", &rec); + jb_putc(&b, ','); + if (obs.ok) eg_emit_vector(&b, "observed", &ng); + else jb_puts(&b, "\"observed\":null"); + /* Volatility and drift are computed here and stored nowhere — the series + * exists only because nothing was destroyed. */ + snprintf(t, sizeof t, + ",\"trajectory\":{\"n_versions\":%d,\"factual_volatility\":%.6g," + "\"relational_volatility\":%.6g,\"factual_drift\":%.6g,\"relational_drift\":%.6g," + "\"stayed_true_became_wrong\":%s}" + ",\"would_record\":%s,\"significance\":\"%s\",\"inherent\":%s,\"written\":false}", + tr.n_versions, tr.factual_volatility, tr.relational_volatility, + tr.factual_drift, tr.relational_drift, + tr.stayed_true_became_wrong ? "true" : "false", + sig != COG_SIG_NONE ? "true" : "false", cog_significance_name(sig), + cog_significance_inherent(sig) ? "true" : "false"); + jb_puts(&b, t); + + store_edge_free(&cur); return el_wrap_str(b.buf); } -/* engram_assert_json(claim_id, for_whom, floor) — the honesty floor as a QUERY at - * ASSERTION time only (holding is never gated). Reads the claim's grounded-by - * edges (for the observer) and returns whether assertion is permitted. */ -el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor) { +/* engram_ground_record_json — the WRITE half, named as one. Recomputes the + * vector, applies the consolidation gate (salience + per-dimension consequence), + * and on significance supersedes the EDGE, versioning the WHOLE vector jointly. + * The predecessor is never touched. + * + * There is no provenance predicate here and nothing takes its place. An earlier + * pass built one — a bounded traversal over a grounding-chain graph, refusing + * evidence downstream of the region being calibrated. It is deleted. Circularity + * of the reference frame is TEMPORAL, not topological: you cannot recalibrate the + * ruler while measuring with it, so that update happens when the frame is not + * being used to act, which is a fact about engagement and belongs to the dreamer. + * The measurement that settles it: reachability from the self region reaches + * 89.2% of the live graph, so any topological predicate marks nearly all evidence + * tainted and degenerates into the total block censorship started as. */ +el_val_t engram_ground_record_json(el_val_t claim, el_val_t evidence, + el_val_t provenance, el_val_t floor_v) { + if (!g_engram_store) return eg_geo_err("store unavailable"); + const char* cid = EL_CSTR(claim); + const char* eid = EL_CSTR(evidence); + double floor = atof(EL_CSTR(floor_v)); if (!(floor > 0)) floor = 0.5; + double rel_floor = floor; + CogProvClass prov = cog_prov_parse(EL_CSTR(provenance)); + int64_t now = engram_now_ms(); + + StoreEdge cur; char base_id[192] = ""; + JsonBuf b; jb_init(&b); char t[768]; + if (!eg_find_relation(cid, eid, &cur, base_id, sizeof base_id)) { + snprintf(t, sizeof t, "{\"claim\":\"%s\",\"evidence\":\"%s\",\"related\":false,\"written\":false}", + cid ? cid : "", eid ? eid : ""); + jb_puts(&b, t); return el_wrap_str(b.buf); + } + + CogGrounding rec; cog_grounding_parse(&cur, now, &rec); + const EgValueRef* V = eg_values_ref(); + EgObservation obs; memset(&obs, 0, sizeof obs); + int nv = V ? V->n : 0; + if (nv <= 0 || !cur.from_id || !cur.to_id || + eg_observe_relation(cur.from_id, cur.to_id, V, &obs) != 0 || !obs.ok) { + store_edge_free(&cur); + return eg_geo_err("geometry unavailable for this relation"); + } + + CogGrounding ng; + eg_fold_observation(&rec, &obs, prov, floor, rel_floor, now, &ng); + + CogSignificance sig = cog_grounding_significant(&rec, &ng, floor, rel_floor); + /* CONSOLIDATION GATE. Significance says the move would change a decision; + * salience says it is worth making durable. The two INHERENT moves — a + * polarity sign flip and a provenance class change — bypass salience because + * they are discrete changes of state rather than drift. */ + double sal = eg_node_salience(cur.from_id) > eg_node_salience(cur.to_id) + ? eg_node_salience(cur.from_id) : eg_node_salience(cur.to_id); + int salient = (sal > 0.0); + int consolidate = (sig != COG_SIG_NONE) && (cog_significance_inherent(sig) || salient); + + char written_id[224] = ""; + int seq = -1; + if (consolidate) seq = cog_grounding_record(g_engram_store, &cur, &ng, written_id, sizeof written_id); + + jb_putc(&b, '{'); + snprintf(t, sizeof t, + "\"claim\":\"%s\",\"evidence\":\"%s\",\"edge\":\"%s\",\"edge_root\":\"%s\"," + "\"value_regions\":%d,\"aggregate\":\"min\",\"floor\":%.4g,\"rel_floor\":%.4g," + "\"salience\":%.6g,\"salient\":%s,", + cid ? cid : "", eid ? eid : "", cur.id ? cur.id : "", base_id, + nv, floor, rel_floor, sal, salient ? "true" : "false"); + jb_puts(&b, t); + eg_emit_vector(&b, "previous", &rec); jb_putc(&b, ','); + eg_emit_vector(&b, "observed", &ng); + snprintf(t, sizeof t, + ",\"significance\":\"%s\",\"inherent\":%s,\"consolidated\":%s," + "\"written\":%s,\"version\":%d,\"version_id\":\"%s\"}", + cog_significance_name(sig), cog_significance_inherent(sig) ? "true" : "false", + consolidate ? "true" : "false", + (consolidate && seq > 0) ? "true" : "false", seq > 0 ? seq : 0, written_id); + jb_puts(&b, t); + + store_edge_free(&cur); + return el_wrap_str(b.buf); +} + +/* engram_ground_trajectory_json(claim, evidence) — the supersession chain read as + * a TIME SERIES OF VECTORS. Not only what the grounding is but which way it has + * been moving and how fast — a derivative obtained for free from immutability, + * because the points were never destroyed. */ +el_val_t engram_ground_trajectory_json(el_val_t claim, el_val_t evidence) { + if (!g_engram_store) return eg_geo_err("store unavailable"); + StoreEdge cur; char base_id[192] = ""; + if (!eg_find_relation(EL_CSTR(claim), EL_CSTR(evidence), &cur, base_id, sizeof base_id)) + return eg_geo_err("no relation between these nodes"); + store_edge_free(&cur); + int64_t now = engram_now_ms(); + JsonBuf b; jb_init(&b); char t[768]; + jb_puts(&b, "{\"edge_root\":\""); jb_puts(&b, base_id); jb_puts(&b, "\",\"versions\":["); + int emitted = 0; + for (int v = 0; v <= 64; v++) { + char vid[224]; + if (v == 0) snprintf(vid, sizeof vid, "%s", base_id); + else snprintf(vid, sizeof vid, "%s#%d", base_id, v); + StoreEdge e; + if (store_get_edge(g_engram_store, vid, &e) != 1) { if (v) break; else continue; } + CogGrounding g; cog_grounding_parse(&e, now, &g); + if (emitted) jb_putc(&b, ','); + snprintf(t, sizeof t, + "{\"version\":%d,\"id\":\"%s\",\"established\":%s,\"ts\":%lld," + "\"factual\":%.6g,\"relational\":%.6g,\"associative\":%.6g,\"polarity\":%.6g," + "\"provenance\":\"%s\",\"cos_angle\":%.6g,\"agreement\":%d," + "\"binding_value\":\"%s\",\"prev\":\"%s\"," + "\"derived\":{\"age_s\":%lld,\"decay\":%.6g,\"factual_now\":%.6g,\"stale\":%s}}", + v, vid, g.present ? "true" : "false", (long long)g.ts, + g.factual, g.relational, g.associative, g.polarity, + cog_prov_name(g.prov), g.cos_angle, g.agreement, + g.binding_value[0] ? g.binding_value : "-", g.prev_edge, + (long long)(g.age_ms / 1000), g.decay, g.factual_now, g.stale ? "true" : "false"); + jb_puts(&b, t); emitted++; + store_edge_free(&e); + } + CogTrajectory tr; memset(&tr, 0, sizeof tr); + cog_grounding_trajectory(g_engram_store, base_id, now, &tr); + snprintf(t, sizeof t, + "],\"n_versions\":%d,\"derived\":{\"factual_volatility\":%.6g," + "\"relational_volatility\":%.6g,\"factual_drift\":%.6g,\"relational_drift\":%.6g," + "\"stayed_true_became_wrong\":%s}}", + emitted, tr.factual_volatility, tr.relational_volatility, + tr.factual_drift, tr.relational_drift, + tr.stayed_true_became_wrong ? "true" : "false"); + jb_puts(&b, t); + return el_wrap_str(b.buf); +} + +/* engram_assert_json(claim_id, for_whom, floor, rel_floor) — the honesty floor as + * a QUERY at ASSERTION time only (holding is never gated), now gating on BOTH + * axes. A well-evidenced claim must not earn the right to be asserted regardless + * of whether it means the right thing. + * + * `still_held` was a HARDCODED `true` in this format string — a temporal property + * named in the API and answered without consulting anything, which is invariant + * §8.1 violated in one literal. It is now derived: the claim's node is read and + * the field reports whether the content is present and live. Holding remains + * unconditional; what decays is the GROUNDING, and a relation whose decayed + * grounding has fallen below its floor stops being assertable on its own, + * without anyone having to remember to check. */ +el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor, el_val_t rel_floor) { if (!g_engram_store) return eg_geo_err("store unavailable"); const char* fw = EL_CSTR(for_whom); if (fw && !*fw) fw = NULL; - double fl = atof(EL_CSTR(floor)); if (!(fl > 0)) fl = 0.5; - int gate = cog_assert_gate(g_engram_store, EL_CSTR(claim_id), fw, fl); - JsonBuf b; jb_init(&b); char t[192]; - snprintf(t, sizeof t, "{\"claim\":\"%s\",\"for_whom\":\"%s\",\"floor\":%.4g,\"may_assert\":%s,\"still_held\":true}", - EL_CSTR(claim_id), fw ? fw : "-", fl, gate == 1 ? "true" : "false"); + double fl = atof(EL_CSTR(floor)); if (!(fl > 0)) fl = 0.5; + double rfl = atof(EL_CSTR(rel_floor)); if (!(rfl > 0)) rfl = fl; + CogAssertion a; + if (cog_assert_two_axis(g_engram_store, EL_CSTR(claim_id), fl, rfl, engram_now_ms(), &a) != 0) + return eg_geo_err("assert failed"); + JsonBuf b; jb_init(&b); char t[640]; + snprintf(t, sizeof t, + "{\"claim\":\"%s\",\"for_whom\":\"%s\",\"floor\":%.4g,\"rel_floor\":%.4g," + "\"may_assert\":%s,\"still_held\":%s,\"found\":%s,\"n_relations\":%d," + "\"factual\":%.6g,\"relational\":%.6g,\"relational_established\":%s," + "\"cos_angle\":%.6g,\"agreement\":%d,\"binding_value\":\"%s\",\"best_relation\":\"%s\"," + "\"refused_because\":\"%s\"}", + EL_CSTR(claim_id), fw ? fw : "-", fl, rfl, + a.may_assert ? "true" : "false", a.still_held ? "true" : "false", + a.found ? "true" : "false", a.n_edges, + a.factual, a.relational, a.relational_established ? "true" : "false", + a.cos_angle, a.agreement, + a.binding_value[0] ? a.binding_value : "-", a.best_edge, + a.may_assert ? "-" : + !a.found ? "no-relation" : + !a.relational_established ? "relational-axis-never-established" : + (a.factual < fl) ? "below-factual-floor" : + (a.relational < rfl) ? "below-relational-floor" : "-"); jb_puts(&b, t); return el_wrap_str(b.buf); } @@ -14636,15 +15090,22 @@ el_val_t engram_correspondence_beat_json(el_val_t seeds, el_val_t faculty, el_va JsonBuf b; jb_init(&b); char t[384]; snprintf(t, sizeof t, "{\"faculty\":\"%s\",\"stance_id\":\"%s\",\"region_hub\":\"%s\",\"dim\":%d,\"n_axes\":%d,\"signal_axes\":%d," + /* `keystone_write_blocked` is GONE from this response. It reported that the + * beat had refused to learn about the reference frame, and the measured + * cost of that refusal was 0.00% brier reduction over n_trials 0 on the + * keystone region — the loop never ran, so nothing about the self was ever + * calibrated OR falsifiable. Nothing replaces the flag: non-circularity of + * the reference frame is temporal, not a permission (spec §5.2). The + * `keystone` field is retained as a label on the region, and it no longer + * gates anything. */ "\"resumed\":%s,\"keystone\":%s,\"probes\":%d,\"epochs\":%d," "\"brier_before\":%.6g,\"brier_after\":%.6g,\"reduction_pct\":%.2f," - "\"reliability\":%.6g,\"n_trials\":%lld,\"stance_written\":%s,\"keystone_write_blocked\":%s}", + "\"reliability\":%.6g,\"n_trials\":%lld,\"stance_written\":%s}", EL_CSTR(faculty), sid, g->hub_id ? g->hub_id : "region", dim, na, signal, resumed ? "true" : "false", st.keystone ? "true" : "false", NP, EP, brier_before, brier_after, brier_before > 0 ? 100.0 * (brier_before - brier_after) / brier_before : 0.0, - st.reliability, (long long)st.n_trials, wrote == 0 ? "true" : "false", - st.keystone ? "true" : "false"); + st.reliability, (long long)st.n_trials, wrote == 0 ? "true" : "false"); jb_puts(&b, t); cog_stance_free(&st); engram_geo_free(g); return el_wrap_str(b.buf); diff --git a/lang/runtime/el_runtime.h b/lang/runtime/el_runtime.h index c43f1cf..e459f4c 100644 --- a/lang/runtime/el_runtime.h +++ b/lang/runtime/el_runtime.h @@ -732,8 +732,13 @@ el_val_t engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds); el_val_t engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds); /* COGNITION (2026-08-14): THE ONE OPERATION + grounding, surfaced live. */ el_val_t engram_think_json(el_val_t seeds, el_val_t faculty); +/* GROUNDING (2026-08-16): grounding is an attribute of the RELATION and it IS the + * hebbian weight. ground reads; ground_record writes; trajectory reads the chain. */ el_val_t engram_ground_json(el_val_t claim, el_val_t evidence, el_val_t for_whom); -el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor); +el_val_t engram_ground_record_json(el_val_t claim, el_val_t evidence, + el_val_t provenance, el_val_t floor); +el_val_t engram_ground_trajectory_json(el_val_t claim, el_val_t evidence); +el_val_t engram_assert_json(el_val_t claim_id, el_val_t for_whom, el_val_t floor, el_val_t rel_floor); el_val_t engram_attend_json(el_val_t node_id, el_val_t observer, el_val_t salience); el_val_t engram_correspondence_beat_json(el_val_t seeds, el_val_t faculty, el_val_t keystone); el_val_t engram_consolidate_permanence(el_val_t node_id); diff --git a/lang/runtime/engram_cognition.c b/lang/runtime/engram_cognition.c index 47a3840..9154a63 100644 --- a/lang/runtime/engram_cognition.c +++ b/lang/runtime/engram_cognition.c @@ -246,14 +246,6 @@ static int put_edge(EngramPagedStore* s, const char* id, const char* from, const e.metadata = (char*)meta; return store_put_edge(s, &e); } -int cog_ground_edge(EngramPagedStore* s, const char* claim_id, - const char* evidence_id, double grounding, const char* for_whom) { - if (!s || !claim_id || !evidence_id) return -1; - char id[512], meta[256]; - snprintf(id, sizeof id, "gb-%s-%s-%s", claim_id, evidence_id, for_whom ? for_whom : "global"); - snprintf(meta, sizeof meta, "for_whom=%s", for_whom ? for_whom : "-"); - return put_edge(s, id, claim_id, evidence_id, COG_GROUNDED_BY_RELATION, grounding, meta); -} int cog_salient_edge(EngramPagedStore* s, const char* node_id, const char* observer_id, double salience) { if (!s || !node_id || !observer_id) return -1; @@ -261,35 +253,386 @@ int cog_salient_edge(EngramPagedStore* s, const char* node_id, snprintf(id, sizeof id, "st-%s-%s", node_id, observer_id); return put_edge(s, id, node_id, observer_id, COG_SALIENT_TO_RELATION, salience, NULL); } -int cog_assert_gate(EngramPagedStore* s, const char* claim_id, - const char* for_whom, double floor) { - if (!s || !claim_id) return -1; - if (!(floor > 0)) floor = 0.5; - StoreEdge* edges = NULL; size_t n = 0; - if (store_get_edges_from(s, claim_id, &edges, &n) < 0) return -1; - double best = 0.0; int found = 0; - for (size_t i = 0; i < n; i++) { - if (!edges[i].relation || strcmp(edges[i].relation, COG_GROUNDED_BY_RELATION) != 0) continue; - /* grounded-for-whom: match observer if requested; global (for_whom=-) always counts */ - int match = 1; - if (for_whom && edges[i].metadata) { - const char* fw = strstr(edges[i].metadata, "for_whom="); - if (fw) { fw += 9; if (strcmp(fw, for_whom) != 0 && strcmp(fw, "-") != 0) match = 0; } - } - if (match) { found = 1; if (edges[i].weight > best) best = edges[i].weight; } - } - store_edges_free(edges, n); - if (!found) return 0; /* ungrounded => refuse assertion (still held) */ - return (best >= floor) ? 1 : 0; +/* ═══════════════════════════════════════════════════════════════════════════ + * §7 GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR. + * See engram_cognition.h §7 for the model and for the measurements the two + * design decisions (thirteen regions, min aggregate) rest on. + * ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── The one decay model. Moved here verbatim from el_runtime.c's + * engram_temporal_decay so nodes and edges share a single implementation and a + * single set of constants; engram_temporal_decay now delegates. Bit-identical + * for nodes: reinforcements := activation_count, lambda_override := + * temporal_decay_rate. + * + * This is what makes decay ANALYTIC rather than sampled: between two recorded + * versions the trajectory is not unknown, it is known in closed form from the + * last point and elapsed time. Store the point, read the curve. */ +double cog_decay_factor(int64_t age_ms, double reinforcements, double lambda_override) { + if (age_ms <= 0) return 1.0; + double lambda = (lambda_override > 0.0) ? lambda_override : COG_DECAY_LAMBDA; + double age_hours = (double)age_ms / 3600000.0; + if (reinforcements < 0) reinforcements = 0; + double t_half = COG_T_HALF_HOURS * (1.0 + log(1.0 + reinforcements)); + double factor = exp(-lambda * age_hours / t_half); + if (factor < COG_DECAY_FLOOR) factor = COG_DECAY_FLOOR; + return factor; } +const char* cog_prov_name(CogProvClass p) { + switch (p) { + case COG_PROV_OBSERVED: return "observed"; + case COG_PROV_INFERRED: return "inferred"; + case COG_PROV_TOLD: return "told"; + case COG_PROV_IMPRINTED: return "imprinted"; + default: return "unset"; + } +} +CogProvClass cog_prov_parse(const char* s) { + if (!s) return COG_PROV_UNSET; + if (!strcmp(s, "observed")) return COG_PROV_OBSERVED; + if (!strcmp(s, "inferred")) return COG_PROV_INFERRED; + if (!strcmp(s, "told")) return COG_PROV_TOLD; + if (!strcmp(s, "imprinted")) return COG_PROV_IMPRINTED; + return COG_PROV_UNSET; +} + +/* Locate the GRD1 block in an edge's metadata. It is always the tail; anything + * ahead of it is the edge's pre-existing metadata, preserved verbatim. */ +static const char* cog_grd_find(const char* meta) { + if (!meta) return NULL; + size_t ml = strlen(COG_GROUNDING_META_MAGIC); + if (strncmp(meta, COG_GROUNDING_META_MAGIC, ml) == 0) return meta; + const char* p = meta; + while ((p = strstr(p, COG_GROUNDING_META_MAGIC)) != NULL) { + if (p > meta && p[-1] == '\n') return p; + p += ml; + } + return NULL; +} + +int cog_grounding_parse(const StoreEdge* e, int64_t now_ms, CogGrounding* out) { + if (!e || !out) return -1; + memset(out, 0, sizeof *out); + + /* Two dimensions exist on every edge whether or not grounding has ever been + * established, because they ARE existing substrate rather than new fields: + * associative — the accrued hebb, with its existing dynamics; + * polarity — the signed authored weight. `inhibitory` is precisely this + * distinction crushed to one bit, so it is the seed sign. */ + out->associative = e->hebb; + out->polarity = e->inhibitory ? -e->weight : e->weight; + out->prov = COG_PROV_UNSET; + out->ts = e->last_fired > 0 ? e->last_fired : e->updated_at; + + const char* blk = cog_grd_find(e->metadata); + if (blk) { + out->present = 1; + char* copy = dupstr(blk); + if (!copy) return -1; + for (char* line = strtok(copy, "\n"); line; line = strtok(NULL, "\n")) { + if (line[0] == '\0') continue; + char tag = line[0]; + const char* rest = line + 1; while (*rest == ' ') rest++; + if (tag == 'w') { /* the four numeric dimensions */ + double v[4] = {0,0,0,0}; parse_floats(rest, v, 4); + out->factual = v[0]; out->relational = v[1]; + out->associative = v[2]; out->polarity = v[3]; + } else if (tag == 'k') { /* provenance class */ + out->prov = cog_prov_parse(rest); + } else if (tag == 't') { /* timestamp + seq + reinforcements */ + double v[3] = {0,0,0}; parse_floats(rest, v, 3); + out->ts = (int64_t)v[0]; out->seq = (int64_t)v[1]; out->reinforcements = v[2]; + } else if (tag == 'd') { + double v[3] = {0,0,0}; parse_floats(rest, v, 3); + out->fac_proj = v[0]; out->rel_proj = v[1]; out->cos_angle = v[2]; + } else if (tag == 'v') { + snprintf(out->binding_value, sizeof out->binding_value, "%s", rest); + } else if (tag == 'c') { + double v[2] = {0,0}; parse_floats(rest, v, 2); + out->floor_at_record = v[0]; out->rel_floor_at_record = v[1]; + } else if (tag == 'p') { + snprintf(out->prev_edge, sizeof out->prev_edge, "%s", rest); + } + } + free(copy); + } + out->agreement = (out->cos_angle > 0) ? 1 : (out->cos_angle < 0 ? -1 : 0); + + /* ── DERIVED. Nothing below this line is ever serialized. Recency, decay and + * staleness are read off the curve; storing them is how a number ends up + * asserting something nothing computed (§8.1 / spec §2). */ + out->age_ms = (out->ts > 0 && now_ms > out->ts) ? (now_ms - out->ts) : 0; + out->decay = cog_decay_factor(out->age_ms, out->reinforcements, 0.0); + out->factual_now = out->factual * out->decay; + out->relational_now = out->relational * out->decay; + out->associative_now = out->associative * out->decay; + out->stale = (out->present && out->floor_at_record > 0 && + out->factual_now < out->floor_at_record) ? 1 : 0; + return 0; +} + +char* cog_grounding_metadata(const char* base_meta, const CogGrounding* g) { + if (!g) return NULL; + size_t keep = 0; + if (base_meta) { + const char* blk = cog_grd_find(base_meta); + keep = blk ? (size_t)(blk - base_meta) : strlen(base_meta); + while (keep > 0 && base_meta[keep - 1] == '\n') keep--; + } + size_t cap = keep + 1024; + char* buf = malloc(cap); if (!buf) return NULL; + size_t o = 0; + if (keep) { memcpy(buf, base_meta, keep); o = keep; buf[o++] = '\n'; } + o += (size_t)snprintf(buf + o, cap - o, "%s\n", COG_GROUNDING_META_MAGIC); + /* STORED ONLY. factual / relational / associative / polarity / provenance / + * timestamp — plus the joint state a decision saw. No confidence, no + * recency, no staleness, no volatility: those are read off the curve. */ + o += (size_t)snprintf(buf + o, cap - o, "w %.9g %.9g %.9g %.9g\n", + g->factual, g->relational, g->associative, g->polarity); + o += (size_t)snprintf(buf + o, cap - o, "k %s\n", cog_prov_name(g->prov)); + o += (size_t)snprintf(buf + o, cap - o, "t %lld %lld %.9g\n", + (long long)g->ts, (long long)g->seq, g->reinforcements); + o += (size_t)snprintf(buf + o, cap - o, "d %.9g %.9g %.9g\n", + g->fac_proj, g->rel_proj, g->cos_angle); + o += (size_t)snprintf(buf + o, cap - o, "v %s\n", g->binding_value[0] ? g->binding_value : "-"); + o += (size_t)snprintf(buf + o, cap - o, "c %.9g %.9g\n", g->floor_at_record, g->rel_floor_at_record); + if (g->prev_edge[0]) o += (size_t)snprintf(buf + o, cap - o, "p %s\n", g->prev_edge); + (void)o; + return buf; +} + +/* ── Consequence, not epsilon. Every test is a floor crossing or a sign change, + * both exact. Ordered so the two INHERENT (discrete) moves are reported in + * preference to the graded ones, because they bypass the salience gate. */ +CogSignificance cog_grounding_significant(const CogGrounding* prev, + const CogGrounding* now, + double floor, double rel_floor) { + if (!now) return COG_SIG_NONE; + if (!prev || !prev->present) return COG_SIG_FIRST_RECORD; + + /* INHERENT 1 — polarity sign flip. Ignorance and disagreement are different + * states, and support → contradiction is a change of state rather than a + * drift, so no threshold applies. Comparing signs, with zero its own class. */ + { + int sp = prev->polarity > 0 ? 1 : (prev->polarity < 0 ? -1 : 0); + int sn = now->polarity > 0 ? 1 : (now->polarity < 0 ? -1 : 0); + if (sp != sn) return COG_SIG_POLARITY_FLIP; + } + /* INHERENT 2 — provenance class change. told → observed is a categorical + * upgrade in what the relation is entitled to, not a movement along an axis. */ + if (prev->prov != now->prov) return COG_SIG_PROVENANCE_CHANGE; + + /* Crossing an assert floor — the move changes whether this relation can be + * spoken. Compared on the DECAYED values, because that is what the gate reads. */ + if ((prev->factual_now >= floor) != (now->factual_now >= floor)) return COG_SIG_FACTUAL_FLOOR; + if ((prev->relational_now >= rel_floor) != (now->relational_now >= rel_floor)) return COG_SIG_RELATIONAL_FLOOR; + + /* Flipping factual/relational agreement — the relation stops being "true and + * meaningful" and becomes "true and misapplied", or the reverse. This is the + * 911/CPS contradiction as a measured event rather than a reviewable one. */ + if (prev->agreement != now->agreement) return COG_SIG_AGREEMENT_FLIP; + + /* A gradient reversing — the evidence stopped pulling the claim toward it and + * began pushing it away, or the same on the values axis. */ + if ((prev->fac_proj > 0) != (now->fac_proj > 0)) return COG_SIG_DIRECTION_REVERSAL; + if ((prev->rel_proj > 0) != (now->rel_proj > 0)) return COG_SIG_DIRECTION_REVERSAL; + + return COG_SIG_NONE; +} + +int cog_significance_inherent(CogSignificance s) { + return (s == COG_SIG_FIRST_RECORD || s == COG_SIG_POLARITY_FLIP || + s == COG_SIG_PROVENANCE_CHANGE) ? 1 : 0; +} + +const char* cog_significance_name(CogSignificance s) { + switch (s) { + case COG_SIG_FIRST_RECORD: return "first-record"; + case COG_SIG_POLARITY_FLIP: return "polarity-sign-flip"; + case COG_SIG_PROVENANCE_CHANGE: return "provenance-class-change"; + case COG_SIG_FACTUAL_FLOOR: return "factual-floor-crossed"; + case COG_SIG_RELATIONAL_FLOOR: return "relational-floor-crossed"; + case COG_SIG_AGREEMENT_FLIP: return "agreement-sign-flip"; + case COG_SIG_DIRECTION_REVERSAL: return "gradient-direction-reversal"; + default: return "none"; + } +} + +/* ── Recording: a NEW edge record. The predecessor is never touched. ────────── */ +int cog_grounding_record(EngramPagedStore* s, const StoreEdge* base, + const CogGrounding* g, char* out_id, size_t out_id_cap) { + if (!s || !base || !base->id || !g) return -1; + char root[192]; + snprintf(root, sizeof root, "%s", base->id); + char* hash = strchr(root, '#'); if (hash) *hash = '\0'; + + int seq = (int)g->seq + 1; + char vid[224]; + snprintf(vid, sizeof vid, "%s#%d", root, seq); + + CogGrounding rec = *g; + rec.seq = seq; + snprintf(rec.prev_edge, sizeof rec.prev_edge, "%s", base->id); + + char* meta = cog_grounding_metadata(base->metadata, &rec); + if (!meta) return -1; + + StoreEdge e; memset(&e, 0, sizeof e); + e.id = vid; e.from_id = base->from_id; e.to_id = base->to_id; + e.relation = base->relation; e.metadata = meta; + /* The vector IS the weight, so the scalar fields carry their dimensions: + * `weight` the magnitude of polarity, `inhibitory` its sign, `hebb` the + * associative strength. Nothing here is a second copy of a derived value. */ + e.weight = rec.polarity < 0 ? -rec.polarity : rec.polarity; + e.inhibitory = rec.polarity < 0 ? 1 : 0; + e.hebb = rec.associative; + e.confidence = base->confidence; + e.created_at = base->created_at; + e.updated_at = rec.ts; + e.last_fired = rec.ts; + e.layer_id = base->layer_id; + int rc = store_put_edge(s, &e); + free(meta); + if (rc != 0) return -1; + if (out_id && out_id_cap) snprintf(out_id, out_id_cap, "%s", vid); + return seq; +} + +int cog_grounding_head(EngramPagedStore* s, const char* base_id, + StoreEdge* out, int max_versions) { + if (!s || !base_id || !out) return -1; + if (max_versions <= 0) max_versions = 64; + char root[192]; snprintf(root, sizeof root, "%s", base_id); + char* hash = strchr(root, '#'); if (hash) *hash = '\0'; + + StoreEdge cur; memset(&cur, 0, sizeof cur); + if (store_get_edge(s, root, &cur) != 1) return -1; + int found = 0; + for (int v = 1; v <= max_versions; v++) { + char vid[224]; snprintf(vid, sizeof vid, "%s#%d", root, v); + StoreEdge nx; + if (store_get_edge(s, vid, &nx) != 1) break; + store_edge_free(&cur); cur = nx; found = v; + } + *out = cur; + return found; +} + +/* ── VOLATILITY AND DRIFT: derived from the chain, stored nowhere. The series + * exists only because nothing was destroyed, which is the whole return on + * immutability — a derivative for free. */ +int cog_grounding_trajectory(EngramPagedStore* s, const char* base_id, + int64_t now_ms, CogTrajectory* out) { + if (!s || !base_id || !out) return -1; + memset(out, 0, sizeof *out); + char root[192]; snprintf(root, sizeof root, "%s", base_id); + char* hash = strchr(root, '#'); if (hash) *hash = '\0'; + + double pf = 0, pr = 0, f0 = 0, r0 = 0, fN = 0, rN = 0; + double sum_df = 0, sum_dr = 0; + int n = 0; + for (int v = 0; v <= 64; v++) { + char vid[224]; + if (v == 0) snprintf(vid, sizeof vid, "%s", root); + else snprintf(vid, sizeof vid, "%s#%d", root, v); + StoreEdge e; + if (store_get_edge(s, vid, &e) != 1) { if (v) break; else continue; } + CogGrounding g; + if (cog_grounding_parse(&e, now_ms, &g) == 0) { + if (n == 0) { f0 = g.factual; r0 = g.relational; } + else { sum_df += fabs(g.factual - pf); sum_dr += fabs(g.relational - pr); } + pf = g.factual; pr = g.relational; fN = pf; rN = pr; + n++; + } + store_edge_free(&e); + } + out->n_versions = n; + if (n > 1) { + out->factual_volatility = sum_df / (double)(n - 1); + out->relational_volatility = sum_dr / (double)(n - 1); + } + out->factual_drift = fN - f0; + out->relational_drift = rN - r0; + /* "STAYED TRUE, BECAME WRONG" — the event the joint record makes visible and + * that per-dimension versioning would have destroyed: the fact held while + * the meaning degraded. Expressed as signs, so there is no tolerance here + * either: factual did not fall, relational did. */ + out->stayed_true_became_wrong = + (n > 1 && out->factual_drift >= 0 && out->relational_drift < 0) ? 1 : 0; + return 0; +} + +/* ── Assertion gates on BOTH floors. Traversal is untouched: activation still + * conducts on the factual/associative side, so a relation can remain thinkable + * while ceasing to be assertable. That gap is where the wide angles live. ──── */ +int cog_assert_two_axis(EngramPagedStore* s, const char* claim_id, + double floor, double rel_floor, int64_t now_ms, + CogAssertion* out) { + if (!s || !claim_id || !out) return -1; + memset(out, 0, sizeof *out); + if (!(floor > 0)) floor = 0.5; + if (!(rel_floor > 0)) rel_floor = floor; + + /* still_held is DERIVED, not a literal (§8.1). Holding is unconditional — + * the store gates nothing — so the question the field actually answers is + * whether the content is present and live. */ + StoreNode n; + if (store_get_node(s, claim_id, &n) == 1) { out->still_held = !n.tombstoned; store_node_free(&n); } + else out->still_held = 0; + + double best = -1.0; + for (int dir = 0; dir < 2; dir++) { + StoreEdge* edges = NULL; size_t ne = 0; + int rc = dir == 0 ? store_get_edges_from(s, claim_id, &edges, &ne) + : store_get_edges_to (s, claim_id, &edges, &ne); + if (rc < 0) continue; + for (size_t i = 0; i < ne; i++) { + if (edges[i].tombstoned) continue; + CogGrounding g; + if (cog_grounding_parse(&edges[i], now_ms, &g) != 0) continue; + out->n_edges++; + out->found = 1; + if (g.factual_now > best) { + best = g.factual_now; + out->factual = g.factual_now; + out->relational = g.relational_now; /* the SAME edge, not a max */ + out->polarity = g.polarity; + out->cos_angle = g.cos_angle; + out->agreement = g.agreement; + out->prov = g.prov; + out->relational_established = g.present; + snprintf(out->best_edge, sizeof out->best_edge, "%s", edges[i].id ? edges[i].id : ""); + snprintf(out->binding_value, sizeof out->binding_value, "%s", g.binding_value); + } + } + store_edges_free(edges, ne); + } + /* BOTH floors, and an unestablished relational axis does NOT pass by default + * — defaulting it to passing is the exemption §0 forbids. A negative polarity + * is a relation that actively contradicts and can never license assertion. */ + out->may_assert = (out->found && out->relational_established && + out->polarity > 0 && + out->factual >= floor && out->relational >= rel_floor) ? 1 : 0; + return 0; +} + + /* ═══════════════════════════════════════════════ THE CORRESPONDENCE-LOOP ═════ */ int engram_correspondence_beat(const GeoDescriptor* region, const float* anchor, double outcome_y, CogStance* stance, int learn, double max_step, CogBeatResult* out) { if (!region || !stance || !out) return -1; memset(out, 0, sizeof *out); - if (stance->keystone) { learn = 0; out->wrote_keystone = 1; } /* §6: never write a keystone */ + /* 2026-08-16: the keystone block is GONE. It refused to learn about the + * reference frame, which does not make it a good reference — it makes it + * unexaminable, trading circular calibration for an ungroundable one (spec + * §2). Measured cost of the block: on the keystone region the beat reported + * 0.00% brier reduction over n_trials 0 — it never ran, so nothing about the + * self was ever calibrated OR falsifiable. What replaces it is a provenance + * constraint, not a permission: cog_grounding_downstream refuses evidence + * that is downstream of the region being calibrated, for every region alike. + * `wrote_keystone` is retained as a reporting field only and is always 0. */ GeoGradient g; if (engram_think(region, anchor, stance, &g) != 0) return -1; /* PREDICTION */ diff --git a/lang/runtime/engram_cognition.h b/lang/runtime/engram_cognition.h index 69e8eb0..b97071f 100644 --- a/lang/runtime/engram_cognition.h +++ b/lang/runtime/engram_cognition.h @@ -23,7 +23,7 @@ * and a region, and grounded-for-whom. * * PURE + (mostly) READ-ONLY, stdlib + libm only. think() and the warp are pure - * over their inputs. Persistence (Stance <-> StoreNode, grounded-by edges) is the + * over their inputs. Persistence (Stance <-> StoreNode, edge grounding vectors) is the * only part that touches the store, and it is additive / supersede / tombstone — * never mutate-in-place, never delete. It NEVER touches the live daemon: all * offline against a scratch store, per the design's rails. @@ -152,30 +152,25 @@ int engram_express(const GeoGradient* g, const float* anchor, float* out_point); /* ═══════════════════════════════════════════════════════════════════════════ * §5 HOLD vs GROUND vs ASSERT. Holding is unconditional (the store gates nothing). - * Grounding is a RELATION — a "grounded-by" edge, probabilistic, grounded-for-whom. - * The honesty floor is checked only at ASSERTION. + * Grounding is an ATTRIBUTE OF a relation — carried on the edge itself, as a + * vector (§7). The honesty floor is checked only at ASSERTION, on both axes. * ═══════════════════════════════════════════════════════════════════════════ */ -#define COG_GROUNDED_BY_RELATION "grounded-by" +/* DELETED 2026-08-16: COG_GROUNDED_BY_RELATION and cog_ground_edge. + * + * A "grounded-by" edge models grounding as a relation BETWEEN two nodes. It is a + * property OF a relation — and it is that relation's weight. Minting a new edge + * to carry a score was the error; #147 corrected which endpoints the edge landed + * on and left the wrong idea standing. There is nothing to ground a claim + * "against" that is not already an edge, and if no edge exists the honest answer + * is that the two are not related — not a freshly minted one scoring 0.98. + * See §7 for what replaced it. */ #define COG_SALIENT_TO_RELATION "salient-to" -/* Write a grounded-by edge (additive). weight = grounding ∈(0,1] from the verifier; - * for_whom recorded in edge metadata (grounding is relational). Never a node flag. */ -int cog_ground_edge(EngramPagedStore* s, const char* claim_id, - const char* evidence_id, double grounding, const char* for_whom); - /* Write/refresh a salient-to edge: salience is RELATIONAL (grounded-for-whom), * carried on the edge to the observer — not baked into the node scalar (§2.1). */ int cog_salient_edge(EngramPagedStore* s, const char* node_id, const char* observer_id, double salience); -/* The honesty floor — a QUERY at assertion time, NOT a schema constraint. Reads the - * claim's stored grounded-by edges (for the given observer) and returns: - * 1 = may assert (best grounding >= floor), - * 0 = REFUSE assertion (holds unconditionally; only asserting is gated), - * <0 = error. The content remains held either way. */ -int cog_assert_gate(EngramPagedStore* s, const char* claim_id, - const char* for_whom, double floor); - /* ═══════════════════════════════════════════════════════════════════════════ * §4 THE REFLEXIVE CORRESPONDENCE-LOOP — the learning engine. think scores its * OWN gradient against outcome, refines the stance on the error, and (optionally) @@ -209,8 +204,265 @@ int engram_correspondence_beat(const GeoDescriptor* region, const float* anchor, /* ═══════════════════════════════════════════════════════════════════════════ * §6 METASTABILITY. Keystones (self/values) are read-mostly: the loop reads but * never writes them. Mark by stance flag or by a keystone-id set the loop consults. + * + * SUPERSEDED BY §7's PROVENANCE CONSTRAINT (2026-08-16). The keystone flag is a + * PERMISSION: it asks who the target is, not where the evidence came from. That + * is censorship, and it costs the ability to ever ground the self (spec + * correspondence-and-censorship.md §0/§2). The constraint that actually protects + * a reference frame is cog_grounding_downstream: a region may not be calibrated + * by evidence downstream of itself. These declarations remain only so existing + * call sites keep compiling; nothing in the grounding path consults them. * ═══════════════════════════════════════════════════════════════════════════ */ typedef struct { const char** ids; int n; } CogKeystoneSet; int cog_is_keystone(const CogKeystoneSet* ks, const CogStance* s); +/* ═══════════════════════════════════════════════════════════════════════════ + * §7 GROUNDING IS THE EDGE'S WEIGHT, AND THE WEIGHT IS A VECTOR + * (2026-08-16; spec correspondence-and-censorship.md §2–§6 @ 2b7e4ba.) + * + * THE MODEL. Grounding is not a subsystem, a score, or a relation BETWEEN nodes. + * It is an attribute OF a relation. The graph already IS the grounding structure: + * every edge is a grounded relation, and what that relation is worth is carried + * on the edge itself. Three things follow, and each DELETES rather than adds: + * + * 1. `grounded-by` as a relation type does not exist, and cog_ground_edge is + * gone. Minting an edge to hold a score models grounding as a relation + * between nodes when it is a property of a relation. #147 corrected which + * endpoints that edge landed on and left the wrong idea standing. + * 2. There is no observer, and no sampling rate. Change is not a consequence of + * use — it IS use, the way potentiation is the firing rather than something + * that reads the firing and writes a weight. So no supervisor compares a + * value to a threshold and decides to persist. + * 3. Between two recorded versions the trajectory is not unknown. Decay is a + * pure function of the last recorded point and elapsed time, so it is + * ANALYTIC: store the point, read the curve. + * + * WHAT IS *NOT* HERE, DELIBERATELY. An earlier draft of the spec posed "a graph + * predicate for evidence downstream of itself" as the hard problem, and this file + * briefly contained one. It is withdrawn. Non-circularity is TEMPORAL, not + * topological: you cannot recalibrate the ruler while measuring with it, so you + * do it when you are not using the frame to act. Reachability could never have + * worked — measured on the live store, reachability from the self region over + * all relations reaches 89.2% of the graph (10,580 of 11,861 nodes) and 16.0% + * over hebbian/semantic relations alone, so the predicate marks essentially all + * evidence tainted and the constraint degenerates into the total block that + * censorship started as. Nothing replaces it here; the independence is a fact + * about engagement, owned by the dreamer, not a fact about the graph. + * + * ═══════════════════════════════════════════════════════════════════════════ + * §7.1 THE VECTOR + * + * The test for a real dimension is whether it can move independently of the + * others. Five can, and each maps onto substrate that already exists: + * + * factual correspondence with evidence. [GRD1] + * relational correspondence with values — min over THIRTEEN + * value regions, carrying the binding value's NAME. [GRD1] + * associative co-activation frequency. This is the edge's `hebb` + * field with its existing dynamics — NOT a new one. + * Independent by construction: every superstition is + * a strong association with no factual grounding. + * polarity SIGNED. Near zero means "no support"; NEGATIVE means + * "this actively contradicts". The edge's `inhibitory` + * bit is exactly this distinction crushed to one bit, + * and is carried forward as the seed value. [GRD1] + * provenance observed / inferred / told / imprinted. Categorical, + * and load-bearing: it governs what the relation is + * entitled to. [GRD1] + * + * Plus a TIMESTAMP, which is what turns the supersession chain into a time + * series of vectors rather than a series of numbers. + * + * DERIVED, THEREFORE NEVER STORED. Confidence (high grounding AND low + * volatility), recency (decay read off the curve), staleness (grounding fallen + * below its floor), volatility (the derivative of a series nothing destroyed). + * Storing confidence separately is how `confidence: 0.5` ends up sitting beside + * a zero direction vector, asserting something nothing computed. Every field in + * CogGrounding below is marked STORED or DERIVED, and the serializer writes + * only the STORED ones. + * + * THE VALUES REFERENCE IS THIRTEEN REGIONS AND THE AGGREGATE IS MIN. + * Measured on the live store: the values root kn-5b606390 `contains` exactly 13 + * value nodes; pairwise centroid cosine among their regions is min 0.1525, + * mean 0.5199, median 0.5282, max 0.9278 — they demonstrably do not form one + * region. Against a single union region the individual values sit at cosine + * 0.38..0.89, with constraints-as-freedom at 0.3812 and change-is-the-signal at + * 0.4677, so a union centroid under-represents precisely the values a claim is + * most likely to be measured against. MIN rather than MEAN because a mean lets + * strong agreement with twelve values mask a violation of the thirteenth, which + * is the mechanism of rationalization; min yields a binding constraint with a + * NAME attached rather than a score. + * + * TRAVERSAL CONDUCTS ON FACTUAL; ASSERTION REQUIRES BOTH. If activation + * conducted on relational weight, Neuron could not follow a chain of reasoning + * to a conclusion he then rejects — censorship arriving through the spreading + * rule. The gap between reachable and assertable is where the wide + * factual/relational angles live, and that gap is the interesting part. + * ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── The one decay model (moved here from el_runtime.c so that node decay and + * edge-grounding decay are a single implementation with a single set of + * constants, rather than a model and a parallel copy of it). Half-life scales + * with how established the thing is: T_eff = T_HALF · (1 + ln(1 + reinforcements)). + * The floor is a preference, not a cliff — max penalty for age alone is 4x. + * `lambda_override` > 0 replaces the default rate; 0 means use the default. */ +#define COG_T_HALF_HOURS 168.0 +#define COG_DECAY_LAMBDA 0.693147 +#define COG_DECAY_FLOOR 0.25 +double cog_decay_factor(int64_t age_ms, double reinforcements, double lambda_override); + +/* The compact vector block carried in the edge's own metadata. Line schema, same + * precedent as STNC1 / GEO1. Metadata the edge already carried is preserved + * verbatim ahead of the magic line. */ +#define COG_GROUNDING_META_MAGIC "GRD1" + +/* Provenance class — categorical, and it governs what the relation is entitled + * to. A change of class is inherently significant and needs no threshold, + * because told → observed is a categorical upgrade, not a drift. */ +typedef enum { + COG_PROV_UNSET = 0, + COG_PROV_OBSERVED = 1, + COG_PROV_INFERRED = 2, + COG_PROV_TOLD = 3, + COG_PROV_IMPRINTED = 4 +} CogProvClass; +const char* cog_prov_name(CogProvClass p); +CogProvClass cog_prov_parse(const char* s); + +typedef struct { + int present; /* 1 iff the edge carries a GRD1 block */ + + /* ── STORED: the vector, as it stood at `ts` ─────────────────────────────── */ + double factual; /* correspondence with evidence */ + double relational; /* min over the thirteen value regions */ + double associative; /* co-activation frequency — mirrors edge->hebb */ + double polarity; /* SIGNED support; <0 = actively contradicts */ + CogProvClass prov; /* observed / inferred / told / imprinted */ + int64_t ts; /* when this version was recorded (ms) */ + int64_t seq; /* supersession sequence number */ + double reinforcements; /* uses folded into this version */ + char binding_value[128]; /* the argmin value — the conflict's NAME */ + /* the two gradients as frame-independent signed projections, plus the angle + * between them in full R^dim. These are part of the JOINT STATE a decision + * saw, not a convenience: near +1 evidence and values push the same way; at + * or below 0 the relation is factually supported and relationally wrong. */ + double fac_proj, rel_proj, cos_angle; + int agreement; /* sign(cos_angle): +1 / 0 / −1 */ + double floor_at_record, rel_floor_at_record; + char prev_edge[192]; /* the version this superseded ("" if first) */ + + /* ── DERIVED at read time. NEVER serialized. ─────────────────────────────── */ + int64_t age_ms; /* recency: now − ts */ + double decay; /* cog_decay_factor over that age */ + double factual_now; /* factual · decay */ + double relational_now; + double associative_now; + int stale; /* grounding fallen below its floor */ +} CogGrounding; + +/* Read an edge's vector as of `now_ms`. Pure — never writes. An edge with no + * GRD1 block still has an associative strength (its accrued hebb) and a polarity + * (its signed authored weight); `present` says whether the grounding dimensions + * have ever been established, and an unestablished dimension is reported as such + * rather than defaulted to a passing value. */ +int cog_grounding_parse(const StoreEdge* e, int64_t now_ms, CogGrounding* out); + +/* Serialize the STORED half of the vector, preserving pre-existing non-GRD1 + * metadata. Returns an owned string. Derived fields are not written. */ +char* cog_grounding_metadata(const char* base_meta, const CogGrounding* g); + +/* ── §7.2 CONSOLIDATION-GATED SUPERSESSION ────────────────────────────────── + * + * Supersession is not recording — it is CONSOLIDATION, gated by salience, which + * is why you remember the argument and not the commute. Significance is + * evaluated PER-DIMENSION but the record is the WHOLE VECTOR: any dimension + * moving enough to matter triggers a supersession, and the new version captures + * every dimension as it stood at that instant. Versioning axes independently + * would make the joint state unreconstructable, and the joint state is the point + * — it is what makes "stayed true, became wrong" visible as an event (factual + * holding steady across versions while relational degrades). + * + * There is deliberately no epsilon in this enum or in the function that computes + * it. Every test is a floor crossing or a sign change, both exact. Two of them + * are INHERENTLY significant because they are discrete state changes rather than + * drift, and those bypass the salience gate entirely. */ +typedef enum { + COG_SIG_NONE = 0, /* nothing decision-relevant moved — DO NOT RECORD */ + COG_SIG_FIRST_RECORD = 1, /* no prior version exists */ + COG_SIG_POLARITY_FLIP = 2, /* INHERENT: support ↔ contradiction, or ignorance + * ↔ either. A discrete change of state. */ + COG_SIG_PROVENANCE_CHANGE = 3, /* INHERENT: told → observed is a categorical + * upgrade in what the relation is entitled to. */ + COG_SIG_FACTUAL_FLOOR = 4, /* crossed the assert floor, factual axis */ + COG_SIG_RELATIONAL_FLOOR = 5, /* crossed the assert floor, relational axis */ + COG_SIG_AGREEMENT_FLIP = 6, /* factual/relational agreement changed sign */ + COG_SIG_DIRECTION_REVERSAL = 7 /* a gradient reversed direction */ +} CogSignificance; + +CogSignificance cog_grounding_significant(const CogGrounding* prev, + const CogGrounding* now, + double floor, double rel_floor); +const char* cog_significance_name(CogSignificance s); +/* 1 iff this reason is a discrete state change that consolidates regardless of + * salience (polarity flip, provenance change, first record). */ +int cog_significance_inherent(CogSignificance s); + +/* ── §7.3 RECORDING: supersession of the EDGE, never an overwrite ──────────── + * Writes version seq+1 as a NEW edge record with the same endpoints and relation + * and id "#", carrying a GRD1 `p` pointer to its predecessor. The + * predecessor is never touched. The chain IS the trajectory: not only what the + * grounding is but which way it has been moving and how fast — a derivative + * obtained for free from immutability, because the points were never destroyed. + * Returns the version written (>=1), or <0 on error. */ +int cog_grounding_record(EngramPagedStore* s, const StoreEdge* base, + const CogGrounding* g, char* out_id, size_t out_id_cap); + +/* Walk forward from a base edge id to its newest recorded version. Point reads + * only; consolidation is gated, so the chain is short. Returns the highest + * version found (0 = the base record is the only one). */ +int cog_grounding_head(EngramPagedStore* s, const char* base_id, + StoreEdge* out, int max_versions); + +/* VOLATILITY — derived, never stored: the mean absolute per-version change of a + * dimension across the recorded chain. Feeds the equally-derived `confidence` + * (high grounding AND low volatility), which is likewise never stored. */ +typedef struct { + int n_versions; + double factual_volatility; + double relational_volatility; + double factual_drift; /* signed: newest − oldest */ + double relational_drift; + int stayed_true_became_wrong; /* factual steady while relational degraded */ +} CogTrajectory; +int cog_grounding_trajectory(EngramPagedStore* s, const char* base_id, + int64_t now_ms, CogTrajectory* out); + +/* ── §7.4 ASSERTION GATES ON BOTH FLOORS ──────────────────────────────────── + * A well-evidenced claim must not earn the right to be asserted regardless of + * whether it means the right thing. `may_assert` requires the decayed factual + * grounding to clear `floor` AND the decayed relational grounding to clear + * `rel_floor`. A relation whose relational axis has never been established does + * not pass by default — it is reported unestablished and refused, because + * defaulting it to passing is exactly the exemption §0 forbids. Traversal is + * untouched: activation still conducts on the factual/associative side, so a + * relation can remain thinkable while ceasing to be assertable. */ +typedef struct { + int may_assert; + int found; /* any relation at all on this claim */ + int relational_established; + int still_held; /* DERIVED: node present and not tombstoned */ + double factual; /* best decayed factual grounding */ + double relational; /* the SAME edge's relational axis, not a max */ + double polarity; + double cos_angle; + int agreement; + CogProvClass prov; + char best_edge[192]; + char binding_value[128]; + int n_edges; +} CogAssertion; +int cog_assert_two_axis(EngramPagedStore* s, const char* claim_id, + double floor, double rel_floor, int64_t now_ms, + CogAssertion* out); + #endif /* ENGRAM_COGNITION_H */