Reasoning layer: analogy/induction/abduction/causal/planning over §5 geometry ops
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-*.
This commit is contained in:
@@ -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.
|
||||
Executable
+23
@@ -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"
|
||||
@@ -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 <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
|
||||
/* ── 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 (i<j symmetric); INFINITY = not adjacent. */
|
||||
double* W = malloc((size_t)n * (size_t)n * sizeof(double));
|
||||
if (!W) return -1;
|
||||
for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) W[(size_t)i * n + j] = (i == j) ? 0.0 : INFINITY;
|
||||
for (int i = 0; i < n; i++) {
|
||||
for (int j = i + 1; j < n; j++) {
|
||||
GeoDistance d;
|
||||
if (nodes[i] && nodes[j] && engram_geo_distance(nodes[i], nodes[j], &d) == 0) {
|
||||
double w = use_wasserstein ? d.wasserstein2 : d.centroid_distance;
|
||||
if (w <= neighbor_radius) { W[(size_t)i * n + j] = w; W[(size_t)j * n + i] = w; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* O(n²) Dijkstra. */
|
||||
double* dist = malloc((size_t)n * sizeof(double));
|
||||
int* prev = malloc((size_t)n * sizeof(int));
|
||||
char* done = calloc((size_t)n, 1);
|
||||
if (!dist || !prev || !done) { free(W); free(dist); free(prev); free(done); return -1; }
|
||||
for (int i = 0; i < n; i++) { dist[i] = INFINITY; prev[i] = -1; }
|
||||
dist[start] = 0;
|
||||
for (int it = 0; it < n; it++) {
|
||||
int u = -1; double bd = INFINITY;
|
||||
for (int i = 0; i < n; i++) if (!done[i] && dist[i] < bd) { bd = dist[i]; u = i; }
|
||||
if (u < 0) break;
|
||||
done[u] = 1;
|
||||
if (u == goal) break;
|
||||
for (int v = 0; v < n; v++) {
|
||||
double w = W[(size_t)u * n + v];
|
||||
if (w < INFINITY && !done[v] && dist[u] + w < dist[v]) { dist[v] = dist[u] + w; prev[v] = u; }
|
||||
}
|
||||
}
|
||||
|
||||
if (dist[goal] < INFINITY) {
|
||||
int len = 0; for (int v = goal; v != -1; v = prev[v]) len++;
|
||||
out->path = 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;
|
||||
}
|
||||
@@ -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 <stdint.h>
|
||||
#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 */
|
||||
Reference in New Issue
Block a user