agent-working-memory 0.13.1 → 0.14.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 (91) hide show
  1. package/README.md +200 -238
  2. package/dist/adapters/common.d.ts +6 -0
  3. package/dist/adapters/common.d.ts.map +1 -1
  4. package/dist/adapters/common.js +457 -362
  5. package/dist/adapters/common.js.map +1 -1
  6. package/dist/api/routes.d.ts.map +1 -1
  7. package/dist/api/routes.js +24 -8
  8. package/dist/api/routes.js.map +1 -1
  9. package/dist/core/alias-map.d.ts +16 -0
  10. package/dist/core/alias-map.d.ts.map +1 -0
  11. package/dist/core/alias-map.js +102 -0
  12. package/dist/core/alias-map.js.map +1 -0
  13. package/dist/core/embeddings.d.ts +17 -0
  14. package/dist/core/embeddings.d.ts.map +1 -1
  15. package/dist/core/embeddings.js +50 -1
  16. package/dist/core/embeddings.js.map +1 -1
  17. package/dist/core/recall-config.d.ts +52 -0
  18. package/dist/core/recall-config.d.ts.map +1 -0
  19. package/dist/core/recall-config.js +110 -0
  20. package/dist/core/recall-config.js.map +1 -0
  21. package/dist/core/rerank-window.d.ts +61 -0
  22. package/dist/core/rerank-window.d.ts.map +1 -0
  23. package/dist/core/rerank-window.js +153 -0
  24. package/dist/core/rerank-window.js.map +1 -0
  25. package/dist/core/rerank2.d.ts +62 -0
  26. package/dist/core/rerank2.d.ts.map +1 -0
  27. package/dist/core/rerank2.js +75 -0
  28. package/dist/core/rerank2.js.map +1 -0
  29. package/dist/core/retrieval-text.d.ts +55 -0
  30. package/dist/core/retrieval-text.d.ts.map +1 -0
  31. package/dist/core/retrieval-text.js +87 -0
  32. package/dist/core/retrieval-text.js.map +1 -0
  33. package/dist/core/temporal-query.d.ts +61 -0
  34. package/dist/core/temporal-query.d.ts.map +1 -0
  35. package/dist/core/temporal-query.js +168 -0
  36. package/dist/core/temporal-query.js.map +1 -0
  37. package/dist/core/token-budget.d.ts +75 -0
  38. package/dist/core/token-budget.d.ts.map +1 -0
  39. package/dist/core/token-budget.js +136 -0
  40. package/dist/core/token-budget.js.map +1 -0
  41. package/dist/core/whoami.d.ts +11 -0
  42. package/dist/core/whoami.d.ts.map +1 -1
  43. package/dist/core/whoami.js +10 -0
  44. package/dist/core/whoami.js.map +1 -1
  45. package/dist/core/write-pipeline.d.ts.map +1 -1
  46. package/dist/core/write-pipeline.js +6 -3
  47. package/dist/core/write-pipeline.js.map +1 -1
  48. package/dist/engine/activation.d.ts.map +1 -1
  49. package/dist/engine/activation.js +135 -32
  50. package/dist/engine/activation.js.map +1 -1
  51. package/dist/hooks/prime.d.ts +77 -0
  52. package/dist/hooks/prime.d.ts.map +1 -0
  53. package/dist/hooks/prime.js +92 -0
  54. package/dist/hooks/prime.js.map +1 -0
  55. package/dist/hooks/sidecar.d.ts.map +1 -1
  56. package/dist/hooks/sidecar.js +39 -0
  57. package/dist/hooks/sidecar.js.map +1 -1
  58. package/dist/mcp.js +134 -102
  59. package/dist/mcp.js.map +1 -1
  60. package/dist/storage/pglite.d.ts.map +1 -1
  61. package/dist/storage/pglite.js +10 -2
  62. package/dist/storage/pglite.js.map +1 -1
  63. package/dist/storage/postgres.d.ts.map +1 -1
  64. package/dist/storage/postgres.js +10 -2
  65. package/dist/storage/postgres.js.map +1 -1
  66. package/dist/storage/sqlite.d.ts.map +1 -1
  67. package/dist/storage/sqlite.js +12 -2
  68. package/dist/storage/sqlite.js.map +1 -1
  69. package/dist/types/engram.d.ts +7 -0
  70. package/dist/types/engram.d.ts.map +1 -1
  71. package/package.json +3 -2
  72. package/src/adapters/common.ts +666 -567
  73. package/src/api/routes.ts +1015 -999
  74. package/src/core/alias-map.ts +97 -0
  75. package/src/core/embeddings.ts +172 -115
  76. package/src/core/recall-config.ts +115 -0
  77. package/src/core/rerank-window.ts +158 -0
  78. package/src/core/rerank2.ts +82 -0
  79. package/src/core/retrieval-text.ts +82 -0
  80. package/src/core/temporal-query.ts +193 -0
  81. package/src/core/token-budget.ts +160 -0
  82. package/src/core/whoami.ts +110 -92
  83. package/src/core/write-pipeline.ts +6 -3
  84. package/src/engine/activation.ts +1568 -1468
  85. package/src/hooks/prime.ts +136 -0
  86. package/src/hooks/sidecar.ts +43 -0
  87. package/src/mcp.ts +1422 -1387
  88. package/src/storage/pglite.ts +10 -2
  89. package/src/storage/postgres.ts +10 -2
  90. package/src/storage/sqlite.ts +12 -2
  91. package/src/types/engram.ts +7 -0
@@ -1,1468 +1,1568 @@
1
- // Copyright 2026 Robert Winter / Complete Ideas
2
- // SPDX-License-Identifier: Apache-2.0
3
- /**
4
- * Activation Pipeline — the core retrieval engine.
5
- *
6
- * Cognitive retrieval pipeline — phases as shipped (D4 honesty pass 2026-07-30;
7
- * default-OFF phases are marked, since operators tune from this header):
8
- * -1. Coreference expansion (conditional: query contains pronouns)
9
- * 0. Query expansion (flan-t5-small; caller-gated, MCP default ON)
10
- * 1. Vector embedding (bge-small 384d)
11
- * 2. Parallel retrieval (dual FTS5/BM25 + native vector top-K)
12
- * 3. Per-candidate scoring (BM25, Jaccard, cosine floor, ACT-R decay,
13
- * Hebbian boost, confidence gate — computed together in phase 3b)
14
- * 3.5 Rocchio pseudo-relevance feedback (conditional on BM25 signal)
15
- * 3.7 Entity-bridge boost (default ON; AWM_DISABLE_ENTITY_BRIDGE=1)
16
- * 3.5 Entity-index candidate injection (DEFAULT OFF; AWM_ENTITY_INDEX_FETCH=1 —
17
- * D11 2026-07-30: D9 inverted-index lookup of query-named entities; injected
18
- * candidates get no boost but a guaranteed rerank audition)
19
- * 3.75 Query-conditioned entity bridge (DEFAULT OFF; AWM_QUERY_BRIDGE=1)
20
- * 4/5 Spreading-activation graph walk (DEFAULT OFF; AWM_SPREAD=1 — parked
21
- * after displacing-gold regressions; see design-proposals D11)
22
- * 6. Filter + sort into rerank pool (wide pool since 0.9.0)
23
- * 7. Cross-encoder rerank (ms-marco; clear-winner skip unless
24
- * AWM_DISABLE_RERANK_SKIP=1)
25
- * 8. Multi-channel OOD detection + agreement gate; supersession penalty;
26
- * abstention enforced only when caller passes require_confidence
27
- * 9. Final sort, granularity, confidence attach
28
- *
29
- * Logs activation events for eval metrics.
30
- */
31
-
32
- import { randomUUID } from 'node:crypto';
33
- import { baseLevelActivation, softplus } from '../core/decay.js';
34
- import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from '../core/hebbian.js';
35
- import { embed, cosineSimilarity } from '../core/embeddings.js';
36
- import { rerank } from '../core/reranker.js';
37
- import { expandQuery } from '../core/query-expander.js';
38
- import { computeRecallConfidence } from './confidence.js';
39
- import type {
40
- Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
41
- } from '../types/index.js';
42
- import type { IEngramStore as EngramStore } from '../storage/store.js';
43
-
44
- // ─── Query-adaptive pipeline parameters ───────────────────────────
45
-
46
- interface AdaptiveParams {
47
- mode: 'targeted' | 'exploratory' | 'balanced';
48
- textWeight: number; // Weight for text match in composite (default 0.6)
49
- temporalWeight: number; // Weight for temporal signals (default 0.4)
50
- decayExponentBase: number;// Base ACT-R decay exponent (default 0.5)
51
- zScoreGate: number; // Z-score threshold for vector match (default 0.5)
52
- beamWidth: number; // Graph walk beam width (default 15)
53
- hopPenalty: number; // Graph walk hop penalty (default 0.3)
54
- }
55
-
56
- const ADAPTIVE_PRESETS: Record<'targeted' | 'exploratory' | 'balanced', AdaptiveParams> = {
57
- targeted: {
58
- mode: 'targeted',
59
- textWeight: 0.75, // Heavy BM25/keyword emphasis
60
- temporalWeight: 0.25,
61
- decayExponentBase: 0.6, // Stronger decay — recent exact matches matter more
62
- zScoreGate: 0.8, // Strict vector gate — only strong semantic matches
63
- beamWidth: 3, // Narrow beam — don't wander
64
- hopPenalty: 0.2, // Steeper hop penalty
65
- },
66
- exploratory: {
67
- mode: 'exploratory',
68
- textWeight: 0.4, // Lower BM25 weight
69
- temporalWeight: 0.6, // Lean on temporal/associative signals
70
- decayExponentBase: 0.3, // Weaker decay — surface older memories
71
- zScoreGate: 0.3, // Relaxed vector gate — cast wider net
72
- beamWidth: 20, // Wide beam — explore associations
73
- hopPenalty: 0.4, // Gentler hop penalty
74
- },
75
- balanced: {
76
- mode: 'balanced',
77
- textWeight: 0.6,
78
- temporalWeight: 0.4,
79
- decayExponentBase: 0.5,
80
- zScoreGate: 0.5,
81
- beamWidth: 15,
82
- hopPenalty: 0.3,
83
- },
84
- };
85
-
86
- /**
87
- * Classify a query as targeted, exploratory, or balanced.
88
- *
89
- * Targeted signals: identifiers (PROJ-123, camelCase, snake_case, UUIDs),
90
- * short queries (< 8 words), quoted strings, file paths.
91
- *
92
- * Exploratory signals: question words, long queries (> 15 words),
93
- * vague modifiers ("general", "overview", "about", "related to").
94
- */
95
- function classifyQuery(context: string): 'targeted' | 'exploratory' | 'balanced' {
96
- const words = context.split(/\s+/).filter(w => w.length > 0);
97
- const wordCount = words.length;
98
- const lower = context.toLowerCase();
99
-
100
- let targetedScore = 0;
101
- let exploratoryScore = 0;
102
-
103
- // Identifier patterns
104
- if (/[A-Z]+-\d+/.test(context)) targetedScore += 2; // PROJ-123
105
- if (/[a-z][A-Z]/.test(context)) targetedScore += 1; // camelCase
106
- if (/\w+_\w+/.test(context)) targetedScore += 1; // snake_case
107
- if (/[0-9a-f]{8}-[0-9a-f]{4}/.test(lower)) targetedScore += 2; // UUID fragment
108
- if (/["']/.test(context)) targetedScore += 1; // Quoted strings
109
- if (/[\/\\]/.test(context)) targetedScore += 1; // File paths
110
- if (/\.\w{1,4}$/.test(context.trim())) targetedScore += 1; // File extensions
111
-
112
- // Short queries are usually targeted
113
- if (wordCount <= 5) targetedScore += 2;
114
- else if (wordCount <= 8) targetedScore += 1;
115
-
116
- // Question words / exploratory modifiers
117
- if (/^(what|how|why|when|where|who|which|can|does|is|are)\b/i.test(context)) exploratoryScore += 1;
118
- if (/\b(overview|general|about|related|similar|like|broad|concept|idea|approach|strategy)\b/i.test(lower)) exploratoryScore += 1;
119
- if (/\b(any|all|everything|anything)\b/i.test(lower)) exploratoryScore += 1;
120
-
121
- // Long queries are usually exploratory
122
- if (wordCount > 15) exploratoryScore += 2;
123
- else if (wordCount > 10) exploratoryScore += 1;
124
-
125
- const diff = targetedScore - exploratoryScore;
126
- if (diff >= 2) return 'targeted';
127
- if (diff <= -2) return 'exploratory';
128
- return 'balanced';
129
- }
130
-
131
- function resolveAdaptiveParams(query: ActivationQuery): AdaptiveParams {
132
- const mode = query.mode ?? 'auto';
133
- if (mode !== 'auto') return ADAPTIVE_PRESETS[mode];
134
- const classified = classifyQuery(query.context);
135
- return ADAPTIVE_PRESETS[classified];
136
- }
137
-
138
- /**
139
- * Common English stopwords — filtered from similarity calculations.
140
- * These words carry no semantic signal for memory retrieval.
141
- */
142
- const STOPWORDS = new Set([
143
- 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
144
- 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'been', 'from', 'that',
145
- 'this', 'with', 'they', 'will', 'each', 'make', 'like', 'then', 'than',
146
- 'them', 'some', 'what', 'when', 'where', 'which', 'who', 'how', 'use',
147
- 'into', 'does', 'also', 'just', 'more', 'over', 'such', 'only', 'very',
148
- 'about', 'after', 'being', 'between', 'could', 'during', 'before',
149
- 'should', 'would', 'their', 'there', 'these', 'those', 'through',
150
- 'because', 'using', 'other',
151
- ]);
152
-
153
- function tokenize(text: string): Set<string> {
154
- return new Set(
155
- text.toLowerCase()
156
- .split(/\s+/)
157
- .filter(w => w.length > 2 && !STOPWORDS.has(w))
158
- );
159
- }
160
-
161
- /**
162
- * Jaccard similarity between two word sets: |intersection| / |union|
163
- */
164
- function jaccard(a: Set<string>, b: Set<string>): number {
165
- if (a.size === 0 || b.size === 0) return 0;
166
- let intersection = 0;
167
- for (const w of a) {
168
- if (b.has(w)) intersection++;
169
- }
170
- const union = a.size + b.size - intersection;
171
- return union > 0 ? intersection / union : 0;
172
- }
173
-
174
- export class ActivationEngine {
175
- private store: EngramStore;
176
- private coActivationBuffer: CoActivationBuffer;
177
- readonly validationGate: ValidationGatedBuffer;
178
-
179
- constructor(store: EngramStore) {
180
- this.store = store;
181
- this.coActivationBuffer = new CoActivationBuffer(50);
182
- this.validationGate = new ValidationGatedBuffer();
183
- }
184
-
185
- /**
186
- * Activate retrieve the most cognitively relevant engrams for a context.
187
- */
188
- async activate(query: ActivationQuery): Promise<ActivationResult[]> {
189
- const startTime = performance.now();
190
- const limit = query.limit ?? 10;
191
- const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
192
- const useReranker = query.useReranker ?? true;
193
- // Default OFF (rerank-only): query expansion ~doubles recall latency (it inflates the rerank
194
- // candidate pool) for no measured accuracy gain — validated no-regression on LoCoMo
195
- // (overall 22.8→22.7, adversarial 73.5→73.4), the 4-suite eval (identical), and the MWA
196
- // gauntlet. Callers can still opt in per-query (useExpansion:true); AWM_DEFAULT_EXPANSION=1
197
- // restores expansion-by-default globally as an escape hatch.
198
- const useExpansion = query.useExpansion ?? process.env.AWM_DEFAULT_EXPANSION === '1';
199
- const abstentionThreshold = query.abstentionThreshold ?? 0;
200
- const requireConfidence = query.requireConfidence ?? 0;
201
- const adaptive = resolveAdaptiveParams(query);
202
-
203
- // Resolve workspace scope: if workspace is set, search across all agents in that workspace
204
- const agentIds = query.workspace
205
- ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
206
- : [query.agentId];
207
- const isWorkspaceScoped = agentIds.length > 1;
208
-
209
- // Phase -1: Coref expansion — if query has pronouns, append recent entity names
210
- let queryContext = query.context;
211
- const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
212
- if (pronounPattern.test(queryContext)) {
213
- try {
214
- const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
215
- .sort((a, b) => b.accessCount - a.accessCount)
216
- .slice(0, 10)
217
- .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
218
- .filter((v, i, a) => a.indexOf(v) === i)
219
- .slice(0, 5);
220
- if (recentEntities.length > 0) {
221
- queryContext = `${queryContext} ${recentEntities.join(' ')}`;
222
- }
223
- } catch { /* non-fatal */ }
224
- }
225
-
226
- // Phase 0: Query expansion — add related terms to improve BM25 recall
227
- let searchContext = queryContext;
228
- if (useExpansion) {
229
- let timer: ReturnType<typeof setTimeout> | undefined;
230
- try {
231
- searchContext = await Promise.race([
232
- expandQuery(query.context),
233
- new Promise<string>((_, reject) => { timer = setTimeout(() => reject(new Error('expansion timeout')), 5000); }),
234
- ]);
235
- } catch {
236
- // Expansion unavailable or timed out — use original query
237
- } finally {
238
- if (timer) clearTimeout(timer);
239
- }
240
- }
241
-
242
- // Phase 1: Embed query for vector similarity (uses coref-expanded context)
243
- let queryEmbedding: number[] | null = null;
244
- try {
245
- queryEmbedding = await embed(queryContext);
246
- } catch {
247
- // Embedding unavailable — fall back to text-only matching
248
- }
249
-
250
- // Phase 2: Parallel retrieval dual BM25 + all active engrams
251
- // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
252
- const keywordQuery = Array.from(tokenize(query.context)).join(' ');
253
- const bm25Keyword = keywordQuery.length > 2
254
- ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
255
- : [];
256
- const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
257
-
258
- // Merge: take the best BM25 score per engram from either pass
259
- const bm25ScoreMap = new Map<string, number>();
260
- const bm25EngramMap = new Map<string, any>();
261
- for (const r of [...bm25Keyword, ...bm25Expanded]) {
262
- const existing = bm25ScoreMap.get(r.engram.id) ?? 0;
263
- if (r.bm25Score > existing) {
264
- bm25ScoreMap.set(r.engram.id, r.bm25Score);
265
- bm25EngramMap.set(r.engram.id, r.engram);
266
- }
267
- }
268
- const bm25Ranked = Array.from(bm25EngramMap.entries()).map(([id, engram]) => ({
269
- engram, bm25Score: bm25ScoreMap.get(id) ?? 0,
270
- }));
271
-
272
- // Phase 3 Two-pass fetch (0.7.9+):
273
- // Pass 1: slim fetch (id, concept, embedding only) for ALL active engrams.
274
- // Used for cosine sim + adaptive z-score stats + cheap pool filter.
275
- // Pass 2: full fetch ONLY on the survivors that pass the filter.
276
- //
277
- // AWM 0.8.xNative vector search refactor (2026-05-25): the prior slim
278
- // fetch path materialized ALL active engrams (id + concept + embedding)
279
- // for in-process cosine + z-score gating. On PGlite that meant parsing
280
- // 11K embedding vectors per recall (~200-500ms). Replaced with native
281
- // vector search: PGlite uses pgvector + ivfflat (O(log N)); SQLite uses
282
- // its slim cache + JS cosine (same as before but encapsulated). Z-score
283
- // normalization is replaced with a mode-adaptive raw-cosine floor —
284
- // simpler, faster, model-tuned for BGE-small embeddings.
285
- // D4 (2026-07-30): AWM_DISABLE_POOL_FILTER=1 was documented since 0.7.x but
286
- // never implemented after the 0.8.x native-vector refactor absorbed the
287
- // pool pre-filter. Honor its documented semantics: score ALL active
288
- // candidates (no top-K cut, no similarity floor).
289
- const POOL_FILTER_DISABLED = process.env.AWM_DISABLE_POOL_FILTER === '1';
290
- const VECTOR_TOP_K = POOL_FILTER_DISABLED ? Number.MAX_SAFE_INTEGER : Math.max(50, limit * 5);
291
-
292
- // Tokenize query once (used by scoring)
293
- const queryTokens = tokenize(query.context);
294
-
295
- // Phase 3a: native vector search across agents — top-K by cosine.
296
- // Apply a candidate floor — BGE-small unit-norm vectors typically cluster
297
- // around 0.30-0.40 even for unrelated text, so we need a floor that
298
- // distinguishes "related" from "noise" without throwing out genuine
299
- // related-but-not-identical matches.
300
- //
301
- // Tuning: targeted=0.40, exploratory=0.30. Earlier 0.55/0.45 floors were
302
- // too aggressive — they dropped Recall@5 on the 200-fact eval corpus
303
- // from 0.80 0.46 (verified 2026-05-26). BGE-small cosines for genuine
304
- // related matches commonly land 0.42-0.55, so a 0.55 floor cut them
305
- // entirely. The vectorMatch scoring floor (0.50 targeted / 0.35 exploratory)
306
- // still suppresses low-confidence matches in the final score.
307
- // Env override: AWM_SIM_CANDIDATE_FLOOR_TARGETED, AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY.
308
- const SIM_CANDIDATE_FLOOR = POOL_FILTER_DISABLED ? -1 : (adaptive.zScoreGate > 0.5
309
- ? Number(process.env.AWM_SIM_CANDIDATE_FLOOR_TARGETED ?? 0.40)
310
- : Number(process.env.AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY ?? 0.30));
311
- const rawCosineSims = new Map<string, number>();
312
- const vectorHits: Engram[] = [];
313
- if (queryEmbedding) {
314
- for (const aid of agentIds) {
315
- try {
316
- const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
317
- for (const h of hits) {
318
- // pgvector cosine distance: 0 = identical, 2 = opposite.
319
- // For unit-norm BGE vectors, distance 1 - cosineSimilarity.
320
- const sim = 1 - h.distance;
321
- // pgvector returns sorted ASC by distance (DESC by sim) — break once
322
- // we drop below the candidate floor; all subsequent hits will too.
323
- if (sim < SIM_CANDIDATE_FLOOR) break;
324
- if (!rawCosineSims.has(h.engram.id)) {
325
- rawCosineSims.set(h.engram.id, sim);
326
- vectorHits.push(h.engram);
327
- }
328
- }
329
- } catch {
330
- // Vector search unavailable — fall back to BM25-only ranking
331
- }
332
- }
333
- }
334
-
335
- // Survivors = BM25 candidates vector candidates.
336
- // The slim-fetch jaccard-fallback path is dropped: in practice it surfaced
337
- // <1% of candidates that BM25 + vector missed, and on PGlite cost more
338
- // than the candidates were worth. Concept jaccard signal still contributes
339
- // via textMatch in scoring.
340
- const survivorIds = new Set<string>();
341
- for (const r of bm25Ranked) survivorIds.add(r.engram.id);
342
- for (const e of vectorHits) survivorIds.add(e.id);
343
-
344
- // Hydrate full engrams. BM25 and vector hits arrive pre-hydrated; nothing
345
- // else needs fetching in the normal path.
346
- const candidateMap = new Map<string, Engram>();
347
- for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
348
- for (const e of vectorHits) candidateMap.set(e.id, e);
349
- let candidates = Array.from(candidateMap.values());
350
-
351
- // Filter by memory type if specified
352
- if (query.memoryType) {
353
- candidates = candidates.filter(e => e.memoryType === query.memoryType);
354
- }
355
-
356
- // ── ENTITY-AWARE CANDIDATE FETCH (2026-06, fixes the buried 2-hop / sparse-cue gap) ──
357
- // A query like "codename for my main project" strongly recalls "main project = Atlas" but the
358
- // ANSWER ("Atlas codename = Magpie") is a different-vocabulary attribute fact that falls out
359
- // of the candidate pool — and rerank can't rescue what isn't in the pool. AWM doesn't form
360
- // entity-co-occurrence edges, so graph-walk can't bridge it either. Fix: from the strongest
361
- // seeds, pull the proper-noun ENTITIES that aren't already in the query, run ONE cheap local
362
- // BM25 pass on them, and add the hits to the candidate pool. RECALL-ONLY: these only become
363
- // candidates the reranker can consider — the final top-K stays rerank-gated, so this never
364
- // surfaces facts you don't need (preserves AWM's precision-first design). Fast (one BM25
365
- // call), local (no network/LLM), capped. DEFAULT-OFF (opt-in AWM_ENTITY_FETCH=1): verified
366
- // recall-only pool-injection is INSUFFICIENT at scale — the buried fact enters the pool but
367
- // still ranks below distractors against the vocab-mismatched original query, and a ranking
368
- // boost would trade precision (against AWM's precision-first design). The design-aligned fix
369
- // is HARNESS-side multi-hop decomposition (LLM chains sequential single-hop recalls). Kept
370
- // opt-in for future spreading-activation experiments.
371
- if (process.env.AWM_ENTITY_FETCH === '1' && candidates.length > 0) {
372
- const ENT_SEEDS = Number(process.env.AWM_ENTITY_FETCH_SEEDS ?? 5);
373
- const ENT_CAP = Number(process.env.AWM_ENTITY_FETCH_CAP ?? 30);
374
- 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']);
375
- const seeds = candidates
376
- .map(e => ({ e, s: Math.max(bm25ScoreMap.get(e.id) ?? 0, rawCosineSims.get(e.id) ?? 0) }))
377
- .sort((a, b) => b.s - a.s).slice(0, ENT_SEEDS);
378
- const ents = new Set<string>();
379
- for (const { e } of seeds) {
380
- for (const match of `${e.concept} ${e.content}`.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
381
- const w = match[0]; const lw = w.toLowerCase();
382
- if (!queryTokens.has(lw) && !STOPCAPS.has(lw)) ents.add(w);
383
- }
384
- }
385
- if (ents.size > 0) {
386
- try {
387
- const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
388
- let added = 0;
389
- for (const h of entHits) {
390
- if (added >= ENT_CAP) break;
391
- const n = h.engram;
392
- if (candidateMap.has(n.id)) continue;
393
- if (n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
394
- if (query.memoryType && n.memoryType !== query.memoryType) continue;
395
- candidateMap.set(n.id, n);
396
- bm25ScoreMap.set(n.id, Math.max(bm25ScoreMap.get(n.id) ?? 0, h.bm25Score)); // scoring sees it
397
- added++;
398
- }
399
- if (added > 0) candidates = Array.from(candidateMap.values());
400
- } catch { /* best-effort: entity fetch never breaks recall */ }
401
- }
402
- }
403
-
404
- // ── D11 (2026-07-30): ENTITY-INDEX CANDIDATE INJECTION (default-OFF, AWM_ENTITY_INDEX_FETCH=1) ──
405
- // The D9 inverted index resolves query-NAMED entities ("Seetha", "ticket 18999") to every
406
- // engram indexed under them — deterministic exact lookup, immune to embedding/BM25 vocabulary
407
- // mismatch. Injected candidates get NO score boost; instead they are GUARANTEED a rerank
408
- // audition (exempt from the topN cut, the minScore pool filter, the rerank-pool slice, and
409
- // the rerank-skip heuristic) and the cross-encoder alone decides whether they surface.
410
- // This is the guarded successor to AWM_ENTITY_FETCH above, whose failure mode was measured:
411
- // injected gold entered the pool but was cut before the reranker ever scored it. Bounded
412
- // (AWM_ENTITY_INDEX_CAP, default 12) so the audition costs at most one extra rerank batch.
413
- const injectedIds = new Set<string>();
414
- if (process.env.AWM_ENTITY_INDEX_FETCH === '1') {
415
- try {
416
- const IDX_CAP = Number(process.env.AWM_ENTITY_INDEX_CAP ?? 12);
417
- const IDX_QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose',
418
- 'the', 'this', 'that', 'and', 'but', 'for', 'did', 'does', 'is', 'are', 'was', 'were',
419
- 'tell', 'about', 'they', 'them', 'write', 'reply', 'list', 'please', 'remember', 'confirm']);
420
- const terms = new Set<string>();
421
- for (const m of query.context.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
422
- const w = m[0].toLowerCase();
423
- if (!IDX_QSTOP.has(w)) terms.add(w);
424
- }
425
- for (const m of query.context.matchAll(/\b\d{4,}\b/g)) terms.add(m[0]); // bare ids: tickets, members, events
426
- let added = 0;
427
- for (const term of Array.from(terms).slice(0, 4)) {
428
- if (added >= IDX_CAP) break;
429
- const entities = await this.store.searchEntities(term, 6);
430
- for (const entity of entities) {
431
- if (added >= IDX_CAP) break;
432
- for (const agentId of agentIds) {
433
- if (added >= IDX_CAP) break;
434
- for (const id of await this.store.getEngramIdsByEntity(entity, agentId)) {
435
- if (added >= IDX_CAP) break;
436
- if (injectedIds.has(id)) continue;
437
- // Already a candidate via BM25/vector? Still VOUCH for it: a weak in-pool
438
- // candidate the index confirms would otherwise die at the minScore/topN
439
- // cuts unguarded. The audition guarantee is about the index match, not
440
- // about how the candidate entered the pool.
441
- const inPool = candidateMap.get(id);
442
- if (inPool) {
443
- if (inPool.stage === 'active' && !(inPool as any).retracted && !inPool.supersededBy) { injectedIds.add(id); added++; }
444
- continue;
445
- }
446
- const n = await this.store.getEngram(id);
447
- if (!n || n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
448
- if (query.memoryType && n.memoryType !== query.memoryType) continue;
449
- candidateMap.set(id, n);
450
- injectedIds.add(id);
451
- added++;
452
- }
453
- }
454
- }
455
- }
456
- if (added > 0) candidates = Array.from(candidateMap.values());
457
- } catch { /* index injection never breaks recall */ }
458
- }
459
-
460
- if (candidates.length === 0) return [];
461
-
462
- // Phase 3b: Score each candidate with per-phase breakdown
463
- // Candidates are already filtered (the slim-pool filter ran before hydration).
464
- //
465
- // Optimization (0.7.12+): the scoring loop only reads `count` and `sumWeight`
466
- // from associations. Use a SQL aggregate (GROUP BY) to fetch scalar stats
467
- // instead of materializing thousands of Association objects. Phase-breakdown
468
- // (post-0.7.10) showed this saves ~200ms (222ms ~20ms).
469
- //
470
- // Graph walk still needs full Association objects, but it operates on the
471
- // top-N (~30 candidates) its on-demand `getAssociationsFor` lookups are
472
- // cheap (<5ms total).
473
- const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
474
- const scored = candidates.map(engram => {
475
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
476
- const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
477
-
478
- // --- Text relevance (keyword signals) ---
479
-
480
- // Signal 1: BM25 continuous score (0-1, from FTS5 rank)
481
- const bm25Score = bm25ScoreMap.get(engram.id) ?? 0;
482
-
483
- // Signal 2: Jaccard similarity with stopword filtering
484
- const conceptTokens = tokenize(engram.concept);
485
- const contentTokens = tokenize(engram.content);
486
- const conceptJaccard = jaccard(queryTokens, conceptTokens);
487
- const contentJaccard = jaccard(queryTokens, contentTokens);
488
- const jaccardScore = 0.6 * conceptJaccard + 0.4 * contentJaccard;
489
-
490
- // Signal 3: Concept exact match bonus (up to 0.3)
491
- const conceptOverlap = conceptTokens.size > 0
492
- ? [...conceptTokens].filter(w => queryTokens.has(w)).length / conceptTokens.size
493
- : 0;
494
- const conceptBonus = conceptOverlap * 0.3;
495
-
496
- const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
497
-
498
- // --- Vector similarity (semantic signal) ---
499
- // AWM 0.8.x: model-tuned raw-cosine floor in place of z-score normalization.
500
- // For BGE-small unit-norm vectors: unrelated ~0.3, related 0.5-0.7, near-duplicate 0.85+.
501
- // Floor adapts to query mode: targeted=0.50 (stricter), exploratory=0.35 (looser).
502
- let vectorMatch = 0;
503
- const rawSim = rawCosineSims.get(engram.id);
504
- if (rawSim !== undefined && rawSim > 0) {
505
- const SIM_FLOOR = adaptive.zScoreGate > 0.5
506
- ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
507
- : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
508
- if (rawSim > SIM_FLOOR) {
509
- // Map [SIM_FLOOR, 1.0] [0, 1] linearly with cap at 1.0.
510
- vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
511
- }
512
- }
513
-
514
- // Combined text match: weighted blend of keyword and vector signals.
515
- // BM25 is better at lexical discrimination; vector is better at semantic matching.
516
- // When both are non-zero, blend favors the stronger signal with a boost.
517
- const textMatch = keywordMatch > 0 && vectorMatch > 0
518
- ? 0.5 * Math.max(keywordMatch, vectorMatch) + 0.3 * Math.min(keywordMatch, vectorMatch) + 0.2 * (keywordMatch * vectorMatch)
519
- : Math.max(keywordMatch, vectorMatch);
520
-
521
- // --- Temporal signals ---
522
-
523
- // ACT-R decay confidence + replay modulated (synaptic tagging)
524
- // High-confidence memories decay slower. Heavily-accessed memories also resist decay.
525
- // Base exponent adapts to query mode: targeted (0.6) decays harder, exploratory (0.3) preserves older memories.
526
- const confMod = 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
527
- const replayMod = Math.min(0.1, 0.05 * Math.log1p(engram.accessCount));
528
- const decayExponent = Math.max(0.2, adaptive.decayExponentBase - confMod - replayMod);
529
- const decayScore = baseLevelActivation(engram.accessCount, ageDays, decayExponent);
530
-
531
- // Hebbian boost from associations capped to prevent popular memories
532
- // from dominating regardless of query relevance
533
- const rawHebbian = stats.count > 0 ? stats.sumWeight / stats.count : 0;
534
- const hebbianBoost = Math.min(rawHebbian, 0.5);
535
-
536
- // Centrality signal well-connected memories (high weighted degree)
537
- // get a small boost. This makes consolidation edges matter for retrieval.
538
- // Log-scaled to prevent hub domination: 10 edges ≈ 0.05 boost, 50 ≈ 0.08
539
- const centralityBoost = stats.count > 0
540
- ? Math.min(0.1, 0.03 * Math.log1p(stats.sumWeight))
541
- : 0;
542
-
543
- // Confidence gate — multiplicative quality signal
544
- const confidenceGate = engram.confidence;
545
-
546
- // Feedback bonus memories confirmed useful via explicit feedback get a
547
- // direct additive boost. Models how a senior dev "just knows" certain things
548
- // are important. Confidence > 0.6 means at least 2+ positive feedbacks.
549
- // Scales: conf 0.6→0.03, 0.7→0.06, 0.8→0.09, 1.0→0.15
550
- const feedbackBonus = engram.confidence > 0.55
551
- ? Math.min(0.15, 0.3 * Math.max(0, engram.confidence - 0.5))
552
- : 0;
553
-
554
- // --- Composite score: relevance-gated additive ---
555
- // Text/temporal weights adapt to query mode: targeted (0.75/0.25), exploratory (0.4/0.6).
556
- const temporalNorm = Math.min(softplus(decayScore + hebbianBoost), 3.0) / 3.0;
557
- const relevanceGate = textMatch > 0.1 ? textMatch : 0.0; // Proportional gate
558
- const composite = (adaptive.textWeight * textMatch + adaptive.temporalWeight * temporalNorm * relevanceGate + centralityBoost * relevanceGate + feedbackBonus * relevanceGate) * confidenceGate;
559
-
560
- const phaseScores: PhaseScores = {
561
- textMatch,
562
- vectorMatch,
563
- decayScore,
564
- hebbianBoost,
565
- graphBoost: 0, // Filled in phase 5
566
- confidenceGate,
567
- composite,
568
- rerankerScore: 0, // Filled in phase 7
569
- };
570
-
571
- // associations: empty in 0.7.12+ — graph walk lazy-fetches per engram on demand
572
- return { engram, score: composite, phaseScores, associations: [] as Association[] };
573
- });
574
-
575
- // Phase 3.5: Rocchio pseudo-relevance feedback — expand query with top result terms
576
- // then re-search BM25 to find candidates that keyword search missed
577
- const preSorted = scored.sort((a, b) => b.score - a.score);
578
- const topForFeedback = preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1);
579
- if (topForFeedback.length > 0) {
580
- const feedbackTerms = new Set<string>();
581
- for (const item of topForFeedback) {
582
- const tokens = tokenize(item.engram.content);
583
- for (const t of tokens) {
584
- if (!queryTokens.has(t) && t.length >= 4) feedbackTerms.add(t);
585
- }
586
- }
587
- // Take top 5 feedback terms and re-search
588
- const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
589
- if (extraTerms) {
590
- const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
591
- for (const r of feedbackBM25) {
592
- if (!candidateMap.has(r.engram.id)) {
593
- candidateMap.set(r.engram.id, r.engram);
594
- // Score the new candidate
595
- const engram = r.engram;
596
- const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
597
- const associations = await this.store.getAssociationsFor(engram.id);
598
- const cTokens = tokenize(engram.concept);
599
- const ctTokens = tokenize(engram.content);
600
- const cJac = jaccard(queryTokens, cTokens);
601
- const ctJac = jaccard(queryTokens, ctTokens);
602
- const jSc = 0.6 * cJac + 0.4 * ctJac;
603
- const cOvlp = cTokens.size > 0 ? [...cTokens].filter(w => queryTokens.has(w)).length / cTokens.size : 0;
604
- const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
605
- let vm = 0;
606
- const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
607
- if (rs > 0) {
608
- const SIM_FLOOR = adaptive.zScoreGate > 0.5
609
- ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
610
- : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
611
- if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
612
- }
613
- const tm = km > 0 && vm > 0
614
- ? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
615
- : Math.max(km, vm);
616
- const ds = baseLevelActivation(engram.accessCount, ageDays);
617
- const rh = associations.length > 0 ? Math.min(associations.reduce((s, a) => s + a.weight, 0) / associations.length, 0.5) : 0;
618
- const tn = Math.min(softplus(ds + rh), 3.0) / 3.0;
619
- const rg = tm > 0.1 ? tm : 0.0;
620
- const comp = (adaptive.textWeight * tm + adaptive.temporalWeight * tn * rg) * engram.confidence;
621
- scored.push({ engram, score: comp, phaseScores: { textMatch: tm, vectorMatch: vm, decayScore: ds, hebbianBoost: rh, graphBoost: 0, confidenceGate: engram.confidence, composite: comp, rerankerScore: 0 }, associations });
622
- }
623
- }
624
- }
625
- }
626
-
627
- // Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
628
- // with the most query-relevant result. The original intent: surface candidates
629
- // that DON'T match the query text directly but share entities with the top
630
- // text-match anchor ("she said something" → bridge to entities of the recent
631
- // speaker named "she"). This is LATERAL relevance, not direct relevance.
632
- //
633
- // 2026-05-26: Added textMatch gate. Without it, when many engrams share entity
634
- // tags AND have similar text matches (e.g., 10 near-clones of the same concept),
635
- // the bridge boost would push the 8 non-anchor clones above the 2 anchors —
636
- // an inversion of the genuine top match. The eval Retrieval suite caught this
637
- // (Recall@5 0.80 → 0.46). Gate: only boost candidates whose textMatch is
638
- // meaningfully below the anchor's. They actually need the lateral boost.
639
- // Env override: AWM_DISABLE_ENTITY_BRIDGE=1 to skip this phase entirely.
640
- if (!process.env.AWM_DISABLE_ENTITY_BRIDGE)
641
- {
642
- // Find the result with the highest textMatch (most query-relevant, not just highest score)
643
- // Gate: only bridge when anchor has meaningful text relevance (> 0.15)
644
- // Adaptive: scale bridge boost inversely with candidate pool size to prevent
645
- // over-boosting in large memory pools where many items share entity tags
646
- const sortedByTextMatch = scored
647
- .filter(r => r.phaseScores.textMatch > 0.15)
648
- .sort((a, b) => b.phaseScores.textMatch - a.phaseScores.textMatch);
649
-
650
- // Bridge from top 2 text-matched results (IDF handles weighting)
651
- const bridgeAnchors = sortedByTextMatch.slice(0, 2);
652
-
653
- if (bridgeAnchors.length > 0) {
654
- const entityTags = new Set<string>();
655
- const anchorIds = new Set(bridgeAnchors.map(r => r.engram.id));
656
-
657
- for (const item of bridgeAnchors) {
658
- for (const tag of item.engram.tags) {
659
- const t = tag.toLowerCase();
660
- // Skip non-entity tags: turn IDs, session tags, dialogue IDs, generic speaker labels
661
- if (/^t\d+$/.test(t) || t.startsWith('session-') || t.startsWith('dia_') || t.length < 3) continue;
662
- if (/^speaker\d*$/.test(t)) continue; // Generic speaker labels are too broad
663
- // Auto-tagger `cat:` category tags are too broad to bridge on (they'd link
664
- // every "cat:work" memory laterally); they stay for BM25 recall only. The
665
- // precise `entity:` proper-noun tags are kept as bridges.
666
- if (t.startsWith('cat:')) continue;
667
- entityTags.add(t);
668
- }
669
- }
670
-
671
- // Document frequency filter: remove tags appearing in >30% of items (too common)
672
- // This prevents speaker names in 2-person conversations from being used as bridges
673
- if (entityTags.size > 0 && scored.length > 10) {
674
- const tagFreqs = new Map<string, number>();
675
- for (const item of scored) {
676
- const seen = new Set<string>();
677
- for (const tag of item.engram.tags) {
678
- const t = tag.toLowerCase();
679
- if (entityTags.has(t) && !seen.has(t)) {
680
- seen.add(t);
681
- tagFreqs.set(t, (tagFreqs.get(t) ?? 0) + 1);
682
- }
683
- }
684
- }
685
- const maxFreq = scored.length * 0.30;
686
- for (const [tag, freq] of tagFreqs) {
687
- if (freq > maxFreq) entityTags.delete(tag);
688
- }
689
- }
690
-
691
- if (entityTags.size > 0) {
692
- // Anchor's textMatch sets the scale. Bridge boost magnitude is
693
- // proportional to the gap between anchor and candidate textMatch:
694
- // - candidate near anchor (a near-clone of the anchor) small gap near-zero boost
695
- // - candidate far below anchor (genuine lateral relevance) large gap → full boost
696
- // Without this scaling, dense same-concept corpora flip the genuine
697
- // top-1 below its 8-9 near-clones (eval Recall@5 0.80 → 0.46 verified
698
- // 2026-05-26). The scaling keeps the lateral-relevance behavior
699
- // (which is what helps the AB test) without inverting the genuine
700
- // text-match winner.
701
- const anchorTextMax = bridgeAnchors[0].phaseScores.textMatch;
702
-
703
- for (const item of scored) {
704
- if (anchorIds.has(item.engram.id)) continue;
705
-
706
- const engramTags = new Set(item.engram.tags.map((t: string) => t.toLowerCase()));
707
- let sharedEntities = 0;
708
- for (const et of entityTags) {
709
- if (engramTags.has(et)) sharedEntities++;
710
- }
711
-
712
- if (sharedEntities > 0) {
713
- // Gap scaling: 1.0 when candidateText << anchorText, 0 when equal.
714
- // Clamped to [0, 1]. Anchors with textMatch 0 fall back to flat boost.
715
- const gapScale = anchorTextMax > 0
716
- ? Math.max(0, Math.min(1, (anchorTextMax - item.phaseScores.textMatch) / anchorTextMax))
717
- : 1;
718
- const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4) * gapScale;
719
- if (bridgeBoost > 0) {
720
- item.score += bridgeBoost;
721
- item.phaseScores.composite += bridgeBoost;
722
- item.phaseScores.graphBoost += bridgeBoost;
723
- }
724
- }
725
- }
726
- }
727
- }
728
- }
729
-
730
- // Phase 3.75: Query-conditioned entity bridge (default-OFF, AWM_QUERY_BRIDGE=1).
731
- //
732
- // The anchor-based bridge above (Phase 3.7) is query-BLIND: it bridges from the
733
- // top text-match result's tags and a document-frequency filter DELETES common
734
- // tags (e.g. a speaker present in >30% of turns). That is exactly backwards for
735
- // attribution / entity-named queries: if the user asks "what does Caroline think
736
- // about the trip" or "who said the trip moved to Saturday", the speaker/entity the
737
- // query NAMES is the single most valuable bridge — its corpus frequency is
738
- // irrelevant. This phase extracts proper-noun entities from the QUERY and boosts
739
- // candidates whose tags match them, regardless of frequency, gated by topical
740
- // relevance (textMatch floor) so it surfaces "Caroline's turns ABOUT the trip"
741
- // rather than every Caroline turn. Boost folds into composite → survives rerank.
742
- // Recall-only re-ranking of in-pool candidates (no injection) → low precision risk.
743
- if (process.env.AWM_QUERY_BRIDGE === '1') {
744
- const QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose', 'whom',
745
- 'the', 'this', 'that', 'these', 'those', 'and', 'but', 'for', 'did', 'does', 'is', 'are',
746
- 'was', 'were', 'how', 'tell', 'about', 'they', 'them']);
747
- const qEnts = new Set<string>();
748
- for (const m of query.context.matchAll(/\b[A-Z][a-zA-Z]{2,}\b/g)) {
749
- const w = m[0].toLowerCase();
750
- if (!QSTOP.has(w)) qEnts.add(w);
751
- }
752
- if (qEnts.size > 0) {
753
- const QC_WEIGHT = Number(process.env.AWM_QUERY_BRIDGE_WEIGHT ?? 0.4);
754
- const QC_CAP = Number(process.env.AWM_QUERY_BRIDGE_CAP ?? 0.4);
755
- const QC_FLOOR = Number(process.env.AWM_QUERY_BRIDGE_FLOOR ?? 0.1);
756
- for (const item of scored) {
757
- if (item.phaseScores.textMatch < QC_FLOOR) continue; // only re-rank topically-relevant candidates
758
- let matches = 0;
759
- for (const tag of item.engram.tags) {
760
- const t = tag.toLowerCase();
761
- const val = t.startsWith('entity:') ? t.slice(7) : t;
762
- // match whole-tag or any word of a multi-word entity ("marcus lee" ← "Marcus")
763
- if (qEnts.has(val) || val.split(/\s+/).some(w => qEnts.has(w))) { matches++; }
764
- }
765
- if (matches > 0) {
766
- // Relevance-modulated: scale by the candidate's topical relevance so
767
- // "named-entity AND on-topic" wins big while "named-entity but off-topic
768
- // chatter" (a common speaker tag on an irrelevant turn) gets almost
769
- // nothing. Without this, a broad speaker tag floods the top with the
770
- // person's unrelated turns (verified 2026-06-16 _query-bridge-verify).
771
- const boost = Math.min(matches * QC_WEIGHT * item.phaseScores.textMatch, QC_CAP);
772
- item.score += boost;
773
- item.phaseScores.composite += boost;
774
- item.phaseScores.graphBoost += boost;
775
- }
776
- }
777
- }
778
- }
779
-
780
- // Phase 4+5: Graph walk — boost engrams connected to high-scoring ones
781
- // Only walk from engrams that had text relevance (composite > 0 pre-walk)
782
- const sorted = scored.sort((a, b) => b.score - a.score);
783
- // Candidate breadth carried into graph-walk + rerank. Default 8×limit (was 3×).
784
- // WHY 8× (2026-06-16): the pipeline-attribution trace showed ~50% of answerable LoCoMo
785
- // queries had gold that CLEARED the floor (89%) but was squeezed out HERE by the
786
- // decay-compressed composite before the (high-lift, +3.29) reranker saw it — the
787
- // dominant loss. Widening this + the rerank pool (below) lifted official LoCoMo
788
- // 22.7→25.1 (every recall category up), 4-suite unchanged, recall 35→77ms; small
789
- // adversarial cost 73.4→71.0 (a fixed step, recoverable on the abstention gate).
790
- // Tunable via AWM_TOPN_MULT.
791
- const topNMult = Number(process.env.AWM_TOPN_MULT ?? 8);
792
- const topN = sorted.slice(0, limit * topNMult);
793
- // D11 guard 1/4: injected entity-index candidates ride into the graph/rerank stages even
794
- // when their (boost-free) composite fell below the topN cut.
795
- if (injectedIds.size > 0) {
796
- for (const item of sorted.slice(limit * topNMult)) {
797
- if (injectedIds.has(item.engram.id)) topN.push(item);
798
- }
799
- }
800
- if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
801
- await this.spreadActivation(topN);
802
- } else {
803
- await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
804
- }
805
-
806
- // Phase 6: Initial filter and sort for re-ranking pool
807
- // (D11 guard 2/4: injected entity-index candidates are exempt from the minScore floor.)
808
- const pool = topN
809
- .filter(r => r.score >= minScore || injectedIds.has(r.engram.id))
810
- .sort((a, b) => b.score - a.score);
811
-
812
- // Phase 7: Cross-encoder re-ranking — scores (query, passage) pairs directly
813
- // Widens the pool to find relevant results that keyword matching missed.
814
- // How many candidates reach the cross-encoder. Default max(limit*4, 40) widened
815
- // from max(limit*2, 15) on 2026-06-16. WHY: the reranker rarely loses gold (0.5%) and
816
- // lifts it +3.29, but the weak composite was only passing it ~35% of retrievable gold;
817
- // feeding it more recovered the dominant lost@pool/scoring bucket. Validated knee on
818
- // recall × precision × latency (pool 40 25.1% LoCoMo / 71.0% adv / 77ms; pool 60 adds
819
- // only +0.6pp for +33ms). The composite is now a CHEAP WIDE PRE-FILTER, not the ranker —
820
- // the reranker does discrimination on a wide pool. Tunable via AWM_RERANK_POOL.
821
- const rerankPoolSize = Number(process.env.AWM_RERANK_POOL ?? Math.max(limit * 4, 40));
822
- const rerankPool = pool.slice(0, rerankPoolSize);
823
- // D11 guard 3/4: injected entity-index candidates always reach the cross-encoder.
824
- if (injectedIds.size > 0) {
825
- for (const item of pool.slice(rerankPoolSize)) {
826
- if (injectedIds.has(item.engram.id)) rerankPool.push(item);
827
- }
828
- }
829
-
830
- // Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
831
- // strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
832
- // is unlikely to change the top result. Skipping saves ~300ms of wall-clock per
833
- // recall on simple queries (40% of post-0.7.9 floor was reranker).
834
- //
835
- // Conservative gate (only skip when very confident):
836
- // - top-1 textMatch >= 0.8 (high BM25 + jaccard agreement)
837
- // - top-1 score is at least 1. top-2 score (clear separation)
838
- // - rerankPool size <= limit*2 (small pool reranker has less to do)
839
- //
840
- // Ambiguous queries (close BM25 scores, weak top-1, large pool) still go through
841
- // the reranker. Disable this heuristic via AWM_DISABLE_RERANK_SKIP=1.
842
- let rerankSkipped = false;
843
- // (D11 guard 4/4: never skip the reranker when entity-index candidates were injected —
844
- // the audition IS the rerank; skipping would return them unjudged or drop them.)
845
- if (useReranker && rerankPool.length >= 2 && injectedIds.size === 0 && process.env.AWM_DISABLE_RERANK_SKIP !== '1') {
846
- const top1 = rerankPool[0];
847
- const top2 = rerankPool[1];
848
- const t1Text = top1.phaseScores.textMatch;
849
- const t1Score = top1.score;
850
- const t2Score = top2.score;
851
- const cleanWinner = t1Text >= 0.8 && t1Score >= 1.5 * Math.max(t2Score, 0.01);
852
- const smallPool = rerankPool.length <= Math.max(limit * 2, 20);
853
- if (cleanWinner && smallPool) {
854
- rerankSkipped = true;
855
- }
856
- }
857
-
858
- if (useReranker && !rerankSkipped && rerankPool.length > 0) {
859
- try {
860
- // Truncate content to ~400 chars before rerank (0.7.14+). Cross-encoders
861
- // have a 512-token max anyway and pad to the longest passage in the batch;
862
- // sending full content (some 5000+ chars) means everything pads to ~512
863
- // tokens. Truncation drops tokenization + inference cost ~3-4× on long
864
- // memory pools without losing rerank signal the concept + first 400
865
- // chars carry the core meaning.
866
- const passages = rerankPool.map(r => {
867
- const concept = r.engram.concept;
868
- const content = r.engram.content.length > 400
869
- ? r.engram.content.slice(0, 400)
870
- : r.engram.content;
871
- return `${concept}: ${content}`;
872
- });
873
- let rerankTimer: ReturnType<typeof setTimeout> | undefined;
874
- const rerankResults = await Promise.race([
875
- rerank(query.context, passages),
876
- new Promise<never>((_, reject) => { rerankTimer = setTimeout(() => reject(new Error('reranker timeout')), 10000); }),
877
- ]).finally(() => { if (rerankTimer) clearTimeout(rerankTimer); });
878
-
879
- // Adaptive reranker blend (Codex recommendation):
880
- // When BM25/text signals are strong, trust them more; when weak, lean on reranker.
881
- const bm25Max = Math.max(...rerankPool.map(r => r.phaseScores.textMatch));
882
- const rerankWeight = Math.min(0.7, Math.max(0.3, 0.3 + 0.4 * (1 - bm25Max)));
883
- const compositeWeight = 1 - rerankWeight;
884
-
885
- for (const rr of rerankResults) {
886
- const item = rerankPool[rr.index];
887
- item.phaseScores.rerankerScore = rr.score;
888
- item.score = compositeWeight * item.phaseScores.composite + rerankWeight * rr.score;
889
- }
890
- } catch {
891
- // Re-ranker unavailable keep original scores
892
- }
893
- }
894
-
895
- // Phase 8: Multi-channel OOD detection + agreement gate
896
- // Requires at least 2 of 3 retrieval channels to agree the query is in-domain.
897
- if (rerankPool.length >= 3) {
898
- // Abstention gate scope (2026-06-16): the in-domain channel maxes used to be taken
899
- // over the ENTIRE rerankPool. Once that pool was widened for recall (pool 40), a lone
900
- // high-scoring distractor inflated the maxes and defeated abstention on adversarial
901
- // queries (adversarial 73.4→71.0). Fix: judge in-domain on the post-rerank TOP-K —
902
- // the items we'd actually return so pool width (recall) is decoupled from the
903
- // abstention decision (precision). AWM_ABSTAIN_GATE_K controls K (0 = legacy
904
- // whole-pool behavior). Answerable queries are unaffected: the gold is in the top-K
905
- // and supplies the in-domain signal; only borderline distractors deep in a wide pool
906
- // stop counting.
907
- // Default 5 (2026-06-16): judge in-domain on the post-rerank top-5. With the widened
908
- // rerank pool, basing it on the whole pool (legacy AWM_ABSTAIN_GATE_K=0) let a lone
909
- // deep distractor defeat abstention; top-5 restored adversarial 71.0→74.9 (ABOVE the
910
- // pre-widening 73.4) at ZERO recall cost (answerable categories unchanged) — the
911
- // precision half of the two-dial pool-widening win.
912
- const gateK = Number(process.env.AWM_ABSTAIN_GATE_K ?? 5);
913
- const gatePool = gateK > 0
914
- ? [...rerankPool].sort((a, b) => b.score - a.score).slice(0, gateK)
915
- : rerankPool;
916
-
917
- const topBM25 = Math.max(...gatePool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
918
- const topVector = queryEmbedding
919
- ? Math.max(...gatePool.map(r => r.phaseScores.vectorMatch))
920
- : 0;
921
- const topReranker = Math.max(...gatePool.map(r => r.phaseScores.rerankerScore));
922
-
923
- const bm25Ok = topBM25 > 0.3;
924
- const vectorOk = topVector > 0.05;
925
- const rerankerOk = topReranker > 0.25;
926
- const channelsAgreeing = (bm25Ok ? 1 : 0) + (vectorOk ? 1 : 0) + (rerankerOk ? 1 : 0);
927
-
928
- const rerankerScores = gatePool
929
- .map(r => r.phaseScores.rerankerScore)
930
- .sort((a, b) => b - a);
931
- const margin = rerankerScores.length >= 2
932
- ? rerankerScores[0] - rerankerScores[1]
933
- : rerankerScores[0];
934
-
935
- const cosineSimValues = Array.from(rawCosineSims.values());
936
- const maxRawCosine = queryEmbedding && cosineSimValues.length > 0
937
- ? Math.max(...cosineSimValues)
938
- : 1.0;
939
-
940
- // Required-channels for hard abstention:
941
- // abstention-explicit (caller passed abstentionThreshold > 0): 3 of 3
942
- // default: 2 of 3 — precision-first
943
- const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
944
-
945
- // Hard abstention: fewer than required channels agree AND semantic match weak.
946
- // After the 2.0.x vector refactor we no longer compute z-score; threshold
947
- // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
948
- const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
949
- if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
950
- return [];
951
- }
952
-
953
- // Soft penalty: only 1 channel agrees or margin is thin
954
- if (channelsAgreeing < 2 || margin < 0.05) {
955
- if (abstentionThreshold > 0) {
956
- return [];
957
- }
958
- for (const item of rerankPool) {
959
- item.score *= 0.4;
960
- }
961
- }
962
- }
963
-
964
- // Legacy abstention gate (when explicitly requested)
965
- if (abstentionThreshold > 0 && rerankPool.length >= 3) {
966
- const topRerankerScores = rerankPool
967
- .map(r => r.phaseScores.rerankerScore)
968
- .sort((a, b) => b - a)
969
- .slice(0, 5);
970
- const maxScore = topRerankerScores[0];
971
- const meanScore = topRerankerScores.reduce((s, v) => s + v, 0) / topRerankerScores.length;
972
- const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
973
-
974
- if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
975
- return [];
976
- }
977
- }
978
-
979
- // Phase 8c: Supersession penalty — superseded memories are deprioritized.
980
- // They aren't wrong (that's retraction), just outdated.
981
- for (const item of rerankPool) {
982
- if (item.engram.supersededBy) {
983
- item.score *= 0.15; // Severe down-rank — successor should dominate
984
- }
985
- }
986
-
987
- // Phase 9: Final sort, limit, explain, attach confidence
988
- const finalRanked = rerankPool.sort((a, b) => b.score - a.score);
989
- const topScoresForConfidence = finalRanked.slice(0, 10).map(r => r.score);
990
- const { confidence } = computeRecallConfidence(topScoresForConfidence);
991
-
992
- // Opt-in confidence-based abstention. When the caller sets
993
- // `requireConfidence`, we return [] if the score-distribution shape
994
- // indicates a low-quality recall (noisy or best-of-bad-bunch).
995
- // Independent of the channel-agreement abstention earlier — that path
996
- // requires the reranker; this one uses just the final composite scores.
997
- if (requireConfidence > 0 && confidence < requireConfidence) {
998
- return [];
999
- }
1000
-
1001
- // Confidence-adaptive output granularity (Paper 3: cognitive teaming).
1002
- // 'full' → no summary (default, current behavior).
1003
- // 'compact' every result gets a short summary (COMPACT_LEN chars).
1004
- // 'auto' → if confidence AUTO_THRESHOLD: top result gets a full-length
1005
- // summary, lower-ranked results get compact summaries. If
1006
- // confidence is lower, all results get compact summaries.
1007
- const granularity = query.granularity ?? 'full';
1008
- const COMPACT_LEN = Number(process.env.AWM_GRANULARITY_COMPACT_LEN ?? 200);
1009
- const FULL_LEN = Number(process.env.AWM_GRANULARITY_FULL_LEN ?? 1000);
1010
- const AUTO_THRESHOLD = Number(process.env.AWM_GRANULARITY_AUTO_THRESHOLD ?? 0.4);
1011
-
1012
- // Snippet token list reuse the activation queryTokens (Set<string>),
1013
- // filtered to ≥2 chars to drop noise. queryTokens was tokenized at line
1014
- // 273 via tokenize() with stopword stripping already applied.
1015
- const snippetTokens = Array.from(queryTokens).filter(t => t.length >= 2);
1016
-
1017
- // Find the densest window of `len` chars in `content` that contains the
1018
- // most query-token matches. Falls back to head if no tokens match.
1019
- const summaryFor = (content: string, len: number): string => {
1020
- if (content.length <= len) return content;
1021
- if (snippetTokens.length === 0) return content.slice(0, len).trimEnd() + '…';
1022
-
1023
- const lower = content.toLowerCase();
1024
- const hits: number[] = [];
1025
- for (const tok of snippetTokens) {
1026
- let from = 0;
1027
- while (true) {
1028
- const idx = lower.indexOf(tok, from);
1029
- if (idx < 0) break;
1030
- hits.push(idx);
1031
- from = idx + tok.length;
1032
- }
1033
- }
1034
- if (hits.length === 0) return content.slice(0, len).trimEnd() + '…';
1035
- hits.sort((a, b) => a - b);
1036
-
1037
- // Find the window of size `len` that contains the most hits, by
1038
- // sliding a window anchored on each hit.
1039
- let bestStart = hits[0];
1040
- let bestCount = 0;
1041
- for (let i = 0; i < hits.length; i++) {
1042
- const start = Math.max(0, hits[i] - Math.floor(len / 4));
1043
- let count = 0;
1044
- for (let j = i; j < hits.length; j++) {
1045
- if (hits[j] - start < len) count++;
1046
- else break;
1047
- }
1048
- if (count > bestCount) {
1049
- bestCount = count;
1050
- bestStart = start;
1051
- }
1052
- }
1053
-
1054
- // Reserve characters for the ellipses we're about to add so the final
1055
- // string stays within `len`. Without this, both '…' prefix + suffix
1056
- // would push the snippet to len+2 chars.
1057
- const hasPrefix = bestStart > 0;
1058
- const tentativeEnd = Math.min(content.length, bestStart + len);
1059
- const hasSuffix = tentativeEnd < content.length;
1060
- const reserveForEllipses = (hasPrefix ? 1 : 0) + (hasSuffix ? 1 : 0);
1061
- const bodyLen = Math.max(0, len - reserveForEllipses);
1062
- const end = Math.min(content.length, bestStart + bodyLen);
1063
- const startAdj = Math.max(0, end - bodyLen);
1064
- let snippet = content.slice(startAdj, end);
1065
- if (startAdj > 0) snippet = '' + snippet.trimStart();
1066
- if (end < content.length) snippet = snippet.trimEnd() + '…';
1067
- return snippet;
1068
- };
1069
-
1070
- const results: ActivationResult[] = finalRanked
1071
- .slice(0, limit)
1072
- .map((r, idx) => {
1073
- let summary: string | undefined;
1074
- if (granularity === 'compact') {
1075
- summary = summaryFor(r.engram.content, COMPACT_LEN);
1076
- } else if (granularity === 'auto') {
1077
- if (confidence >= AUTO_THRESHOLD && idx === 0) {
1078
- summary = summaryFor(r.engram.content, FULL_LEN);
1079
- } else {
1080
- summary = summaryFor(r.engram.content, COMPACT_LEN);
1081
- }
1082
- }
1083
- return {
1084
- engram: r.engram,
1085
- score: r.score,
1086
- phaseScores: r.phaseScores,
1087
- why: this.explain(r.phaseScores, r.engram, r.associations),
1088
- associations: r.associations,
1089
- confidence,
1090
- ...(summary !== undefined && { summary }),
1091
- };
1092
- });
1093
-
1094
- const activatedIds = results.map(r => r.engram.id);
1095
-
1096
- // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
1097
- if (!query.internal) {
1098
- for (const id of activatedIds) {
1099
- await this.store.touchEngram(id);
1100
- }
1101
- this.coActivationBuffer.pushBatch(activatedIds);
1102
- // Validation-gated Hebbian: defer strengthening until feedback arrives
1103
- const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
1104
- const seen = new Set<string>();
1105
- const uniquePairs: [string, string][] = [];
1106
- for (const [a, b] of pairs) {
1107
- const key = a < b ? `${a}:${b}` : `${b}:${a}`;
1108
- if (!seen.has(key)) { seen.add(key); uniquePairs.push([a, b]); }
1109
- }
1110
- this.validationGate.addPending(activatedIds, uniquePairs);
1111
-
1112
- // Log activation event for eval
1113
- const latencyMs = performance.now() - startTime;
1114
- await this.store.logActivationEvent({
1115
- id: randomUUID(),
1116
- agentId: query.agentId,
1117
- timestamp: new Date(),
1118
- context: query.context,
1119
- resultsReturned: results.length,
1120
- topScore: results.length > 0 ? results[0].score : 0,
1121
- latencyMs,
1122
- engramIds: activatedIds,
1123
- });
1124
- }
1125
-
1126
- return results;
1127
- }
1128
-
1129
- /**
1130
- * Multi-graph traversal (MAGMA-inspired).
1131
- *
1132
- * Instead of one beam search over all edge types, runs independent traversals
1133
- * per graph type with specialized scoring, then fuses the boosts.
1134
- *
1135
- * Four sub-graphs:
1136
- * - Semantic (connection + hebbian edges) standard weight-based walk
1137
- * - Temporal (temporal edges) recency-weighted (favor recent connections)
1138
- * - Causal (causal edges) → full weight walk (causal links are high-value)
1139
- * - Entity (bridge edges) → entity-tag-weighted walk
1140
- *
1141
- * Each sub-graph contributes independently to the final graph boost,
1142
- * weighted by configurable per-graph weights.
1143
- */
1144
- private static readonly GRAPH_WEIGHTS = {
1145
- semantic: 0.40, // connection + hebbian
1146
- temporal: 0.20, // temporal edges
1147
- causal: 0.25, // causal edges (high-value signal)
1148
- entity: 0.15, // bridge edges
1149
- };
1150
-
1151
- private async graphWalk(
1152
- scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
1153
- maxDepth: number,
1154
- hopPenalty: number,
1155
- beamWidth: number = 15
1156
- ): Promise<void> {
1157
- const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
1158
- const MAX_TOTAL_BOOST = 0.25;
1159
-
1160
- // Define which edge types belong to each sub-graph
1161
- const graphTypes: Record<string, string[]> = {
1162
- semantic: ['connection', 'hebbian'],
1163
- temporal: ['temporal'],
1164
- causal: ['causal'],
1165
- entity: ['bridge'],
1166
- };
1167
-
1168
- // Run independent traversals per sub-graph, accumulate boosts
1169
- const boostAccum = new Map<string, number>(); // engramId → total boost
1170
-
1171
- for (const [graphName, edgeTypes] of Object.entries(graphTypes)) {
1172
- const graphWeight = ActivationEngine.GRAPH_WEIGHTS[graphName as keyof typeof ActivationEngine.GRAPH_WEIGHTS];
1173
- const subBeamWidth = Math.max(3, Math.ceil(beamWidth * graphWeight));
1174
-
1175
- // Seed beam
1176
- const beam = scored
1177
- .filter(item => item.phaseScores.textMatch >= 0.15)
1178
- .sort((a, b) => b.score - a.score)
1179
- .slice(0, subBeamWidth);
1180
-
1181
- const explored = new Set<string>();
1182
-
1183
- for (let depth = 0; depth < maxDepth; depth++) {
1184
- const nextBeam: typeof beam = [];
1185
-
1186
- for (const item of beam) {
1187
- if (explored.has(item.engram.id)) continue;
1188
- explored.add(item.engram.id);
1189
-
1190
- const associations = item.associations.length > 0
1191
- ? item.associations
1192
- : await this.store.getAssociationsFor(item.engram.id);
1193
-
1194
- // Filter to only edges of this sub-graph type
1195
- const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
1196
-
1197
- for (const assoc of relevantEdges) {
1198
- const neighborId = assoc.fromEngramId === item.engram.id
1199
- ? assoc.toEngramId
1200
- : assoc.fromEngramId;
1201
-
1202
- if (explored.has(neighborId)) continue;
1203
- const neighbor = scoreMap.get(neighborId);
1204
- if (!neighbor) continue;
1205
-
1206
- const relevanceFloor = depth === 0 ? 0.1 : 0.05;
1207
- if (neighbor.phaseScores.textMatch < relevanceFloor) continue;
1208
-
1209
- // Path score with graph-type-specific weighting
1210
- const normalizedWeight = Math.min(assoc.weight, 5.0) / 5.0;
1211
- let pathScore = item.score * normalizedWeight * Math.pow(hopPenalty, depth + 1);
1212
-
1213
- // Causal edges get a 2x boost — they represent verified reasoning chains
1214
- if (graphName === 'causal') pathScore *= 2.0;
1215
-
1216
- // Weight by sub-graph importance
1217
- const boost = Math.min(pathScore * graphWeight, 0.15);
1218
- if (boost > 0.001) {
1219
- boostAccum.set(neighborId, (boostAccum.get(neighborId) ?? 0) + boost);
1220
- nextBeam.push(neighbor);
1221
- }
1222
- }
1223
- }
1224
-
1225
- if (nextBeam.length === 0) break;
1226
- beam.length = 0;
1227
- beam.push(...nextBeam
1228
- .sort((a, b) => b.score - a.score)
1229
- .slice(0, subBeamWidth)
1230
- );
1231
- }
1232
- }
1233
-
1234
- // Apply fused boosts to scored items
1235
- for (const [engramId, totalBoost] of boostAccum) {
1236
- const item = scoreMap.get(engramId);
1237
- if (!item) continue;
1238
- const capped = Math.min(totalBoost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
1239
- if (capped > 0.001) {
1240
- item.score += capped;
1241
- item.phaseScores.graphBoost += capped;
1242
- }
1243
- }
1244
- }
1245
-
1246
- /**
1247
- * R2 bounded iterative spreading activation (PPR / SYNAPSE-style).
1248
- *
1249
- * Default-OFF (`AWM_SPREAD=1`). The principled, in-AWM successor to the
1250
- * fixed depth-2 beam `graphWalk` and the MWA harness bridge: it runs T
1251
- * iterations of **fan-normalized** spreading with **lateral inhibition** and
1252
- * a **restart** term (Personalized PageRank) over the association graph
1253
- * richest when R1's `AWM_BROAD_EDGES` entity edges are present.
1254
- *
1255
- * Two effects, both precision-guarded:
1256
- * - **Boost** existing pool candidates by the *graph evidence* they receive
1257
- * (convergent multi-path activation, not a single spurious hop).
1258
- * - **Inject** (`AWM_SPREAD_INJECT=1`) strongly-reached *out-of-pool*
1259
- * engrams as recall-only candidates so the reranker can see true
1260
- * multi-hop bridges that BM25/vector missed. Their composite carries the
1261
- * graph-activation signal (blended with rerank), which is what lets a
1262
- * vocab-mismatched bridge surface where the prior `AWM_ENTITY_FETCH`
1263
- * recall-only injection (rerank-only) could not.
1264
- *
1265
- * Precision is preserved because spreading is **seeded by the initial
1266
- * retrieval**: adversarial "is this even in memory?" queries have weak/empty
1267
- * seeds, so nothing meaningful propagates and abstention is unaffected.
1268
- * Fan-normalization stops hubs from flooding; lateral inhibition keeps only
1269
- * the top-M activated nodes per step; a node budget bounds cost; injected
1270
- * candidates stay rerank-gated and the pool-level OOD agreement gate is
1271
- * unaffected (seeds still supply the BM25/vector channels).
1272
- */
1273
- private async spreadActivation(
1274
- topN: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
1275
- ): Promise<void> {
1276
- const T = Number(process.env.AWM_SPREAD_ITERS ?? 3);
1277
- const delta = Number(process.env.AWM_SPREAD_DAMPING ?? 0.5);
1278
- const NODE_BUDGET = Number(process.env.AWM_SPREAD_BUDGET ?? 64);
1279
- const BOOST_SCALE = Number(process.env.AWM_SPREAD_BOOST ?? 0.4);
1280
- const PER_NODE_CAP = 0.15;
1281
- const MAX_TOTAL_BOOST = 0.25;
1282
- const inject = process.env.AWM_SPREAD_INJECT === '1';
1283
- const INJECT_THRESHOLD = Number(process.env.AWM_SPREAD_INJECT_MIN ?? 0.08);
1284
- const INJECT_BUDGET = Number(process.env.AWM_SPREAD_INJECT_CAP ?? 8);
1285
- const INJECT_SCALE = Number(process.env.AWM_SPREAD_INJECT_SCALE ?? 1.0);
1286
- const EPS = 0.01;
1287
- // 'invalidation' edges link superseded→replacement; excluded so spreading
1288
- // never pulls stale facts back in.
1289
- const allowed = new Set(['connection', 'hebbian', 'temporal', 'causal', 'bridge']);
1290
-
1291
- const scoreMap = new Map(topN.map(s => [s.engram.id, s]));
1292
-
1293
- // Seed activation from query-relevant candidates (textMatch gate), normalized to [0,1].
1294
- const seed = new Map<string, number>();
1295
- let maxSeed = 0;
1296
- for (const item of topN) {
1297
- if (item.phaseScores.textMatch >= 0.15) {
1298
- const v = Math.max(0, item.score);
1299
- seed.set(item.engram.id, v);
1300
- if (v > maxSeed) maxSeed = v;
1301
- }
1302
- }
1303
- if (seed.size === 0 || maxSeed <= 0) return;
1304
- for (const [k, v] of seed) seed.set(k, v / maxSeed);
1305
-
1306
- const edgeCache = new Map<string, Association[]>();
1307
- const getEdges = async (id: string): Promise<Association[]> => {
1308
- let e = edgeCache.get(id);
1309
- if (!e) {
1310
- e = (await this.store.getAssociationsFor(id)).filter(a => allowed.has(a.type));
1311
- edgeCache.set(id, e);
1312
- }
1313
- return e;
1314
- };
1315
-
1316
- let act = new Map(seed);
1317
- // Cumulative inflow received from the graph (excludes a node's own seed)
1318
- // this is the multi-hop "evidence" signal used for boost + injection.
1319
- const graphActivation = new Map<string, number>();
1320
-
1321
- for (let t = 0; t < T; t++) {
1322
- const inflow = new Map<string, number>();
1323
- for (const [u, au] of act) {
1324
- if (au <= EPS) continue;
1325
- const edges = await getEdges(u);
1326
- if (edges.length === 0) continue;
1327
- let fan = 0;
1328
- for (const e of edges) fan += Math.max(0, e.weight);
1329
- if (fan <= 0) continue;
1330
- for (const e of edges) {
1331
- const v = e.fromEngramId === u ? e.toEngramId : e.fromEngramId;
1332
- const share = Math.max(0, e.weight) / fan; // fan-effect normalization
1333
- inflow.set(v, (inflow.get(v) ?? 0) + au * share);
1334
- }
1335
- }
1336
- // D11: SYNAPSE-style lateral inhibition (divisive normalization) — competing
1337
- // receivers suppress each other WITHIN an iteration: a node keeps its inflow in
1338
- // proportion to how much of the iteration's total it earned, so a few strongly-
1339
- // reached nodes stay strong while diffuse spread mass is crushed. This is the
1340
- // published fix for the displacing-gold regression that parked AWM_SPREAD.
1341
- // λ=0 (default) disables; enable for the re-test with AWM_SPREAD_INHIBIT=0.3.
1342
- const INHIBIT = Number(process.env.AWM_SPREAD_INHIBIT ?? 0);
1343
- if (INHIBIT > 0 && inflow.size > 1) {
1344
- let total = 0;
1345
- for (const f of inflow.values()) total += f;
1346
- for (const [v, f] of inflow) inflow.set(v, f * (f / (f + INHIBIT * (total - f))));
1347
- }
1348
- for (const [v, f] of inflow) graphActivation.set(v, (graphActivation.get(v) ?? 0) + f);
1349
-
1350
- // Restart (PPR): blend propagated inflow with the original seed vector.
1351
- const newAct = new Map<string, number>();
1352
- const keys = new Set<string>([...act.keys(), ...inflow.keys()]);
1353
- for (const v of keys) {
1354
- const val = (1 - delta) * (seed.get(v) ?? 0) + delta * (inflow.get(v) ?? 0);
1355
- if (val > EPS) newAct.set(v, val);
1356
- }
1357
- // Lateral inhibition: keep only the top-M activated nodes (competition + cost bound).
1358
- if (newAct.size > NODE_BUDGET) {
1359
- act = new Map([...newAct.entries()].sort((a, b) => b[1] - a[1]).slice(0, NODE_BUDGET));
1360
- } else {
1361
- act = newAct;
1362
- }
1363
- }
1364
-
1365
- // Normalize graph evidence to [0,1] so the boost magnitude is scale-stable
1366
- // (raw `ga` accumulates across iterations + bidirectional edges, so its
1367
- // absolute scale varies with graph density). The top-reached node maps to 1.0.
1368
- let maxGa = 0;
1369
- for (const ga of graphActivation.values()) if (ga > maxGa) maxGa = ga;
1370
- const normGa = (id: string): number => (maxGa > 0 ? (graphActivation.get(id) ?? 0) / maxGa : 0);
1371
-
1372
- if (process.env.AWM_SPREAD_DEBUG === '1') {
1373
- const top = [...graphActivation.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
1374
- process.stderr.write(`[spread] seeds=${seed.size} reached=${graphActivation.size} inPool=${[...graphActivation.keys()].filter(id => scoreMap.has(id)).length} maxGa=${maxGa.toFixed(3)}\n`);
1375
- for (const [id, ga] of top) {
1376
- const e = scoreMap.get(id)?.engram;
1377
- 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`);
1378
- }
1379
- }
1380
-
1381
- // Boost existing candidates by the (normalized) graph evidence they received.
1382
- // Folded into `composite` (NOT just `score`) so it survives the rerank blend
1383
- // the reranker recomputes score from composite, so a score-only boost would
1384
- // be discarded. This makes spreading a first-class multi-hop ranking signal.
1385
- for (const [id] of graphActivation) {
1386
- const item = scoreMap.get(id);
1387
- if (!item) continue;
1388
- const boost = Math.min(normGa(id) * BOOST_SCALE, PER_NODE_CAP);
1389
- const capped = Math.min(boost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
1390
- if (capped > 0.001) {
1391
- item.phaseScores.composite += capped;
1392
- item.score += capped;
1393
- item.phaseScores.graphBoost += capped;
1394
- }
1395
- }
1396
-
1397
- // Inject strongly-reached out-of-pool engrams as recall-only candidates.
1398
- if (inject) {
1399
- const reached = [...graphActivation.entries()]
1400
- .filter(([id]) => !scoreMap.has(id) && normGa(id) >= INJECT_THRESHOLD)
1401
- .sort((a, b) => b[1] - a[1])
1402
- .slice(0, INJECT_BUDGET);
1403
- for (const [id] of reached) {
1404
- const engram = await this.store.getEngram(id);
1405
- if (!engram || engram.stage !== 'active') continue;
1406
- if ((engram as unknown as { retracted?: boolean }).retracted || engram.supersededBy) continue;
1407
- const composite = Math.min(0.6, normGa(id) * INJECT_SCALE);
1408
- const phaseScores: PhaseScores = {
1409
- textMatch: 0,
1410
- vectorMatch: 0,
1411
- decayScore: 0,
1412
- hebbianBoost: 0,
1413
- graphBoost: composite,
1414
- confidenceGate: engram.confidence,
1415
- composite,
1416
- rerankerScore: 0,
1417
- };
1418
- const injected = { engram, score: composite, phaseScores, associations: [] as Association[] };
1419
- topN.push(injected);
1420
- scoreMap.set(id, injected);
1421
- }
1422
- }
1423
- }
1424
-
1425
- /**
1426
- * Resolve validation-gated Hebbian update for a specific engram.
1427
- * Called by memory_feedback — only strengthens when retrieval was useful.
1428
- * This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
1429
- */
1430
- async resolveHebbianFeedback(engramId: string, useful: boolean): Promise<number> {
1431
- const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
1432
- let updated = 0;
1433
-
1434
- for (const [a, b] of pairs) {
1435
- const existing = (await this.store.getAssociation(a, b)) ?? (await this.store.getAssociation(b, a));
1436
- const currentWeight = existing?.weight ?? 0.1;
1437
-
1438
- if (signal > 0) {
1439
- // Positive feedback strengthen
1440
- const newWeight = strengthenAssociation(currentWeight, signal);
1441
- await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1442
- await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1443
- } else {
1444
- // Negative feedback → slight weakening (decay by signal magnitude)
1445
- const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
1446
- await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1447
- await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1448
- }
1449
- updated++;
1450
- }
1451
- return updated;
1452
- }
1453
-
1454
- private explain(phases: PhaseScores, engram: Engram, associations: Association[]): string {
1455
- const parts: string[] = [];
1456
- parts.push(`composite=${phases.composite.toFixed(3)}`);
1457
- if (phases.textMatch > 0) parts.push(`text=${phases.textMatch.toFixed(2)}`);
1458
- if (phases.vectorMatch > 0) parts.push(`vector=${phases.vectorMatch.toFixed(2)}`);
1459
- parts.push(`decay=${phases.decayScore.toFixed(2)}`);
1460
- if (phases.hebbianBoost > 0) parts.push(`hebbian=${phases.hebbianBoost.toFixed(2)}`);
1461
- if (phases.graphBoost > 0) parts.push(`graph=${phases.graphBoost.toFixed(2)}`);
1462
- if (phases.rerankerScore > 0) parts.push(`reranker=${phases.rerankerScore.toFixed(2)}`);
1463
- parts.push(`conf=${phases.confidenceGate.toFixed(2)}`);
1464
- parts.push(`access=${engram.accessCount}`);
1465
- if (associations.length > 0) parts.push(`edges=${associations.length}`);
1466
- return parts.join(' | ');
1467
- }
1468
- }
1
+ // Copyright 2026 Robert Winter / Complete Ideas
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Activation Pipeline — the core retrieval engine.
5
+ *
6
+ * Cognitive retrieval pipeline — phases as shipped (D4 honesty pass 2026-07-30;
7
+ * default-OFF phases are marked, since operators tune from this header):
8
+ * -1. Coreference expansion (conditional: query contains pronouns)
9
+ * 0. Query expansion (flan-t5-small; caller-gated, MCP default ON)
10
+ * 1. Vector embedding (bge-small 384d)
11
+ * 2. Parallel retrieval (dual FTS5/BM25 + native vector top-K)
12
+ * 3. Per-candidate scoring (BM25, Jaccard, cosine floor, ACT-R decay,
13
+ * Hebbian boost, confidence gate — computed together in phase 3b)
14
+ * 3.5 Rocchio pseudo-relevance feedback (conditional on BM25 signal)
15
+ * 3.7 Entity-bridge boost (default ON; AWM_DISABLE_ENTITY_BRIDGE=1)
16
+ * 3.5 Entity-index candidate injection (DEFAULT OFF; AWM_ENTITY_INDEX_FETCH=1 —
17
+ * D11 2026-07-30: D9 inverted-index lookup of query-named entities; injected
18
+ * candidates get no boost but a guaranteed rerank audition)
19
+ * 3.75 Query-conditioned entity bridge (DEFAULT OFF; AWM_QUERY_BRIDGE=1)
20
+ * 4/5 Spreading-activation graph walk (DEFAULT OFF; AWM_SPREAD=1 — parked
21
+ * after displacing-gold regressions; see design-proposals D11)
22
+ * 6. Filter + sort into rerank pool (wide pool since 0.9.0)
23
+ * 7. Cross-encoder rerank (ms-marco; clear-winner skip unless
24
+ * AWM_DISABLE_RERANK_SKIP=1)
25
+ * 8. Multi-channel OOD detection + agreement gate; supersession penalty;
26
+ * abstention enforced only when caller passes require_confidence
27
+ * 9. Final sort, granularity, confidence attach
28
+ *
29
+ * Logs activation events for eval metrics.
30
+ */
31
+
32
+ import { randomUUID } from 'node:crypto';
33
+ import { baseLevelActivation, softplus } from '../core/decay.js';
34
+ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from '../core/hebbian.js';
35
+ import { embed, cosineSimilarity } from '../core/embeddings.js';
36
+ import { rerank } from '../core/reranker.js';
37
+ import { expandQuery } from '../core/query-expander.js';
38
+ import { computeRecallConfidence } from './confidence.js';
39
+ import type {
40
+ Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
41
+ } from '../types/index.js';
42
+ import type { IEngramStore as EngramStore } from '../storage/store.js';
43
+ import { reorderByReranker, rerank2Enabled, rerank2WindowSize } from '../core/rerank2.js';
44
+ import { buildRerankPassage, rerankTruncation, rerankWindowMode } from '../core/rerank-window.js';
45
+ import { parseTemporal, temporalEnabled, temporalBoost, type TemporalMatch } from '../core/temporal-query.js';
46
+ import { aliasTermsFor } from '../core/alias-map.js';
47
+
48
+ // ─── Query-adaptive pipeline parameters ───────────────────────────
49
+
50
+ interface AdaptiveParams {
51
+ mode: 'targeted' | 'exploratory' | 'balanced';
52
+ textWeight: number; // Weight for text match in composite (default 0.6)
53
+ temporalWeight: number; // Weight for temporal signals (default 0.4)
54
+ decayExponentBase: number;// Base ACT-R decay exponent (default 0.5)
55
+ zScoreGate: number; // Z-score threshold for vector match (default 0.5)
56
+ beamWidth: number; // Graph walk beam width (default 15)
57
+ hopPenalty: number; // Graph walk hop penalty (default 0.3)
58
+ }
59
+
60
+ const ADAPTIVE_PRESETS: Record<'targeted' | 'exploratory' | 'balanced', AdaptiveParams> = {
61
+ targeted: {
62
+ mode: 'targeted',
63
+ textWeight: 0.75, // Heavy BM25/keyword emphasis
64
+ temporalWeight: 0.25,
65
+ decayExponentBase: 0.6, // Stronger decay — recent exact matches matter more
66
+ zScoreGate: 0.8, // Strict vector gate — only strong semantic matches
67
+ beamWidth: 3, // Narrow beam — don't wander
68
+ hopPenalty: 0.2, // Steeper hop penalty
69
+ },
70
+ exploratory: {
71
+ mode: 'exploratory',
72
+ textWeight: 0.4, // Lower BM25 weight
73
+ temporalWeight: 0.6, // Lean on temporal/associative signals
74
+ decayExponentBase: 0.3, // Weaker decay — surface older memories
75
+ zScoreGate: 0.3, // Relaxed vector gate — cast wider net
76
+ beamWidth: 20, // Wide beam — explore associations
77
+ hopPenalty: 0.4, // Gentler hop penalty
78
+ },
79
+ balanced: {
80
+ mode: 'balanced',
81
+ textWeight: 0.6,
82
+ temporalWeight: 0.4,
83
+ decayExponentBase: 0.5,
84
+ zScoreGate: 0.5,
85
+ beamWidth: 15,
86
+ hopPenalty: 0.3,
87
+ },
88
+ };
89
+
90
+ /**
91
+ * Classify a query as targeted, exploratory, or balanced.
92
+ *
93
+ * Targeted signals: identifiers (PROJ-123, camelCase, snake_case, UUIDs),
94
+ * short queries (< 8 words), quoted strings, file paths.
95
+ *
96
+ * Exploratory signals: question words, long queries (> 15 words),
97
+ * vague modifiers ("general", "overview", "about", "related to").
98
+ */
99
+ function classifyQuery(context: string): 'targeted' | 'exploratory' | 'balanced' {
100
+ const words = context.split(/\s+/).filter(w => w.length > 0);
101
+ const wordCount = words.length;
102
+ const lower = context.toLowerCase();
103
+
104
+ let targetedScore = 0;
105
+ let exploratoryScore = 0;
106
+
107
+ // Identifier patterns
108
+ if (/[A-Z]+-\d+/.test(context)) targetedScore += 2; // PROJ-123
109
+ if (/[a-z][A-Z]/.test(context)) targetedScore += 1; // camelCase
110
+ if (/\w+_\w+/.test(context)) targetedScore += 1; // snake_case
111
+ if (/[0-9a-f]{8}-[0-9a-f]{4}/.test(lower)) targetedScore += 2; // UUID fragment
112
+ if (/["']/.test(context)) targetedScore += 1; // Quoted strings
113
+ if (/[\/\\]/.test(context)) targetedScore += 1; // File paths
114
+ if (/\.\w{1,4}$/.test(context.trim())) targetedScore += 1; // File extensions
115
+
116
+ // Short queries are usually targeted
117
+ if (wordCount <= 5) targetedScore += 2;
118
+ else if (wordCount <= 8) targetedScore += 1;
119
+
120
+ // Question words / exploratory modifiers
121
+ if (/^(what|how|why|when|where|who|which|can|does|is|are)\b/i.test(context)) exploratoryScore += 1;
122
+ if (/\b(overview|general|about|related|similar|like|broad|concept|idea|approach|strategy)\b/i.test(lower)) exploratoryScore += 1;
123
+ if (/\b(any|all|everything|anything)\b/i.test(lower)) exploratoryScore += 1;
124
+
125
+ // Long queries are usually exploratory
126
+ if (wordCount > 15) exploratoryScore += 2;
127
+ else if (wordCount > 10) exploratoryScore += 1;
128
+
129
+ const diff = targetedScore - exploratoryScore;
130
+ if (diff >= 2) return 'targeted';
131
+ if (diff <= -2) return 'exploratory';
132
+ return 'balanced';
133
+ }
134
+
135
+ function resolveAdaptiveParams(query: ActivationQuery): AdaptiveParams {
136
+ const mode = query.mode ?? 'auto';
137
+ if (mode !== 'auto') return ADAPTIVE_PRESETS[mode];
138
+ const classified = classifyQuery(query.context);
139
+ return ADAPTIVE_PRESETS[classified];
140
+ }
141
+
142
+ /**
143
+ * Common English stopwords filtered from similarity calculations.
144
+ * These words carry no semantic signal for memory retrieval.
145
+ */
146
+ const STOPWORDS = new Set([
147
+ 'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had',
148
+ 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'been', 'from', 'that',
149
+ 'this', 'with', 'they', 'will', 'each', 'make', 'like', 'then', 'than',
150
+ 'them', 'some', 'what', 'when', 'where', 'which', 'who', 'how', 'use',
151
+ 'into', 'does', 'also', 'just', 'more', 'over', 'such', 'only', 'very',
152
+ 'about', 'after', 'being', 'between', 'could', 'during', 'before',
153
+ 'should', 'would', 'their', 'there', 'these', 'those', 'through',
154
+ 'because', 'using', 'other',
155
+ ]);
156
+
157
+ function tokenize(text: string): Set<string> {
158
+ return new Set(
159
+ text.toLowerCase()
160
+ .split(/\s+/)
161
+ .filter(w => w.length > 2 && !STOPWORDS.has(w))
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Jaccard similarity between two word sets: |intersection| / |union|
167
+ */
168
+ function jaccard(a: Set<string>, b: Set<string>): number {
169
+ if (a.size === 0 || b.size === 0) return 0;
170
+ let intersection = 0;
171
+ for (const w of a) {
172
+ if (b.has(w)) intersection++;
173
+ }
174
+ const union = a.size + b.size - intersection;
175
+ return union > 0 ? intersection / union : 0;
176
+ }
177
+
178
+ export class ActivationEngine {
179
+ private store: EngramStore;
180
+ private coActivationBuffer: CoActivationBuffer;
181
+ readonly validationGate: ValidationGatedBuffer;
182
+
183
+ constructor(store: EngramStore) {
184
+ this.store = store;
185
+ this.coActivationBuffer = new CoActivationBuffer(50);
186
+ this.validationGate = new ValidationGatedBuffer();
187
+ }
188
+
189
+ /**
190
+ * Activate retrieve the most cognitively relevant engrams for a context.
191
+ */
192
+ async activate(query: ActivationQuery): Promise<ActivationResult[]> {
193
+ const startTime = performance.now();
194
+ const limit = query.limit ?? 10;
195
+ const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
196
+ const useReranker = query.useReranker ?? true;
197
+ // Default OFF (rerank-only): query expansion ~doubles recall latency (it inflates the rerank
198
+ // candidate pool) for no measured accuracy gain — validated no-regression on LoCoMo
199
+ // (overall 22.8→22.7, adversarial 73.5→73.4), the 4-suite eval (identical), and the MWA
200
+ // gauntlet. Callers can still opt in per-query (useExpansion:true); AWM_DEFAULT_EXPANSION=1
201
+ // restores expansion-by-default globally as an escape hatch.
202
+ const useExpansion = query.useExpansion ?? process.env.AWM_DEFAULT_EXPANSION === '1';
203
+ const abstentionThreshold = query.abstentionThreshold ?? 0;
204
+ const requireConfidence = query.requireConfidence ?? 0;
205
+ const adaptive = resolveAdaptiveParams(query);
206
+
207
+ // Resolve workspace scope: if workspace is set, search across all agents in that workspace
208
+ const agentIds = query.workspace
209
+ ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
210
+ : [query.agentId];
211
+ const isWorkspaceScoped = agentIds.length > 1;
212
+
213
+ // ── Phase -2: temporal expression ──
214
+ // Nothing downstream parses dates, so "from last Thursday" was being spent
215
+ // as ordinary BM25 tokens: diluting the subject terms and matching `date=`
216
+ // tags corpus-wide. Measured on the real store, adding a temporal cue COST
217
+ // 3-8pp of success@1 the most selective thing the user said was a
218
+ // penalty. Stripping it recovers that; the window then PREFERS (never
219
+ // filters) candidates from the implied period. Oracle ceiling is +36.6pp.
220
+ let queryContext = query.context;
221
+ let temporal: TemporalMatch | null = null;
222
+ if (temporalEnabled()) {
223
+ temporal = parseTemporal(query.context, query.asOf ?? Date.now());
224
+ // Strict no-op when nothing matched, and never strip the query to nothing.
225
+ if (temporal && temporal.stripped.trim().length >= 3) {
226
+ queryContext = temporal.stripped;
227
+ } else if (temporal) {
228
+ temporal = null;
229
+ }
230
+ }
231
+ const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
232
+ if (pronounPattern.test(queryContext)) {
233
+ try {
234
+ const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
235
+ .sort((a, b) => b.accessCount - a.accessCount)
236
+ .slice(0, 10)
237
+ .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
238
+ .filter((v, i, a) => a.indexOf(v) === i)
239
+ .slice(0, 5);
240
+ if (recentEntities.length > 0) {
241
+ queryContext = `${queryContext} ${recentEntities.join(' ')}`;
242
+ }
243
+ } catch { /* non-fatal */ }
244
+ }
245
+
246
+ // Phase 0: Query expansion — add related terms to improve BM25 recall
247
+ let searchContext = queryContext;
248
+
249
+ // Project-dialect aliases. Added to the BM25 SEARCH STRING ONLY — never to
250
+ // `queryTokens`, which drives textMatch scoring below. That scope IS
251
+ // guardrail 4 ("require at least one original query term"): a candidate
252
+ // matching only alias terms arrives with near-zero textMatch and is dropped
253
+ // by the existing minScore gate, while one that also matches an original
254
+ // term scores normally. Aliases buy REACH; original terms still decide
255
+ // RELEVANCE, so a hub alias cannot drag an irrelevant memory to the top.
256
+ const aliasAdded = aliasTermsFor(queryContext);
257
+ if (aliasAdded.length > 0) searchContext = `${searchContext} ${aliasAdded.join(' ')}`;
258
+ if (useExpansion) {
259
+ let timer: ReturnType<typeof setTimeout> | undefined;
260
+ try {
261
+ searchContext = await Promise.race([
262
+ expandQuery(queryContext),
263
+ new Promise<string>((_, reject) => { timer = setTimeout(() => reject(new Error('expansion timeout')), 5000); }),
264
+ ]);
265
+ } catch {
266
+ // Expansion unavailable or timed out — use original query
267
+ } finally {
268
+ if (timer) clearTimeout(timer);
269
+ }
270
+ }
271
+
272
+ // Phase 1: Embed query for vector similarity (uses coref-expanded context)
273
+ let queryEmbedding: number[] | null = null;
274
+ try {
275
+ queryEmbedding = await embed(queryContext);
276
+ } catch {
277
+ // Embedding unavailablefall back to text-only matching
278
+ }
279
+
280
+ // Phase 2: Parallel retrieval dual BM25 + all active engrams
281
+ // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
282
+ // Uses queryContext, not query.context: temporal words must not reach BM25.
283
+ const keywordQuery = Array.from(tokenize(queryContext)).join(' ');
284
+ const bm25Keyword = keywordQuery.length > 2
285
+ ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
286
+ : [];
287
+ const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
288
+
289
+ // Merge: take the best BM25 score per engram from either pass
290
+ const bm25ScoreMap = new Map<string, number>();
291
+ const bm25EngramMap = new Map<string, any>();
292
+ for (const r of [...bm25Keyword, ...bm25Expanded]) {
293
+ const existing = bm25ScoreMap.get(r.engram.id) ?? 0;
294
+ if (r.bm25Score > existing) {
295
+ bm25ScoreMap.set(r.engram.id, r.bm25Score);
296
+ bm25EngramMap.set(r.engram.id, r.engram);
297
+ }
298
+ }
299
+ const bm25Ranked = Array.from(bm25EngramMap.entries()).map(([id, engram]) => ({
300
+ engram, bm25Score: bm25ScoreMap.get(id) ?? 0,
301
+ }));
302
+
303
+ // Phase 3 Two-pass fetch (0.7.9+):
304
+ // Pass 1: slim fetch (id, concept, embedding only) for ALL active engrams.
305
+ // Used for cosine sim + adaptive z-score stats + cheap pool filter.
306
+ // Pass 2: full fetch ONLY on the survivors that pass the filter.
307
+ //
308
+ // AWM 0.8.x Native vector search refactor (2026-05-25): the prior slim
309
+ // fetch path materialized ALL active engrams (id + concept + embedding)
310
+ // for in-process cosine + z-score gating. On PGlite that meant parsing
311
+ // 11K embedding vectors per recall (~200-500ms). Replaced with native
312
+ // vector search: PGlite uses pgvector + ivfflat (O(log N)); SQLite uses
313
+ // its slim cache + JS cosine (same as before but encapsulated). Z-score
314
+ // normalization is replaced with a mode-adaptive raw-cosine floor —
315
+ // simpler, faster, model-tuned for BGE-small embeddings.
316
+ // D4 (2026-07-30): AWM_DISABLE_POOL_FILTER=1 was documented since 0.7.x but
317
+ // never implemented after the 0.8.x native-vector refactor absorbed the
318
+ // pool pre-filter. Honor its documented semantics: score ALL active
319
+ // candidates (no top-K cut, no similarity floor).
320
+ const POOL_FILTER_DISABLED = process.env.AWM_DISABLE_POOL_FILTER === '1';
321
+ const VECTOR_TOP_K = POOL_FILTER_DISABLED ? Number.MAX_SAFE_INTEGER : Math.max(50, limit * 5);
322
+
323
+ // Tokenize query once (used by scoring)
324
+ const queryTokens = tokenize(queryContext);
325
+
326
+ // Phase 3a: native vector search across agents — top-K by cosine.
327
+ // Apply a candidate floor — BGE-small unit-norm vectors typically cluster
328
+ // around 0.30-0.40 even for unrelated text, so we need a floor that
329
+ // distinguishes "related" from "noise" without throwing out genuine
330
+ // related-but-not-identical matches.
331
+ //
332
+ // Tuning: targeted=0.40, exploratory=0.30. Earlier 0.55/0.45 floors were
333
+ // too aggressive — they dropped Recall@5 on the 200-fact eval corpus
334
+ // from 0.80 → 0.46 (verified 2026-05-26). BGE-small cosines for genuine
335
+ // related matches commonly land 0.42-0.55, so a 0.55 floor cut them
336
+ // entirely. The vectorMatch scoring floor (0.50 targeted / 0.35 exploratory)
337
+ // still suppresses low-confidence matches in the final score.
338
+ // Env override: AWM_SIM_CANDIDATE_FLOOR_TARGETED, AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY.
339
+ const SIM_CANDIDATE_FLOOR = POOL_FILTER_DISABLED ? -1 : (adaptive.zScoreGate > 0.5
340
+ ? Number(process.env.AWM_SIM_CANDIDATE_FLOOR_TARGETED ?? 0.40)
341
+ : Number(process.env.AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY ?? 0.30));
342
+ const rawCosineSims = new Map<string, number>();
343
+ const vectorHits: Engram[] = [];
344
+ if (queryEmbedding) {
345
+ for (const aid of agentIds) {
346
+ try {
347
+ const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
348
+ for (const h of hits) {
349
+ // pgvector cosine distance: 0 = identical, 2 = opposite.
350
+ // For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
351
+ const sim = 1 - h.distance;
352
+ // pgvector returns sorted ASC by distance (DESC by sim) — break once
353
+ // we drop below the candidate floor; all subsequent hits will too.
354
+ if (sim < SIM_CANDIDATE_FLOOR) break;
355
+ if (!rawCosineSims.has(h.engram.id)) {
356
+ rawCosineSims.set(h.engram.id, sim);
357
+ vectorHits.push(h.engram);
358
+ }
359
+ }
360
+ } catch {
361
+ // Vector search unavailable fall back to BM25-only ranking
362
+ }
363
+ }
364
+ }
365
+
366
+ // Survivors = BM25 candidates vector candidates.
367
+ // The slim-fetch jaccard-fallback path is dropped: in practice it surfaced
368
+ // <1% of candidates that BM25 + vector missed, and on PGlite cost more
369
+ // than the candidates were worth. Concept jaccard signal still contributes
370
+ // via textMatch in scoring.
371
+ const survivorIds = new Set<string>();
372
+ for (const r of bm25Ranked) survivorIds.add(r.engram.id);
373
+ for (const e of vectorHits) survivorIds.add(e.id);
374
+
375
+ // Hydrate full engrams. BM25 and vector hits arrive pre-hydrated; nothing
376
+ // else needs fetching in the normal path.
377
+ const candidateMap = new Map<string, Engram>();
378
+ for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
379
+ for (const e of vectorHits) candidateMap.set(e.id, e);
380
+ let candidates = Array.from(candidateMap.values());
381
+
382
+ // Filter by memory type if specified
383
+ if (query.memoryType) {
384
+ candidates = candidates.filter(e => e.memoryType === query.memoryType);
385
+ }
386
+
387
+ // ── ENTITY-AWARE CANDIDATE FETCH (2026-06, fixes the buried 2-hop / sparse-cue gap) ──
388
+ // A query like "codename for my main project" strongly recalls "main project = Atlas" but the
389
+ // ANSWER ("Atlas codename = Magpie") is a different-vocabulary attribute fact that falls out
390
+ // of the candidate pool — and rerank can't rescue what isn't in the pool. AWM doesn't form
391
+ // entity-co-occurrence edges, so graph-walk can't bridge it either. Fix: from the strongest
392
+ // seeds, pull the proper-noun ENTITIES that aren't already in the query, run ONE cheap local
393
+ // BM25 pass on them, and add the hits to the candidate pool. RECALL-ONLY: these only become
394
+ // candidates the reranker can consider — the final top-K stays rerank-gated, so this never
395
+ // surfaces facts you don't need (preserves AWM's precision-first design). Fast (one BM25
396
+ // call), local (no network/LLM), capped. DEFAULT-OFF (opt-in AWM_ENTITY_FETCH=1): verified
397
+ // recall-only pool-injection is INSUFFICIENT at scale — the buried fact enters the pool but
398
+ // still ranks below distractors against the vocab-mismatched original query, and a ranking
399
+ // boost would trade precision (against AWM's precision-first design). The design-aligned fix
400
+ // is HARNESS-side multi-hop decomposition (LLM chains sequential single-hop recalls). Kept
401
+ // opt-in for future spreading-activation experiments.
402
+ if (process.env.AWM_ENTITY_FETCH === '1' && candidates.length > 0) {
403
+ const ENT_SEEDS = Number(process.env.AWM_ENTITY_FETCH_SEEDS ?? 5);
404
+ const ENT_CAP = Number(process.env.AWM_ENTITY_FETCH_CAP ?? 30);
405
+ 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']);
406
+ const seeds = candidates
407
+ .map(e => ({ e, s: Math.max(bm25ScoreMap.get(e.id) ?? 0, rawCosineSims.get(e.id) ?? 0) }))
408
+ .sort((a, b) => b.s - a.s).slice(0, ENT_SEEDS);
409
+ const ents = new Set<string>();
410
+ for (const { e } of seeds) {
411
+ for (const match of `${e.concept} ${e.content}`.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
412
+ const w = match[0]; const lw = w.toLowerCase();
413
+ if (!queryTokens.has(lw) && !STOPCAPS.has(lw)) ents.add(w);
414
+ }
415
+ }
416
+ if (ents.size > 0) {
417
+ try {
418
+ const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
419
+ let added = 0;
420
+ for (const h of entHits) {
421
+ if (added >= ENT_CAP) break;
422
+ const n = h.engram;
423
+ if (candidateMap.has(n.id)) continue;
424
+ if (n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
425
+ if (query.memoryType && n.memoryType !== query.memoryType) continue;
426
+ candidateMap.set(n.id, n);
427
+ bm25ScoreMap.set(n.id, Math.max(bm25ScoreMap.get(n.id) ?? 0, h.bm25Score)); // scoring sees it
428
+ added++;
429
+ }
430
+ if (added > 0) candidates = Array.from(candidateMap.values());
431
+ } catch { /* best-effort: entity fetch never breaks recall */ }
432
+ }
433
+ }
434
+
435
+ // ── D11 (2026-07-30): ENTITY-INDEX CANDIDATE INJECTION (default-OFF, AWM_ENTITY_INDEX_FETCH=1) ──
436
+ // The D9 inverted index resolves query-NAMED entities ("Seetha", "ticket 18999") to every
437
+ // engram indexed under them deterministic exact lookup, immune to embedding/BM25 vocabulary
438
+ // mismatch. Injected candidates get NO score boost; instead they are GUARANTEED a rerank
439
+ // audition (exempt from the topN cut, the minScore pool filter, the rerank-pool slice, and
440
+ // the rerank-skip heuristic) and the cross-encoder alone decides whether they surface.
441
+ // This is the guarded successor to AWM_ENTITY_FETCH above, whose failure mode was measured:
442
+ // injected gold entered the pool but was cut before the reranker ever scored it. Bounded
443
+ // (AWM_ENTITY_INDEX_CAP, default 12) so the audition costs at most one extra rerank batch.
444
+ const injectedIds = new Set<string>();
445
+ if (process.env.AWM_ENTITY_INDEX_FETCH === '1') {
446
+ try {
447
+ const IDX_CAP = Number(process.env.AWM_ENTITY_INDEX_CAP ?? 12);
448
+ const IDX_QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose',
449
+ 'the', 'this', 'that', 'and', 'but', 'for', 'did', 'does', 'is', 'are', 'was', 'were',
450
+ 'tell', 'about', 'they', 'them', 'write', 'reply', 'list', 'please', 'remember', 'confirm']);
451
+ const terms = new Set<string>();
452
+ for (const m of query.context.matchAll(/\b[A-Z][A-Za-z]{2,}\b/g)) {
453
+ const w = m[0].toLowerCase();
454
+ if (!IDX_QSTOP.has(w)) terms.add(w);
455
+ }
456
+ for (const m of query.context.matchAll(/\b\d{4,}\b/g)) terms.add(m[0]); // bare ids: tickets, members, events
457
+ let added = 0;
458
+ for (const term of Array.from(terms).slice(0, 4)) {
459
+ if (added >= IDX_CAP) break;
460
+ const entities = await this.store.searchEntities(term, 6);
461
+ for (const entity of entities) {
462
+ if (added >= IDX_CAP) break;
463
+ for (const agentId of agentIds) {
464
+ if (added >= IDX_CAP) break;
465
+ for (const id of await this.store.getEngramIdsByEntity(entity, agentId)) {
466
+ if (added >= IDX_CAP) break;
467
+ if (injectedIds.has(id)) continue;
468
+ // Already a candidate via BM25/vector? Still VOUCH for it: a weak in-pool
469
+ // candidate the index confirms would otherwise die at the minScore/topN
470
+ // cuts unguarded. The audition guarantee is about the index match, not
471
+ // about how the candidate entered the pool.
472
+ const inPool = candidateMap.get(id);
473
+ if (inPool) {
474
+ if (inPool.stage === 'active' && !(inPool as any).retracted && !inPool.supersededBy) { injectedIds.add(id); added++; }
475
+ continue;
476
+ }
477
+ const n = await this.store.getEngram(id);
478
+ if (!n || n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
479
+ if (query.memoryType && n.memoryType !== query.memoryType) continue;
480
+ candidateMap.set(id, n);
481
+ injectedIds.add(id);
482
+ added++;
483
+ }
484
+ }
485
+ }
486
+ }
487
+ if (added > 0) candidates = Array.from(candidateMap.values());
488
+ } catch { /* index injection never breaks recall */ }
489
+ }
490
+
491
+ if (candidates.length === 0) return [];
492
+
493
+ // Phase 3b: Score each candidate with per-phase breakdown
494
+ // Candidates are already filtered (the slim-pool filter ran before hydration).
495
+ //
496
+ // Optimization (0.7.12+): the scoring loop only reads `count` and `sumWeight`
497
+ // from associations. Use a SQL aggregate (GROUP BY) to fetch scalar stats
498
+ // instead of materializing thousands of Association objects. Phase-breakdown
499
+ // (post-0.7.10) showed this saves ~200ms (222ms ~20ms).
500
+ //
501
+ // Graph walk still needs full Association objects, but it operates on the
502
+ // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
503
+ // cheap (<5ms total).
504
+ const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
505
+ const scored = candidates.map(engram => {
506
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
507
+ const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
508
+
509
+ // --- Text relevance (keyword signals) ---
510
+
511
+ // Signal 1: BM25 continuous score (0-1, from FTS5 rank)
512
+ const bm25Score = bm25ScoreMap.get(engram.id) ?? 0;
513
+
514
+ // Signal 2: Jaccard similarity with stopword filtering
515
+ const conceptTokens = tokenize(engram.concept);
516
+ const contentTokens = tokenize(engram.content);
517
+ const conceptJaccard = jaccard(queryTokens, conceptTokens);
518
+ const contentJaccard = jaccard(queryTokens, contentTokens);
519
+ const jaccardScore = 0.6 * conceptJaccard + 0.4 * contentJaccard;
520
+
521
+ // Signal 3: Concept exact match bonus (up to 0.3)
522
+ const conceptOverlap = conceptTokens.size > 0
523
+ ? [...conceptTokens].filter(w => queryTokens.has(w)).length / conceptTokens.size
524
+ : 0;
525
+ const conceptBonus = conceptOverlap * 0.3;
526
+
527
+ const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
528
+
529
+ // --- Vector similarity (semantic signal) ---
530
+ // AWM 0.8.x: model-tuned raw-cosine floor in place of z-score normalization.
531
+ // For BGE-small unit-norm vectors: unrelated ~0.3, related 0.5-0.7, near-duplicate 0.85+.
532
+ // Floor adapts to query mode: targeted=0.50 (stricter), exploratory=0.35 (looser).
533
+ let vectorMatch = 0;
534
+ const rawSim = rawCosineSims.get(engram.id);
535
+ if (rawSim !== undefined && rawSim > 0) {
536
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5
537
+ ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
538
+ : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
539
+ if (rawSim > SIM_FLOOR) {
540
+ // Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
541
+ vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
542
+ }
543
+ }
544
+
545
+ // Combined text match: weighted blend of keyword and vector signals.
546
+ // BM25 is better at lexical discrimination; vector is better at semantic matching.
547
+ // When both are non-zero, blend favors the stronger signal with a boost.
548
+ const textMatch = keywordMatch > 0 && vectorMatch > 0
549
+ ? 0.5 * Math.max(keywordMatch, vectorMatch) + 0.3 * Math.min(keywordMatch, vectorMatch) + 0.2 * (keywordMatch * vectorMatch)
550
+ : Math.max(keywordMatch, vectorMatch);
551
+
552
+ // --- Temporal signals ---
553
+
554
+ // ACT-R decay confidence + replay modulated (synaptic tagging)
555
+ // High-confidence memories decay slower. Heavily-accessed memories also resist decay.
556
+ // Base exponent adapts to query mode: targeted (0.6) decays harder, exploratory (0.3) preserves older memories.
557
+ const confMod = 0.2 * Math.max(0, (engram.confidence - 0.5) / 0.5);
558
+ const replayMod = Math.min(0.1, 0.05 * Math.log1p(engram.accessCount));
559
+ const decayExponent = Math.max(0.2, adaptive.decayExponentBase - confMod - replayMod);
560
+ const decayScore = baseLevelActivation(engram.accessCount, ageDays, decayExponent);
561
+
562
+ // Hebbian boost from associations — capped to prevent popular memories
563
+ // from dominating regardless of query relevance
564
+ const rawHebbian = stats.count > 0 ? stats.sumWeight / stats.count : 0;
565
+ const hebbianBoost = Math.min(rawHebbian, 0.5);
566
+
567
+ // Centrality signal — well-connected memories (high weighted degree)
568
+ // get a small boost. This makes consolidation edges matter for retrieval.
569
+ // Log-scaled to prevent hub domination: 10 edges ≈ 0.05 boost, 50 ≈ 0.08
570
+ const centralityBoost = stats.count > 0
571
+ ? Math.min(0.1, 0.03 * Math.log1p(stats.sumWeight))
572
+ : 0;
573
+
574
+ // Confidence gate — multiplicative quality signal
575
+ const confidenceGate = engram.confidence;
576
+
577
+ // Feedback bonus memories confirmed useful via explicit feedback get a
578
+ // direct additive boost. Models how a senior dev "just knows" certain things
579
+ // are important. Confidence > 0.6 means at least 2+ positive feedbacks.
580
+ // Scales: conf 0.6→0.03, 0.7→0.06, 0.8→0.09, 1.0→0.15
581
+ const feedbackBonus = engram.confidence > 0.55
582
+ ? Math.min(0.15, 0.3 * Math.max(0, engram.confidence - 0.5))
583
+ : 0;
584
+
585
+ // --- Composite score: relevance-gated additive ---
586
+ // Text/temporal weights adapt to query mode: targeted (0.75/0.25), exploratory (0.4/0.6).
587
+ const temporalNorm = Math.min(softplus(decayScore + hebbianBoost), 3.0) / 3.0;
588
+ const relevanceGate = textMatch > 0.1 ? textMatch : 0.0; // Proportional gate
589
+ let composite = (adaptive.textWeight * textMatch + adaptive.temporalWeight * temporalNorm * relevanceGate + centralityBoost * relevanceGate + feedbackBonus * relevanceGate) * confidenceGate;
590
+ // In-window preference applied in the MAIN scoring pass, which is what
591
+ // decides the topN cut. (An earlier version of this sat in the Rocchio
592
+ // feedback re-search branch and therefore did nothing: it only ever saw
593
+ // candidates that path newly discovered. Same trap as D11's boost-vs-
594
+ // inject finding a boost can only reorder what is already a candidate.)
595
+ // Additive and scaled by textMatch, so a temporally-plausible memory
596
+ // rises while a subject-irrelevant one cannot be dragged in on date
597
+ // alone; out-of-window memories stay reachable on subject strength.
598
+ if (temporal) {
599
+ const createdMs = engram.createdAt.getTime();
600
+ if (createdMs >= temporal.from && createdMs < temporal.to) {
601
+ composite += temporalBoost() * Math.max(textMatch, 0.15);
602
+ }
603
+ }
604
+
605
+ const phaseScores: PhaseScores = {
606
+ textMatch,
607
+ vectorMatch,
608
+ decayScore,
609
+ hebbianBoost,
610
+ graphBoost: 0, // Filled in phase 5
611
+ confidenceGate,
612
+ composite,
613
+ rerankerScore: 0, // Filled in phase 7
614
+ };
615
+
616
+ // associations: empty in 0.7.12+ — graph walk lazy-fetches per engram on demand
617
+ return { engram, score: composite, phaseScores, associations: [] as Association[] };
618
+ });
619
+
620
+ // Phase 3.5: Rocchio pseudo-relevance feedback expand query with top result terms
621
+ // then re-search BM25 to find candidates that keyword search missed
622
+ const preSorted = scored.sort((a, b) => b.score - a.score);
623
+ const topForFeedback = preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1);
624
+ if (topForFeedback.length > 0) {
625
+ const feedbackTerms = new Set<string>();
626
+ for (const item of topForFeedback) {
627
+ const tokens = tokenize(item.engram.content);
628
+ for (const t of tokens) {
629
+ if (!queryTokens.has(t) && t.length >= 4) feedbackTerms.add(t);
630
+ }
631
+ }
632
+ // Take top 5 feedback terms and re-search
633
+ const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
634
+ if (extraTerms) {
635
+ const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
636
+ for (const r of feedbackBM25) {
637
+ if (!candidateMap.has(r.engram.id)) {
638
+ candidateMap.set(r.engram.id, r.engram);
639
+ // Score the new candidate
640
+ const engram = r.engram;
641
+ const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
642
+ const associations = await this.store.getAssociationsFor(engram.id);
643
+ const cTokens = tokenize(engram.concept);
644
+ const ctTokens = tokenize(engram.content);
645
+ const cJac = jaccard(queryTokens, cTokens);
646
+ const ctJac = jaccard(queryTokens, ctTokens);
647
+ const jSc = 0.6 * cJac + 0.4 * ctJac;
648
+ const cOvlp = cTokens.size > 0 ? [...cTokens].filter(w => queryTokens.has(w)).length / cTokens.size : 0;
649
+ const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
650
+ let vm = 0;
651
+ const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
652
+ if (rs > 0) {
653
+ const SIM_FLOOR = adaptive.zScoreGate > 0.5
654
+ ? Number(process.env.AWM_SIM_FLOOR_TARGETED ?? 0.50)
655
+ : Number(process.env.AWM_SIM_FLOOR_EXPLORATORY ?? 0.35);
656
+ if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
657
+ }
658
+ const tm = km > 0 && vm > 0
659
+ ? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
660
+ : Math.max(km, vm);
661
+ const ds = baseLevelActivation(engram.accessCount, ageDays);
662
+ const rh = associations.length > 0 ? Math.min(associations.reduce((s, a) => s + a.weight, 0) / associations.length, 0.5) : 0;
663
+ const tn = Math.min(softplus(ds + rh), 3.0) / 3.0;
664
+ const rg = tm > 0.1 ? tm : 0.0;
665
+ let comp = (adaptive.textWeight * tm + adaptive.temporalWeight * tn * rg) * engram.confidence;
666
+ // Same in-window preference as the main pass, for candidates this
667
+ // feedback re-search discovers.
668
+ if (temporal) {
669
+ const cms = engram.createdAt.getTime();
670
+ if (cms >= temporal.from && cms < temporal.to) comp += temporalBoost() * Math.max(tm, 0.15);
671
+ }
672
+ scored.push({ engram, score: comp, phaseScores: { textMatch: tm, vectorMatch: vm, decayScore: ds, hebbianBoost: rh, graphBoost: 0, confidenceGate: engram.confidence, composite: comp, rerankerScore: 0 }, associations });
673
+ }
674
+ }
675
+ }
676
+ }
677
+
678
+ // Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
679
+ // with the most query-relevant result. The original intent: surface candidates
680
+ // that DON'T match the query text directly but share entities with the top
681
+ // text-match anchor ("she said something" bridge to entities of the recent
682
+ // speaker named "she"). This is LATERAL relevance, not direct relevance.
683
+ //
684
+ // 2026-05-26: Added textMatch gate. Without it, when many engrams share entity
685
+ // tags AND have similar text matches (e.g., 10 near-clones of the same concept),
686
+ // the bridge boost would push the 8 non-anchor clones above the 2 anchors —
687
+ // an inversion of the genuine top match. The eval Retrieval suite caught this
688
+ // (Recall@5 0.80 → 0.46). Gate: only boost candidates whose textMatch is
689
+ // meaningfully below the anchor's. They actually need the lateral boost.
690
+ // Env override: AWM_DISABLE_ENTITY_BRIDGE=1 to skip this phase entirely.
691
+ if (!process.env.AWM_DISABLE_ENTITY_BRIDGE)
692
+ {
693
+ // Find the result with the highest textMatch (most query-relevant, not just highest score)
694
+ // Gate: only bridge when anchor has meaningful text relevance (> 0.15)
695
+ // Adaptive: scale bridge boost inversely with candidate pool size to prevent
696
+ // over-boosting in large memory pools where many items share entity tags
697
+ const sortedByTextMatch = scored
698
+ .filter(r => r.phaseScores.textMatch > 0.15)
699
+ .sort((a, b) => b.phaseScores.textMatch - a.phaseScores.textMatch);
700
+
701
+ // Bridge from top 2 text-matched results (IDF handles weighting)
702
+ const bridgeAnchors = sortedByTextMatch.slice(0, 2);
703
+
704
+ if (bridgeAnchors.length > 0) {
705
+ const entityTags = new Set<string>();
706
+ const anchorIds = new Set(bridgeAnchors.map(r => r.engram.id));
707
+
708
+ for (const item of bridgeAnchors) {
709
+ for (const tag of item.engram.tags) {
710
+ const t = tag.toLowerCase();
711
+ // Skip non-entity tags: turn IDs, session tags, dialogue IDs, generic speaker labels
712
+ if (/^t\d+$/.test(t) || t.startsWith('session-') || t.startsWith('dia_') || t.length < 3) continue;
713
+ if (/^speaker\d*$/.test(t)) continue; // Generic speaker labels are too broad
714
+ // Auto-tagger `cat:` category tags are too broad to bridge on (they'd link
715
+ // every "cat:work" memory laterally); they stay for BM25 recall only. The
716
+ // precise `entity:` proper-noun tags are kept as bridges.
717
+ if (t.startsWith('cat:')) continue;
718
+ entityTags.add(t);
719
+ }
720
+ }
721
+
722
+ // Document frequency filter: remove tags appearing in >30% of items (too common)
723
+ // This prevents speaker names in 2-person conversations from being used as bridges
724
+ if (entityTags.size > 0 && scored.length > 10) {
725
+ const tagFreqs = new Map<string, number>();
726
+ for (const item of scored) {
727
+ const seen = new Set<string>();
728
+ for (const tag of item.engram.tags) {
729
+ const t = tag.toLowerCase();
730
+ if (entityTags.has(t) && !seen.has(t)) {
731
+ seen.add(t);
732
+ tagFreqs.set(t, (tagFreqs.get(t) ?? 0) + 1);
733
+ }
734
+ }
735
+ }
736
+ const maxFreq = scored.length * 0.30;
737
+ for (const [tag, freq] of tagFreqs) {
738
+ if (freq > maxFreq) entityTags.delete(tag);
739
+ }
740
+ }
741
+
742
+ if (entityTags.size > 0) {
743
+ // Anchor's textMatch sets the scale. Bridge boost magnitude is
744
+ // proportional to the gap between anchor and candidate textMatch:
745
+ // - candidate near anchor (a near-clone of the anchor) small gap → near-zero boost
746
+ // - candidate far below anchor (genuine lateral relevance) → large gap → full boost
747
+ // Without this scaling, dense same-concept corpora flip the genuine
748
+ // top-1 below its 8-9 near-clones (eval Recall@5 0.80 → 0.46 verified
749
+ // 2026-05-26). The scaling keeps the lateral-relevance behavior
750
+ // (which is what helps the AB test) without inverting the genuine
751
+ // text-match winner.
752
+ const anchorTextMax = bridgeAnchors[0].phaseScores.textMatch;
753
+
754
+ for (const item of scored) {
755
+ if (anchorIds.has(item.engram.id)) continue;
756
+
757
+ const engramTags = new Set(item.engram.tags.map((t: string) => t.toLowerCase()));
758
+ let sharedEntities = 0;
759
+ for (const et of entityTags) {
760
+ if (engramTags.has(et)) sharedEntities++;
761
+ }
762
+
763
+ if (sharedEntities > 0) {
764
+ // Gap scaling: 1.0 when candidateText << anchorText, 0 when equal.
765
+ // Clamped to [0, 1]. Anchors with textMatch ≤ 0 fall back to flat boost.
766
+ const gapScale = anchorTextMax > 0
767
+ ? Math.max(0, Math.min(1, (anchorTextMax - item.phaseScores.textMatch) / anchorTextMax))
768
+ : 1;
769
+ const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4) * gapScale;
770
+ if (bridgeBoost > 0) {
771
+ item.score += bridgeBoost;
772
+ item.phaseScores.composite += bridgeBoost;
773
+ item.phaseScores.graphBoost += bridgeBoost;
774
+ }
775
+ }
776
+ }
777
+ }
778
+ }
779
+ }
780
+
781
+ // Phase 3.75: Query-conditioned entity bridge (default-OFF, AWM_QUERY_BRIDGE=1).
782
+ //
783
+ // The anchor-based bridge above (Phase 3.7) is query-BLIND: it bridges from the
784
+ // top text-match result's tags and a document-frequency filter DELETES common
785
+ // tags (e.g. a speaker present in >30% of turns). That is exactly backwards for
786
+ // attribution / entity-named queries: if the user asks "what does Caroline think
787
+ // about the trip" or "who said the trip moved to Saturday", the speaker/entity the
788
+ // query NAMES is the single most valuable bridge its corpus frequency is
789
+ // irrelevant. This phase extracts proper-noun entities from the QUERY and boosts
790
+ // candidates whose tags match them, regardless of frequency, gated by topical
791
+ // relevance (textMatch floor) so it surfaces "Caroline's turns ABOUT the trip"
792
+ // rather than every Caroline turn. Boost folds into composite → survives rerank.
793
+ // Recall-only re-ranking of in-pool candidates (no injection) low precision risk.
794
+ if (process.env.AWM_QUERY_BRIDGE === '1') {
795
+ const QSTOP = new Set(['what', 'who', 'when', 'where', 'why', 'how', 'which', 'whose', 'whom',
796
+ 'the', 'this', 'that', 'these', 'those', 'and', 'but', 'for', 'did', 'does', 'is', 'are',
797
+ 'was', 'were', 'how', 'tell', 'about', 'they', 'them']);
798
+ const qEnts = new Set<string>();
799
+ for (const m of query.context.matchAll(/\b[A-Z][a-zA-Z]{2,}\b/g)) {
800
+ const w = m[0].toLowerCase();
801
+ if (!QSTOP.has(w)) qEnts.add(w);
802
+ }
803
+ if (qEnts.size > 0) {
804
+ const QC_WEIGHT = Number(process.env.AWM_QUERY_BRIDGE_WEIGHT ?? 0.4);
805
+ const QC_CAP = Number(process.env.AWM_QUERY_BRIDGE_CAP ?? 0.4);
806
+ const QC_FLOOR = Number(process.env.AWM_QUERY_BRIDGE_FLOOR ?? 0.1);
807
+ for (const item of scored) {
808
+ if (item.phaseScores.textMatch < QC_FLOOR) continue; // only re-rank topically-relevant candidates
809
+ let matches = 0;
810
+ for (const tag of item.engram.tags) {
811
+ const t = tag.toLowerCase();
812
+ const val = t.startsWith('entity:') ? t.slice(7) : t;
813
+ // match whole-tag or any word of a multi-word entity ("marcus lee" ← "Marcus")
814
+ if (qEnts.has(val) || val.split(/\s+/).some(w => qEnts.has(w))) { matches++; }
815
+ }
816
+ if (matches > 0) {
817
+ // Relevance-modulated: scale by the candidate's topical relevance so
818
+ // "named-entity AND on-topic" wins big while "named-entity but off-topic
819
+ // chatter" (a common speaker tag on an irrelevant turn) gets almost
820
+ // nothing. Without this, a broad speaker tag floods the top with the
821
+ // person's unrelated turns (verified 2026-06-16 _query-bridge-verify).
822
+ const boost = Math.min(matches * QC_WEIGHT * item.phaseScores.textMatch, QC_CAP);
823
+ item.score += boost;
824
+ item.phaseScores.composite += boost;
825
+ item.phaseScores.graphBoost += boost;
826
+ }
827
+ }
828
+ }
829
+ }
830
+
831
+ // Phase 4+5: Graph walk boost engrams connected to high-scoring ones
832
+ // Only walk from engrams that had text relevance (composite > 0 pre-walk)
833
+ const sorted = scored.sort((a, b) => b.score - a.score);
834
+ // Candidate breadth carried into graph-walk + rerank. Default 8×limit (was 3×).
835
+ // WHY (2026-06-16): the pipeline-attribution trace showed ~50% of answerable LoCoMo
836
+ // queries had gold that CLEARED the floor (89%) but was squeezed out HERE by the
837
+ // decay-compressed composite before the (high-lift, +3.29) reranker saw it — the
838
+ // dominant loss. Widening this + the rerank pool (below) lifted official LoCoMo
839
+ // 22.7→25.1 (every recall category up), 4-suite unchanged, recall 35→77ms; small
840
+ // adversarial cost 73.4→71.0 (a fixed step, recoverable on the abstention gate).
841
+ // Tunable via AWM_TOPN_MULT.
842
+ const topNMult = Number(process.env.AWM_TOPN_MULT ?? 8);
843
+ const topN = sorted.slice(0, limit * topNMult);
844
+ // D11 guard 1/4: injected entity-index candidates ride into the graph/rerank stages even
845
+ // when their (boost-free) composite fell below the topN cut.
846
+ if (injectedIds.size > 0) {
847
+ for (const item of sorted.slice(limit * topNMult)) {
848
+ if (injectedIds.has(item.engram.id)) topN.push(item);
849
+ }
850
+ }
851
+ if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
852
+ await this.spreadActivation(topN);
853
+ } else {
854
+ await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
855
+ }
856
+
857
+ // Phase 6: Initial filter and sort for re-ranking pool
858
+ // (D11 guard 2/4: injected entity-index candidates are exempt from the minScore floor.)
859
+ const pool = topN
860
+ .filter(r => r.score >= minScore || injectedIds.has(r.engram.id))
861
+ .sort((a, b) => b.score - a.score);
862
+
863
+ // Phase 7: Cross-encoder re-ranking scores (query, passage) pairs directly
864
+ // Widens the pool to find relevant results that keyword matching missed.
865
+ // How many candidates reach the cross-encoder. Default max(limit*4, 40) — widened
866
+ // from max(limit*2, 15) on 2026-06-16. WHY: the reranker rarely loses gold (0.5%) and
867
+ // lifts it +3.29, but the weak composite was only passing it ~35% of retrievable gold;
868
+ // feeding it more recovered the dominant lost@pool/scoring bucket. Validated knee on
869
+ // recall × precision × latency (pool 40 ≈ 25.1% LoCoMo / 71.0% adv / 77ms; pool 60 adds
870
+ // only +0.6pp for +33ms). The composite is now a CHEAP WIDE PRE-FILTER, not the ranker —
871
+ // the reranker does discrimination on a wide pool. Tunable via AWM_RERANK_POOL.
872
+ const rerankPoolSize = Number(process.env.AWM_RERANK_POOL ?? Math.max(limit * 4, 40));
873
+ const rerankPool = pool.slice(0, rerankPoolSize);
874
+ // D11 guard 3/4: injected entity-index candidates always reach the cross-encoder.
875
+ if (injectedIds.size > 0) {
876
+ for (const item of pool.slice(rerankPoolSize)) {
877
+ if (injectedIds.has(item.engram.id)) rerankPool.push(item);
878
+ }
879
+ }
880
+
881
+ // Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
882
+ // strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
883
+ // is unlikely to change the top result. Skipping saves ~300ms of wall-clock per
884
+ // recall on simple queries (40% of post-0.7.9 floor was reranker).
885
+ //
886
+ // Conservative gate (only skip when very confident):
887
+ // - top-1 textMatch >= 0.8 (high BM25 + jaccard agreement)
888
+ // - top-1 score is at least 1. top-2 score (clear separation)
889
+ // - rerankPool size <= limit*2 (small pool — reranker has less to do)
890
+ //
891
+ // Ambiguous queries (close BM25 scores, weak top-1, large pool) still go through
892
+ // the reranker. Disable this heuristic via AWM_DISABLE_RERANK_SKIP=1.
893
+ let rerankSkipped = false;
894
+ // (D11 guard 4/4: never skip the reranker when entity-index candidates were injected —
895
+ // the audition IS the rerank; skipping would return them unjudged or drop them.)
896
+ if (useReranker && rerankPool.length >= 2 && injectedIds.size === 0 && process.env.AWM_DISABLE_RERANK_SKIP !== '1') {
897
+ const top1 = rerankPool[0];
898
+ const top2 = rerankPool[1];
899
+ const t1Text = top1.phaseScores.textMatch;
900
+ const t1Score = top1.score;
901
+ const t2Score = top2.score;
902
+ const cleanWinner = t1Text >= 0.8 && t1Score >= 1.5 * Math.max(t2Score, 0.01);
903
+ const smallPool = rerankPool.length <= Math.max(limit * 2, 20);
904
+ if (cleanWinner && smallPool) {
905
+ rerankSkipped = true;
906
+ }
907
+ }
908
+
909
+ if (useReranker && !rerankSkipped && rerankPool.length > 0) {
910
+ try {
911
+ // Passage selection for the cross-encoder. Truncation exists for a real
912
+ // reason: cross-encoders pad to the longest passage in the batch, so one
913
+ // 5,000-char memory in a 40-item pool drags everything to ~512 tokens and
914
+ // costs 3-4x and the reranker is already ~90% of warm recall latency.
915
+ //
916
+ // But a PREFIX is the wrong budget to spend. On the live store, canonical
917
+ // memories are median 1,965 chars and 98.7% exceed 400, so the reranker
918
+ // cannot see 78.8% of their vocabulary; 99.9% of them carry identifiers
919
+ // only past char 400. tests/longmem-eval shows the consequence: moving an
920
+ // answer from char 150 to char 700 takes success@1 from 100% to 0%, with
921
+ // the gold's cross-encoder score collapsing 0.986 -> 0.000 while its BM25
922
+ // score barely moves — retrievable, but not rankable.
923
+ //
924
+ // AWM_RERANK_WINDOW=query spends the SAME budget on the window that
925
+ // actually contains the query terms. Cost is unchanged. See
926
+ // src/core/rerank-window.ts.
927
+ const rrBudget = rerankTruncation();
928
+ const rrMode = rerankWindowMode();
929
+ const passages = rerankPool.map(r =>
930
+ buildRerankPassage(r.engram.concept, r.engram.content, queryContext, rrBudget, rrMode, r.engram.tags));
931
+ let rerankTimer: ReturnType<typeof setTimeout> | undefined;
932
+ const rerankResults = await Promise.race([
933
+ rerank(queryContext, passages),
934
+ new Promise<never>((_, reject) => { rerankTimer = setTimeout(() => reject(new Error('reranker timeout')), 10000); }),
935
+ ]).finally(() => { if (rerankTimer) clearTimeout(rerankTimer); });
936
+
937
+ // Adaptive reranker blend (Codex recommendation):
938
+ // When BM25/text signals are strong, trust them more; when weak, lean on reranker.
939
+ const bm25Max = Math.max(...rerankPool.map(r => r.phaseScores.textMatch));
940
+ const rerankWeight = Math.min(0.7, Math.max(0.3, 0.3 + 0.4 * (1 - bm25Max)));
941
+ const compositeWeight = 1 - rerankWeight;
942
+
943
+ for (const rr of rerankResults) {
944
+ const item = rerankPool[rr.index];
945
+ item.phaseScores.rerankerScore = rr.score;
946
+ item.score = compositeWeight * item.phaseScores.composite + rerankWeight * rr.score;
947
+ }
948
+ } catch {
949
+ // Re-ranker unavailable keep original scores
950
+ }
951
+ }
952
+
953
+ // Phase 8: Multi-channel OOD detection + agreement gate
954
+ // Requires at least 2 of 3 retrieval channels to agree the query is in-domain.
955
+ if (rerankPool.length >= 3) {
956
+ // Abstention gate scope (2026-06-16): the in-domain channel maxes used to be taken
957
+ // over the ENTIRE rerankPool. Once that pool was widened for recall (pool 40), a lone
958
+ // high-scoring distractor inflated the maxes and defeated abstention on adversarial
959
+ // queries (adversarial 73.4→71.0). Fix: judge in-domain on the post-rerank TOP-K —
960
+ // the items we'd actually return — so pool width (recall) is decoupled from the
961
+ // abstention decision (precision). AWM_ABSTAIN_GATE_K controls K (0 = legacy
962
+ // whole-pool behavior). Answerable queries are unaffected: the gold is in the top-K
963
+ // and supplies the in-domain signal; only borderline distractors deep in a wide pool
964
+ // stop counting.
965
+ // Default 5 (2026-06-16): judge in-domain on the post-rerank top-5. With the widened
966
+ // rerank pool, basing it on the whole pool (legacy AWM_ABSTAIN_GATE_K=0) let a lone
967
+ // deep distractor defeat abstention; top-5 restored adversarial 71.0→74.9 (ABOVE the
968
+ // pre-widening 73.4) at ZERO recall cost (answerable categories unchanged) — the
969
+ // precision half of the two-dial pool-widening win.
970
+ const gateK = Number(process.env.AWM_ABSTAIN_GATE_K ?? 5);
971
+ const gatePool = gateK > 0
972
+ ? [...rerankPool].sort((a, b) => b.score - a.score).slice(0, gateK)
973
+ : rerankPool;
974
+
975
+ const topBM25 = Math.max(...gatePool.map(r => bm25ScoreMap.get(r.engram.id) ?? 0));
976
+ const topVector = queryEmbedding
977
+ ? Math.max(...gatePool.map(r => r.phaseScores.vectorMatch))
978
+ : 0;
979
+ const topReranker = Math.max(...gatePool.map(r => r.phaseScores.rerankerScore));
980
+
981
+ const bm25Ok = topBM25 > 0.3;
982
+ const vectorOk = topVector > 0.05;
983
+ const rerankerOk = topReranker > 0.25;
984
+ const channelsAgreeing = (bm25Ok ? 1 : 0) + (vectorOk ? 1 : 0) + (rerankerOk ? 1 : 0);
985
+
986
+ const rerankerScores = gatePool
987
+ .map(r => r.phaseScores.rerankerScore)
988
+ .sort((a, b) => b - a);
989
+ const margin = rerankerScores.length >= 2
990
+ ? rerankerScores[0] - rerankerScores[1]
991
+ : rerankerScores[0];
992
+
993
+ const cosineSimValues = Array.from(rawCosineSims.values());
994
+ const maxRawCosine = queryEmbedding && cosineSimValues.length > 0
995
+ ? Math.max(...cosineSimValues)
996
+ : 1.0;
997
+
998
+ // Required-channels for hard abstention:
999
+ // abstention-explicit (caller passed abstentionThreshold > 0): 3 of 3
1000
+ // default: 2 of 3 — precision-first
1001
+ const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
1002
+
1003
+ // Hard abstention: fewer than required channels agree AND semantic match weak.
1004
+ // After the 2.0.x vector refactor we no longer compute z-score; threshold
1005
+ // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
1006
+ const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
1007
+ if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
1008
+ return [];
1009
+ }
1010
+
1011
+ // Soft penalty: only 1 channel agrees or margin is thin
1012
+ if (channelsAgreeing < 2 || margin < 0.05) {
1013
+ if (abstentionThreshold > 0) {
1014
+ return [];
1015
+ }
1016
+ for (const item of rerankPool) {
1017
+ item.score *= 0.4;
1018
+ }
1019
+ }
1020
+ }
1021
+
1022
+ // Legacy abstention gate (when explicitly requested)
1023
+ if (abstentionThreshold > 0 && rerankPool.length >= 3) {
1024
+ const topRerankerScores = rerankPool
1025
+ .map(r => r.phaseScores.rerankerScore)
1026
+ .sort((a, b) => b - a)
1027
+ .slice(0, 5);
1028
+ const maxScore = topRerankerScores[0];
1029
+ const meanScore = topRerankerScores.reduce((s, v) => s + v, 0) / topRerankerScores.length;
1030
+ const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
1031
+
1032
+ if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
1033
+ return [];
1034
+ }
1035
+ }
1036
+
1037
+ // Phase 8c: Supersession penalty superseded memories are deprioritized.
1038
+ // They aren't wrong (that's retraction), just outdated.
1039
+ for (const item of rerankPool) {
1040
+ if (item.engram.supersededBy) {
1041
+ item.score *= 0.15; // Severe down-rank successor should dominate
1042
+ }
1043
+ }
1044
+
1045
+ // Phase 9: Final sort, limit, explain, attach confidence
1046
+ const finalRanked = rerankPool.sort((a, b) => b.score - a.score);
1047
+ const topScoresForConfidence = finalRanked.slice(0, 10).map(r => r.score);
1048
+ const { confidence } = computeRecallConfidence(topScoresForConfidence);
1049
+
1050
+ // Opt-in confidence-based abstention. When the caller sets
1051
+ // `requireConfidence`, we return [] if the score-distribution shape
1052
+ // indicates a low-quality recall (noisy or best-of-bad-bunch).
1053
+ // Independent of the channel-agreement abstention earlier — that path
1054
+ // requires the reranker; this one uses just the final composite scores.
1055
+ if (requireConfidence > 0 && confidence < requireConfidence) {
1056
+ return [];
1057
+ }
1058
+
1059
+ // Confidence-adaptive output granularity (Paper 3: cognitive teaming).
1060
+ // 'full' → no summary (default, current behavior).
1061
+ // 'compact' every result gets a short summary (COMPACT_LEN chars).
1062
+ // 'auto' → if confidence AUTO_THRESHOLD: top result gets a full-length
1063
+ // summary, lower-ranked results get compact summaries. If
1064
+ // confidence is lower, all results get compact summaries.
1065
+ const granularity = query.granularity ?? 'full';
1066
+ const COMPACT_LEN = Number(process.env.AWM_GRANULARITY_COMPACT_LEN ?? 200);
1067
+ const FULL_LEN = Number(process.env.AWM_GRANULARITY_FULL_LEN ?? 1000);
1068
+ const AUTO_THRESHOLD = Number(process.env.AWM_GRANULARITY_AUTO_THRESHOLD ?? 0.4);
1069
+
1070
+ // Snippet token list — reuse the activation queryTokens (Set<string>),
1071
+ // filtered to ≥2 chars to drop noise. queryTokens was tokenized at line
1072
+ // 273 via tokenize() with stopword stripping already applied.
1073
+ const snippetTokens = Array.from(queryTokens).filter(t => t.length >= 2);
1074
+
1075
+ // Find the densest window of `len` chars in `content` that contains the
1076
+ // most query-token matches. Falls back to head if no tokens match.
1077
+ const summaryFor = (content: string, len: number): string => {
1078
+ if (content.length <= len) return content;
1079
+ if (snippetTokens.length === 0) return content.slice(0, len).trimEnd() + '…';
1080
+
1081
+ const lower = content.toLowerCase();
1082
+ // Each hit carries a WEIGHT, not a flat 1.
1083
+ //
1084
+ // Counting every hit equally makes the window land wherever the common
1085
+ // concept words cluster. A real query is concept words plus a rare
1086
+ // identifier ("... division mock d.requires_horse"); in a long memory the
1087
+ // concept words recur throughout while the identifier appears once, so
1088
+ // the densest-by-count window reliably misses the answer. Measured on the
1089
+ // real store: compact@200 delivered the answer only 20.9% of the time.
1090
+ //
1091
+ // AWM_SNIPPET_WEIGHT=rarity weights a hit by 1/(occurrences of that token
1092
+ // in THIS document), so one occurrence of a rare term outweighs many of a
1093
+ // common one. Doc-local: no corpus statistics, no extra queries, no
1094
+ // latency. Default OFF preserves the shipped behaviour exactly.
1095
+ const rarityMode = process.env.AWM_SNIPPET_WEIGHT === 'rarity' || process.env.AWM_SNIPPET_WEIGHT === 'anchor';
1096
+ const hits: number[] = [];
1097
+ const hitW: number[] = [];
1098
+ for (const tok of snippetTokens) {
1099
+ const positions: number[] = [];
1100
+ let from = 0;
1101
+ while (true) {
1102
+ const idx = lower.indexOf(tok, from);
1103
+ if (idx < 0) break;
1104
+ positions.push(idx);
1105
+ from = idx + tok.length;
1106
+ }
1107
+ const w = rarityMode && positions.length > 0 ? 1 / positions.length : 1;
1108
+ for (const pos of positions) { hits.push(pos); hitW.push(w); }
1109
+ }
1110
+ if (hits.length === 0) return content.slice(0, len).trimEnd() + '…';
1111
+ // Sort positions and weights together.
1112
+ const order = hits.map((h, i) => i).sort((a, b) => hits[a] - hits[b]);
1113
+ const sortedHits = order.map(i => hits[i]);
1114
+ const sortedW = order.map(i => hitW[i]);
1115
+ hits.length = 0; hits.push(...sortedHits);
1116
+
1117
+ // Candidate anchors. Maximising a SUM of weights still lets a tight
1118
+ // cluster of common-word hits outweigh the single rare hit that actually
1119
+ // answers the query — rarity weighting alone moved sufficiency only
1120
+ // 20.9% -> 27.0% on the real store. `anchor` mode instead GUARANTEES the
1121
+ // rarest matched token is inside the window, then picks the best window
1122
+ // among those, so the answer-bearing term cannot be outvoted.
1123
+ const anchorMode = process.env.AWM_SNIPPET_WEIGHT === 'anchor';
1124
+ let anchorIdx: number[] = hits.map((_, i) => i);
1125
+ if (anchorMode) {
1126
+ const rarest = Math.max(...sortedW); // 1/occurrences: rarest == largest weight
1127
+ const only = anchorIdx.filter(i => sortedW[i] >= rarest - 1e-9);
1128
+ if (only.length > 0) anchorIdx = only;
1129
+ }
1130
+
1131
+ let bestStart = hits[0];
1132
+ let bestCount = -1;
1133
+ for (const i of anchorIdx) {
1134
+ const start = Math.max(0, hits[i] - Math.floor(len / 4));
1135
+ let count = 0;
1136
+ for (let j = 0; j < hits.length; j++) {
1137
+ if (hits[j] >= start && hits[j] - start < len) count += sortedW[j];
1138
+ }
1139
+ if (count > bestCount) {
1140
+ bestCount = count;
1141
+ bestStart = start;
1142
+ }
1143
+ }
1144
+
1145
+ // Reserve characters for the ellipses we're about to add so the final
1146
+ // string stays within `len`. Without this, both '…' prefix + suffix
1147
+ // would push the snippet to len+2 chars.
1148
+ const hasPrefix = bestStart > 0;
1149
+ const tentativeEnd = Math.min(content.length, bestStart + len);
1150
+ const hasSuffix = tentativeEnd < content.length;
1151
+ const reserveForEllipses = (hasPrefix ? 1 : 0) + (hasSuffix ? 1 : 0);
1152
+ const bodyLen = Math.max(0, len - reserveForEllipses);
1153
+ const end = Math.min(content.length, bestStart + bodyLen);
1154
+ const startAdj = Math.max(0, end - bodyLen);
1155
+ let snippet = content.slice(startAdj, end);
1156
+ if (startAdj > 0) snippet = '…' + snippet.trimStart();
1157
+ if (end < content.length) snippet = snippet.trimEnd() + '…';
1158
+ return snippet;
1159
+ };
1160
+
1161
+ // ── Phase 9b: SECOND-STAGE REORDER "rerank the rerank" ──
1162
+ // See src/core/rerank2.ts for the measurement and the safety argument.
1163
+ // Placed here deliberately: after the agreement gate, after
1164
+ // computeRecallConfidence, and after the requireConfidence check, so it
1165
+ // cannot influence abstention. Default OFF (AWM_RERANK2=1).
1166
+ const ordered = rerank2Enabled()
1167
+ ? reorderByReranker(finalRanked, rerank2WindowSize())
1168
+ : finalRanked;
1169
+
1170
+ const results: ActivationResult[] = ordered
1171
+ .slice(0, limit)
1172
+ .map((r, idx) => {
1173
+ let summary: string | undefined;
1174
+ if (granularity === 'compact') {
1175
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
1176
+ } else if (granularity === 'auto') {
1177
+ if (confidence >= AUTO_THRESHOLD && idx === 0) {
1178
+ summary = summaryFor(r.engram.content, FULL_LEN);
1179
+ } else {
1180
+ summary = summaryFor(r.engram.content, COMPACT_LEN);
1181
+ }
1182
+ }
1183
+ return {
1184
+ engram: r.engram,
1185
+ score: r.score,
1186
+ phaseScores: r.phaseScores,
1187
+ why: this.explain(r.phaseScores, r.engram, r.associations),
1188
+ associations: r.associations,
1189
+ confidence,
1190
+ ...(summary !== undefined && { summary }),
1191
+ };
1192
+ });
1193
+
1194
+ const activatedIds = results.map(r => r.engram.id);
1195
+
1196
+ // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
1197
+ if (!query.internal) {
1198
+ for (const id of activatedIds) {
1199
+ await this.store.touchEngram(id);
1200
+ }
1201
+ this.coActivationBuffer.pushBatch(activatedIds);
1202
+ // Validation-gated Hebbian: defer strengthening until feedback arrives
1203
+ const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
1204
+ const seen = new Set<string>();
1205
+ const uniquePairs: [string, string][] = [];
1206
+ for (const [a, b] of pairs) {
1207
+ const key = a < b ? `${a}:${b}` : `${b}:${a}`;
1208
+ if (!seen.has(key)) { seen.add(key); uniquePairs.push([a, b]); }
1209
+ }
1210
+ this.validationGate.addPending(activatedIds, uniquePairs);
1211
+
1212
+ // Log activation event for eval
1213
+ const latencyMs = performance.now() - startTime;
1214
+ await this.store.logActivationEvent({
1215
+ id: randomUUID(),
1216
+ agentId: query.agentId,
1217
+ timestamp: new Date(),
1218
+ context: query.context,
1219
+ resultsReturned: results.length,
1220
+ topScore: results.length > 0 ? results[0].score : 0,
1221
+ latencyMs,
1222
+ engramIds: activatedIds,
1223
+ });
1224
+ }
1225
+
1226
+ return results;
1227
+ }
1228
+
1229
+ /**
1230
+ * Multi-graph traversal (MAGMA-inspired).
1231
+ *
1232
+ * Instead of one beam search over all edge types, runs independent traversals
1233
+ * per graph type with specialized scoring, then fuses the boosts.
1234
+ *
1235
+ * Four sub-graphs:
1236
+ * - Semantic (connection + hebbian edges) → standard weight-based walk
1237
+ * - Temporal (temporal edges) → recency-weighted (favor recent connections)
1238
+ * - Causal (causal edges) → full weight walk (causal links are high-value)
1239
+ * - Entity (bridge edges) → entity-tag-weighted walk
1240
+ *
1241
+ * Each sub-graph contributes independently to the final graph boost,
1242
+ * weighted by configurable per-graph weights.
1243
+ */
1244
+ private static readonly GRAPH_WEIGHTS = {
1245
+ semantic: 0.40, // connection + hebbian
1246
+ temporal: 0.20, // temporal edges
1247
+ causal: 0.25, // causal edges (high-value signal)
1248
+ entity: 0.15, // bridge edges
1249
+ };
1250
+
1251
+ private async graphWalk(
1252
+ scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
1253
+ maxDepth: number,
1254
+ hopPenalty: number,
1255
+ beamWidth: number = 15
1256
+ ): Promise<void> {
1257
+ const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
1258
+ const MAX_TOTAL_BOOST = 0.25;
1259
+
1260
+ // Define which edge types belong to each sub-graph
1261
+ const graphTypes: Record<string, string[]> = {
1262
+ semantic: ['connection', 'hebbian'],
1263
+ temporal: ['temporal'],
1264
+ causal: ['causal'],
1265
+ entity: ['bridge'],
1266
+ };
1267
+
1268
+ // Run independent traversals per sub-graph, accumulate boosts
1269
+ const boostAccum = new Map<string, number>(); // engramId total boost
1270
+
1271
+ for (const [graphName, edgeTypes] of Object.entries(graphTypes)) {
1272
+ const graphWeight = ActivationEngine.GRAPH_WEIGHTS[graphName as keyof typeof ActivationEngine.GRAPH_WEIGHTS];
1273
+ const subBeamWidth = Math.max(3, Math.ceil(beamWidth * graphWeight));
1274
+
1275
+ // Seed beam
1276
+ const beam = scored
1277
+ .filter(item => item.phaseScores.textMatch >= 0.15)
1278
+ .sort((a, b) => b.score - a.score)
1279
+ .slice(0, subBeamWidth);
1280
+
1281
+ const explored = new Set<string>();
1282
+
1283
+ for (let depth = 0; depth < maxDepth; depth++) {
1284
+ const nextBeam: typeof beam = [];
1285
+
1286
+ for (const item of beam) {
1287
+ if (explored.has(item.engram.id)) continue;
1288
+ explored.add(item.engram.id);
1289
+
1290
+ const associations = item.associations.length > 0
1291
+ ? item.associations
1292
+ : await this.store.getAssociationsFor(item.engram.id);
1293
+
1294
+ // Filter to only edges of this sub-graph type
1295
+ const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
1296
+
1297
+ for (const assoc of relevantEdges) {
1298
+ const neighborId = assoc.fromEngramId === item.engram.id
1299
+ ? assoc.toEngramId
1300
+ : assoc.fromEngramId;
1301
+
1302
+ if (explored.has(neighborId)) continue;
1303
+ const neighbor = scoreMap.get(neighborId);
1304
+ if (!neighbor) continue;
1305
+
1306
+ const relevanceFloor = depth === 0 ? 0.1 : 0.05;
1307
+ if (neighbor.phaseScores.textMatch < relevanceFloor) continue;
1308
+
1309
+ // Path score with graph-type-specific weighting
1310
+ const normalizedWeight = Math.min(assoc.weight, 5.0) / 5.0;
1311
+ let pathScore = item.score * normalizedWeight * Math.pow(hopPenalty, depth + 1);
1312
+
1313
+ // Causal edges get a 2x boost — they represent verified reasoning chains
1314
+ if (graphName === 'causal') pathScore *= 2.0;
1315
+
1316
+ // Weight by sub-graph importance
1317
+ const boost = Math.min(pathScore * graphWeight, 0.15);
1318
+ if (boost > 0.001) {
1319
+ boostAccum.set(neighborId, (boostAccum.get(neighborId) ?? 0) + boost);
1320
+ nextBeam.push(neighbor);
1321
+ }
1322
+ }
1323
+ }
1324
+
1325
+ if (nextBeam.length === 0) break;
1326
+ beam.length = 0;
1327
+ beam.push(...nextBeam
1328
+ .sort((a, b) => b.score - a.score)
1329
+ .slice(0, subBeamWidth)
1330
+ );
1331
+ }
1332
+ }
1333
+
1334
+ // Apply fused boosts to scored items
1335
+ for (const [engramId, totalBoost] of boostAccum) {
1336
+ const item = scoreMap.get(engramId);
1337
+ if (!item) continue;
1338
+ const capped = Math.min(totalBoost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
1339
+ if (capped > 0.001) {
1340
+ item.score += capped;
1341
+ item.phaseScores.graphBoost += capped;
1342
+ }
1343
+ }
1344
+ }
1345
+
1346
+ /**
1347
+ * R2 — bounded iterative spreading activation (PPR / SYNAPSE-style).
1348
+ *
1349
+ * Default-OFF (`AWM_SPREAD=1`). The principled, in-AWM successor to the
1350
+ * fixed depth-2 beam `graphWalk` and the MWA harness bridge: it runs T
1351
+ * iterations of **fan-normalized** spreading with **lateral inhibition** and
1352
+ * a **restart** term (Personalized PageRank) over the association graph —
1353
+ * richest when R1's `AWM_BROAD_EDGES` entity edges are present.
1354
+ *
1355
+ * Two effects, both precision-guarded:
1356
+ * - **Boost** existing pool candidates by the *graph evidence* they receive
1357
+ * (convergent multi-path activation, not a single spurious hop).
1358
+ * - **Inject** (`AWM_SPREAD_INJECT=1`) strongly-reached *out-of-pool*
1359
+ * engrams as recall-only candidates so the reranker can see true
1360
+ * multi-hop bridges that BM25/vector missed. Their composite carries the
1361
+ * graph-activation signal (blended with rerank), which is what lets a
1362
+ * vocab-mismatched bridge surface where the prior `AWM_ENTITY_FETCH`
1363
+ * recall-only injection (rerank-only) could not.
1364
+ *
1365
+ * Precision is preserved because spreading is **seeded by the initial
1366
+ * retrieval**: adversarial "is this even in memory?" queries have weak/empty
1367
+ * seeds, so nothing meaningful propagates and abstention is unaffected.
1368
+ * Fan-normalization stops hubs from flooding; lateral inhibition keeps only
1369
+ * the top-M activated nodes per step; a node budget bounds cost; injected
1370
+ * candidates stay rerank-gated and the pool-level OOD agreement gate is
1371
+ * unaffected (seeds still supply the BM25/vector channels).
1372
+ */
1373
+ private async spreadActivation(
1374
+ topN: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
1375
+ ): Promise<void> {
1376
+ const T = Number(process.env.AWM_SPREAD_ITERS ?? 3);
1377
+ const delta = Number(process.env.AWM_SPREAD_DAMPING ?? 0.5);
1378
+ const NODE_BUDGET = Number(process.env.AWM_SPREAD_BUDGET ?? 64);
1379
+ const BOOST_SCALE = Number(process.env.AWM_SPREAD_BOOST ?? 0.4);
1380
+ const PER_NODE_CAP = 0.15;
1381
+ const MAX_TOTAL_BOOST = 0.25;
1382
+ const inject = process.env.AWM_SPREAD_INJECT === '1';
1383
+ const INJECT_THRESHOLD = Number(process.env.AWM_SPREAD_INJECT_MIN ?? 0.08);
1384
+ const INJECT_BUDGET = Number(process.env.AWM_SPREAD_INJECT_CAP ?? 8);
1385
+ const INJECT_SCALE = Number(process.env.AWM_SPREAD_INJECT_SCALE ?? 1.0);
1386
+ const EPS = 0.01;
1387
+ // 'invalidation' edges link superseded→replacement; excluded so spreading
1388
+ // never pulls stale facts back in.
1389
+ const allowed = new Set(['connection', 'hebbian', 'temporal', 'causal', 'bridge']);
1390
+
1391
+ const scoreMap = new Map(topN.map(s => [s.engram.id, s]));
1392
+
1393
+ // Seed activation from query-relevant candidates (textMatch gate), normalized to [0,1].
1394
+ const seed = new Map<string, number>();
1395
+ let maxSeed = 0;
1396
+ for (const item of topN) {
1397
+ if (item.phaseScores.textMatch >= 0.15) {
1398
+ const v = Math.max(0, item.score);
1399
+ seed.set(item.engram.id, v);
1400
+ if (v > maxSeed) maxSeed = v;
1401
+ }
1402
+ }
1403
+ if (seed.size === 0 || maxSeed <= 0) return;
1404
+ for (const [k, v] of seed) seed.set(k, v / maxSeed);
1405
+
1406
+ const edgeCache = new Map<string, Association[]>();
1407
+ const getEdges = async (id: string): Promise<Association[]> => {
1408
+ let e = edgeCache.get(id);
1409
+ if (!e) {
1410
+ e = (await this.store.getAssociationsFor(id)).filter(a => allowed.has(a.type));
1411
+ edgeCache.set(id, e);
1412
+ }
1413
+ return e;
1414
+ };
1415
+
1416
+ let act = new Map(seed);
1417
+ // Cumulative inflow received from the graph (excludes a node's own seed) —
1418
+ // this is the multi-hop "evidence" signal used for boost + injection.
1419
+ const graphActivation = new Map<string, number>();
1420
+
1421
+ for (let t = 0; t < T; t++) {
1422
+ const inflow = new Map<string, number>();
1423
+ for (const [u, au] of act) {
1424
+ if (au <= EPS) continue;
1425
+ const edges = await getEdges(u);
1426
+ if (edges.length === 0) continue;
1427
+ let fan = 0;
1428
+ for (const e of edges) fan += Math.max(0, e.weight);
1429
+ if (fan <= 0) continue;
1430
+ for (const e of edges) {
1431
+ const v = e.fromEngramId === u ? e.toEngramId : e.fromEngramId;
1432
+ const share = Math.max(0, e.weight) / fan; // fan-effect normalization
1433
+ inflow.set(v, (inflow.get(v) ?? 0) + au * share);
1434
+ }
1435
+ }
1436
+ // D11: SYNAPSE-style lateral inhibition (divisive normalization) — competing
1437
+ // receivers suppress each other WITHIN an iteration: a node keeps its inflow in
1438
+ // proportion to how much of the iteration's total it earned, so a few strongly-
1439
+ // reached nodes stay strong while diffuse spread mass is crushed. This is the
1440
+ // published fix for the displacing-gold regression that parked AWM_SPREAD.
1441
+ // λ=0 (default) disables; enable for the re-test with AWM_SPREAD_INHIBIT=0.3.
1442
+ const INHIBIT = Number(process.env.AWM_SPREAD_INHIBIT ?? 0);
1443
+ if (INHIBIT > 0 && inflow.size > 1) {
1444
+ let total = 0;
1445
+ for (const f of inflow.values()) total += f;
1446
+ for (const [v, f] of inflow) inflow.set(v, f * (f / (f + INHIBIT * (total - f))));
1447
+ }
1448
+ for (const [v, f] of inflow) graphActivation.set(v, (graphActivation.get(v) ?? 0) + f);
1449
+
1450
+ // Restart (PPR): blend propagated inflow with the original seed vector.
1451
+ const newAct = new Map<string, number>();
1452
+ const keys = new Set<string>([...act.keys(), ...inflow.keys()]);
1453
+ for (const v of keys) {
1454
+ const val = (1 - delta) * (seed.get(v) ?? 0) + delta * (inflow.get(v) ?? 0);
1455
+ if (val > EPS) newAct.set(v, val);
1456
+ }
1457
+ // Lateral inhibition: keep only the top-M activated nodes (competition + cost bound).
1458
+ if (newAct.size > NODE_BUDGET) {
1459
+ act = new Map([...newAct.entries()].sort((a, b) => b[1] - a[1]).slice(0, NODE_BUDGET));
1460
+ } else {
1461
+ act = newAct;
1462
+ }
1463
+ }
1464
+
1465
+ // Normalize graph evidence to [0,1] so the boost magnitude is scale-stable
1466
+ // (raw `ga` accumulates across iterations + bidirectional edges, so its
1467
+ // absolute scale varies with graph density). The top-reached node maps to 1.0.
1468
+ let maxGa = 0;
1469
+ for (const ga of graphActivation.values()) if (ga > maxGa) maxGa = ga;
1470
+ const normGa = (id: string): number => (maxGa > 0 ? (graphActivation.get(id) ?? 0) / maxGa : 0);
1471
+
1472
+ if (process.env.AWM_SPREAD_DEBUG === '1') {
1473
+ const top = [...graphActivation.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
1474
+ process.stderr.write(`[spread] seeds=${seed.size} reached=${graphActivation.size} inPool=${[...graphActivation.keys()].filter(id => scoreMap.has(id)).length} maxGa=${maxGa.toFixed(3)}\n`);
1475
+ for (const [id, ga] of top) {
1476
+ const e = scoreMap.get(id)?.engram;
1477
+ 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`);
1478
+ }
1479
+ }
1480
+
1481
+ // Boost existing candidates by the (normalized) graph evidence they received.
1482
+ // Folded into `composite` (NOT just `score`) so it survives the rerank blend
1483
+ // — the reranker recomputes score from composite, so a score-only boost would
1484
+ // be discarded. This makes spreading a first-class multi-hop ranking signal.
1485
+ for (const [id] of graphActivation) {
1486
+ const item = scoreMap.get(id);
1487
+ if (!item) continue;
1488
+ const boost = Math.min(normGa(id) * BOOST_SCALE, PER_NODE_CAP);
1489
+ const capped = Math.min(boost, MAX_TOTAL_BOOST - item.phaseScores.graphBoost);
1490
+ if (capped > 0.001) {
1491
+ item.phaseScores.composite += capped;
1492
+ item.score += capped;
1493
+ item.phaseScores.graphBoost += capped;
1494
+ }
1495
+ }
1496
+
1497
+ // Inject strongly-reached out-of-pool engrams as recall-only candidates.
1498
+ if (inject) {
1499
+ const reached = [...graphActivation.entries()]
1500
+ .filter(([id]) => !scoreMap.has(id) && normGa(id) >= INJECT_THRESHOLD)
1501
+ .sort((a, b) => b[1] - a[1])
1502
+ .slice(0, INJECT_BUDGET);
1503
+ for (const [id] of reached) {
1504
+ const engram = await this.store.getEngram(id);
1505
+ if (!engram || engram.stage !== 'active') continue;
1506
+ if ((engram as unknown as { retracted?: boolean }).retracted || engram.supersededBy) continue;
1507
+ const composite = Math.min(0.6, normGa(id) * INJECT_SCALE);
1508
+ const phaseScores: PhaseScores = {
1509
+ textMatch: 0,
1510
+ vectorMatch: 0,
1511
+ decayScore: 0,
1512
+ hebbianBoost: 0,
1513
+ graphBoost: composite,
1514
+ confidenceGate: engram.confidence,
1515
+ composite,
1516
+ rerankerScore: 0,
1517
+ };
1518
+ const injected = { engram, score: composite, phaseScores, associations: [] as Association[] };
1519
+ topN.push(injected);
1520
+ scoreMap.set(id, injected);
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ /**
1526
+ * Resolve validation-gated Hebbian update for a specific engram.
1527
+ * Called by memory_feedback — only strengthens when retrieval was useful.
1528
+ * This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
1529
+ */
1530
+ async resolveHebbianFeedback(engramId: string, useful: boolean): Promise<number> {
1531
+ const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
1532
+ let updated = 0;
1533
+
1534
+ for (const [a, b] of pairs) {
1535
+ const existing = (await this.store.getAssociation(a, b)) ?? (await this.store.getAssociation(b, a));
1536
+ const currentWeight = existing?.weight ?? 0.1;
1537
+
1538
+ if (signal > 0) {
1539
+ // Positive feedback → strengthen
1540
+ const newWeight = strengthenAssociation(currentWeight, signal);
1541
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1542
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1543
+ } else {
1544
+ // Negative feedback → slight weakening (decay by signal magnitude)
1545
+ const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
1546
+ await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
1547
+ await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
1548
+ }
1549
+ updated++;
1550
+ }
1551
+ return updated;
1552
+ }
1553
+
1554
+ private explain(phases: PhaseScores, engram: Engram, associations: Association[]): string {
1555
+ const parts: string[] = [];
1556
+ parts.push(`composite=${phases.composite.toFixed(3)}`);
1557
+ if (phases.textMatch > 0) parts.push(`text=${phases.textMatch.toFixed(2)}`);
1558
+ if (phases.vectorMatch > 0) parts.push(`vector=${phases.vectorMatch.toFixed(2)}`);
1559
+ parts.push(`decay=${phases.decayScore.toFixed(2)}`);
1560
+ if (phases.hebbianBoost > 0) parts.push(`hebbian=${phases.hebbianBoost.toFixed(2)}`);
1561
+ if (phases.graphBoost > 0) parts.push(`graph=${phases.graphBoost.toFixed(2)}`);
1562
+ if (phases.rerankerScore > 0) parts.push(`reranker=${phases.rerankerScore.toFixed(2)}`);
1563
+ parts.push(`conf=${phases.confidenceGate.toFixed(2)}`);
1564
+ parts.push(`access=${engram.accessCount}`);
1565
+ if (associations.length > 0) parts.push(`edges=${associations.length}`);
1566
+ return parts.join(' | ');
1567
+ }
1568
+ }