feat(engram): semantically seed the graph leg (Will's HippoRAG pass, SEED_K=8)
engram_assoc_leg previously took its seeds only from the top-3 LEXICAL hits. For a paraphrase query the lexical hits are noise by construction, so the walk never reached the neighbourhood that holds the answer. This adds the seeding pass Will documents at el_runtime.c l.6082 — "Semantic seeding (HippoRAG pattern, use similarity twice): the query is embedded, the top-K nodes by cosine join the seed set" — using his own ENGRAM_EMBED_SEED_K (8). Similarity is now used twice, coherently: cosine picks where to STAND in the graph, the structural-relation walk decides what is REACHABLE, and cosine orders what was reached (iteration 2's finding, unchanged). The seed list is deliberately NOT floored at ENGRAM_EMBED_SEED_MIN. Measured over all 38 gold queries: true paraphrase targets score cosine 0.46-0.66 and the three nonsense controls' own nearest neighbours score 0.55/0.60/0.62 — the distributions OVERLAP, so no absolute cosine floor separates signal from gibberish. The gate that works is reachability: gibberish's nearest neighbours carry no structural edge, so its graph leg is empty and the controls hold. The raw top-K is selected inside the existing scoring pass, so the cosine is computed exactly once per node: no extra corpus pass, no extra embed round-trip, latency flat (p50 1220 -> 1227 ms, 1.01x). Measured vs the certified baseline feat/hybrid-semantic-recall, embedded corpus, 2 runs each, zero run-to-run drift on both sides: hit@5 51.4% -> 68.6% MRR@10 0.387 -> 0.461 paraphrase 38.5% -> 61.5% associative 0% -> 66.7% exact_rare 100% held, nonsense 2/3 held, superseded 2/3 held phrase 85.7% -> 71.4% (q11, the known rank-5 rotation tax) net +6 queries (7 fixed / 1 broken), McNemar p=0.0703
This commit is contained in:
+62
-4
@@ -7531,8 +7531,30 @@ static int eg_assoc_excluded(const EngramNode* n) {
|
||||
* function of hop count and ranks the relay hub above all of its own
|
||||
* children. Composing the two is mine, not Will's — the description ranks the
|
||||
* activation result set by strength (l.78). */
|
||||
/* Semantic seeding of the graph leg — the HippoRAG pass Will documents at
|
||||
* l.6082: "the query is embedded, the top-K nodes by cosine >= SEED_MIN join
|
||||
* the seed set", using his own ENGRAM_EMBED_SEED_K (8). It is "similarity used
|
||||
* twice, coherently": cosine picks where to STAND in the graph, the structural
|
||||
* walk decides what is REACHABLE from there, and cosine then ORDERS what was
|
||||
* reached (iteration-2's finding, kept intact).
|
||||
*
|
||||
* Why the seed list is NOT floored at ENGRAM_EMBED_SEED_MIN here. That
|
||||
* constant is calibrated for a cosine scale this corpus does not have: with
|
||||
* nomic-embed-text every true paraphrase target measures 0.46-0.66, and the
|
||||
* three nonsense controls' own nearest neighbours measure 0.55/0.60/0.62 —
|
||||
* they OVERLAP, so no absolute cosine floor separates signal from gibberish
|
||||
* (measured, all 38 queries). Iteration 2 established the same thing one step
|
||||
* later in the pipeline: applying the floor to graph CANDIDATES removed every
|
||||
* gain, because within a structurally-reached neighbourhood relative cosine
|
||||
* still discriminates below the absolute threshold. The gate that actually
|
||||
* works is reachability — eg_rel_is_structural() plus the firing threshold.
|
||||
* A semantically-near node with no structural attachment expands to nothing
|
||||
* and contributes nothing, which is exactly what happens to gibberish: the
|
||||
* nearest neighbours of q33/q34 are unattached, so their graph leg is empty.
|
||||
*/
|
||||
static int64_t engram_assoc_leg(EngramStore* g,
|
||||
const EngramRankEntry* L, int64_t nL,
|
||||
const int64_t* semseed, int64_t nsemseed,
|
||||
const float* qv, int32_t qdim,
|
||||
EngramSemEntry* out, int64_t out_cap) {
|
||||
if (!g || nL <= 0 || !qv || qdim <= 0 || out_cap <= 0) return 0;
|
||||
@@ -7553,6 +7575,14 @@ static int64_t engram_assoc_leg(EngramStore* g,
|
||||
act[idx] = 1.0; seen[idx] = 2; /* 2 = seed: never a result */
|
||||
if (qt < qcap) { q[qt] = idx; hop[qt] = 0; qt++; }
|
||||
}
|
||||
/* ...and the semantic seeds, on the same footing (strength 1.0, hop 0). */
|
||||
for (int64_t s = 0; s < nsemseed; s++) {
|
||||
int64_t idx = semseed[s];
|
||||
if (idx < 0 || idx >= g->node_count) continue;
|
||||
if (seen[idx]) continue;
|
||||
act[idx] = 1.0; seen[idx] = 2;
|
||||
if (qt < qcap) { q[qt] = idx; hop[qt] = 0; qt++; }
|
||||
}
|
||||
|
||||
const double SPREAD_DECAY = 0.7;
|
||||
while (qh < qt) {
|
||||
@@ -9524,6 +9554,13 @@ el_val_t engram_search_json(el_val_t query, el_val_t limit) {
|
||||
EngramSemEntry* sem = qv ? malloc((size_t)g->node_count * sizeof(EngramSemEntry)) : NULL;
|
||||
int64_t nsem = 0;
|
||||
int64_t nhits = 0;
|
||||
/* Raw (unfloored) cosine top-K, kept for the graph leg's
|
||||
* semantic seeds. Selected in THIS pass so the cosine is
|
||||
* computed exactly once per node — the seeding costs no extra
|
||||
* pass over the corpus and no extra embed round-trip. */
|
||||
int64_t semseed[ENGRAM_EMBED_SEED_K];
|
||||
double semseedc[ENGRAM_EMBED_SEED_K];
|
||||
int64_t nsemseed = 0;
|
||||
for (int64_t i = 0; i < g->node_count; i++) {
|
||||
EngramNode* n = &g->nodes[i];
|
||||
/* Filter transparent layers — same as engram_search. */
|
||||
@@ -9535,9 +9572,29 @@ el_val_t engram_search_json(el_val_t query, el_val_t limit) {
|
||||
hits[nhits].salience = n->salience;
|
||||
nhits++;
|
||||
}
|
||||
if (sem) {
|
||||
double sv = eg_sem_term(n, qv, qdim);
|
||||
if (sv > 0.0) { sem[nsem].idx = i; sem[nsem].sem = sv; nsem++; }
|
||||
if (sem && n->emb && n->emb_dim == qdim) {
|
||||
double c = eg_cosine(n->emb, qv, qdim);
|
||||
/* Semantic leg: identical to eg_sem_term(), which is
|
||||
* left in place and still used by engram_search().
|
||||
* Inlined here only so one cosine serves both uses. */
|
||||
if (c > ENGRAM_EMBED_SEED_MIN) {
|
||||
double sv = (c - ENGRAM_EMBED_SEED_MIN) / (1.0 - ENGRAM_EMBED_SEED_MIN);
|
||||
if (sv > 1.0) sv = 1.0;
|
||||
sem[nsem].idx = i; sem[nsem].sem = sv; nsem++;
|
||||
}
|
||||
/* Graph seeds: top-K by RAW cosine, insertion-ordered. */
|
||||
if (c > 0.0 && (nsemseed < ENGRAM_EMBED_SEED_K
|
||||
|| c > semseedc[nsemseed - 1])) {
|
||||
int64_t p = nsemseed < ENGRAM_EMBED_SEED_K
|
||||
? nsemseed : ENGRAM_EMBED_SEED_K - 1;
|
||||
while (p > 0 && semseedc[p - 1] < c) {
|
||||
semseedc[p] = semseedc[p - 1];
|
||||
semseed[p] = semseed[p - 1];
|
||||
p--;
|
||||
}
|
||||
semseedc[p] = c; semseed[p] = i;
|
||||
if (nsemseed < ENGRAM_EMBED_SEED_K) nsemseed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
qsort(hits, (size_t)nhits, sizeof(EngramRankEntry), engram_rank_cmp);
|
||||
@@ -9549,7 +9606,8 @@ el_val_t engram_search_json(el_val_t query, el_val_t limit) {
|
||||
* lexical ordering safe. */
|
||||
EngramSemEntry* assoc = qv ? malloc((size_t)ENGRAM_ASSOC_MAX * sizeof(EngramSemEntry)) : NULL;
|
||||
int64_t nassoc = assoc
|
||||
? engram_assoc_leg(g, hits, nhits, qv, qdim, assoc, ENGRAM_ASSOC_MAX)
|
||||
? engram_assoc_leg(g, hits, nhits, semseed, nsemseed,
|
||||
qv, qdim, assoc, ENGRAM_ASSOC_MAX)
|
||||
: 0;
|
||||
int64_t* order = malloc((size_t)lim * sizeof(int64_t));
|
||||
if (order) {
|
||||
|
||||
Reference in New Issue
Block a user