Files
el/engram/test/test_reason.c
T
will.anderson a3358dfc95 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-*.
2026-08-13 01:33:14 -05:00

256 lines
14 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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). AD 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;
}