agent-working-memory 0.11.0 → 0.12.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.
- package/README.md +29 -0
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +63 -3
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +329 -306
- package/dist/adapters/common.js.map +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +29 -7
- package/dist/api/routes.js.map +1 -1
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +174 -170
- package/dist/coordination/routes.js.map +1 -1
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +3 -0
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/entity-extract.d.ts +3 -0
- package/dist/core/entity-extract.d.ts.map +1 -0
- package/dist/core/entity-extract.js +47 -0
- package/dist/core/entity-extract.js.map +1 -0
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +14 -2
- package/dist/core/salience.js.map +1 -1
- package/dist/core/whoami.d.ts +24 -0
- package/dist/core/whoami.d.ts.map +1 -0
- package/dist/core/whoami.js +66 -0
- package/dist/core/whoami.js.map +1 -0
- package/dist/core/write-pipeline.d.ts +9 -0
- package/dist/core/write-pipeline.d.ts.map +1 -1
- package/dist/core/write-pipeline.js +109 -68
- package/dist/core/write-pipeline.js.map +1 -1
- package/dist/core/write-telemetry.d.ts +33 -0
- package/dist/core/write-telemetry.d.ts.map +1 -0
- package/dist/core/write-telemetry.js +110 -0
- package/dist/core/write-telemetry.js.map +1 -0
- package/dist/engine/activation.d.ts +22 -12
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +133 -17
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/consolidation-scheduler.d.ts +1 -1
- package/dist/engine/consolidation-scheduler.js +1 -1
- package/dist/engine/consolidation.d.ts +1 -0
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +18 -0
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/engine/eval.d.ts.map +1 -1
- package/dist/engine/eval.js +5 -1
- package/dist/engine/eval.js.map +1 -1
- package/dist/index.js +20 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +2 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +168 -100
- package/dist/mcp.js.map +1 -1
- package/dist/recipes/index.d.ts +57 -0
- package/dist/recipes/index.d.ts.map +1 -0
- package/dist/recipes/index.js +81 -0
- package/dist/recipes/index.js.map +1 -0
- package/dist/storage/pglite-schema.d.ts.map +1 -1
- package/dist/storage/pglite-schema.js +27 -0
- package/dist/storage/pglite-schema.js.map +1 -1
- package/dist/storage/pglite.d.ts +5 -0
- package/dist/storage/pglite.d.ts.map +1 -1
- package/dist/storage/pglite.js +180 -138
- package/dist/storage/pglite.js.map +1 -1
- package/dist/storage/postgres.d.ts +5 -0
- package/dist/storage/postgres.d.ts.map +1 -1
- package/dist/storage/postgres.js +180 -138
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/sqlite.d.ts +9 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +394 -326
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/types/engram.d.ts +14 -0
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +1 -1
- package/src/adapters/claude-code.ts +66 -3
- package/src/adapters/common.ts +538 -515
- package/src/api/routes.ts +999 -971
- package/src/coordination/routes.ts +2155 -2150
- package/src/core/embeddings.ts +3 -0
- package/src/core/entity-extract.ts +47 -0
- package/src/core/salience.ts +529 -514
- package/src/core/whoami.ts +92 -0
- package/src/core/write-pipeline.ts +60 -8
- package/src/core/write-telemetry.ts +131 -0
- package/src/engine/activation.ts +1468 -1369
- package/src/engine/consolidation-scheduler.ts +1 -1
- package/src/engine/consolidation.ts +887 -869
- package/src/engine/eval.ts +6 -1
- package/src/index.ts +248 -227
- package/src/mcp.ts +1341 -1270
- package/src/recipes/index.ts +125 -0
- package/src/storage/pglite-schema.ts +27 -0
- package/src/storage/pglite.ts +1420 -1372
- package/src/storage/postgres.ts +1523 -1475
- package/src/storage/sqlite.ts +1936 -1861
- package/src/types/engram.ts +22 -0
package/src/engine/activation.ts
CHANGED
|
@@ -1,1369 +1,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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 1.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
import
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
if (
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
const
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
//
|
|
345
|
-
//
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
const
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
//
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
//
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
//
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
//
|
|
741
|
-
//
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
const
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
const
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
.
|
|
882
|
-
.
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
const
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
const
|
|
971
|
-
const
|
|
972
|
-
const
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
const
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
const
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
//
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
const
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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.x — Native 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.5× 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
|
+
}
|