agent-working-memory 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +207 -46
  2. package/dist/api/routes.js +1 -1
  3. package/dist/cli/migrate.js +29 -29
  4. package/dist/cli.js +1 -1
  5. package/dist/coordination/circuit-breaker.js +23 -23
  6. package/dist/core/write-pipeline.d.ts.map +1 -1
  7. package/dist/core/write-pipeline.js +17 -0
  8. package/dist/core/write-pipeline.js.map +1 -1
  9. package/dist/engine/activation.d.ts +28 -0
  10. package/dist/engine/activation.d.ts.map +1 -1
  11. package/dist/engine/activation.js +341 -11
  12. package/dist/engine/activation.js.map +1 -1
  13. package/dist/engine/connections.d.ts +12 -0
  14. package/dist/engine/connections.d.ts.map +1 -1
  15. package/dist/engine/connections.js +95 -0
  16. package/dist/engine/connections.js.map +1 -1
  17. package/dist/mcp.js +2 -2
  18. package/dist/storage/pglite-schema.js +143 -143
  19. package/dist/storage/pglite.js +138 -138
  20. package/dist/types/engram.d.ts +1 -0
  21. package/dist/types/engram.d.ts.map +1 -1
  22. package/package.json +1 -1
  23. package/src/api/index.ts +3 -3
  24. package/src/api/routes.ts +1 -1
  25. package/src/cli/migrate.ts +307 -307
  26. package/src/cli.ts +1 -1
  27. package/src/coordination/circuit-breaker.ts +83 -83
  28. package/src/coordination/failure-modes.ts +50 -50
  29. package/src/core/decay.ts +63 -63
  30. package/src/core/embeddings.ts +110 -110
  31. package/src/core/index.ts +5 -5
  32. package/src/core/logger.ts +36 -36
  33. package/src/core/ml-worker-entry.ts +194 -194
  34. package/src/core/ml-worker.ts +281 -281
  35. package/src/core/query-expander.ts +122 -122
  36. package/src/core/reranker.ts +119 -119
  37. package/src/core/write-pipeline.ts +15 -0
  38. package/src/engine/activation.ts +328 -11
  39. package/src/engine/confidence.ts +120 -120
  40. package/src/engine/connections.ts +94 -0
  41. package/src/engine/consolidation-scheduler.ts +242 -242
  42. package/src/engine/eval.ts +102 -102
  43. package/src/engine/eviction.ts +101 -101
  44. package/src/engine/index.ts +8 -8
  45. package/src/engine/retraction.ts +366 -366
  46. package/src/engine/staging.ts +74 -74
  47. package/src/mcp.ts +2 -2
  48. package/src/storage/factory.ts +147 -147
  49. package/src/storage/index.ts +3 -3
  50. package/src/storage/pglite-schema.ts +166 -166
  51. package/src/storage/pglite.ts +1363 -1363
  52. package/src/storage/store.ts +80 -80
  53. package/src/types/agent.ts +67 -67
  54. package/src/types/checkpoint.ts +46 -46
  55. package/src/types/engram.ts +1 -0
  56. package/src/types/eval.ts +100 -100
  57. package/src/types/index.ts +6 -6
@@ -180,7 +180,12 @@ export class ActivationEngine {
180
180
  const limit = query.limit ?? 10;
181
181
  const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
182
182
  const useReranker = query.useReranker ?? true;
183
- const useExpansion = query.useExpansion ?? true;
183
+ // Default OFF (rerank-only): query expansion ~doubles recall latency (it inflates the rerank
184
+ // candidate pool) for no measured accuracy gain — validated no-regression on LoCoMo
185
+ // (overall 22.8→22.7, adversarial 73.5→73.4), the 4-suite eval (identical), and the MWA
186
+ // gauntlet. Callers can still opt in per-query (useExpansion:true); AWM_DEFAULT_EXPANSION=1
187
+ // restores expansion-by-default globally as an escape hatch.
188
+ const useExpansion = query.useExpansion ?? process.env.AWM_DEFAULT_EXPANSION === '1';
184
189
  const abstentionThreshold = query.abstentionThreshold ?? 0;
185
190
  const requireConfidence = query.requireConfidence ?? 0;
186
191
  const adaptive = resolveAdaptiveParams(query);
@@ -333,6 +338,54 @@ export class ActivationEngine {
333
338
  candidates = candidates.filter(e => e.memoryType === query.memoryType);
334
339
  }
335
340
 
341
+ // ── ENTITY-AWARE CANDIDATE FETCH (2026-06, fixes the buried 2-hop / sparse-cue gap) ──
342
+ // A query like "codename for my main project" strongly recalls "main project = Atlas" but the
343
+ // ANSWER ("Atlas codename = Magpie") is a different-vocabulary attribute fact that falls out
344
+ // of the candidate pool — and rerank can't rescue what isn't in the pool. AWM doesn't form
345
+ // entity-co-occurrence edges, so graph-walk can't bridge it either. Fix: from the strongest
346
+ // seeds, pull the proper-noun ENTITIES that aren't already in the query, run ONE cheap local
347
+ // BM25 pass on them, and add the hits to the candidate pool. RECALL-ONLY: these only become
348
+ // candidates the reranker can consider — the final top-K stays rerank-gated, so this never
349
+ // surfaces facts you don't need (preserves AWM's precision-first design). Fast (one BM25
350
+ // call), local (no network/LLM), capped. DEFAULT-OFF (opt-in AWM_ENTITY_FETCH=1): verified
351
+ // recall-only pool-injection is INSUFFICIENT at scale — the buried fact enters the pool but
352
+ // still ranks below distractors against the vocab-mismatched original query, and a ranking
353
+ // boost would trade precision (against AWM's precision-first design). The design-aligned fix
354
+ // is HARNESS-side multi-hop decomposition (LLM chains sequential single-hop recalls). Kept
355
+ // opt-in for future spreading-activation experiments.
356
+ if (process.env.AWM_ENTITY_FETCH === '1' && candidates.length > 0) {
357
+ const ENT_SEEDS = Number(process.env.AWM_ENTITY_FETCH_SEEDS ?? 5);
358
+ const ENT_CAP = Number(process.env.AWM_ENTITY_FETCH_CAP ?? 30);
359
+ const STOPCAPS = new Set(['my', 'the', 'a', 'an', 'i', 'we', 'you', 'he', 'she', 'it', 'they', 'this', 'that', 'what', 'when', 'where', 'who', 'why', 'how', 'is', 'are', 'was', 'were', 'do', 'does', 'project', 'account', 'codename', 'name', 'team', 'main', 'internal']);
360
+ const seeds = candidates
361
+ .map(e => ({ e, s: Math.max(bm25ScoreMap.get(e.id) ?? 0, rawCosineSims.get(e.id) ?? 0) }))
362
+ .sort((a, b) => b.s - a.s).slice(0, ENT_SEEDS);
363
+ const ents = new Set<string>();
364
+ for (const { e } of seeds) {
365
+ for (const match of `${e.concept} ${e.content}`.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
366
+ const w = match[0]; const lw = w.toLowerCase();
367
+ if (!queryTokens.has(lw) && !STOPCAPS.has(lw)) ents.add(w);
368
+ }
369
+ }
370
+ if (ents.size > 0) {
371
+ try {
372
+ const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
373
+ let added = 0;
374
+ for (const h of entHits) {
375
+ if (added >= ENT_CAP) break;
376
+ const n = h.engram;
377
+ if (candidateMap.has(n.id)) continue;
378
+ if (n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
379
+ if (query.memoryType && n.memoryType !== query.memoryType) continue;
380
+ candidateMap.set(n.id, n);
381
+ bm25ScoreMap.set(n.id, Math.max(bm25ScoreMap.get(n.id) ?? 0, h.bm25Score)); // scoring sees it
382
+ added++;
383
+ }
384
+ if (added > 0) candidates = Array.from(candidateMap.values());
385
+ } catch { /* best-effort: entity fetch never breaks recall */ }
386
+ }
387
+ }
388
+
336
389
  if (candidates.length === 0) return [];
337
390
 
338
391
  // Phase 3b: Score each candidate with per-phase breakdown
@@ -378,7 +431,9 @@ export class ActivationEngine {
378
431
  let vectorMatch = 0;
379
432
  const rawSim = rawCosineSims.get(engram.id);
380
433
  if (rawSim !== undefined && rawSim > 0) {
381
- const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
434
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5
435
+ ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
436
+ : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
382
437
  if (rawSim > SIM_FLOOR) {
383
438
  // Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
384
439
  vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
@@ -479,7 +534,9 @@ export class ActivationEngine {
479
534
  let vm = 0;
480
535
  const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
481
536
  if (rs > 0) {
482
- const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
537
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5
538
+ ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
539
+ : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
483
540
  if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
484
541
  }
485
542
  const tm = km > 0 && vm > 0
@@ -532,6 +589,10 @@ export class ActivationEngine {
532
589
  // Skip non-entity tags: turn IDs, session tags, dialogue IDs, generic speaker labels
533
590
  if (/^t\d+$/.test(t) || t.startsWith('session-') || t.startsWith('dia_') || t.length < 3) continue;
534
591
  if (/^speaker\d*$/.test(t)) continue; // Generic speaker labels are too broad
592
+ // Auto-tagger `cat:` category tags are too broad to bridge on (they'd link
593
+ // every "cat:work" memory laterally); they stay for BM25 recall only. The
594
+ // precise `entity:` proper-noun tags are kept as bridges.
595
+ if (t.startsWith('cat:')) continue;
535
596
  entityTags.add(t);
536
597
  }
537
598
  }
@@ -595,11 +656,74 @@ export class ActivationEngine {
595
656
  }
596
657
  }
597
658
 
659
+ // Phase 3.75: Query-conditioned entity bridge (default-OFF, AWM_QUERY_BRIDGE=1).
660
+ //
661
+ // The anchor-based bridge above (Phase 3.7) is query-BLIND: it bridges from the
662
+ // top text-match result's tags and a document-frequency filter DELETES common
663
+ // tags (e.g. a speaker present in >30% of turns). That is exactly backwards for
664
+ // attribution / entity-named queries: if the user asks "what does Caroline think
665
+ // about the trip" or "who said the trip moved to Saturday", the speaker/entity the
666
+ // query NAMES is the single most valuable bridge — its corpus frequency is
667
+ // irrelevant. This phase extracts proper-noun entities from the QUERY and boosts
668
+ // candidates whose tags match them, regardless of frequency, gated by topical
669
+ // relevance (textMatch floor) so it surfaces "Caroline's turns ABOUT the trip"
670
+ // rather than every Caroline turn. Boost folds into composite → survives rerank.
671
+ // Recall-only re-ranking of in-pool candidates (no injection) → low precision risk.
672
+ if (process.env.AWM_QUERY_BRIDGE === '1') {
673
+ const QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose', 'whom',
674
+ 'the', 'this', 'that', 'these', 'those', 'and', 'but', 'for', 'did', 'does', 'is', 'are',
675
+ 'was', 'were', 'how', 'tell', 'about', 'they', 'them']);
676
+ const qEnts = new Set<string>();
677
+ for (const m of query.context.matchAll(/\b[A-Z][a-zA-Z]{2,}\b/g)) {
678
+ const w = m[0].toLowerCase();
679
+ if (!QSTOP.has(w)) qEnts.add(w);
680
+ }
681
+ if (qEnts.size > 0) {
682
+ const QC_WEIGHT = Number(process.env.AWM_QUERY_BRIDGE_WEIGHT ?? 0.4);
683
+ const QC_CAP = Number(process.env.AWM_QUERY_BRIDGE_CAP ?? 0.4);
684
+ const QC_FLOOR = Number(process.env.AWM_QUERY_BRIDGE_FLOOR ?? 0.1);
685
+ for (const item of scored) {
686
+ if (item.phaseScores.textMatch < QC_FLOOR) continue; // only re-rank topically-relevant candidates
687
+ let matches = 0;
688
+ for (const tag of item.engram.tags) {
689
+ const t = tag.toLowerCase();
690
+ const val = t.startsWith('entity:') ? t.slice(7) : t;
691
+ // match whole-tag or any word of a multi-word entity ("marcus lee" ← "Marcus")
692
+ if (qEnts.has(val) || val.split(/\s+/).some(w => qEnts.has(w))) { matches++; }
693
+ }
694
+ if (matches > 0) {
695
+ // Relevance-modulated: scale by the candidate's topical relevance so
696
+ // "named-entity AND on-topic" wins big while "named-entity but off-topic
697
+ // chatter" (a common speaker tag on an irrelevant turn) gets almost
698
+ // nothing. Without this, a broad speaker tag floods the top with the
699
+ // person's unrelated turns (verified 2026-06-16 _query-bridge-verify).
700
+ const boost = Math.min(matches * QC_WEIGHT * item.phaseScores.textMatch, QC_CAP);
701
+ item.score += boost;
702
+ item.phaseScores.composite += boost;
703
+ item.phaseScores.graphBoost += boost;
704
+ }
705
+ }
706
+ }
707
+ }
708
+
598
709
  // Phase 4+5: Graph walk — boost engrams connected to high-scoring ones
599
710
  // Only walk from engrams that had text relevance (composite > 0 pre-walk)
600
711
  const sorted = scored.sort((a, b) => b.score - a.score);
601
- const topN = sorted.slice(0, limit * 3);
602
- await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
712
+ // Candidate breadth carried into graph-walk + rerank. Default 8×limit (was 3×).
713
+ // WHY 8× (2026-06-16): the pipeline-attribution trace showed ~50% of answerable LoCoMo
714
+ // queries had gold that CLEARED the floor (89%) but was squeezed out HERE by the
715
+ // decay-compressed composite before the (high-lift, +3.29) reranker saw it — the
716
+ // dominant loss. Widening this + the rerank pool (below) lifted official LoCoMo
717
+ // 22.7→25.1 (every recall category up), 4-suite unchanged, recall 35→77ms; small
718
+ // adversarial cost 73.4→71.0 (a fixed step, recoverable on the abstention gate).
719
+ // Tunable via AWM_TOPN_MULT.
720
+ const topNMult = Number(process.env.AWM_TOPN_MULT ?? 8);
721
+ const topN = sorted.slice(0, limit * topNMult);
722
+ if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
723
+ await this.spreadActivation(topN);
724
+ } else {
725
+ await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
726
+ }
603
727
 
604
728
  // Phase 6: Initial filter and sort for re-ranking pool
605
729
  const pool = topN
@@ -608,8 +732,15 @@ export class ActivationEngine {
608
732
 
609
733
  // Phase 7: Cross-encoder re-ranking — scores (query, passage) pairs directly
610
734
  // Widens the pool to find relevant results that keyword matching missed.
611
- // 0.7.13: max(limit*2, 15) halved the cross-encoder cost (was max(limit*3, 30))
612
- const rerankPool = pool.slice(0, Math.max(limit * 2, 15));
735
+ // How many candidates reach the cross-encoder. Default max(limit*4, 40) — widened
736
+ // from max(limit*2, 15) on 2026-06-16. WHY: the reranker rarely loses gold (0.5%) and
737
+ // lifts it +3.29, but the weak composite was only passing it ~35% of retrievable gold;
738
+ // feeding it more recovered the dominant lost@pool/scoring bucket. Validated knee on
739
+ // recall × precision × latency (pool 40 ≈ 25.1% LoCoMo / 71.0% adv / 77ms; pool 60 adds
740
+ // only +0.6pp for +33ms). The composite is now a CHEAP WIDE PRE-FILTER, not the ranker —
741
+ // the reranker does discrimination on a wide pool. Tunable via AWM_RERANK_POOL.
742
+ const rerankPoolSize = Number(process.env.AWM_RERANK_POOL ?? Math.max(limit * 4, 40));
743
+ const rerankPool = pool.slice(0, rerankPoolSize);
613
744
 
614
745
  // Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
615
746
  // strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
@@ -677,18 +808,37 @@ export class ActivationEngine {
677
808
  // Phase 8: Multi-channel OOD detection + agreement gate
678
809
  // Requires at least 2 of 3 retrieval channels to agree the query is in-domain.
679
810
  if (rerankPool.length >= 3) {
680
- const topBM25 = Math.max(...rerankPool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
811
+ // Abstention gate scope (2026-06-16): the in-domain channel maxes used to be taken
812
+ // over the ENTIRE rerankPool. Once that pool was widened for recall (pool 40), a lone
813
+ // high-scoring distractor inflated the maxes and defeated abstention on adversarial
814
+ // queries (adversarial 73.4→71.0). Fix: judge in-domain on the post-rerank TOP-K —
815
+ // the items we'd actually return — so pool width (recall) is decoupled from the
816
+ // abstention decision (precision). AWM_ABSTAIN_GATE_K controls K (0 = legacy
817
+ // whole-pool behavior). Answerable queries are unaffected: the gold is in the top-K
818
+ // and supplies the in-domain signal; only borderline distractors deep in a wide pool
819
+ // stop counting.
820
+ // Default 5 (2026-06-16): judge in-domain on the post-rerank top-5. With the widened
821
+ // rerank pool, basing it on the whole pool (legacy AWM_ABSTAIN_GATE_K=0) let a lone
822
+ // deep distractor defeat abstention; top-5 restored adversarial 71.0→74.9 (ABOVE the
823
+ // pre-widening 73.4) at ZERO recall cost (answerable categories unchanged) — the
824
+ // precision half of the two-dial pool-widening win.
825
+ const gateK = Number(process.env.AWM_ABSTAIN_GATE_K ?? 5);
826
+ const gatePool = gateK > 0
827
+ ? [...rerankPool].sort((a, b) => b.score - a.score).slice(0, gateK)
828
+ : rerankPool;
829
+
830
+ const topBM25 = Math.max(...gatePool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
681
831
  const topVector = queryEmbedding
682
- ? Math.max(...rerankPool.map(r => r.phaseScores.vectorMatch))
832
+ ? Math.max(...gatePool.map(r => r.phaseScores.vectorMatch))
683
833
  : 0;
684
- const topReranker = Math.max(...rerankPool.map(r => r.phaseScores.rerankerScore));
834
+ const topReranker = Math.max(...gatePool.map(r => r.phaseScores.rerankerScore));
685
835
 
686
836
  const bm25Ok = topBM25 > 0.3;
687
837
  const vectorOk = topVector > 0.05;
688
838
  const rerankerOk = topReranker > 0.25;
689
839
  const channelsAgreeing = (bm25Ok ? 1 : 0) + (vectorOk ? 1 : 0) + (rerankerOk ? 1 : 0);
690
840
 
691
- const rerankerScores = rerankPool
841
+ const rerankerScores = gatePool
692
842
  .map(r => r.phaseScores.rerankerScore)
693
843
  .sort((a, b) => b - a);
694
844
  const margin = rerankerScores.length >= 2
@@ -1006,6 +1156,173 @@ export class ActivationEngine {
1006
1156
  }
1007
1157
  }
1008
1158
 
1159
+ /**
1160
+ * R2 — bounded iterative spreading activation (PPR / SYNAPSE-style).
1161
+ *
1162
+ * Default-OFF (`AWM_SPREAD=1`). The principled, in-AWM successor to the
1163
+ * fixed depth-2 beam `graphWalk` and the MWA harness bridge: it runs T
1164
+ * iterations of **fan-normalized** spreading with **lateral inhibition** and
1165
+ * a **restart** term (Personalized PageRank) over the association graph —
1166
+ * richest when R1's `AWM_BROAD_EDGES` entity edges are present.
1167
+ *
1168
+ * Two effects, both precision-guarded:
1169
+ * - **Boost** existing pool candidates by the *graph evidence* they receive
1170
+ * (convergent multi-path activation, not a single spurious hop).
1171
+ * - **Inject** (`AWM_SPREAD_INJECT=1`) strongly-reached *out-of-pool*
1172
+ * engrams as recall-only candidates so the reranker can see true
1173
+ * multi-hop bridges that BM25/vector missed. Their composite carries the
1174
+ * graph-activation signal (blended with rerank), which is what lets a
1175
+ * vocab-mismatched bridge surface where the prior `AWM_ENTITY_FETCH`
1176
+ * recall-only injection (rerank-only) could not.
1177
+ *
1178
+ * Precision is preserved because spreading is **seeded by the initial
1179
+ * retrieval**: adversarial "is this even in memory?" queries have weak/empty
1180
+ * seeds, so nothing meaningful propagates and abstention is unaffected.
1181
+ * Fan-normalization stops hubs from flooding; lateral inhibition keeps only
1182
+ * the top-M activated nodes per step; a node budget bounds cost; injected
1183
+ * candidates stay rerank-gated and the pool-level OOD agreement gate is
1184
+ * unaffected (seeds still supply the BM25/vector channels).
1185
+ */
1186
+ private async spreadActivation(
1187
+ topN: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
1188
+ ): Promise<void> {
1189
+ const T = Number(process.env.AWM_SPREAD_ITERS ?? 3);
1190
+ const delta = Number(process.env.AWM_SPREAD_DAMPING ?? 0.5);
1191
+ const NODE_BUDGET = Number(process.env.AWM_SPREAD_BUDGET ?? 64);
1192
+ const BOOST_SCALE = Number(process.env.AWM_SPREAD_BOOST ?? 0.4);
1193
+ const PER_NODE_CAP = 0.15;
1194
+ const MAX_TOTAL_BOOST = 0.25;
1195
+ const inject = process.env.AWM_SPREAD_INJECT === '1';
1196
+ const INJECT_THRESHOLD = Number(process.env.AWM_SPREAD_INJECT_MIN ?? 0.08);
1197
+ const INJECT_BUDGET = Number(process.env.AWM_SPREAD_INJECT_CAP ?? 8);
1198
+ const INJECT_SCALE = Number(process.env.AWM_SPREAD_INJECT_SCALE ?? 1.0);
1199
+ const EPS = 0.01;
1200
+ // 'invalidation' edges link superseded→replacement; excluded so spreading
1201
+ // never pulls stale facts back in.
1202
+ const allowed = new Set(['connection', 'hebbian', 'temporal', 'causal', 'bridge']);
1203
+
1204
+ const scoreMap = new Map(topN.map(s => [s.engram.id, s]));
1205
+
1206
+ // Seed activation from query-relevant candidates (textMatch gate), normalized to [0,1].
1207
+ const seed = new Map<string, number>();
1208
+ let maxSeed = 0;
1209
+ for (const item of topN) {
1210
+ if (item.phaseScores.textMatch >= 0.15) {
1211
+ const v = Math.max(0, item.score);
1212
+ seed.set(item.engram.id, v);
1213
+ if (v > maxSeed) maxSeed = v;
1214
+ }
1215
+ }
1216
+ if (seed.size === 0 || maxSeed <= 0) return;
1217
+ for (const [k, v] of seed) seed.set(k, v / maxSeed);
1218
+
1219
+ const edgeCache = new Map<string, Association[]>();
1220
+ const getEdges = async (id: string): Promise<Association[]> => {
1221
+ let e = edgeCache.get(id);
1222
+ if (!e) {
1223
+ e = (await this.store.getAssociationsFor(id)).filter(a => allowed.has(a.type));
1224
+ edgeCache.set(id, e);
1225
+ }
1226
+ return e;
1227
+ };
1228
+
1229
+ let act = new Map(seed);
1230
+ // Cumulative inflow received from the graph (excludes a node's own seed) —
1231
+ // this is the multi-hop "evidence" signal used for boost + injection.
1232
+ const graphActivation = new Map<string, number>();
1233
+
1234
+ for (let t = 0; t < T; t++) {
1235
+ const inflow = new Map<string, number>();
1236
+ for (const [u, au] of act) {
1237
+ if (au <= EPS) continue;
1238
+ const edges = await getEdges(u);
1239
+ if (edges.length === 0) continue;
1240
+ let fan = 0;
1241
+ for (const e of edges) fan += Math.max(0, e.weight);
1242
+ if (fan <= 0) continue;
1243
+ for (const e of edges) {
1244
+ const v = e.fromEngramId === u ? e.toEngramId : e.fromEngramId;
1245
+ const share = Math.max(0, e.weight) / fan; // fan-effect normalization
1246
+ inflow.set(v, (inflow.get(v) ?? 0) + au * share);
1247
+ }
1248
+ }
1249
+ for (const [v, f] of inflow) graphActivation.set(v, (graphActivation.get(v) ?? 0) + f);
1250
+
1251
+ // Restart (PPR): blend propagated inflow with the original seed vector.
1252
+ const newAct = new Map<string, number>();
1253
+ const keys = new Set<string>([...act.keys(), ...inflow.keys()]);
1254
+ for (const v of keys) {
1255
+ const val = (1 - delta) * (seed.get(v) ?? 0) + delta * (inflow.get(v) ?? 0);
1256
+ if (val > EPS) newAct.set(v, val);
1257
+ }
1258
+ // Lateral inhibition: keep only the top-M activated nodes (competition + cost bound).
1259
+ if (newAct.size > NODE_BUDGET) {
1260
+ act = new Map([...newAct.entries()].sort((a, b) => b[1] - a[1]).slice(0, NODE_BUDGET));
1261
+ } else {
1262
+ act = newAct;
1263
+ }
1264
+ }
1265
+
1266
+ // Normalize graph evidence to [0,1] so the boost magnitude is scale-stable
1267
+ // (raw `ga` accumulates across iterations + bidirectional edges, so its
1268
+ // absolute scale varies with graph density). The top-reached node maps to 1.0.
1269
+ let maxGa = 0;
1270
+ for (const ga of graphActivation.values()) if (ga > maxGa) maxGa = ga;
1271
+ const normGa = (id: string): number => (maxGa > 0 ? (graphActivation.get(id) ?? 0) / maxGa : 0);
1272
+
1273
+ if (process.env.AWM_SPREAD_DEBUG === '1') {
1274
+ const top = [...graphActivation.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
1275
+ process.stderr.write(`[spread] seeds=${seed.size} reached=${graphActivation.size} inPool=${[...graphActivation.keys()].filter(id => scoreMap.has(id)).length} maxGa=${maxGa.toFixed(3)}\n`);
1276
+ for (const [id, ga] of top) {
1277
+ const e = scoreMap.get(id)?.engram;
1278
+ process.stderr.write(`[spread] ga=${ga.toFixed(3)} norm=${normGa(id).toFixed(2)} pool=${scoreMap.has(id)} ${e ? e.concept.slice(0, 40) : '(out-of-pool ' + id.slice(0, 8) + ')'}\n`);
1279
+ }
1280
+ }
1281
+
1282
+ // Boost existing candidates by the (normalized) graph evidence they received.
1283
+ // Folded into `composite` (NOT just `score`) so it survives the rerank blend
1284
+ // — the reranker recomputes score from composite, so a score-only boost would
1285
+ // be discarded. This makes spreading a first-class multi-hop ranking signal.
1286
+ for (const [id] of graphActivation) {
1287
+ const item = scoreMap.get(id);
1288
+ if (!item) continue;
1289
+ const boost = Math.min(normGa(id) * BOOST_SCALE, PER_NODE_CAP);
1290
+ const capped = Math.min(boost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
1291
+ if (capped > 0.001) {
1292
+ item.phaseScores.composite += capped;
1293
+ item.score += capped;
1294
+ item.phaseScores.graphBoost += capped;
1295
+ }
1296
+ }
1297
+
1298
+ // Inject strongly-reached out-of-pool engrams as recall-only candidates.
1299
+ if (inject) {
1300
+ const reached = [...graphActivation.entries()]
1301
+ .filter(([id]) => !scoreMap.has(id) && normGa(id) >= INJECT_THRESHOLD)
1302
+ .sort((a, b) => b[1] - a[1])
1303
+ .slice(0, INJECT_BUDGET);
1304
+ for (const [id] of reached) {
1305
+ const engram = await this.store.getEngram(id);
1306
+ if (!engram || engram.stage !== 'active') continue;
1307
+ if ((engram as unknown as { retracted?: boolean }).retracted || engram.supersededBy) continue;
1308
+ const composite = Math.min(0.6, normGa(id) * INJECT_SCALE);
1309
+ const phaseScores: PhaseScores = {
1310
+ textMatch: 0,
1311
+ vectorMatch: 0,
1312
+ decayScore: 0,
1313
+ hebbianBoost: 0,
1314
+ graphBoost: composite,
1315
+ confidenceGate: engram.confidence,
1316
+ composite,
1317
+ rerankerScore: 0,
1318
+ };
1319
+ const injected = { engram, score: composite, phaseScores, associations: [] as Association[] };
1320
+ topN.push(injected);
1321
+ scoreMap.set(id, injected);
1322
+ }
1323
+ }
1324
+ }
1325
+
1009
1326
  /**
1010
1327
  * Resolve validation-gated Hebbian update for a specific engram.
1011
1328
  * Called by memory_feedback — only strengthens when retrieval was useful.
@@ -1,120 +1,120 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Retrieval confidence — score-distribution-aware signal that complements
5
- * the per-result `score`. The shape of the result set carries information
6
- * the raw scores do not:
7
- *
8
- * - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
9
- * - Noisy recall: many similar scores, flat distribution, weak floor.
10
- * - "Best of bad bunch": sharp cliff but the cliff sits below a usable
11
- * floor — the system found a winner among uninteresting candidates.
12
- *
13
- * Research grounding:
14
- * - Geifman & El-Yaniv, "Selective Classification for Deep Neural
15
- * Networks" (NeurIPS 2017): abstaining improves precision on confused
16
- * inputs more than recalibrating thresholds.
17
- * - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
18
- * score-distribution shape predicts retrieval quality better than
19
- * top-1 score in isolation.
20
- * - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
21
- * Lectures, 2010): post-retrieval predictors — sharpness, depth of
22
- * score drop — correlate with TREC topic difficulty.
23
- *
24
- * AWM 0.8.5 integration: confidence is computed once per recall after
25
- * final scoring and attached to every `ActivationResult`. Consumers may
26
- * use it however they like (display, abstention, paired retrieval).
27
- * Default behavior of recall is unchanged — confidence is data, not a
28
- * gate, in PR-1.
29
- *
30
- * Configurable via env vars (initial weights tuned to favour sharpness):
31
- * AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
32
- * AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
33
- * AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
34
- */
35
-
36
- export interface RecallConfidence {
37
- /** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
38
- confidence: number;
39
- /** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
40
- sharpness: number;
41
- /** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
42
- cliff: number;
43
- /** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
44
- floor: number;
45
- }
46
-
47
- const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
48
- const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
49
- const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
50
-
51
- /**
52
- * Compute recall confidence from an ordered (descending) array of result scores.
53
- *
54
- * Returns a confidence near 0 when:
55
- * - Empty result set (no winner)
56
- * - Flat distribution (sharpness ~1, cliff ~0)
57
- * - Low absolute scores (floor low — "best of bad bunch")
58
- *
59
- * Returns a confidence near 1 when:
60
- * - top-1 dominates (sharpness >> 1)
61
- * - Sharp cliff after top-1 (cliff close to 1)
62
- * - top-1 is itself a strong absolute match (floor close to 1)
63
- *
64
- * Edge cases:
65
- * - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
66
- * to dominate). Confidence anchored entirely by floor.
67
- * - 0 results: all zero, confidence = 0.
68
- */
69
- export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
70
- if (scoresDesc.length === 0) {
71
- return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
72
- }
73
-
74
- const top1 = scoresDesc[0];
75
-
76
- // Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
77
- // range under normal use, but be defensive.
78
- const floor = Math.max(0, Math.min(1, top1));
79
-
80
- // Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
81
- let sharpness = 0;
82
- if (scoresDesc.length >= 2) {
83
- const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
84
- const mean = window.reduce((s, v) => s + v, 0) / window.length;
85
- if (mean > 0) {
86
- const ratio = top1 / mean; // typically in [1, K]
87
- sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
88
- }
89
- }
90
-
91
- // Cliff: how steep is the drop from top-1 to the K-th candidate?
92
- // Use top-10 (or last available). If only 1 result, no cliff to measure.
93
- let cliff = 0;
94
- if (scoresDesc.length >= 2 && top1 > 0) {
95
- const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
96
- cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
97
- }
98
-
99
- // Geometric blend — any near-zero component pulls confidence down.
100
- // Add a tiny epsilon so log/zero doesn't collapse the whole signal when
101
- // a result is genuinely sharp but the cliff is computed off only 2-3
102
- // candidates (cliff small even for confident recalls).
103
- const EPS = 0.05;
104
- const s = sharpness + EPS;
105
- const c = cliff + EPS;
106
- const f = floor + EPS;
107
-
108
- // Weighted geometric mean: prod(x_i ^ w_i)
109
- const logConf =
110
- SHARPNESS_W * Math.log(s)
111
- + CLIFF_W * Math.log(c)
112
- + FLOOR_W * Math.log(f);
113
- const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
114
- // Subtract epsilon contribution so the floor of confidence is ~0 when all
115
- // signals are zero (rather than the value of EPS).
116
- const rawConf = Math.exp(logConf / totalW) - EPS;
117
- const confidence = Math.max(0, Math.min(1, rawConf));
118
-
119
- return { confidence, sharpness, cliff, floor };
120
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Retrieval confidence — score-distribution-aware signal that complements
5
+ * the per-result `score`. The shape of the result set carries information
6
+ * the raw scores do not:
7
+ *
8
+ * - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
9
+ * - Noisy recall: many similar scores, flat distribution, weak floor.
10
+ * - "Best of bad bunch": sharp cliff but the cliff sits below a usable
11
+ * floor — the system found a winner among uninteresting candidates.
12
+ *
13
+ * Research grounding:
14
+ * - Geifman & El-Yaniv, "Selective Classification for Deep Neural
15
+ * Networks" (NeurIPS 2017): abstaining improves precision on confused
16
+ * inputs more than recalibrating thresholds.
17
+ * - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
18
+ * score-distribution shape predicts retrieval quality better than
19
+ * top-1 score in isolation.
20
+ * - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
21
+ * Lectures, 2010): post-retrieval predictors — sharpness, depth of
22
+ * score drop — correlate with TREC topic difficulty.
23
+ *
24
+ * AWM 0.8.5 integration: confidence is computed once per recall after
25
+ * final scoring and attached to every `ActivationResult`. Consumers may
26
+ * use it however they like (display, abstention, paired retrieval).
27
+ * Default behavior of recall is unchanged — confidence is data, not a
28
+ * gate, in PR-1.
29
+ *
30
+ * Configurable via env vars (initial weights tuned to favour sharpness):
31
+ * AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
32
+ * AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
33
+ * AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
34
+ */
35
+
36
+ export interface RecallConfidence {
37
+ /** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
38
+ confidence: number;
39
+ /** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
40
+ sharpness: number;
41
+ /** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
42
+ cliff: number;
43
+ /** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
44
+ floor: number;
45
+ }
46
+
47
+ const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
48
+ const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
49
+ const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
50
+
51
+ /**
52
+ * Compute recall confidence from an ordered (descending) array of result scores.
53
+ *
54
+ * Returns a confidence near 0 when:
55
+ * - Empty result set (no winner)
56
+ * - Flat distribution (sharpness ~1, cliff ~0)
57
+ * - Low absolute scores (floor low — "best of bad bunch")
58
+ *
59
+ * Returns a confidence near 1 when:
60
+ * - top-1 dominates (sharpness >> 1)
61
+ * - Sharp cliff after top-1 (cliff close to 1)
62
+ * - top-1 is itself a strong absolute match (floor close to 1)
63
+ *
64
+ * Edge cases:
65
+ * - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
66
+ * to dominate). Confidence anchored entirely by floor.
67
+ * - 0 results: all zero, confidence = 0.
68
+ */
69
+ export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
70
+ if (scoresDesc.length === 0) {
71
+ return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
72
+ }
73
+
74
+ const top1 = scoresDesc[0];
75
+
76
+ // Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
77
+ // range under normal use, but be defensive.
78
+ const floor = Math.max(0, Math.min(1, top1));
79
+
80
+ // Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
81
+ let sharpness = 0;
82
+ if (scoresDesc.length >= 2) {
83
+ const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
84
+ const mean = window.reduce((s, v) => s + v, 0) / window.length;
85
+ if (mean > 0) {
86
+ const ratio = top1 / mean; // typically in [1, K]
87
+ sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
88
+ }
89
+ }
90
+
91
+ // Cliff: how steep is the drop from top-1 to the K-th candidate?
92
+ // Use top-10 (or last available). If only 1 result, no cliff to measure.
93
+ let cliff = 0;
94
+ if (scoresDesc.length >= 2 && top1 > 0) {
95
+ const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
96
+ cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
97
+ }
98
+
99
+ // Geometric blend — any near-zero component pulls confidence down.
100
+ // Add a tiny epsilon so log/zero doesn't collapse the whole signal when
101
+ // a result is genuinely sharp but the cliff is computed off only 2-3
102
+ // candidates (cliff small even for confident recalls).
103
+ const EPS = 0.05;
104
+ const s = sharpness + EPS;
105
+ const c = cliff + EPS;
106
+ const f = floor + EPS;
107
+
108
+ // Weighted geometric mean: prod(x_i ^ w_i)
109
+ const logConf =
110
+ SHARPNESS_W * Math.log(s)
111
+ + CLIFF_W * Math.log(c)
112
+ + FLOOR_W * Math.log(f);
113
+ const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
114
+ // Subtract epsilon contribution so the floor of confidence is ~0 when all
115
+ // signals are zero (rather than the value of EPS).
116
+ const rawConf = Math.exp(logConf / totalW) - EPS;
117
+ const confidence = Math.max(0, Math.min(1, rawConf));
118
+
119
+ return { confidence, sharpness, cliff, floor };
120
+ }