agent-working-memory 0.15.9 → 0.16.1

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 (77) hide show
  1. package/AGENTS.md +36 -0
  2. package/README.md +24 -12
  3. package/dist/adapters/common.d.ts +1 -1
  4. package/dist/adapters/common.d.ts.map +1 -1
  5. package/dist/adapters/common.js +33 -20
  6. package/dist/adapters/common.js.map +1 -1
  7. package/dist/core/embeddings.d.ts +1 -1
  8. package/dist/core/embeddings.js +1 -1
  9. package/dist/core/entity-extract.js +1 -1
  10. package/dist/core/query-expander.d.ts +4 -2
  11. package/dist/core/query-expander.d.ts.map +1 -1
  12. package/dist/core/query-expander.js +4 -2
  13. package/dist/core/query-expander.js.map +1 -1
  14. package/dist/core/recall-config.d.ts +1 -1
  15. package/dist/core/recall-config.d.ts.map +1 -1
  16. package/dist/core/recall-config.js +6 -1
  17. package/dist/core/recall-config.js.map +1 -1
  18. package/dist/core/recall-telemetry.d.ts +15 -0
  19. package/dist/core/recall-telemetry.d.ts.map +1 -0
  20. package/dist/core/recall-telemetry.js +108 -0
  21. package/dist/core/recall-telemetry.js.map +1 -0
  22. package/dist/core/rerank-window.d.ts +5 -4
  23. package/dist/core/rerank-window.d.ts.map +1 -1
  24. package/dist/core/rerank-window.js +5 -4
  25. package/dist/core/rerank-window.js.map +1 -1
  26. package/dist/core/reranker.d.ts +2 -1
  27. package/dist/core/reranker.d.ts.map +1 -1
  28. package/dist/core/reranker.js +33 -5
  29. package/dist/core/reranker.js.map +1 -1
  30. package/dist/core/salience.js +1 -1
  31. package/dist/core/salience.js.map +1 -1
  32. package/dist/core/token-budget.d.ts +2 -2
  33. package/dist/core/token-budget.js +2 -2
  34. package/dist/core/whoami.d.ts +1 -1
  35. package/dist/engine/activation.d.ts +30 -13
  36. package/dist/engine/activation.d.ts.map +1 -1
  37. package/dist/engine/activation.js +159 -50
  38. package/dist/engine/activation.js.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/mcp.js +98 -98
  42. package/dist/mcp.js.map +1 -1
  43. package/dist/storage/pglite.d.ts +28 -0
  44. package/dist/storage/pglite.d.ts.map +1 -1
  45. package/dist/storage/pglite.js +55 -0
  46. package/dist/storage/pglite.js.map +1 -1
  47. package/dist/storage/postgres.d.ts +28 -0
  48. package/dist/storage/postgres.d.ts.map +1 -1
  49. package/dist/storage/postgres.js +55 -0
  50. package/dist/storage/postgres.js.map +1 -1
  51. package/dist/storage/sqlite.d.ts +28 -0
  52. package/dist/storage/sqlite.d.ts.map +1 -1
  53. package/dist/storage/sqlite.js +56 -0
  54. package/dist/storage/sqlite.js.map +1 -1
  55. package/llms.txt +437 -0
  56. package/package.json +106 -100
  57. package/plugin/.claude-plugin/plugin.json +1 -1
  58. package/plugin/README.md +1 -1
  59. package/plugin/bin/awm-mcp-launcher.cjs +2 -2
  60. package/plugin/skills/awm-memory/SKILL.md +33 -20
  61. package/src/adapters/common.ts +33 -20
  62. package/src/core/embeddings.ts +1 -1
  63. package/src/core/entity-extract.ts +1 -1
  64. package/src/core/query-expander.ts +4 -2
  65. package/src/core/recall-config.ts +6 -1
  66. package/src/core/recall-telemetry.ts +131 -0
  67. package/src/core/rerank-window.ts +159 -158
  68. package/src/core/reranker.ts +150 -121
  69. package/src/core/salience.ts +1 -1
  70. package/src/core/token-budget.ts +160 -160
  71. package/src/core/whoami.ts +120 -120
  72. package/src/engine/activation.ts +159 -51
  73. package/src/index.ts +1 -1
  74. package/src/mcp.ts +1475 -1475
  75. package/src/storage/pglite.ts +49 -0
  76. package/src/storage/postgres.ts +49 -0
  77. package/src/storage/sqlite.ts +51 -0
@@ -6,25 +6,42 @@
6
6
  * Cognitive retrieval pipeline — phases as shipped (D4 honesty pass 2026-07-30;
7
7
  * default-OFF phases are marked, since operators tune from this header):
8
8
  * -1. Coreference expansion (conditional: query contains pronouns)
9
- * 0. Query expansion (flan-t5-small; caller-gated, MCP default ON)
9
+ * 0. Query expansion (flan-t5-small; caller-gated. The ENGINE default is OFF and
10
+ * AWM_DEFAULT_EXPANSION=1 flips it — but MCP memory_recall defaults
11
+ * use_expansion to TRUE, so it DOES run on the path most callers use.
12
+ * HTTP /memory/activate and the prime hook pass nothing, so they get OFF.)
10
13
  * 1. Vector embedding (bge-small 384d)
11
14
  * 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)
15
+ * 3a. Candidate fetch — and entity-index candidate injection splices in HERE,
16
+ * before any scoring (DEFAULT OFF; AWM_ENTITY_INDEX_FETCH=1 — D11
17
+ * 2026-07-30: D9 inverted-index lookup of query-named entities; injected
18
+ * candidates get no boost but a guaranteed rerank audition)
19
+ * 3b. Per-candidate scoring (BM25, Jaccard, cosine floor, ACT-R decay,
20
+ * Hebbian boost, confidence gate — all computed TOGETHER here, not as
21
+ * separate later passes)
22
+ * 3.5 Rocchio pseudo-relevance feedback (DEFAULT OFF since 2026-10-09;
23
+ * AWM_FEEDBACK_BM25=1 — measured at 7% of a recall for nothing, see phase 3.5)
24
+ * 3.7 Entity-bridge boost (default ON. AWM_DISABLE_ENTITY_BRIDGE disables it —
25
+ * the check is for PRESENCE, not value, so =0 disables it too; unset to
26
+ * re-enable.)
19
27
  * 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)
28
+ * 4/5 Graph walk. A plain beam walk over Hebbian + temporal edges is the
29
+ * DEFAULT; the iterative SPREADING-activation variant is the opt-in
30
+ * (AWM_SPREAD=1 — parked after displacing-gold regressions, see D11)
22
31
  * 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;
32
+ * 7. Cross-encoder rerank (ms-marco; every pooled candidate — the
33
+ * 0.7.10 clear-winner skip was removed 2026-10-09, see phase 7a)
34
+ * 8. Multi-channel OOD detection + agreement gate; supersession penalty (8c);
26
35
  * abstention enforced only when caller passes require_confidence
27
36
  * 9. Final sort, granularity, confidence attach
37
+ * 9b. rerank2 second-pass reorder (feedback / edge-strength / class bonuses).
38
+ * Engine default OFF (AWM_RERANK2=1), but awm setup, the plugin and the
39
+ * Desktop bundle all switch it on, so a normal install HAS it.
40
+ *
41
+ * Phase numbers are the ones used in the code below, so they can be grepped.
42
+ * Corrected 2026-10-09: the entity-index injection was listed as a second
43
+ * "3.5" (colliding with Rocchio) and placed after 3.7 though it runs inside
44
+ * 3a; 9b was missing entirely.
28
45
  *
29
46
  * Logs activation events for eval metrics.
30
47
  */
@@ -34,6 +51,7 @@ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from
34
51
  import { embed, cosineSimilarity } from '../core/embeddings.js';
35
52
  import { rerank } from '../core/reranker.js';
36
53
  import { expandQuery } from '../core/query-expander.js';
54
+ import { startRecallProfile } from '../core/recall-telemetry.js';
37
55
  import { computeRecallConfidence } from './confidence.js';
38
56
  import { reorderByReranker, rerank2Enabled, rerank2WindowSize } from '../core/rerank2.js';
39
57
  import { buildRerankPassage, rerankTruncation, rerankWindowMode } from '../core/rerank-window.js';
@@ -176,6 +194,7 @@ export class ActivationEngine {
176
194
  */
177
195
  async activate(query) {
178
196
  const startTime = performance.now();
197
+ const prof = startRecallProfile();
179
198
  const limit = query.limit ?? 10;
180
199
  const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
181
200
  const useReranker = query.useReranker ?? true;
@@ -189,9 +208,11 @@ export class ActivationEngine {
189
208
  const requireConfidence = query.requireConfidence ?? 0;
190
209
  const adaptive = resolveAdaptiveParams(query);
191
210
  // Resolve workspace scope: if workspace is set, search across all agents in that workspace
211
+ prof.begin('agents');
192
212
  const agentIds = query.workspace
193
213
  ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
194
214
  : [query.agentId];
215
+ prof.end('agents');
195
216
  const isWorkspaceScoped = agentIds.length > 1;
196
217
  // ── Phase -2: temporal expression ──
197
218
  // Nothing downstream parses dates, so "from last Thursday" was being spent
@@ -214,11 +235,14 @@ export class ActivationEngine {
214
235
  }
215
236
  const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
216
237
  if (pronounPattern.test(queryContext)) {
238
+ prof.begin('pronounEntities');
217
239
  try {
218
- const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
219
- .sort((a, b) => b.accessCount - a.accessCount)
220
- .slice(0, 10)
221
- .flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
240
+ // Bounded in SQL. This used to load EVERY active engram to pick five
241
+ // tag words — 280ms median on 2.1% of real prompts. See
242
+ // EngramStore.getTopAccessedTags and docs/recall-latency.md.
243
+ const recentEntities = (await this.store.getTopAccessedTags(agentIds, 10, 'active'))
244
+ .flat()
245
+ .filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t))
222
246
  .filter((v, i, a) => a.indexOf(v) === i)
223
247
  .slice(0, 5);
224
248
  if (recentEntities.length > 0) {
@@ -227,6 +251,7 @@ export class ActivationEngine {
227
251
  }
228
252
  catch { /* non-fatal */ }
229
253
  }
254
+ prof.end('pronounEntities');
230
255
  // Phase 0: Query expansion — add related terms to improve BM25 recall
231
256
  let searchContext = queryContext;
232
257
  // Project-dialect aliases. Added to the BM25 SEARCH STRING ONLY — never to
@@ -239,6 +264,7 @@ export class ActivationEngine {
239
264
  const aliasAdded = aliasTermsFor(queryContext);
240
265
  if (aliasAdded.length > 0)
241
266
  searchContext = `${searchContext} ${aliasAdded.join(' ')}`;
267
+ prof.begin('expansion');
242
268
  if (useExpansion) {
243
269
  let timer;
244
270
  try {
@@ -255,22 +281,27 @@ export class ActivationEngine {
255
281
  clearTimeout(timer);
256
282
  }
257
283
  }
284
+ prof.end('expansion');
258
285
  // Phase 1: Embed query for vector similarity (uses coref-expanded context)
259
286
  let queryEmbedding = null;
287
+ prof.begin('embed');
260
288
  try {
261
289
  queryEmbedding = await embed(queryContext);
262
290
  }
263
291
  catch {
264
292
  // Embedding unavailable — fall back to text-only matching
265
293
  }
294
+ prof.end('embed');
266
295
  // Phase 2: Parallel retrieval — dual BM25 + all active engrams
267
296
  // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
268
297
  // Uses queryContext, not query.context: temporal words must not reach BM25.
298
+ prof.begin('bm25');
269
299
  const keywordQuery = Array.from(tokenize(queryContext)).join(' ');
270
300
  const bm25Keyword = keywordQuery.length > 2
271
301
  ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
272
302
  : [];
273
303
  const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
304
+ prof.end('bm25');
274
305
  // Merge: take the best BM25 score per engram from either pass
275
306
  const bm25ScoreMap = new Map();
276
307
  const bm25EngramMap = new Map();
@@ -326,7 +357,9 @@ export class ActivationEngine {
326
357
  if (queryEmbedding) {
327
358
  for (const aid of agentIds) {
328
359
  try {
360
+ prof.begin('vector');
329
361
  const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
362
+ prof.end('vector');
330
363
  for (const h of hits) {
331
364
  // pgvector cosine distance: 0 = identical, 2 = opposite.
332
365
  // For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
@@ -401,7 +434,9 @@ export class ActivationEngine {
401
434
  }
402
435
  if (ents.size > 0) {
403
436
  try {
437
+ prof.begin('entityBridge');
404
438
  const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
439
+ prof.end('entityBridge');
405
440
  let added = 0;
406
441
  for (const h of entHits) {
407
442
  if (added >= ENT_CAP)
@@ -424,11 +459,13 @@ export class ActivationEngine {
424
459
  }
425
460
  }
426
461
  // ── D11 (2026-07-30): ENTITY-INDEX CANDIDATE INJECTION (default-OFF, AWM_ENTITY_INDEX_FETCH=1) ──
427
- // The D9 inverted index resolves query-NAMED entities ("Seetha", "ticket 18999") to every
462
+ // The D9 inverted index resolves query-NAMED entities ("Avery", "ticket 10002") to every
428
463
  // engram indexed under them — deterministic exact lookup, immune to embedding/BM25 vocabulary
429
464
  // mismatch. Injected candidates get NO score boost; instead they are GUARANTEED a rerank
430
- // audition (exempt from the topN cut, the minScore pool filter, the rerank-pool slice, and
431
- // the rerank-skip heuristic) and the cross-encoder alone decides whether they surface.
465
+ // audition (exempt from the topN cut, the minScore pool filter and the rerank-pool slice)
466
+ // and the cross-encoder alone decides whether they surface. The fourth exemption, from the
467
+ // clear-winner rerank skip, went away with that branch on 2026-10-09 — the reranker is now
468
+ // unconditional, so an injected candidate is always auditioned.
432
469
  // This is the guarded successor to AWM_ENTITY_FETCH above, whose failure mode was measured:
433
470
  // injected gold entered the pool but was cut before the reranker ever scored it. Bounded
434
471
  // (AWM_ENTITY_INDEX_CAP, default 12) so the audition costs at most one extra rerank batch.
@@ -451,7 +488,9 @@ export class ActivationEngine {
451
488
  for (const term of Array.from(terms).slice(0, 4)) {
452
489
  if (added >= IDX_CAP)
453
490
  break;
491
+ prof.begin('entitySearch');
454
492
  const entities = await this.store.searchEntities(term, 6);
493
+ prof.end('entitySearch');
455
494
  for (const entity of entities) {
456
495
  if (added >= IDX_CAP)
457
496
  break;
@@ -475,7 +514,9 @@ export class ActivationEngine {
475
514
  }
476
515
  continue;
477
516
  }
517
+ prof.begin('entityFetch');
478
518
  const n = await this.store.getEngram(id);
519
+ prof.end('entityFetch');
479
520
  if (!n || n.stage !== 'active' || n.retracted || n.supersededBy)
480
521
  continue;
481
522
  if (query.memoryType && n.memoryType !== query.memoryType)
@@ -505,10 +546,14 @@ export class ActivationEngine {
505
546
  // Graph walk still needs full Association objects, but it operates on the
506
547
  // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
507
548
  // cheap (<5ms total).
549
+ prof.note('candidates', candidates.length);
550
+ prof.begin('assocStats');
508
551
  const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
552
+ prof.end('assocStats');
509
553
  // 0.14.4: decay clock is overridable (query.now) so evals against a frozen
510
554
  // snapshot are reproducible across days. See ActivationQuery.now.
511
555
  const nowMs = query.now ?? Date.now();
556
+ prof.begin('scoring');
512
557
  const scored = candidates.map(engram => {
513
558
  const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
514
559
  const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
@@ -607,10 +652,39 @@ export class ActivationEngine {
607
652
  // associations: empty in 0.7.12+ — graph walk lazy-fetches per engram on demand
608
653
  return { engram, score: composite, phaseScores, associations: [] };
609
654
  });
655
+ prof.end('scoring');
610
656
  // Phase 3.5: Rocchio pseudo-relevance feedback — expand query with top result terms
611
- // then re-search BM25 to find candidates that keyword search missed
657
+ // then re-search BM25 to find candidates that keyword search missed.
658
+ //
659
+ // DEFAULT OFF since 2026-10-09. `AWM_FEEDBACK_BM25=1` restores it.
660
+ //
661
+ // It ran on 100% of queries and cost 6.7-7.0% of every warm recall (35-41ms
662
+ // mean, plus a getAssociationsFor per newly discovered candidate on 71-79%
663
+ // of them). What it bought had never been measured. Across three suites and
664
+ // 950 queries it is (arrow reads ON -> OFF):
665
+ //
666
+ // identifier n=300 s@1 92.7 -> 93.0 s@5 96.7 -> 97.0 silence 90.0 -> 90.0
667
+ // topic n=450 s@1 92.0 -> 92.2 s@5 96.7 -> 96.9 silence 90.0 -> 90.0
668
+ // public n=200 s@1 56.0 -> 56.0 s@5 61.5 -> 61.5 silence 100 -> 100
669
+ //
670
+ // Three gold ranks move in 950 queries, and two of the three move in the
671
+ // wrong direction for the pass — which is the shape you would expect, since
672
+ // it can only ADD candidates and an added candidate can only displace. The
673
+ // public corpus is the decisive row: it is the only suite with real recall
674
+ // headroom (s@5 61.5%), and there the pass is INERT per query, not merely
675
+ // equal in aggregate — 0 of 200 queries differ in gold rank, top-1 identity
676
+ // or top-1 score, against an ON-vs-ON determinism check that is also 0.
677
+ //
678
+ // Scope that to THIS implementation before concluding anything about the
679
+ // technique: the expansion terms are the first five novel tokens in DOCUMENT
680
+ // ORDER (a Set populated by iterating the top-3 contents), not the top five
681
+ // by weight, and they come mostly from the top-1 result. A weighted term
682
+ // selection is a different experiment and might well pay.
612
683
  const preSorted = scored.sort((a, b) => b.score - a.score);
613
- const topForFeedback = preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1);
684
+ const feedbackEnabled = process.env.AWM_FEEDBACK_BM25 === '1';
685
+ const topForFeedback = feedbackEnabled
686
+ ? preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1)
687
+ : [];
614
688
  if (topForFeedback.length > 0) {
615
689
  const feedbackTerms = new Set();
616
690
  for (const item of topForFeedback) {
@@ -623,14 +697,18 @@ export class ActivationEngine {
623
697
  // Take top 5 feedback terms and re-search
624
698
  const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
625
699
  if (extraTerms) {
700
+ prof.begin('feedbackBM25');
626
701
  const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
702
+ prof.end('feedbackBM25');
627
703
  for (const r of feedbackBM25) {
628
704
  if (!candidateMap.has(r.engram.id)) {
629
705
  candidateMap.set(r.engram.id, r.engram);
630
706
  // Score the new candidate
631
707
  const engram = r.engram;
632
708
  const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
709
+ prof.begin('feedbackAssoc');
633
710
  const associations = await this.store.getAssociationsFor(engram.id);
711
+ prof.end('feedbackAssoc');
634
712
  const cTokens = tokenize(engram.concept);
635
713
  const ctTokens = tokenize(engram.content);
636
714
  const cJac = jaccard(queryTokens, cTokens);
@@ -840,12 +918,14 @@ export class ActivationEngine {
840
918
  topN.push(item);
841
919
  }
842
920
  }
921
+ prof.begin('graph');
843
922
  if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
844
923
  await this.spreadActivation(topN);
845
924
  }
846
925
  else {
847
926
  await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
848
927
  }
928
+ prof.end('graph');
849
929
  // Phase 6: Initial filter and sort for re-ranking pool
850
930
  // (D11 guard 2/4: injected entity-index candidates are exempt from the minScore floor.)
851
931
  const pool = topN
@@ -869,39 +949,51 @@ export class ActivationEngine {
869
949
  rerankPool.push(item);
870
950
  }
871
951
  }
872
- // Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
873
- // strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
874
- // is unlikely to change the top result. Skipping saves ~300ms of wall-clock per
875
- // recall on simple queries (40% of post-0.7.9 floor was reranker).
952
+ // Phase 7a: every candidate in the pool is judged by the cross-encoder.
876
953
  //
877
- // Conservative gate (only skip when very confident):
878
- // - top-1 textMatch >= 0.8 (high BM25 + jaccard agreement)
879
- // - top-1 score is at least 1.5× top-2 score (clear separation)
880
- // - rerankPool size <= limit*2 (small pool — reranker has less to do)
954
+ // There used to be a skip here (0.7.10): when the composite already had a
955
+ // "clean winner" — top-1 textMatch >= 0.8 AND top-1 >= 1.5x top-2 — and the
956
+ // pool was small, the cross-encoder was skipped to save its ~400ms.
881
957
  //
882
- // Ambiguous queries (close BM25 scores, weak top-1, large pool) still go through
883
- // the reranker. Disable this heuristic via AWM_DISABLE_RERANK_SKIP=1.
884
- let rerankSkipped = false;
885
- // (D11 guard 4/4: never skip the reranker when entity-index candidates were injected —
886
- // the audition IS the rerank; skipping would return them unjudged or drop them.)
887
- if (useReranker && rerankPool.length >= 2 && injectedIds.size === 0 && process.env.AWM_DISABLE_RERANK_SKIP !== '1') {
888
- const top1 = rerankPool[0];
889
- const top2 = rerankPool[1];
890
- const t1Text = top1.phaseScores.textMatch;
891
- const t1Score = top1.score;
892
- const t2Score = top2.score;
893
- const cleanWinner = t1Text >= 0.8 && t1Score >= 1.5 * Math.max(t2Score, 0.01);
894
- const smallPool = rerankPool.length <= Math.max(limit * 2, 20);
895
- if (cleanWinner && smallPool) {
896
- rerankSkipped = true;
897
- }
898
- }
899
- if (useReranker && !rerankSkipped && rerankPool.length > 0) {
958
+ // It is gone, measured 2026-10-09 (`npm run profile:recall` at d7417c4,
959
+ // which carries `--skip-pool off` for exactly this):
960
+ //
961
+ // - It never fired at any store size this repository benchmarks: 0/300
962
+ // identifier, 0/200 public, 0/870 in the earlier profile. Its bound was
963
+ // max(limit*2, 20) = 20 against a pool of 24 at the shipped k=3. The
964
+ // bound was in step when written — 0.7.13 had cut the pool to
965
+ // max(limit*2, 15) — and 0.9.0 widened the pool to max(limit*4, 40) for
966
+ // recall without revisiting it.
967
+ //
968
+ // - Forced on, it cost success@1 92.7% -> 91.7%, success@5 96.7% -> 96.3%,
969
+ // MRR 94.6% -> 93.9% on the identifier suite, to save at most 13.6ms of a
970
+ // 528ms recall — under this machine's run-to-run drift. A per-query diff
971
+ // moved exactly the 10 queries it fired on: 3 of those 10 lost rank-1 and
972
+ // one lost the gold out of the top 5 altogether. Its premise, that the
973
+ // cross-encoder rarely changes the top result on a clean winner, does not
974
+ // hold on the queries it selected for itself.
975
+ //
976
+ // - Where it DID fire — a store with under ~20 candidates clearing
977
+ // minScore, which is to say a new install — it left every pool item at
978
+ // rerankerScore = 0, and phase 8 reads that in three places with no idea
979
+ // the stage was skipped: the reranker agreement channel dies; `margin` is
980
+ // 0 so the thin-margin branch ALWAYS fires (x0.4 on every score, which
981
+ // drops `floor` in computeRecallConfidence and so depresses confidence on
982
+ // exactly the clearest queries); and a caller passing abstentionThreshold
983
+ // needs 3 of 3 channels, so it abstains.
984
+ //
985
+ // That last hazard outlives the skip on the two paths where the reranker
986
+ // still does not run: `useReranker: false` (exposed as the MCP
987
+ // `use_reranker` parameter) and the catch below. Separate defect, needs its
988
+ // own measurement — AWM task 2026-10-09.
989
+ prof.note('rerankPool', rerankPool.length);
990
+ if (useReranker && rerankPool.length > 0) {
900
991
  try {
901
992
  // Passage selection for the cross-encoder. Truncation exists for a real
902
993
  // reason: cross-encoders pad to the longest passage in the batch, so one
903
994
  // 5,000-char memory in a 40-item pool drags everything to ~512 tokens and
904
- // costs 3-4x — and the reranker is already ~90% of warm recall latency.
995
+ // costs 3-4x — and the reranker is 83% of warm recall latency (measured,
996
+ // `npm run profile:recall`; this comment claimed ~90% until it was).
905
997
  //
906
998
  // But a PREFIX is the wrong budget to spend. On the live store, canonical
907
999
  // memories are median 1,965 chars and 98.7% exceed 400, so the reranker
@@ -914,15 +1006,21 @@ export class ActivationEngine {
914
1006
  // AWM_RERANK_WINDOW=query spends the SAME budget on the window that
915
1007
  // actually contains the query terms. Cost is unchanged. See
916
1008
  // src/core/rerank-window.ts.
1009
+ prof.begin('passages');
917
1010
  const rrBudget = rerankTruncation();
918
1011
  const rrMode = rerankWindowMode();
919
1012
  const passages = rerankPool.map(r => buildRerankPassage(r.engram.concept, r.engram.content, queryContext, rrBudget, rrMode, r.engram.tags));
1013
+ prof.end('passages');
1014
+ prof.note('passageChars', passages.reduce((s, p) => s + p.length, 0));
1015
+ prof.note('passageMaxChars', passages.reduce((m, p) => Math.max(m, p.length), 0));
920
1016
  let rerankTimer;
1017
+ prof.begin('rerank');
921
1018
  const rerankResults = await Promise.race([
922
1019
  rerank(queryContext, passages),
923
1020
  new Promise((_, reject) => { rerankTimer = setTimeout(() => reject(new Error('reranker timeout')), 10000); }),
924
1021
  ]).finally(() => { if (rerankTimer)
925
1022
  clearTimeout(rerankTimer); });
1023
+ prof.end('rerank');
926
1024
  // Adaptive reranker blend (Codex recommendation):
927
1025
  // When BM25/text signals are strong, trust them more; when weak, lean on reranker.
928
1026
  const bm25Max = Math.max(...rerankPool.map(r => r.phaseScores.textMatch));
@@ -987,11 +1085,13 @@ export class ActivationEngine {
987
1085
  // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
988
1086
  const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
989
1087
  if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
1088
+ prof.finish({ results: 0, abstained: 'channels' });
990
1089
  return [];
991
1090
  }
992
1091
  // Soft penalty: only 1 channel agrees or margin is thin
993
1092
  if (channelsAgreeing < 2 || margin < 0.05) {
994
1093
  if (abstentionThreshold > 0) {
1094
+ prof.finish({ results: 0, abstained: 'soft' });
995
1095
  return [];
996
1096
  }
997
1097
  for (const item of rerankPool) {
@@ -1009,6 +1109,7 @@ export class ActivationEngine {
1009
1109
  const meanScore = topRerankerScores.reduce((s, v) => s + v, 0) / topRerankerScores.length;
1010
1110
  const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
1011
1111
  if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
1112
+ prof.finish({ results: 0, abstained: 'legacy' });
1012
1113
  return [];
1013
1114
  }
1014
1115
  }
@@ -1040,6 +1141,7 @@ export class ActivationEngine {
1040
1141
  confidence,
1041
1142
  threshold: requireConfidence,
1042
1143
  });
1144
+ prof.finish({ results: 0, abstained: 'confidence' });
1043
1145
  return [];
1044
1146
  }
1045
1147
  // Confidence-adaptive output granularity (Paper 3: cognitive teaming).
@@ -1076,7 +1178,7 @@ export class ActivationEngine {
1076
1178
  // AWM_SNIPPET_WEIGHT=rarity weights a hit by 1/(occurrences of that token
1077
1179
  // in THIS document), so one occurrence of a rare term outweighs many of a
1078
1180
  // common one. Doc-local: no corpus statistics, no extra queries, no
1079
- // latency. Default OFF preserves the shipped behaviour exactly.
1181
+ // latency. Default OFF preserves the shipped behavior exactly.
1080
1182
  const rarityMode = process.env.AWM_SNIPPET_WEIGHT === 'rarity' || process.env.AWM_SNIPPET_WEIGHT === 'anchor';
1081
1183
  const hits = [];
1082
1184
  const hitW = [];
@@ -1186,8 +1288,11 @@ export class ActivationEngine {
1186
1288
  // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
1187
1289
  if (!query.internal) {
1188
1290
  for (const id of activatedIds) {
1291
+ prof.begin('touch');
1189
1292
  await this.store.touchEngram(id);
1293
+ prof.end('touch');
1190
1294
  }
1295
+ prof.begin('hebbian');
1191
1296
  this.coActivationBuffer.pushBatch(activatedIds);
1192
1297
  // Validation-gated Hebbian: defer strengthening until feedback arrives
1193
1298
  const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
@@ -1201,8 +1306,10 @@ export class ActivationEngine {
1201
1306
  }
1202
1307
  }
1203
1308
  this.validationGate.addPending(activatedIds, uniquePairs);
1309
+ prof.end('hebbian');
1204
1310
  // Log activation event for eval
1205
1311
  const latencyMs = performance.now() - startTime;
1312
+ prof.begin('logEvent');
1206
1313
  const eventId = randomUUID();
1207
1314
  await this.store.logActivationEvent({
1208
1315
  id: eventId,
@@ -1214,6 +1321,7 @@ export class ActivationEngine {
1214
1321
  latencyMs,
1215
1322
  engramIds: activatedIds,
1216
1323
  });
1324
+ prof.end('logEvent');
1217
1325
  // 0.14.3: expose the event id so callers can hand it back on
1218
1326
  // memory_feedback. Before this, the id was generated here and dropped —
1219
1327
  // every one of the 872 retrieval_feedback rows in the live store has
@@ -1224,6 +1332,7 @@ export class ActivationEngine {
1224
1332
  r.activationEventId = eventId;
1225
1333
  this.lastActivationEventId = eventId;
1226
1334
  }
1335
+ prof.finish({ results: results.length });
1227
1336
  return results;
1228
1337
  }
1229
1338
  /** Id of the most recent non-internal activation this engine logged (0.14.3). */