8e9d88fc01
El SDK CI - dev / build-and-test (pull_request) Failing after 10m59s
The crash (SIGTRAP in engram_activate -> eg_vindex_sync -> vindex_insert -> _realloc) had three read paths mutating five process-global statics. engram_activate, eg_knn_for_node (whose own comment says "No writes.") and engram_geo_reify_run_json all called eg_vindex_sync, which frees the index, reallocs the seen-map and inserts — on a read. Three moves, in decreasing order of how much they dissolve: 1. Misfiled scratch is not shared state. visited/visit_epoch/visited_cap were never owned by the index; they are one traversal's local, hoisted into struct VIndex as an allocation optimisation. They want neither a lock nor a capability nor a pool — just to go back in the call frame. Two concurrent READS stomped each other purely because of this. 2. const IS the capability. Once the scratch leaves the struct, search reads and nothing else, so vindex_search takes a const VIndex*. That is exactly what a capability-pointer ABI would have bought — a read path physically cannot call vindex_insert, enforced by the compiler on every future caller — for one qualifier instead of an ABI swept across hundreds of builtins. 3. What survives is publication, not ownership. HNSW insert is NOT an append: it rewires the neighbour links of already-existing elements and reallocs elems[], so the store's append-only property does not transfer to the index derived from it. eg_vindex_sync therefore splits into eg_vindex_maintain (exclusive, sole mutator) and eg_vindex_view (shared, returns const VIndex*). A read path may demand that a current snapshot exist — a request to the owner, not a mutation by the reader. Write-side owner: eg_vindex_note_embedded hooks the embedding-ASSIGNMENT sites rather than the append sites, because a node with no embedding cannot be in a vector index — embedding assignment is the event that owns index membership. One O(log n) insert, no O(node_count) presence scan. This also retires the "STALENESS (honest tradeoff)" note where a lazily-embedded older node stayed invisible to route_nearest/autoconnect until a full rebuild (the embed-gap #20 shape). Evidence. The existing harness conflated two hazards, which is why fixing half of it read as failure. Split into four: single (3000 vec, ASan+UBSan) clean -> clean readers (4 readers, no writer, TSan) RACE -> clean unsynchronized (writer+reader, bare) race -> race, expected forever published (owner + 4 readers) n/a -> clean, 3000/3000 landed RESULT: PASS. recall@10 = 0.9365 at ef_search=128 (gate >= 0.90); determinism byte-identical across two independent builds. The unsynchronized half is now permanently expected to race, deliberately: it is the executable proof that the boundary must live above the data structure, not inside it. fb32d15's guard is KEPT, correcting this design's own section 5. Measured, it guards TWO structures and only one was converted here: g->nodes/g->edges are realloc'd in place (el_runtime.c:7618,7629) and engram_activate_inner's embed-backfill writes n->emb through exactly such a borrowed pointer. Deleting the guard reintroduces a measured 11171->9579 edge loss. Its comment is narrowed to the RAM graph and the deletion precondition named. That corrects the ordering claim too: the residual is not one ABI that dissolves everything at once, it is a PROPERTY applied per structure. Residues evaporate in the order the property is applied, and a residue whose structure has not been converted must be left standing.
2009 lines
95 KiB
C
2009 lines
95 KiB
C
/* engram_geometry.c — M9 FOUNDATION: relational-neighborhood geometry descriptor.
|
||
* See engram_geometry.h. Pure C11, stdlib + libm. READ-ONLY over store + vindex.
|
||
*/
|
||
#include "engram_geometry.h"
|
||
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <math.h>
|
||
#include <stdio.h>
|
||
#include <stdarg.h>
|
||
#include <time.h>
|
||
|
||
/* Must match ENGRAM_HEBB_GAIN in el_runtime.c (eff = weight*(1+GAIN*hebb)). */
|
||
#define GEO_HEBB_GAIN 0.5
|
||
/* Internal cap on the m×m Jacobi eigensolve: above this we still give centroid +
|
||
* radius but skip principal axes (honest degradation, not a lie). */
|
||
#define GEO_EIG_CAP 512
|
||
|
||
/* ───────────────────────── small dynamic member table ──────────────────────
|
||
* Neighborhoods are small (tens..few hundred), so linear-scan dedup is fine. */
|
||
typedef struct {
|
||
char** id; /* strdup'd ids */
|
||
double* memb; /* provisional membership */
|
||
float** emb; /* L2-normalized emb copy (dim floats) or NULL */
|
||
double* sal; /* stored salience */
|
||
int n, cap, dim;
|
||
} MemSet;
|
||
|
||
static int ms_init(MemSet* s, int dim){
|
||
s->n=0; s->cap=16; s->dim=dim;
|
||
s->id=calloc(s->cap,sizeof*s->id); s->memb=calloc(s->cap,sizeof*s->memb);
|
||
s->emb=calloc(s->cap,sizeof*s->emb); s->sal=calloc(s->cap,sizeof*s->sal);
|
||
return (s->id&&s->memb&&s->emb&&s->sal)?0:-1;
|
||
}
|
||
static int ms_find(const MemSet* s, const char* id){
|
||
for(int i=0;i<s->n;i++) if(strcmp(s->id[i],id)==0) return i;
|
||
return -1;
|
||
}
|
||
/* Insert or bump membership (keep the max). Returns member index or <0 on OOM. */
|
||
static int ms_upsert(MemSet* s, const char* id, double memb){
|
||
int i=ms_find(s,id);
|
||
if(i>=0){ if(memb>s->memb[i]) s->memb[i]=memb; return i; }
|
||
if(s->n==s->cap){
|
||
int nc=s->cap*2;
|
||
char** a=realloc(s->id,nc*sizeof*a); if(!a) return -1; s->id=a;
|
||
double* b=realloc(s->memb,nc*sizeof*b); if(!b) return -1; s->memb=b;
|
||
float** c=realloc(s->emb,nc*sizeof*c); if(!c) return -1; s->emb=c;
|
||
double* d=realloc(s->sal,nc*sizeof*d); if(!d) return -1; s->sal=d;
|
||
s->cap=nc;
|
||
}
|
||
s->id[s->n]=strdup(id); if(!s->id[s->n]) return -1;
|
||
s->memb[s->n]=memb; s->emb[s->n]=NULL; s->sal[s->n]=0.0;
|
||
return s->n++;
|
||
}
|
||
static void ms_free(MemSet* s){
|
||
for(int i=0;i<s->n;i++){ free(s->id[i]); free(s->emb[i]); }
|
||
free(s->id); free(s->memb); free(s->emb); free(s->sal);
|
||
}
|
||
|
||
/* L2-normalize a copy of v into out (dim floats). Returns 0, or -1 if ~zero. */
|
||
static int normcopy(const float* v, int dim, float* out){
|
||
double s=0; for(int i=0;i<dim;i++) s+=(double)v[i]*v[i];
|
||
double n=sqrt(s); if(n<1e-12) return -1;
|
||
for(int i=0;i<dim;i++) out[i]=(float)(v[i]/n);
|
||
return 0;
|
||
}
|
||
/* ── mean-centered cosine geometry (whitening the anisotropic emb space) ──────
|
||
* All three operate on RAW (unnormalized-here, but member embs are already unit)
|
||
* vectors, subtracting the global-mean offset `gm` on the fly. With gm all-zeros
|
||
* they reduce EXACTLY to the raw unit-space cosine — so the same code path serves
|
||
* both the centered and legacy-raw modes. */
|
||
static double cnorm2(const float* a, const float* gm, int dim){
|
||
double s=0; for(int d=0;d<dim;d++){ double v=(double)a[d]-gm[d]; s+=v*v; } return s;
|
||
}
|
||
static double cdot(const float* a, const float* b, const float* gm, int dim){
|
||
double s=0; for(int d=0;d<dim;d++){ double av=(double)a[d]-gm[d], bv=(double)b[d]-gm[d]; s+=av*bv; }
|
||
return s;
|
||
}
|
||
static double ccos(const float* a, const float* b, const float* gm, int dim){
|
||
double na=sqrt(cnorm2(a,gm,dim)), nb=sqrt(cnorm2(b,gm,dim));
|
||
if(na<1e-12||nb<1e-12) return 0.0;
|
||
double c=cdot(a,b,gm,dim)/(na*nb); if(c>1)c=1; if(c<-1)c=-1; return c;
|
||
}
|
||
/* cosine of (a-gm) against a pre-centered UNIT direction `dir`. */
|
||
static double ccos_dir(const float* a, const float* gm, const float* dir, int dim){
|
||
double na=sqrt(cnorm2(a,gm,dim)); if(na<1e-12) return 0.0;
|
||
double s=0; for(int d=0;d<dim;d++) s+=((double)a[d]-gm[d])*(double)dir[d];
|
||
double c=s/na; if(c>1)c=1; if(c<-1)c=-1; return c;
|
||
}
|
||
|
||
/* ───────────────────────── global-mean cache ───────────────────────────────
|
||
* Store-derived centering offset: the mean of the L2-normalized embeddings over
|
||
* the embed-eligible set. See engram_geometry.h for the anisotropy rationale. */
|
||
struct GeoMeanCache { float* mean; int dim; uint64_t n; };
|
||
|
||
typedef struct { double* sum; int dim; uint64_t n; int err; } GeoMeanAcc;
|
||
/* The reified records (Neighborhood / GeoMeanFrame) carry an emb (centroid / mean)
|
||
* but are STRUCTURE, not corpus content — they must never pollute the store-wide
|
||
* mean, the hub scan, or the descriptor. One predicate, used everywhere. */
|
||
static int geo_is_reified_type(const char* nt){
|
||
return nt && (strcmp(nt,ENGRAM_GEO_NBHD_TYPE)==0 ||
|
||
strcmp(nt,ENGRAM_GEO_MEANFRAME_TYPE)==0);
|
||
}
|
||
/* Identify a structural record by its id convention (no store read needed), so the
|
||
* descriptor never admits a reified Neighborhood / GeoMeanFrame as a neighborhood
|
||
* MEMBER even when the ANN index or adjacency still references it (re-reify/refresh
|
||
* on a store that already holds reified records; ad-hoc descriptors alike). */
|
||
static int geo_is_structural_id(const char* id){
|
||
if(!id) return 0;
|
||
if(strcmp(id,ENGRAM_GEO_MEANFRAME_ID)==0) return 1;
|
||
size_t p=strlen(ENGRAM_GEO_NBHD_ID_PREFIX);
|
||
return strncmp(id,ENGRAM_GEO_NBHD_ID_PREFIX,p)==0;
|
||
}
|
||
static void geo_mean_cb(const StoreNode* n, void* ctx){
|
||
GeoMeanAcc* a=ctx; if(a->err) return;
|
||
if(geo_is_reified_type(n->node_type)) return; /* skip structural records */
|
||
if(!(n->emb && n->emb_dim>0)) return; /* skip unembedded */
|
||
if(a->dim==0){
|
||
a->dim=n->emb_dim;
|
||
a->sum=calloc((size_t)a->dim,sizeof(double));
|
||
if(!a->sum){ a->err=1; return; }
|
||
}
|
||
if(n->emb_dim!=a->dim) return; /* skip off-dim */
|
||
double s=0; for(int d=0;d<a->dim;d++) s+=(double)n->emb[d]*n->emb[d];
|
||
double nn=sqrt(s); if(nn<1e-12) return; /* skip ~zero */
|
||
for(int d=0;d<a->dim;d++) a->sum[d]+=(double)n->emb[d]/nn;
|
||
a->n++;
|
||
}
|
||
typedef struct { uint64_t n; } GeoCntAcc;
|
||
static void geo_cnt_cb(const StoreNode* n, void* ctx){
|
||
if(n->emb && n->emb_dim>0) ((GeoCntAcc*)ctx)->n++;
|
||
}
|
||
|
||
GeoMeanCache* engram_geo_mean_build(EngramPagedStore* store){
|
||
if(!store) return NULL;
|
||
GeoMeanAcc a; memset(&a,0,sizeof a);
|
||
if(store_scan_nodes(store,geo_mean_cb,&a)<0){ free(a.sum); return NULL; }
|
||
if(a.err || a.n==0 || !a.sum){ free(a.sum); return NULL; }
|
||
GeoMeanCache* c=calloc(1,sizeof*c);
|
||
if(!c){ free(a.sum); return NULL; }
|
||
c->mean=malloc((size_t)a.dim*sizeof(float));
|
||
if(!c->mean){ free(a.sum); free(c); return NULL; }
|
||
for(int d=0;d<a.dim;d++) c->mean[d]=(float)(a.sum[d]/(double)a.n);
|
||
c->dim=a.dim; c->n=a.n; free(a.sum);
|
||
return c;
|
||
}
|
||
const float* engram_geo_mean_vec(const GeoMeanCache* c){ return c?c->mean:NULL; }
|
||
int engram_geo_mean_dim(const GeoMeanCache* c){ return c?c->dim:0; }
|
||
uint64_t engram_geo_mean_count(const GeoMeanCache* c){ return c?c->n:0; }
|
||
|
||
int engram_geo_mean_maybe_refresh(GeoMeanCache* c, EngramPagedStore* store, double frac){
|
||
if(!c||!store) return -1;
|
||
GeoCntAcc cn={0};
|
||
if(store_scan_nodes(store,geo_cnt_cb,&cn)<0) return -1;
|
||
double base=(double)(c->n?c->n:1);
|
||
double drift=fabs((double)cn.n-(double)c->n)/base;
|
||
if(drift<=frac) return 0; /* no significant change */
|
||
GeoMeanAcc a; memset(&a,0,sizeof a);
|
||
if(store_scan_nodes(store,geo_mean_cb,&a)<0){ free(a.sum); return -1; }
|
||
if(a.err || a.n==0 || !a.sum){ free(a.sum); return -1; }
|
||
float* nm=malloc((size_t)a.dim*sizeof(float));
|
||
if(!nm){ free(a.sum); return -1; }
|
||
for(int d=0;d<a.dim;d++) nm[d]=(float)(a.sum[d]/(double)a.n);
|
||
free(a.sum); free(c->mean);
|
||
c->mean=nm; c->dim=a.dim; c->n=a.n;
|
||
return 1;
|
||
}
|
||
void engram_geo_mean_free(GeoMeanCache* c){ if(c){ free(c->mean); free(c); } }
|
||
|
||
/* Attach a member's emb (normalized) + salience by point-reading the store. */
|
||
static void ms_load_node(MemSet* s, int i, EngramPagedStore* st){
|
||
StoreNode nn; memset(&nn,0,sizeof nn);
|
||
if(store_get_node(st, s->id[i], &nn)!=1){ return; }
|
||
s->sal[i]=nn.salience;
|
||
if(nn.emb && nn.emb_dim==s->dim){
|
||
float* e=malloc((size_t)s->dim*sizeof(float));
|
||
if(e && normcopy(nn.emb,s->dim,e)==0) s->emb[i]=e; else free(e);
|
||
}
|
||
store_node_free(&nn);
|
||
}
|
||
|
||
/* ───────────────────────── Jacobi symmetric eigensolver ─────────────────────
|
||
* Cyclic Jacobi on a dense symmetric m×m matrix A (row-major, overwritten).
|
||
* Eigenvalues -> w[m]; eigenvectors (columns) -> V[m*m]. Robust, libm-only. */
|
||
static void jacobi_sym(double* A, int m, double* w, double* V){
|
||
for(int i=0;i<m;i++){ for(int j=0;j<m;j++) V[i*m+j]=(i==j)?1.0:0.0; }
|
||
for(int sweep=0; sweep<100; sweep++){
|
||
double off=0; for(int p=0;p<m;p++) for(int q=p+1;q<m;q++) off+=A[p*m+q]*A[p*m+q];
|
||
if(off < 1e-18) break;
|
||
for(int p=0;p<m;p++) for(int q=p+1;q<m;q++){
|
||
double apq=A[p*m+q]; if(fabs(apq)<1e-300) continue;
|
||
double app=A[p*m+p], aqq=A[q*m+q];
|
||
double phi=0.5*atan2(2*apq, aqq-app);
|
||
double c=cos(phi), sn=sin(phi);
|
||
for(int k=0;k<m;k++){
|
||
double akp=A[k*m+p], akq=A[k*m+q];
|
||
A[k*m+p]=c*akp - sn*akq; A[k*m+q]=sn*akp + c*akq;
|
||
}
|
||
for(int k=0;k<m;k++){
|
||
double apk=A[p*m+k], aqk=A[q*m+k];
|
||
A[p*m+k]=c*apk - sn*aqk; A[q*m+k]=sn*apk + c*aqk;
|
||
}
|
||
for(int k=0;k<m;k++){
|
||
double vkp=V[k*m+p], vkq=V[k*m+q];
|
||
V[k*m+p]=c*vkp - sn*vkq; V[k*m+q]=sn*vkp + c*vkq;
|
||
}
|
||
}
|
||
}
|
||
for(int i=0;i<m;i++) w[i]=A[i*m+i];
|
||
}
|
||
|
||
void engram_geo_default_params(GeoParams* p){
|
||
if(!p) return;
|
||
p->ann_k=24; p->hop_relational=1; p->edge_min_weight=0.05;
|
||
p->kcore_k=0; p->top_axes=8; p->max_members=400;
|
||
}
|
||
|
||
/* Effective hebb-weighted edge strength, matching eg_edge_eff_weight. */
|
||
static double eff_w(double weight, double hebb){
|
||
double w = weight * (1.0 + GEO_HEBB_GAIN*hebb);
|
||
if(w>1.0) w=1.0; if(w<0.0) w=0.0; return w;
|
||
}
|
||
|
||
GeoDescriptor* engram_geometry_descriptor(
|
||
EngramPagedStore* store, const VIndex* vindex,
|
||
char** vids, int n_vids,
|
||
const char* const* seed_ids, size_t n_seeds,
|
||
const GeoParams* params,
|
||
const float* global_mean)
|
||
{
|
||
if(!store || !seed_ids || n_seeds==0) return NULL;
|
||
GeoParams P; if(params) P=*params; else engram_geo_default_params(&P);
|
||
|
||
int dim = 0;
|
||
/* infer dim from the first embedded seed */
|
||
for(size_t i=0;i<n_seeds && dim==0;i++){
|
||
StoreNode nn; memset(&nn,0,sizeof nn);
|
||
if(store_get_node(store, seed_ids[i], &nn)==1){
|
||
if(nn.emb && nn.emb_dim>0) dim=nn.emb_dim;
|
||
}
|
||
store_node_free(&nn);
|
||
}
|
||
if(dim==0) dim = 768; /* no embedded seed: still build the relational side */
|
||
|
||
/* Centering offset. When a global_mean is supplied the semantic cosine math
|
||
* runs in mean-centered (isotropic) space; otherwise GM is an all-zeros
|
||
* vector so the identical code path reproduces raw unit-space cosines. */
|
||
int centered = (global_mean != NULL);
|
||
float* zeros = NULL;
|
||
const float* GM;
|
||
if(centered) GM = global_mean;
|
||
else { zeros = calloc((size_t)dim,sizeof(float));
|
||
if(!zeros) return NULL; GM = zeros; }
|
||
|
||
MemSet ms; if(ms_init(&ms,dim)!=0){ ms_free(&ms); free(zeros); return NULL; }
|
||
|
||
/* 1. seeds (membership 1.0) */
|
||
for(size_t i=0;i<n_seeds;i++) ms_upsert(&ms, seed_ids[i], 1.0);
|
||
int n_seed_members = ms.n;
|
||
for(int i=0;i<ms.n;i++) ms_load_node(&ms,i,store);
|
||
|
||
/* provisional centroid from seed embeddings (for the ANN query) */
|
||
float* prov = calloc((size_t)dim,sizeof(float));
|
||
int prov_n=0;
|
||
for(int i=0;i<n_seed_members;i++) if(ms.emb[i]){
|
||
for(int d=0;d<dim;d++) prov[d]+=ms.emb[i][d]; prov_n++;
|
||
}
|
||
if(prov_n){ for(int d=0;d<dim;d++) prov[d]/=(float)prov_n; }
|
||
|
||
/* 2. semantic expansion via vindex ANN around the provisional centroid */
|
||
if(vindex && vids && P.ann_k>0 && prov_n>0){
|
||
int k=P.ann_k*(int)n_seeds; if(k<P.ann_k) k=P.ann_k; if(k>n_vids) k=n_vids;
|
||
uint64_t* rids=malloc((size_t)k*sizeof(uint64_t));
|
||
float* dd=malloc((size_t)k*sizeof(float));
|
||
if(rids&&dd){
|
||
int got=vindex_search(vindex, prov, k, 0, rids, dd);
|
||
for(int r=0;r<got;r++){
|
||
if(rids[r]>=(uint64_t)n_vids) continue;
|
||
if(geo_is_structural_id(vids[rids[r]])) continue; /* never a member */
|
||
double memb = 1.0 - (double)dd[r]; /* cosine sim in [-1,1] */
|
||
if(memb<0) memb=0;
|
||
int mi=ms_upsert(&ms, vids[rids[r]], memb*0.9); /* <1: not a seed */
|
||
if(mi>=0 && !ms.emb[mi]) ms_load_node(&ms,mi,store);
|
||
}
|
||
}
|
||
free(rids); free(dd);
|
||
}
|
||
free(prov);
|
||
|
||
/* 3. relational expansion: seeds' hebb neighbors become members */
|
||
if(P.hop_relational){
|
||
for(int i=0;i<n_seed_members;i++){
|
||
StoreEdge* es=NULL; size_t ne=0;
|
||
if(store_get_edges_from(store, ms.id[i], &es, &ne)==0 && es){
|
||
for(size_t e=0;e<ne;e++){
|
||
if(es[e].tombstoned || es[e].inhibitory) continue;
|
||
if(es[e].relation && strcmp(es[e].relation,ENGRAM_GEO_MEMBER_RELATION)==0) continue;
|
||
if(geo_is_structural_id(es[e].to_id)) continue;
|
||
double w=eff_w(es[e].weight, es[e].hebb);
|
||
if(w < P.edge_min_weight) continue;
|
||
int mi=ms_upsert(&ms, es[e].to_id, w);
|
||
if(mi>=0 && !ms.emb[mi]) ms_load_node(&ms,mi,store);
|
||
}
|
||
}
|
||
store_edges_free(es,ne);
|
||
es=NULL; ne=0;
|
||
if(store_get_edges_to(store, ms.id[i], &es, &ne)==0 && es){
|
||
for(size_t e=0;e<ne;e++){
|
||
if(es[e].tombstoned || es[e].inhibitory) continue;
|
||
if(es[e].relation && strcmp(es[e].relation,ENGRAM_GEO_MEMBER_RELATION)==0) continue;
|
||
if(geo_is_structural_id(es[e].from_id)) continue;
|
||
double w=eff_w(es[e].weight, es[e].hebb);
|
||
if(w < P.edge_min_weight) continue;
|
||
int mi=ms_upsert(&ms, es[e].from_id, w);
|
||
if(mi>=0 && !ms.emb[mi]) ms_load_node(&ms,mi,store);
|
||
}
|
||
}
|
||
store_edges_free(es,ne);
|
||
}
|
||
}
|
||
|
||
/* optional cap: keep the highest-membership members (guards eigensolve) */
|
||
if(P.max_members>0 && ms.n>P.max_members){
|
||
/* simple selection: repeatedly drop the min-membership non-seed member */
|
||
while(ms.n>P.max_members){
|
||
int worst=-1; double wv=1e30;
|
||
for(int i=n_seed_members;i<ms.n;i++) if(ms.memb[i]<wv){wv=ms.memb[i];worst=i;}
|
||
if(worst<0) break;
|
||
free(ms.id[worst]); free(ms.emb[worst]);
|
||
ms.id[worst]=ms.id[ms.n-1]; ms.emb[worst]=ms.emb[ms.n-1];
|
||
ms.memb[worst]=ms.memb[ms.n-1]; ms.sal[worst]=ms.sal[ms.n-1];
|
||
ms.n--;
|
||
}
|
||
}
|
||
|
||
int M = ms.n;
|
||
/* ── final centroid over all embedded members ── */
|
||
float* centroid=calloc((size_t)dim,sizeof(float));
|
||
int nemb=0; int* eidx=malloc((size_t)M*sizeof(int));
|
||
for(int i=0;i<M;i++) if(ms.emb[i]){ eidx[nemb++]=i;
|
||
for(int d=0;d<dim;d++) centroid[d]+=ms.emb[i][d]; }
|
||
if(nemb){ for(int d=0;d<dim;d++) centroid[d]/=(float)nemb; }
|
||
|
||
/* ── radius + per-member cosine distance to centroid (CENTERED frame) ── */
|
||
double total_var=0;
|
||
double* distc=calloc((size_t)M,sizeof(double));
|
||
/* centered centroid (= raw centroid - global mean) and its unit direction */
|
||
float* ccen=malloc((size_t)dim*sizeof(float));
|
||
for(int d=0;d<dim;d++) ccen[d]=centroid[d]-GM[d];
|
||
float* cdir=malloc((size_t)dim*sizeof(float));
|
||
int have_cdir = (nemb>0 && normcopy(ccen,dim,cdir)==0);
|
||
for(int i=0;i<M;i++){
|
||
if(ms.emb[i] && have_cdir){
|
||
double cs=ccos_dir(ms.emb[i],GM,cdir,dim);
|
||
distc[i]=1.0-cs;
|
||
} else distc[i]=-1.0; /* unknown */
|
||
}
|
||
/* variance = mean squared Euclid distance of normalized embs to centroid */
|
||
for(int j=0;j<nemb;j++){
|
||
int i=eidx[j]; double s=0;
|
||
for(int d=0;d<dim;d++){ double df=(double)ms.emb[i][d]-centroid[d]; s+=df*df; }
|
||
total_var+=s;
|
||
}
|
||
if(nemb) total_var/=nemb;
|
||
double radius=sqrt(total_var>0?total_var:0);
|
||
|
||
/* ── principal axes via dual PCA (Jacobi on the m×m Gram of centered embs) ──
|
||
* Skipped entirely when top_axes==0: the eigensolve is the dominant cost, and
|
||
* priming needs only members+membership, so reified records that don't want the
|
||
* ellipsoid pass top_axes=0 and pay nothing here (centroid+radius still filled). */
|
||
int n_axes=0; GeoAxis* axes=NULL;
|
||
if(P.top_axes>0 && nemb>=2 && nemb<=GEO_EIG_CAP){
|
||
int m=nemb;
|
||
/* centered, row-major m×dim */
|
||
float* Xc=malloc((size_t)m*dim*sizeof(float));
|
||
for(int j=0;j<m;j++){ int i=eidx[j];
|
||
for(int d=0;d<dim;d++) Xc[(size_t)j*dim+d]=ms.emb[i][d]-centroid[d]; }
|
||
double* G=malloc((size_t)m*m*sizeof(double));
|
||
for(int a=0;a<m;a++) for(int b=a;b<m;b++){
|
||
double s=0; for(int d=0;d<dim;d++) s+=(double)Xc[(size_t)a*dim+d]*Xc[(size_t)b*dim+d];
|
||
G[a*m+b]=s; G[b*m+a]=s;
|
||
}
|
||
double* w=malloc((size_t)m*sizeof(double));
|
||
double* V=malloc((size_t)m*m*sizeof(double));
|
||
jacobi_sym(G,m,w,V);
|
||
/* sort eigenvalue indices descending */
|
||
int* ord=malloc((size_t)m*sizeof(int));
|
||
for(int i=0;i<m;i++) ord[i]=i;
|
||
for(int a=0;a<m;a++) for(int b=a+1;b<m;b++) if(w[ord[b]]>w[ord[a]]){int t=ord[a];ord[a]=ord[b];ord[b]=t;}
|
||
int keep=P.top_axes; if(keep>m) keep=m; if(keep<0) keep=0;
|
||
axes=calloc((size_t)keep,sizeof(GeoAxis));
|
||
for(int t=0;t<keep;t++){
|
||
int c=ord[t];
|
||
double lam=w[c]; if(lam<0) lam=0;
|
||
double eigcov = lam/(double)(m-1); /* covariance eigenvalue */
|
||
/* principal axis in R^dim: a = Xc^T u_c, then unit-normalize */
|
||
float* ax=calloc((size_t)dim,sizeof(float));
|
||
for(int d=0;d<dim;d++){ double s=0;
|
||
for(int j=0;j<m;j++) s+=(double)V[j*m+c]*Xc[(size_t)j*dim+d];
|
||
ax[d]=(float)s; }
|
||
double nn=0; for(int d=0;d<dim;d++) nn+=(double)ax[d]*ax[d]; nn=sqrt(nn);
|
||
if(nn>1e-12) for(int d=0;d<dim;d++) ax[d]=(float)(ax[d]/nn);
|
||
axes[t].axis=ax; axes[t].extent=sqrt(eigcov);
|
||
n_axes++;
|
||
}
|
||
free(Xc); free(G); free(w); free(V); free(ord);
|
||
}
|
||
|
||
/* ── skeleton: internal hebb edges among members + centrality gradient ── */
|
||
GeoEdge* edges=NULL; int n_edges=0, cap_e=0;
|
||
double* centrality=calloc((size_t)M,sizeof(double));
|
||
int* degree=calloc((size_t)M,sizeof(int));
|
||
/* co-registration accumulators */
|
||
double cr_n=0, cr_sx=0, cr_sy=0, cr_sxx=0, cr_syy=0, cr_sxy=0;
|
||
for(int i=0;i<M;i++){
|
||
StoreEdge* es=NULL; size_t ne=0;
|
||
if(store_get_edges_from(store, ms.id[i], &es, &ne)==0 && es){
|
||
for(size_t e=0;e<ne;e++){
|
||
if(es[e].tombstoned || es[e].inhibitory) continue;
|
||
int j=ms_find(&ms, es[e].to_id);
|
||
if(j<0 || j<=i) continue; /* internal, undirected, i<j only */
|
||
double w=eff_w(es[e].weight, es[e].hebb);
|
||
if(w < P.edge_min_weight) continue;
|
||
if(n_edges==cap_e){ cap_e=cap_e?cap_e*2:32;
|
||
GeoEdge* t=realloc(edges,(size_t)cap_e*sizeof(GeoEdge)); if(!t) break; edges=t; }
|
||
edges[n_edges].a=(uint32_t)i; edges[n_edges].b=(uint32_t)j;
|
||
edges[n_edges].eff_weight=w; edges[n_edges].hebb=es[e].hebb;
|
||
n_edges++;
|
||
centrality[i]+=w; centrality[j]+=w; degree[i]++; degree[j]++;
|
||
/* co-registration: relational strength vs semantic proximity
|
||
* (semantic proximity measured in the CENTERED frame). */
|
||
if(ms.emb[i] && ms.emb[j]){
|
||
double cs=ccos(ms.emb[i],ms.emb[j],GM,dim);
|
||
double x=w, y=cs;
|
||
cr_n++; cr_sx+=x; cr_sy+=y; cr_sxx+=x*x; cr_syy+=y*y; cr_sxy+=x*y;
|
||
}
|
||
}
|
||
}
|
||
store_edges_free(es,ne);
|
||
}
|
||
double co_reg=0;
|
||
if(cr_n>=2){
|
||
double cov=cr_sxy - cr_sx*cr_sy/cr_n;
|
||
double vx=cr_sxx - cr_sx*cr_sx/cr_n, vy=cr_syy - cr_sy*cr_sy/cr_n;
|
||
if(vx>1e-12 && vy>1e-12) co_reg=cov/sqrt(vx*vy);
|
||
}
|
||
|
||
/* ── k-core: peel members by internal degree to get core numbers ── */
|
||
int* core=calloc((size_t)M,sizeof(int));
|
||
{
|
||
int* deg=malloc((size_t)M*sizeof(int));
|
||
int* removed=calloc((size_t)M,sizeof(int));
|
||
for(int i=0;i<M;i++) deg[i]=degree[i];
|
||
int level=0, remaining=M;
|
||
while(remaining>0){
|
||
int progressed=0;
|
||
for(int i=0;i<M;i++){
|
||
if(!removed[i] && deg[i]<=level){
|
||
core[i]=level; removed[i]=1; remaining--; progressed=1;
|
||
/* decrement neighbors' working degree */
|
||
for(int e=0;e<n_edges;e++){
|
||
int o=-1;
|
||
if((int)edges[e].a==i && !removed[edges[e].b]) o=edges[e].b;
|
||
else if((int)edges[e].b==i && !removed[edges[e].a]) o=edges[e].a;
|
||
if(o>=0) deg[o]--;
|
||
}
|
||
}
|
||
}
|
||
if(!progressed) level++;
|
||
}
|
||
free(deg); free(removed);
|
||
}
|
||
int k_core=0; for(int i=0;i<M;i++) if(core[i]>k_core) k_core=core[i];
|
||
|
||
/* hub = highest centrality (tie-break salience) */
|
||
int hub=-1; double hv=-1;
|
||
for(int i=0;i<M;i++){ double v=centrality[i]+1e-6*ms.sal[i];
|
||
if(v>hv){hv=v;hub=i;} }
|
||
if(hub<0) hub=0;
|
||
|
||
/* ── assemble descriptor ── */
|
||
GeoDescriptor* g=calloc(1,sizeof(GeoDescriptor));
|
||
g->dim=dim;
|
||
g->hub_id = strdup(ms.id[hub]);
|
||
g->centroid = ccen; /* CENTERED centroid; transfer ownership */
|
||
free(centroid);
|
||
if(centered){
|
||
g->global_mean = malloc((size_t)dim*sizeof(float));
|
||
if(g->global_mean) memcpy(g->global_mean, GM, (size_t)dim*sizeof(float));
|
||
} else g->global_mean = NULL;
|
||
g->n_axes=n_axes; g->axes=axes;
|
||
g->total_variance=total_var; g->radius=radius;
|
||
g->n_members=M; g->n_embedded=nemb;
|
||
g->members=calloc((size_t)M,sizeof(GeoMember));
|
||
for(int i=0;i<M;i++){
|
||
g->members[i].id=strdup(ms.id[i]);
|
||
g->members[i].membership=ms.memb[i];
|
||
g->members[i].centrality=centrality[i];
|
||
g->members[i].salience=ms.sal[i];
|
||
g->members[i].core=core[i];
|
||
g->members[i].dist_centroid=distc[i];
|
||
g->members[i].embedded=ms.emb[i]?1:0;
|
||
}
|
||
g->n_edges=n_edges; g->edges=edges;
|
||
g->k_core=(P.kcore_k>0?P.kcore_k:k_core);
|
||
g->co_registration=co_reg;
|
||
|
||
free(centrality); free(degree); free(core); free(distc); free(eidx);
|
||
free(cdir); free(zeros);
|
||
ms_free(&ms);
|
||
return g;
|
||
}
|
||
|
||
void engram_geo_free(GeoDescriptor* g){
|
||
if(!g) return;
|
||
free(g->hub_id); free(g->centroid); free(g->global_mean);
|
||
for(int i=0;i<g->n_axes;i++) free(g->axes[i].axis);
|
||
free(g->axes);
|
||
for(int i=0;i<g->n_members;i++) free(g->members[i].id);
|
||
free(g->members); free(g->edges);
|
||
free(g);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* M-INTEROCEPTION P3 — DRIFT SENSOR primitive (descriptor displacement).
|
||
* Read-only. Measures how far descriptor B has drifted from a baseline A and
|
||
* decomposes it into GROWTH (periphery extends, core fixed) vs CORRUPTION (the
|
||
* invariant core displaces). The core is the top `core_frac` of A's members by
|
||
* centrality; the periphery is the rest. Per shared member (matched by id), the
|
||
* displacement is the change in its radial position (dist_centroid) between A
|
||
* and B; centroid separation + radius delta give the aggregate move.
|
||
*
|
||
* PREREQUISITE FLAGGED (honesty rail, design §2/§6): a LIVE self-drift reading
|
||
* needs a persisted SelfAnchor baseline descriptor to compare "now" against.
|
||
* That anchor does NOT exist yet — there is no persisted self node / anchored
|
||
* self-neighborhood in this store. This primitive therefore takes an EXPLICIT
|
||
* baseline so it is real and testable today; capturing a durable SelfAnchor
|
||
* snapshot and wiring the ENGRAM_DRIFT_SENSOR live reading is a follow-up. We do
|
||
* NOT fabricate a self silently.
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
static double eg_geo_l2(const float* x, const float* y, int dim){
|
||
double s=0; for(int i=0;i<dim;i++){ double d=(double)x[i]-(double)y[i]; s+=d*d; } return sqrt(s);
|
||
}
|
||
static double eg_geo_cosv(const float* x, const float* y, int dim){
|
||
double dot=0,nx=0,ny=0;
|
||
for(int i=0;i<dim;i++){ dot+=(double)x[i]*y[i]; nx+=(double)x[i]*x[i]; ny+=(double)y[i]*y[i]; }
|
||
if(nx<=0.0||ny<=0.0) return 0.0;
|
||
return dot/(sqrt(nx)*sqrt(ny));
|
||
}
|
||
void engram_geo_displacement(const GeoDescriptor* a, const GeoDescriptor* b,
|
||
double core_frac, GeoDisplacement* out){
|
||
if(!out) return;
|
||
memset(out,0,sizeof(*out));
|
||
if(!a||!b) return;
|
||
if(a->centroid && b->centroid && a->dim==b->dim && a->dim>0){
|
||
out->centroid_sep = eg_geo_l2(a->centroid,b->centroid,a->dim);
|
||
out->centroid_cos = 1.0 - eg_geo_cosv(a->centroid,b->centroid,a->dim);
|
||
}
|
||
out->radius_delta = fabs(a->radius - b->radius);
|
||
if(!(core_frac>0.0 && core_frac<=1.0)) core_frac=0.3;
|
||
int na=a->n_members;
|
||
if(na<=0) return;
|
||
int* order=malloc((size_t)na*sizeof(int));
|
||
if(!order) return;
|
||
for(int i=0;i<na;i++) order[i]=i;
|
||
/* insertion sort by centrality desc (neighborhoods are small) */
|
||
for(int i=1;i<na;i++){ int k=order[i]; int j=i-1;
|
||
while(j>=0 && a->members[order[j]].centrality < a->members[k].centrality){ order[j+1]=order[j]; j--; }
|
||
order[j+1]=k; }
|
||
int ncore=(int)(core_frac*na+0.5); if(ncore<1) ncore=1; if(ncore>na) ncore=na;
|
||
double core_sum=0, periph_sum=0; int core_n=0, periph_n=0;
|
||
for(int r=0;r<na;r++){
|
||
const GeoMember* ma=&a->members[order[r]];
|
||
const GeoMember* mb=NULL;
|
||
for(int j=0;j<b->n_members;j++){
|
||
if(b->members[j].id && ma->id && strcmp(b->members[j].id,ma->id)==0){ mb=&b->members[j]; break; }
|
||
}
|
||
if(!mb) continue;
|
||
double disp=fabs(ma->dist_centroid - mb->dist_centroid);
|
||
if(r<ncore){ core_sum+=disp; core_n++; } else { periph_sum+=disp; periph_n++; }
|
||
}
|
||
free(order);
|
||
out->core_matched=core_n; out->periph_matched=periph_n;
|
||
out->core_disp = core_n ? core_sum/core_n : 0.0;
|
||
out->periph_disp = periph_n ? periph_sum/periph_n : 0.0;
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* §5 GEOMETRY OPERATORS — relational algebra over descriptors. Pure, read-only.
|
||
* See engram_geometry.h for the frame contract + the low-rank representation note.
|
||
* All eigen-work reuses the file-static jacobi_sym above.
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
/* dot of two dim-length float vectors. */
|
||
static double geo_vdot(const float* a, const float* b, int dim){
|
||
double s=0; for(int d=0;d<dim;d++) s+=(double)a[d]*b[d]; return s;
|
||
}
|
||
/* coefficient of w along a principal axis: a_k · w. */
|
||
static double geo_axis_coef(const GeoAxis* ax, const float* w, int dim){
|
||
double s=0; for(int d=0;d<dim;d++) s+=(double)ax->axis[d]*w[d]; return s;
|
||
}
|
||
/* Σ_g · w → out (dim). Σ_g = Σ_k extent_k² a_k a_kᵀ (low-rank from the axes). */
|
||
static void geo_cov_apply(const GeoDescriptor* g, const float* w, float* out, int dim){
|
||
for(int d=0;d<dim;d++) out[d]=0.0f;
|
||
for(int k=0;k<g->n_axes;k++){
|
||
double e=g->axes[k].extent;
|
||
double c=geo_axis_coef(&g->axes[k],w,dim)*e*e;
|
||
const float* a=g->axes[k].axis;
|
||
for(int d=0;d<dim;d++) out[d]+=(float)(c*a[d]);
|
||
}
|
||
}
|
||
/* trace(Σ_g) = Σ_k extent_k² (the retained variance). */
|
||
static double geo_cov_trace(const GeoDescriptor* g){
|
||
double s=0; for(int k=0;k<g->n_axes;k++) s+=g->axes[k].extent*g->axes[k].extent; return s;
|
||
}
|
||
/* Orthonormal basis (modified Gram–Schmidt) of span(cand[0..nc-1]); each cand is
|
||
* dim floats. Writes up to nc rows into Q (row-major, caller allocs nc*dim floats).
|
||
* Returns the rank r ≤ nc (near-dependent vectors are dropped). */
|
||
static int geo_orthobasis(float* const* cand, int nc, int dim, float* Q){
|
||
int r=0;
|
||
double* v=malloc((size_t)dim*sizeof(double));
|
||
if(!v) return 0;
|
||
for(int i=0;i<nc;i++){
|
||
for(int d=0;d<dim;d++) v[d]=(double)cand[i][d];
|
||
for(int j=0;j<r;j++){
|
||
double dot=0; const float* qj=&Q[(size_t)j*dim];
|
||
for(int d=0;d<dim;d++) dot+=v[d]*qj[d];
|
||
for(int d=0;d<dim;d++) v[d]-=dot*qj[d];
|
||
}
|
||
double nrm=0; for(int d=0;d<dim;d++) nrm+=v[d]*v[d]; nrm=sqrt(nrm);
|
||
if(nrm>1e-6){ for(int d=0;d<dim;d++) Q[(size_t)r*dim+d]=(float)(v[d]/nrm); r++; }
|
||
}
|
||
free(v);
|
||
return r;
|
||
}
|
||
/* M[i][j] = q_iᵀ Σ_g q_j over an r×dim basis Q; M is r×r (symmetrized). */
|
||
static void geo_cov_in_basis(const GeoDescriptor* g, const float* Q, int r, int dim, double* M){
|
||
float* sq=malloc((size_t)dim*sizeof(float));
|
||
if(!sq){ for(int i=0;i<r*r;i++) M[i]=0; return; }
|
||
for(int j=0;j<r;j++){
|
||
geo_cov_apply(g,&Q[(size_t)j*dim],sq,dim);
|
||
for(int i=0;i<r;i++){
|
||
const float* qi=&Q[(size_t)i*dim];
|
||
double s=0; for(int d=0;d<dim;d++) s+=(double)qi[d]*sq[d];
|
||
M[i*r+j]=s;
|
||
}
|
||
}
|
||
free(sq);
|
||
for(int i=0;i<r;i++) for(int j=i+1;j<r;j++){ double m=0.5*(M[i*r+j]+M[j*r+i]); M[i*r+j]=M[j*r+i]=m; }
|
||
}
|
||
/* C = A·B for n×n row-major. */
|
||
static void geo_matmul(const double* A, const double* B, double* C, int n){
|
||
for(int i=0;i<n;i++) for(int j=0;j<n;j++){
|
||
double s=0; for(int k=0;k<n;k++) s+=A[i*n+k]*B[k*n+j]; C[i*n+j]=s; }
|
||
}
|
||
/* R = S^{1/2} for symmetric PSD n×n (eigenvalues clipped ≥0). Mirrors _sym_sqrt. */
|
||
static void geo_symsqrt(const double* S, double* R, int n){
|
||
double* A=malloc((size_t)n*n*sizeof(double));
|
||
double* w=malloc((size_t)n*sizeof(double));
|
||
double* V=malloc((size_t)n*n*sizeof(double));
|
||
if(!A||!w||!V){ free(A);free(w);free(V); for(int i=0;i<n*n;i++) R[i]=0; return; }
|
||
memcpy(A,S,(size_t)n*n*sizeof(double));
|
||
jacobi_sym(A,n,w,V); /* V columns = eigenvectors */
|
||
for(int i=0;i<n;i++) for(int j=0;j<n;j++){
|
||
double s=0;
|
||
for(int k=0;k<n;k++){ double sq=w[k]>0?sqrt(w[k]):0.0; s+=V[i*n+k]*sq*V[j*n+k]; }
|
||
R[i*n+j]=s;
|
||
}
|
||
free(A); free(w); free(V);
|
||
}
|
||
|
||
/* ── overlap ─────────────────────────────────────────────────────────────── */
|
||
int engram_geo_overlap(const GeoDescriptor* a, const GeoDescriptor* b, GeoOverlap* out){
|
||
if(!a||!b||!out||a->dim!=b->dim) return -1;
|
||
memset(out,0,sizeof*out);
|
||
int dim=a->dim; out->dim=dim;
|
||
int cap = a->n_members<b->n_members ? a->n_members : b->n_members;
|
||
out->shared_ids = cap? calloc((size_t)cap,sizeof(char*)) : NULL;
|
||
int ns=0;
|
||
for(int i=0;i<a->n_members;i++){
|
||
const char* id=a->members[i].id; if(!id) continue;
|
||
for(int j=0;j<b->n_members;j++){
|
||
if(b->members[j].id && strcmp(b->members[j].id,id)==0){
|
||
if(out->shared_ids) out->shared_ids[ns]=strdup(id);
|
||
ns++; break;
|
||
}
|
||
}
|
||
}
|
||
out->n_shared=ns;
|
||
out->n_union = a->n_members + b->n_members - ns;
|
||
out->jaccard = out->n_union? (double)ns/(double)out->n_union : 0.0;
|
||
double d=0;
|
||
if(a->centroid && b->centroid){
|
||
for(int k=0;k<dim;k++){ double df=(double)a->centroid[k]-b->centroid[k]; d+=df*df; }
|
||
d=sqrt(d);
|
||
out->intersection_centroid=malloc((size_t)dim*sizeof(float));
|
||
if(out->intersection_centroid)
|
||
for(int k=0;k<dim;k++) out->intersection_centroid[k]=0.5f*(a->centroid[k]+b->centroid[k]);
|
||
}
|
||
out->centroid_distance=d;
|
||
double denom=a->radius+b->radius+1e-9;
|
||
double prox=1.0 - d/denom; if(prox<0) prox=0;
|
||
out->overlap_score = out->jaccard*0.5 + prox*0.5;
|
||
return 0;
|
||
}
|
||
void engram_geo_overlap_free(GeoOverlap* o){
|
||
if(!o) return;
|
||
for(int i=0;i<o->n_shared;i++) free(o->shared_ids[i]);
|
||
free(o->shared_ids); free(o->intersection_centroid);
|
||
memset(o,0,sizeof*o);
|
||
}
|
||
|
||
/* ── subtract: orthogonal-complement residual ────────────────────────────── */
|
||
int engram_geo_subtract(const GeoDescriptor* a, const GeoDescriptor* b,
|
||
int b_dims, GeoResidual* out){
|
||
if(!a||!b||!out||a->dim!=b->dim) return -1;
|
||
memset(out,0,sizeof*out);
|
||
int dim=a->dim; out->dim=dim;
|
||
int mB = (b_dims>0) ? b_dims : (b->n_axes<3 ? b->n_axes : 3);
|
||
if(mB>b->n_axes) mB=b->n_axes; if(mB<0) mB=0;
|
||
out->removed_dims=mB;
|
||
|
||
double cA_norm2 = a->centroid ? geo_vdot(a->centroid,a->centroid,dim) : 0.0;
|
||
double Qc2=0;
|
||
out->residual_centroid = a->centroid ? malloc((size_t)dim*sizeof(float)) : NULL;
|
||
if(a->centroid && out->residual_centroid){
|
||
memcpy(out->residual_centroid,a->centroid,(size_t)dim*sizeof(float));
|
||
for(int k=0;k<mB;k++){
|
||
double coef=geo_axis_coef(&b->axes[k],a->centroid,dim);
|
||
Qc2 += coef*coef;
|
||
const float* ax=b->axes[k].axis;
|
||
for(int d=0;d<dim;d++) out->residual_centroid[d]-=(float)(coef*ax[d]);
|
||
}
|
||
}
|
||
/* variance of A explained by B: Tr(QΣ_A)=Σ_k Σ_j eA_j²(a_k·u_j)². */
|
||
double trA=geo_cov_trace(a), trQA=0;
|
||
for(int k=0;k<mB;k++){
|
||
for(int j=0;j<a->n_axes;j++){
|
||
double c=geo_axis_coef(&b->axes[k],a->axes[j].axis,dim);
|
||
double e=a->axes[j].extent;
|
||
trQA += e*e*c*c;
|
||
}
|
||
}
|
||
double Etot=cA_norm2+trA, Eres=(cA_norm2-Qc2)+(trA-trQA);
|
||
double ex = Etot>1e-12 ? 1.0-Eres/Etot : 0.0;
|
||
if(ex<0)ex=0; if(ex>1)ex=1;
|
||
out->variance_explained_by_B=ex;
|
||
double resvar=trA-trQA; if(resvar<0) resvar=0;
|
||
out->residual_scale=sqrt(resvar);
|
||
/* residual axes = P⊥ u_j (projected out of B's subspace). */
|
||
if(a->n_axes>0){
|
||
out->axes=calloc((size_t)a->n_axes,sizeof(GeoAxis));
|
||
int na=0;
|
||
for(int j=0;j<a->n_axes && out->axes;j++){
|
||
float* r=malloc((size_t)dim*sizeof(float)); if(!r) break;
|
||
memcpy(r,a->axes[j].axis,(size_t)dim*sizeof(float));
|
||
for(int k=0;k<mB;k++){
|
||
double coef=geo_axis_coef(&b->axes[k],a->axes[j].axis,dim);
|
||
const float* ax=b->axes[k].axis;
|
||
for(int d=0;d<dim;d++) r[d]-=(float)(coef*ax[d]);
|
||
}
|
||
double nrm=0; for(int d=0;d<dim;d++) nrm+=(double)r[d]*r[d]; nrm=sqrt(nrm);
|
||
if(nrm>1e-6){ for(int d=0;d<dim;d++) r[d]=(float)(r[d]/nrm);
|
||
out->axes[na].axis=r; out->axes[na].extent=a->axes[j].extent*nrm; na++; }
|
||
else free(r);
|
||
}
|
||
out->n_axes=na;
|
||
if(na==0){ free(out->axes); out->axes=NULL; }
|
||
}
|
||
out->centroid_diff = malloc((size_t)dim*sizeof(float));
|
||
double mag=0;
|
||
if(a->centroid && b->centroid && out->centroid_diff)
|
||
for(int d=0;d<dim;d++){ float df=a->centroid[d]-b->centroid[d]; out->centroid_diff[d]=df; mag+=(double)df*df; }
|
||
out->centroid_diff_mag=sqrt(mag);
|
||
return 0;
|
||
}
|
||
void engram_geo_residual_free(GeoResidual* r){
|
||
if(!r) return;
|
||
free(r->residual_centroid); free(r->centroid_diff);
|
||
for(int i=0;i<r->n_axes;i++) free(r->axes[i].axis);
|
||
free(r->axes);
|
||
memset(r,0,sizeof*r);
|
||
}
|
||
|
||
/* ── set-difference variant ──────────────────────────────────────────────── */
|
||
int engram_geo_setdiff(const GeoDescriptor* a, const GeoDescriptor* b, GeoSetDiff* out){
|
||
if(!a||!b||!out||a->dim!=b->dim) return -1;
|
||
memset(out,0,sizeof*out);
|
||
int dim=a->dim; out->dim=dim;
|
||
out->only_ids = a->n_members? calloc((size_t)a->n_members,sizeof(char*)) : NULL;
|
||
int no=0, rem=0;
|
||
for(int i=0;i<a->n_members;i++){
|
||
const char* id=a->members[i].id; if(!id) continue;
|
||
int in=0;
|
||
for(int j=0;j<b->n_members;j++) if(b->members[j].id && strcmp(b->members[j].id,id)==0){ in=1; break; }
|
||
if(in) rem++;
|
||
else if(out->only_ids){ out->only_ids[no++]=strdup(id); }
|
||
else no++;
|
||
}
|
||
out->n_only=no; out->removed=rem;
|
||
out->centroid_diff=malloc((size_t)dim*sizeof(float));
|
||
double mag=0;
|
||
if(a->centroid && b->centroid && out->centroid_diff)
|
||
for(int d=0;d<dim;d++){ float df=a->centroid[d]-b->centroid[d]; out->centroid_diff[d]=df; mag+=(double)df*df; }
|
||
out->centroid_diff_mag=sqrt(mag);
|
||
return 0;
|
||
}
|
||
void engram_geo_setdiff_free(GeoSetDiff* s){
|
||
if(!s) return;
|
||
for(int i=0;i<s->n_only;i++) free(s->only_ids[i]);
|
||
free(s->only_ids); free(s->centroid_diff);
|
||
memset(s,0,sizeof*s);
|
||
}
|
||
|
||
/* ── combine: pooled Gaussian (exact law-of-total-variance) ───────────────── */
|
||
GeoDescriptor* engram_geo_combine(const GeoDescriptor* a, const GeoDescriptor* b, int top_axes){
|
||
if(!a||!b||a->dim!=b->dim) return NULL;
|
||
int dim=a->dim;
|
||
if(top_axes<=0) top_axes=8;
|
||
double nA=a->n_embedded>0?a->n_embedded:a->n_members;
|
||
double nB=b->n_embedded>0?b->n_embedded:b->n_members;
|
||
if(nA<1) nA=1; if(nB<1) nB=1;
|
||
double nt=nA+nB, wA=nA/nt, wB=nB/nt, cross=nA*nB/(nt*nt);
|
||
|
||
GeoDescriptor* g=calloc(1,sizeof(GeoDescriptor));
|
||
if(!g) return NULL;
|
||
g->dim=dim;
|
||
/* pooled centroid (both must be embedded to have a meaningful centroid) */
|
||
float* d=NULL; double dnorm2=0;
|
||
if(a->centroid && b->centroid){
|
||
g->centroid=malloc((size_t)dim*sizeof(float));
|
||
d=malloc((size_t)dim*sizeof(float));
|
||
if(!g->centroid||!d){ free(d); engram_geo_free(g); return NULL; }
|
||
for(int i=0;i<dim;i++){
|
||
g->centroid[i]=(float)(wA*a->centroid[i]+wB*b->centroid[i]);
|
||
d[i]=a->centroid[i]-b->centroid[i]; dnorm2+=(double)d[i]*d[i];
|
||
}
|
||
}
|
||
if(a->global_mean){
|
||
g->global_mean=malloc((size_t)dim*sizeof(float));
|
||
if(g->global_mean) memcpy(g->global_mean,a->global_mean,(size_t)dim*sizeof(float));
|
||
}
|
||
/* pooled total variance = wA·trA + wB·trB + cross·‖d‖² */
|
||
double trA=geo_cov_trace(a), trB=geo_cov_trace(b);
|
||
g->total_variance = wA*trA + wB*trB + cross*dnorm2;
|
||
g->radius = sqrt(g->total_variance>0?g->total_variance:0);
|
||
|
||
/* eigendecompose the pooled covariance inside the joint subspace. */
|
||
int nc=a->n_axes+b->n_axes+(d?1:0);
|
||
if(nc>0){
|
||
float** cand=malloc((size_t)nc*sizeof(float*)); int ci=0;
|
||
for(int k=0;k<a->n_axes;k++) cand[ci++]=a->axes[k].axis;
|
||
for(int k=0;k<b->n_axes;k++) cand[ci++]=b->axes[k].axis;
|
||
if(d) cand[ci++]=d;
|
||
float* Q=malloc((size_t)nc*dim*sizeof(float));
|
||
int r=(cand&&Q)?geo_orthobasis(cand,nc,dim,Q):0;
|
||
if(r>0){
|
||
/* M[i][j] = q_iᵀ Σ_pooled q_j */
|
||
double* M=calloc((size_t)r*r,sizeof(double));
|
||
float* sqa=malloc((size_t)dim*sizeof(float));
|
||
float* sqb=malloc((size_t)dim*sizeof(float));
|
||
if(M&&sqa&&sqb){
|
||
for(int j=0;j<r;j++){
|
||
const float* qj=&Q[(size_t)j*dim];
|
||
geo_cov_apply(a,qj,sqa,dim);
|
||
geo_cov_apply(b,qj,sqb,dim);
|
||
double dq = d? geo_vdot(d,qj,dim) : 0.0;
|
||
for(int i=0;i<r;i++){
|
||
const float* qi=&Q[(size_t)i*dim];
|
||
double s=0;
|
||
for(int k=0;k<dim;k++)
|
||
s+=(double)qi[k]*(wA*sqa[k]+wB*sqb[k]);
|
||
if(d) s+=cross*dq*geo_vdot(d,qi,dim);
|
||
M[i*r+j]=s;
|
||
}
|
||
}
|
||
for(int i=0;i<r;i++) for(int j=i+1;j<r;j++){ double m=0.5*(M[i*r+j]+M[j*r+i]); M[i*r+j]=M[j*r+i]=m; }
|
||
double* w=malloc((size_t)r*sizeof(double));
|
||
double* V=malloc((size_t)r*r*sizeof(double));
|
||
if(w&&V){
|
||
jacobi_sym(M,r,w,V);
|
||
int* ord=malloc((size_t)r*sizeof(int));
|
||
for(int i=0;i<r;i++) ord[i]=i;
|
||
for(int i=0;i<r;i++) for(int j=i+1;j<r;j++) if(w[ord[j]]>w[ord[i]]){int t=ord[i];ord[i]=ord[j];ord[j]=t;}
|
||
int keep=top_axes; if(keep>r) keep=r;
|
||
g->axes=calloc((size_t)keep,sizeof(GeoAxis));
|
||
int na=0;
|
||
for(int t=0;t<keep && g->axes;t++){
|
||
int c=ord[t]; double lam=w[c]; if(lam<0) lam=0;
|
||
if(lam<1e-12) continue;
|
||
float* ax=calloc((size_t)dim,sizeof(float)); if(!ax) break;
|
||
for(int dd=0;dd<dim;dd++){ double s=0;
|
||
for(int i=0;i<r;i++) s+=V[i*r+c]*Q[(size_t)i*dim+dd];
|
||
ax[dd]=(float)s; }
|
||
double nn=0; for(int dd=0;dd<dim;dd++) nn+=(double)ax[dd]*ax[dd]; nn=sqrt(nn);
|
||
if(nn>1e-12) for(int dd=0;dd<dim;dd++) ax[dd]=(float)(ax[dd]/nn);
|
||
g->axes[na].axis=ax; g->axes[na].extent=sqrt(lam); na++;
|
||
}
|
||
g->n_axes=na;
|
||
free(ord);
|
||
}
|
||
free(w); free(V);
|
||
}
|
||
free(M); free(sqa); free(sqb);
|
||
}
|
||
free(Q); free(cand);
|
||
}
|
||
free(d);
|
||
|
||
/* member id-union (membership = max of the two copies). */
|
||
int cap=a->n_members+b->n_members;
|
||
g->members = cap? calloc((size_t)cap,sizeof(GeoMember)) : NULL;
|
||
int M=0;
|
||
for(int i=0;i<a->n_members && g->members;i++){
|
||
g->members[M].id=strdup(a->members[i].id?a->members[i].id:"");
|
||
g->members[M].membership=a->members[i].membership;
|
||
g->members[M].centrality=a->members[i].centrality;
|
||
g->members[M].salience=a->members[i].salience;
|
||
g->members[M].core=a->members[i].core;
|
||
g->members[M].dist_centroid=a->members[i].dist_centroid;
|
||
g->members[M].embedded=a->members[i].embedded;
|
||
M++;
|
||
}
|
||
for(int j=0;j<b->n_members && g->members;j++){
|
||
const char* id=b->members[j].id; int found=-1;
|
||
for(int i=0;i<M;i++) if(g->members[i].id && id && strcmp(g->members[i].id,id)==0){ found=i; break; }
|
||
if(found>=0){
|
||
if(b->members[j].membership>g->members[found].membership)
|
||
g->members[found].membership=b->members[j].membership;
|
||
if(b->members[j].centrality>g->members[found].centrality)
|
||
g->members[found].centrality=b->members[j].centrality;
|
||
} else {
|
||
g->members[M].id=strdup(id?id:"");
|
||
g->members[M].membership=b->members[j].membership;
|
||
g->members[M].centrality=b->members[j].centrality;
|
||
g->members[M].salience=b->members[j].salience;
|
||
g->members[M].core=b->members[j].core;
|
||
g->members[M].dist_centroid=b->members[j].dist_centroid;
|
||
g->members[M].embedded=b->members[j].embedded;
|
||
M++;
|
||
}
|
||
}
|
||
g->n_members=M;
|
||
g->n_embedded=a->n_embedded+b->n_embedded;
|
||
/* hub = highest-centrality union member (fallback A's hub). */
|
||
int hub=-1; double hv=-1;
|
||
for(int i=0;i<M;i++) if(g->members[i].centrality>hv){ hv=g->members[i].centrality; hub=i; }
|
||
g->hub_id = strdup(hub>=0 ? g->members[hub].id : (a->hub_id?a->hub_id:""));
|
||
g->k_core = a->k_core>b->k_core ? a->k_core : b->k_core;
|
||
g->co_registration = 0.5*(a->co_registration+b->co_registration);
|
||
g->n_edges=0; g->edges=NULL;
|
||
return g;
|
||
}
|
||
|
||
/* ── distance: centroid + Wasserstein-2 (Bures) ──────────────────────────── */
|
||
int engram_geo_distance(const GeoDescriptor* a, const GeoDescriptor* b, GeoDistance* out){
|
||
if(!a||!b||!out||a->dim!=b->dim) return -1;
|
||
memset(out,0,sizeof*out);
|
||
int dim=a->dim; out->dim=dim;
|
||
double d2=0, cdot=0, na=0, nb=0;
|
||
if(a->centroid && b->centroid){
|
||
for(int k=0;k<dim;k++){ double x=a->centroid[k], y=b->centroid[k];
|
||
double df=x-y; d2+=df*df; cdot+=x*y; na+=x*x; nb+=y*y; }
|
||
}
|
||
out->centroid_distance=sqrt(d2);
|
||
out->centroid_cosine = (na>1e-12 && nb>1e-12) ? cdot/(sqrt(na)*sqrt(nb)) : 0.0;
|
||
|
||
double trace_term=0;
|
||
int nc=a->n_axes+b->n_axes;
|
||
if(nc>0){
|
||
float** cand=malloc((size_t)nc*sizeof(float*)); int ci=0;
|
||
for(int k=0;k<a->n_axes;k++) cand[ci++]=a->axes[k].axis;
|
||
for(int k=0;k<b->n_axes;k++) cand[ci++]=b->axes[k].axis;
|
||
float* Q=malloc((size_t)nc*dim*sizeof(float));
|
||
int r=(cand&&Q)?geo_orthobasis(cand,nc,dim,Q):0;
|
||
if(r>0){
|
||
double* C1=malloc((size_t)r*r*sizeof(double));
|
||
double* C2=malloc((size_t)r*r*sizeof(double));
|
||
double* s2=malloc((size_t)r*r*sizeof(double));
|
||
double* tmp=malloc((size_t)r*r*sizeof(double));
|
||
double* mid=malloc((size_t)r*r*sizeof(double));
|
||
double* inner=malloc((size_t)r*r*sizeof(double));
|
||
if(C1&&C2&&s2&&tmp&&mid&&inner){
|
||
geo_cov_in_basis(a,Q,r,dim,C1);
|
||
geo_cov_in_basis(b,Q,r,dim,C2);
|
||
geo_symsqrt(C2,s2,r); /* s2 = C2^{1/2} */
|
||
geo_matmul(s2,C1,tmp,r); geo_matmul(tmp,s2,mid,r); /* s2 C1 s2 */
|
||
geo_symsqrt(mid,inner,r); /* inner = (s2 C1 s2)^{1/2}*/
|
||
double trc=0;
|
||
for(int i=0;i<r;i++) trc += C1[i*r+i]+C2[i*r+i]-2.0*inner[i*r+i];
|
||
trace_term=trc;
|
||
}
|
||
free(C1);free(C2);free(s2);free(tmp);free(mid);free(inner);
|
||
}
|
||
free(Q); free(cand);
|
||
}
|
||
double w2=d2+trace_term; if(w2<0) w2=0;
|
||
out->wasserstein2=sqrt(w2);
|
||
return 0;
|
||
}
|
||
|
||
/* ── analogy: orthogonal Procrustes (SVD via jacobi on MᵀM) ───────────────── */
|
||
int engram_geo_analogy(const GeoDescriptor* a, const GeoDescriptor* b, GeoAnalogy* out){
|
||
if(!a||!b||!out||a->dim!=b->dim) return -1;
|
||
memset(out,0,sizeof*out);
|
||
int dim=a->dim; out->dim=dim;
|
||
int k = a->n_axes<b->n_axes ? a->n_axes : b->n_axes; /* paired axes */
|
||
if(k<=0){ out->r=0; out->residual=0; return 0; }
|
||
int nc=a->n_axes+b->n_axes;
|
||
float** cand=malloc((size_t)nc*sizeof(float*)); int ci=0;
|
||
for(int t=0;t<a->n_axes;t++) cand[ci++]=a->axes[t].axis;
|
||
for(int t=0;t<b->n_axes;t++) cand[ci++]=b->axes[t].axis;
|
||
float* Q=malloc((size_t)nc*dim*sizeof(float));
|
||
int r=(cand&&Q)?geo_orthobasis(cand,nc,dim,Q):0;
|
||
if(r<=0){ free(cand); free(Q); out->r=0; return 0; }
|
||
/* extent-scaled frame coords in Q: Ahat,Bhat are r×k. */
|
||
double* Ah=calloc((size_t)r*k,sizeof(double));
|
||
double* Bh=calloc((size_t)r*k,sizeof(double));
|
||
for(int c=0;c<k;c++){
|
||
double ea=a->axes[c].extent, eb=b->axes[c].extent;
|
||
for(int i=0;i<r;i++){
|
||
Ah[i*k+c]=ea*geo_axis_coef(&a->axes[c],&Q[(size_t)i*dim],dim);
|
||
Bh[i*k+c]=eb*geo_axis_coef(&b->axes[c],&Q[(size_t)i*dim],dim);
|
||
}
|
||
}
|
||
/* Mhat = Ah·Bhᵀ (r×r) */
|
||
double* Mh=calloc((size_t)r*r,sizeof(double));
|
||
for(int i=0;i<r;i++) for(int j=0;j<r;j++){ double s=0;
|
||
for(int c=0;c<k;c++) s+=Ah[i*k+c]*Bh[j*k+c]; Mh[i*r+j]=s; }
|
||
/* S = MhᵀMh (r×r symmetric) → jacobi → V (cols), σ²=w */
|
||
double* S=calloc((size_t)r*r,sizeof(double));
|
||
for(int i=0;i<r;i++) for(int j=0;j<r;j++){ double s=0;
|
||
for(int l=0;l<r;l++) s+=Mh[l*r+i]*Mh[l*r+j]; S[i*r+j]=s; }
|
||
double* w=malloc((size_t)r*sizeof(double));
|
||
double* V=malloc((size_t)r*r*sizeof(double));
|
||
jacobi_sym(S,r,w,V);
|
||
/* U columns: u_c = Mh·V_c / σ_c (σ_c≈0 → u_c = V_c, identity on that dir). */
|
||
double* U=calloc((size_t)r*r,sizeof(double));
|
||
for(int c=0;c<r;c++){
|
||
double sig=w[c]>0?sqrt(w[c]):0.0;
|
||
if(sig>1e-9){
|
||
for(int i=0;i<r;i++){ double s=0;
|
||
for(int j=0;j<r;j++) s+=Mh[i*r+j]*V[j*r+c]; U[i*r+c]=s/sig; }
|
||
} else {
|
||
for(int i=0;i<r;i++) U[i*r+c]=V[i*r+c];
|
||
}
|
||
}
|
||
/* R̂ = U·Vᵀ */
|
||
out->R=calloc((size_t)r*r,sizeof(double));
|
||
for(int i=0;i<r;i++) for(int j=0;j<r;j++){ double s=0;
|
||
for(int c=0;c<r;c++) s+=U[i*r+c]*V[j*r+c]; out->R[i*r+j]=s; }
|
||
/* residual = ‖Ah − R̂·Bh‖_F */
|
||
double resid=0;
|
||
for(int c=0;c<k;c++) for(int i=0;i<r;i++){
|
||
double rb=0; for(int j=0;j<r;j++) rb+=out->R[i*r+j]*Bh[j*k+c];
|
||
double df=Ah[i*k+c]-rb; resid+=df*df;
|
||
}
|
||
out->residual=sqrt(resid);
|
||
out->r=r;
|
||
out->basis=malloc((size_t)r*dim*sizeof(float));
|
||
if(out->basis) memcpy(out->basis,Q,(size_t)r*dim*sizeof(float));
|
||
free(Ah); free(Bh); free(Mh); free(S); free(w); free(V); free(U);
|
||
free(Q); free(cand);
|
||
return 0;
|
||
}
|
||
void engram_geo_analogy_apply(const GeoAnalogy* an, const float* v, float* out_vec){
|
||
if(!an||!v||!out_vec) return;
|
||
int dim=an->dim, r=an->r;
|
||
for(int d=0;d<dim;d++) out_vec[d]=v[d];
|
||
if(r<=0||!an->basis||!an->R) return;
|
||
double* c=malloc((size_t)r*sizeof(double));
|
||
double* cp=malloc((size_t)r*sizeof(double));
|
||
if(!c||!cp){ free(c); free(cp); return; }
|
||
for(int i=0;i<r;i++){ double s=0; const float* qi=&an->basis[(size_t)i*dim];
|
||
for(int d=0;d<dim;d++) s+=(double)qi[d]*v[d]; c[i]=s; }
|
||
for(int i=0;i<r;i++){ double s=0; for(int j=0;j<r;j++) s+=an->R[i*r+j]*c[j]; cp[i]=s; }
|
||
for(int i=0;i<r;i++){ double delta=cp[i]-c[i]; const float* qi=&an->basis[(size_t)i*dim];
|
||
for(int d=0;d<dim;d++) out_vec[d]+=(float)(delta*qi[d]); }
|
||
free(c); free(cp);
|
||
}
|
||
void engram_geo_analogy_free(GeoAnalogy* an){
|
||
if(!an) return;
|
||
free(an->basis); free(an->R);
|
||
memset(an,0,sizeof*an);
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════════════════════
|
||
* M10 — REIFICATION: persist / load / lookup first-class neighborhood records.
|
||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||
|
||
static int64_t geo_now_ms(void){
|
||
struct timespec ts;
|
||
if(clock_gettime(CLOCK_REALTIME,&ts)==0)
|
||
return (int64_t)ts.tv_sec*1000 + ts.tv_nsec/1000000;
|
||
return (int64_t)time(NULL)*1000;
|
||
}
|
||
|
||
void engram_geo_reify_default_params(GeoReifyParams* p){
|
||
if(!p) return;
|
||
p->min_weighted_degree=0;
|
||
p->max_neighborhoods=128;
|
||
p->cover_membership=0.5;
|
||
p->persist_member_edges=1;
|
||
engram_geo_default_params(&p->descriptor);
|
||
p->descriptor.top_axes=4; /* keep a small ellipsoid summary; cheap */
|
||
p->descriptor.max_members=256; /* reified neighborhoods stay compact */
|
||
}
|
||
|
||
/* ── tiny growable string builder ─────────────────────────────────────────── */
|
||
typedef struct { char* s; size_t n, cap; } SB;
|
||
static int sb_reserve(SB* b, size_t add){
|
||
if(b->n+add+1<=b->cap) return 0;
|
||
size_t nc=b->cap?b->cap:256; while(nc<b->n+add+1) nc*=2;
|
||
char* t=realloc(b->s,nc); if(!t) return -1; b->s=t; b->cap=nc; return 0;
|
||
}
|
||
static int sb_puts(SB* b, const char* s){
|
||
size_t l=strlen(s); if(sb_reserve(b,l)) return -1;
|
||
memcpy(b->s+b->n,s,l); b->n+=l; b->s[b->n]=0; return 0;
|
||
}
|
||
static int sb_fmt(SB* b, const char* fmt, ...){
|
||
char tmp[512]; va_list ap; va_start(ap,fmt);
|
||
int k=vsnprintf(tmp,sizeof tmp,fmt,ap); va_end(ap);
|
||
if(k<0) return -1; if(k>=(int)sizeof tmp) k=sizeof tmp-1;
|
||
return sb_puts(b,tmp);
|
||
}
|
||
|
||
/* ── SELF-REIFICATION: signature (change-detection) ──────────────────────────
|
||
* An ORDER-INDEPENDENT hash of a neighborhood's identity: its member set, each
|
||
* member's membership rounded to 1e-2, plus coarse geometry (radius to 1e-2,
|
||
* k_core, n_members). Two neighborhoods with the same members at the same graded
|
||
* membership and the same coarse shape hash EQUAL — so a beat that re-derives an
|
||
* unchanged region produces the same signature and is SKIPPED (no re-append).
|
||
* Commutative accumulation (sum of per-member mixes) makes it independent of the
|
||
* order the descriptor happens to emit members in. */
|
||
static uint64_t geo_djb2(const char* s); /* fwd: defined in the string-set block */
|
||
static uint64_t geo_sig_member(const char* id, double membership){
|
||
uint64_t h = geo_djb2(id?id:"");
|
||
uint64_t w = (uint64_t)llround(membership*100.0);
|
||
h = h*1000003u + w;
|
||
/* mix so additive accumulation still spreads bits */
|
||
h ^= h>>29; h *= 0xbf58476d1ce4e5b9ULL; h ^= h>>32;
|
||
return h;
|
||
}
|
||
static uint64_t geo_nbhd_signature(const GeoDescriptor* g){
|
||
uint64_t acc = 0;
|
||
for(int i=0;i<g->n_members;i++)
|
||
acc += geo_sig_member(g->members[i].id, g->members[i].membership);
|
||
acc = acc*31 + (uint64_t)llround(g->radius*100.0);
|
||
acc = acc*31 + (uint64_t)g->k_core;
|
||
acc = acc*31 + (uint64_t)g->n_members;
|
||
return acc;
|
||
}
|
||
|
||
/* Serialize a descriptor's DURABLE geometry into the GEO1 metadata schema.
|
||
* (The raw centroid is stored separately as the record's emb.)
|
||
* name — grounded neighborhood name (NULL = legacy, omit the line)
|
||
* sig — change-detection signature (0 = omit)
|
||
* residue_block— pre-formatted "residue ..." line(s) to carry the maturation
|
||
* trail forward (NULL = none). Newest entry first.
|
||
* Unknown GEO1 lines (name/sig/residue/mean/e) are ignored by geo_parse_nbhd, so
|
||
* adding them is backward-compatible with the resident-index loader. */
|
||
static char* geo_nbhd_metadata(const GeoDescriptor* g, const char* hub,
|
||
const char* meanid, const char* name,
|
||
uint64_t sig, const char* residue_block,
|
||
int pinned){
|
||
SB b={0};
|
||
if(sb_puts(&b,"GEO1\n")) { free(b.s); return NULL; }
|
||
sb_fmt(&b,"hub %s\n", hub?hub:"");
|
||
sb_fmt(&b,"mean %s\n", meanid?meanid:"");
|
||
if(name && *name) sb_fmt(&b,"name %s\n", name);
|
||
/* pinned = this name was set by an explicit override (/api/rename or /api/reify);
|
||
* the autonomous namer must inherit it, never overwrite it with a member-derived
|
||
* name. Only the NAME is held — membership/geometry/nesting still update freely. */
|
||
if(pinned) sb_puts(&b,"pinned 1\n");
|
||
if(sig) sb_fmt(&b,"sig %llx\n", (unsigned long long)sig);
|
||
if(residue_block && *residue_block){ sb_puts(&b,residue_block);
|
||
if(residue_block[strlen(residue_block)-1] != '\n') sb_puts(&b,"\n"); }
|
||
sb_fmt(&b,"s %.9g %.9g %d %.9g %d %d\n",
|
||
g->radius, g->total_variance, g->k_core, g->co_registration,
|
||
g->n_embedded, g->n_members);
|
||
sb_puts(&b,"e");
|
||
for(int i=0;i<g->n_axes;i++) sb_fmt(&b," %.9g", g->axes[i].extent);
|
||
sb_puts(&b,"\n");
|
||
for(int i=0;i<g->n_members;i++){
|
||
sb_fmt(&b,"m %s %.9g %.9g %d\n",
|
||
g->members[i].id, g->members[i].membership,
|
||
g->members[i].centrality, g->members[i].core);
|
||
}
|
||
return b.s; /* caller frees */
|
||
}
|
||
|
||
/* Extract one GEO1 field line's value (e.g. field="name", "sig") into out.
|
||
* Returns 1 on hit. Matches at line start (after '\n' or buffer start). */
|
||
static int geo_md_field(const char* md, const char* field, char* out, size_t olen){
|
||
if(!md||!field||!out||!olen) return 0;
|
||
size_t fl=strlen(field);
|
||
for(const char* p=md; p && *p; ){
|
||
if((p==md||p[-1]=='\n') && strncmp(p,field,fl)==0 && p[fl]==' '){
|
||
const char* v=p+fl+1; const char* e=v; while(*e&&*e!='\n') e++;
|
||
size_t n=(size_t)(e-v); if(n>=olen) n=olen-1;
|
||
memcpy(out,v,n); out[n]=0; return 1;
|
||
}
|
||
p=strchr(p,'\n'); if(p) p++;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
/* Collect all "residue ..." lines from a prior neighborhood's metadata, newest
|
||
* first, as a single block (each terminated by '\n'). Caller frees. NULL if none. */
|
||
static char* geo_md_residue_block(const char* md){
|
||
if(!md) return NULL;
|
||
SB b={0}; int any=0;
|
||
for(const char* p=md; p && *p; ){
|
||
if((p==md||p[-1]=='\n') && strncmp(p,"residue ",8)==0){
|
||
const char* e=p; while(*e&&*e!='\n') e++;
|
||
sb_reserve(&b,(size_t)(e-p)+1);
|
||
memcpy(b.s+b.n,p,(size_t)(e-p)); b.n+=(size_t)(e-p); b.s[b.n++]='\n'; b.s[b.n]=0; any=1;
|
||
}
|
||
p=strchr(p,'\n'); if(p) p++;
|
||
}
|
||
if(!any){ free(b.s); return NULL; }
|
||
return b.s;
|
||
}
|
||
|
||
/* GROUNDED NAMING: compose a neighborhood name from its most-central members'
|
||
* labels. Not fabricated — every token is a real member label (or content head).
|
||
* "· "-joined, top-3 by centrality, sanitized (no newline/control), <=120 chars.
|
||
* FLAT: encodes no importance/priority — just what the region is made of. */
|
||
static void geo_sanitize_label(const char* in, char* out, size_t olen){
|
||
size_t j=0;
|
||
for(const char* p=in; p && *p && j+1<olen; p++){
|
||
unsigned char c=(unsigned char)*p;
|
||
if(c=='\n'||c=='\r'||c=='\t'||c=='|') { out[j++]=' '; continue; }
|
||
if(c<32) continue;
|
||
out[j++]=(char)c;
|
||
}
|
||
out[j]=0;
|
||
/* trim trailing spaces */
|
||
while(j>0 && out[j-1]==' ') out[--j]=0;
|
||
}
|
||
static void geo_grounded_name(EngramPagedStore* store, const GeoDescriptor* g,
|
||
char* out, size_t olen){
|
||
out[0]=0;
|
||
/* rank members by centrality desc into idx[] (small: top few only) */
|
||
int n=g->n_members; if(n<=0){ snprintf(out,olen,"reified-neighborhood"); return; }
|
||
int top[3]={-1,-1,-1}; double topc[3]={-1,-1,-1};
|
||
for(int i=0;i<n;i++){
|
||
double c=g->members[i].centrality;
|
||
for(int s=0;s<3;s++){ if(c>topc[s]){ for(int t=2;t>s;t--){topc[t]=topc[t-1];top[t]=top[t-1];} topc[s]=c; top[s]=i; break; } }
|
||
}
|
||
SB b={0}; sb_puts(&b,"region: "); int wrote=0;
|
||
for(int s=0;s<3;s++){
|
||
if(top[s]<0) break;
|
||
StoreNode sn; memset(&sn,0,sizeof sn);
|
||
if(store_get_node(store, g->members[top[s]].id, &sn)==1){
|
||
const char* raw = (sn.label&&*sn.label)? sn.label : (sn.content?sn.content:"");
|
||
char lab[128]; char clean[128];
|
||
size_t rl=strlen(raw); if(rl>90) rl=90;
|
||
memcpy(lab,raw,rl); lab[rl]=0;
|
||
geo_sanitize_label(lab,clean,sizeof clean);
|
||
if(*clean){ if(wrote) sb_puts(&b," · "); sb_puts(&b,clean); wrote++; }
|
||
store_node_free(&sn);
|
||
}
|
||
}
|
||
if(!wrote){ free(b.s); snprintf(out,olen,"reified-neighborhood"); return; }
|
||
size_t k=strlen(b.s); if(k>=olen) k=olen-1;
|
||
memcpy(out,b.s,k); out[k]=0; free(b.s);
|
||
}
|
||
|
||
/* ── string set (greedy-cover claimed ids) + string→id list (hub→old nbhd) ──── */
|
||
static uint64_t geo_djb2(const char* s){
|
||
uint64_t h=5381; for(;*s;s++) h=((h<<5)+h)^(unsigned char)*s; return h;
|
||
}
|
||
typedef struct SSNode { char* key; struct SSNode* next; } SSNode;
|
||
typedef struct { SSNode** b; size_t nb; } SSet;
|
||
static void ss_init(SSet* s, size_t nb){ s->nb=nb; s->b=calloc(nb,sizeof*s->b); }
|
||
static int ss_has(const SSet* s, const char* k){
|
||
if(!s->b) return 0; SSNode* n=s->b[geo_djb2(k)%s->nb];
|
||
for(;n;n=n->next) if(strcmp(n->key,k)==0) return 1; return 0;
|
||
}
|
||
static void ss_add(SSet* s, const char* k){
|
||
if(!s->b||ss_has(s,k)) return; size_t i=geo_djb2(k)%s->nb;
|
||
SSNode* n=malloc(sizeof*n); if(!n) return; n->key=strdup(k); n->next=s->b[i]; s->b[i]=n;
|
||
}
|
||
static void ss_free(SSet* s){
|
||
if(!s->b) return;
|
||
for(size_t i=0;i<s->nb;i++){ SSNode* n=s->b[i]; while(n){ SSNode* x=n->next; free(n->key); free(n); n=x; } }
|
||
free(s->b); s->b=NULL;
|
||
}
|
||
|
||
typedef struct { char** id; int n, cap; } StrVec;
|
||
static void sv_push(StrVec* v, const char* s){
|
||
if(v->n==v->cap){ v->cap=v->cap?v->cap*2:64; v->id=realloc(v->id,(size_t)v->cap*sizeof*v->id); }
|
||
v->id[v->n++]=strdup(s);
|
||
}
|
||
static void sv_free(StrVec* v){ for(int i=0;i<v->n;i++) free(v->id[i]); free(v->id); }
|
||
|
||
typedef struct { uint64_t* v; int n, cap; } U64Vec;
|
||
static void u64_push(U64Vec* u, uint64_t x){
|
||
if(u->n==u->cap){ u->cap=u->cap?u->cap*2:64; u->v=realloc(u->v,(size_t)u->cap*sizeof*u->v); }
|
||
u->v[u->n++]=x;
|
||
}
|
||
static void u64_free(U64Vec* u){ free(u->v); }
|
||
|
||
/* Recompute a neighborhood's signature FROM ITS PERSISTED metadata (member lines
|
||
* + s-line), using the exact same order-independent formula as geo_nbhd_signature
|
||
* so a re-derived unchanged region compares EQUAL. Returns 0 if unparseable. */
|
||
static uint64_t geo_sig_from_metadata(const char* md){
|
||
if(!md) return 0;
|
||
uint64_t acc=0; int n_members=0; double radius=0; int k_core=0;
|
||
for(const char* line=md; line && *line; ){
|
||
const char* nl=strchr(line,'\n');
|
||
size_t len = nl? (size_t)(nl-line):strlen(line);
|
||
char buf[600]; if(len>=sizeof buf) len=sizeof buf-1;
|
||
memcpy(buf,line,len); buf[len]=0;
|
||
if(buf[0]=='m'&&buf[1]==' '){
|
||
char mid[512]; double w=0;
|
||
if(sscanf(buf+2,"%511s %lf",mid,&w)>=1){ acc += geo_sig_member(mid,w); n_members++; }
|
||
} else if(buf[0]=='s'&&buf[1]==' '){
|
||
double tv=0,cr=0; int ne=0,nm=0;
|
||
sscanf(buf+2,"%lf %lf %d %lf %d %d",&radius,&tv,&k_core,&cr,&ne,&nm);
|
||
}
|
||
line = nl? nl+1:NULL;
|
||
}
|
||
acc = acc*31 + (uint64_t)llround(radius*100.0);
|
||
acc = acc*31 + (uint64_t)k_core;
|
||
acc = acc*31 + (uint64_t)n_members;
|
||
return acc;
|
||
}
|
||
|
||
/* pass 1 collector: all non-structural node ids; also record existing Neighborhood
|
||
* records as (hub -> old_id) so a re-reify supersedes the prior version. For the
|
||
* on-beat incremental path we also retain each existing neighborhood's signature
|
||
* (change-detection) and full metadata (residue carry-forward + prior name). */
|
||
typedef struct {
|
||
StrVec cand; /* candidate node ids (content nodes) */
|
||
StrVec old_hub, old_id, old_md; /* parallel: existing nbhd hub, id, metadata */
|
||
U64Vec old_sig; /* parallel: existing nbhd signature */
|
||
} ReifyScan;
|
||
static void geo_reify_scan_cb(const StoreNode* n, void* ctx){
|
||
ReifyScan* rs=ctx; if(!n->id||!n->node_type) { if(n->id) sv_push(&rs->cand,n->id); return; }
|
||
if(strcmp(n->node_type,ENGRAM_GEO_NBHD_TYPE)==0){
|
||
/* SUPER records (nbhd-super-<hub>) are also type Neighborhood and carry a
|
||
* "hub" line equal to their first child's hub. They are owned exclusively by
|
||
* engram_geo_reify_nest (which tombstones prior supers itself). Excluding
|
||
* them from the FLAT-reify lineage is essential: otherwise a flat hub could
|
||
* match a super record as its "prior neighborhood", supersede the SUPER, and
|
||
* carry the wrong name/residue — the exact defect that dropped a manual
|
||
* rename from the chain (2026-08-14). Flat reify supersedes ONLY flat records. */
|
||
if(n->id && strncmp(n->id,ENGRAM_GEO_SUPER_ID_PREFIX,strlen(ENGRAM_GEO_SUPER_ID_PREFIX))==0)
|
||
return;
|
||
/* parse hub from metadata GEO1 (line "hub <id>") for supersede lineage */
|
||
const char* md=n->metadata?n->metadata:"";
|
||
const char* p=strstr(md,"hub ");
|
||
if(p && (p==md || p[-1]=='\n')){
|
||
p+=4; const char* e=p; while(*e && *e!='\n') e++;
|
||
char* hub=strndup(p,(size_t)(e-p));
|
||
sv_push(&rs->old_hub,hub); sv_push(&rs->old_id,n->id);
|
||
sv_push(&rs->old_md, md);
|
||
u64_push(&rs->old_sig, geo_sig_from_metadata(md));
|
||
free(hub);
|
||
}
|
||
return; /* structural: not a candidate */
|
||
}
|
||
if(strcmp(n->node_type,ENGRAM_GEO_MEANFRAME_TYPE)==0) return;
|
||
sv_push(&rs->cand,n->id);
|
||
}
|
||
|
||
/* STRUCTURAL relations are reification's OWN bookkeeping edges (member/supersedes/
|
||
* contains/nested-in/tombstones). They must be invisible to hub detection: if the
|
||
* weighted degree counted them, each beat's member/supersedes edges would inflate
|
||
* the degree of the nodes reification just touched, shift the top-N hub ranking,
|
||
* and re-reify a different set every beat — unbounded churn (observed 2026-08-14
|
||
* before this guard). Excluding them makes hub selection a pure function of the
|
||
* Hebbian/semantic graph, so a settled store yields the SAME hubs every beat →
|
||
* identical signatures → all skipped → convergence. */
|
||
static int geo_is_structural_relation(const char* r){
|
||
if(!r) return 0;
|
||
return strcmp(r,ENGRAM_GEO_MEMBER_RELATION)==0
|
||
|| strcmp(r,ENGRAM_GEO_CONTAINS_RELATION)==0
|
||
|| strcmp(r,ENGRAM_GEO_NESTED_RELATION)==0
|
||
|| strcmp(r,"supersedes")==0
|
||
|| strcmp(r,"tombstones")==0;
|
||
}
|
||
|
||
/* weighted strong-edge degree of a node (from+to), matching eff_w/threshold. */
|
||
static double geo_weighted_degree(EngramPagedStore* st, const char* id, double emin){
|
||
double deg=0; StoreEdge* es=NULL; size_t ne=0;
|
||
if(store_get_edges_from(st,id,&es,&ne)==0 && es){
|
||
for(size_t e=0;e<ne;e++){ if(es[e].tombstoned||es[e].inhibitory) continue;
|
||
if(geo_is_structural_relation(es[e].relation)) continue;
|
||
double w=eff_w(es[e].weight,es[e].hebb); if(w>=emin) deg+=w; }
|
||
}
|
||
store_edges_free(es,ne); es=NULL; ne=0;
|
||
if(store_get_edges_to(st,id,&es,&ne)==0 && es){
|
||
for(size_t e=0;e<ne;e++){ if(es[e].tombstoned||es[e].inhibitory) continue;
|
||
if(geo_is_structural_relation(es[e].relation)) continue;
|
||
double w=eff_w(es[e].weight,es[e].hebb); if(w>=emin) deg+=w; }
|
||
}
|
||
store_edges_free(es,ne);
|
||
return deg;
|
||
}
|
||
|
||
int engram_geo_reify_store(EngramPagedStore* store, const VIndex* vindex,
|
||
char** vids, int n_vids,
|
||
const GeoReifyParams* params){
|
||
if(!store) return -1;
|
||
GeoReifyParams P; if(params) P=*params; else engram_geo_reify_default_params(&P);
|
||
|
||
/* 1. true store-wide mean → persist the GeoMeanFrame record (once). */
|
||
GeoMeanCache* mc=engram_geo_mean_build(store);
|
||
if(!mc) return -2;
|
||
int dim=engram_geo_mean_dim(mc);
|
||
const float* mean=engram_geo_mean_vec(mc);
|
||
int64_t now=geo_now_ms();
|
||
{ StoreNode mf; memset(&mf,0,sizeof mf);
|
||
mf.id=(char*)ENGRAM_GEO_MEANFRAME_ID; mf.node_type=(char*)ENGRAM_GEO_MEANFRAME_TYPE;
|
||
mf.content=(char*)"geo-mean-frame"; mf.tier=(char*)"Semantic"; mf.metadata=(char*)"{}";
|
||
mf.emb=(float*)mean; mf.emb_dim=dim; mf.created_at=now; mf.updated_at=now;
|
||
if(store_put_node(store,&mf)<0){ engram_geo_mean_free(mc); return -3; }
|
||
}
|
||
|
||
/* 2. scan: candidate ids + existing (hub→old id) for supersede. */
|
||
ReifyScan rs; memset(&rs,0,sizeof rs);
|
||
if(store_scan_nodes(store,geo_reify_scan_cb,&rs)<0){
|
||
sv_free(&rs.cand); sv_free(&rs.old_hub); sv_free(&rs.old_id);
|
||
sv_free(&rs.old_md); u64_free(&rs.old_sig);
|
||
engram_geo_mean_free(mc); return -4;
|
||
}
|
||
|
||
/* 3. weighted degree per candidate; sort desc. */
|
||
int N=rs.cand.n;
|
||
double* deg=malloc((size_t)N*sizeof(double));
|
||
int* ord=malloc((size_t)N*sizeof(int));
|
||
for(int i=0;i<N;i++){ deg[i]=geo_weighted_degree(store,rs.cand.id[i],P.descriptor.edge_min_weight); ord[i]=i; }
|
||
/* simple insertion-ish selection sort by degree desc (N a few thousand, one-time) */
|
||
for(int a=0;a<N;a++){ int best=a; for(int b=a+1;b<N;b++) if(deg[ord[b]]>deg[ord[best]]) best=b;
|
||
int t=ord[a]; ord[a]=ord[best]; ord[best]=t; }
|
||
|
||
/* 4. greedy non-redundant cover: reify each qualifying hub once.
|
||
* BUDGET is over the CANONICAL SET, not over writes. `considered` counts every
|
||
* hub that resolves to a neighborhood this beat — whether newly written OR
|
||
* incrementally skipped-because-unchanged. Bounding `considered` (not
|
||
* `persisted`) means every beat revisits the SAME top-max_neighborhoods hubs:
|
||
* a settled store skips all of them and writes nothing (convergent + bounded).
|
||
* Counting only writes would instead march max_neighborhoods hubs DEEPER into
|
||
* the candidate list each beat → unbounded growth (observed 2026-08-14). */
|
||
SSet claimed; ss_init(&claimed, (size_t)(N>16?N:16));
|
||
int persisted=0, considered=0;
|
||
for(int oi=0; oi<N && considered<P.max_neighborhoods; oi++){
|
||
int i=ord[oi]; const char* hub=rs.cand.id[i];
|
||
if(P.min_weighted_degree>0 && deg[i]<(double)P.min_weighted_degree) break; /* sorted: rest smaller */
|
||
if(ss_has(&claimed,hub)) continue;
|
||
const char* seeds[1]={hub};
|
||
GeoDescriptor* g=engram_geometry_descriptor(store,vindex,vids,n_vids,
|
||
seeds,1,&P.descriptor,mean);
|
||
if(!g || g->n_members<=0){ if(g) engram_geo_free(g); continue; }
|
||
/* claim members above cover threshold (incl. the hub itself). This is
|
||
* HUB-dedup only — it prevents a second hub re-deriving the SAME region.
|
||
* It does NOT partition membership: each hub's descriptor independently
|
||
* includes whatever nodes are near it, so a node can be a graded member
|
||
* of several overlapping neighborhoods (soft/overlapping communities). */
|
||
for(int m=0;m<g->n_members;m++)
|
||
if(g->members[m].membership>=P.cover_membership) ss_add(&claimed,g->members[m].id);
|
||
|
||
/* locate this hub's current live neighborhood (first match) for
|
||
* change-detection + residue lineage. -1 = brand-new region. */
|
||
int oldk=-1;
|
||
for(int k=0;k<rs.old_hub.n;k++) if(strcmp(rs.old_hub.id[k],hub)==0){ oldk=k; break; }
|
||
|
||
/* CHANGE-DETECTION (on-beat idempotency): if this hub already has a live
|
||
* neighborhood whose signature equals the freshly-derived one, the region
|
||
* has not materially changed — write NOTHING (no re-append, no supersede).
|
||
* On a settled store every hub takes this branch → zero store growth per
|
||
* beat, which is what makes the operation convergent under barrier/GC. */
|
||
uint64_t newsig = geo_nbhd_signature(g);
|
||
if(P.incremental && oldk>=0 && rs.old_sig.v[oldk]==newsig){
|
||
if(P.stats) P.stats->skipped++;
|
||
considered++; /* still a canonical neighborhood this beat */
|
||
engram_geo_free(g);
|
||
continue;
|
||
}
|
||
|
||
/* build record: id = nbhd-<hub>-<now>, emb = RAW centroid = centered+mean */
|
||
char nid[512]; snprintf(nid,sizeof nid,"%s%s-%lld",ENGRAM_GEO_NBHD_ID_PREFIX,hub,(long long)now);
|
||
float* raw=NULL;
|
||
if(g->n_embedded>0 && g->centroid && g->global_mean){
|
||
raw=malloc((size_t)dim*sizeof(float));
|
||
if(raw) for(int d=0;d<dim;d++) raw[d]=g->centroid[d]+g->global_mean[d];
|
||
}
|
||
|
||
/* NAME. If this hub's current live record has a PINNED (explicit-override)
|
||
* name, INHERIT it — the autonomous namer never overwrites a manual name.
|
||
* The region may have re-clustered (that is why we are writing a new record
|
||
* at all), but the NAME is held until the next explicit change. Otherwise
|
||
* ground the name in the most-central members (or legacy fixed content). */
|
||
char name[256]; name[0]=0;
|
||
int pinned=0;
|
||
if(oldk>=0){
|
||
char pf[8];
|
||
if(geo_md_field(rs.old_md.id[oldk],"pinned",pf,sizeof pf) && pf[0]=='1'){
|
||
if(geo_md_field(rs.old_md.id[oldk],"name",name,sizeof name)) pinned=1;
|
||
}
|
||
}
|
||
const char* content = (char*)"reified-neighborhood";
|
||
if(pinned){ content=name; }
|
||
else if(P.grounded_name){ geo_grounded_name(store,g,name,sizeof name); content=name; }
|
||
|
||
/* RESIDUE: when superseding a prior neighborhood, PREPEND a residue entry
|
||
* (old_id | cause | prior_name) and carry the prior residue chain forward,
|
||
* so the new record retains the ordered trail of how the understanding got
|
||
* here. Nothing is destroyed: store_supersede tombstones (recoverable). */
|
||
char* residue=NULL;
|
||
if(oldk>=0){
|
||
const char* cause = (P.cause&&*P.cause)? P.cause : "reify";
|
||
char prevname[200]; if(!geo_md_field(rs.old_md.id[oldk],"name",prevname,sizeof prevname))
|
||
snprintf(prevname,sizeof prevname,"reified-neighborhood");
|
||
char* carry = geo_md_residue_block(rs.old_md.id[oldk]);
|
||
SB rb={0};
|
||
sb_fmt(&rb,"residue %s|%s|%s\n", rs.old_id.id[oldk], cause, prevname);
|
||
if(carry) sb_puts(&rb,carry);
|
||
free(carry);
|
||
residue = rb.s;
|
||
}
|
||
|
||
char* md=geo_nbhd_metadata(g,hub,ENGRAM_GEO_MEANFRAME_ID,
|
||
(pinned||P.grounded_name)?name:NULL, newsig, residue, pinned);
|
||
StoreNode nn; memset(&nn,0,sizeof nn);
|
||
nn.id=nid; nn.node_type=(char*)ENGRAM_GEO_NBHD_TYPE;
|
||
nn.content=(char*)content; nn.tier=(char*)"Semantic";
|
||
nn.metadata=md?md:(char*)"{}"; nn.emb=raw; nn.emb_dim=raw?dim:0;
|
||
nn.created_at=now; nn.updated_at=now;
|
||
int wrc=store_put_node(store,&nn);
|
||
free(raw); free(md); free(residue);
|
||
if(wrc<0){ engram_geo_free(g); continue; }
|
||
|
||
/* provenance: supersede ALL prior neighborhoods for this hub (never a
|
||
* content node — old_id is always an nbhd- record from the scan). Also
|
||
* write a graph-queryable "supersedes" edge new→old for the residue trail. */
|
||
for(int k=0;k<rs.old_hub.n;k++) if(strcmp(rs.old_hub.id[k],hub)==0){
|
||
store_supersede(store, rs.old_id.id[k], nid);
|
||
char seid[600]; snprintf(seid,sizeof seid,"%s~sup~%s",nid,rs.old_id.id[k]);
|
||
StoreEdge sup; memset(&sup,0,sizeof sup);
|
||
sup.id=seid; sup.from_id=nid; sup.to_id=rs.old_id.id[k];
|
||
sup.relation=(char*)"supersedes"; sup.metadata=(char*)"{}";
|
||
sup.weight=1.0; sup.confidence=1.0; sup.created_at=now; sup.updated_at=now;
|
||
store_put_edge(store,&sup);
|
||
if(P.stats) P.stats->superseded++;
|
||
}
|
||
|
||
/* member links (durable, but inert to activation — runtime skips them).
|
||
* weight = graded membership → soft/overlapping membership is preserved. */
|
||
if(P.persist_member_edges){
|
||
for(int m=0;m<g->n_members;m++){
|
||
char eid[600]; snprintf(eid,sizeof eid,"%s->%s",nid,g->members[m].id);
|
||
StoreEdge se; memset(&se,0,sizeof se);
|
||
se.id=eid; se.from_id=nid; se.to_id=g->members[m].id;
|
||
se.relation=(char*)ENGRAM_GEO_MEMBER_RELATION;
|
||
se.metadata=(char*)"{}"; se.weight=g->members[m].membership;
|
||
se.confidence=1.0; se.created_at=now; se.updated_at=now;
|
||
store_put_edge(store,&se);
|
||
if(P.stats) P.stats->member_edges++;
|
||
}
|
||
}
|
||
engram_geo_free(g);
|
||
if(P.stats) P.stats->reified++;
|
||
persisted++;
|
||
considered++;
|
||
}
|
||
|
||
ss_free(&claimed);
|
||
free(deg); free(ord);
|
||
sv_free(&rs.cand); sv_free(&rs.old_hub); sv_free(&rs.old_id);
|
||
sv_free(&rs.old_md); u64_free(&rs.old_sig);
|
||
engram_geo_mean_free(mc);
|
||
return persisted;
|
||
}
|
||
|
||
/* ── ASYNC EXPLICIT OVERRIDE: rename a live neighborhood (degenerate manual case).
|
||
* Non-blocking wrt the autonomous beat: it just writes a superseding record. */
|
||
char* engram_geo_neighborhood_rename(EngramPagedStore* store,
|
||
const char* nbhd_id, const char* new_name){
|
||
if(!store||!nbhd_id||!new_name||!*new_name) return NULL;
|
||
StoreNode old; memset(&old,0,sizeof old);
|
||
if(store_get_node(store,nbhd_id,&old)!=1) return NULL;
|
||
if(!old.node_type || strcmp(old.node_type,ENGRAM_GEO_NBHD_TYPE)!=0){ store_node_free(&old); return NULL; }
|
||
const char* md = old.metadata?old.metadata:"";
|
||
/* keep the SAME geometry: copy every non-(name/sig/residue/content) line and
|
||
* splice a new name + prepended residue entry recording the prior name. */
|
||
char hub[512]=""; geo_md_field(md,"hub",hub,sizeof hub);
|
||
char prevname[200]; if(!geo_md_field(md,"name",prevname,sizeof prevname))
|
||
snprintf(prevname,sizeof prevname,"%s", old.content?old.content:"reified-neighborhood");
|
||
char* carry = geo_md_residue_block(md);
|
||
|
||
int64_t now=geo_now_ms();
|
||
char nid[560]; snprintf(nid,sizeof nid,"%s%s-%lld",ENGRAM_GEO_NBHD_ID_PREFIX,hub[0]?hub:nbhd_id,(long long)now);
|
||
|
||
SB out={0}; sb_puts(&out,"GEO1\n");
|
||
for(const char* line=md; line && *line; ){
|
||
const char* nl=strchr(line,'\n'); size_t len=nl?(size_t)(nl-line):strlen(line);
|
||
char buf[600]; if(len>=sizeof buf) len=sizeof buf-1; memcpy(buf,line,len); buf[len]=0;
|
||
if(strncmp(buf,"GEO1",4)==0){ line=nl?nl+1:NULL; continue; }
|
||
if(strncmp(buf,"name ",5)==0){ line=nl?nl+1:NULL; continue; }
|
||
if(strncmp(buf,"sig ",4)==0){ line=nl?nl+1:NULL; continue; }
|
||
if(strncmp(buf,"pinned ",7)==0){ line=nl?nl+1:NULL; continue; }
|
||
if(strncmp(buf,"residue ",8)==0){ line=nl?nl+1:NULL; continue; }
|
||
if(buf[0]=='h'&&buf[1]=='u'&&buf[2]=='b'&&buf[3]==' '){
|
||
sb_fmt(&out,"hub %s\n", hub[0]?hub:nbhd_id);
|
||
sb_fmt(&out,"name %s\n", new_name);
|
||
/* PIN: a name set by explicit override is held against the autonomous
|
||
* namer until the next explicit change. */
|
||
sb_puts(&out,"pinned 1\n");
|
||
sb_fmt(&out,"residue %s|explicit-override|%s\n", nbhd_id, prevname);
|
||
if(carry) sb_puts(&out,carry);
|
||
line=nl?nl+1:NULL; continue;
|
||
}
|
||
sb_puts(&out,buf); sb_puts(&out,"\n");
|
||
line=nl?nl+1:NULL;
|
||
}
|
||
free(carry);
|
||
|
||
StoreNode nn; memset(&nn,0,sizeof nn);
|
||
nn.id=nid; nn.node_type=(char*)ENGRAM_GEO_NBHD_TYPE;
|
||
nn.content=(char*)new_name; nn.tier=(char*)"Semantic";
|
||
nn.metadata=out.s?out.s:(char*)"{}"; nn.emb=old.emb; nn.emb_dim=old.emb_dim;
|
||
nn.created_at=now; nn.updated_at=now;
|
||
int wrc=store_put_node(store,&nn);
|
||
free(out.s);
|
||
if(wrc<0){ store_node_free(&old); return NULL; }
|
||
store_supersede(store,nbhd_id,nid);
|
||
{ char seid[640]; snprintf(seid,sizeof seid,"%s~sup~%s",nid,nbhd_id);
|
||
StoreEdge sup; memset(&sup,0,sizeof sup);
|
||
sup.id=seid; sup.from_id=nid; sup.to_id=(char*)nbhd_id;
|
||
sup.relation=(char*)"supersedes"; sup.metadata=(char*)"{}";
|
||
sup.weight=1.0; sup.confidence=1.0; sup.created_at=now; sup.updated_at=now;
|
||
store_put_edge(store,&sup); }
|
||
store_node_free(&old);
|
||
return strdup(nid);
|
||
}
|
||
|
||
/* ═══════════════ resident loaded form + hot-path lookup ═════════════════════ */
|
||
|
||
typedef struct {
|
||
char* id;
|
||
char* hub_id;
|
||
int n_members;
|
||
char** member_ids;
|
||
double* member_w;
|
||
double radius, co_reg;
|
||
int k_core, n_embedded;
|
||
float* centroid_raw; /* dim floats or NULL */
|
||
float* centroid_unit; /* centered+normalized (finalize) or NULL */
|
||
int dim;
|
||
char** contains; /* sub-neighborhood ids (super nbhd) or NULL */
|
||
int n_contains;
|
||
int level; /* 0 = flat, 1 = super (contains sub-neighborhoods) */
|
||
GeoNeighborhood view;
|
||
} RNbhd;
|
||
|
||
typedef struct RE { char* id; int nbhd; double w; struct RE* next; } RE;
|
||
|
||
struct GeoReifyIndex {
|
||
RNbhd* nb; int n, cap;
|
||
float* mean; int mean_dim;
|
||
RE** buckets; size_t nbuckets;
|
||
double* score; /* scratch[n], reused per lookup */
|
||
};
|
||
|
||
GeoReifyIndex* engram_geo_reify_index_new(void){
|
||
GeoReifyIndex* ix=calloc(1,sizeof*ix); return ix;
|
||
}
|
||
|
||
/* parse a GEO1 metadata blob into an RNbhd (members + scalars). */
|
||
static int geo_parse_nbhd(const char* md, RNbhd* r){
|
||
if(!md) return -1;
|
||
if(strncmp(md,"GEO1",4)!=0) return -1;
|
||
/* count member lines to size arrays */
|
||
int cap=0; for(const char* p=md; (p=strstr(p,"\nm ")); p+=3) cap++;
|
||
r->member_ids=cap?calloc((size_t)cap,sizeof(char*)):NULL;
|
||
r->member_w =cap?calloc((size_t)cap,sizeof(double)):NULL;
|
||
r->n_members=0;
|
||
const char* line=md;
|
||
while(line && *line){
|
||
const char* nl=strchr(line,'\n');
|
||
size_t len= nl? (size_t)(nl-line) : strlen(line);
|
||
char buf[600]; if(len>=sizeof buf) len=sizeof buf-1;
|
||
memcpy(buf,line,len); buf[len]=0;
|
||
if(buf[0]=='h'&&buf[1]=='u'&&buf[2]=='b'&&buf[3]==' '){
|
||
free(r->hub_id); r->hub_id=strdup(buf+4);
|
||
} else if(buf[0]=='s'&&buf[1]==' '){
|
||
int kc=0,ne=0,nm=0; double rad=0,tv=0,cr=0;
|
||
sscanf(buf+2,"%lf %lf %d %lf %d %d",&rad,&tv,&kc,&cr,&ne,&nm);
|
||
r->radius=rad; r->co_reg=cr; r->k_core=kc; r->n_embedded=ne;
|
||
} else if(buf[0]=='m'&&buf[1]==' '){
|
||
char mid[512]; double w=0,c=0; int core=0;
|
||
if(sscanf(buf+2,"%511s %lf %lf %d",mid,&w,&c,&core)>=2 && r->member_ids){
|
||
r->member_ids[r->n_members]=strdup(mid);
|
||
r->member_w[r->n_members]=w;
|
||
r->n_members++;
|
||
}
|
||
} else if(buf[0]=='c'&&buf[1]==' '){ /* nesting: child neighborhood id */
|
||
char cid[512];
|
||
if(sscanf(buf+2,"%511s",cid)==1){
|
||
char** t=realloc(r->contains,(size_t)(r->n_contains+1)*sizeof(char*));
|
||
if(t){ r->contains=t; r->contains[r->n_contains++]=strdup(cid); }
|
||
}
|
||
} else if(strncmp(buf,"level ",6)==0){
|
||
r->level=atoi(buf+6);
|
||
}
|
||
line = nl? nl+1 : NULL;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
int engram_geo_reify_index_add(GeoReifyIndex* ix, const StoreNode* n){
|
||
if(!ix||!n||!n->node_type) return 0;
|
||
if(strcmp(n->node_type,ENGRAM_GEO_MEANFRAME_TYPE)==0){
|
||
if(n->emb && n->emb_dim>0){
|
||
free(ix->mean);
|
||
ix->mean=malloc((size_t)n->emb_dim*sizeof(float));
|
||
if(ix->mean){ memcpy(ix->mean,n->emb,(size_t)n->emb_dim*sizeof(float)); ix->mean_dim=n->emb_dim; }
|
||
}
|
||
return 0;
|
||
}
|
||
if(strcmp(n->node_type,ENGRAM_GEO_NBHD_TYPE)!=0) return 0;
|
||
if(ix->n==ix->cap){ ix->cap=ix->cap?ix->cap*2:16;
|
||
RNbhd* t=realloc(ix->nb,(size_t)ix->cap*sizeof*t); if(!t) return -1; ix->nb=t; }
|
||
RNbhd* r=&ix->nb[ix->n]; memset(r,0,sizeof*r);
|
||
r->id=strdup(n->id?n->id:"");
|
||
if(geo_parse_nbhd(n->metadata,r)!=0){ free(r->id); return 0; } /* skip malformed */
|
||
if(n->emb && n->emb_dim>0){
|
||
r->dim=n->emb_dim;
|
||
r->centroid_raw=malloc((size_t)n->emb_dim*sizeof(float));
|
||
if(r->centroid_raw) memcpy(r->centroid_raw,n->emb,(size_t)n->emb_dim*sizeof(float));
|
||
}
|
||
ix->n++;
|
||
return 0;
|
||
}
|
||
|
||
int engram_geo_reify_index_finalize(GeoReifyIndex* ix){
|
||
if(!ix) return -1;
|
||
/* member → neighborhood hash */
|
||
size_t total=0; for(int i=0;i<ix->n;i++) total+=(size_t)ix->nb[i].n_members;
|
||
ix->nbuckets = total? (total*2+1) : 1;
|
||
ix->buckets=calloc(ix->nbuckets,sizeof(RE*));
|
||
if(!ix->buckets) return -1;
|
||
for(int i=0;i<ix->n;i++){
|
||
RNbhd* r=&ix->nb[i];
|
||
for(int m=0;m<r->n_members;m++){
|
||
size_t b=geo_djb2(r->member_ids[m])%ix->nbuckets;
|
||
RE* e=malloc(sizeof*e); if(!e) continue;
|
||
e->id=r->member_ids[m]; e->nbhd=i; e->w=r->member_w[m]; e->next=ix->buckets[b]; ix->buckets[b]=e;
|
||
}
|
||
/* centered, normalized centroid for the nearest-fallback */
|
||
if(r->centroid_raw && ix->mean && ix->mean_dim==r->dim){
|
||
r->centroid_unit=malloc((size_t)r->dim*sizeof(float));
|
||
if(r->centroid_unit){
|
||
double nrm=0; for(int d=0;d<r->dim;d++){ double v=(double)r->centroid_raw[d]-ix->mean[d]; r->centroid_unit[d]=(float)v; nrm+=v*v; }
|
||
nrm=sqrt(nrm);
|
||
if(nrm>1e-12){ for(int d=0;d<r->dim;d++) r->centroid_unit[d]=(float)(r->centroid_unit[d]/nrm); }
|
||
else { free(r->centroid_unit); r->centroid_unit=NULL; }
|
||
}
|
||
}
|
||
/* fill the borrowed view */
|
||
r->view.id=r->id; r->view.hub_id=r->hub_id; r->view.n_members=r->n_members;
|
||
r->view.member_ids=r->member_ids; r->view.member_w=r->member_w;
|
||
r->view.radius=r->radius; r->view.co_registration=r->co_reg;
|
||
r->view.k_core=r->k_core; r->view.n_embedded=r->n_embedded;
|
||
}
|
||
ix->score=ix->n?calloc((size_t)ix->n,sizeof(double)):NULL;
|
||
return 0;
|
||
}
|
||
|
||
static void geo__reify_load_cb(const StoreNode* n, void* ctx){
|
||
engram_geo_reify_index_add((GeoReifyIndex*)ctx, n);
|
||
}
|
||
GeoReifyIndex* engram_geo_reify_load(EngramPagedStore* store){
|
||
if(!store) return NULL;
|
||
GeoReifyIndex* ix=engram_geo_reify_index_new(); if(!ix) return NULL;
|
||
store_scan_nodes(store, geo__reify_load_cb, ix);
|
||
if(ix->n==0 && ix->mean==NULL){ engram_geo_reify_index_free(ix); return NULL; }
|
||
engram_geo_reify_index_finalize(ix);
|
||
return ix;
|
||
}
|
||
|
||
/* ── M10 reified-neighborhood READ-ONLY JSON serializers (added for the
|
||
* GET /api/neighborhoods viz surface). These surface the ALREADY-maintained
|
||
* resident reify index (loaded at boot); they compute nothing. Return a
|
||
* malloc'd JSON C-string the caller owns. ───────────────────────────────── */
|
||
static void geo_json_escape(FILE* f, const char* s){
|
||
if(!s) return;
|
||
for(const unsigned char* p=(const unsigned char*)s; *p; p++){
|
||
switch(*p){
|
||
case '"': fputs("\\\"",f); break;
|
||
case '\\': fputs("\\\\",f); break;
|
||
case '\n': fputs("\\n",f); break;
|
||
case '\r': fputs("\\r",f); break;
|
||
case '\t': fputs("\\t",f); break;
|
||
default: if(*p < 0x20) fprintf(f,"\\u%04x",(unsigned)*p); else fputc(*p,f);
|
||
}
|
||
}
|
||
}
|
||
|
||
char* engram_geo_reify_list_cstr(const GeoReifyIndex* ix){
|
||
char* buf=NULL; size_t sz=0;
|
||
FILE* f=open_memstream(&buf,&sz);
|
||
if(!f) return NULL;
|
||
fputc('[',f);
|
||
int n = ix ? ix->n : 0;
|
||
for(int i=0;i<n;i++){
|
||
const RNbhd* r=&ix->nb[i];
|
||
if(i) fputc(',',f);
|
||
fputs("{\"id\":\"",f); geo_json_escape(f, r->id?r->id:""); fputc('"',f);
|
||
fputs(",\"hub_id\":\"",f); geo_json_escape(f, r->hub_id?r->hub_id:""); fputc('"',f);
|
||
fprintf(f,",\"n_members\":%d,\"k_core\":%d,\"n_embedded\":%d,\"radius\":%.6g,\"co_registration\":%.6g,\"dim\":%d,\"level\":%d,\"n_contains\":%d}",
|
||
r->n_members, r->k_core, r->n_embedded, r->radius, r->co_reg, r->dim, r->level, r->n_contains);
|
||
}
|
||
fputc(']',f);
|
||
fclose(f);
|
||
return buf;
|
||
}
|
||
|
||
char* engram_geo_reify_get_cstr(const GeoReifyIndex* ix, const char* id){
|
||
if(!ix || !id) return NULL;
|
||
const RNbhd* r=NULL;
|
||
for(int i=0;i<ix->n;i++){ if(ix->nb[i].id && strcmp(ix->nb[i].id,id)==0){ r=&ix->nb[i]; break; } }
|
||
if(!r) return NULL;
|
||
char* buf=NULL; size_t sz=0;
|
||
FILE* f=open_memstream(&buf,&sz);
|
||
if(!f) return NULL;
|
||
fputs("{\"id\":\"",f); geo_json_escape(f,r->id?r->id:""); fputc('"',f);
|
||
fputs(",\"hub_id\":\"",f); geo_json_escape(f,r->hub_id?r->hub_id:""); fputc('"',f);
|
||
fprintf(f,",\"n_members\":%d,\"k_core\":%d,\"n_embedded\":%d,\"radius\":%.6g,\"co_registration\":%.6g,\"dim\":%d,\"level\":%d",
|
||
r->n_members,r->k_core,r->n_embedded,r->radius,r->co_reg,r->dim,r->level);
|
||
fputs(",\"contains\":[",f);
|
||
for(int j=0;j<r->n_contains;j++){ if(j) fputc(',',f);
|
||
fputc('"',f); geo_json_escape(f, r->contains[j]); fputc('"',f); }
|
||
fputs("]",f);
|
||
fputs(",\"centroid\":[",f);
|
||
if(r->centroid_raw && r->dim>0){
|
||
for(int j=0;j<r->dim;j++){ if(j) fputc(',',f); fprintf(f,"%.6g",(double)r->centroid_raw[j]); }
|
||
}
|
||
fputs("],\"members\":[",f);
|
||
for(int j=0;j<r->n_members;j++){
|
||
if(j) fputc(',',f);
|
||
fputs("{\"id\":\"",f); geo_json_escape(f, r->member_ids ? r->member_ids[j] : ""); fputs("\",\"membership\":",f);
|
||
fprintf(f,"%.6g}", r->member_w ? r->member_w[j] : 0.0);
|
||
}
|
||
fputs("]}",f);
|
||
fclose(f);
|
||
return buf;
|
||
}
|
||
|
||
const GeoNeighborhood* engram_geo_reify_lookup(
|
||
const GeoReifyIndex* ix,
|
||
const char* const* seed_ids, size_t n_seeds,
|
||
const float* q_emb, int q_dim){
|
||
if(!ix||ix->n<=0) return NULL;
|
||
/* (a) membership route: score each neighborhood by summed seed membership. */
|
||
if(ix->score && ix->buckets && seed_ids && n_seeds>0){
|
||
for(int i=0;i<ix->n;i++) ((GeoReifyIndex*)ix)->score[i]=0.0;
|
||
int any=0;
|
||
for(size_t s=0;s<n_seeds;s++){
|
||
const char* id=seed_ids[s]; if(!id) continue;
|
||
for(RE* e=ix->buckets[geo_djb2(id)%ix->nbuckets]; e; e=e->next)
|
||
if(strcmp(e->id,id)==0){ ((GeoReifyIndex*)ix)->score[e->nbhd]+=e->w; any=1; }
|
||
}
|
||
if(any){
|
||
int best=-1; double bv=-1;
|
||
for(int i=0;i<ix->n;i++) if(ix->score[i]>bv){ bv=ix->score[i]; best=i; }
|
||
if(best>=0 && bv>0) return &ix->nb[best].view;
|
||
}
|
||
}
|
||
/* (b) centroid-nearest fallback (centered query vs centered centroids). */
|
||
if(q_emb && q_dim>0 && ix->mean && ix->mean_dim==q_dim){
|
||
double nq=0; float* cq=malloc((size_t)q_dim*sizeof(float));
|
||
if(!cq) return NULL;
|
||
for(int d=0;d<q_dim;d++){ double v=(double)q_emb[d]-ix->mean[d]; cq[d]=(float)v; nq+=v*v; }
|
||
nq=sqrt(nq);
|
||
if(nq>1e-12){
|
||
int best=-1; double bc=-1e9;
|
||
for(int i=0;i<ix->n;i++){ RNbhd* r=&ix->nb[i]; if(!r->centroid_unit) continue;
|
||
double s=0; for(int d=0;d<q_dim;d++) s+=(double)cq[d]*r->centroid_unit[d];
|
||
s/=nq; if(s>bc){ bc=s; best=i; } }
|
||
free(cq);
|
||
if(best>=0) return &ix->nb[best].view;
|
||
} else free(cq);
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
int engram_geo_reify_count(const GeoReifyIndex* ix){ return ix?ix->n:0; }
|
||
const float* engram_geo_reify_mean(const GeoReifyIndex* ix, int* dim){
|
||
if(!ix||!ix->mean){ if(dim)*dim=0; return NULL; }
|
||
if(dim)*dim=ix->mean_dim; return ix->mean;
|
||
}
|
||
|
||
void engram_geo_reify_index_free(GeoReifyIndex* ix){
|
||
if(!ix) return;
|
||
if(ix->buckets){
|
||
for(size_t b=0;b<ix->nbuckets;b++){ RE* e=ix->buckets[b]; while(e){ RE* x=e->next; free(e); e=x; } }
|
||
free(ix->buckets);
|
||
}
|
||
for(int i=0;i<ix->n;i++){ RNbhd* r=&ix->nb[i];
|
||
free(r->id); free(r->hub_id);
|
||
for(int m=0;m<r->n_members;m++) free(r->member_ids[m]);
|
||
free(r->member_ids); free(r->member_w);
|
||
for(int c=0;c<r->n_contains;c++) free(r->contains[c]);
|
||
free(r->contains);
|
||
free(r->centroid_raw); free(r->centroid_unit);
|
||
}
|
||
free(ix->nb); free(ix->score); free(ix->mean);
|
||
free(ix);
|
||
}
|
||
|
||
/* ═══════════════ M10 NESTING — one-level containment DAG ════════════════════
|
||
* Agglomerate the persisted flat neighborhoods by centroid cosine into groups
|
||
* and persist one PARENT "super" Neighborhood node per group of >= 2. The parent
|
||
* is an ordinary Neighborhood node (id "nbhd-super-…", content "reified-super-
|
||
* neighborhood", metadata GEO1 with `level 1` + `c <child_id>` lines, emb = mean
|
||
* of child centroids); "contains" edges join parent→child and "nested-in" join
|
||
* child→parent. Boot loads it into _eg_reify like any neighborhood and skips its
|
||
* edges from activation adjacency (id begins "nbhd-"). Read-then-write. ──────── */
|
||
static double geo_unit_cos(const float* a, const float* b, int dim){
|
||
if(!a||!b||dim<=0) return -2.0;
|
||
double s=0; for(int i=0;i<dim;i++) s+=(double)a[i]*b[i];
|
||
return s;
|
||
}
|
||
int engram_geo_reify_nest(EngramPagedStore* store, double min_cos){
|
||
if(!store) return -1;
|
||
if(min_cos<=0) min_cos=0.30;
|
||
GeoReifyIndex* ix=engram_geo_reify_load(store);
|
||
if(!ix) return 0; /* nothing reified yet */
|
||
int64_t now=geo_now_ms();
|
||
size_t sp_len=strlen(ENGRAM_GEO_SUPER_ID_PREFIX);
|
||
|
||
/* 1. tombstone prior super records (idempotent re-nest). */
|
||
for(int i=0;i<ix->n;i++){ RNbhd* r=&ix->nb[i];
|
||
if(r->level>=1 || (r->id && strncmp(r->id,ENGRAM_GEO_SUPER_ID_PREFIX,sp_len)==0))
|
||
store_tombstone(store, r->id);
|
||
}
|
||
/* 2. gather flat (level 0) neighborhoods with a unit centroid. */
|
||
int* idx=malloc((size_t)(ix->n>0?ix->n:1)*sizeof(int)); int F=0;
|
||
if(!idx){ engram_geo_reify_index_free(ix); return -2; }
|
||
for(int i=0;i<ix->n;i++){ RNbhd* r=&ix->nb[i];
|
||
if(r->level>=1) continue;
|
||
if(r->id && strncmp(r->id,ENGRAM_GEO_SUPER_ID_PREFIX,sp_len)==0) continue;
|
||
if(!r->centroid_unit || r->dim<=0) continue;
|
||
idx[F++]=i;
|
||
}
|
||
/* 3. greedy agglomerative grouping by centroid cosine. */
|
||
char* used=calloc((size_t)(F>0?F:1),1);
|
||
int parents=0;
|
||
for(int a=0;a<F;a++){
|
||
if(used[a]) continue;
|
||
RNbhd* ra=&ix->nb[idx[a]]; int dim=ra->dim;
|
||
int* grp=malloc((size_t)F*sizeof(int)); int gN=0;
|
||
grp[gN++]=a; used[a]=1;
|
||
for(int b=a+1;b<F;b++){ if(used[b]) continue;
|
||
RNbhd* rb=&ix->nb[idx[b]]; if(rb->dim!=dim) continue;
|
||
if(geo_unit_cos(ra->centroid_unit,rb->centroid_unit,dim)>=min_cos){ grp[gN++]=b; used[b]=1; } }
|
||
if(gN>=2){
|
||
float* pc=calloc((size_t)dim,sizeof(float)); int nraw=0;
|
||
for(int gi=0;gi<gN;gi++){ RNbhd* rc=&ix->nb[idx[grp[gi]]];
|
||
if(rc->centroid_raw){ for(int d=0;d<dim;d++) pc[d]+=rc->centroid_raw[d]; nraw++; } }
|
||
if(nraw>0) for(int d=0;d<dim;d++) pc[d]/=(float)nraw;
|
||
SB mb; memset(&mb,0,sizeof mb); char line[700];
|
||
sb_puts(&mb,"GEO1\nlevel 1\n");
|
||
snprintf(line,sizeof line,"hub %s\n", ra->hub_id?ra->hub_id:(ra->id?ra->id:"")); sb_puts(&mb,line);
|
||
int summ=0,maxk=0; double sumr=0;
|
||
for(int gi=0;gi<gN;gi++){ RNbhd* rc=&ix->nb[idx[grp[gi]]];
|
||
summ+=rc->n_members; if(rc->k_core>maxk) maxk=rc->k_core; sumr+=rc->radius; }
|
||
snprintf(line,sizeof line,"s %.6g 0 %d 0 %d %d\n", sumr/(double)gN, maxk, gN, summ); sb_puts(&mb,line);
|
||
for(int gi=0;gi<gN;gi++){ RNbhd* rc=&ix->nb[idx[grp[gi]]];
|
||
snprintf(line,sizeof line,"c %s\n", rc->id?rc->id:""); sb_puts(&mb,line); }
|
||
char pid[600];
|
||
snprintf(pid,sizeof pid,"%s%s-%lld",ENGRAM_GEO_SUPER_ID_PREFIX,
|
||
ra->hub_id?ra->hub_id:(ra->id?ra->id:"x"),(long long)now);
|
||
StoreNode pn; memset(&pn,0,sizeof pn);
|
||
pn.id=pid; pn.node_type=(char*)ENGRAM_GEO_NBHD_TYPE;
|
||
pn.content=(char*)ENGRAM_GEO_SUPER_CONTENT; pn.tier=(char*)"Semantic";
|
||
pn.metadata=mb.s?mb.s:(char*)"GEO1\nlevel 1\n";
|
||
pn.emb=pc; pn.emb_dim=dim; pn.created_at=now; pn.updated_at=now;
|
||
if(store_put_node(store,&pn)>=0){
|
||
for(int gi=0;gi<gN;gi++){ RNbhd* rc=&ix->nb[idx[grp[gi]]]; char eid[700];
|
||
StoreEdge ce; memset(&ce,0,sizeof ce);
|
||
snprintf(eid,sizeof eid,"%s->%s",pid,rc->id?rc->id:"");
|
||
ce.id=eid; ce.from_id=pid; ce.to_id=rc->id?rc->id:(char*)"";
|
||
ce.relation=(char*)ENGRAM_GEO_CONTAINS_RELATION; ce.metadata=(char*)"{}";
|
||
ce.weight=1.0; ce.confidence=1.0; ce.created_at=now; ce.updated_at=now;
|
||
store_put_edge(store,&ce);
|
||
StoreEdge ne; memset(&ne,0,sizeof ne); char nid2[700];
|
||
snprintf(nid2,sizeof nid2,"%s->%s",rc->id?rc->id:"",pid);
|
||
ne.id=nid2; ne.from_id=rc->id?rc->id:(char*)""; ne.to_id=pid;
|
||
ne.relation=(char*)ENGRAM_GEO_NESTED_RELATION; ne.metadata=(char*)"{}";
|
||
ne.weight=1.0; ne.confidence=1.0; ne.created_at=now; ne.updated_at=now;
|
||
store_put_edge(store,&ne);
|
||
}
|
||
parents++;
|
||
}
|
||
free(pc); free(mb.s);
|
||
}
|
||
free(grp);
|
||
}
|
||
free(used); free(idx);
|
||
engram_geo_reify_index_free(ix);
|
||
return parents;
|
||
}
|