agent-working-memory 0.15.9 → 0.16.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 (77) hide show
  1. package/AGENTS.md +36 -0
  2. package/README.md +23 -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 +23 -3
  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 +7 -1
  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 +24 -3
  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
  */
@@ -35,6 +52,7 @@ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from
35
52
  import { embed, cosineSimilarity } from '../core/embeddings.js';
36
53
  import { rerank } from '../core/reranker.js';
37
54
  import { expandQuery } from '../core/query-expander.js';
55
+ import { startRecallProfile } from '../core/recall-telemetry.js';
38
56
  import { computeRecallConfidence } from './confidence.js';
39
57
  import type {
40
58
  Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
@@ -191,6 +209,7 @@ export class ActivationEngine {
191
209
  */
192
210
  async activate(query: ActivationQuery): Promise<ActivationResult[]> {
193
211
  const startTime = performance.now();
212
+ const prof = startRecallProfile();
194
213
  const limit = query.limit ?? 10;
195
214
  const minScore = query.minScore ?? 0.01; // Default: filter out zero-relevance results
196
215
  const useReranker = query.useReranker ?? true;
@@ -205,9 +224,11 @@ export class ActivationEngine {
205
224
  const adaptive = resolveAdaptiveParams(query);
206
225
 
207
226
  // Resolve workspace scope: if workspace is set, search across all agents in that workspace
227
+ prof.begin('agents');
208
228
  const agentIds = query.workspace
209
229
  ? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
210
230
  : [query.agentId];
231
+ prof.end('agents');
211
232
  const isWorkspaceScoped = agentIds.length > 1;
212
233
 
213
234
  // ── Phase -2: temporal expression ──
@@ -230,11 +251,14 @@ export class ActivationEngine {
230
251
  }
231
252
  const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
232
253
  if (pronounPattern.test(queryContext)) {
254
+ prof.begin('pronounEntities');
233
255
  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)))
256
+ // Bounded in SQL. This used to load EVERY active engram to pick five
257
+ // tag words — 280ms median on 2.1% of real prompts. See
258
+ // EngramStore.getTopAccessedTags and docs/recall-latency.md.
259
+ const recentEntities = (await this.store.getTopAccessedTags(agentIds, 10, 'active'))
260
+ .flat()
261
+ .filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t))
238
262
  .filter((v, i, a) => a.indexOf(v) === i)
239
263
  .slice(0, 5);
240
264
  if (recentEntities.length > 0) {
@@ -243,6 +267,7 @@ export class ActivationEngine {
243
267
  } catch { /* non-fatal */ }
244
268
  }
245
269
 
270
+ prof.end('pronounEntities');
246
271
  // Phase 0: Query expansion — add related terms to improve BM25 recall
247
272
  let searchContext = queryContext;
248
273
 
@@ -255,6 +280,7 @@ export class ActivationEngine {
255
280
  // RELEVANCE, so a hub alias cannot drag an irrelevant memory to the top.
256
281
  const aliasAdded = aliasTermsFor(queryContext);
257
282
  if (aliasAdded.length > 0) searchContext = `${searchContext} ${aliasAdded.join(' ')}`;
283
+ prof.begin('expansion');
258
284
  if (useExpansion) {
259
285
  let timer: ReturnType<typeof setTimeout> | undefined;
260
286
  try {
@@ -269,22 +295,27 @@ export class ActivationEngine {
269
295
  }
270
296
  }
271
297
 
298
+ prof.end('expansion');
272
299
  // Phase 1: Embed query for vector similarity (uses coref-expanded context)
273
300
  let queryEmbedding: number[] | null = null;
301
+ prof.begin('embed');
274
302
  try {
275
303
  queryEmbedding = await embed(queryContext);
276
304
  } catch {
277
305
  // Embedding unavailable — fall back to text-only matching
278
306
  }
307
+ prof.end('embed');
279
308
 
280
309
  // Phase 2: Parallel retrieval — dual BM25 + all active engrams
281
310
  // Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
282
311
  // Uses queryContext, not query.context: temporal words must not reach BM25.
312
+ prof.begin('bm25');
283
313
  const keywordQuery = Array.from(tokenize(queryContext)).join(' ');
284
314
  const bm25Keyword = keywordQuery.length > 2
285
315
  ? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
286
316
  : [];
287
317
  const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
318
+ prof.end('bm25');
288
319
 
289
320
  // Merge: take the best BM25 score per engram from either pass
290
321
  const bm25ScoreMap = new Map<string, number>();
@@ -344,7 +375,9 @@ export class ActivationEngine {
344
375
  if (queryEmbedding) {
345
376
  for (const aid of agentIds) {
346
377
  try {
378
+ prof.begin('vector');
347
379
  const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
380
+ prof.end('vector');
348
381
  for (const h of hits) {
349
382
  // pgvector cosine distance: 0 = identical, 2 = opposite.
350
383
  // For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
@@ -415,7 +448,9 @@ export class ActivationEngine {
415
448
  }
416
449
  if (ents.size > 0) {
417
450
  try {
451
+ prof.begin('entityBridge');
418
452
  const entHits = await this.store.searchBM25WithRankMultiAgent(agentIds, Array.from(ents).slice(0, 8).join(' '), ENT_CAP);
453
+ prof.end('entityBridge');
419
454
  let added = 0;
420
455
  for (const h of entHits) {
421
456
  if (added >= ENT_CAP) break;
@@ -433,11 +468,13 @@ export class ActivationEngine {
433
468
  }
434
469
 
435
470
  // ── 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
471
+ // The D9 inverted index resolves query-NAMED entities ("Avery", "ticket 10002") to every
437
472
  // engram indexed under them — deterministic exact lookup, immune to embedding/BM25 vocabulary
438
473
  // 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.
474
+ // audition (exempt from the topN cut, the minScore pool filter and the rerank-pool slice)
475
+ // and the cross-encoder alone decides whether they surface. The fourth exemption, from the
476
+ // clear-winner rerank skip, went away with that branch on 2026-10-09 — the reranker is now
477
+ // unconditional, so an injected candidate is always auditioned.
441
478
  // This is the guarded successor to AWM_ENTITY_FETCH above, whose failure mode was measured:
442
479
  // injected gold entered the pool but was cut before the reranker ever scored it. Bounded
443
480
  // (AWM_ENTITY_INDEX_CAP, default 12) so the audition costs at most one extra rerank batch.
@@ -457,7 +494,9 @@ export class ActivationEngine {
457
494
  let added = 0;
458
495
  for (const term of Array.from(terms).slice(0, 4)) {
459
496
  if (added >= IDX_CAP) break;
497
+ prof.begin('entitySearch');
460
498
  const entities = await this.store.searchEntities(term, 6);
499
+ prof.end('entitySearch');
461
500
  for (const entity of entities) {
462
501
  if (added >= IDX_CAP) break;
463
502
  for (const agentId of agentIds) {
@@ -474,7 +513,9 @@ export class ActivationEngine {
474
513
  if (inPool.stage === 'active' && !(inPool as any).retracted && !inPool.supersededBy) { injectedIds.add(id); added++; }
475
514
  continue;
476
515
  }
516
+ prof.begin('entityFetch');
477
517
  const n = await this.store.getEngram(id);
518
+ prof.end('entityFetch');
478
519
  if (!n || n.stage !== 'active' || (n as any).retracted || n.supersededBy) continue;
479
520
  if (query.memoryType && n.memoryType !== query.memoryType) continue;
480
521
  candidateMap.set(id, n);
@@ -501,10 +542,14 @@ export class ActivationEngine {
501
542
  // Graph walk still needs full Association objects, but it operates on the
502
543
  // top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
503
544
  // cheap (<5ms total).
545
+ prof.note('candidates', candidates.length);
546
+ prof.begin('assocStats');
504
547
  const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
548
+ prof.end('assocStats');
505
549
  // 0.14.4: decay clock is overridable (query.now) so evals against a frozen
506
550
  // snapshot are reproducible across days. See ActivationQuery.now.
507
551
  const nowMs = query.now ?? Date.now();
552
+ prof.begin('scoring');
508
553
  const scored = candidates.map(engram => {
509
554
  const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
510
555
  const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
@@ -619,11 +664,40 @@ export class ActivationEngine {
619
664
  // associations: empty in 0.7.12+ — graph walk lazy-fetches per engram on demand
620
665
  return { engram, score: composite, phaseScores, associations: [] as Association[] };
621
666
  });
667
+ prof.end('scoring');
622
668
 
623
669
  // Phase 3.5: Rocchio pseudo-relevance feedback — expand query with top result terms
624
- // then re-search BM25 to find candidates that keyword search missed
670
+ // then re-search BM25 to find candidates that keyword search missed.
671
+ //
672
+ // DEFAULT OFF since 2026-10-09. `AWM_FEEDBACK_BM25=1` restores it.
673
+ //
674
+ // It ran on 100% of queries and cost 6.7-7.0% of every warm recall (35-41ms
675
+ // mean, plus a getAssociationsFor per newly discovered candidate on 71-79%
676
+ // of them). What it bought had never been measured. Across three suites and
677
+ // 950 queries it is (arrow reads ON -> OFF):
678
+ //
679
+ // identifier n=300 s@1 92.7 -> 93.0 s@5 96.7 -> 97.0 silence 90.0 -> 90.0
680
+ // topic n=450 s@1 92.0 -> 92.2 s@5 96.7 -> 96.9 silence 90.0 -> 90.0
681
+ // public n=200 s@1 56.0 -> 56.0 s@5 61.5 -> 61.5 silence 100 -> 100
682
+ //
683
+ // Three gold ranks move in 950 queries, and two of the three move in the
684
+ // wrong direction for the pass — which is the shape you would expect, since
685
+ // it can only ADD candidates and an added candidate can only displace. The
686
+ // public corpus is the decisive row: it is the only suite with real recall
687
+ // headroom (s@5 61.5%), and there the pass is INERT per query, not merely
688
+ // equal in aggregate — 0 of 200 queries differ in gold rank, top-1 identity
689
+ // or top-1 score, against an ON-vs-ON determinism check that is also 0.
690
+ //
691
+ // Scope that to THIS implementation before concluding anything about the
692
+ // technique: the expansion terms are the first five novel tokens in DOCUMENT
693
+ // ORDER (a Set populated by iterating the top-3 contents), not the top five
694
+ // by weight, and they come mostly from the top-1 result. A weighted term
695
+ // selection is a different experiment and might well pay.
625
696
  const preSorted = scored.sort((a, b) => b.score - a.score);
626
- const topForFeedback = preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1);
697
+ const feedbackEnabled = process.env.AWM_FEEDBACK_BM25 === '1';
698
+ const topForFeedback = feedbackEnabled
699
+ ? preSorted.slice(0, 3).filter(r => r.phaseScores.textMatch > 0.1)
700
+ : [];
627
701
  if (topForFeedback.length > 0) {
628
702
  const feedbackTerms = new Set<string>();
629
703
  for (const item of topForFeedback) {
@@ -635,14 +709,18 @@ export class ActivationEngine {
635
709
  // Take top 5 feedback terms and re-search
636
710
  const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
637
711
  if (extraTerms) {
712
+ prof.begin('feedbackBM25');
638
713
  const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
714
+ prof.end('feedbackBM25');
639
715
  for (const r of feedbackBM25) {
640
716
  if (!candidateMap.has(r.engram.id)) {
641
717
  candidateMap.set(r.engram.id, r.engram);
642
718
  // Score the new candidate
643
719
  const engram = r.engram;
644
720
  const ageDays = (nowMs - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
721
+ prof.begin('feedbackAssoc');
645
722
  const associations = await this.store.getAssociationsFor(engram.id);
723
+ prof.end('feedbackAssoc');
646
724
  const cTokens = tokenize(engram.concept);
647
725
  const ctTokens = tokenize(engram.content);
648
726
  const cJac = jaccard(queryTokens, cTokens);
@@ -851,11 +929,13 @@ export class ActivationEngine {
851
929
  if (injectedIds.has(item.engram.id)) topN.push(item);
852
930
  }
853
931
  }
932
+ prof.begin('graph');
854
933
  if (process.env.AWM_SPREAD === '1' && query.spread !== false) {
855
934
  await this.spreadActivation(topN);
856
935
  } else {
857
936
  await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
858
937
  }
938
+ prof.end('graph');
859
939
 
860
940
  // Phase 6: Initial filter and sort for re-ranking pool
861
941
  // (D11 guard 2/4: injected entity-index candidates are exempt from the minScore floor.)
@@ -881,40 +961,51 @@ export class ActivationEngine {
881
961
  }
882
962
  }
883
963
 
884
- // Reranker skip heuristic (0.7.10+): if BM25 already has a clear winner with
885
- // strong absolute score AND a meaningful gap to the runner-up, the cross-encoder
886
- // is unlikely to change the top result. Skipping saves ~300ms of wall-clock per
887
- // recall on simple queries (40% of post-0.7.9 floor was reranker).
964
+ // Phase 7a: every candidate in the pool is judged by the cross-encoder.
888
965
  //
889
- // Conservative gate (only skip when very confident):
890
- // - top-1 textMatch >= 0.8 (high BM25 + jaccard agreement)
891
- // - top-1 score is at least 1.5× top-2 score (clear separation)
892
- // - rerankPool size <= limit*2 (small pool — reranker has less to do)
966
+ // There used to be a skip here (0.7.10): when the composite already had a
967
+ // "clean winner" — top-1 textMatch >= 0.8 AND top-1 >= 1.5x top-2 — and the
968
+ // pool was small, the cross-encoder was skipped to save its ~400ms.
893
969
  //
894
- // Ambiguous queries (close BM25 scores, weak top-1, large pool) still go through
895
- // the reranker. Disable this heuristic via AWM_DISABLE_RERANK_SKIP=1.
896
- let rerankSkipped = false;
897
- // (D11 guard 4/4: never skip the reranker when entity-index candidates were injected —
898
- // the audition IS the rerank; skipping would return them unjudged or drop them.)
899
- if (useReranker && rerankPool.length >= 2 && injectedIds.size === 0 && process.env.AWM_DISABLE_RERANK_SKIP !== '1') {
900
- const top1 = rerankPool[0];
901
- const top2 = rerankPool[1];
902
- const t1Text = top1.phaseScores.textMatch;
903
- const t1Score = top1.score;
904
- const t2Score = top2.score;
905
- const cleanWinner = t1Text >= 0.8 && t1Score >= 1.5 * Math.max(t2Score, 0.01);
906
- const smallPool = rerankPool.length <= Math.max(limit * 2, 20);
907
- if (cleanWinner && smallPool) {
908
- rerankSkipped = true;
909
- }
910
- }
911
-
912
- if (useReranker && !rerankSkipped && rerankPool.length > 0) {
970
+ // It is gone, measured 2026-10-09 (`npm run profile:recall` at d7417c4,
971
+ // which carries `--skip-pool off` for exactly this):
972
+ //
973
+ // - It never fired at any store size this repository benchmarks: 0/300
974
+ // identifier, 0/200 public, 0/870 in the earlier profile. Its bound was
975
+ // max(limit*2, 20) = 20 against a pool of 24 at the shipped k=3. The
976
+ // bound was in step when written — 0.7.13 had cut the pool to
977
+ // max(limit*2, 15) — and 0.9.0 widened the pool to max(limit*4, 40) for
978
+ // recall without revisiting it.
979
+ //
980
+ // - Forced on, it cost success@1 92.7% -> 91.7%, success@5 96.7% -> 96.3%,
981
+ // MRR 94.6% -> 93.9% on the identifier suite, to save at most 13.6ms of a
982
+ // 528ms recall — under this machine's run-to-run drift. A per-query diff
983
+ // moved exactly the 10 queries it fired on: 3 of those 10 lost rank-1 and
984
+ // one lost the gold out of the top 5 altogether. Its premise, that the
985
+ // cross-encoder rarely changes the top result on a clean winner, does not
986
+ // hold on the queries it selected for itself.
987
+ //
988
+ // - Where it DID fire — a store with under ~20 candidates clearing
989
+ // minScore, which is to say a new install — it left every pool item at
990
+ // rerankerScore = 0, and phase 8 reads that in three places with no idea
991
+ // the stage was skipped: the reranker agreement channel dies; `margin` is
992
+ // 0 so the thin-margin branch ALWAYS fires (x0.4 on every score, which
993
+ // drops `floor` in computeRecallConfidence and so depresses confidence on
994
+ // exactly the clearest queries); and a caller passing abstentionThreshold
995
+ // needs 3 of 3 channels, so it abstains.
996
+ //
997
+ // That last hazard outlives the skip on the two paths where the reranker
998
+ // still does not run: `useReranker: false` (exposed as the MCP
999
+ // `use_reranker` parameter) and the catch below. Separate defect, needs its
1000
+ // own measurement — AWM task 2026-10-09.
1001
+ prof.note('rerankPool', rerankPool.length);
1002
+ if (useReranker && rerankPool.length > 0) {
913
1003
  try {
914
1004
  // Passage selection for the cross-encoder. Truncation exists for a real
915
1005
  // reason: cross-encoders pad to the longest passage in the batch, so one
916
1006
  // 5,000-char memory in a 40-item pool drags everything to ~512 tokens and
917
- // costs 3-4x — and the reranker is already ~90% of warm recall latency.
1007
+ // costs 3-4x — and the reranker is 83% of warm recall latency (measured,
1008
+ // `npm run profile:recall`; this comment claimed ~90% until it was).
918
1009
  //
919
1010
  // But a PREFIX is the wrong budget to spend. On the live store, canonical
920
1011
  // memories are median 1,965 chars and 98.7% exceed 400, so the reranker
@@ -927,15 +1018,21 @@ export class ActivationEngine {
927
1018
  // AWM_RERANK_WINDOW=query spends the SAME budget on the window that
928
1019
  // actually contains the query terms. Cost is unchanged. See
929
1020
  // src/core/rerank-window.ts.
1021
+ prof.begin('passages');
930
1022
  const rrBudget = rerankTruncation();
931
1023
  const rrMode = rerankWindowMode();
932
1024
  const passages = rerankPool.map(r =>
933
1025
  buildRerankPassage(r.engram.concept, r.engram.content, queryContext, rrBudget, rrMode, r.engram.tags));
1026
+ prof.end('passages');
1027
+ prof.note('passageChars', passages.reduce((s, p) => s + p.length, 0));
1028
+ prof.note('passageMaxChars', passages.reduce((m, p) => Math.max(m, p.length), 0));
934
1029
  let rerankTimer: ReturnType<typeof setTimeout> | undefined;
1030
+ prof.begin('rerank');
935
1031
  const rerankResults = await Promise.race([
936
1032
  rerank(queryContext, passages),
937
1033
  new Promise<never>((_, reject) => { rerankTimer = setTimeout(() => reject(new Error('reranker timeout')), 10000); }),
938
1034
  ]).finally(() => { if (rerankTimer) clearTimeout(rerankTimer); });
1035
+ prof.end('rerank');
939
1036
 
940
1037
  // Adaptive reranker blend (Codex recommendation):
941
1038
  // When BM25/text signals are strong, trust them more; when weak, lean on reranker.
@@ -1008,12 +1105,14 @@ export class ActivationEngine {
1008
1105
  // on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
1009
1106
  const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
1010
1107
  if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
1108
+ prof.finish({ results: 0, abstained: 'channels' });
1011
1109
  return [];
1012
1110
  }
1013
1111
 
1014
1112
  // Soft penalty: only 1 channel agrees or margin is thin
1015
1113
  if (channelsAgreeing < 2 || margin < 0.05) {
1016
1114
  if (abstentionThreshold > 0) {
1115
+ prof.finish({ results: 0, abstained: 'soft' });
1017
1116
  return [];
1018
1117
  }
1019
1118
  for (const item of rerankPool) {
@@ -1033,6 +1132,7 @@ export class ActivationEngine {
1033
1132
  const variance = topRerankerScores.reduce((s, v) => s + (v - meanScore) ** 2, 0) / topRerankerScores.length;
1034
1133
 
1035
1134
  if (maxScore < abstentionThreshold || (maxScore < 0.5 && variance < 0.01)) {
1135
+ prof.finish({ results: 0, abstained: 'legacy' });
1036
1136
  return [];
1037
1137
  }
1038
1138
  }
@@ -1067,6 +1167,7 @@ export class ActivationEngine {
1067
1167
  confidence,
1068
1168
  threshold: requireConfidence,
1069
1169
  });
1170
+ prof.finish({ results: 0, abstained: 'confidence' });
1070
1171
  return [];
1071
1172
  }
1072
1173
 
@@ -1105,7 +1206,7 @@ export class ActivationEngine {
1105
1206
  // AWM_SNIPPET_WEIGHT=rarity weights a hit by 1/(occurrences of that token
1106
1207
  // in THIS document), so one occurrence of a rare term outweighs many of a
1107
1208
  // common one. Doc-local: no corpus statistics, no extra queries, no
1108
- // latency. Default OFF preserves the shipped behaviour exactly.
1209
+ // latency. Default OFF preserves the shipped behavior exactly.
1109
1210
  const rarityMode = process.env.AWM_SNIPPET_WEIGHT === 'rarity' || process.env.AWM_SNIPPET_WEIGHT === 'anchor';
1110
1211
  const hits: number[] = [];
1111
1212
  const hitW: number[] = [];
@@ -1210,8 +1311,11 @@ export class ActivationEngine {
1210
1311
  // Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
1211
1312
  if (!query.internal) {
1212
1313
  for (const id of activatedIds) {
1314
+ prof.begin('touch');
1213
1315
  await this.store.touchEngram(id);
1316
+ prof.end('touch');
1214
1317
  }
1318
+ prof.begin('hebbian');
1215
1319
  this.coActivationBuffer.pushBatch(activatedIds);
1216
1320
  // Validation-gated Hebbian: defer strengthening until feedback arrives
1217
1321
  const pairs = this.coActivationBuffer.getCoActivatedPairs(10_000);
@@ -1222,9 +1326,11 @@ export class ActivationEngine {
1222
1326
  if (!seen.has(key)) { seen.add(key); uniquePairs.push([a, b]); }
1223
1327
  }
1224
1328
  this.validationGate.addPending(activatedIds, uniquePairs);
1329
+ prof.end('hebbian');
1225
1330
 
1226
1331
  // Log activation event for eval
1227
1332
  const latencyMs = performance.now() - startTime;
1333
+ prof.begin('logEvent');
1228
1334
  const eventId = randomUUID();
1229
1335
  await this.store.logActivationEvent({
1230
1336
  id: eventId,
@@ -1236,6 +1342,7 @@ export class ActivationEngine {
1236
1342
  latencyMs,
1237
1343
  engramIds: activatedIds,
1238
1344
  });
1345
+ prof.end('logEvent');
1239
1346
  // 0.14.3: expose the event id so callers can hand it back on
1240
1347
  // memory_feedback. Before this, the id was generated here and dropped —
1241
1348
  // every one of the 872 retrieval_feedback rows in the live store has
@@ -1246,6 +1353,7 @@ export class ActivationEngine {
1246
1353
  this.lastActivationEventId = eventId;
1247
1354
  }
1248
1355
 
1356
+ prof.finish({ results: results.length });
1249
1357
  return results;
1250
1358
  }
1251
1359
 
package/src/index.ts CHANGED
@@ -204,7 +204,7 @@ async function main() {
204
204
  }
205
205
  }, 10 * 60_000) : null;
206
206
 
207
- // Pre-load ML models (downloads on first run: embeddings ~22MB, reranker ~22MB, expander ~80MB)
207
+ // Pre-load ML models (first run downloads ~600MB: embeddings 134MB, reranker 92MB fp32, expander 377MB)
208
208
  getEmbedder().catch(err => console.warn('Embedding model unavailable:', err.message));
209
209
  getReranker().catch(err => console.warn('Reranker model unavailable:', err.message));
210
210
  getExpander().catch(err => console.warn('Query expander model unavailable:', err.message));