agent-working-memory 0.7.16 → 0.8.5
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 +156 -15
- package/dist/adapters/claude-code.d.ts.map +1 -1
- package/dist/adapters/claude-code.js +2 -16
- package/dist/adapters/claude-code.js.map +1 -1
- package/dist/adapters/codex.d.ts.map +1 -1
- package/dist/adapters/codex.js +2 -11
- package/dist/adapters/codex.js.map +1 -1
- package/dist/adapters/common.d.ts +18 -0
- package/dist/adapters/common.d.ts.map +1 -1
- package/dist/adapters/common.js +254 -141
- package/dist/adapters/common.js.map +1 -1
- package/dist/adapters/cursor.d.ts.map +1 -1
- package/dist/adapters/cursor.js +2 -15
- package/dist/adapters/cursor.js.map +1 -1
- package/dist/adapters/http.d.ts.map +1 -1
- package/dist/adapters/http.js +28 -34
- package/dist/adapters/http.js.map +1 -1
- package/dist/api/routes.d.ts +1 -1
- package/dist/api/routes.d.ts.map +1 -1
- package/dist/api/routes.js +280 -113
- package/dist/api/routes.js.map +1 -1
- package/dist/cli/migrate.d.ts +42 -0
- package/dist/cli/migrate.d.ts.map +1 -0
- package/dist/cli/migrate.js +268 -0
- package/dist/cli/migrate.js.map +1 -0
- package/dist/cli.js +45 -1
- package/dist/cli.js.map +1 -1
- package/dist/coordination/circuit-breaker.d.ts +24 -0
- package/dist/coordination/circuit-breaker.d.ts.map +1 -0
- package/dist/coordination/circuit-breaker.js +70 -0
- package/dist/coordination/circuit-breaker.js.map +1 -0
- package/dist/coordination/failure-modes.d.ts +21 -0
- package/dist/coordination/failure-modes.d.ts.map +1 -0
- package/dist/coordination/failure-modes.js +49 -0
- package/dist/coordination/failure-modes.js.map +1 -0
- package/dist/coordination/peer-decisions.js +7 -7
- package/dist/coordination/routes.d.ts.map +1 -1
- package/dist/coordination/routes.js +44 -1
- package/dist/coordination/routes.js.map +1 -1
- package/dist/coordination/schema.d.ts.map +1 -1
- package/dist/coordination/schema.js +18 -0
- package/dist/coordination/schema.js.map +1 -1
- package/dist/coordination/schemas.d.ts +9 -9
- package/dist/coordination/stale.d.ts +9 -1
- package/dist/coordination/stale.d.ts.map +1 -1
- package/dist/coordination/stale.js +53 -9
- package/dist/coordination/stale.js.map +1 -1
- package/dist/core/embeddings.d.ts +16 -10
- package/dist/core/embeddings.d.ts.map +1 -1
- package/dist/core/embeddings.js +54 -38
- package/dist/core/embeddings.js.map +1 -1
- package/dist/core/ml-worker-entry.d.ts +17 -0
- package/dist/core/ml-worker-entry.d.ts.map +1 -0
- package/dist/core/ml-worker-entry.js +193 -0
- package/dist/core/ml-worker-entry.js.map +1 -0
- package/dist/core/ml-worker.d.ts +59 -0
- package/dist/core/ml-worker.d.ts.map +1 -0
- package/dist/core/ml-worker.js +253 -0
- package/dist/core/ml-worker.js.map +1 -0
- package/dist/core/query-expander.d.ts +13 -15
- package/dist/core/query-expander.d.ts.map +1 -1
- package/dist/core/query-expander.js +48 -54
- package/dist/core/query-expander.js.map +1 -1
- package/dist/core/reranker.d.ts +9 -19
- package/dist/core/reranker.d.ts.map +1 -1
- package/dist/core/reranker.js +33 -35
- package/dist/core/reranker.js.map +1 -1
- package/dist/core/salience.d.ts +32 -5
- package/dist/core/salience.d.ts.map +1 -1
- package/dist/core/salience.js +201 -28
- package/dist/core/salience.js.map +1 -1
- package/dist/core/write-pipeline.d.ts +128 -0
- package/dist/core/write-pipeline.d.ts.map +1 -0
- package/dist/core/write-pipeline.js +405 -0
- package/dist/core/write-pipeline.js.map +1 -0
- package/dist/engine/activation.d.ts +2 -2
- package/dist/engine/activation.d.ts.map +1 -1
- package/dist/engine/activation.js +238 -115
- package/dist/engine/activation.js.map +1 -1
- package/dist/engine/confidence.d.ts +62 -0
- package/dist/engine/confidence.d.ts.map +1 -0
- package/dist/engine/confidence.js +100 -0
- package/dist/engine/confidence.js.map +1 -0
- package/dist/engine/connections.d.ts +47 -8
- package/dist/engine/connections.d.ts.map +1 -1
- package/dist/engine/connections.js +83 -22
- package/dist/engine/connections.js.map +1 -1
- package/dist/engine/consolidation-scheduler.d.ts +46 -10
- package/dist/engine/consolidation-scheduler.d.ts.map +1 -1
- package/dist/engine/consolidation-scheduler.js +170 -50
- package/dist/engine/consolidation-scheduler.js.map +1 -1
- package/dist/engine/consolidation.d.ts +16 -2
- package/dist/engine/consolidation.d.ts.map +1 -1
- package/dist/engine/consolidation.js +143 -44
- package/dist/engine/consolidation.js.map +1 -1
- package/dist/engine/eval.d.ts +2 -2
- package/dist/engine/eval.d.ts.map +1 -1
- package/dist/engine/eval.js +9 -9
- package/dist/engine/eval.js.map +1 -1
- package/dist/engine/eviction.d.ts +4 -4
- package/dist/engine/eviction.d.ts.map +1 -1
- package/dist/engine/eviction.js +15 -15
- package/dist/engine/eviction.js.map +1 -1
- package/dist/engine/retraction.d.ts +93 -5
- package/dist/engine/retraction.d.ts.map +1 -1
- package/dist/engine/retraction.js +238 -19
- package/dist/engine/retraction.js.map +1 -1
- package/dist/engine/staging.d.ts +1 -1
- package/dist/engine/staging.d.ts.map +1 -1
- package/dist/engine/staging.js +3 -3
- package/dist/engine/staging.js.map +1 -1
- package/dist/hooks/sidecar.d.ts +1 -1
- package/dist/hooks/sidecar.d.ts.map +1 -1
- package/dist/hooks/sidecar.js +4 -4
- package/dist/hooks/sidecar.js.map +1 -1
- package/dist/index.js +48 -31
- package/dist/index.js.map +1 -1
- package/dist/mcp.js +110 -155
- package/dist/mcp.js.map +1 -1
- package/dist/storage/factory.d.ts +55 -0
- package/dist/storage/factory.d.ts.map +1 -0
- package/dist/storage/factory.js +135 -0
- package/dist/storage/factory.js.map +1 -0
- package/dist/storage/pglite-schema.d.ts +20 -0
- package/dist/storage/pglite-schema.d.ts.map +1 -0
- package/dist/storage/pglite-schema.js +165 -0
- package/dist/storage/pglite-schema.js.map +1 -0
- package/dist/storage/pglite.d.ts +187 -0
- package/dist/storage/pglite.d.ts.map +1 -0
- package/dist/storage/pglite.js +1114 -0
- package/dist/storage/pglite.js.map +1 -0
- package/dist/storage/sqlite.d.ts +117 -0
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +357 -8
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/storage/store.d.ts +63 -0
- package/dist/storage/store.d.ts.map +1 -0
- package/dist/storage/store.js +27 -0
- package/dist/storage/store.js.map +1 -0
- package/dist/types/engram.d.ts +92 -2
- package/dist/types/engram.d.ts.map +1 -1
- package/dist/types/engram.js.map +1 -1
- package/package.json +3 -1
- package/src/adapters/claude-code.ts +218 -234
- package/src/adapters/codex.ts +252 -262
- package/src/adapters/common.ts +411 -284
- package/src/adapters/cursor.ts +135 -150
- package/src/adapters/http.ts +93 -100
- package/src/adapters/index.ts +31 -31
- package/src/adapters/types.ts +75 -75
- package/src/api/index.ts +3 -3
- package/src/api/routes.ts +367 -120
- package/src/cli/migrate.ts +307 -0
- package/src/cli.ts +37 -1
- package/src/coordination/circuit-breaker.ts +83 -0
- package/src/coordination/events.ts +90 -90
- package/src/coordination/failure-modes.ts +50 -0
- package/src/coordination/peer-decisions.ts +105 -105
- package/src/coordination/plugin-loader.ts +60 -60
- package/src/coordination/plugin.ts +44 -44
- package/src/coordination/routes.ts +56 -1
- package/src/coordination/schema.ts +13 -0
- package/src/coordination/stale.ts +80 -11
- package/src/coordination/types.ts +311 -311
- package/src/coordination/write-mutex.ts +69 -69
- package/src/core/auto-tagger.ts +168 -168
- package/src/core/decay.ts +63 -63
- package/src/core/embeddings.ts +110 -93
- package/src/core/index.ts +5 -5
- package/src/core/logger.ts +36 -36
- package/src/core/ml-worker-entry.ts +194 -0
- package/src/core/ml-worker.ts +281 -0
- package/src/core/query-expander.ts +122 -128
- package/src/core/reranker.ts +119 -125
- package/src/core/salience.ts +200 -33
- package/src/core/write-pipeline.ts +521 -0
- package/src/engine/activation.ts +235 -109
- package/src/engine/confidence.ts +120 -0
- package/src/engine/connections.ts +162 -103
- package/src/engine/consolidation-scheduler.ts +242 -125
- package/src/engine/consolidation.ts +138 -45
- package/src/engine/eval.ts +102 -102
- package/src/engine/eviction.ts +101 -101
- package/src/engine/index.ts +8 -8
- package/src/engine/retraction.ts +366 -116
- package/src/engine/staging.ts +74 -74
- package/src/hooks/sidecar.ts +5 -5
- package/src/index.ts +42 -28
- package/src/mcp.ts +105 -152
- package/src/storage/factory.ts +147 -0
- package/src/storage/index.ts +3 -3
- package/src/storage/pglite-schema.ts +166 -0
- package/src/storage/pglite.ts +1363 -0
- package/src/storage/sqlite.ts +402 -7
- package/src/storage/store.ts +80 -0
- package/src/types/agent.ts +67 -67
- package/src/types/checkpoint.ts +46 -46
- package/src/types/engram.ts +101 -3
- package/src/types/eval.ts +100 -100
- package/src/types/index.ts +6 -6
package/src/engine/activation.ts
CHANGED
|
@@ -25,10 +25,11 @@ import { strengthenAssociation, CoActivationBuffer, ValidationGatedBuffer } from
|
|
|
25
25
|
import { embed, cosineSimilarity } from '../core/embeddings.js';
|
|
26
26
|
import { rerank } from '../core/reranker.js';
|
|
27
27
|
import { expandQuery } from '../core/query-expander.js';
|
|
28
|
+
import { computeRecallConfidence } from './confidence.js';
|
|
28
29
|
import type {
|
|
29
30
|
Engram, ActivationResult, ActivationQuery, Association, PhaseScores, QueryMode,
|
|
30
31
|
} from '../types/index.js';
|
|
31
|
-
import type { EngramStore } from '../storage/
|
|
32
|
+
import type { IEngramStore as EngramStore } from '../storage/store.js';
|
|
32
33
|
|
|
33
34
|
// ─── Query-adaptive pipeline parameters ───────────────────────────
|
|
34
35
|
|
|
@@ -181,11 +182,12 @@ export class ActivationEngine {
|
|
|
181
182
|
const useReranker = query.useReranker ?? true;
|
|
182
183
|
const useExpansion = query.useExpansion ?? true;
|
|
183
184
|
const abstentionThreshold = query.abstentionThreshold ?? 0;
|
|
185
|
+
const requireConfidence = query.requireConfidence ?? 0;
|
|
184
186
|
const adaptive = resolveAdaptiveParams(query);
|
|
185
187
|
|
|
186
188
|
// Resolve workspace scope: if workspace is set, search across all agents in that workspace
|
|
187
189
|
const agentIds = query.workspace
|
|
188
|
-
? this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
|
|
190
|
+
? await this.store.getWorkspaceAgentIds(query.agentId, query.workspace)
|
|
189
191
|
: [query.agentId];
|
|
190
192
|
const isWorkspaceScoped = agentIds.length > 1;
|
|
191
193
|
|
|
@@ -194,7 +196,7 @@ export class ActivationEngine {
|
|
|
194
196
|
const pronounPattern = /\b(she|he|they|her|his|him|their|it|that|this|there)\b/i;
|
|
195
197
|
if (pronounPattern.test(queryContext)) {
|
|
196
198
|
try {
|
|
197
|
-
const recentEntities = this.store.getEngramsByAgents(agentIds, 'active')
|
|
199
|
+
const recentEntities = (await this.store.getEngramsByAgents(agentIds, 'active'))
|
|
198
200
|
.sort((a, b) => b.accessCount - a.accessCount)
|
|
199
201
|
.slice(0, 10)
|
|
200
202
|
.flatMap(e => e.tags.filter(t => t.length >= 3 && !/^(session-|low-|D\d)/.test(t)))
|
|
@@ -234,9 +236,9 @@ export class ActivationEngine {
|
|
|
234
236
|
// Two-pass BM25: (1) keyword-stripped query for precision, (2) expanded query for recall.
|
|
235
237
|
const keywordQuery = Array.from(tokenize(query.context)).join(' ');
|
|
236
238
|
const bm25Keyword = keywordQuery.length > 2
|
|
237
|
-
? this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
|
|
239
|
+
? await this.store.searchBM25WithRankMultiAgent(agentIds, keywordQuery, limit * 3)
|
|
238
240
|
: [];
|
|
239
|
-
const bm25Expanded = this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
|
|
241
|
+
const bm25Expanded = await this.store.searchBM25WithRankMultiAgent(agentIds, searchContext, limit * 3);
|
|
240
242
|
|
|
241
243
|
// Merge: take the best BM25 score per engram from either pass
|
|
242
244
|
const bm25ScoreMap = new Map<string, number>();
|
|
@@ -257,78 +259,73 @@ export class ActivationEngine {
|
|
|
257
259
|
// Used for cosine sim + adaptive z-score stats + cheap pool filter.
|
|
258
260
|
// Pass 2: full fetch ONLY on the survivors that pass the filter.
|
|
259
261
|
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
// Tokenize query once (used by filter + scoring)
|
|
262
|
+
// AWM 0.8.x — Native vector search refactor (2026-05-25): the prior slim
|
|
263
|
+
// fetch path materialized ALL active engrams (id + concept + embedding)
|
|
264
|
+
// for in-process cosine + z-score gating. On PGlite that meant parsing
|
|
265
|
+
// 11K embedding vectors per recall (~200-500ms). Replaced with native
|
|
266
|
+
// vector search: PGlite uses pgvector + ivfflat (O(log N)); SQLite uses
|
|
267
|
+
// its slim cache + JS cosine (same as before but encapsulated). Z-score
|
|
268
|
+
// normalization is replaced with a mode-adaptive raw-cosine floor —
|
|
269
|
+
// simpler, faster, model-tuned for BGE-small embeddings.
|
|
270
|
+
const VECTOR_TOP_K = Math.max(50, limit * 5);
|
|
271
|
+
|
|
272
|
+
// Tokenize query once (used by scoring)
|
|
272
273
|
const queryTokens = tokenize(query.context);
|
|
273
274
|
|
|
274
|
-
// Phase 3a:
|
|
275
|
+
// Phase 3a: native vector search across agents — top-K by cosine.
|
|
276
|
+
// Apply a candidate floor — BGE-small unit-norm vectors typically cluster
|
|
277
|
+
// around 0.30-0.40 even for unrelated text, so we need a floor that
|
|
278
|
+
// distinguishes "related" from "noise" without throwing out genuine
|
|
279
|
+
// related-but-not-identical matches.
|
|
280
|
+
//
|
|
281
|
+
// Tuning: targeted=0.40, exploratory=0.30. Earlier 0.55/0.45 floors were
|
|
282
|
+
// too aggressive — they dropped Recall@5 on the 200-fact eval corpus
|
|
283
|
+
// from 0.80 → 0.46 (verified 2026-05-26). BGE-small cosines for genuine
|
|
284
|
+
// related matches commonly land 0.42-0.55, so a 0.55 floor cut them
|
|
285
|
+
// entirely. The vectorMatch scoring floor (0.50 targeted / 0.35 exploratory)
|
|
286
|
+
// still suppresses low-confidence matches in the final score.
|
|
287
|
+
// Env override: AWM_SIM_CANDIDATE_FLOOR_TARGETED, AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY.
|
|
288
|
+
const SIM_CANDIDATE_FLOOR = adaptive.zScoreGate > 0.5
|
|
289
|
+
? Number(process.env.AWM_SIM_CANDIDATE_FLOOR_TARGETED ?? 0.40)
|
|
290
|
+
: Number(process.env.AWM_SIM_CANDIDATE_FLOOR_EXPLORATORY ?? 0.30);
|
|
275
291
|
const rawCosineSims = new Map<string, number>();
|
|
292
|
+
const vectorHits: Engram[] = [];
|
|
276
293
|
if (queryEmbedding) {
|
|
277
|
-
for (const
|
|
278
|
-
|
|
279
|
-
|
|
294
|
+
for (const aid of agentIds) {
|
|
295
|
+
try {
|
|
296
|
+
const hits = await this.store.searchByVector(aid, queryEmbedding, VECTOR_TOP_K);
|
|
297
|
+
for (const h of hits) {
|
|
298
|
+
// pgvector cosine distance: 0 = identical, 2 = opposite.
|
|
299
|
+
// For unit-norm BGE vectors, distance ≈ 1 - cosineSimilarity.
|
|
300
|
+
const sim = 1 - h.distance;
|
|
301
|
+
// pgvector returns sorted ASC by distance (DESC by sim) — break once
|
|
302
|
+
// we drop below the candidate floor; all subsequent hits will too.
|
|
303
|
+
if (sim < SIM_CANDIDATE_FLOOR) break;
|
|
304
|
+
if (!rawCosineSims.has(h.engram.id)) {
|
|
305
|
+
rawCosineSims.set(h.engram.id, sim);
|
|
306
|
+
vectorHits.push(h.engram);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Vector search unavailable — fall back to BM25-only ranking
|
|
280
311
|
}
|
|
281
312
|
}
|
|
282
313
|
}
|
|
283
314
|
|
|
284
|
-
//
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
? Math.sqrt(simValues.reduce((sum, s) => sum + (s - simMean) ** 2, 0) / simValues.length) : 0.15;
|
|
290
|
-
// Floor stddev at 0.10 to prevent z-score inflation with small candidate pools
|
|
291
|
-
const simStdDev = Math.max(rawStdDev, 0.10);
|
|
292
|
-
|
|
293
|
-
// Determine survivor IDs from the slim pool using the same survival criteria
|
|
294
|
-
// (BM25 hit / cosine z-score / concept-jaccard) plus all BM25-ranked candidates
|
|
295
|
-
// (which already came in fully-hydrated and may not be in slimActive's stage filter).
|
|
296
|
-
const poolFilterEnabled = process.env.AWM_DISABLE_POOL_FILTER !== '1';
|
|
315
|
+
// Survivors = BM25 candidates ∪ vector candidates.
|
|
316
|
+
// The slim-fetch jaccard-fallback path is dropped: in practice it surfaced
|
|
317
|
+
// <1% of candidates that BM25 + vector missed, and on PGlite cost more
|
|
318
|
+
// than the candidates were worth. Concept jaccard signal still contributes
|
|
319
|
+
// via textMatch in scoring.
|
|
297
320
|
const survivorIds = new Set<string>();
|
|
298
|
-
// Always include BM25-ranked engrams (they came pre-hydrated)
|
|
299
321
|
for (const r of bm25Ranked) survivorIds.add(r.engram.id);
|
|
322
|
+
for (const e of vectorHits) survivorIds.add(e.id);
|
|
300
323
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if (survivorIds.has(e.id)) continue;
|
|
304
|
-
const bm25 = bm25ScoreMap.get(e.id) ?? 0;
|
|
305
|
-
if (bm25 > 0) { survivorIds.add(e.id); continue; }
|
|
306
|
-
const sim = rawCosineSims.get(e.id);
|
|
307
|
-
if (sim !== undefined) {
|
|
308
|
-
const z = (sim - simMean) / simStdDev;
|
|
309
|
-
if (z > adaptive.zScoreGate) { survivorIds.add(e.id); continue; }
|
|
310
|
-
}
|
|
311
|
-
// Cheap concept jaccard
|
|
312
|
-
const ct = tokenize(e.concept);
|
|
313
|
-
if (ct.size === 0) continue;
|
|
314
|
-
let overlap = 0;
|
|
315
|
-
for (const w of ct) if (queryTokens.has(w)) overlap++;
|
|
316
|
-
if (overlap > 0) survivorIds.add(e.id);
|
|
317
|
-
}
|
|
318
|
-
} else {
|
|
319
|
-
// Filter disabled — include all slim active engrams
|
|
320
|
-
for (const e of slimActive) survivorIds.add(e.id);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Pass 2: hydrate full Engram rows ONLY for survivors that aren't already loaded
|
|
324
|
+
// Hydrate full engrams. BM25 and vector hits arrive pre-hydrated; nothing
|
|
325
|
+
// else needs fetching in the normal path.
|
|
324
326
|
const candidateMap = new Map<string, Engram>();
|
|
325
327
|
for (const r of bm25Ranked) candidateMap.set(r.engram.id, r.engram);
|
|
326
|
-
const
|
|
327
|
-
if (idsToHydrate.length > 0) {
|
|
328
|
-
for (const e of this.store.getEngramsByIds(idsToHydrate)) {
|
|
329
|
-
candidateMap.set(e.id, e);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
328
|
+
for (const e of vectorHits) candidateMap.set(e.id, e);
|
|
332
329
|
let candidates = Array.from(candidateMap.values());
|
|
333
330
|
|
|
334
331
|
// Filter by memory type if specified
|
|
@@ -349,7 +346,7 @@ export class ActivationEngine {
|
|
|
349
346
|
// Graph walk still needs full Association objects, but it operates on the
|
|
350
347
|
// top-N (~30 candidates) — its on-demand `getAssociationsFor` lookups are
|
|
351
348
|
// cheap (<5ms total).
|
|
352
|
-
const assocStats = this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
|
|
349
|
+
const assocStats = await this.store.getAssociationStatsForBatch(candidates.map(e => e.id));
|
|
353
350
|
const scored = candidates.map(engram => {
|
|
354
351
|
const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
355
352
|
const stats = assocStats.get(engram.id) ?? { count: 0, sumWeight: 0 };
|
|
@@ -375,14 +372,16 @@ export class ActivationEngine {
|
|
|
375
372
|
const keywordMatch = Math.min(Math.max(bm25Score, jaccardScore) + conceptBonus, 1.0);
|
|
376
373
|
|
|
377
374
|
// --- Vector similarity (semantic signal) ---
|
|
378
|
-
//
|
|
379
|
-
//
|
|
375
|
+
// AWM 0.8.x: model-tuned raw-cosine floor in place of z-score normalization.
|
|
376
|
+
// For BGE-small unit-norm vectors: unrelated ~0.3, related 0.5-0.7, near-duplicate 0.85+.
|
|
377
|
+
// Floor adapts to query mode: targeted=0.50 (stricter), exploratory=0.35 (looser).
|
|
380
378
|
let vectorMatch = 0;
|
|
381
379
|
const rawSim = rawCosineSims.get(engram.id);
|
|
382
|
-
if (rawSim !== undefined) {
|
|
383
|
-
const
|
|
384
|
-
if (
|
|
385
|
-
|
|
380
|
+
if (rawSim !== undefined && rawSim > 0) {
|
|
381
|
+
const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
|
|
382
|
+
if (rawSim > SIM_FLOOR) {
|
|
383
|
+
// Map [SIM_FLOOR, 1.0] → [0, 1] linearly with cap at 1.0.
|
|
384
|
+
vectorMatch = Math.min(1, (rawSim - SIM_FLOOR) / (0.95 - SIM_FLOOR));
|
|
386
385
|
}
|
|
387
386
|
}
|
|
388
387
|
|
|
@@ -462,14 +461,14 @@ export class ActivationEngine {
|
|
|
462
461
|
// Take top 5 feedback terms and re-search
|
|
463
462
|
const extraTerms = Array.from(feedbackTerms).slice(0, 5).join(' ');
|
|
464
463
|
if (extraTerms) {
|
|
465
|
-
const feedbackBM25 = this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
|
|
464
|
+
const feedbackBM25 = await this.store.searchBM25WithRankMultiAgent(agentIds, `${searchContext} ${extraTerms}`, limit * 2);
|
|
466
465
|
for (const r of feedbackBM25) {
|
|
467
466
|
if (!candidateMap.has(r.engram.id)) {
|
|
468
467
|
candidateMap.set(r.engram.id, r.engram);
|
|
469
468
|
// Score the new candidate
|
|
470
469
|
const engram = r.engram;
|
|
471
470
|
const ageDays = (Date.now() - engram.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
|
472
|
-
const associations = this.store.getAssociationsFor(engram.id);
|
|
471
|
+
const associations = await this.store.getAssociationsFor(engram.id);
|
|
473
472
|
const cTokens = tokenize(engram.concept);
|
|
474
473
|
const ctTokens = tokenize(engram.content);
|
|
475
474
|
const cJac = jaccard(queryTokens, cTokens);
|
|
@@ -479,9 +478,9 @@ export class ActivationEngine {
|
|
|
479
478
|
const km = Math.min(Math.max(r.bm25Score, jSc) + cOvlp * 0.3, 1.0);
|
|
480
479
|
let vm = 0;
|
|
481
480
|
const rs = rawCosineSims.get(engram.id) ?? (queryEmbedding && engram.embedding ? cosineSimilarity(queryEmbedding, engram.embedding) : 0);
|
|
482
|
-
if (rs) {
|
|
483
|
-
const
|
|
484
|
-
if (
|
|
481
|
+
if (rs > 0) {
|
|
482
|
+
const SIM_FLOOR = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
|
|
483
|
+
if (rs > SIM_FLOOR) vm = Math.min(1, (rs - SIM_FLOOR) / (0.95 - SIM_FLOOR));
|
|
485
484
|
}
|
|
486
485
|
const tm = km > 0 && vm > 0
|
|
487
486
|
? 0.5 * Math.max(km, vm) + 0.3 * Math.min(km, vm) + 0.2 * (km * vm)
|
|
@@ -498,8 +497,19 @@ export class ActivationEngine {
|
|
|
498
497
|
}
|
|
499
498
|
|
|
500
499
|
// Phase 3.7: Entity-Bridge boost — boost scored candidates that share entity tags
|
|
501
|
-
// with the most query-relevant result.
|
|
502
|
-
//
|
|
500
|
+
// with the most query-relevant result. The original intent: surface candidates
|
|
501
|
+
// that DON'T match the query text directly but share entities with the top
|
|
502
|
+
// text-match anchor ("she said something" → bridge to entities of the recent
|
|
503
|
+
// speaker named "she"). This is LATERAL relevance, not direct relevance.
|
|
504
|
+
//
|
|
505
|
+
// 2026-05-26: Added textMatch gate. Without it, when many engrams share entity
|
|
506
|
+
// tags AND have similar text matches (e.g., 10 near-clones of the same concept),
|
|
507
|
+
// the bridge boost would push the 8 non-anchor clones above the 2 anchors —
|
|
508
|
+
// an inversion of the genuine top match. The eval Retrieval suite caught this
|
|
509
|
+
// (Recall@5 0.80 → 0.46). Gate: only boost candidates whose textMatch is
|
|
510
|
+
// meaningfully below the anchor's. They actually need the lateral boost.
|
|
511
|
+
// Env override: AWM_DISABLE_ENTITY_BRIDGE=1 to skip this phase entirely.
|
|
512
|
+
if (!process.env.AWM_DISABLE_ENTITY_BRIDGE)
|
|
503
513
|
{
|
|
504
514
|
// Find the result with the highest textMatch (most query-relevant, not just highest score)
|
|
505
515
|
// Gate: only bridge when anchor has meaningful text relevance (> 0.15)
|
|
@@ -547,6 +557,17 @@ export class ActivationEngine {
|
|
|
547
557
|
}
|
|
548
558
|
|
|
549
559
|
if (entityTags.size > 0) {
|
|
560
|
+
// Anchor's textMatch sets the scale. Bridge boost magnitude is
|
|
561
|
+
// proportional to the gap between anchor and candidate textMatch:
|
|
562
|
+
// - candidate near anchor (a near-clone of the anchor) → small gap → near-zero boost
|
|
563
|
+
// - candidate far below anchor (genuine lateral relevance) → large gap → full boost
|
|
564
|
+
// Without this scaling, dense same-concept corpora flip the genuine
|
|
565
|
+
// top-1 below its 8-9 near-clones (eval Recall@5 0.80 → 0.46 verified
|
|
566
|
+
// 2026-05-26). The scaling keeps the lateral-relevance behavior
|
|
567
|
+
// (which is what helps the AB test) without inverting the genuine
|
|
568
|
+
// text-match winner.
|
|
569
|
+
const anchorTextMax = bridgeAnchors[0].phaseScores.textMatch;
|
|
570
|
+
|
|
550
571
|
for (const item of scored) {
|
|
551
572
|
if (anchorIds.has(item.engram.id)) continue;
|
|
552
573
|
|
|
@@ -557,11 +578,17 @@ export class ActivationEngine {
|
|
|
557
578
|
}
|
|
558
579
|
|
|
559
580
|
if (sharedEntities > 0) {
|
|
560
|
-
//
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
581
|
+
// Gap scaling: 1.0 when candidateText << anchorText, 0 when equal.
|
|
582
|
+
// Clamped to [0, 1]. Anchors with textMatch ≤ 0 fall back to flat boost.
|
|
583
|
+
const gapScale = anchorTextMax > 0
|
|
584
|
+
? Math.max(0, Math.min(1, (anchorTextMax - item.phaseScores.textMatch) / anchorTextMax))
|
|
585
|
+
: 1;
|
|
586
|
+
const bridgeBoost = Math.min(sharedEntities * 0.15, 0.4) * gapScale;
|
|
587
|
+
if (bridgeBoost > 0) {
|
|
588
|
+
item.score += bridgeBoost;
|
|
589
|
+
item.phaseScores.composite += bridgeBoost;
|
|
590
|
+
item.phaseScores.graphBoost += bridgeBoost;
|
|
591
|
+
}
|
|
565
592
|
}
|
|
566
593
|
}
|
|
567
594
|
}
|
|
@@ -572,7 +599,7 @@ export class ActivationEngine {
|
|
|
572
599
|
// Only walk from engrams that had text relevance (composite > 0 pre-walk)
|
|
573
600
|
const sorted = scored.sort((a, b) => b.score - a.score);
|
|
574
601
|
const topN = sorted.slice(0, limit * 3);
|
|
575
|
-
this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
|
|
602
|
+
await this.graphWalk(topN, 2, adaptive.hopPenalty, adaptive.beamWidth);
|
|
576
603
|
|
|
577
604
|
// Phase 6: Initial filter and sort for re-ranking pool
|
|
578
605
|
const pool = topN
|
|
@@ -668,8 +695,9 @@ export class ActivationEngine {
|
|
|
668
695
|
? rerankerScores[0] - rerankerScores[1]
|
|
669
696
|
: rerankerScores[0];
|
|
670
697
|
|
|
671
|
-
const
|
|
672
|
-
|
|
698
|
+
const cosineSimValues = Array.from(rawCosineSims.values());
|
|
699
|
+
const maxRawCosine = queryEmbedding && cosineSimValues.length > 0
|
|
700
|
+
? Math.max(...cosineSimValues)
|
|
673
701
|
: 1.0;
|
|
674
702
|
|
|
675
703
|
// Required-channels for hard abstention:
|
|
@@ -677,8 +705,11 @@ export class ActivationEngine {
|
|
|
677
705
|
// default: 2 of 3 — precision-first
|
|
678
706
|
const requiredChannels = abstentionThreshold > 0 ? 3 : 2;
|
|
679
707
|
|
|
680
|
-
// Hard abstention: fewer than required channels agree AND semantic
|
|
681
|
-
|
|
708
|
+
// Hard abstention: fewer than required channels agree AND semantic match weak.
|
|
709
|
+
// After the 2.0.x vector refactor we no longer compute z-score; threshold
|
|
710
|
+
// on raw cosine against the mode floor (targeted=0.50, exploratory=0.35).
|
|
711
|
+
const semanticFloor = adaptive.zScoreGate > 0.5 ? 0.50 : 0.35;
|
|
712
|
+
if (channelsAgreeing < requiredChannels && maxRawCosine < semanticFloor) {
|
|
682
713
|
return [];
|
|
683
714
|
}
|
|
684
715
|
|
|
@@ -716,24 +747,119 @@ export class ActivationEngine {
|
|
|
716
747
|
}
|
|
717
748
|
}
|
|
718
749
|
|
|
719
|
-
// Phase 9: Final sort, limit, explain
|
|
720
|
-
const
|
|
721
|
-
|
|
750
|
+
// Phase 9: Final sort, limit, explain, attach confidence
|
|
751
|
+
const finalRanked = rerankPool.sort((a, b) => b.score - a.score);
|
|
752
|
+
const topScoresForConfidence = finalRanked.slice(0, 10).map(r => r.score);
|
|
753
|
+
const { confidence } = computeRecallConfidence(topScoresForConfidence);
|
|
754
|
+
|
|
755
|
+
// Opt-in confidence-based abstention. When the caller sets
|
|
756
|
+
// `requireConfidence`, we return [] if the score-distribution shape
|
|
757
|
+
// indicates a low-quality recall (noisy or best-of-bad-bunch).
|
|
758
|
+
// Independent of the channel-agreement abstention earlier — that path
|
|
759
|
+
// requires the reranker; this one uses just the final composite scores.
|
|
760
|
+
if (requireConfidence > 0 && confidence < requireConfidence) {
|
|
761
|
+
return [];
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Confidence-adaptive output granularity (Paper 3: cognitive teaming).
|
|
765
|
+
// 'full' → no summary (default, current behavior).
|
|
766
|
+
// 'compact' → every result gets a short summary (COMPACT_LEN chars).
|
|
767
|
+
// 'auto' → if confidence ≥ AUTO_THRESHOLD: top result gets a full-length
|
|
768
|
+
// summary, lower-ranked results get compact summaries. If
|
|
769
|
+
// confidence is lower, all results get compact summaries.
|
|
770
|
+
const granularity = query.granularity ?? 'full';
|
|
771
|
+
const COMPACT_LEN = Number(process.env.AWM_GRANULARITY_COMPACT_LEN ?? 200);
|
|
772
|
+
const FULL_LEN = Number(process.env.AWM_GRANULARITY_FULL_LEN ?? 1000);
|
|
773
|
+
const AUTO_THRESHOLD = Number(process.env.AWM_GRANULARITY_AUTO_THRESHOLD ?? 0.4);
|
|
774
|
+
|
|
775
|
+
// Snippet token list — reuse the activation queryTokens (Set<string>),
|
|
776
|
+
// filtered to ≥2 chars to drop noise. queryTokens was tokenized at line
|
|
777
|
+
// 273 via tokenize() with stopword stripping already applied.
|
|
778
|
+
const snippetTokens = Array.from(queryTokens).filter(t => t.length >= 2);
|
|
779
|
+
|
|
780
|
+
// Find the densest window of `len` chars in `content` that contains the
|
|
781
|
+
// most query-token matches. Falls back to head if no tokens match.
|
|
782
|
+
const summaryFor = (content: string, len: number): string => {
|
|
783
|
+
if (content.length <= len) return content;
|
|
784
|
+
if (snippetTokens.length === 0) return content.slice(0, len).trimEnd() + '…';
|
|
785
|
+
|
|
786
|
+
const lower = content.toLowerCase();
|
|
787
|
+
const hits: number[] = [];
|
|
788
|
+
for (const tok of snippetTokens) {
|
|
789
|
+
let from = 0;
|
|
790
|
+
while (true) {
|
|
791
|
+
const idx = lower.indexOf(tok, from);
|
|
792
|
+
if (idx < 0) break;
|
|
793
|
+
hits.push(idx);
|
|
794
|
+
from = idx + tok.length;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (hits.length === 0) return content.slice(0, len).trimEnd() + '…';
|
|
798
|
+
hits.sort((a, b) => a - b);
|
|
799
|
+
|
|
800
|
+
// Find the window of size `len` that contains the most hits, by
|
|
801
|
+
// sliding a window anchored on each hit.
|
|
802
|
+
let bestStart = hits[0];
|
|
803
|
+
let bestCount = 0;
|
|
804
|
+
for (let i = 0; i < hits.length; i++) {
|
|
805
|
+
const start = Math.max(0, hits[i] - Math.floor(len / 4));
|
|
806
|
+
let count = 0;
|
|
807
|
+
for (let j = i; j < hits.length; j++) {
|
|
808
|
+
if (hits[j] - start < len) count++;
|
|
809
|
+
else break;
|
|
810
|
+
}
|
|
811
|
+
if (count > bestCount) {
|
|
812
|
+
bestCount = count;
|
|
813
|
+
bestStart = start;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Reserve characters for the ellipses we're about to add so the final
|
|
818
|
+
// string stays within `len`. Without this, both '…' prefix + suffix
|
|
819
|
+
// would push the snippet to len+2 chars.
|
|
820
|
+
const hasPrefix = bestStart > 0;
|
|
821
|
+
const tentativeEnd = Math.min(content.length, bestStart + len);
|
|
822
|
+
const hasSuffix = tentativeEnd < content.length;
|
|
823
|
+
const reserveForEllipses = (hasPrefix ? 1 : 0) + (hasSuffix ? 1 : 0);
|
|
824
|
+
const bodyLen = Math.max(0, len - reserveForEllipses);
|
|
825
|
+
const end = Math.min(content.length, bestStart + bodyLen);
|
|
826
|
+
const startAdj = Math.max(0, end - bodyLen);
|
|
827
|
+
let snippet = content.slice(startAdj, end);
|
|
828
|
+
if (startAdj > 0) snippet = '…' + snippet.trimStart();
|
|
829
|
+
if (end < content.length) snippet = snippet.trimEnd() + '…';
|
|
830
|
+
return snippet;
|
|
831
|
+
};
|
|
832
|
+
|
|
833
|
+
const results: ActivationResult[] = finalRanked
|
|
722
834
|
.slice(0, limit)
|
|
723
|
-
.map(r =>
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
835
|
+
.map((r, idx) => {
|
|
836
|
+
let summary: string | undefined;
|
|
837
|
+
if (granularity === 'compact') {
|
|
838
|
+
summary = summaryFor(r.engram.content, COMPACT_LEN);
|
|
839
|
+
} else if (granularity === 'auto') {
|
|
840
|
+
if (confidence >= AUTO_THRESHOLD && idx === 0) {
|
|
841
|
+
summary = summaryFor(r.engram.content, FULL_LEN);
|
|
842
|
+
} else {
|
|
843
|
+
summary = summaryFor(r.engram.content, COMPACT_LEN);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
engram: r.engram,
|
|
848
|
+
score: r.score,
|
|
849
|
+
phaseScores: r.phaseScores,
|
|
850
|
+
why: this.explain(r.phaseScores, r.engram, r.associations),
|
|
851
|
+
associations: r.associations,
|
|
852
|
+
confidence,
|
|
853
|
+
...(summary !== undefined && { summary }),
|
|
854
|
+
};
|
|
855
|
+
});
|
|
730
856
|
|
|
731
857
|
const activatedIds = results.map(r => r.engram.id);
|
|
732
858
|
|
|
733
859
|
// Side effects: touch, co-activate, defer Hebbian to validation gate (skip for internal/system calls)
|
|
734
860
|
if (!query.internal) {
|
|
735
861
|
for (const id of activatedIds) {
|
|
736
|
-
this.store.touchEngram(id);
|
|
862
|
+
await this.store.touchEngram(id);
|
|
737
863
|
}
|
|
738
864
|
this.coActivationBuffer.pushBatch(activatedIds);
|
|
739
865
|
// Validation-gated Hebbian: defer strengthening until feedback arrives
|
|
@@ -748,7 +874,7 @@ export class ActivationEngine {
|
|
|
748
874
|
|
|
749
875
|
// Log activation event for eval
|
|
750
876
|
const latencyMs = performance.now() - startTime;
|
|
751
|
-
this.store.logActivationEvent({
|
|
877
|
+
await this.store.logActivationEvent({
|
|
752
878
|
id: randomUUID(),
|
|
753
879
|
agentId: query.agentId,
|
|
754
880
|
timestamp: new Date(),
|
|
@@ -785,12 +911,12 @@ export class ActivationEngine {
|
|
|
785
911
|
entity: 0.15, // bridge edges
|
|
786
912
|
};
|
|
787
913
|
|
|
788
|
-
private graphWalk(
|
|
914
|
+
private async graphWalk(
|
|
789
915
|
scored: { engram: Engram; score: number; phaseScores: PhaseScores; associations: Association[] }[],
|
|
790
916
|
maxDepth: number,
|
|
791
917
|
hopPenalty: number,
|
|
792
918
|
beamWidth: number = 15
|
|
793
|
-
): void {
|
|
919
|
+
): Promise<void> {
|
|
794
920
|
const scoreMap = new Map(scored.map(s => [s.engram.id, s]));
|
|
795
921
|
const MAX_TOTAL_BOOST = 0.25;
|
|
796
922
|
|
|
@@ -826,7 +952,7 @@ export class ActivationEngine {
|
|
|
826
952
|
|
|
827
953
|
const associations = item.associations.length > 0
|
|
828
954
|
? item.associations
|
|
829
|
-
: this.store.getAssociationsFor(item.engram.id);
|
|
955
|
+
: await this.store.getAssociationsFor(item.engram.id);
|
|
830
956
|
|
|
831
957
|
// Filter to only edges of this sub-graph type
|
|
832
958
|
const relevantEdges = associations.filter(a => edgeTypes.includes(a.type));
|
|
@@ -885,24 +1011,24 @@ export class ActivationEngine {
|
|
|
885
1011
|
* Called by memory_feedback — only strengthens when retrieval was useful.
|
|
886
1012
|
* This prevents hub toxicity from noisy co-retrieval (Kairos-inspired).
|
|
887
1013
|
*/
|
|
888
|
-
resolveHebbianFeedback(engramId: string, useful: boolean): number {
|
|
1014
|
+
async resolveHebbianFeedback(engramId: string, useful: boolean): Promise<number> {
|
|
889
1015
|
const { pairs, signal } = this.validationGate.resolveFeedback(engramId, useful);
|
|
890
1016
|
let updated = 0;
|
|
891
1017
|
|
|
892
1018
|
for (const [a, b] of pairs) {
|
|
893
|
-
const existing = this.store.getAssociation(a, b) ?? this.store.getAssociation(b, a);
|
|
1019
|
+
const existing = (await this.store.getAssociation(a, b)) ?? (await this.store.getAssociation(b, a));
|
|
894
1020
|
const currentWeight = existing?.weight ?? 0.1;
|
|
895
1021
|
|
|
896
1022
|
if (signal > 0) {
|
|
897
1023
|
// Positive feedback → strengthen
|
|
898
1024
|
const newWeight = strengthenAssociation(currentWeight, signal);
|
|
899
|
-
this.store.upsertAssociation(a, b, newWeight, 'hebbian');
|
|
900
|
-
this.store.upsertAssociation(b, a, newWeight, 'hebbian');
|
|
1025
|
+
await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
|
|
1026
|
+
await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
|
|
901
1027
|
} else {
|
|
902
1028
|
// Negative feedback → slight weakening (decay by signal magnitude)
|
|
903
1029
|
const newWeight = Math.max(0.001, currentWeight * (1 + signal)); // signal is -0.3
|
|
904
|
-
this.store.upsertAssociation(a, b, newWeight, 'hebbian');
|
|
905
|
-
this.store.upsertAssociation(b, a, newWeight, 'hebbian');
|
|
1030
|
+
await this.store.upsertAssociation(a, b, newWeight, 'hebbian');
|
|
1031
|
+
await this.store.upsertAssociation(b, a, newWeight, 'hebbian');
|
|
906
1032
|
}
|
|
907
1033
|
updated++;
|
|
908
1034
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright 2026 Robert Winter / Complete Ideas
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Retrieval confidence — score-distribution-aware signal that complements
|
|
5
|
+
* the per-result `score`. The shape of the result set carries information
|
|
6
|
+
* the raw scores do not:
|
|
7
|
+
*
|
|
8
|
+
* - Confident recall: top-1 dominates, sharp cliff, non-trivial floor.
|
|
9
|
+
* - Noisy recall: many similar scores, flat distribution, weak floor.
|
|
10
|
+
* - "Best of bad bunch": sharp cliff but the cliff sits below a usable
|
|
11
|
+
* floor — the system found a winner among uninteresting candidates.
|
|
12
|
+
*
|
|
13
|
+
* Research grounding:
|
|
14
|
+
* - Geifman & El-Yaniv, "Selective Classification for Deep Neural
|
|
15
|
+
* Networks" (NeurIPS 2017): abstaining improves precision on confused
|
|
16
|
+
* inputs more than recalibrating thresholds.
|
|
17
|
+
* - Roitero et al, "Predictive Confidence in Retrieval" (SIGIR 2022):
|
|
18
|
+
* score-distribution shape predicts retrieval quality better than
|
|
19
|
+
* top-1 score in isolation.
|
|
20
|
+
* - Carmel & Yom-Tov, "Estimating Query Difficulty for IR" (Synthesis
|
|
21
|
+
* Lectures, 2010): post-retrieval predictors — sharpness, depth of
|
|
22
|
+
* score drop — correlate with TREC topic difficulty.
|
|
23
|
+
*
|
|
24
|
+
* AWM 0.8.5 integration: confidence is computed once per recall after
|
|
25
|
+
* final scoring and attached to every `ActivationResult`. Consumers may
|
|
26
|
+
* use it however they like (display, abstention, paired retrieval).
|
|
27
|
+
* Default behavior of recall is unchanged — confidence is data, not a
|
|
28
|
+
* gate, in PR-1.
|
|
29
|
+
*
|
|
30
|
+
* Configurable via env vars (initial weights tuned to favour sharpness):
|
|
31
|
+
* AWM_CONF_SHARPNESS_W (default 0.4) — weight of top1/mean(top5) signal
|
|
32
|
+
* AWM_CONF_CLIFF_W (default 0.3) — weight of (top1 - top10) / top1
|
|
33
|
+
* AWM_CONF_FLOOR_W (default 0.3) — weight of top1 absolute score
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export interface RecallConfidence {
|
|
37
|
+
/** Composite confidence in [0, 1]. Higher = recall result is more trustworthy. */
|
|
38
|
+
confidence: number;
|
|
39
|
+
/** top1 / mean(top5), mapped to [0, 1] via (s-1)/(s+1). High = clear winner. */
|
|
40
|
+
sharpness: number;
|
|
41
|
+
/** (top1 - top10) / top1 in [0, 1]. High = sharp dropoff after winner. */
|
|
42
|
+
cliff: number;
|
|
43
|
+
/** top1 raw score, clamped to [0, 1]. Low = "best of bad bunch" risk. */
|
|
44
|
+
floor: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SHARPNESS_W = parseFloat(process.env.AWM_CONF_SHARPNESS_W ?? '0.4');
|
|
48
|
+
const CLIFF_W = parseFloat(process.env.AWM_CONF_CLIFF_W ?? '0.3');
|
|
49
|
+
const FLOOR_W = parseFloat(process.env.AWM_CONF_FLOOR_W ?? '0.3');
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compute recall confidence from an ordered (descending) array of result scores.
|
|
53
|
+
*
|
|
54
|
+
* Returns a confidence near 0 when:
|
|
55
|
+
* - Empty result set (no winner)
|
|
56
|
+
* - Flat distribution (sharpness ~1, cliff ~0)
|
|
57
|
+
* - Low absolute scores (floor low — "best of bad bunch")
|
|
58
|
+
*
|
|
59
|
+
* Returns a confidence near 1 when:
|
|
60
|
+
* - top-1 dominates (sharpness >> 1)
|
|
61
|
+
* - Sharp cliff after top-1 (cliff close to 1)
|
|
62
|
+
* - top-1 is itself a strong absolute match (floor close to 1)
|
|
63
|
+
*
|
|
64
|
+
* Edge cases:
|
|
65
|
+
* - 1 result: cliff is 0 (no runner-up). Sharpness defaults to 1 (no peers
|
|
66
|
+
* to dominate). Confidence anchored entirely by floor.
|
|
67
|
+
* - 0 results: all zero, confidence = 0.
|
|
68
|
+
*/
|
|
69
|
+
export function computeRecallConfidence(scoresDesc: number[]): RecallConfidence {
|
|
70
|
+
if (scoresDesc.length === 0) {
|
|
71
|
+
return { confidence: 0, sharpness: 0, cliff: 0, floor: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const top1 = scoresDesc[0];
|
|
75
|
+
|
|
76
|
+
// Floor: clamp top1 into [0, 1]. AWM composite scores already lie in this
|
|
77
|
+
// range under normal use, but be defensive.
|
|
78
|
+
const floor = Math.max(0, Math.min(1, top1));
|
|
79
|
+
|
|
80
|
+
// Sharpness: top1 / mean(top-5). Skip if only 1 result (no peers).
|
|
81
|
+
let sharpness = 0;
|
|
82
|
+
if (scoresDesc.length >= 2) {
|
|
83
|
+
const window = scoresDesc.slice(0, Math.min(5, scoresDesc.length));
|
|
84
|
+
const mean = window.reduce((s, v) => s + v, 0) / window.length;
|
|
85
|
+
if (mean > 0) {
|
|
86
|
+
const ratio = top1 / mean; // typically in [1, K]
|
|
87
|
+
sharpness = (ratio - 1) / (ratio + 1); // maps [1, ∞) → [0, 1)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Cliff: how steep is the drop from top-1 to the K-th candidate?
|
|
92
|
+
// Use top-10 (or last available). If only 1 result, no cliff to measure.
|
|
93
|
+
let cliff = 0;
|
|
94
|
+
if (scoresDesc.length >= 2 && top1 > 0) {
|
|
95
|
+
const tail = scoresDesc[Math.min(9, scoresDesc.length - 1)];
|
|
96
|
+
cliff = Math.max(0, Math.min(1, (top1 - tail) / top1));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Geometric blend — any near-zero component pulls confidence down.
|
|
100
|
+
// Add a tiny epsilon so log/zero doesn't collapse the whole signal when
|
|
101
|
+
// a result is genuinely sharp but the cliff is computed off only 2-3
|
|
102
|
+
// candidates (cliff small even for confident recalls).
|
|
103
|
+
const EPS = 0.05;
|
|
104
|
+
const s = sharpness + EPS;
|
|
105
|
+
const c = cliff + EPS;
|
|
106
|
+
const f = floor + EPS;
|
|
107
|
+
|
|
108
|
+
// Weighted geometric mean: prod(x_i ^ w_i)
|
|
109
|
+
const logConf =
|
|
110
|
+
SHARPNESS_W * Math.log(s)
|
|
111
|
+
+ CLIFF_W * Math.log(c)
|
|
112
|
+
+ FLOOR_W * Math.log(f);
|
|
113
|
+
const totalW = SHARPNESS_W + CLIFF_W + FLOOR_W;
|
|
114
|
+
// Subtract epsilon contribution so the floor of confidence is ~0 when all
|
|
115
|
+
// signals are zero (rather than the value of EPS).
|
|
116
|
+
const rawConf = Math.exp(logConf / totalW) - EPS;
|
|
117
|
+
const confidence = Math.max(0, Math.min(1, rawConf));
|
|
118
|
+
|
|
119
|
+
return { confidence, sharpness, cliff, floor };
|
|
120
|
+
}
|