From a3358dfc955c65a5820086342c54d63ec6b9e502 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Thu, 13 Aug 2026 01:33:14 -0500 Subject: [PATCH] =?UTF-8?q?Reasoning=20layer:=20analogy/induction/abductio?= =?UTF-8?q?n/causal/planning=20over=20=C2=A75=20geometry=20ops?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the live relational-neighborhood geometry OPERATORS into five reasoning modes as pure, read-only C (engram_reason.{h,c}); each is proven with closed-form constructed tests before it ships, not declared. - ANALOGY (Procrustes R + residual translation, apply to C, rank candidates) - INDUCTION (combine-pooled rule geometry + point-to-manifold membership) - ABDUCTION (best-explaining structure by point-to-manifold fit) - CAUSAL (centroid-cosine correlation vs directed influence: temporal precedence + association surviving confounder control via subtract; emits a correlation-vs-causation flag) - PLANNING (geo-distance edges + Dijkstra → discrete geodesic path) A shared point-to-manifold fit primitive underlies induction membership and abduction ranking. engram/test/run_reason_tests.sh: 33/33 checks on both PERF and ASan/UBSan passes; macOS leaks 0/0. ANALOGY is surfaced as an el builtin (engram_reason_analogy_json) via the same pass-through the §5 operators use — demonstrated callable from compiled El with a container-capped fold (no self-host fold). The other four are C-layer only: their set/point/timestamp inputs do not map to the flat-CSV el ABI without touching codegen (deferred). engram_reason.c must join the server link line beside engram_geometry.c at cutover. See docs/runbooks/2026-08-13-reasoning-operators-*. --- ...-reasoning-operators-decisions-reversal.md | 107 +++++++ engram/test/run_reason_tests.sh | 23 ++ engram/test/test_reason.c | 255 ++++++++++++++++ lang/runtime/el_runtime.c | 27 ++ lang/runtime/el_runtime.h | 4 + lang/runtime/el_seed.c | 4 + lang/runtime/engram_reason.c | 287 ++++++++++++++++++ lang/runtime/engram_reason.h | 161 ++++++++++ 8 files changed, 868 insertions(+) create mode 100644 docs/runbooks/2026-08-13-reasoning-operators-decisions-reversal.md create mode 100755 engram/test/run_reason_tests.sh create mode 100644 engram/test/test_reason.c create mode 100644 lang/runtime/engram_reason.c create mode 100644 lang/runtime/engram_reason.h diff --git a/docs/runbooks/2026-08-13-reasoning-operators-decisions-reversal.md b/docs/runbooks/2026-08-13-reasoning-operators-decisions-reversal.md new file mode 100644 index 0000000..840e1a2 --- /dev/null +++ b/docs/runbooks/2026-08-13-reasoning-operators-decisions-reversal.md @@ -0,0 +1,107 @@ +# Reasoning Operators — Decisions & Reversal + +**Date:** 2026-08-13 +**Branch:** `engram-tiered-storage` (worktree `/tmp/engram-tiered-wt`) +**Status:** staged locally — NOT pushed, NOT tagged, NOT merged. Live `:8742` untouched. + +## What this adds + +A **REASONING layer** built as pure C compositions over the already-live §5 geometry +OPERATORS (`engram_geometry.{h,c}`: overlap, subtract, setdiff, combine, distance, +analogy). Where the operators are a relational algebra over neighborhood descriptors, +these are reasoning *modes* built by chaining that algebra. New files: + +- `lang/runtime/engram_reason.h` — public API for the five modes + a shared + point-to-manifold fit primitive. +- `lang/runtime/engram_reason.c` — implementations. READ-ONLY over descriptor inputs, + `stdlib + libm` only, touches no store / index / activation. All geometry is + delegated to `engram_geo_*`; this file only composes. +- `engram/test/test_reason.c` + `engram/test/run_reason_tests.sh` — closed-form + constructed tests (hand-built descriptors with known answers), PERF + ASan/UBSan. + +El-exposure (pass-through, no self-host fold): +- `lang/runtime/el_runtime.c` — `+#include "engram_reason.h"` and the builtin + `engram_reason_analogy_json(a_csv,b_csv,c_csv)`. +- `lang/runtime/el_runtime.h` — its declaration. +- `lang/runtime/el_seed.c` — native `__engram_reason_analogy_json` wrapper (same + C-table wiring as the §5 ops). + +## The five modes — signatures & composition + +| Mode | C entry point | Composes | +|------|---------------|----------| +| **ANALOGY** `A:B :: C:?` | `engram_reason_analogy(A,B,C,candidates,n,out)` | `engram_geo_analogy` (Procrustes R) + `engram_geo_analogy_apply` + centroid L2. Learns `R_{A→B}` = `engram_geo_analogy(B,A)` (that op returns R with `apply(R, Y-axis)≈X-axis`), reconstructs the residual translation `t = c_B − R·c_A`, maps `mapped = R·c_C + t`, ranks candidates by distance. | +| **INDUCTION** `{E_i}→rule` | `engram_reason_induce(examples,n,top_axes,ext_floor,out)` + `engram_reason_membership` | `engram_geo_combine` folded left→right → pooled "rule" descriptor; shared subspace surfaces as the dominant pooled axes. Membership = point-to-manifold fit. | +| **ABDUCTION** `x→best H` | `engram_reason_abduce(obs,dim,hyps,n,ext_floor,out)` | shared `engram_reason_point_fit` against each hypothesis; argmax fit score; full ranking. | +| **CAUSAL** `x?y \| Z,t` | `engram_reason_causal(x,y,confounders,nZ,t_x,t_y,drop_frac,out)` | centroid cosine (raw correlation) + `engram_geo_subtract` residual-centroid (control for each confounder, take the strongest single explainer) + temporal precedence. Verdict `DIRECTED` / `CONFOUNDED` / `NONE` + a `confounded` flag. | +| **PLANNING** `start→goal` | `engram_reason_plan(nodes,n,start,goal,radius,use_w,out)` | `engram_geo_distance` as edge weights over neighborhoods within `radius`; O(n²) Dijkstra → discrete geodesic path. | + +Shared primitive `engram_reason_point_fit` splits `(x − centroid)` into an in-subspace +Mahalanobis distance (scaled by axis extents) and an orthogonal off-model residual; +it is the single engine under INDUCTION's membership test and ABDUCTION's ranking. + +## Proof (DONE-WITH-PROOF) + +`engram/test/run_reason_tests.sh`: **33/33 checks, 0 failures** on BOTH passes +(PERF -O2, and ASan+UBSan). macOS `leaks --atExit`: **0 leaks / 0 total leaked bytes**. + +Per-mode closed-form assertions actually exercised: +- **ANALOGY** — A→B = +90° rotation in e0-e1 plane + a +5 shift in e2; Procrustes + residual `~0`; predicted point `(0,2,5,0)` recovered exactly; nearest candidate = + the planted true D (index 1), distance `~0`. +- **INDUCTION** — 3 examples sharing span(e0,e1) (extents 1.0 / 0.8) each with a small + idiosyncratic axis (e2 or e3); induced top-2 axes lie in span(e0,e1) (extents + recovered ~1.0 / ~0.8); held-out in-plane point fits (membership 0.885), off-subspace + point rejected (0.100), in-plane-but-far point rejected (0.039). +- **ABDUCTION** — observation planted inside H1 among {H0,H1,H2}; best = H1, rank[0] = H1, + H1 smallest distance. +- **CAUSAL** — chain A→B→C along e0 (t 1<2<3) + confounder Z(e1) that leaks into A and + drives D(t=4): A→B and B→C flagged `DIRECTED` with correct precedence and association + that survives control; A–D `CONFOUNDED` (raw |cos|=0.707 collapses to 0.0 under + control) with `confounded=1`; B–D `NONE` (no association). +- **PLANNING** — 6 neighborhoods on a semicircle (r=10); `neighbor_radius=7` admits only + consecutive hops; plan = `[0,1,2,3,4,5]` (the arc), cost `30.90` (> the 20-unit chord, + confirming it is the geodesic through the manifold, not a straight jump); a too-small + radius correctly yields `reached=0`. + +## El-exposure status + +- **ANALOGY is el-callable** via the same pass-through the §5 operators use. Proof: a + container-capped fold (`capfold.sh`, peak ~0 GB) of a demo `.el` through the shipped + `lang/dist/platform/elc` emits a *direct C call* `engram_reason_analogy_json(A,B,A)` + (no registration, no self-host fold); the generated C links against `el_runtime.c` + + `engram_reason.c` + geometry/store/vindex and runs end-to-end. (The standalone demo's + store copy boots 0 nodes — a pre-existing quirk that hits the *shipped geo demo + identically* — so the call returns `{"error":"geometry unavailable"}`; this still proves + the compiled El → C reasoning symbol → JSON chain executes. Numeric correctness on real + data is covered by the C test.) This compile also confirms `el_runtime.c` + + `engram_reason.c` compile and link clean. +- **INDUCTION / ABDUCTION / CAUSAL / PLANNING are C-layer only for now.** Their inputs are + candidate *sets*, raw *points*, and *timestamps* that do not map to the flat comma- + separated-seed El ABI. A richer marshalling surface would touch the codegen/registration + path and risk an uncapped fold — explicitly deferred per the hard rail. The C functions + are fully proven and callable from any C caller today. + +## Build wiring (for the later cutover/durability pass) + +`engram_reason.c` must be added to the engram server link line **alongside** +`engram_geometry.c` (the heavy-runtime path `cc dist/engram.c el_runtime.c +engram_store.c engram_geometry.c engram_vindex.c …`). `el_runtime.c` now +`#include`s `engram_reason.h` and references `engram_reason_analogy_json`, so a build +that omits `engram_reason.c` will fail to link that symbol. One-line addition, same as +how `engram_geometry.c` was originally added. + +## Reversal + +Fully additive; nothing existing was modified in behavior. To revert: + +1. Delete `lang/runtime/engram_reason.h`, `lang/runtime/engram_reason.c`, + `engram/test/test_reason.c`, `engram/test/run_reason_tests.sh`, and this doc. +2. In `lang/runtime/el_runtime.c`: remove `#include "engram_reason.h"` and the + `engram_reason_analogy_json` function. +3. In `lang/runtime/el_runtime.h`: remove the `engram_reason_analogy_json` declaration. +4. In `lang/runtime/el_seed.c`: remove the `__engram_reason_analogy_json` wrapper. +5. Remove `engram_reason.c` from any server link line if the cutover added it. + +No store, schema, config, WAL, or on-disk format was touched; no data migration exists, +so reversal is a pure code removal with no state to undo. diff --git a/engram/test/run_reason_tests.sh b/engram/test/run_reason_tests.sh new file mode 100755 index 0000000..6402cf7 --- /dev/null +++ b/engram/test/run_reason_tests.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Build + RUN the REASONING-layer tests (engram_reason.c): closed-form constructed +# cases for ANALOGY / INDUCTION / ABDUCTION / CAUSAL / PLANNING, each composing the +# §5 geometry OPERATORS (engram_geometry.c). 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 (memory-safety is size-independent). +set -e +HERE=$(cd "$(dirname "$0")" && pwd) +RT="$HERE/../../lang/runtime" +CC=${CC:-cc} +SRC="$HERE/test_reason.c $RT/engram_reason.c $RT/engram_geometry.c $RT/engram_store.c $RT/engram_vindex.c" +WARN="-std=c11 -Wall -Wextra" +TMP=$(mktemp -d) + +echo "### PASS 1: PERF (optimised, un-sanitised) — functional gate" +$CC $WARN -O2 -I"$RT" $SRC -lm -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 -o "$TMP/safe" +ASAN_OPTIONS=${ASAN_OPTIONS:-detect_leaks=0} UBSAN_OPTIONS=halt_on_error=1 "$TMP/safe" diff --git a/engram/test/test_reason.c b/engram/test/test_reason.c new file mode 100644 index 0000000..0bdccea --- /dev/null +++ b/engram/test/test_reason.c @@ -0,0 +1,255 @@ +/* Closed-form unit tests for the REASONING layer (engram_reason.c). All inputs are + * hand-built synthetic descriptors whose answers are known in closed form. Every + * reasoning MODE is proven, not declared. ASan/UBSan target. */ +#include "engram_reason.h" +#include +#include +#include +#include + +static int failures = 0, checks = 0; +static void ok(const char* what, int cond) { + checks++; + if (!cond) { failures++; printf(" FAIL: %s\n", what); } + else printf(" ok: %s\n", what); +} +static void approx(const char* what, double got, double exp, double tol) { + ok(what, fabs(got - exp) <= tol); + if (fabs(got - exp) > tol) printf(" got=%.9g exp=%.9g\n", got, exp); +} + +/* ── descriptor builders (mirror scratchpad/test_geo_ops.c) ─────────────────── */ +static float* vec(const double* v, int dim) { + float* f = malloc((size_t)dim * sizeof(float)); + for (int i = 0; i < dim; i++) f[i] = (float)v[i]; + return f; +} +static GeoDescriptor* mk(int dim, const double* centroid, + int n_axes, const double* axis_flat, const double* extents, + int n_members, const char** ids, double total_var) { + GeoDescriptor* g = calloc(1, sizeof(GeoDescriptor)); + g->dim = dim; + g->centroid = centroid ? vec(centroid, dim) : NULL; + g->global_mean = NULL; + g->n_axes = n_axes; + g->axes = n_axes ? calloc((size_t)n_axes, sizeof(GeoAxis)) : NULL; + double tr = 0; + for (int k = 0; k < n_axes; k++) { + g->axes[k].axis = vec(&axis_flat[(size_t)k * dim], dim); + g->axes[k].extent = extents[k]; + tr += extents[k] * extents[k]; + } + g->total_variance = (total_var >= 0) ? total_var : tr; + g->radius = sqrt(g->total_variance > 0 ? g->total_variance : 0); + g->n_members = n_members; g->n_embedded = n_members; + g->members = n_members ? calloc((size_t)n_members, sizeof(GeoMember)) : NULL; + for (int i = 0; i < n_members; i++) { + g->members[i].id = strdup(ids[i]); + g->members[i].membership = 1.0; + g->members[i].centrality = (double)(n_members - i); + g->members[i].embedded = 1; + } + g->hub_id = n_members ? strdup(ids[0]) : strdup(""); + g->k_core = 1; g->co_registration = 0.0; g->n_edges = 0; g->edges = NULL; + return g; +} + +int main(void) { + printf("== REASONING layer unit tests ==\n"); + + /* ══════════════════ ANALOGY — recover an affine A→B, apply to C ══════════ */ + /* A→B is a +90° rotation in the e0-e1 plane ((x,y)→(-y,x)) plus a +5 shift in e2. + * A frame = (e0,e1); B frame = rotated (e1,-e0); cB = R·cA + t. Predict D from C. */ + { + int dim = 4; + double cA[4] = {1,0,0,0}; + double cB[4] = {0,1,5,0}; /* R·(1,0,0,0)=(0,1,0,0) + (0,0,5,0) */ + double cC[4] = {2,0,0,0}; + double axA[8] = {1,0,0,0, 0,1,0,0}; double exA[2] = {1,1}; + double axB[8] = {0,1,0,0, -1,0,0,0}; double exB[2] = {1,1}; /* R·e0, R·e1 */ + double axC[8] = {1,0,0,0, 0,1,0,0}; double exC[2] = {1,1}; + const char* idA[1] = {"A"}, *idB[1] = {"B"}, *idC[1] = {"C"}; + GeoDescriptor* A = mk(dim, cA, 2, axA, exA, 1, idA, -1); + GeoDescriptor* B = mk(dim, cB, 2, axB, exB, 1, idB, -1); + GeoDescriptor* C = mk(dim, cC, 2, axC, exC, 1, idC, -1); + /* candidates: the true D + two distractors. true D = R·cC + t = (0,2,5,0). */ + double d_true[4] = {0,2,5,0}, d_far1[4] = {9,9,9,9}, d_far2[4] = {0,0,0,0}; + const char* idD[1] = {"Dt"}, *idF1[1] = {"F1"}, *idF2[1] = {"F2"}; + GeoDescriptor* Dt = mk(dim, d_true, 0, NULL, NULL, 1, idD, 0.0); + GeoDescriptor* F1 = mk(dim, d_far1, 0, NULL, NULL, 1, idF1, 0.0); + GeoDescriptor* F2 = mk(dim, d_far2, 0, NULL, NULL, 1, idF2, 0.0); + const GeoDescriptor* cand[3] = {F1, Dt, F2}; /* true one at index 1 */ + GeoAnalogyResult res; + int rc = engram_reason_analogy(A, B, C, cand, 3, &res); + ok("analogy returns 0", rc == 0); + printf("[analogy] residual=%.6f mapped=(%.4f,%.4f,%.4f,%.4f) best=%d bd=%.5f\n", + res.analogy_residual, res.mapped_point[0], res.mapped_point[1], + res.mapped_point[2], res.mapped_point[3], res.best, res.best_distance); + approx("procrustes residual ~0", res.analogy_residual, 0.0, 1e-4); + approx("mapped.x=0", res.mapped_point[0], 0.0, 1e-4); + approx("mapped.y=2", res.mapped_point[1], 2.0, 1e-4); + approx("mapped.z(e2)=5", res.mapped_point[2], 5.0, 1e-4); + ok("nearest candidate = true D (idx 1)", res.best == 1); + approx("best distance ~0", res.best_distance, 0.0, 1e-3); + engram_reason_analogy_free(&res); + engram_geo_free(A); engram_geo_free(B); engram_geo_free(C); + engram_geo_free(Dt); engram_geo_free(F1); engram_geo_free(F2); + } + + /* ══════════════════ INDUCTION — recover a shared subspace + membership ═══ */ + /* 3 examples all spread over span(e0,e1) (ext 1 & 0.8), each with a small + * idiosyncratic axis (e2 or e3, ext 0.2). Centroids all 0. The induced rule's + * top-2 axes must lie in span(e0,e1); a held-out in-plane point fits, an + * off-subspace point does not. */ + { + int dim = 4; + double c0[4] = {0,0,0,0}; + double axsh[8] = {1,0,0,0, 0,1,0,0}; double exsh[2] = {1.0, 0.8}; + double ax1[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0}; double ex1[3] = {1.0,0.8,0.2}; /* +e2 */ + double ax2[12] = {1,0,0,0, 0,1,0,0, 0,0,0,1}; double ex2[3] = {1.0,0.8,0.2}; /* +e3 */ + const char* i1[2] = {"e1a","e1b"}, *i2[2] = {"e2a","e2b"}, *i3[2] = {"e3a","e3b"}; + GeoDescriptor* E1 = mk(dim, c0, 3, ax1, ex1, 2, i1, -1); + GeoDescriptor* E2 = mk(dim, c0, 3, ax2, ex2, 2, i2, -1); + GeoDescriptor* E3 = mk(dim, c0, 2, axsh, exsh, 2, i3, -1); + const GeoDescriptor* ex[3] = {E1, E2, E3}; + GeoInduction ind; + int rc = engram_reason_induce(ex, 3, 8, 1.0, &ind); + ok("induce returns 0", rc == 0); + printf("[induction] rule n_axes=%d ext0=%.4f ext1=%.4f\n", + ind.rule->n_axes, ind.rule->n_axes > 0 ? ind.rule->axes[0].extent : 0, + ind.rule->n_axes > 1 ? ind.rule->axes[1].extent : 0); + /* top-2 axes lie in span(e0,e1): their e2,e3 components ~0. */ + int inplane = 1; + for (int k = 0; k < 2 && k < ind.rule->n_axes; k++) { + const float* a = ind.rule->axes[k].axis; + printf(" axis%d=(%.3f,%.3f,%.3f,%.3f) ext=%.4f\n", k, a[0],a[1],a[2],a[3], ind.rule->axes[k].extent); + if (fabs(a[2]) > 0.06 || fabs(a[3]) > 0.06) inplane = 0; + } + ok("induced top-2 axes lie in shared span(e0,e1)", inplane); + approx("dominant extent ~1.0", ind.rule->axes[0].extent, 1.0, 0.06); + approx("second extent ~0.8", ind.rule->axes[1].extent, 0.8, 0.06); + /* membership: in-plane near-centroid positive fits; off-subspace negative doesn't. */ + float xpos[4] = {0.3f, -0.2f, 0, 0}; + float xneg[4] = {0, 0, 3.0f, 0}; /* large along e2 — outside the rule */ + float xfar[4] = {5.0f, 0, 0, 0}; /* in-plane but far — Mahalanobis blows up */ + double mp = engram_reason_membership(&ind, xpos); + double mn = engram_reason_membership(&ind, xneg); + double mf = engram_reason_membership(&ind, xfar); + printf("[induction] membership pos=%.4f neg=%.4f far=%.4f\n", mp, mn, mf); + ok("held-out positive fits (>0.5)", mp > 0.5); + ok("off-subspace negative rejected (<0.3)", mn < 0.3); + ok("in-plane-but-far rejected (<0.3)", mf < 0.3); + ok("positive fits far better than negative", mp > mn + 0.4); + engram_reason_induction_free(&ind); + engram_geo_free(E1); engram_geo_free(E2); engram_geo_free(E3); + } + + /* ══════════════════ ABDUCTION — pick the best-explaining structure ═══════ */ + /* obs planted near H1's centroid among 3 candidate structures. */ + { + int dim = 4; + double h0[4] = {0,0,0,0}, h1[4] = {5,0,0,0}, h2[4] = {0,5,0,0}; + double ax[8] = {1,0,0,0, 0,1,0,0}; double ex[2] = {1,1}; + const char* n0[1] = {"H0"}, *n1[1] = {"H1"}, *n2[1] = {"H2"}; + GeoDescriptor* H0 = mk(dim, h0, 2, ax, ex, 1, n0, -1); + GeoDescriptor* H1 = mk(dim, h1, 2, ax, ex, 1, n1, -1); + GeoDescriptor* H2 = mk(dim, h2, 2, ax, ex, 1, n2, -1); + const GeoDescriptor* H[3] = {H0, H1, H2}; + float obs[4] = {5.2f, 0.1f, 0, 0}; /* sits inside H1 */ + GeoAbduction ab; + int rc = engram_reason_abduce(obs, dim, H, 3, 1.0, &ab); + ok("abduce returns 0", rc == 0); + printf("[abduction] best=%d best_score=%.4f rank=[%d,%d,%d] d=[%.3f,%.3f,%.3f]\n", + ab.best, ab.best_score, ab.rank[0], ab.rank[1], ab.rank[2], + ab.distances[0], ab.distances[1], ab.distances[2]); + ok("best explanation = H1", ab.best == 1); + ok("rank[0] = H1", ab.rank[0] == 1); + ok("H1 has smallest distance", ab.distances[1] < ab.distances[0] && ab.distances[1] < ab.distances[2]); + engram_reason_abduction_free(&ab); + engram_geo_free(H0); engram_geo_free(H1); engram_geo_free(H2); + } + + /* ══════════════════ CAUSAL — direction + confounder flag ═════════════════ */ + /* Chain A→B→C along e0 (temporal 1<2<3). Confounder Z (e1) injects into A and + * drives D (t=4). A–D correlate only via Z ⇒ must be flagged CONFOUNDED. */ + { + int dim = 4; + double cA[4] = {1,1,0,0}; /* e0 (chain) + e1 (confounder leak) */ + double cB[4] = {1,0,0,0}; /* e0 */ + double cC[4] = {2,0,0,0}; /* e0 */ + double cD[4] = {0,1,0,0}; /* e1 only — driven by Z */ + double cZ[4] = {0,1,0,0}; /* confounder centroid */ + double axZ[4] = {0,1,0,0}; double exZ[1] = {1}; /* Z's subspace = e1 */ + const char* idA[1]={"A"},*idB[1]={"B"},*idC[1]={"C"},*idD[1]={"D"},*idZ[1]={"Z"}; + GeoDescriptor* A = mk(dim, cA, 0, NULL, NULL, 1, idA, 0.0); + GeoDescriptor* B = mk(dim, cB, 0, NULL, NULL, 1, idB, 0.0); + GeoDescriptor* C = mk(dim, cC, 0, NULL, NULL, 1, idC, 0.0); + GeoDescriptor* D = mk(dim, cD, 0, NULL, NULL, 1, idD, 0.0); + GeoDescriptor* Z = mk(dim, cZ, 1, axZ, exZ, 1, idZ, -1); + const GeoDescriptor* conf[1] = {Z}; + + GeoCausal ab, bc, ad, bd; + engram_reason_causal(A, B, conf, 1, /*t*/1, 2, 0.5, &ab); + engram_reason_causal(B, C, conf, 1, 2, 3, 0.5, &bc); + engram_reason_causal(A, D, conf, 1, 1, 4, 0.5, &ad); + engram_reason_causal(B, D, conf, 1, 2, 4, 0.5, &bd); + printf("[causal] A->B: raw=%.3f ctrl=%.3f dir=%d verdict=%d strength=%.3f\n", + ab.assoc_raw, ab.assoc_controlled, ab.temporal_dir, ab.verdict, ab.strength); + printf("[causal] B->C: raw=%.3f ctrl=%.3f dir=%d verdict=%d\n", bc.assoc_raw, bc.assoc_controlled, bc.temporal_dir, bc.verdict); + printf("[causal] A--D: raw=%.3f ctrl=%.3f dir=%d verdict=%d confounded=%d\n", + ad.assoc_raw, ad.assoc_controlled, ad.temporal_dir, ad.verdict, ad.confounded); + printf("[causal] B--D: raw=%.3f verdict=%d\n", bd.assoc_raw, bd.verdict); + ok("A->B DIRECTED", ab.verdict == GEO_CAUSAL_DIRECTED); + ok("A->B direction A precedes B", ab.temporal_dir == 1); + ok("A->B association survives control (ctrl high)", ab.assoc_controlled > 0.6); + ok("B->C DIRECTED", bc.verdict == GEO_CAUSAL_DIRECTED); + ok("A--D CONFOUNDED (flagged)", ad.verdict == GEO_CAUSAL_CONFOUNDED && ad.confounded == 1); + ok("A--D raw correlated but control kills it", ad.assoc_raw > 0.6 && ad.assoc_controlled < 0.2); + ok("B--D NONE (no association at all)", bd.verdict == GEO_CAUSAL_NONE); + engram_geo_free(A); engram_geo_free(B); engram_geo_free(C); engram_geo_free(D); engram_geo_free(Z); + } + + /* ══════════════════ PLANNING — geodesic path along a curved manifold ═════ */ + /* 6 neighborhoods on a semicircle (radius 10). Consecutive chord ~6.18, + * skip-one ~11.76, endpoints ~20. neighbor_radius=7 admits only consecutive + * hops ⇒ the plan must traverse the whole arc 0→1→2→3→4→5. */ + { + int dim = 4; int N = 6; double R = 10.0; + GeoDescriptor* nodes[6]; + char nm[6][8]; + for (int k = 0; k < N; k++) { + double th = M_PI * (double)k / (double)(N - 1); + double c[4] = { R * cos(th), R * sin(th), 0, 0 }; + snprintf(nm[k], sizeof nm[k], "n%d", k); + const char* id[1] = { nm[k] }; + nodes[k] = mk(dim, c, 0, NULL, NULL, 1, id, 0.0); + } + const GeoDescriptor* cn[6]; + for (int k = 0; k < N; k++) cn[k] = nodes[k]; + GeoPlan plan; + int rc = engram_reason_plan(cn, N, 0, 5, 7.0, 0, &plan); + ok("plan returns 0", rc == 0); + printf("[planning] reached=%d len=%d cost=%.4f path=[", plan.reached, plan.path_len, plan.total_cost); + for (int i = 0; i < plan.path_len; i++) printf("%s%d", i ? "," : "", plan.path[i]); + printf("]\n"); + ok("goal reached", plan.reached == 1); + ok("path length = 6 (full arc)", plan.path_len == 6); + int monotone = (plan.path_len == 6); + for (int i = 0; i < plan.path_len; i++) if (plan.path[i] != i) monotone = 0; + ok("path = 0,1,2,3,4,5 (the geodesic)", monotone); + /* arc cost ~ 5 * 6.18 = 30.9, and strictly longer than the 20-unit chord. */ + approx("arc cost ~30.9", plan.total_cost, 30.9, 0.6); + ok("arc longer than straight chord (20)", plan.total_cost > 20.0); + engram_reason_plan_free(&plan); + + /* negative control: radius too small to connect anything ⇒ unreachable. */ + GeoPlan p2; + engram_reason_plan(cn, N, 0, 5, 1.0, 0, &p2); + ok("unreachable when radius < min edge", p2.reached == 0); + engram_reason_plan_free(&p2); + for (int k = 0; k < N; k++) engram_geo_free(nodes[k]); + } + + printf("\n== %d checks, %d failures ==\n", checks, failures); + return failures ? 1 : 0; +} diff --git a/lang/runtime/el_runtime.c b/lang/runtime/el_runtime.c index ec1aa20..3eb088e 100644 --- a/lang/runtime/el_runtime.c +++ b/lang/runtime/el_runtime.c @@ -7446,6 +7446,7 @@ static char* engram_first_n_chars(const char* s, size_t n) { #include "engram_store.h" #include "engram_vindex.h" /* M8: ANN (HNSW) index for activation seed selection */ #include "engram_geometry.h" /* M9: centered relational-neighborhood geometry (priming) */ +#include "engram_reason.h" /* reasoning layer: compositions over the §5 operators */ /* M10 REIFICATION: resident loaded form of the first-class persisted neighborhood * records (Neighborhood + GeoMeanFrame). Built once at boot from the durable store @@ -12395,6 +12396,32 @@ el_val_t engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds) { return el_wrap_str(b.buf); } +/* engram_reason_analogy_json(a_csv, b_csv, c_csv) — REASONING: "A:B :: C:?". + * Learns the A→B transform (Procrustes rotation + residual translation) and applies + * it to C, returning the predicted point + the Procrustes frame-fit residual. This + * is the analogy MODE (engram_reason.c) surfaced over the same flat-CSV seed ABI as + * the §5 operators. The remaining reasoning modes (induction/abduction/causal/ + * planning) take candidate-set / point / timestamp inputs that do not map to flat + * CSV and are C-layer only for now (see the reasoning-operators runbook). */ +el_val_t engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds) { + GeoDescriptor* A = eg_geo_build_desc(EL_CSTR(a_seeds)); + GeoDescriptor* B = eg_geo_build_desc(EL_CSTR(b_seeds)); + GeoDescriptor* C = eg_geo_build_desc(EL_CSTR(c_seeds)); + if (!A || !B || !C) { if (A) engram_geo_free(A); if (B) engram_geo_free(B); if (C) engram_geo_free(C); return eg_geo_err("geometry unavailable"); } + GeoAnalogyResult res; char t[80]; JsonBuf b; jb_init(&b); + if (engram_reason_analogy(A, B, C, NULL, 0, &res) != 0) { + engram_geo_free(A); engram_geo_free(B); engram_geo_free(C); + return eg_geo_err("dim/frame mismatch or missing centroid"); + } + jb_putc(&b, '{'); + snprintf(t, sizeof t, "\"dim\":%d,\"analogy_residual\":%.6g", res.dim, res.analogy_residual); jb_puts(&b, t); + jb_puts(&b, ",\"mapped_point\":"); eg_geo_emit_vec(&b, res.mapped_point, res.dim); + jb_putc(&b, '}'); + engram_reason_analogy_free(&res); + engram_geo_free(A); engram_geo_free(B); engram_geo_free(C); + return el_wrap_str(b.buf); +} + el_val_t engram_neighbors_json(el_val_t node_id, el_val_t max_depth, el_val_t direction) { /* Re-implement here directly so we serialize without going through * the ElList path. Walks BFS to max_depth, emits {node, edge, hops} diff --git a/lang/runtime/el_runtime.h b/lang/runtime/el_runtime.h index 0dd4ef8..2aea04c 100644 --- a/lang/runtime/el_runtime.h +++ b/lang/runtime/el_runtime.h @@ -630,6 +630,10 @@ el_val_t engram_geo_subtract_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t el_val_t engram_geo_combine_json(el_val_t a_seeds, el_val_t b_seeds); el_val_t engram_geo_distance_json(el_val_t a_seeds, el_val_t b_seeds); el_val_t engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds); +/* reasoning layer (compositions over §5 operators). ANALOGY maps cleanly to the + * flat-CSV seed ABI; the other modes take set/point/timestamp inputs deferred from + * this ABI (see engram_reason.h / the reasoning-operators runbook). */ +el_val_t engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds); el_val_t engram_consolidate_permanence(el_val_t node_id); el_val_t engram_age_field(el_val_t delta_ms); el_val_t engram_age_field_catchup(void); diff --git a/lang/runtime/el_seed.c b/lang/runtime/el_seed.c index 0a96e00..2bc6631 100644 --- a/lang/runtime/el_seed.c +++ b/lang/runtime/el_seed.c @@ -1114,6 +1114,10 @@ el_val_t __engram_geo_distance_json(el_val_t a_seeds, el_val_t b_seeds) { el_val_t __engram_geo_analogy_json(el_val_t a_seeds, el_val_t b_seeds) { return engram_geo_analogy_json(a_seeds, b_seeds); } +/* reasoning layer — ANALOGY native wrapper (same C-table wiring as the §5 ops). */ +el_val_t __engram_reason_analogy_json(el_val_t a_seeds, el_val_t b_seeds, el_val_t c_seeds) { + return engram_reason_analogy_json(a_seeds, b_seeds, c_seeds); +} el_val_t __engram_consolidate_permanence(el_val_t node_id) { return engram_consolidate_permanence(node_id); diff --git a/lang/runtime/engram_reason.c b/lang/runtime/engram_reason.c new file mode 100644 index 0000000..f5a60e7 --- /dev/null +++ b/lang/runtime/engram_reason.c @@ -0,0 +1,287 @@ +/* engram_reason.c — the REASONING layer. Pure compositions over engram_geometry.h. + * stdlib + libm only; READ-ONLY over its descriptor inputs; touches no store/index. */ +#include "engram_reason.h" +#include +#include +#include + +/* ── small float-vector helpers ─────────────────────────────────────────────── */ +static double 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; +} +static double vnorm(const float* a, int dim) { return sqrt(vdot(a, a, dim)); } +static double vcos(const float* a, const float* b, int dim) { + double na = vnorm(a, dim), nb = vnorm(b, dim); + if (na < 1e-12 || nb < 1e-12) return 0.0; /* a null vector ⇒ no direction */ + double c = vdot(a, b, dim) / (na * nb); + if (c > 1.0) c = 1.0; if (c < -1.0) c = -1.0; + return c; +} +static double l2(const float* a, const float* b, int dim) { + double s = 0; for (int i = 0; i < dim; i++) { double d = (double)a[i] - (double)b[i]; s += d * d; } + return sqrt(s); +} + +/* ═══════════════════════════════════════════ SHARED — point-to-manifold FIT ══ */ +int engram_reason_point_fit(const GeoDescriptor* g, const float* x, + double ext_floor, GeoFit* out) { + if (!g || !x || !out || g->dim <= 0 || !g->centroid) return -1; + if (!(ext_floor > 0)) ext_floor = 1.0; + int dim = g->dim; + /* residual r = x − centroid */ + double rr = 0; /* ‖r‖² */ + float* r = malloc((size_t)dim * sizeof(float)); + if (!r) return -1; + for (int i = 0; i < dim; i++) { double d = (double)x[i] - (double)g->centroid[i]; r[i] = (float)d; rr += d * d; } + double maha2 = 0, ss_in = 0; /* Mahalanobis² and in-subspace energy */ + for (int k = 0; k < g->n_axes; k++) { + const float* ax = g->axes[k].axis; if (!ax) continue; + double proj = vdot(r, ax, dim); /* axes are orthonormal directions */ + double den = g->axes[k].extent; if (den < ext_floor) den = ext_floor; + maha2 += (proj / den) * (proj / den); + ss_in += proj * proj; + } + double ortho2 = rr - ss_in; if (ortho2 < 0) ortho2 = 0; /* off-subspace energy */ + double dist2 = maha2 + ortho2 / (ext_floor * ext_floor); + out->mahalanobis = sqrt(maha2); + out->ortho_residual = sqrt(ortho2); + out->distance = sqrt(dist2); + out->score = 1.0 / (1.0 + dist2); + free(r); + return 0; +} + +/* ═══════════════════════════════════════════════════════════════ ANALOGY ════ */ +int engram_reason_analogy(const GeoDescriptor* A, const GeoDescriptor* B, + const GeoDescriptor* C, + const GeoDescriptor* const* candidates, int n_candidates, + GeoAnalogyResult* out) { + if (!A || !B || !C || !out) return -1; + if (!A->centroid || !B->centroid || !C->centroid) return -1; + int dim = A->dim; + if (B->dim != dim || C->dim != dim) return -1; + memset(out, 0, sizeof *out); + out->dim = dim; out->best = -1; + + /* Learn R_{A→B}. engram_geo_analogy(X,Y) yields R with apply(R, Y-axis) ≈ X-axis + * (R maps Y's frame → X's frame); so R that maps A→B is engram_geo_analogy(B,A). */ + GeoAnalogy an; + if (engram_geo_analogy(B, A, &an) != 0) return -1; + out->analogy_residual = an.residual; + + /* mapped = R·c_C + (c_B − R·c_A) : the A→B affine (rotation + residual shift). */ + float* RcA = malloc((size_t)dim * sizeof(float)); + float* RcC = malloc((size_t)dim * sizeof(float)); + out->mapped_point = malloc((size_t)dim * sizeof(float)); + if (!RcA || !RcC || !out->mapped_point) { free(RcA); free(RcC); free(out->mapped_point); out->mapped_point = NULL; engram_geo_analogy_free(&an); return -1; } + engram_geo_analogy_apply(&an, A->centroid, RcA); + engram_geo_analogy_apply(&an, C->centroid, RcC); + for (int i = 0; i < dim; i++) + out->mapped_point[i] = (float)((double)RcC[i] + ((double)B->centroid[i] - (double)RcA[i])); + free(RcA); free(RcC); + engram_geo_analogy_free(&an); + + /* nearest candidate to the mapped point (centroid L2). */ + if (candidates && n_candidates > 0) { + out->n_candidates = n_candidates; + out->distances = malloc((size_t)n_candidates * sizeof(double)); + if (!out->distances) return -1; + double best = -1; int bi = -1; + for (int i = 0; i < n_candidates; i++) { + const GeoDescriptor* cd = candidates[i]; + double d = (cd && cd->centroid && cd->dim == dim) ? l2(out->mapped_point, cd->centroid, dim) : INFINITY; + out->distances[i] = d; + if (bi < 0 || d < best) { best = d; bi = i; } + } + out->best = bi; out->best_distance = best; + } + return 0; +} +void engram_reason_analogy_free(GeoAnalogyResult* r) { + if (!r) return; + free(r->mapped_point); free(r->distances); + r->mapped_point = NULL; r->distances = NULL; +} + +/* ═══════════════════════════════════════════════════════════════ INDUCTION ══ */ +int engram_reason_induce(const GeoDescriptor* const* examples, int n_examples, + int top_axes, double ext_floor, GeoInduction* out) { + if (!examples || n_examples < 1 || !out) return -1; + if (top_axes <= 0) top_axes = 8; + memset(out, 0, sizeof *out); + + /* fold the examples left→right through the pooled-Gaussian combine. n==1 pools + * the single example with itself (identical cov ⇒ same shape, id-union = itself). */ + GeoDescriptor* acc = engram_geo_combine(examples[0], + examples[n_examples > 1 ? 1 : 0], top_axes); + if (!acc) return -1; + for (int i = 2; i < n_examples; i++) { + GeoDescriptor* nxt = engram_geo_combine(acc, examples[i], top_axes); + engram_geo_free(acc); + if (!nxt) return -1; + acc = nxt; + } + out->rule = acc; + out->n_examples = n_examples; + out->ext_floor = (ext_floor > 0) ? ext_floor + : (acc->radius > 0 ? acc->radius * 0.25 : 1.0); + return 0; +} +double engram_reason_membership(const GeoInduction* ind, const float* x) { + if (!ind || !ind->rule || !x) return -1; + GeoFit f; + if (engram_reason_point_fit(ind->rule, x, ind->ext_floor, &f) != 0) return -1; + return f.score; +} +void engram_reason_induction_free(GeoInduction* out) { + if (!out) return; + if (out->rule) engram_geo_free(out->rule); + out->rule = NULL; +} + +/* ═══════════════════════════════════════════════════════════════ ABDUCTION ══ */ +int engram_reason_abduce(const float* obs, int dim, + const GeoDescriptor* const* hypotheses, int n, + double ext_floor, GeoAbduction* out) { + if (!obs || !hypotheses || n < 1 || dim <= 0 || !out) return -1; + if (!(ext_floor > 0)) ext_floor = 1.0; + memset(out, 0, sizeof *out); + out->n = n; out->best = -1; + out->scores = malloc((size_t)n * sizeof(double)); + out->distances = malloc((size_t)n * sizeof(double)); + out->rank = malloc((size_t)n * sizeof(int)); + if (!out->scores || !out->distances || !out->rank) { engram_reason_abduction_free(out); return -1; } + + double best = -1; int bi = -1; + for (int i = 0; i < n; i++) { + out->rank[i] = i; + const GeoDescriptor* h = hypotheses[i]; + GeoFit f; + if (!h || h->dim != dim || engram_reason_point_fit(h, obs, ext_floor, &f) != 0) { + out->scores[i] = 0.0; out->distances[i] = INFINITY; + } else { + out->scores[i] = f.score; out->distances[i] = f.distance; + } + if (bi < 0 || out->scores[i] > best) { best = out->scores[i]; bi = i; } + } + out->best = bi; out->best_score = (bi >= 0) ? out->scores[bi] : 0.0; + + /* rank indices best→worst by score (insertion sort — n is small). */ + for (int i = 1; i < n; i++) { + int key = out->rank[i]; int j = i - 1; + while (j >= 0 && out->scores[out->rank[j]] < out->scores[key]) { out->rank[j + 1] = out->rank[j]; j--; } + out->rank[j + 1] = key; + } + return 0; +} +void engram_reason_abduction_free(GeoAbduction* out) { + if (!out) return; + free(out->scores); free(out->distances); free(out->rank); + out->scores = NULL; out->distances = NULL; out->rank = NULL; +} + +/* ═══════════════════════════════════════════════════════════════════ CAUSAL ══ */ +/* |cos| of two descriptors' centroids after removing confounder Z's subspace. */ +static double controlled_assoc(const GeoDescriptor* x, const GeoDescriptor* y, + const GeoDescriptor* z) { + GeoResidual rx, ry; double c = 0; + int ox = engram_geo_subtract(x, z, 0, &rx); + int oy = engram_geo_subtract(y, z, 0, &ry); + if (ox == 0 && oy == 0 && rx.residual_centroid && ry.residual_centroid) + c = fabs(vcos(rx.residual_centroid, ry.residual_centroid, x->dim)); + if (ox == 0) engram_geo_residual_free(&rx); + if (oy == 0) engram_geo_residual_free(&ry); + return c; +} +int engram_reason_causal(const GeoDescriptor* x, const GeoDescriptor* y, + const GeoDescriptor* const* confounders, int n_conf, + int64_t t_x, int64_t t_y, + double drop_frac, GeoCausal* out) { + if (!x || !y || !out || !x->centroid || !y->centroid || x->dim != y->dim) return -1; + if (!(drop_frac > 0 && drop_frac < 1)) drop_frac = 0.5; + memset(out, 0, sizeof *out); + const double assoc_floor = 0.2; /* below this = no meaningful association */ + + out->assoc_raw = fabs(vcos(x->centroid, y->centroid, x->dim)); + /* control for each confounder; the strongest single explainer wins (min assoc). */ + double ctrl = out->assoc_raw; + for (int i = 0; i < n_conf; i++) { + if (!confounders[i]) continue; + double c = controlled_assoc(x, y, confounders[i]); + if (c < ctrl) ctrl = c; + } + out->assoc_controlled = ctrl; + out->temporal_dir = (t_x < t_y) ? 1 : (t_x > t_y) ? -1 : 0; + + if (out->assoc_raw < assoc_floor) { + out->verdict = GEO_CAUSAL_NONE; + } else if (ctrl < (1.0 - drop_frac) * out->assoc_raw && ctrl < assoc_floor) { + out->verdict = GEO_CAUSAL_CONFOUNDED; out->confounded = 1; + } else if (out->temporal_dir != 0) { + out->verdict = GEO_CAUSAL_DIRECTED; out->strength = ctrl; + } else { + out->verdict = GEO_CAUSAL_NONE; /* associated + robust but unorientable */ + } + return 0; +} + +/* ═══════════════════════════════════════════════════════════════════ PLANNING ══ */ +int engram_reason_plan(const GeoDescriptor* const* nodes, int n, + int start, int goal, double neighbor_radius, + int use_wasserstein, GeoPlan* out) { + if (!nodes || n < 1 || !out) return -1; + if (start < 0 || start >= n || goal < 0 || goal >= n) return -1; + if (!(neighbor_radius > 0)) return -1; + memset(out, 0, sizeof *out); + + /* dense edge weights (ipath = malloc((size_t)len * sizeof(int)); + if (out->path) { + out->path_len = len; + int idx = len - 1; + for (int v = goal; v != -1; v = prev[v]) out->path[idx--] = v; + out->total_cost = dist[goal]; + out->reached = 1; + } + } + free(W); free(dist); free(prev); free(done); + return 0; +} +void engram_reason_plan_free(GeoPlan* out) { + if (!out) return; + free(out->path); out->path = NULL; +} diff --git a/lang/runtime/engram_reason.h b/lang/runtime/engram_reason.h new file mode 100644 index 0000000..4b1606f --- /dev/null +++ b/lang/runtime/engram_reason.h @@ -0,0 +1,161 @@ +/* engram_reason.h — the REASONING layer: compositions over the §5 geometry + * OPERATORS (engram_geometry.h). Where the operators are a relational ALGEBRA over + * neighborhood descriptors, these are reasoning MODES built by CHAINING that algebra: + * + * ANALOGY A:B :: C:? — learn the A→B transform (Procrustes), apply to C. + * INDUCTION {E_i} → rule — pool example geometries; a generalizing structure + * + a membership test. + * ABDUCTION x → best H — the structure whose geometry best PLACES an + * observation in-distribution (inverse of prediction). + * CAUSAL x ? y | Z, t — separate mere overlap (correlation) from directed + * influence (temporal precedence + association that + * SURVIVES controlling for confounders via subtract). + * PLANNING start → goal — a trajectory (sequence of neighborhoods) through the + * manifold: shortest path over geo-distance edges. + * + * PURE + READ-ONLY (stdlib + libm only): every function consumes GeoDescriptor(s) + * (+ a few scalars / timestamps) and NEVER touches the store, index, or activation. + * All geometry is delegated to the engram_geo_* primitives; this file only composes. + * + * FRAME CONTRACT (inherited): descriptors passed together MUST share emb `dim` and + * `global_mean` frame — exactly the §5 operator contract. A function returns <0 on + * a dim/frame mismatch or bad argument. + */ +#ifndef ENGRAM_REASON_H +#define ENGRAM_REASON_H + +#include +#include "engram_geometry.h" + +/* ═══════════════════════════════════════════════════════════════════════════ + * SHARED PRIMITIVE — point-to-manifold FIT. How well does a single point x sit + * inside a neighborhood's ellipsoid? Splits the residual (x − centroid) into: + * - the IN-SUBSPACE part, scaled by each axis extent → a Mahalanobis distance + * (how many "radii" out along the modeled directions), and + * - the ORTHOGONAL part outside the retained axes → energy the model does not + * explain at all (charged at the extent floor). + * This is the common engine under INDUCTION's membership test and ABDUCTION's + * explanation ranking. ext_floor (>0) guards zero-extent axes / the null model. + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef struct { + double mahalanobis; /* sqrt( Σ_k ((a_k·(x−c)) / max(ext_k,floor))² ) */ + double ortho_residual; /* ‖(x−c) projected off the retained axes‖ (raw L2) */ + double distance; /* sqrt( maha² + (ortho_residual/floor)² ) — full fit */ + double score; /* 1 / (1 + distance²) ∈ (0,1] (1 = dead-center) */ +} GeoFit; +int engram_reason_point_fit(const GeoDescriptor* g, const float* x, + double ext_floor, GeoFit* out); + +/* ═══════════════════════════════════════════════════════════════════════════ + * ANALOGY — "A:B :: C:?". Learn the transform that carries A to B (orthogonal + * Procrustes rotation R between their principal frames + the residual translation), + * apply it to C, and return the mapped point + the nearest candidate neighborhood. + * Composes: engram_geo_analogy (R) + engram_geo_analogy_apply + engram_geo_distance. + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef struct { + int dim; + float* mapped_point; /* predicted D location = R·c_C + (c_B − R·c_A) (owned)*/ + double analogy_residual;/* Procrustes ‖A−B R‖_F — frame-alignment quality */ + int best; /* index of nearest candidate to mapped_point, or −1 */ + double best_distance; /* centroid L2 from mapped_point to the winner */ + int n_candidates; + double* distances; /* centroid L2 mapped_point→candidate[i] (owned)*/ +} GeoAnalogyResult; +/* candidates may be NULL/0 (then best=−1 and only mapped_point is filled). */ +int engram_reason_analogy(const GeoDescriptor* A, const GeoDescriptor* B, + const GeoDescriptor* C, + const GeoDescriptor* const* candidates, int n_candidates, + GeoAnalogyResult* out); +void engram_reason_analogy_free(GeoAnalogyResult* r); + +/* ═══════════════════════════════════════════════════════════════════════════ + * INDUCTION — from a SET of example neighborhoods to the generalizing structure. + * Pools the examples (law-of-total-variance via engram_geo_combine, folded left to + * right) into a single "rule" descriptor whose top principal axes are the directions + * CONSISTENTLY present across the examples (the shared subspace surfaces as the + * dominant pooled axes; idiosyncratic per-example directions fall to the tail). + * The rule carries a membership test (point-to-manifold fit against the pool). + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef struct { + GeoDescriptor* rule; /* induced generalizing geometry (owned; geo_free) */ + double ext_floor; /* extent floor used by the membership test */ + int n_examples;/* how many examples were pooled */ +} GeoInduction; +/* top_axes<=0 → 8. ext_floor<=0 → derived from the pooled radius. */ +int engram_reason_induce(const GeoDescriptor* const* examples, int n_examples, + int top_axes, double ext_floor, GeoInduction* out); +/* Membership of a point in the induced rule ∈ (0,1] (the fit score). <0 on error. */ +double engram_reason_membership(const GeoInduction* ind, const float* x); +void engram_reason_induction_free(GeoInduction* out); + +/* ═══════════════════════════════════════════════════════════════════════════ + * ABDUCTION — inference to the best explanation. Given an observation POINT, rank a + * set of candidate structures by how well each PLACES the observation in-distribution + * (min point-to-manifold distance = the structure that, if assumed, best accounts for + * the observation). The inverse of prediction. + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef struct { + int best; /* index of best-explaining hypothesis, or −1 */ + double best_score; + int n; + double* scores; /* fit score per hypothesis (higher = better) (owned)*/ + double* distances; /* explanation distance per hypothesis (owned)*/ + int* rank; /* hypothesis indices sorted best→worst (owned)*/ +} GeoAbduction; +int engram_reason_abduce(const float* obs, int dim, + const GeoDescriptor* const* hypotheses, int n, + double ext_floor, GeoAbduction* out); +void engram_reason_abduction_free(GeoAbduction* out); + +/* ═══════════════════════════════════════════════════════════════════════════ + * CAUSAL — correlation vs causation. Over two variables' geometries (+ candidate + * confounders + temporal order), distinguish: + * - mere co-occurrence / overlap (correlation), from + * - directed influence: association that (a) SURVIVES controlling for confounders + * (subtract each Z's subspace from both centroids, re-measure) and (b) is oriented + * by temporal PRECEDENCE. + * Composes: centroid cosine (correlation) + engram_geo_subtract (control) + timestamps. + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef enum { + GEO_CAUSAL_NONE = 0, /* no meaningful association */ + GEO_CAUSAL_DIRECTED = 1, /* survives control + temporally ordered → cause→eff */ + GEO_CAUSAL_CONFOUNDED = 2 /* correlated but association dies under control */ +} GeoCausalVerdict; +typedef struct { + double assoc_raw; /* |cos(c_x,c_y)| — the raw correlation */ + double assoc_controlled; /* |cos| of residual centroids after control */ + int temporal_dir; /* +1 x→y, −1 y→x, 0 tie/unknown */ + GeoCausalVerdict verdict; + int confounded; /* 1 iff verdict==CONFOUNDED (the flag) */ + double strength; /* directed influence estimate ∈[0,1] (0 else)*/ +} GeoCausal; +/* confounders may be NULL/0. t_x,t_y are comparable timestamps (any monotone unit); + * pass equal values for "unknown order". drop_frac∈(0,1): a controlled association + * below (1−drop_frac)·assoc_raw AND below an absolute floor ⇒ CONFOUNDED. */ +int engram_reason_causal(const GeoDescriptor* x, const GeoDescriptor* y, + const GeoDescriptor* const* confounders, int n_conf, + int64_t t_x, int64_t t_y, + double drop_frac, GeoCausal* out); + +/* ═══════════════════════════════════════════════════════════════════════════ + * PLANNING — trajectory construction. Given a set of neighborhoods (manifold nodes), + * a start and a goal, build a PATH (sequence of intermediate neighborhoods) by + * shortest path over the graph whose edges connect neighborhoods within + * neighbor_radius, weighted by geo-distance. Long straight jumps are not edges, so + * the path follows the manifold's curvature through intermediates (a discrete geodesic). + * Composes: engram_geo_distance (edge weights) + Dijkstra. + * ═══════════════════════════════════════════════════════════════════════════ */ +typedef struct { + int* path; /* node indices start..goal (owned) */ + int path_len; + double total_cost; /* summed centroid-distance edge weights along path */ + int reached; /* 1 if goal reachable within neighbor_radius graph */ +} GeoPlan; +/* neighbor_radius>0: max centroid distance for two neighborhoods to be adjacent. + * Use "wasserstein"!=0 to weight edges by Wasserstein-2 instead of centroid L2. */ +int engram_reason_plan(const GeoDescriptor* const* nodes, int n, + int start, int goal, double neighbor_radius, + int use_wasserstein, GeoPlan* out); +void engram_reason_plan_free(GeoPlan* out); + +#endif /* ENGRAM_REASON_H */